serverless-ircd 0.7.0 → 0.8.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 (135) hide show
  1. package/.github/workflows/ci.yml +3 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +273 -29
  4. package/README.md +155 -55
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-worker/package.json +1 -1
  10. package/apps/cf-worker/src/worker.ts +4 -4
  11. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  12. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  13. package/apps/cf-worker/wrangler.test.toml +6 -6
  14. package/apps/cf-worker/wrangler.toml +7 -5
  15. package/apps/local-cli/package.json +1 -1
  16. package/apps/local-cli/src/config-loader.ts +8 -0
  17. package/apps/local-cli/src/main.ts +16 -0
  18. package/apps/local-cli/src/server.ts +1 -0
  19. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  20. package/apps/web/landing/index.html +14 -16
  21. package/apps/web/package.json +2 -2
  22. package/apps/web/scripts/build.mjs +23 -16
  23. package/apps/web/src/build-env.ts +1 -1
  24. package/apps/web/src/config-schema.ts +6 -6
  25. package/apps/web/tests/build-smoke.test.ts +14 -14
  26. package/apps/web/tests/config-schema.test.ts +1 -1
  27. package/docs/AWS-Deployment.md +21 -8
  28. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  29. package/docs/PlanExtensions.md +113 -3
  30. package/docs/Release-Process.md +23 -13
  31. package/docs/Services.md +546 -0
  32. package/docs/WebClientGuide.md +32 -31
  33. package/package.json +2 -2
  34. package/packages/aws-adapter/package.json +1 -1
  35. package/packages/aws-adapter/src/aws-runtime.ts +5 -0
  36. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  37. package/packages/aws-adapter/src/config-loader.ts +11 -0
  38. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  39. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  40. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  41. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  42. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  43. package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
  44. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  45. package/packages/aws-adapter/src/index.ts +4 -0
  46. package/packages/aws-adapter/src/stats.ts +6 -1
  47. package/packages/aws-adapter/src/tables.ts +34 -4
  48. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  49. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  50. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  51. package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
  52. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  53. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  54. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  55. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  56. package/packages/cf-adapter/package.json +1 -1
  57. package/packages/cf-adapter/src/config-loader.ts +11 -0
  58. package/packages/cf-adapter/src/connection-do.ts +112 -2
  59. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  60. package/packages/cf-adapter/src/env.ts +8 -0
  61. package/packages/cf-adapter/src/index.ts +5 -0
  62. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  63. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  64. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  65. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  66. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  67. package/packages/in-memory-runtime/package.json +1 -1
  68. package/packages/irc-core/package.json +1 -1
  69. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  70. package/packages/irc-core/src/commands/account-auth.ts +172 -0
  71. package/packages/irc-core/src/commands/chanserv.ts +882 -0
  72. package/packages/irc-core/src/commands/hostserv.ts +487 -0
  73. package/packages/irc-core/src/commands/index.ts +12 -0
  74. package/packages/irc-core/src/commands/join.ts +164 -8
  75. package/packages/irc-core/src/commands/markread.ts +202 -0
  76. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  77. package/packages/irc-core/src/commands/mode.ts +96 -4
  78. package/packages/irc-core/src/commands/nickserv.ts +390 -0
  79. package/packages/irc-core/src/commands/oper.ts +18 -1
  80. package/packages/irc-core/src/commands/operserv.ts +346 -0
  81. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  82. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  83. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  84. package/packages/irc-core/src/commands/registration.ts +61 -6
  85. package/packages/irc-core/src/commands/sasl.ts +18 -49
  86. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  87. package/packages/irc-core/src/commands/topic.ts +37 -0
  88. package/packages/irc-core/src/config.ts +36 -5
  89. package/packages/irc-core/src/effects.ts +56 -1
  90. package/packages/irc-core/src/ports.ts +1653 -84
  91. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  92. package/packages/irc-core/src/state/channel.ts +21 -1
  93. package/packages/irc-core/src/state/connection.ts +25 -1
  94. package/packages/irc-core/src/types.ts +48 -12
  95. package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
  96. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  97. package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
  98. package/packages/irc-core/tests/commands/join.test.ts +393 -1
  99. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  100. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  101. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  102. package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
  103. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  104. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  105. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  106. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  107. package/packages/irc-core/tests/commands/registration.test.ts +788 -14
  108. package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
  109. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  111. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  112. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  113. package/packages/irc-core/tests/config.test.ts +49 -5
  114. package/packages/irc-core/tests/effects.test.ts +19 -0
  115. package/packages/irc-core/tests/message-store.test.ts +63 -0
  116. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  117. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  118. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  119. package/packages/irc-server/package.json +1 -1
  120. package/packages/irc-server/src/actor.ts +71 -16
  121. package/packages/irc-server/src/dispatch.ts +94 -7
  122. package/packages/irc-server/src/routing.ts +19 -0
  123. package/packages/irc-server/tests/actor.test.ts +623 -12
  124. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  125. package/packages/irc-server/tests/routing.test.ts +6 -0
  126. package/packages/irc-test-support/package.json +1 -1
  127. package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
  128. package/packages/irc-test-support/src/index.ts +1 -0
  129. package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
  130. package/tools/ci-hardening/package.json +1 -1
  131. package/tools/load-test/package.json +1 -1
  132. package/tools/tcp-ws-forwarder/package.json +1 -1
  133. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
  134. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  135. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -0,0 +1,703 @@
