serverless-ircd 0.3.0 → 0.5.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 (223) 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 +349 -18
  8. package/README.md +132 -30
  9. package/apps/aws-stack/README.md +6 -5
  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 +150 -10
  13. package/apps/aws-stack/tests/stack.test.ts +145 -4
  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/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +149 -24
  38. package/apps/local-cli/tests/e2e.test.ts +1 -1
  39. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  40. package/package.json +14 -10
  41. package/packages/aws-adapter/package.json +3 -3
  42. package/packages/aws-adapter/src/account-store.ts +1 -1
  43. package/packages/aws-adapter/src/admission.ts +74 -0
  44. package/packages/aws-adapter/src/aws-runtime.ts +75 -4
  45. package/packages/aws-adapter/src/config-loader.ts +32 -0
  46. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  47. package/packages/aws-adapter/src/handlers/connect.ts +96 -9
  48. package/packages/aws-adapter/src/handlers/default.ts +98 -7
  49. package/packages/aws-adapter/src/handlers/index.ts +109 -4
  50. package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
  51. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  52. package/packages/aws-adapter/src/index.ts +8 -0
  53. package/packages/aws-adapter/src/serialize.ts +11 -1
  54. package/packages/aws-adapter/src/stats.ts +80 -0
  55. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  56. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  57. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  58. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  59. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  60. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  61. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  62. package/packages/aws-adapter/tests/connect.test.ts +174 -0
  63. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  64. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  65. package/packages/aws-adapter/tests/handlers.test.ts +302 -53
  66. package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
  67. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  68. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  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 +5 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +68 -5
  73. package/packages/cf-adapter/src/channel-do.ts +2 -2
  74. package/packages/cf-adapter/src/config-loader.ts +33 -0
  75. package/packages/cf-adapter/src/connection-do.ts +278 -85
  76. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  77. package/packages/cf-adapter/src/env.ts +54 -11
  78. package/packages/cf-adapter/src/index.ts +11 -8
  79. package/packages/cf-adapter/src/registry-do.ts +22 -3
  80. package/packages/cf-adapter/src/sharding.ts +1 -2
  81. package/packages/cf-adapter/src/stats.ts +65 -0
  82. package/packages/cf-adapter/tests/cf-harness.ts +12 -2
  83. package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
  84. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  85. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  86. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  87. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  88. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  89. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  90. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  91. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  92. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  93. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  94. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  95. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  96. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  97. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  98. package/packages/cf-adapter/tests/worker/main.ts +15 -8
  99. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  100. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  101. package/packages/cf-adapter/wrangler.test.toml +15 -0
  102. package/packages/in-memory-runtime/package.json +1 -1
  103. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  104. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  105. package/packages/irc-core/package.json +6 -1
  106. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  107. package/packages/irc-core/src/admission.ts +16 -15
  108. package/packages/irc-core/src/caps/capabilities.ts +24 -3
  109. package/packages/irc-core/src/cloak.ts +1 -1
  110. package/packages/irc-core/src/commands/cap.ts +8 -1
  111. package/packages/irc-core/src/commands/index.ts +17 -0
  112. package/packages/irc-core/src/commands/invite.ts +2 -4
  113. package/packages/irc-core/src/commands/ison.ts +61 -0
  114. package/packages/irc-core/src/commands/isupport.ts +6 -2
  115. package/packages/irc-core/src/commands/kick.ts +2 -4
  116. package/packages/irc-core/src/commands/kill.ts +127 -0
  117. package/packages/irc-core/src/commands/list.ts +1 -1
  118. package/packages/irc-core/src/commands/lusers.ts +204 -0
  119. package/packages/irc-core/src/commands/mode.ts +4 -8
  120. package/packages/irc-core/src/commands/names.ts +3 -5
  121. package/packages/irc-core/src/commands/part.ts +2 -4
  122. package/packages/irc-core/src/commands/quit.ts +12 -0
  123. package/packages/irc-core/src/commands/registration.ts +18 -12
  124. package/packages/irc-core/src/commands/rehash.ts +119 -0
  125. package/packages/irc-core/src/commands/sasl.ts +72 -9
  126. package/packages/irc-core/src/commands/server-info.ts +129 -0
  127. package/packages/irc-core/src/commands/setname.ts +109 -0
  128. package/packages/irc-core/src/commands/stats.ts +152 -0
  129. package/packages/irc-core/src/commands/topic.ts +2 -4
  130. package/packages/irc-core/src/commands/trace.ts +137 -0
  131. package/packages/irc-core/src/commands/userhost.ts +84 -0
  132. package/packages/irc-core/src/commands/wallops.ts +118 -0
  133. package/packages/irc-core/src/commands/whowas.ts +113 -0
  134. package/packages/irc-core/src/config.ts +65 -0
  135. package/packages/irc-core/src/credential-hashing.ts +124 -0
  136. package/packages/irc-core/src/effects.ts +33 -30
  137. package/packages/irc-core/src/index.ts +3 -0
  138. package/packages/irc-core/src/ports.ts +360 -12
  139. package/packages/irc-core/src/protocol/numerics.ts +48 -11
  140. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  141. package/packages/irc-core/src/types.ts +46 -2
  142. package/packages/irc-core/src/ws-framing.ts +132 -0
  143. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  144. package/packages/irc-core/tests/account-store.test.ts +45 -2
  145. package/packages/irc-core/tests/admission.test.ts +18 -0
  146. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  147. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  148. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  149. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  150. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  151. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  152. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  153. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  154. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  155. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  156. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  157. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  158. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  159. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  160. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  161. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  162. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  163. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  164. package/packages/irc-core/tests/config.test.ts +95 -1
  165. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  166. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  167. package/packages/irc-core/tests/effects.test.ts +14 -27
  168. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  169. package/packages/irc-core/tests/numerics.test.ts +102 -0
  170. package/packages/irc-core/tests/outbound.test.ts +51 -0
  171. package/packages/irc-core/tests/ports.test.ts +22 -0
  172. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  173. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  174. package/packages/irc-core/tests/types.test.ts +35 -1
  175. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  176. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  177. package/packages/irc-core/tsconfig.build.json +1 -1
  178. package/packages/irc-core/tsconfig.test.json +1 -1
  179. package/packages/irc-server/package.json +1 -1
  180. package/packages/irc-server/src/actor.ts +393 -16
  181. package/packages/irc-server/src/dispatch.ts +1 -3
  182. package/packages/irc-server/src/index.ts +10 -2
  183. package/packages/irc-server/src/routing.ts +15 -0
  184. package/packages/irc-server/src/runtime.ts +31 -0
  185. package/packages/irc-server/src/transport.ts +104 -0
  186. package/packages/irc-server/tests/actor.test.ts +1489 -4
  187. package/packages/irc-server/tests/dispatch.test.ts +37 -17
  188. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  189. package/packages/irc-server/tests/routing.test.ts +5 -0
  190. package/packages/irc-server/tests/runtime.test.ts +7 -0
  191. package/packages/irc-server/tests/transport.test.ts +230 -0
  192. package/packages/irc-test-support/package.json +1 -1
  193. package/packages/irc-test-support/src/harness.ts +44 -9
  194. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  195. package/packages/irc-test-support/src/index.ts +3 -0
  196. package/packages/irc-test-support/src/scenarios.ts +141 -3
  197. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  198. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  199. package/pnpm-workspace.yaml +10 -1
  200. package/tools/ci-hardening/package.json +1 -1
  201. package/tools/package.json +9 -0
  202. package/tools/seed-aws-accounts.ts +5 -6
  203. package/tools/seed-cf-accounts.ts +107 -0
  204. package/tools/tcp-ws-forwarder/package.json +1 -1
  205. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  206. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  207. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
  208. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  209. package/docs/ADR-002-location-of-authority.md +0 -82
  210. package/docs/ADR-003-durable-object-sharding.md +0 -93
  211. package/docs/ADR-004-dynamodb-schema.md +0 -96
  212. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  213. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  214. package/docs/ADR-007-deterministic-ports.md +0 -82
  215. package/docs/ADR-008-monorepo-tooling.md +0 -60
  216. package/docs/AWS-Adapter-Architecture.md +0 -496
  217. package/docs/AWS-Deployment.md +0 -1186
  218. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  219. package/docs/Home.md +0 -11
  220. package/docs/Observability.md +0 -87
  221. package/docs/PlanIRCv3Websocket.md +0 -489
  222. package/docs/PlanWebClient.md +0 -451
  223. package/docs/Release-Process.md +0 -443
