serverless-ircd 0.8.0 → 0.10.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 (179) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  4. package/.github/workflows/deploy-cf.yml +14 -14
  5. package/CHANGELOG.md +550 -0
  6. package/README.md +275 -222
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +82 -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 +177 -52
  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 +267 -92
  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/package.json +2 -3
  18. package/apps/cf-tcp-container/src/container-server.ts +33 -10
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  20. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  21. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  22. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  23. package/apps/cf-tcp-container/wrangler.toml +1 -10
  24. package/apps/cf-worker/package.json +3 -4
  25. package/apps/cf-worker/wrangler.toml +12 -71
  26. package/apps/local-cli/package.json +1 -1
  27. package/apps/local-cli/src/server.ts +115 -48
  28. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  29. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  30. package/apps/local-cli/tests/rehash.test.ts +147 -0
  31. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  32. package/apps/local-cli/tests/tcp.test.ts +89 -0
  33. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  34. package/apps/web/landing/favicon.ico +0 -0
  35. package/apps/web/landing/index.html +227 -3
  36. package/apps/web/package.json +3 -2
  37. package/apps/web/scripts/build.mjs +91 -6
  38. package/apps/web/src/build-env.ts +125 -4
  39. package/apps/web/src/config-schema.ts +20 -6
  40. package/apps/web/src/render-docs.ts +292 -0
  41. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  42. package/apps/web/tests/build-env.test.ts +210 -9
  43. package/apps/web/tests/build-smoke.test.ts +33 -4
  44. package/apps/web/tests/config-schema.test.ts +149 -25
  45. package/apps/web/tests/landing-content.test.ts +103 -0
  46. package/apps/web/tests/render-docs.test.ts +198 -0
  47. package/docs/AWS-Adapter-Architecture.md +3 -2
  48. package/docs/AWS-Deployment.md +670 -96
  49. package/docs/AWS-TCP-Deployment.md +20 -45
  50. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  51. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  52. package/docs/Release-Process.md +27 -23
  53. package/docs/Services.md +102 -23
  54. package/docs/WebClientGuide.md +35 -26
  55. package/package.json +7 -10
  56. package/packages/aws-adapter/package.json +1 -1
  57. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  58. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  59. package/packages/aws-adapter/src/config-loader.ts +19 -2
  60. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  63. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  64. package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
  65. package/packages/aws-adapter/src/index.ts +5 -7
  66. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  67. package/packages/aws-adapter/src/serialize.ts +15 -0
  68. package/packages/aws-adapter/src/tables.ts +2 -12
  69. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  70. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  71. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  72. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  73. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  74. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  75. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  76. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  77. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  78. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  79. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  80. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  81. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  82. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  83. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  84. package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
  85. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  86. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  87. package/packages/cf-adapter/package.json +1 -5
  88. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  89. package/packages/cf-adapter/src/channel-do.ts +13 -3
  90. package/packages/cf-adapter/src/connection-do.ts +284 -115
  91. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  92. package/packages/cf-adapter/src/env.ts +11 -10
  93. package/packages/cf-adapter/src/index.ts +0 -6
  94. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  95. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  96. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  97. package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
  98. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  99. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  100. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  101. package/packages/in-memory-runtime/package.json +1 -1
  102. package/packages/irc-core/package.json +1 -1
  103. package/packages/irc-core/src/account-migration.ts +140 -0
  104. package/packages/irc-core/src/commands/account-auth.ts +60 -35
  105. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  106. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  107. package/packages/irc-core/src/commands/index.ts +1 -0
  108. package/packages/irc-core/src/commands/join.ts +41 -35
  109. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  110. package/packages/irc-core/src/commands/nickserv.ts +138 -15
  111. package/packages/irc-core/src/commands/registration.ts +28 -17
  112. package/packages/irc-core/src/commands/sasl.ts +22 -31
  113. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  114. package/packages/irc-core/src/commands/topic.ts +23 -10
  115. package/packages/irc-core/src/config.ts +35 -9
  116. package/packages/irc-core/src/credential-hashing.ts +11 -54
  117. package/packages/irc-core/src/index.ts +1 -0
  118. package/packages/irc-core/src/ports.ts +159 -179
  119. package/packages/irc-core/src/state/channel.ts +17 -0
  120. package/packages/irc-core/src/types.ts +38 -10
  121. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  122. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  123. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  124. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  125. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  126. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  127. package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
  128. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  129. package/packages/irc-core/tests/commands/registration.test.ts +336 -108
  130. package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
  131. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  132. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  133. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  134. package/packages/irc-core/tests/message-store.test.ts +5 -0
  135. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  136. package/packages/irc-core/tests/ports.test.ts +71 -0
  137. package/packages/irc-core/tests/services-store.test.ts +204 -0
  138. package/packages/irc-core/vitest.config.ts +6 -1
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +80 -44
  141. package/packages/irc-server/tests/actor.test.ts +384 -50
  142. package/packages/irc-test-support/package.json +1 -1
  143. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  144. package/packages/irc-test-support/src/scenarios.ts +21 -6
  145. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  146. package/packages/irc-test-support/vitest.config.ts +6 -1
  147. package/pnpm-workspace.yaml +1 -0
  148. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  149. package/scripts/deploy-web-aws.mjs +290 -0
  150. package/scripts/package.json +23 -0
  151. package/scripts/tsconfig.test.json +12 -0
  152. package/scripts/vitest.config.ts +19 -0
  153. package/tools/ci-hardening/package.json +1 -1
  154. package/tools/ci-hardening/src/index.ts +2 -0
  155. package/tools/ci-hardening/src/validate.ts +57 -0
  156. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  157. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  158. package/tools/load-test/package.json +1 -1
  159. package/tools/load-test/src/client.ts +13 -13
  160. package/tools/load-test/tests/client.test.ts +258 -2
  161. package/tools/load-test/tests/config.test.ts +39 -0
  162. package/tools/load-test/tests/harness.test.ts +21 -0
  163. package/tools/load-test/tests/metrics.test.ts +7 -0
  164. package/tools/migrate-accounts-to-services.ts +270 -0
  165. package/tools/package.json +2 -1
  166. package/tools/seed-aws-accounts.ts +35 -10
  167. package/tools/seed-cf-accounts.ts +42 -9
  168. package/tools/tcp-ws-forwarder/package.json +1 -1
  169. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  170. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  171. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  172. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  173. package/packages/aws-adapter/src/account-store.ts +0 -121
  174. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  175. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  176. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  177. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  178. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  179. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -29,7 +29,6 @@