1
+ /**
2
+ * D1-backed {@link PersistentServicesStore} for the Cloudflare adapter.
3
+ *
4
+ * Shares the `ACCOUNTS_DB` D1 binding from the SASL account store and
5
+ * lays out one table per services section so the whole
6
+ * {@link ServicesSnapshot} round-trips across a deploy:
7
+ *
8
+ * - `nickserv_accounts` — NickServ registrations (scrypt-hashed
9
+ * credential: never plaintext; the unified account record so a
10
+ * NickServ registration IS a SASL account).
11
+ * - `chanserv_channels` — ChanServ channel registrations (+ topic).
12
+ * - `chanserv_access` — ChanServ `(account, level)` access entries.
13
+ * - `chanserv_levels` — founder-redefined ChanServ level thresholds.
14
+ * - `hostserv_vhosts` — HostServ `(account, vhost)` entries.
15
+ * - `hostserv_pending_vhosts` — HostServ pending `(account, vhost)`
16
+ * requests awaiting oper approval.
17
+ * - `memoserv_memos` — MemoServ queued memos (globally-unique id).
18
+ * - `read_markers` — per-`(account, channel)` draft/read-marker.
19
+ * - `operserv_akills` — OperServ AKILL roster.
20
+ * - `operserv_jupes` — OperServ JUPE roster.
21
+ *
22
+ * Boot path: {@link loadD1ServicesStore} scans every table into a
23
+ * {@link ServicesSnapshot} (tolerating a fresh deploy where the tables do
24
+ * not exist yet) and hands it to a `D1ServicesStore`. The store mutates
25
+ * the inherited in-memory cache synchronously and enqueues one
26
+ * {@link ServicesWrite} per mutation; `flush()` drains the queue into a
27
+ * single `db.batch([...])` round-trip.
28
+ *
29
+ * Case-insensitive lookups: every PK column is the rfc1459-folded key
30
+ * (`*_key`) so a re-registration of `Alice` overwrites `alice`. Display
31
+ * spelling is preserved in a separate column so the wire renders the
32
+ * user's preferred casing after a reload.
33
+ */
34
+
35
+ import {
36
+ type AkillEntry,
37
+ type ChannelAccessLevel,
38
+ type ChannelLevelOp,
39
+ type Clock,
40
+ type JupeEntry,
41
+ type MemoEntry,
42
+ type NickEnforcePolicy,
43
+ type PersistedTopic,
44
+ PersistentServicesStore,
45
+ type RegisteredChannel,
46
+ type ServicesAccessRow,
47
+ type ServicesLevelRow,
48
+ type ServicesNickRow,
49
+ type ServicesPendingVhostRow,
50
+ type ServicesSnapshot,
51
+ type ServicesVhostRow,
52
+ type ServicesWrite,
53
+ SystemClock,
54
+ caseFold,
55
+ } from '@serverless-ircd/irc-core';
56
+
57
+ /** rfc1459 case-fold used for every storage key. */
58
+ function fold(value: string): string {
59
+ return caseFold('rfc1459', value);
60
+ }
61
+
62
+ /**
63
+ * Minimal query surface shared by {@link D1Database} and
64
+ * {@link D1DatabaseSession}. The service loaders accept either so the
65
+ * snapshot hydrate can run through a session (for read-from-primary
66
+ * consistency) without the write path (`applyWrites`, which always targets
67
+ * the primary) caring which it is.
68
+ */
69
+ interface D1QueryClient {
70
+ prepare(query: string): D1PreparedStatement;
71
+ batch<T = unknown>(statements: D1PreparedStatement[]): Promise<D1Result<T>[]>;
72
+ }
73
+
74
+ /**
75
+ * DDL for the ten services tables, in dependency-free order. Exported so
76
+ * the seed tool, the provisioning docs, and the test `beforeEach` reference
77
+ * one schema definition. Each statement is `IF NOT EXISTS` so re-running
78
+ * the list on a warm deploy is a no-op.
79
+ */
80
+ export const CREATE_NICKSERV_ACCOUNTS_SQL =
81
+ 'CREATE TABLE IF NOT EXISTS nickserv_accounts (nick_key TEXT PRIMARY KEY, nick TEXT NOT NULL, account TEXT NOT NULL, email TEXT NOT NULL, created_at INTEGER NOT NULL, enforce TEXT NOT NULL, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)';
82
+
83
+ export const CREATE_SERVICES_TABLES_SQL: readonly string[] = [
84
+ CREATE_NICKSERV_ACCOUNTS_SQL,
85
+ 'CREATE TABLE IF NOT EXISTS chanserv_channels (channel_key TEXT PRIMARY KEY, channel TEXT NOT NULL, founder TEXT NOT NULL, created_at INTEGER NOT NULL, mlock TEXT NOT NULL, restricted INTEGER NOT NULL, keep_topic INTEGER NOT NULL, topic_text TEXT, topic_setter TEXT, topic_set_at INTEGER)',
86
+ 'CREATE TABLE IF NOT EXISTS chanserv_access (channel_key TEXT NOT NULL, account_key TEXT NOT NULL, channel TEXT NOT NULL, account TEXT NOT NULL, level INTEGER NOT NULL, PRIMARY KEY (channel_key, account_key))',
87
+ 'CREATE TABLE IF NOT EXISTS chanserv_levels (channel_key TEXT NOT NULL, op TEXT NOT NULL, channel TEXT NOT NULL, level INTEGER NOT NULL, PRIMARY KEY (channel_key, op))',
88
+ 'CREATE TABLE IF NOT EXISTS hostserv_vhosts (account_key TEXT PRIMARY KEY, account TEXT NOT NULL, vhost TEXT NOT NULL)',
89
+ 'CREATE TABLE IF NOT EXISTS hostserv_pending_vhosts (account_key TEXT PRIMARY KEY, account TEXT NOT NULL, vhost TEXT NOT NULL)',
90
+ 'CREATE TABLE IF NOT EXISTS memoserv_memos (id INTEGER PRIMARY KEY, from_account TEXT NOT NULL, to_account TEXT NOT NULL, body TEXT NOT NULL, sent_at INTEGER NOT NULL, read INTEGER NOT NULL)',
91
+ 'CREATE TABLE IF NOT EXISTS read_markers (account_key TEXT NOT NULL, channel_key TEXT NOT NULL, account TEXT NOT NULL, channel TEXT NOT NULL, msgid TEXT NOT NULL, PRIMARY KEY (account_key, channel_key))',
92
+ 'CREATE TABLE IF NOT EXISTS operserv_akills (mask_key TEXT PRIMARY KEY, mask TEXT NOT NULL, reason TEXT NOT NULL, created_at INTEGER NOT NULL)',
93
+ 'CREATE TABLE IF NOT EXISTS operserv_jupes (name_key TEXT PRIMARY KEY, name TEXT NOT NULL, reason TEXT NOT NULL, created_at INTEGER NOT NULL)',
94
+ ];
95
+
96
+ /**
97
+ * D1-backed persistent services store. Extends the shared
98
+ * {@link PersistentServicesStore} write-behind base; the cache is hydrated
99
+ * from the snapshot loaded by {@link loadD1ServicesStore} at construction.
100
+ *
101
+ * `applyWrites` translates each {@link ServicesWrite} into one (or more)
102
+ * prepared statements and runs them in a single `db.batch()` round-trip
103
+ * per `flush()`.
104
+ */
105
+ export class D1ServicesStore extends PersistentServicesStore {
106
+ private readonly db: D1Database;
107
+
108
+ constructor(opts: { db: D1Database; clock?: Clock; snapshot?: ServicesSnapshot }) {
109
+ const superOpts: { clock?: Clock; snapshot?: ServicesSnapshot } = {};
110
+ if (opts.clock !== undefined) superOpts.clock = opts.clock;
111
+ if (opts.snapshot !== undefined) superOpts.snapshot = opts.snapshot;
112
+ super(superOpts);
113
+ this.db = opts.db;
114
+ }
115
+
116
+ protected async applyWrites(writes: ServicesWrite[]): Promise<void> {
117
+ const stmts: D1PreparedStatement[] = [];
118
+ for (const w of writes) {
119
+ for (const stmt of this.statementsFor(w)) stmts.push(stmt);
120
+ }
121
+ // The base class never calls applyWrites with an empty queue, and every
122
+ // ServicesWrite maps to ≥1 statement, so `stmts` is non-empty here.
123
+ await this.db.batch(stmts);
124
+ }
125
+
126
+ /**
127
+ * Maps a single {@link ServicesWrite} to its prepared statement(s).
128
+ * `deleteChannel` fans out into three deletes (channel + access +
129
+ * levels) so the backend wipes the channel's whole subtree in one
130
+ * batch.
131
+ */
132
+ private statementsFor(w: ServicesWrite): D1PreparedStatement[] {
133
+ switch (w.kind) {
134
+ case 'upsertNick': {
135
+ const r = w.row;
136
+ return [
137
+ this.db
138
+ .prepare(
139
+ 'INSERT OR REPLACE INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, algorithm, salt, hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
140
+ )
141
+ .bind(
142
+ fold(r.nick),
143
+ r.nick,
144
+ r.account,
145
+ r.email,
146
+ r.createdAt,
147
+ r.enforce,
148
+ r.credential.algorithm,
149
+ r.credential.salt,
150
+ r.credential.hash,
151
+ ),
152
+ ];
153
+ }
154
+ case 'deleteNick':
155
+ return [
156
+ this.db.prepare('DELETE FROM nickserv_accounts WHERE nick_key = ?').bind(fold(w.nick)),
157
+ ];
158
+ case 'upsertChannel': {
159
+ const r = w.row;
160
+ return [
161
+ this.db
162
+ .prepare(
163
+ 'INSERT OR REPLACE INTO chanserv_channels (channel_key, channel, founder, created_at, mlock, restricted, keep_topic, topic_text, topic_setter, topic_set_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
164
+ )
165
+ .bind(
166
+ fold(r.channel),
167
+ r.channel,
168
+ r.founder,
169
+ r.createdAt,
170
+ r.mlock,
171
+ r.restricted ? 1 : 0,
172
+ r.keepTopic ? 1 : 0,
173
+ r.topic?.text ?? null,
174
+ r.topic?.setter ?? null,
175
+ r.topic?.setAt ?? null,
176
+ ),
177
+ ];
178
+ }
179
+ case 'deleteChannel':
180
+ return [
181
+ this.db
182
+ .prepare('DELETE FROM chanserv_channels WHERE channel_key = ?')
183
+ .bind(fold(w.channel)),
184
+ this.db
185
+ .prepare('DELETE FROM chanserv_access WHERE channel_key = ?')
186
+ .bind(fold(w.channel)),
187
+ this.db
188
+ .prepare('DELETE FROM chanserv_levels WHERE channel_key = ?')
189
+ .bind(fold(w.channel)),
190
+ ];
191
+ case 'upsertChannelAccess': {
192
+ const r = w.row;
193
+ return [
194
+ this.db
195
+ .prepare(
196
+ 'INSERT OR REPLACE INTO chanserv_access (channel_key, account_key, channel, account, level) VALUES (?, ?, ?, ?, ?)',
197
+ )
198
+ .bind(fold(r.channel), fold(r.account), r.channel, r.account, r.level),
199
+ ];
200
+ }
201
+ case 'deleteChannelAccess':
202
+ return [
203
+ this.db
204
+ .prepare('DELETE FROM chanserv_access WHERE channel_key = ? AND account_key = ?')
205
+ .bind(fold(w.channel), fold(w.account)),
206
+ ];
207
+ case 'upsertChannelLevel': {
208
+ const r = w.row;
209
+ return [
210
+ this.db
211
+ .prepare(
212
+ 'INSERT OR REPLACE INTO chanserv_levels (channel_key, op, channel, level) VALUES (?, ?, ?, ?)',
213
+ )
214
+ .bind(fold(r.channel), r.op, r.channel, r.level),
215
+ ];
216
+ }
217
+ case 'resetChannelLevels':
218
+ return [
219
+ this.db
220
+ .prepare('DELETE FROM chanserv_levels WHERE channel_key = ?')
221
+ .bind(fold(w.channel)),
222
+ ];
223
+ case 'upsertVhost': {
224
+ const r = w.row;
225
+ return [
226
+ this.db
227
+ .prepare(
228
+ 'INSERT OR REPLACE INTO hostserv_vhosts (account_key, account, vhost) VALUES (?, ?, ?)',
229
+ )
230
+ .bind(fold(r.account), r.account, r.vhost),
231
+ ];
232
+ }
233
+ case 'deleteVhost':
234
+ return [
235
+ this.db
236
+ .prepare('DELETE FROM hostserv_vhosts WHERE account_key = ?')
237
+ .bind(fold(w.account)),
238
+ ];
239
+ case 'upsertPendingVhost': {
240
+ const r = w.row;
241
+ return [
242
+ this.db
243
+ .prepare(
244
+ 'INSERT OR REPLACE INTO hostserv_pending_vhosts (account_key, account, vhost) VALUES (?, ?, ?)',
245
+ )
246
+ .bind(fold(r.account), r.account, r.vhost),
247
+ ];
248
+ }
249
+ case 'deletePendingVhost':
250
+ return [
251
+ this.db
252
+ .prepare('DELETE FROM hostserv_pending_vhosts WHERE account_key = ?')
253
+ .bind(fold(w.account)),
254
+ ];
255
+ case 'insertMemo': {
256
+ const r = w.row;
257
+ return [
258
+ this.db
259
+ .prepare(
260
+ 'INSERT OR REPLACE INTO memoserv_memos (id, from_account, to_account, body, sent_at, read) VALUES (?, ?, ?, ?, ?, ?)',
261
+ )
262
+ .bind(r.id, r.from, r.to, r.body, r.sentAt, 0),
263
+ ];
264
+ }
265
+ case 'updateMemoRead':
266
+ return [this.db.prepare('UPDATE memoserv_memos SET read = 1 WHERE id = ?').bind(w.id)];
267
+ case 'deleteMemo':
268
+ return [this.db.prepare('DELETE FROM memoserv_memos WHERE id = ?').bind(w.id)];
269
+ case 'upsertReadMarker': {
270
+ const r = w.row;
271
+ return [
272
+ this.db
273
+ .prepare(
274
+ 'INSERT OR REPLACE INTO read_markers (account_key, channel_key, account, channel, msgid) VALUES (?, ?, ?, ?, ?)',
275
+ )
276
+ .bind(fold(r.account), fold(r.channel), r.account, r.channel, r.msgid),
277
+ ];
278
+ }
279
+ case 'upsertAkill': {
280
+ const r = w.row;
281
+ return [
282
+ this.db
283
+ .prepare(
284
+ 'INSERT OR REPLACE INTO operserv_akills (mask_key, mask, reason, created_at) VALUES (?, ?, ?, ?)',
285
+ )
286
+ .bind(fold(r.mask), r.mask, r.reason, r.createdAt),
287
+ ];
288
+ }
289
+ case 'deleteAkill':
290
+ return [
291
+ this.db.prepare('DELETE FROM operserv_akills WHERE mask_key = ?').bind(fold(w.mask)),
292
+ ];
293
+ case 'upsertJupe': {
294
+ const r = w.row;
295
+ return [
296
+ this.db
297
+ .prepare(
298
+ 'INSERT OR REPLACE INTO operserv_jupes (name_key, name, reason, created_at) VALUES (?, ?, ?, ?)',
299
+ )
300
+ .bind(fold(r.name), r.name, r.reason, r.createdAt),
301
+ ];
302
+ }
303
+ case 'deleteJupe':
304
+ return [
305
+ this.db.prepare('DELETE FROM operserv_jupes WHERE name_key = ?').bind(fold(w.name)),
306
+ ];
307
+ }
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Scans every services table into a {@link ServicesSnapshot}, tolerating a
313
+ * fresh deploy where the tables do not exist yet (each `SELECT` that
314
+ * throws is treated as an empty section). Returns the empty snapshot when
315
+ * the binding is present but unprovisioned so the caller can still mutate
316
+ * + flush. Callers should run {@link ensureServicesTables} first to
317
+ * self-provision the schema; this loader stays defensive regardless.
318
+ */
319
+ async function loadSnapshot(db: D1QueryClient): Promise<ServicesSnapshot> {
320
+ const [
321
+ nicks,
322
+ channels,
323
+ access,
324
+ levels,
325
+ vhosts,
326
+ pendingVhosts,
327
+ memos,
328
+ readMarkers,
329
+ akills,
330
+ jupes,
331
+ ] = await Promise.all([
332
+ loadNicks(db),
333
+ loadChannels(db),
334
+ loadAccess(db),
335
+ loadLevels(db),
336
+ loadVhosts(db),
337
+ loadPendingVhosts(db),
338
+ loadMemos(db),
339
+ loadReadMarkers(db),
340
+ loadAkills(db),
341
+ loadJupes(db),
342
+ ]);
343
+ return {
344
+ nicks,
345
+ channels,
346
+ access,
347
+ levels,
348
+ vhosts,
349
+ pendingVhosts,
350
+ memos,
351
+ readMarkers,
352
+ akills,
353
+ jupes,
354
+ };
355
+ }
356
+
357
+ async function loadNicks(db: D1QueryClient): Promise<ServicesNickRow[]> {
358
+ const rows = await safeSelect<{
359
+ nick: unknown;
360
+ account: unknown;
361
+ email: unknown;
362
+ created_at: unknown;
363
+ enforce: unknown;
364
+ algorithm: unknown;
365
+ salt: unknown;
366
+ hash: unknown;
367
+ }>(
368
+ db,
369
+ 'SELECT nick, account, email, created_at, enforce, algorithm, salt, hash FROM nickserv_accounts',
370
+ );
371
+ const out: ServicesNickRow[] = [];
372
+ for (const r of rows) {
373
+ if (
374
+ typeof r.nick !== 'string' ||
375
+ typeof r.account !== 'string' ||
376
+ typeof r.email !== 'string' ||
377
+ typeof r.created_at !== 'number' ||
378
+ typeof r.enforce !== 'string' ||
379
+ typeof r.algorithm !== 'string' ||
380
+ typeof r.salt !== 'string' ||
381
+ typeof r.hash !== 'string'
382
+ ) {
383
+ continue;
384
+ }
385
+ out.push({
386
+ nick: r.nick,
387
+ account: r.account,
388
+ email: r.email,
389
+ createdAt: r.created_at,
390
+ enforce: r.enforce as NickEnforcePolicy,
391
+ credential: {
392
+ account: r.account,
393
+ algorithm: 'scrypt',
394
+ salt: r.salt,
395
+ hash: r.hash,
396
+ },
397
+ });
398
+ }
399
+ return out;
400
+ }
401
+
402
+ async function loadChannels(db: D1QueryClient): Promise<RegisteredChannel[]> {
403
+ const rows = await safeSelect<{
404
+ channel: unknown;
405
+ founder: unknown;
406
+ created_at: unknown;
407
+ mlock: unknown;
408
+ restricted: unknown;
409
+ keep_topic: unknown;
410
+ topic_text: unknown;
411
+ topic_setter: unknown;
412
+ topic_set_at: unknown;
413
+ }>(
414
+ db,
415
+ 'SELECT channel, founder, created_at, mlock, restricted, keep_topic, topic_text, topic_setter, topic_set_at FROM chanserv_channels',
416
+ );
417
+ const out: RegisteredChannel[] = [];
418
+ for (const r of rows) {
419
+ if (
420
+ typeof r.channel !== 'string' ||
421
+ typeof r.founder !== 'string' ||
422
+ typeof r.created_at !== 'number' ||
423
+ typeof r.mlock !== 'string' ||
424
+ typeof r.restricted !== 'number' ||
425
+ typeof r.keep_topic !== 'number'
426
+ ) {
427
+ continue;
428
+ }
429
+ const channel: RegisteredChannel = {
430
+ channel: r.channel,
431
+ founder: r.founder,
432
+ createdAt: r.created_at,
433
+ mlock: r.mlock,
434
+ restricted: r.restricted !== 0,
435
+ keepTopic: r.keep_topic !== 0,
436
+ };
437
+ if (
438
+ typeof r.topic_text === 'string' &&
439
+ typeof r.topic_setter === 'string' &&
440
+ typeof r.topic_set_at === 'number'
441
+ ) {
442
+ const topic: PersistedTopic = {
443
+ text: r.topic_text,
444
+ setter: r.topic_setter,
445
+ setAt: r.topic_set_at,
446
+ };
447
+ channel.topic = topic;
448
+ }
449
+ out.push(channel);
450
+ }
451
+ return out;
452
+ }
453
+
454
+ async function loadAccess(db: D1QueryClient): Promise<ServicesAccessRow[]> {
455
+ const rows = await safeSelect<{ channel: unknown; account: unknown; level: unknown }>(
456
+ db,
457
+ 'SELECT channel, account, level FROM chanserv_access',
458
+ );
459
+ const out: ServicesAccessRow[] = [];
460
+ for (const r of rows) {
461
+ if (
462
+ typeof r.channel !== 'string' ||
463
+ typeof r.account !== 'string' ||
464
+ typeof r.level !== 'number'
465
+ )
466
+ continue;
467
+ out.push({ channel: r.channel, account: r.account, level: r.level as ChannelAccessLevel });
468
+ }
469
+ return out;
470
+ }
471
+
472
+ async function loadLevels(db: D1QueryClient): Promise<ServicesLevelRow[]> {
473
+ const rows = await safeSelect<{ channel: unknown; op: unknown; level: unknown }>(
474
+ db,
475
+ 'SELECT channel, op, level FROM chanserv_levels',
476
+ );
477
+ const out: ServicesLevelRow[] = [];
478
+ for (const r of rows) {
479
+ if (typeof r.channel !== 'string' || typeof r.op !== 'string' || typeof r.level !== 'number')
480
+ continue;
481
+ out.push({ channel: r.channel, op: r.op as ChannelLevelOp, level: r.level });
482
+ }
483
+ return out;
484
+ }
485
+
486
+ async function loadVhosts(db: D1QueryClient): Promise<ServicesVhostRow[]> {
487
+ const rows = await safeSelect<{ account: unknown; vhost: unknown }>(
488
+ db,
489
+ 'SELECT account, vhost FROM hostserv_vhosts',
490
+ );
491
+ const out: ServicesVhostRow[] = [];
492
+ for (const r of rows) {
493
+ if (typeof r.account !== 'string' || typeof r.vhost !== 'string') continue;
494
+ out.push({ account: r.account, vhost: r.vhost });
495
+ }
496
+ return out;
497
+ }
498
+
499
+ async function loadPendingVhosts(db: D1QueryClient): Promise<ServicesPendingVhostRow[]> {
500
+ const rows = await safeSelect<{ account: unknown; vhost: unknown }>(
501
+ db,
502
+ 'SELECT account, vhost FROM hostserv_pending_vhosts',
503
+ );
504
+ const out: ServicesPendingVhostRow[] = [];
505
+ for (const r of rows) {
506
+ if (typeof r.account !== 'string' || typeof r.vhost !== 'string') continue;
507
+ out.push({ account: r.account, vhost: r.vhost });
508
+ }
509
+ return out;
510
+ }
511
+
512
+ async function loadMemos(db: D1QueryClient): Promise<MemoEntry[]> {
513
+ const rows = await safeSelect<{
514
+ id: unknown;
515
+ from_account: unknown;
516
+ to_account: unknown;
517
+ body: unknown;
518
+ sent_at: unknown;
519
+ read: unknown;
520
+ }>(db, 'SELECT id, from_account, to_account, body, sent_at, read FROM memoserv_memos');
521
+ const out: MemoEntry[] = [];
522
+ for (const r of rows) {
523
+ if (
524
+ typeof r.id !== 'number' ||
525
+ typeof r.from_account !== 'string' ||
526
+ typeof r.to_account !== 'string' ||
527
+ typeof r.body !== 'string' ||
528
+ typeof r.sent_at !== 'number' ||
529
+ typeof r.read !== 'number'
530
+ ) {
531
+ continue;
532
+ }
533
+ out.push({
534
+ id: r.id,
535
+ from: r.from_account,
536
+ to: r.to_account,
537
+ body: r.body,
538
+ sentAt: r.sent_at,
539
+ read: r.read !== 0,
540
+ });
541
+ }
542
+ return out;
543
+ }
544
+
545
+ async function loadReadMarkers(
546
+ db: D1QueryClient,
547
+ ): Promise<{ account: string; channel: string; msgid: string }[]> {
548
+ const rows = await safeSelect<{ account: unknown; channel: unknown; msgid: unknown }>(
549
+ db,
550
+ 'SELECT account, channel, msgid FROM read_markers',
551
+ );
552
+ const out: { account: string; channel: string; msgid: string }[] = [];
553
+ for (const r of rows) {
554
+ if (
555
+ typeof r.account !== 'string' ||
556
+ typeof r.channel !== 'string' ||
557
+ typeof r.msgid !== 'string'
558
+ )
559
+ continue;
560
+ out.push({ account: r.account, channel: r.channel, msgid: r.msgid });
561
+ }
562
+ return out;
563
+ }
564
+
565
+ async function loadAkills(db: D1QueryClient): Promise<AkillEntry[]> {
566
+ const rows = await safeSelect<{ mask: unknown; reason: unknown; created_at: unknown }>(
567
+ db,
568
+ 'SELECT mask, reason, created_at FROM operserv_akills',
569
+ );
570
+ const out: AkillEntry[] = [];
571
+ for (const r of rows) {
572
+ if (
573
+ typeof r.mask !== 'string' ||
574
+ typeof r.reason !== 'string' ||
575
+ typeof r.created_at !== 'number'
576
+ )
577
+ continue;
578
+ out.push({ mask: r.mask, reason: r.reason, createdAt: r.created_at });
579
+ }
580
+ return out;
581
+ }
582
+
583
+ async function loadJupes(db: D1QueryClient): Promise<JupeEntry[]> {
584
+ const rows = await safeSelect<{ name: unknown; reason: unknown; created_at: unknown }>(
585
+ db,
586
+ 'SELECT name, reason, created_at FROM operserv_jupes',
587
+ );
588
+ const out: JupeEntry[] = [];
589
+ for (const r of rows) {
590
+ if (
591
+ typeof r.name !== 'string' ||
592
+ typeof r.reason !== 'string' ||
593
+ typeof r.created_at !== 'number'
594
+ )
595
+ continue;
596
+ out.push({ name: r.name, reason: r.reason, createdAt: r.created_at });
597
+ }
598
+ return out;
599
+ }
600
+
601
+ /**
602
+ * Runs `sql` against `db` and returns `result.results`, or `[]` when the
603
+ * table does not exist yet (fresh deploy) or the binding is unreachable.
604
+ * Silent-skip mirrors the DynamoDB loader: a missing table is treated as
605
+ * an empty section rather than a fatal boot error.
606
+ */
607
+ async function safeSelect<R>(db: D1QueryClient, sql: string): Promise<R[]> {
608
+ try {
609
+ const result = await db.prepare(sql).all<R>();
610
+ return result.results;
611
+ } catch (err) {
612
+ // A missing table on a fresh deploy legitimately yields no rows, but a
613
+ // real D1 error (transient unavailability, a lagging replica, a schema
614
+ // drift) would otherwise be masked as "empty section" — bricking
615
+ // NickServ for the whole connection (the snapshot is cached for life).
616
+ // Surface it so the failure shows up in `wrangler tail`.
617
+ console.error('[d1-services-store] snapshot read failed', { sql, error: String(err) });
618
+ return [];
619
+ }
620
+ }
621
+
622
+ /**
623
+ * Loads the full services snapshot from D1 and returns a hydrated
624
+ * {@link D1ServicesStore}. Returns `undefined` only when the D1 binding is
625
+ * absent (services not configured) — an empty binding still returns a
626
+ * working store so reducers can register state and persist it on the next
627
+ * `flush()`.
628
+ *
629
+ * @param db The D1 binding (`undefined` when D1 is not configured).
630
+ * @param clock Optional injected clock (tests pass a {@link FakeClock};
631
+ * production leaves it at {@link SystemClock}).
632
+ */
633
+ /**
634
+ * Idempotently creates the ten services tables so a fresh deploy
635
+ * self-provisions on first load without a manual DDL step. Every statement
636
+ * is `CREATE TABLE IF NOT EXISTS`, so a warm deploy re-runs the batch as a
637
+ * cheap no-op. Without this, `loadD1ServicesStore` loads an empty snapshot
638
+ * (the `SELECT`s hit `safeSelect`'s table-missing branch) and the first
639
+ * `flush()` throws on the missing table — registrations would appear to
640
+ * succeed (the NOTICE is emitted before flush) yet never persist, so a
641
+ * reconnecting client finds nothing in D1.
642
+ *
643
+ * Runs as one `db.batch()` round-trip (one D1 call per cold start / DO).
644
+ */
645
+ async function ensureServicesTables(db: D1QueryClient): Promise<void> {
646
+ await db.batch(CREATE_SERVICES_TABLES_SQL.map((sql) => db.prepare(sql)));
647
+ await migrateServicesSchema(db);
648
+ }
649
+
650
+ /**
651
+ * One-time schema migration for the `algorithm/salt/hash` unification
652
+ * (commit cfa5a3c). `CREATE TABLE IF NOT EXISTS` does NOT add columns to
653
+ * an existing table, so a D1 first provisioned by an older deploy keeps
654
+ * the original `nickserv_accounts (... enforce, password)` schema. Every
655
+ * current SELECT/INSERT references `algorithm`, so reads throw
656
+ * "no such column: algorithm" (masked by `safeSelect` as an empty
657
+ * snapshot) and writes throw inside `flush()` (after the success NOTICE).
658
+ *
659
+ * Detection: `PRAGMA table_info`. If `nickserv_accounts` lacks
660
+ * `algorithm`, it is the pre-unification schema. Those rows stored a
661
+ * non-scrypt `password` the current `verifyNick` cannot validate, so the
662
+ * table is dropped and recreated with the current schema (data loss is
663
+ * intentional and limited to already-unusable credentials). Idempotent:
664
+ * once the new schema is in place, `algorithm` is present and this is a
665
+ * no-op.
666
+ */
667
+ async function migrateServicesSchema(db: D1QueryClient): Promise<void> {
668
+ const nickservInfo = await db
669
+ .prepare('PRAGMA table_info(nickserv_accounts)')
670
+ .all<{ name: string }>();
671
+ const nickservCols = new Set(nickservInfo.results.map((r) => r.name));
672
+ if (nickservCols.has('algorithm')) return;
673
+ // Old schema detected (has `password`, lacks `algorithm`). The table is
674
+ // guaranteed to exist (ensureServicesTables just ran CREATE IF NOT
675
+ // EXISTS); recreate it with the current schema.
676
+ await db.batch([
677
+ db.prepare('DROP TABLE nickserv_accounts'),
678
+ db.prepare(CREATE_NICKSERV_ACCOUNTS_SQL),
679
+ ]);
680
+ }
681
+
682
+ export async function loadD1ServicesStore(
683
+ db: D1Database | undefined,
684
+ clock: Clock = SystemClock,
685
+ ): Promise<D1ServicesStore | undefined> {
686
+ if (db === undefined) return undefined;
687
+ // Read services state from the D1 PRIMARY. When read replication is
688
+ // enabled on the database, D1 may otherwise serve these SELECTs from a
689
+ // replica that is "arbitrarily out of date" (Cloudflare D1 consistency
690
+ // docs). A freshly-connecting client would then hydrate an empty/stale
691
+ // snapshot and cache it for the connection's whole life
692
+ // (`servicesResolved` guard in ConnectionDO) — so a NickServ IDENTIFY
693
+ // reports "not registered" even though the row exists in the primary.
694
+ // `first-primary` forces the first query to the primary; sequential
695
+ // consistency within the session keeps the rest of the load in sync.
696
+ // Writes (flush → `this.db.batch`) always target the primary regardless,
697
+ // so the store still holds the raw `db` for `applyWrites`.
698
+ // Safe per Cloudflare docs even when read replication is disabled.
699
+ const session = db.withSession('first-primary');
700
+ await ensureServicesTables(session);
701
+ const snapshot = await loadSnapshot(session);
702
+ return new D1ServicesStore({ db, clock, snapshot });
703
+ }