@@ -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 wires them together
13
+ * behind the platform-agnostic {@link IrcRuntime} port; ConnectionDO
14
+ * 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,17 +23,20 @@ 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}, 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 / listMembers. Stubbed now;
35
- * real in 035.
36
+ * applyChannelDelta / getChannelSnapshot / listMembers. Backed by the
37
+ * {@link ChannelDO}, which owns the authoritative roster, modes,
38
+ * topic, and ban list and fans out broadcasts to each member's
39
+ * ConnectionDO.
36
40
  */
37
41
  CHANNEL_DO: DurableObjectNamespace;
38
42
  /**
@@ -42,9 +46,29 @@ export interface Env {
42
46
  * unregister / list.
43
47
  */
44
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;
45
59
  /** Server-level config knobs (name, MOTD lines, limits). */
46
60
  SERVER_NAME: string;
47
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;
48
72
  MOTD_LINES: string;
49
73
  /**
50
74
  * SASL PLAIN accounts seeded into the in-worker `AccountStore`.
@@ -53,22 +77,41 @@ export interface Env {
53
77
  * end-to-end. Empty/undefined disables SASL account verification.
54
78
  */
55
79
  SASL_ACCOUNTS?: string;
80
+ /**
81
+ * IRC operator name consulted by the `OPER` reducer. Pair with
82
+ * {@link OPER_PASSWORD}; when either is set the connection-do threads
83
+ * both into the reducer-facing `ServerConfig.operCreds` via
84
+ * `loadServerConfigFromCfEnv`.
85
+ */
86
+ OPER_USER?: string;
87
+ /**
88
+ * IRC operator password paired with {@link OPER_USER}. Treat as a
89
+ * Cloudflare secret (never a plaintext `[vars]` entry) in production.
90
+ */
91
+ OPER_PASSWORD?: string;
56
92
  }
