serverless-ircd 0.2.0 → 0.4.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 (221) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +258 -18
  8. package/README.md +72 -30
  9. package/apps/aws-stack/README.md +2 -2
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  34. package/apps/cf-worker/src/worker.ts +8 -2
  35. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  36. package/apps/cf-worker/wrangler.test.toml +11 -1
  37. package/apps/cf-worker/wrangler.toml +69 -19
  38. package/apps/local-cli/package.json +1 -1
  39. package/apps/local-cli/src/config-loader.ts +11 -0
  40. package/apps/local-cli/src/main.ts +1 -1
  41. package/apps/local-cli/src/server.ts +46 -3
  42. package/apps/local-cli/tests/e2e.test.ts +52 -0
  43. package/package.json +19 -16
  44. package/packages/aws-adapter/package.json +3 -3
  45. package/packages/aws-adapter/src/account-store.ts +121 -0
  46. package/packages/aws-adapter/src/admission.ts +74 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +124 -34
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  49. package/packages/aws-adapter/src/config-loader.ts +62 -0
  50. package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  52. package/packages/aws-adapter/src/handlers/default.ts +43 -2
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  54. package/packages/aws-adapter/src/handlers/index.ts +120 -9
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  56. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  57. package/packages/aws-adapter/src/index.ts +13 -0
  58. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
  59. package/packages/aws-adapter/tests/account-store.test.ts +279 -0
  60. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  61. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  62. package/packages/aws-adapter/tests/config-loader.test.ts +75 -0
  63. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
  65. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  66. package/packages/aws-adapter/tests/handlers.test.ts +194 -47
  67. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  68. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +1 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +40 -22
  73. package/packages/cf-adapter/src/channel-do.ts +40 -1
  74. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  75. package/packages/cf-adapter/src/config-loader.ts +62 -0
  76. package/packages/cf-adapter/src/connection-do.ts +181 -31
  77. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  78. package/packages/cf-adapter/src/env.ts +58 -8
  79. package/packages/cf-adapter/src/index.ts +11 -9
  80. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  81. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  82. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  83. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  84. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  85. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  86. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  87. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  88. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  89. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +9 -1
  91. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  92. package/packages/cf-adapter/wrangler.test.toml +18 -1
  93. package/packages/in-memory-runtime/package.json +1 -1
  94. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  95. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  96. package/packages/irc-core/package.json +8 -2
  97. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  98. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  99. package/packages/irc-core/src/case-fold.ts +64 -0
  100. package/packages/irc-core/src/cloak.ts +1 -1
  101. package/packages/irc-core/src/commands/cap.ts +8 -1
  102. package/packages/irc-core/src/commands/index.ts +11 -0
  103. package/packages/irc-core/src/commands/invite.ts +6 -9
  104. package/packages/irc-core/src/commands/ison.ts +61 -0
  105. package/packages/irc-core/src/commands/isupport.ts +1 -1
  106. package/packages/irc-core/src/commands/join.ts +4 -3
  107. package/packages/irc-core/src/commands/kick.ts +4 -11
  108. package/packages/irc-core/src/commands/list.ts +5 -4
  109. package/packages/irc-core/src/commands/mode.ts +5 -9
  110. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  111. package/packages/irc-core/src/commands/motd.ts +6 -110
  112. package/packages/irc-core/src/commands/oper.ts +151 -0
  113. package/packages/irc-core/src/commands/quit.ts +12 -0
  114. package/packages/irc-core/src/commands/registration.ts +26 -19
  115. package/packages/irc-core/src/commands/sasl.ts +72 -9
  116. package/packages/irc-core/src/commands/server-info.ts +129 -0
  117. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  118. package/packages/irc-core/src/commands/userhost.ts +84 -0
  119. package/packages/irc-core/src/commands/whowas.ts +113 -0
  120. package/packages/irc-core/src/config.ts +84 -3
  121. package/packages/irc-core/src/credential-hashing.ts +124 -0
  122. package/packages/irc-core/src/effects.ts +6 -29
  123. package/packages/irc-core/src/index.ts +3 -0
  124. package/packages/irc-core/src/ports.ts +240 -7
  125. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  126. package/packages/irc-core/src/types.ts +60 -1
  127. package/packages/irc-core/tests/account-store.test.ts +131 -0
  128. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  129. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  130. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  131. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  132. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  133. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  134. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  135. package/packages/irc-core/tests/commands/registration.test.ts +256 -19
  136. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  137. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  138. package/packages/irc-core/tests/commands/tagmsg.test.ts +662 -0
  139. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  140. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  141. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  142. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  143. package/packages/irc-core/tests/config.test.ts +170 -1
  144. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  145. package/packages/irc-core/tests/effects.test.ts +0 -27
  146. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  147. package/packages/irc-core/tests/numerics.test.ts +12 -0
  148. package/packages/irc-core/tests/types.test.ts +35 -1
  149. package/packages/irc-core/tsconfig.build.json +1 -1
  150. package/packages/irc-core/tsconfig.test.json +1 -1
  151. package/packages/irc-server/package.json +1 -1
  152. package/packages/irc-server/src/actor.ts +182 -14
  153. package/packages/irc-server/src/dispatch.ts +0 -3
  154. package/packages/irc-server/src/index.ts +10 -2
  155. package/packages/irc-server/src/routing.ts +21 -0
  156. package/packages/irc-server/src/transport.ts +101 -0
  157. package/packages/irc-server/tests/actor.test.ts +617 -1
  158. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  159. package/packages/irc-server/tests/routing.test.ts +7 -0
  160. package/packages/irc-server/tests/transport.test.ts +230 -0
  161. package/packages/irc-test-support/package.json +1 -1
  162. package/packages/irc-test-support/src/harness.ts +44 -9
  163. package/packages/irc-test-support/src/in-memory-harness.ts +78 -13
  164. package/packages/irc-test-support/src/index.ts +3 -0
  165. package/packages/irc-test-support/src/scenarios.ts +132 -2
  166. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  167. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  168. package/pnpm-workspace.yaml +9 -0
  169. package/tools/ci-hardening/package.json +1 -1
  170. package/tools/package.json +4 -0
  171. package/tools/seed-aws-accounts.ts +79 -0
  172. package/tools/seed-cf-accounts.ts +104 -0
  173. package/tools/tcp-ws-forwarder/package.json +1 -1
  174. package/AGENTS.md +0 -5
  175. package/MOTD.txt +0 -3
  176. package/PLAN-FIXES.md +0 -358
  177. package/PLAN.md +0 -420
  178. package/dashboards/cloudwatch-irc.json +0 -139
  179. package/docs/AWS-Adapter-Architecture.md +0 -494
  180. package/docs/AWS-Deployment.md +0 -1107
  181. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  182. package/docs/Home.md +0 -1
  183. package/docs/Observability.md +0 -87
  184. package/docs/PlanIRCv3Websocket.md +0 -489
  185. package/docs/PlanWebClient.md +0 -451
  186. package/docs/Release-Process.md +0 -440
  187. package/progress.md +0 -107
  188. package/tickets.md +0 -2485
  189. package/webircgateway/LICENSE +0 -201
  190. package/webircgateway/Makefile +0 -44
  191. package/webircgateway/README.md +0 -134
  192. package/webircgateway/config.conf.example +0 -135
  193. package/webircgateway/go.mod +0 -16
  194. package/webircgateway/go.sum +0 -89
  195. package/webircgateway/main.go +0 -118
  196. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  197. package/webircgateway/pkg/identd/identd.go +0 -86
  198. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  199. package/webircgateway/pkg/irc/isupport.go +0 -56
  200. package/webircgateway/pkg/irc/message.go +0 -217
  201. package/webircgateway/pkg/irc/state.go +0 -79
  202. package/webircgateway/pkg/proxy/proxy.go +0 -129
  203. package/webircgateway/pkg/proxy/server.go +0 -237
  204. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  205. package/webircgateway/pkg/webircgateway/client.go +0 -741
  206. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  207. package/webircgateway/pkg/webircgateway/config.go +0 -385
  208. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  209. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  210. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  211. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  212. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  213. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  214. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  215. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  216. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  217. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  218. package/webircgateway/plugins/example/plugin.go +0 -11
  219. package/webircgateway/plugins/stats/plugin.go +0 -52
  220. package/webircgateway/staticcheck.conf +0 -1
  221. package/webircgateway/webircgateway.svg +0 -3