29
29
 
30
30
  import { DurableObject } from 'cloudflare:workers';
31
31
  import {
32
- type AccountStore,
33
32
  type ChanName,
34
33
  type ChannelState,
35
34
  type Clock,
@@ -46,8 +45,8 @@ import {
46
45
  type MotdProvider,
47
46
  type MtlsIdentityProvider,
48
47
  type NickHistoryStore,
48
+ type ParsedServerConfig,
49
49
  type RawLine,
50
- type SaslAccountCredential,
51
50
  type ServerConfig,
52
51
  SystemClock,
53
52
  UuidIdFactory,
@@ -66,7 +65,6 @@ import { ConnectionActor, type Transport } from '@serverless-ircd/irc-server';
66
65
  import { makeCfRuntime } from './cf-runtime.js';
67
66
  import type { CfConnectionHandlers } from './cf-runtime.js';
68
67
  import { loadServerConfigFromCfEnv } from './config-loader.js';
69
- import { resolveAccountStore } from './d1-account-store.js';
70
68
  import { type D1ServicesStore, loadD1ServicesStore } from './d1-services-store.js';
71
69
  import type { Env } from './env.js';
72
70
  import { STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
@@ -100,9 +98,11 @@ export class ConnectionDO extends DurableObject<Env> {
100
98
  * Cached reducer-facing server config. Parsed once from `this.env`
101
99
  * (bindings are stable for the DO's lifetime) so the schema-validated
102
100
  * `loadServerConfigFromCfEnv` runs a single time rather than on every
103
- * WebSocket frame.
101
+ * WebSocket frame. Held as {@link ParsedServerConfig} so the adapter
102
+ * can read extra fields (e.g. `saslAccounts` for the services seed)
103
+ * the reducer-facing `ServerConfig` view omits.
104
104
  */
105
- private cachedServerConfig: ServerConfig | undefined;
105
+ private cachedParsedConfig: ParsedServerConfig | undefined;
106
106
  /**
107
107
  * Per-instance channel-state cache. Reused across every WebSocket frame
108
108
  * the connection handles (within a single hibernation cycle) so that
@@ -142,25 +142,14 @@ export class ConnectionDO extends DurableObject<Env> {
142
142
  * keyed by nick) is a documented follow-up. Lazily constructed.
143
143
  */
144
144
  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
145
  /**
158
146
  * Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
159
147
  * OperServ + read-marker), hydrated once via {@link loadServicesStore}
160
148
  * before the first frame dispatches. The store mutates the in-memory
161
149
  * cache synchronously and enqueues write-behind ops; {@link flushServices}
162
150
  * drains them into D1 after each frame / at teardown. `undefined` when
163
- * D1 is not configured (services unbound → no NickServ/ChanServ routing).
151
+ * D1 is not configured (services unbound → no NickServ/ChanServ routing,
152
+ * no SASL/PASS credential verify).
164
153
  */
165
154
  private services: D1ServicesStore | undefined;
166
155
  /** Guards {@link loadServicesStore} so the D1 scan runs at most once. */
@@ -218,14 +207,7 @@ export class ConnectionDO extends DurableObject<Env> {
218
207
  const pair = new WebSocketPair();
219
208
  const server = pair[0];
220
209
  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
- }
210
+ this.captureMtlsSubject(request);
229
211
  // Capture the client source IP for ConnectionState.host. CF-Connecting-IP
230
212
  // is set by the Cloudflare edge on every proxied request; X-Real-IP is
231
213
  // the conventional fallback for non-CF reverse proxies. Stashed on the
@@ -283,7 +265,6 @@ export class ConnectionDO extends DurableObject<Env> {
283
265
  const state = await this.loadState();
284
266
  const beforeNick = state.nick;
285
267
 
286
- await this.loadAccountStore();
287
268
  await this.loadServicesStore();
288
269
  const actor = this.buildActor(ws, state, mode);
289
270
  try {
@@ -388,17 +369,39 @@ export class ConnectionDO extends DurableObject<Env> {
388
369
  * socket count without sending anything. Used by ChannelDO.sweep.
389
370
  */
390
371
  async deliver(lines: RawLine[]): Promise<{ delivered: number }> {
372
+ return this.deliverToSockets(this.ctx.getWebSockets() as unknown as WebSocket[], lines);
373
+ }
374
+
375
+ /**
376
+ * Iterates `sockets`, delivering `lines` to each OPEN one and counting
377
+ * the writes. Extracted from {@link deliver} so the false arm of the
378
+ * tryDeliver check is testable with a fake non-OPEN socket — the real
379
+ * `ctx.getWebSockets()` only returns OPEN sockets in the test runtime.
380
+ */
381
+ public deliverToSockets(sockets: WebSocket[], lines: RawLine[]): { delivered: number } {
391
382
  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++;
383
+ for (const ws of sockets) {
384
+ delivered += this.tryDeliver(ws, lines) ? 1 : 0;
398
385
  }
399
386
  return { delivered };
400
387
  }
401
388
 
389
+ /**
390
+ * Delivers `lines` to a single attached WebSocket. Returns `false`
391
+ * (without writing) when the socket is no longer OPEN, `true` after a
392
+ * successful write. Public so the readyState guard is testable with a
393
+ * fake socket — the real `ctx.getWebSockets()` only returns OPEN
394
+ * sockets in the test runtime, but production may briefly surface
395
+ * CLOSING sockets between the unilateral-close and the close event
396
+ * reaching the DO.
397
+ */
398
+ public tryDeliver(ws: WebSocket, lines: RawLine[]): boolean {
399
+ if (ws.readyState !== WebSocket.OPEN) return false;
400
+ const mode = this.wsFrameMode(ws);
401
+ this.sendOutbound(ws, lines, mode);
402
+ return true;
403
+ }
404
+
402
405
  // -------------------------------------------------------------------------
403
406
  // Internal helpers
404
407
  // -------------------------------------------------------------------------
@@ -441,6 +444,17 @@ export class ConnectionDO extends DurableObject<Env> {
441
444
  ws.send(`${lines.map((l) => l.text).join('\r\n')}\r\n`);
442
445
  }
443
446
 
447
+ /**
448
+ * Test-only seam that exposes {@link sendOutbound} so the readyState
449
+ * early-return is reachable with a fake non-OPEN socket. Production
450
+ * callers always pass an OPEN socket (the {@link deliver} and
451
+ * {@link tryDeliver} guards filter first), so the path cannot be
452
+ * exercised via real RPC.
453
+ */
454
+ public __sendOutboundForTest(ws: WebSocket, lines: RawLine[], mode: WsFrameMode): void {
455
+ this.sendOutbound(ws, lines, mode);
456
+ }
457
+
444
458
  /** Loads the connection state from storage, caching for the event. */
445
459
  private async loadState(): Promise<ConnectionState> {
446
460
  if (this.cached !== undefined) return this.cached;
@@ -484,10 +498,7 @@ export class ConnectionDO extends DurableObject<Env> {
484
498
  this.sendOutbound(ws, lines, mode);
485
499
  },
486
500
  disconnect: (reason?: string): void => {
487
- if (reason !== undefined) {
488
- this.sendOutbound(ws, [{ text: `ERROR :Closing link: (${reason})` }], mode);
489
- }
490
- ws.close();
501
+ this.writeDisconnect(ws, mode, reason);
491
502
  },
492
503
  snapshot: (): ConnectionState | undefined => this.cached,
493
504
  };
@@ -503,7 +514,6 @@ export class ConnectionDO extends DurableObject<Env> {
503
514
  // Bound `connectionId` so every record the actor emits is filterable
504
515
  // per-connection in the Workers dashboard.
505
516
  const logger: Logger = new ConsoleLogger({ connectionId: state.id }, undefined, LogLevel.Info);
506
- const accounts = this.accountStore();
507
517
  const services = this.servicesStore();
508
518
  const mtlsIdentity: MtlsIdentityProvider | undefined =
509
519
  this.mtlsCertSubject !== undefined
@@ -529,17 +539,48 @@ export class ConnectionDO extends DurableObject<Env> {
529
539
  // Cloudflare terminates TLS at the edge before the WebSocket reaches
530
540
  // the DO, so every WS connection is secure → user mode `S`.
531
541
  secure: true,
532
- ...(accounts !== undefined ? { accounts } : {}),
533
- ...(services !== undefined ? { services } : {}),
534
- ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
542
+ ...buildOptionalActorFields(services, mtlsIdentity),
535
543
  logger,
536
544
  });
537
545
  }
538
546
 
547
+ /**
548
+ * Writes the optional `ERROR :Closing link` line and closes the socket.
549
+ * Extracted from the {@link buildActor} disconnect callback so the
550
+ * reason-gated branch is testable without driving a full actor flow
551
+ * that emits a self-Disconnect with a reason (rare — OperServ GHOST,
552
+ * KILL, flood, bad server-password).
553
+ */
554
+ private writeDisconnect(ws: WebSocket, mode: WsFrameMode, reason?: string): void {
555
+ if (reason !== undefined) {
556
+ this.sendOutbound(ws, [{ text: `ERROR :Closing link: (${reason})` }], mode);
557
+ }
558
+ ws.close();
559
+ }
560
+
561
+ /**
562
+ * Test-only seam that exposes {@link closeIfStillOpen} so the
563
+ * readyState-guarded `ws.close()` is reachable with a fake OPEN
564
+ * socket. The real QUIT flow always closes the WS via the actor's
565
+ * self-Disconnect handler before tearDown reaches this guard.
566
+ */
567
+ public __closeIfStillOpenForTest(ws: WebSocket): void {
568
+ this.closeIfStillOpen(ws);
569
+ }
570
+
571
+ /**
572
+ * Test-only seam that exposes {@link writeDisconnect} so the
573
+ * reason-gated `ERROR :Closing link` write is reachable without
574
+ * driving a full actor flow that emits a self-Disconnect with a
575
+ * reason (rare — OperServ GHOST, KILL, flood, bad server-password).
576
+ */
577
+ public __writeDisconnectForTest(ws: WebSocket, mode: WsFrameMode, reason?: string): void {
578
+ this.writeDisconnect(ws, mode, reason);
579
+ }
580
+
539
581
  /** Tears down a connection: emit QUIT effects, release nick, close WS. */
540
582
  private async tearDown(ws: WebSocket): Promise<void> {
541
583
  const state = await this.loadState();
542
- await this.loadAccountStore();
543
584
  await this.loadServicesStore();
544
585
  const actor = this.buildActor(ws, state, this.wsFrameMode(ws));
545
586
  // Drive QUIT through the actor so the same effect pipeline handles
@@ -547,9 +588,7 @@ export class ConnectionDO extends DurableObject<Env> {
547
588
  await actor.receiveTextFrame('QUIT\r\n');
548
589
  await this.releaseAndPersist(state);
549
590
  await this.flushServices();
550
- if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
551
- ws.close();
552
- }
591
+ this.closeIfStillOpen(ws);
553
592
  try {
554
593
  await this.ctx.storage.deleteAlarm();
555
594
  } catch {
@@ -557,6 +596,21 @@ export class ConnectionDO extends DurableObject<Env> {
557
596
  }
558
597
  }
559
598
 
599
+ /**
600
+ * Closes `ws` when it is still in a state where calling `close()` is
601
+ * meaningful (OPEN or CONNECTING). The QUIT flow drives a self-Disconnect
602
+ * that already closes the socket, so by the time {@link tearDown} reaches
603
+ * this guard the WS is usually CLOSING/CLOSED; the helper is a defensive
604
+ * backstop for the rare path where the actor does not emit a self-
605
+ * Disconnect. Extracted as a public seam so both arms are testable with
606
+ * a fake socket — the real QUIT flow always hits the false arm.
607
+ */
608
+ private closeIfStillOpen(ws: WebSocket): void {
609
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
610
+ ws.close();
611
+ }
612
+ }
613
+
560
614
  /** Releases the nick via the registry and persists the (possibly mutated) state. */
561
615
  private async releaseAndPersist(state: ConnectionState): Promise<void> {
562
616
  if (state.nick !== undefined) {
@@ -577,19 +631,24 @@ export class ConnectionDO extends DurableObject<Env> {
577
631
  // silently dropped fields like operCreds, which left OPER unable to
578
632
  // authenticate (491 ERR_NOOPERHOST) even when OPER_USER/OPER_PASSWORD
579
633
  // were bound. See config-loader.ts and config.ts.
580
- if (this.cachedServerConfig === undefined) {
581
- this.cachedServerConfig = loadServerConfigFromCfEnv(this.env);
634
+ return this.parsedConfig();
635
+ }
636
+
637
+ /**
638
+ * Returns the full parsed config (the schema-validated superset of
639
+ * `ServerConfig`). The adapter reads fields the reducer-facing
640
+ * `ServerConfig` omits — `saslAccounts` (services seed), `motdLines`
641
+ * (MOTD provider), `maxClients` (admission), …
642
+ */
643
+ private parsedConfig(): ParsedServerConfig {
644
+ if (this.cachedParsedConfig === undefined) {
645
+ this.cachedParsedConfig = loadServerConfigFromCfEnv(this.env);
582
646
  }
583
- return this.cachedServerConfig;
647
+ return this.cachedParsedConfig;
584
648
  }
585
649
 
586
650
  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') };
651
+ return chooseMotdProvider(this.env.MOTD_LINES);
593
652
  }
594
653
 
595
654
  /**
@@ -617,41 +676,32 @@ export class ConnectionDO extends DurableObject<Env> {
617
676
  return this.history;
618
677
  }
619
678
 
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
679
  /**
644
680
  * Resolves the persistent services store once (D1 → write-behind cache),
645
681
  * caching the result. Safe to call on every frame; the D1 scan runs at
646
682
  * most once per ConnectionDO instance. Must complete before the first
647
683
  * reducer dispatches so the synchronous `ServicesStore` port has its
648
684
  * snapshot ready. Leaves `services` `undefined` when D1 is absent so the
649
- * actor's `ctx.services` stays unset (no NickServ/ChanServ routing).
685
+ * actor's `ctx.services` stays unset (no NickServ/ChanServ routing,
686
+ * no SASL/PASS credential verify).
687
+ *
688
+ * After the D1 load, the `SASL_ACCOUNTS` env-var seed is ingested:
689
+ * each `username:password` pair is registered via `registerNick` when
690
+ * the nick is not already present. Idempotent — re-ingesting on every
691
+ * cold start is a no-op for nicks the services table already holds
692
+ * (including NickServ-registered nicks whose password wins). New
693
+ * env-seed accounts are persisted by the write-behind flush.
650
694
  */
651
695
  private async loadServicesStore(): Promise<void> {
652
696
  if (this.servicesResolved) return;
653
697
  this.servicesResolved = true;
654
698
  this.services = await loadD1ServicesStore(this.env.ACCOUNTS_DB, this.clock);
699
+ if (this.services !== undefined) {
700
+ const cfg = this.parsedConfig();
701
+ for (const acct of cfg.saslAccounts) {
702
+ this.services.registerNick(acct.username, acct.password, '');
703
+ }
704
+ }
655
705
  }
656
706
 
657
707
  /**
@@ -674,6 +724,18 @@ export class ConnectionDO extends DurableObject<Env> {
674
724
  await this.services.flush();
675
725
  }
676
726
 
727
+ /**
728
+ * Test-only seam that exposes {@link flushServices} so the
729
+ * "services unbound" early-return is reachable. Production always
730
+ * drives flushServices after `loadServicesStore()` has run (in
731
+ * `webSocketMessage` and `tearDown*`), so the field is populated;
732
+ * the undefined arm fires only on a DO that has never processed a
733
+ * frame.
734
+ */
735
+ public async __flushServicesForTest(): Promise<void> {
736
+ await this.flushServices();
737
+ }
738
+
677
739
  // -------------------------------------------------------------------------
678
740
  // Test hooks (only invoked from `runInDurableObject` in tests)
679
741
  // -------------------------------------------------------------------------
@@ -699,13 +761,12 @@ export class ConnectionDO extends DurableObject<Env> {
699
761
  private async tearDownNoSocket(state: ConnectionState): Promise<void> {
700
762
  // Emit QUIT effects without dispatching transport calls (the socket
701
763
  // is already gone). The cross-DO fanout still runs.
702
- await this.loadAccountStore();
703
764
  await this.loadServicesStore();
704
765
  const services = this.servicesStore();
705
766
  const noOpHandlers: CfConnectionHandlers = {
706
- send: (): void => {},
707
- disconnect: (): void => {},
708
- snapshot: (): ConnectionState | undefined => this.cached,
767
+ send: NO_OP_SEND,
768
+ disconnect: NO_OP_DISCONNECT,
769
+ snapshot: this.peekCachedSync.bind(this),
709
770
  };
710
771
  const registryKey = this.ctx.id.toString();
711
772
  const runtime = makeCfRuntime(this.env, state.id, registryKey, noOpHandlers);
@@ -723,7 +784,7 @@ export class ConnectionDO extends DurableObject<Env> {
723
784
  motd: this.motdProvider(),
724
785
  messages: this.messageStore(),
725
786
  history: this.historyStore(),
726
- ...(services !== undefined ? { services } : {}),
787
+ ...buildOptionalActorFields(services, undefined),
727
788
  });
728
789
  await actor.receiveTextFrame('QUIT\r\n');
729
790
  await this.releaseAndPersist(state);
@@ -735,6 +796,11 @@ export class ConnectionDO extends DurableObject<Env> {
735
796
  }
736
797
  }
737
798
 
799
+ /** Synchronous cached-state peek used by the no-socket teardown handlers. */
800
+ private peekCachedSync(): ConnectionState | undefined {
801
+ return this.cached;
802
+ }
803
+
738
804
  /** Drops the in-memory state cache, simulating wake-from-hibernation. */
739
805
  public __resetCache(): void {
740
806
  this.cached = undefined;
@@ -742,7 +808,7 @@ export class ConnectionDO extends DurableObject<Env> {
742
808
 
743
809
  /** Returns the live cached state (without forcing a storage read). */
744
810
  public async __peekState(): Promise<ConnectionState | undefined> {
745
- return this.cached;
811
+ return this.peekCachedSync();
746
812
  }
747
813
 
748
814
  /** Backdates `lastSeen` to test the alarm-driven disconnect path. */
@@ -761,6 +827,58 @@ export class ConnectionDO extends DurableObject<Env> {
761
827
  this.__suppressCloseTeardown = true;
762
828
  }
763
829
 
830
+ /**
831
+ * Test-only: sets the captured mTLS cert subject, simulating an upgrade
832
+ * request that arrived through CF API Shield mTLS. Used to exercise the
833
+ * `mtlsIdentity` construction branch in {@link buildActor} without
834
+ * having to inject `request.cf` (which is read-only in the workerd
835
+ * test runtime).
836
+ */
837
+ /**
838
+ * Captures the verified mTLS cert subject off an inbound request and
839
+ * stores it on the instance. Extracted so the assignment branch is
840
+ * testable via {@link __captureMtlsSubjectForTest} (the workerd test
841
+ * runtime strips `request.cf` across the DO RPC boundary, so the
842
+ * inline fetch path cannot be exercised end-to-end).
843
+ */
844
+ private captureMtlsSubject(request: Request): void {
845
+ const cf = extractMtlsSubject(request);
846
+ if (cf !== undefined) {
847
+ this.mtlsCertSubject = cf;
848
+ }
849
+ }
850
+
851
+ public __setMtlsCertSubject(subject: string | undefined): void {
852
+ this.mtlsCertSubject = subject;
853
+ }
854
+
855
+ /** Test-only: returns the captured mTLS cert subject (or undefined). */
856
+ public async __peekMtlsCertSubject(): Promise<string | undefined> {
857
+ return this.mtlsCertSubject;
858
+ }
859
+
860
+ /**
861
+ * Test-only seam that exposes {@link captureMtlsSubject} so the
862
+ * if-defined assignment can be exercised with a crafted request.cf.
863
+ */
864
+ public __captureMtlsSubjectForTest(request: Request): void {
865
+ this.captureMtlsSubject(request);
866
+ }
867
+
868
+ /**
869
+ * Test-only: invokes {@link webSocketError} against the first attached
870
+ * WebSocket. The platform normally fires this asynchronously on
871
+ * transport errors; calling it here deterministically exercises the
872
+ * handler body. The WebSocket must have been accepted via `fetch` so
873
+ * `ctx.getWebSockets()` returns it.
874
+ */
875
+ public async __triggerWsError(error: unknown): Promise<void> {
876
+ const sockets = this.ctx.getWebSockets();
877
+ const ws = sockets[0];
878
+ if (ws === undefined) return;
879
+ await this.webSocketError(ws as WebSocket, error);
880
+ }
881
+
764
882
  /** Returns this DO's hex id — the canonical ConnId used in RPC routing. */
765
883
  public __peekHexId(): string {
766
884
  return this.ctx.id.toString();
@@ -813,6 +931,70 @@ export class ConnectionDO extends DurableObject<Env> {
813
931
  /** Default MOTD when no `MOTD_LINES` env is supplied. */
814
932
  const DEFAULT_MOTD: string[] = ['Welcome to the ServerlessIRCd Cloudflare adapter.'];
815
933
 
934
+ /**
935
+ * Reads the verified mTLS client-cert subject off an inbound Request, when
936
+ * CF API Shield mTLS is configured. Extracted as a pure helper so the
937
+ * `cf.tlsClientAuthCertSubject` branch is unit-testable directly (the
938
+ * workerd test runtime does not surface `request.cf` the way the CF edge
939
+ * does, and `Request.cf` is read-only so tests cannot inject it via the
940
+ * standard constructor). Returns the subject string when present, or
941
+ * `undefined` when mTLS is not configured.
942
+ */
943
+ export function extractMtlsSubject(request: Request): string | undefined {
944
+ const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
945
+ if (cf !== undefined && typeof cf.tlsClientAuthCertSubject === 'string') {
946
+ return cf.tlsClientAuthCertSubject;
947
+ }
948
+ return undefined;
949
+ }
950
+
951
+ /**
952
+ * No-op transport callbacks used by {@link ConnectionDO.tearDownNoSocket}
953
+ * when the WebSocket has already been dropped. Exported so the function
954
+ * bodies are exercised by a direct unit test (the actor never invokes
955
+ * `send`/`disconnect` during the no-socket QUIT path because there is
956
+ * no peer to deliver to).
957
+ */
958
+ export const NO_OP_SEND: (lines: RawLine[]) => void = () => {};
959
+ export const NO_OP_DISCONNECT: (reason?: string) => void = () => {};
960
+
961
+ /**
962
+ * Builds the optional `services` / `mtlsIdentity` fields for a
963
+ * {@link ConnectionActor} constructor. Each field is included only when
964
+ * the source resolved a value; the `: {}` arms of the conditional spreads
965
+ * are exercised by both the with-everything and with-nothing test paths.
966
+ *
967
+ * Extracted as a pure helper so the conditional-spread branches are
968
+ * testable without driving a full DO event cycle that leaves the
969
+ * underlying stores unset (the test env binds D1 + services, so production
970
+ * builds always hit the defined arm for `services`).
971
+ */
972
+ export function buildOptionalActorFields(
973
+ services: D1ServicesStore | undefined,
974
+ mtlsIdentity: MtlsIdentityProvider | undefined,
975
+ ): {
976
+ services?: D1ServicesStore;
977
+ mtlsIdentity?: MtlsIdentityProvider;
978
+ } {
979
+ return {
980
+ ...(services !== undefined ? { services } : {}),
981
+ ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
982
+ };
983
+ }
984
+
985
+ /**
986
+ * Resolves the {@link MotdProvider} for a raw `MOTD_LINES` env value.
987
+ * Returns the default single-line MOTD when the env is unset or empty;
988
+ * otherwise splits the env on newlines. Extracted so both branches are
989
+ * unit-testable without changing the bound env in the test worker.
990
+ */
991
+ export function chooseMotdProvider(raw: string | undefined): MotdProvider {
992
+ if (raw === undefined || raw.length === 0) {
993
+ return { lines: () => DEFAULT_MOTD };
994
+ }
995
+ return { lines: () => raw.split('\n') };
996
+ }
997
+
816
998
  /**
817
999
  * `ActorChannelAccess` backed by a per-instance cache that is refreshed
818
1000
  * from the authoritative ChannelDO before each reducer that needs to
@@ -827,7 +1009,7 @@ const DEFAULT_MOTD: string[] = ['Welcome to the ServerlessIRCd Cloudflare adapte
827
1009
  * synchronous reducers can mutate `members` (a Map) and `banMasks` /
828
1010
  * `pendingInvites` (Sets) in place.
829
1011
  */
830
- class PassthroughChannelAccess {
1012
+ export class PassthroughChannelAccess {
831
1013
  private readonly cache = new Map<string, ChannelState>();
832
1014
  constructor(
833
1015
  private readonly now: number,
@@ -879,11 +1061,23 @@ class PassthroughChannelAccess {
879
1061
  createdAt: number;
880
1062
  } | null;
881
1063
  if (dto === null) return;
882
- // Don't replace the cache with an empty snapshot the channel
883
- // effectively doesn't exist yet, and the local reducer should lazily
884
- // seed it via getOrCreateChannel. This also avoids clobbering the
885
- // local cache with ChannelDO's "name unknown" default state.
886
- if (dto.members.length === 0) return;
1064
+ // When the authoritative ChannelDO reports zero members (the
1065
+ // post-deploy state), don't replace the cache with an empty roster
1066
+ // the local reducer should lazily seed memberships via
1067
+ // getOrCreateChannel. But DO propagate the persisted topic / modes /
1068
+ // ban list onto an existing cache entry so the JOIN reducer sees the
1069
+ // survived topic and emits 332/333. Mirrors the AWS adapter's
1070
+ // LambdaChannelAccess.refreshChannel empty-members branch.
1071
+ if (dto.members.length === 0) {
1072
+ const existing = this.cache.get(key);
1073
+ if (existing !== undefined) {
1074
+ existing.modes = { ...dto.modes };
1075
+ existing.banMasks = new Set(dto.banMasks);
1076
+ existing.pendingInvites = new Set(dto.pendingInvites);
1077
+ if (dto.topic !== undefined) existing.topic = dto.topic;
1078
+ }
1079
+ return;
1080
+ }
887
1081
  const members = new Map<ConnId, { conn: ConnId; nick: string; op: boolean; voice: boolean }>();
888
1082
  for (const entry of dto.members) {
889
1083
  members.set(entry.conn, {
@@ -907,31 +1101,6 @@ class PassthroughChannelAccess {
907
1101
  }
908
1102
  }
909
1103
 
910
- /**
911
- * Parses the `SASL_ACCOUNTS` env var into credential entries.
912
- *
913
- * Format: newline-delimited `username:password` pairs (mirroring the
914
- * `MOTD_LINES` convention). Blank lines and malformed entries (missing
915
- * colon or empty username/password) are skipped so a trailing newline or a
916
- * partial edit does not break boot. Exported so the parsing boundary is
917
- * unit-testable in isolation.
918
- */
919
- export function parseSaslAccountsEnv(raw: string | undefined): SaslAccountCredential[] {
920
- if (raw === undefined || raw.length === 0) return [];
921
- const out: SaslAccountCredential[] = [];
922
- for (const line of raw.split('\n')) {
923
- const trimmed = line.trim();
924
- if (trimmed.length === 0) continue;
925
- const sep = trimmed.indexOf(':');
926
- if (sep <= 0) continue; // missing colon or empty username
927
- const username = trimmed.slice(0, sep);
928
- const password = trimmed.slice(sep + 1);
929
- if (username.length === 0 || password.length === 0) continue;
930
- out.push({ username, password });
931
- }
932
- return out;
933
- }
934
-
935
1104
  /**
936
1105
  * Hibernation-tag prefixes written by {@link ConnectionDO.fetch}. Each tag
937
1106
  * encodes the negotiated {@link WsFrameMode} so it survives DO eviction and