57
93
 
58
94
  /**
59
95
  * RPC contract ConnectionDO expects from the registry. The real
60
- * RegistryDO (034) and the test stub both implement this.
96
+ * RegistryDO and the test stub both implement this.
61
97
  */
62
98
  export interface RegistryRpc {
63
99
  reserveNick(nick: string, conn: string): Promise<{ ok: true } | { ok: false }>;
64
100
  changeNick(conn: string, oldNick: string, newNick: string): Promise<boolean>;
65
101
  releaseNick(nick: string): Promise<void>;
66
102
  lookupNick(nick: string): Promise<string | null>;
103
+ /**
104
+ * Enumerates every `(nickLower, connectionId)` entry in this shard. Used
105
+ * by global fan-out paths (e.g. `WALLOPS`) that need to reach connections
106
+ * regardless of shared channel membership. Returns a plain array so the
107
+ * RPC marshals cleanly across the DO boundary.
108
+ */
109
+ listEntries(): Promise<Array<[nickLower: string, connId: string]>>;
67
110
  }
68
111
 
69
112
  /**
70
113
  * RPC contract ConnectionDO expects from each channel instance. The real
71
- * ChannelDO (035) and the test stub both implement this.
114
+ * ChannelDO and the test stub both implement this.
72
115
  *
73
116
  * Note: `broadcast` accepts already-formatted raw line texts (no
74
117
  * `RawLine` wrapper) because that's what `cf-runtime.broadcast`
@@ -2,14 +2,9 @@
2
2
  * @serverless-ircd/cf-adapter
3
3
  *
4
4
  * Cloudflare Workers adapter for ServerlessIRCd. Implements the three
5
- * Durable Objects (ConnectionDO, RegistryDO, ChannelDO) and a
6
- * CfRuntime that bridges them behind the platform-agnostic
7
- * {@link IrcRuntime} port.
8
- *
9
- * 033 shipped the {@link ConnectionDO} + a stub CfRuntime.
10
- * 034 ships the {@link RegistryDO} (real nick registry with
11
- * sharding). The {@link ChannelDO} (roster + modes + fanout) ships
12
- * here. The full CfRuntime lands in 036.
5
+ * Durable Objects (ConnectionDO, RegistryDO, ChannelDO), the
6
+ * ChannelRegistryDO index, and a CfRuntime that bridges them behind the
7
+ * platform-agnostic {@link IrcRuntime} port.
13
8
  */