@@ -6,8 +6,8 @@
6
6
  * `HibernatingWebSocketHandler` so idle WebSocket connections cost
7
7
  * nothing between messages. When a message arrives the DO wakes, replays
8
8
  * the persisted {@link ConnectionState} from `state.storage`, runs the
9
- * frame through {@link ConnectionActor} with a stub {@link CfRuntime},
10
- * and persists the updated state back.
9
+ * frame through {@link ConnectionActor} with the {@link CfRuntime}, and
10
+ * persists the updated state back.
11
11
  *
12
12
  * Lifecycle:
13
13
  * - `fetch()` WebSocket upgrade; `acceptWebSocket`.
@@ -16,19 +16,21 @@
16
16
  * - `alarm()` PING sweep; missing PONG disconnects.
17
17
  *
18
18
  * Cross-DO coordination:
19
- * - `REGISTRY_DO` (RPC stub today, real in 034).
20
- * - `CHANNEL_DO` per lowercased channel (stub today, real in 035).
19
+ * - `REGISTRY_DO` sharded nick registry (034).
20
+ * - `CHANNEL_DO` per lowercased channel authoritative roster/modes
21
+ * (035).
21
22
  *
22
23
  * The actor's `ActorChannelAccess` is a passthrough that returns
23
24
  * throwaway {@link ChannelState} objects — reducers mutate the local
24
25
  * view and emit `ApplyChannelDelta` effects that the CfRuntime forwards
25
- * to the authoritative ChannelDO. Sufficient for connection-authority
26
- * commands and JOIN/QUIT fan-out; richer channel reads (NAMES of remote
27
- * state) ship in 036.
26
+ * to the authoritative ChannelDO. Connection-authority commands and
27
+ * JOIN/QUIT fan-out are fully wired; richer channel reads (NAMES of
28
+ * remote state) are served via the ChannelDO snapshot RPC.
28
29
  */
29
30
 
30
31
  import { DurableObject } from 'cloudflare:workers';