14
9
 
15
10
  export {
@@ -28,7 +23,15 @@ export {
28
23
  } from './sharding.js';
29
24
  export { makeCfRuntime } from './cf-runtime.js';
30
25
  export type { CfConnectionHandlers } from './cf-runtime.js';
26
+ export { CfStats } from './stats.js';
27
+ export type { CfStatsRuntime } from './stats.js';
31
28
  export type { ChannelRpc, ChannelRegistryRpc, Env, RegistryRpc } from './env.js';
29
+ export {
30
+ CREATE_ACCOUNTS_TABLE_SQL,
31
+ loadD1AccountStore,
32
+ putAccountCredential,
33
+ resolveAccountStore,
34
+ } from './d1-account-store.js';
32
35
  export {
33
36
  PERSISTED_STATE_VERSION,
34
37
  STATE_STORAGE_KEY,
@@ -23,8 +23,8 @@
23
23
  * and the second sees the new owner and fails.
24
24
  *
25
25
  * RPC surface — implements {@link RegistryRpc}, the same shape the
26
- * stub `RecordingRegistryDO` exposed for 033. Consumers
27
- * (`CfRuntime`, 036) call these methods via `env.REGISTRY_DO`
26
+ * stub `RecordingRegistryDO` exposed for the connection tests. Consumers
27
+ * (`CfRuntime`) call these methods via `env.REGISTRY_DO`
28
28
  * after computing the shard key with {@link registryKeyForNick}.
29
29
  */
30
30
 
@@ -85,7 +85,7 @@ export class RegistryDO extends DurableObject implements RegistryRpc {
85
85
  * `oldNick` after this conn abandoned it (defence in depth).
86
86
  *
87
87
  * Cross-shard changes (old and new hash to different shards) are
88
- * orchestrated by `CfRuntime` in 036 as a two-phase
88
+ * orchestrated by `CfRuntime` as a two-phase
89
89
  * reserve-new-then-release-old. This DO only sees single-shard
90
90
  * changes and crash-recovery paths (where `oldNick` may not be
91
91
  * present); the latter is why the conditional delete is a soft check.
@@ -127,4 +127,23 @@ export class RegistryDO extends DurableObject implements RegistryRpc {
127
127
  const owner = await this.ctx.storage.get<string>(nickKey(nick.toLowerCase()));
128
128
  return owner ?? null;
129
129
  }
130
+
131
+ /**
132
+ * Enumerates every `(nickLower, connectionId)` pair in this shard.
133
+ * Backs the global WALLOPS fan-out: the caller scans every shard, collects
134
+ * all connection ids, and delivers to those carrying the `+w` mode. The
135
+ * walk is a single `list` over the `nick:` prefix; cost is proportional to
136
+ * the slice's live nick count (sharded, so bounded per DO).
137
+ */
138
+ async listEntries(): Promise<Array<[nickLower: string, connId: string]>> {
139
+ const out: Array<[nickLower: string, connId: string]> = [];
140
+ // `storage.list` resolves to a Map of `key → value` for the prefix.
141
+ // Each key is `nick:<lower>`; we strip the prefix to recover the nick.
142
+ const entries = await this.ctx.storage.list<string>({ prefix: NICK_KEY_PREFIX });
143
+ for (const [key, connId] of entries) {
144
+ const nickLower = key.slice(NICK_KEY_PREFIX.length);
145
+ out.push([nickLower, connId]);
146
+ }
147
+ return out;
148
+ }
130
149
  }
@@ -22,8 +22,7 @@
22
22
  * `N` defaults to {@link DEFAULT_REGISTRY_SHARDS} (32) and is
23
23
  * configurable per deployment via the worker env. Changing N after
24
24
  * production traffic requires a migration (re-sharding every existing
25
- * nick→conn mapping); document this in `docs/deployment-cf.md`
26
- * (038).
25
+ * nick→conn mapping); document this in `docs/deployment-cf.md`.
27
26
  */
28
27
 
29
28
  /**
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Cloudflare Workers-flavoured {@link ServerStats} backend.
3
+ *
4
+ * Aggregates the network-wide counts the `LUSERS` / `STATS` reducers need
5
+ * by fanning out across the existing CF RPC surface: `listChannels` (via
6
+ * the {@link ChannelRegistryDO}) plus per-channel `listMembers` and
7
+ * per-member `getConnState`. Classification (oper / invisible / unknown)
8
+ * is delegated to the shared {@link computeStatsSnapshot} helper in
9
+ * irc-core so the classification rules live in exactly one place.
10
+ *
11
+ * **Limitation:** the CF runtime does not currently expose an
12
+ * "enumerate all connections" RPC, so a connection that has not joined
13
+ * any channel is not counted. This matches the ticket's "aggregate via
14
+ * listChannels / connection-enumeration RPCs" guidance; a future
15
+ * dedicated stats DO would close the gap (every connection would be
16
+ * counted regardless of channel membership).
17
+ *
18
+ * Constructed per `LUSERS` / `STATS` invocation alongside the
19
+ * {@link CfRuntime}; cheap to build, no caching. `uptimeStartedAt` is
20
+ * supplied by the caller (typically the worker's startup timestamp).
21
+ */
22
+
23
+ import type {
24
+ ChanName,
25
+ ConnId,
26
+ ConnectionState,
27
+ ServerStatsSnapshot,
28
+ } from '@serverless-ircd/irc-core';
29
+ import { computeStatsSnapshot } from '@serverless-ircd/irc-core';
30
+
31
+ /**
32
+ * The minimal slice of {@link CfRuntime} (or any adapter runtime) that
33
+ * {@link CfStats} needs. Defined structurally so unit tests can pass a
34
+ * stub without spinning up workerd / miniflare.
35
+ */
36
+ export interface CfStatsRuntime {
37
+ listChannels(): Promise<ReadonlyArray<{ nameLower: string }>>;
38
+ getChannelConnections(name: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>>;
39
+ }
40
+
41
+ export class CfStats {
42
+ private readonly runtime: CfStatsRuntime;
43
+ private readonly uptimeStartedAt: number;
44
+
45
+ constructor(runtime: CfStatsRuntime, uptimeStartedAt: number) {
46
+ this.runtime = runtime;
47
+ this.uptimeStartedAt = uptimeStartedAt;
48
+ }
49
+
50
+ async getStats(): Promise<ServerStatsSnapshot> {
51
+ const channels = await this.runtime.listChannels();
52
+ // Fan out: gather every channel's members into one deduplicated map.
53
+ // A user in N channels is counted once.
54
+ const unique = new Map<ConnId, ConnectionState>();
55
+ await Promise.all(
56
+ channels.map(async (chan) => {
57
+ const members = await this.runtime.getChannelConnections(chan.nameLower as ChanName);
58
+ for (const [id, state] of members) {
59
+ unique.set(id, state);
60
+ }
61
+ }),
62
+ );
63
+ return computeStatsSnapshot(unique.values(), channels.length, this.uptimeStartedAt);
64
+ }
65
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * CF-side {@link IrcHarnessFactory} — registers the parametrized IRC
3
- * scenario suite (032) against the Cloudflare adapter.
3
+ * scenario suite from `@serverless-ircd/irc-test-support` against the Cloudflare adapter.
4
4
  *
5
5
  * Each {@link CfHarness} instance is hermetic at the vitest-pool-workers
6
6
  * level: every test gets an isolated storage namespace
@@ -65,7 +65,8 @@ export interface CfHarnessEnv {
65
65
  */
66
66
  export function makeCfHarnessFactory(opts: CfHarnessFactoryOptions): IrcHarnessFactory {
67
67
  return Object.freeze({
68
- name: 'cf',
68
+ name: 'cf+ws',
69
+ transport: 'ws',
69
70
  async create() {
70
71
  return new CfHarness(opts);
71
72
  },
@@ -204,6 +205,15 @@ class CfClientHandle implements ClientHarness {
204
205
  this.ws.send(`${line}\r\n`);
205
206
  }
206
207
 
208
+ async feed(chunk: string): Promise<void> {
209
+ if (this.closed) return;
210
+ // The CF harness speaks the WebSocket transport end-to-end (a real WS
211
+ // to a ConnectionDO), so one feed() is one text frame — identical to
212
+ // send() minus the framing. A CF+tcp factory (Spectrum + container
213
+ // origin) would route this through a TCP byte stream.
214
+ this.ws.send(chunk);
215
+ }
216
+
207
217
  async waitForLine(
208
218
  predicate: (line: string) => boolean,
209
219
  timeoutMs: number = DEFAULT_TIMEOUT_MS,
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Parametrized IRC scenarios against the Cloudflare runtime — 036.
2
+ * Parametrized IRC scenarios against the Cloudflare runtime.
3
3
  *
4
4
  * Reuses the scenario runner from `@serverless-ircd/irc-test-support`
5
- * (032) and registers the CF harness factory built on top of the
5
+ * and registers the CF harness factory built on top of the
6
6
  * real ConnectionDO / RegistryDO / ChannelDO worker bindings. Each
7
7
  * scenario spawns one or more real WebSockets and drives the full
8
8
  * bytes → actor → CfRuntime pipeline inside `workerd`.
@@ -15,13 +15,13 @@
15
15
  * against the in-memory runtime; CF-specific failures stem from the
16
16
  * ConnectionDO's `PassthroughChannelAccess` not yet fetching
17
17
  * authoritative roster state from ChannelDO before running channel
18
- * reducers. That wiring lands in a follow-up to keep this ticket
18
+ * reducers. That wiring lands in a follow-up to keep this work
19
19
  * focused on the CfRuntime class itself):
20
20
  * - Multi-client channel reads (NAMES of peers, KICK roster refresh)
21
21
  * see only the local connection until the actor's channel access
22
22
  * is upgraded to read from ChannelDO.
23
23
  * - ChannelDO.getChannelSnapshot has an unrelated bug in the parallel
24
- * ChannelDO ticket (035) that returns a malformed snapshot;
24
+ * ChannelDO work that returns a malformed snapshot;
25
25
  * that bug is not introduced here.
26
26
  */
27
27
 
@@ -62,7 +62,8 @@ afterAll(async () => {
62
62
  // guaranteed to be initialized.
63
63
  runIrcScenarios([
64
64
  {
65
- name: 'cf',
65
+ name: 'cf+ws',
66
+ transport: 'ws',
66
67
  async create() {
67
68
  if (factory === undefined) {
68
69
  factory = makeCfHarnessFactory({ env });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * CfRuntime — 036.
2
+ * CfRuntime.
3
3
  *
4
4
  * Verifies that every {@link IrcRuntime} method on {@link CfRuntime} issues
5
5
  * the correct DO `stub()` call:
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * Two envs are used:
17
17
  * - `env` — production bindings (recording stubs for the
18
- * 033 unit suite; swapped to `_REAL` for
18
+ * ConnectionDO unit suite; swapped to `_REAL` for
19
19
  * the CfRuntime uniqueness + end-to-end tests).
20
20
  * - `envWithRealDOs()` — a shim that re-exposes the real RegistryDO +
21
21
  * ChannelDO under the production binding names,
@@ -616,3 +616,39 @@ describe('CfRuntime — IrcRuntime structural conformance', () => {
616
616
  expect(handlers.sent).toEqual([':server 001 alice :Welcome']);
617
617
  });
618
618
  });
619
+
620
+ describe('CfRuntime — reloadConfig (REHASH)', () => {
621
+ it('re-reads the bound Workers env and reparses the server config', async () => {
622
+ const realEnv = envWithRealDOs();
623
+ const rt = new CfRuntime(realEnv, 'conn-rehash', recordingHandlers());
624
+
625
+ const cfg = await rt.reloadConfig();
626
+
627
+ expect(cfg.serverName).toBe('irc.example.com');
628
+ expect(cfg.networkName).toBe('ExampleNet');
629
+ });
630
+
631
+ it('reflects a rotated env value on the next reload (no caching)', async () => {
632
+ const rotatingEnv = {
633
+ ...envWithRealDOs(),
634
+ OPER_USER: 'admin',
635
+ OPER_PASSWORD: 'old',
636
+ };
637
+ const rt = new CfRuntime(rotatingEnv, 'conn-rehash-2', recordingHandlers());
638
+
639
+ const first = await rt.reloadConfig();
640
+ expect(first.operCreds).toEqual([{ user: 'admin', password: 'old' }]);
641
+
642
+ // Rotate the env (simulate a re-deployed secret) and reload again.
643
+ rotatingEnv.OPER_PASSWORD = 'rotated';
644
+ const second = await rt.reloadConfig();
645
+ expect(second.operCreds).toEqual([{ user: 'admin', password: 'rotated' }]);
646
+ });
647
+
648
+ it('propagates a schema-validation failure so the actor applies the graceful 382', async () => {
649
+ const badEnv: Env = { ...envWithRealDOs(), NETWORK_NAME: '' };
650
+ const rt = new CfRuntime(badEnv, 'conn-rehash-bad', recordingHandlers());
651
+
652
+ await expect(rt.reloadConfig()).rejects.toThrow(/networkName/u);
653
+ });
654
+ });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ChannelDO — roster + modes + fanout via ConnectionDO stubs.
3
3
  *
4
- * TDD outline (from tickets.md):
4
+ * TDD outline:
5
5
  * "Red on 'two members of a channel both receive PRIVMSG'; green;
6
6
  * then gone-connection sweep."
7
7
  *
@@ -452,7 +452,7 @@ describe('ChannelDO — instance keying', () => {
452
452
  });
453
453
 
454
454
  // ---------------------------------------------------------------------------
455
- // Scale smoke test — exercises the 1k-member ceiling from the ticket.
455
+ // Scale smoke test — exercises the 1k-member ceiling.
456
456
  // ---------------------------------------------------------------------------
457
457
 
458
458
  describe('ChannelDO — scale', () => {
@@ -101,6 +101,28 @@ describe('loadServerConfigFromCfEnv — valid env', () => {
101
101
  });
102
102
  expect(cfg.quitMessage).toBe('Bye from CF');
103
103
  });
104
+
105
+ it('threads SERVER_VERSION into serverVersion', () => {
106
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), SERVER_VERSION: '1.2.3' });
107
+ expect(cfg.serverVersion).toBe('1.2.3');
108
+ });
109
+
110
+ it('passes CREATED_AT verbatim when it is non-numeric', () => {
111
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), CREATED_AT: '2024-06-01' });
112
+ expect(cfg.createdAt).toBe('2024-06-01');
113
+ });
114
+
115
+ it('parses a numeric CREATED_AT string into an epoch-ms number', () => {
116
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), CREATED_AT: '1700000000000' });
117
+ expect(cfg.createdAt).toBe(1_700_000_000_000);
118
+ });
119
+
120
+ it('applies the schema defaults for serverVersion/createdAt when unset', () => {
121
+ const cfg = loadServerConfigFromCfEnv(baseEnv());
122
+ expect(typeof cfg.serverVersion).toBe('string');
123
+ expect(cfg.serverVersion.length).toBeGreaterThan(0);
124
+ expect(cfg.createdAt).toBeDefined();
125
+ });
104
126
  });
105
127
 
106
128
  describe('loadServerConfigFromCfEnv — failure modes', () => {