31
32
  import {
33
+ type AccountStore,
32
34
  type ChanName,
33
35
  type ChannelState,
34
36
  type Clock,
@@ -37,15 +39,19 @@ import {
37
39
  ConsoleLogger,
38
40
  type IdFactory,
39
41
  InMemoryMessageStore,
42
+ InMemoryMtlsIdentityProvider,
43
+ InMemoryNickHistoryStore,
40
44
  LogLevel,
41
45
  type Logger,
42
46
  type MessageStore,
43
47
  type MotdProvider,
48
+ type MtlsIdentityProvider,
49
+ type NickHistoryStore,
44
50
  type RawLine,
51
+ type SaslAccountCredential,
45
52
  type ServerConfig,
46
53
  SystemClock,
47
54
  UuidIdFactory,
48
- applyChannelDelta,
49
55
  createChannel,
50
56
  createConnection,
51
57
  toSnapshot,
@@ -53,8 +59,10 @@ import {
53
59
  import { ConnectionActor } from '@serverless-ircd/irc-server';
54
60
  import { makeCfRuntime } from './cf-runtime.js';
55
61
  import type { CfConnectionHandlers } from './cf-runtime.js';
62
+ import { resolveAccountStore } from './d1-account-store.js';
56
63
  import type { Env } from './env.js';
57
- import { PERSISTED_STATE_VERSION, STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
64
+ import { STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
65
+ import type { PersistedConnectionState } from './serialize.js';
58
66
 
59
67
  /** Default PING cadence (ms) — tuned for typical IRC client behavior. */
60
68
  export const DEFAULT_PING_INTERVAL_MS = 60_000;
@@ -104,12 +112,53 @@ export class ConnectionDO extends DurableObject<Env> {
104
112
  * follow-up. Lazily constructed so the first frame seeds it.
105
113
  */
106
114
  private messages: MessageStore | undefined;
115
+ /**
116
+ * Per-connection nick-history store backing `WHOWAS`. The minimum-viable
117
+ * in-worker ring buffer is scoped to this connection: sign-offs this
118
+ * connection records are queryable by the same connection. A
119
+ * Durable-Object-backed persistent variant (shared across connections,
120
+ * keyed by nick) is a documented follow-up. Lazily constructed.
121
+ */
122
+ private history: NickHistoryStore | undefined;
123
+ /**
124
+ * SASL account store, resolved once via {@link loadAccountStore} before the
125
+ * first frame dispatches. The two-phase load mirrors AWS's
126
+ * `loadDynamoAccountStore`: D1 `accounts` table (authoritative when it has
127
+ * rows) is queried at boot; when empty/unreachable the `SASL_ACCOUNTS`
128
+ * env-var seed provides the fallback `InMemoryAccountStore`. The
129
+ * `AccountStore.verify` port is synchronous, so pre-loading MUST complete
130
+ * before the first reducer runs.
131
+ */
132
+ private accounts: AccountStore | undefined;
133
+ /** Guards {@link loadAccountStore} so the D1 query runs at most once. */
134
+ private accountsResolved = false;
135
+ /**
136
+ * Verified client-cert subject captured at WebSocket upgrade time when
137
+ * CF API Shield mTLS is configured. Surfaced to the SASL EXTERNAL reducer
138
+ * via an {@link MtlsIdentityProvider} so `AUTHENTICATE EXTERNAL` can map
139
+ * the cert subject to an account.
140
+ *
141
+ * CF API Shield mTLS setup:
142
+ * 1. Upload the client CA to API Shield (dashboard → Security → API Shield).
143
+ * 2. Create an mTLS policy on the custom hostname that requires client certs.
144
+ * 3. The verified subject is injected into `request.cf.tlsClientAuthCertSubject`
145
+ * on every request, which the Worker reads at upgrade time.
146
+ * When unset (no mTLS configured), EXTERNAL auth fails with `904`.
147
+ */
148
+ private mtlsCertSubject: string | undefined;
107
149
  private readonly clock: Clock = SystemClock;
108
150
  private readonly ids: IdFactory = new UuidIdFactory();
109
151
  private readonly pingIntervalMs: number = DEFAULT_PING_INTERVAL_MS;
110
152
  private readonly pongTimeoutMs: number = DEFAULT_PONG_TIMEOUT_MS;
111
- /** Outstanding outbound lines, drained when the WS sends complete. */
112
- // (Reserved for a future batching optimization; kept off until 036.)
153
+ // Outbound lines are batched per call, not via a reserved instance
154
+ // field: `deliver()` joins every line of a ChannelDO broadcast into a
155
+ // single `WebSocket.send()`, and the actor `send` handler in
156
+ // `buildActor` does the same for one frame's response. Cross-call
157
+ // per-microtask coalescing (the optimization previously reserved here)
158
+ // was dropped — it would add a drain queue and ordering hazards between
159
+ // independent fan-out calls for no measured benefit (see ADR-003 and
160
+ // the CF adapter load-test report). Revisit only if WS round-trips
161
+ // become a demonstrated bottleneck.
113
162
 
114
163
  // -------------------------------------------------------------------------
115
164
  // WebSocket upgrade
@@ -123,6 +172,14 @@ export class ConnectionDO extends DurableObject<Env> {
123
172
  const pair = new WebSocketPair();
124
173
  const server = pair[0];
125
174
  const client = pair[1];
175
+ // Capture the verified client-cert subject when CF API Shield mTLS is
176
+ // active. `request.cf` is a Cloudflare-specific extension; the property
177
+ // is present only when a client certificate was verified against the
178
+ // uploaded CA pool.
179
+ const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
180
+ if (cf !== undefined && typeof cf.tlsClientAuthCertSubject === 'string') {
181
+ this.mtlsCertSubject = cf.tlsClientAuthCertSubject;
182
+ }
126
183
  // Hibernation: `acceptWebSocket` accepts the server side implicitly.
127
184
  // Calling `server.accept()` first throws "already accepted".
128
185
  this.ctx.acceptWebSocket(server);
@@ -139,9 +196,9 @@ export class ConnectionDO extends DurableObject<Env> {
139
196
  override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
140
197
  const text = typeof message === 'string' ? message : new TextDecoder().decode(message);
141
198
  const state = await this.loadState();
142
- const before = new Set(state.joinedChannels);
143
199
  const beforeNick = state.nick;
144
200
 
201
+ await this.loadAccountStore();
145
202
  const actor = this.buildActor(ws, state);
146
203
  try {
147
204
  await actor.receiveTextFrame(text);
@@ -154,17 +211,11 @@ export class ConnectionDO extends DurableObject<Env> {
154
211
  if (beforeNick === undefined && state.nick !== undefined) {
155
212
  await this.ctx.storage.setAlarm(Date.now() + this.pingIntervalMs);
156
213
  }
157
- // Detect newly joined channels so we can register them with the
158
- // channel DO necessary for the close handler to fan out QUIT to
159
- // them. The stub channel DO records the call; the real ChannelDO
160
- // (035) maintains authoritative roster state.
161
- for (const chan of state.joinedChannels) {
162
- if (!before.has(chan)) {
163
- // joinReducer already emitted an ApplyChannelDelta; the stub
164
- // ChannelDO records it. No extra work needed here for 033.
165
- void chan;
166
- }
167
- }
214
+ // No per-ConnectionDO channel-registration step is needed here: channel
215
+ // membership is driven solely by the `ApplyChannelDelta` effect the
216
+ // joinReducer emits, which the CfRuntime forwards to the authoritative
217
+ // ChannelDO. The ChannelDO roster IS the fan-out registration target, so
218
+ // a second loop over newly joined channels here would be redundant.
168
219
  }
169
220
 
170
221
  /** QUIT fanout + nick release. RFC 1459: a missing close is a QUIT. */
@@ -299,9 +350,9 @@ export class ConnectionDO extends DurableObject<Env> {
299
350
  },
300
351
  snapshot: (): ConnectionState | undefined => this.cached,
301
352
  };
302
- // For 033 stub routing we look up the per-connection registry
303
- // stub by the DO instance's hex id. Production (034) shards the
304
- // registry by nick instead, at which point `registryKey` is unused.
353
+ // The CfRuntime shards the registry by nick (034); the per-connection
354
+ // key passed here is ignored by `makeCfRuntime` (kept in the signature
355
+ // for call-site stability).
305
356
  const registryKey = this.ctx.id.toString();
306
357
  const runtime = makeCfRuntime(this.env, state.id, registryKey, handlers);
307
358
  if (this.channelAccess === undefined) {
@@ -311,6 +362,11 @@ export class ConnectionDO extends DurableObject<Env> {
311
362
  // Bound `connectionId` so every record the actor emits is filterable
312
363
  // per-connection in the Workers dashboard.
313
364
  const logger: Logger = new ConsoleLogger({ connectionId: state.id }, undefined, LogLevel.Info);
365
+ const accounts = this.accountStore();
366
+ const mtlsIdentity: MtlsIdentityProvider | undefined =
367
+ this.mtlsCertSubject !== undefined
368
+ ? new InMemoryMtlsIdentityProvider([{ connId: state.id, identity: this.mtlsCertSubject }])
369
+ : undefined;
314
370
  return new ConnectionActor({
315
371
  state,
316
372
  runtime,
@@ -320,6 +376,9 @@ export class ConnectionDO extends DurableObject<Env> {
320
376
  ids: this.ids,
321
377
  motd,
322
378
  messages: this.messageStore(),
379
+ history: this.historyStore(),
380
+ ...(accounts !== undefined ? { accounts } : {}),
381
+ ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
323
382
  logger,
324
383
  });
325
384
  }
@@ -327,6 +386,7 @@ export class ConnectionDO extends DurableObject<Env> {
327
386
  /** Tears down a connection: emit QUIT effects, release nick, close WS. */
328
387
  private async tearDown(ws: WebSocket): Promise<void> {
329
388
  const state = await this.loadState();
389
+ await this.loadAccountStore();
330
390
  const actor = this.buildActor(ws, state);
331
391
  // Drive QUIT through the actor so the same effect pipeline handles
332
392
  // fanout as during normal operation.
@@ -359,6 +419,8 @@ export class ConnectionDO extends DurableObject<Env> {
359
419
  ...DEFAULT_SERVER_CONFIG,
360
420
  ...(this.env.SERVER_NAME !== undefined ? { serverName: this.env.SERVER_NAME } : {}),
361
421
  ...(this.env.NETWORK_NAME !== undefined ? { networkName: this.env.NETWORK_NAME } : {}),
422
+ ...(this.env.SERVER_VERSION !== undefined ? { serverVersion: this.env.SERVER_VERSION } : {}),
423
+ ...(this.env.CREATED_AT !== undefined ? { createdAt: this.env.CREATED_AT } : {}),
362
424
  };
363
425
  }
364
426
 
@@ -384,6 +446,41 @@ export class ConnectionDO extends DurableObject<Env> {
384
446
  return this.messages;
385
447
  }
386
448
 
449
+ /**
450
+ * Lazily constructs the per-connection nick-history store. A future
451
+ * persistent variant (DO/KV-backed, keyed by nick) will replace this
452
+ * without touching the actor wiring.
453
+ */
454
+ private historyStore(): NickHistoryStore {
455
+ if (this.history === undefined) {
456
+ this.history = new InMemoryNickHistoryStore(this.clock);
457
+ }
458
+ return this.history;
459
+ }
460
+
461
+ /**
462
+ * Resolves the SASL account store once (D1 table → `SASL_ACCOUNTS` seed),
463
+ * caching the result. Safe to call on every frame; the D1 query runs at
464
+ * most once per ConnectionDO instance. Must complete before the first
465
+ * reducer dispatches so the synchronous `AccountStore.verify` port has its
466
+ * data ready.
467
+ */
468
+ private async loadAccountStore(): Promise<void> {
469
+ if (this.accountsResolved) return;
470
+ this.accountsResolved = true;
471
+ this.accounts = await resolveAccountStore(this.env.ACCOUNTS_DB, this.env.SASL_ACCOUNTS);
472
+ }
473
+
474
+ /**
475
+ * Returns the resolved account store, or `undefined` when no accounts are
476
+ * configured (so the actor's `ctx.accounts` stays unset: `AUTHENTICATE
477
+ * PLAIN` → `904`). Populated by {@link loadAccountStore}; callers MUST
478
+ * `await loadAccountStore()` before reading this.
479
+ */
480
+ private accountStore(): AccountStore | undefined {
481
+ return this.accounts;
482
+ }
483
+
387
484
  // -------------------------------------------------------------------------
388
485
  // Test hooks (only invoked from `runInDurableObject` in tests)
389
486
  // -------------------------------------------------------------------------
@@ -409,6 +506,7 @@ export class ConnectionDO extends DurableObject<Env> {
409
506
  private async tearDownNoSocket(state: ConnectionState): Promise<void> {
410
507
  // Emit QUIT effects without dispatching transport calls (the socket
411
508
  // is already gone). The cross-DO fanout still runs.
509
+ await this.loadAccountStore();
412
510
  const noOpHandlers: CfConnectionHandlers = {
413
511
  send: (): void => {},
414
512
  disconnect: (): void => {},
@@ -428,6 +526,7 @@ export class ConnectionDO extends DurableObject<Env> {
428
526
  ids: this.ids,
429
527
  motd: this.motdProvider(),
430
528
  messages: this.messageStore(),
529
+ history: this.historyStore(),
431
530
  });
432
531
  await actor.receiveTextFrame('QUIT\r\n');
433
532
  await this.releaseAndPersist(state);
@@ -470,6 +569,38 @@ export class ConnectionDO extends DurableObject<Env> {
470
569
  const state = await this.loadState();
471
570
  return toSnapshot(state);
472
571
  }
572
+
573
+ /**
574
+ * Cross-connection RPC: returns the full connection state as a
575
+ * JSON-friendly DTO ({@link PersistedConnectionState}). Called by
576
+ * another connection's {@link CfRuntime} when a `GetConnection`
577
+ * lookup targets this connection (e.g. WHOIS). The DTO survives DO
578
+ * RPC because `Set`/`Map` fields are pre-flattened to arrays by
579
+ * {@link serialize}; the caller rehydrates via {@link deserialize}.
580
+ */
581
+ public async getConnState(): Promise<PersistedConnectionState | null> {
582
+ const state = await this.loadState();
583
+ return serialize(state);
584
+ }
585
+
586
+ /**
587
+ * Cross-connection RPC: tears down this connection. Reuses the same
588
+ * QUIT fan-out / nick-release / WebSocket-close path as the normal
589
+ * `webSocketClose` handler so KICK and operator-driven disconnect
590
+ * produce identical side effects. If no live socket is attached the
591
+ * no-socket teardown path still releases the nick.
592
+ */
593
+ public async disconnect(reason?: string): Promise<void> {
594
+ void reason;
595
+ const sockets = this.ctx.getWebSockets();
596
+ const ws = sockets[0];
597
+ if (ws === undefined) {
598
+ const state = await this.loadState();
599
+ await this.tearDownNoSocket(state);
600
+ return;
601
+ }
602
+ await this.tearDown(ws as WebSocket);
603
+ }
473
604
  }
474
605
 
475
606
  /** Default MOTD when no `MOTD_LINES` env is supplied. */
@@ -569,8 +700,27 @@ class PassthroughChannelAccess {
569
700
  }
570
701
  }
571
702
 
572
- // Reference `applyChannelDelta` to ensure the import is used downstream by
573
- // tests that exercise state migrations; harmless if the symbol is unused
574
- // in production today. (Removed from the ConnectionDO path because the
575
- // authoritative delta application lives in the ChannelDO stub today.)
576
- export { applyChannelDelta, PERSISTED_STATE_VERSION };
703
+ /**
704
+ * Parses the `SASL_ACCOUNTS` env var into credential entries.
705
+ *
706
+ * Format: newline-delimited `username:password` pairs (mirroring the
707
+ * `MOTD_LINES` convention). Blank lines and malformed entries (missing
708
+ * colon or empty username/password) are skipped so a trailing newline or a
709
+ * partial edit does not break boot. Exported so the parsing boundary is
710
+ * unit-testable in isolation.
711
+ */
712
+ export function parseSaslAccountsEnv(raw: string | undefined): SaslAccountCredential[] {
713
+ if (raw === undefined || raw.length === 0) return [];
714
+ const out: SaslAccountCredential[] = [];
715
+ for (const line of raw.split('\n')) {
716
+ const trimmed = line.trim();
717
+ if (trimmed.length === 0) continue;
718
+ const sep = trimmed.indexOf(':');
719
+ if (sep <= 0) continue; // missing colon or empty username
720
+ const username = trimmed.slice(0, sep);
721
+ const password = trimmed.slice(sep + 1);
722
+ if (username.length === 0 || password.length === 0) continue;
723
+ out.push({ username, password });
724
+ }
725
+ return out;
726
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * D1-backed SASL account store primitives for the Cloudflare adapter.
3
+ *
4
+ * Mirrors the AWS `dynamo-account-store.ts` / `account-store.ts` pair:
5
+ * credentials are stored as scrypt hashes (never plaintext) in a D1
6
+ * `accounts` table and pre-loaded at {@link ConnectionDO} construction
7
+ * into the shared {@link HashedAccountStore}.
8
+ *
9
+ * Table-then-config precedence is implemented by {@link resolveAccountStore}:
10
+ * when the D1 table has rows it is authoritative; when it is empty (or
11
+ * unreachable, or the binding is absent) the code falls back to the
12
+ * `SASL_ACCOUNTS` env-var seed — preserving the config-seed behaviour for
13
+ * deployments that have not migrated to D1.
14
+ *
15
+ * The adapters diverge only at the scan/put boundary; the hashing helpers
16
+ * and the synchronous {@link HashedAccountStore} are shared via `irc-core`.
17
+ */
18
+
19
+ import {
20
+ type AccountStore,
21
+ type HashedAccountCredential,
22
+ HashedAccountStore,
23
+ InMemoryAccountStore,
24
+ hashAccountCredential,
25
+ } from '@serverless-ircd/irc-core';
26
+
27
+ /**
28
+ * SQL to create the `accounts` table. Exported so the seed tool and
29
+ * provisioning docs reference one schema definition.
30
+ */
31
+ export const CREATE_ACCOUNTS_TABLE_SQL =
32
+ 'CREATE TABLE IF NOT EXISTS accounts (account TEXT PRIMARY KEY, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)';
33
+
34
+ /**
35
+ * A raw D1 row — the untyped shape returned by `prepare().all()`. Each
36
+ * column is `unknown` until validated into a {@link HashedAccountCredential}.
37
+ */
38
+ type AccountsRow = Record<string, unknown>;
39
+
40
+ /**
41
+ * Coerces a raw D1 row into a {@link HashedAccountCredential}, or returns
42
+ * `undefined` when the row is missing required columns or has the wrong
43
+ * types. Mirrors the silent-skip behaviour of the DynamoDB loader.
44
+ */
45
+ function coerceRow(row: AccountsRow): HashedAccountCredential | undefined {
46
+ const { account, algorithm, salt, hash } = row;
47
+ if (
48
+ typeof account !== 'string' ||
49
+ typeof algorithm !== 'string' ||
50
+ typeof salt !== 'string' ||
51
+ typeof hash !== 'string'
52
+ ) {
53
+ return undefined;
54
+ }
55
+ return {
56
+ account,
57
+ algorithm: algorithm as HashedAccountCredential['algorithm'],
58
+ salt,
59
+ hash,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Queries the D1 `accounts` table and returns a fully-populated
65
+ * {@link HashedAccountStore}, or `undefined` when the table has no rows.
66
+ *
67
+ * Rows missing required attributes are silently skipped. Callers that need
68
+ * the table-then-config fallback should use {@link resolveAccountStore}
69
+ * rather than catching `undefined` themselves.
70
+ *
71
+ * @param db The D1 binding (may be `undefined` when D1 is not configured).
72
+ */
73
+ export async function loadD1AccountStore(
74
+ db: D1Database | undefined,
75
+ ): Promise<HashedAccountStore | undefined> {
76
+ if (db === undefined) return undefined;
77
+ const result = await db
78
+ .prepare('SELECT account, algorithm, salt, hash FROM accounts')
79
+ .all<AccountsRow>();
80
+ const entries: HashedAccountCredential[] = [];
81
+ for (const row of result.results ?? []) {
82
+ const entry = coerceRow(row);
83
+ if (entry !== undefined) entries.push(entry);
84
+ }
85
+ if (entries.length === 0) return undefined;
86
+ return new HashedAccountStore(entries);
87
+ }
88
+
89
+ /**
90
+ * Writes (or re-seeds) a SASL PLAIN account into the D1 `accounts` table.
91
+ *
92
+ * The password is hashed via {@link hashAccountCredential} (scrypt, random
93
+ * salt) and stored as a {@link HashedAccountCredential} row — plaintext is
94
+ * never written. A repeat call for the same `username` overwrites the prior
95
+ * row (the PK is `account`), so this is the canonical CRUD/seed entry point
96
+ * for oper and SASL account provisioning.
97
+ *
98
+ * @returns The stored {@link HashedAccountCredential} (for tooling/tests).
99
+ */
100
+ export async function putAccountCredential(
101
+ db: D1Database,
102
+ username: string,
103
+ password: string,
104
+ ): Promise<HashedAccountCredential> {
105
+ const entry = hashAccountCredential(username, password);
106
+ await db
107
+ .prepare('INSERT OR REPLACE INTO accounts (account, algorithm, salt, hash) VALUES (?, ?, ?, ?)')
108
+ .bind(entry.account, entry.algorithm, entry.salt, entry.hash)
109
+ .run();
110
+ return entry;
111
+ }
112
+
113
+ /**
114
+ * Builds a self-contained `INSERT OR REPLACE` SQL statement that writes a
115
+ * scrypt-hashed account row, suitable for `wrangler d1 execute --command`.
116
+ *
117
+ * The password is hashed via {@link hashAccountCredential}; the plaintext
118
+ * never appears in the SQL. The values are single-quoted with embedded
119
+ * single quotes doubled (`'` → `''`) to avoid injection through the
120
+ * username or the base64 output.
121
+ *
122
+ * This is the pure (non-IO) half of {@link putAccountCredential}; the seed
123
+ * CLI (`tools/seed-cf-accounts.ts`) calls this and pipes the result to
124
+ * `wrangler d1 execute`, while the runtime `ConnectionDO` path uses the
125
+ * bound-parameter {@link putAccountCredential}.
126
+ *
127
+ * @returns The hashed credential and the executable SQL string.
128
+ */
129
+ export function buildAccountPutStatement(
130
+ username: string,
131
+ password: string,
132
+ ): { entry: HashedAccountCredential; sql: string } {
133
+ const entry = hashAccountCredential(username, password);
134
+ const sql = `INSERT OR REPLACE INTO accounts (account, algorithm, salt, hash) VALUES ('${sqlEscape(entry.account)}', '${sqlEscape(entry.algorithm)}', '${sqlEscape(entry.salt)}', '${sqlEscape(entry.hash)}')`;
135
+ return { entry, sql };
136
+ }
137
+
138
+ /** Doubles single quotes for safe embedding in a D1 SQL string literal. */
139
+ function sqlEscape(value: string): string {
140
+ return value.replace(/'/g, "''");
141
+ }
142
+
143
+ /**
144
+ * Resolves the SASL account store for a CF deployment, applying the
145
+ * table-then-config precedence.
146
+ *
147
+ * 1. Queries the D1 `accounts` table (pre-loading every hashed credential
148
+ * into a {@link HashedAccountStore}). When the table has one or more
149
+ * rows it is authoritative — the table wins and the config seed is
150
+ * ignored.
151
+ * 2. When the table is empty (or the binding is absent), falls back to
152
+ * the `SASL_ACCOUNTS` env-var seed (an {@link InMemoryAccountStore})
153
+ * so existing deployments that have not migrated to D1 keep working.
154
+ * 3. Returns `undefined` when neither source has accounts so the actor's
155
+ * `ctx.accounts` stays unset (`AUTHENTICATE PLAIN` → `904`).
156
+ *
157
+ * Query failures are swallowed and treated as an empty table, falling
158
+ * through to the config seed — mirroring `resolveAccountStore` on AWS.
159
+ *
160
+ * @param db The D1 binding (`undefined` when D1 is not configured).
161
+ * @param saslAccountsRaw The raw `SASL_ACCOUNTS` env var (newline-delimited
162
+ * `username:password` pairs), or `undefined` when unset.
163
+ */
164
+ export async function resolveAccountStore(
165
+ db: D1Database | undefined,
166
+ saslAccountsRaw: string | undefined,
167
+ ): Promise<AccountStore | undefined> {
168
+ try {
169
+ const store = await loadD1AccountStore(db);
170
+ if (store !== undefined) return store;
171
+ } catch {
172
+ // Table might not exist yet or be unreachable; fall through to seed.
173
+ }
174
+ const seed = parseSeed(saslAccountsRaw);
175
+ if (seed.length === 0) return undefined;
176
+ return new InMemoryAccountStore(seed);
177
+ }
178
+
179
+ /**
180
+ * Parses the `SASL_ACCOUNTS` env var into credential entries for the
181
+ * config-seed fallback. Re-exported from the in-memory account-store module
182
+ * so the precedence resolver is self-contained.
183
+ */
184
+ function parseSeed(raw: string | undefined): Array<{ username: string; password: string }> {
185
+ if (raw === undefined || raw.length === 0) return [];
186
+ const out: Array<{ username: string; password: string }> = [];
187
+ for (const line of raw.split('\n')) {
188
+ const trimmed = line.trim();
189
+ if (trimmed.length === 0) continue;
190
+ const sep = trimmed.indexOf(':');
191
+ if (sep <= 0) continue;
192
+ const username = trimmed.slice(0, sep);
193
+ const password = trimmed.slice(sep + 1);
194
+ if (username.length === 0 || password.length === 0) continue;
195
+ out.push({ username, password });
196
+ }
197
+ return out;
198
+ }
@@ -8,10 +8,11 @@
8
8
 
9
9
  /**
10
10
  * The production {@link Env} for `apps/cf-worker`. ConnectionDO uses the
11
- * three DO namespaces below to coordinate with the registry and channel
12
- * authorities. 036 (CfRuntime) is where the full wiring lives;
13
- * 033 (ConnectionDO) only needs `CONNECTION_DO` itself plus the
14
- * two collaborator namespaces.
11
+ * four DO namespaces below to coordinate with the registry, channel, and
12
+ * channel-registry authorities. The CfRuntime (036) wires them together
13
+ * behind the platform-agnostic {@link IrcRuntime} port; ConnectionDO
14
+ * (033) only needs `CONNECTION_DO` itself plus the three collaborator
15
+ * namespaces.
15
16
  *
16
17
  * RPC method shapes on {@link RegistryRpc} and {@link ChannelRpc} are
17
18
  * implemented by both the production DOs and the test stubs in
@@ -22,22 +23,60 @@ export interface Env {
22
23
  CONNECTION_DO: DurableObjectNamespace;
23
24
  /**
24
25
  * Nick registry authority. RPC: reserveNick / changeNick / releaseNick /
25
- * lookupNick. Stubbed now (033); real impl in 034.
26
+ * lookupNick. Backed by the sharded {@link RegistryDO} (034), which keys
27
+ * instances by `hash(nick) % N` so the single-threaded DO gives the nick
28
+ * uniqueness invariant for free.
26
29
  *
27
30
  * Note: the production DO class is what's brand-typed; here we keep the
28
- * namespace unparameterized and cast to the RPC surface at the call
29
- * site. The full type plumbing lands with 034.
31
+ * namespace unparameterized and cast to the RPC surface at the call site.
30
32
  */
31
33
  REGISTRY_DO: DurableObjectNamespace;
32
34
  /**
33
35
  * Channel authority, keyed by lowercased channel name. RPC: broadcast /
34
- * applyChannelDelta / getChannelSnapshot. Stubbed now; real in 035.
36
+ * applyChannelDelta / getChannelSnapshot / listMembers. Backed by the
37
+ * {@link ChannelDO} (035), which owns the authoritative roster, modes,
38
+ * topic, and ban list and fans out broadcasts to each member's
39
+ * ConnectionDO.
35
40
  */
36
41
  CHANNEL_DO: DurableObjectNamespace;
42
+ /**
43
+ * Channel registry authority. A single DO instance tracks the set of
44
+ * all channel names so {@link CfRuntime.listChannels} can enumerate
45
+ * them without scanning every per-channel DO. RPC: register /
46
+ * unregister / list.
47
+ */
48
+ CHANNEL_REGISTRY_DO: DurableObjectNamespace;
49
+ /**
50
+ * D1 database backing persistent SASL account credentials. The `accounts`
51
+ * table holds scrypt-hashed rows (`{ account PK, algorithm, salt, hash }`),
52
+ * loaded at {@link ConnectionDO} construction by `loadD1AccountStore`.
53
+ *
54
+ * Optional so deployments that have not migrated to D1 keep working: when
55
+ * unset, the precedence resolver falls straight through to the
56
+ * `SASL_ACCOUNTS` env-var seed (the legacy `InMemoryAccountStore`).
57
+ */
58
+ ACCOUNTS_DB?: D1Database;
37
59
  /** Server-level config knobs (name, MOTD lines, limits). */
38
60
  SERVER_NAME: string;
39
61
  NETWORK_NAME: string;
62
+ /**
63
+ * Server version surfaced in `002`/`004`/`351`/`371`. Optional; when unset
64
+ * the reducer falls back to the irc-core default version.
65
+ */
66
+ SERVER_VERSION?: string;
67
+ /**
68
+ * "Created" text for `003 RPL_CREATED`. Optional; when unset the reducer
69
+ * falls back to the default created text.
70
+ */
71
+ CREATED_AT?: string;
40
72
  MOTD_LINES: string;
73
+ /**
74
+ * SASL PLAIN accounts seeded into the in-worker `AccountStore`.
75
+ * Newline-delimited `username:password` pairs; the connection-do parses
76
+ * these into an `InMemoryAccountStore` so `AUTHENTICATE PLAIN` works
77
+ * end-to-end. Empty/undefined disables SASL account verification.
78
+ */
79
+ SASL_ACCOUNTS?: string;
41
80
  }
42
81
 
43
82
  /**
@@ -64,4 +103,15 @@ export interface ChannelRpc {
64
103
  broadcast(lines: unknown[], except?: string): Promise<void>;
65
104
  applyChannelDelta(delta: unknown): Promise<void>;
66
105
  getChannelSnapshot(): Promise<unknown>;
106
+ listMembers(): Promise<string[]>;
107
+ }
108
+
109
+ /**
110
+ * RPC contract for the channel registry DO. Tracks the set of all
111
+ * channel names so {@link CfRuntime.listChannels} can enumerate them.
112
+ */
113
+ export interface ChannelRegistryRpc {
114
+ register(name: string): Promise<void>;
115
+ unregister(name: string): Promise<void>;
116
+ list(): Promise<string[]>;
67
117
  }