serverless-ircd 0.7.0 → 0.9.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 (168) hide show
  1. package/.github/workflows/ci.yml +7 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +518 -29
  4. package/README.md +221 -161
  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-tcp-container/src/container-server.ts +21 -1
  10. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  11. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  12. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  13. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  14. package/apps/cf-worker/package.json +1 -1
  15. package/apps/cf-worker/src/worker.ts +4 -4
  16. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  17. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  18. package/apps/cf-worker/wrangler.test.toml +6 -6
  19. package/apps/cf-worker/wrangler.toml +7 -5
  20. package/apps/local-cli/package.json +1 -1
  21. package/apps/local-cli/src/config-loader.ts +8 -0
  22. package/apps/local-cli/src/main.ts +16 -0
  23. package/apps/local-cli/src/server.ts +95 -31
  24. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  25. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  26. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  27. package/apps/local-cli/tests/rehash.test.ts +147 -0
  28. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  29. package/apps/local-cli/tests/tcp.test.ts +89 -0
  30. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  31. package/apps/web/landing/index.html +237 -16
  32. package/apps/web/package.json +3 -2
  33. package/apps/web/scripts/build.mjs +46 -16
  34. package/apps/web/src/build-env.ts +1 -1
  35. package/apps/web/src/config-schema.ts +6 -6
  36. package/apps/web/src/render-docs.ts +292 -0
  37. package/apps/web/tests/build-smoke.test.ts +43 -14
  38. package/apps/web/tests/config-schema.test.ts +1 -1
  39. package/apps/web/tests/landing-content.test.ts +103 -0
  40. package/apps/web/tests/render-docs.test.ts +198 -0
  41. package/docs/AWS-Adapter-Architecture.md +3 -2
  42. package/docs/AWS-Deployment.md +21 -8
  43. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  44. package/docs/PlanExtensions.md +113 -3
  45. package/docs/Release-Process.md +23 -13
  46. package/docs/Services.md +578 -0
  47. package/docs/WebClientGuide.md +32 -31
  48. package/package.json +2 -2
  49. package/packages/aws-adapter/package.json +1 -1
  50. package/packages/aws-adapter/src/aws-runtime.ts +20 -1
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  52. package/packages/aws-adapter/src/config-loader.ts +11 -0
  53. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  54. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  56. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  57. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
  59. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  60. package/packages/aws-adapter/src/index.ts +4 -0
  61. package/packages/aws-adapter/src/stats.ts +6 -1
  62. package/packages/aws-adapter/src/tables.ts +34 -4
  63. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  65. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  66. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  67. package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
  68. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  69. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  70. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  72. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  73. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  74. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  76. package/packages/cf-adapter/package.json +1 -1
  77. package/packages/cf-adapter/src/config-loader.ts +11 -0
  78. package/packages/cf-adapter/src/connection-do.ts +130 -8
  79. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  80. package/packages/cf-adapter/src/env.ts +8 -0
  81. package/packages/cf-adapter/src/index.ts +5 -0
  82. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  83. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  84. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  85. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  86. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  87. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  88. package/packages/in-memory-runtime/package.json +1 -1
  89. package/packages/irc-core/package.json +1 -1
  90. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  91. package/packages/irc-core/src/commands/account-auth.ts +200 -0
  92. package/packages/irc-core/src/commands/chanserv.ts +1166 -0
  93. package/packages/irc-core/src/commands/hostserv.ts +522 -0
  94. package/packages/irc-core/src/commands/index.ts +13 -0
  95. package/packages/irc-core/src/commands/join.ts +171 -9
  96. package/packages/irc-core/src/commands/markread.ts +202 -0
  97. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  98. package/packages/irc-core/src/commands/mode.ts +96 -4
  99. package/packages/irc-core/src/commands/nickserv.ts +402 -0
  100. package/packages/irc-core/src/commands/oper.ts +18 -1
  101. package/packages/irc-core/src/commands/operserv.ts +346 -0
  102. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  103. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  104. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  105. package/packages/irc-core/src/commands/registration.ts +78 -12
  106. package/packages/irc-core/src/commands/sasl.ts +18 -49
  107. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  108. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  109. package/packages/irc-core/src/commands/topic.ts +60 -10
  110. package/packages/irc-core/src/config.ts +36 -5
  111. package/packages/irc-core/src/effects.ts +56 -1
  112. package/packages/irc-core/src/ports.ts +1653 -84
  113. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  114. package/packages/irc-core/src/state/channel.ts +38 -1
  115. package/packages/irc-core/src/state/connection.ts +25 -1
  116. package/packages/irc-core/src/types.ts +48 -12
  117. package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
  118. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  119. package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
  120. package/packages/irc-core/tests/commands/join.test.ts +572 -1
  121. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  122. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  123. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  124. package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
  125. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  126. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  127. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  128. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  129. package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
  130. package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
  131. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  132. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  133. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  134. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  135. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  136. package/packages/irc-core/tests/config.test.ts +49 -5
  137. package/packages/irc-core/tests/effects.test.ts +19 -0
  138. package/packages/irc-core/tests/message-store.test.ts +63 -0
  139. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  140. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  141. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  142. package/packages/irc-server/package.json +1 -1
  143. package/packages/irc-server/src/actor.ts +121 -16
  144. package/packages/irc-server/src/dispatch.ts +94 -7
  145. package/packages/irc-server/src/routing.ts +19 -0
  146. package/packages/irc-server/tests/actor.test.ts +988 -15
  147. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  148. package/packages/irc-server/tests/routing.test.ts +6 -0
  149. package/packages/irc-test-support/package.json +1 -1
  150. package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
  151. package/packages/irc-test-support/src/index.ts +1 -0
  152. package/packages/irc-test-support/src/scenarios.ts +21 -6
  153. package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
  154. package/packages/irc-test-support/vitest.config.ts +6 -1
  155. package/tools/ci-hardening/package.json +1 -1
  156. package/tools/load-test/package.json +1 -1
  157. package/tools/load-test/src/client.ts +13 -13
  158. package/tools/load-test/tests/client.test.ts +258 -2
  159. package/tools/load-test/tests/config.test.ts +39 -0
  160. package/tools/load-test/tests/harness.test.ts +21 -0
  161. package/tools/load-test/tests/metrics.test.ts +7 -0
  162. package/tools/tcp-ws-forwarder/package.json +1 -1
  163. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  164. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  165. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +53 -2
  166. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  167. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  168. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -0,0 +1,649 @@
1
+ /**
2
+ * DynamoDB-backed {@link PersistentServicesStore} for the AWS adapter.
3
+ *
4
+ * All nine services sections share a single `Services` table via a
5
+ * single-table key scheme:
6
+ *
7
+ * - NickServ: `NICK:<fold>` / `#`
8
+ * - ChanServ channel meta: `CHAN:<fold>` / `#META`
9
+ * - ChanServ access: `CHAN:<fold>` / `ACC:<foldAcct>` (same partition as the channel)
10
+ * - ChanServ levels: `CHAN:<fold>` / `LVL:<op>` (same partition as the channel)
11
+ * - HostServ: `VH:<fold>` / `#`
12
+ * - HostServ pending: `PV:<fold>` / `#`
13
+ * - MemoServ: `MEMO:<id>` / `#` (id is globally monotonic)
14
+ * - read-marker:`RM:<foldAcct>` / `<foldChan>`
15
+ * - OperServ AKILL: `AK:<foldMask>` / `#`
16
+ * - OperServ JUPE: `JP:<foldName>` / `#`
17
+ *
18
+ * Co-locating a channel's meta + access + levels on one partition lets
19
+ * `deleteChannel` / `resetChannelLevels` fan out as a single partition
20
+ * `Query` + `BatchWriteItem` instead of a per-row scan.
21
+ *
22
+ * Each item carries a `type` discriminator so the boot `Scan` can dispatch
23
+ * a row to the right snapshot section without parsing the key. Display
24
+ * spelling is preserved alongside the folded key so the wire renders the
25
+ * user's preferred casing after a reload.
26
+ */
27
+
28
+ import {
29
+ BatchWriteCommand,
30
+ type BatchWriteCommandInput,
31
+ QueryCommand,
32
+ ScanCommand,
33
+ UpdateCommand,
34
+ } from '@aws-sdk/lib-dynamodb';
35
+ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
36
+ import {
37
+ type AkillEntry,
38
+ type ChannelAccessLevel,
39
+ type ChannelLevelOp,
40
+ type Clock,
41
+ type JupeEntry,
42
+ type MemoEntry,
43
+ type NickEnforcePolicy,
44
+ type PersistedTopic,
45
+ PersistentServicesStore,
46
+ type RegisteredChannel,
47
+ type ServicesAccessRow,
48
+ type ServicesLevelRow,
49
+ type ServicesNickRow,
50
+ type ServicesPendingVhostRow,
51
+ type ServicesSnapshot,
52
+ type ServicesVhostRow,
53
+ type ServicesWrite,
54
+ SystemClock,
55
+ caseFold,
56
+ } from '@serverless-ircd/irc-core';
57
+
58
+ /** rfc1459 case-fold used for every storage key. */
59
+ function fold(value: string): string {
60
+ return caseFold('rfc1459', value);
61
+ }
62
+
63
+ const SINGLE_SK = '#';
64
+ const CHANNEL_META_SK = '#META';
65
+ const ACCESS_SK_PREFIX = 'ACC:';
66
+ const LEVEL_SK_PREFIX = 'LVL:';
67
+
68
+ /** Partition-key builders per section. */
69
+ const nickPk = (nick: string): string => `NICK:${fold(nick)}`;
70
+ const channelPk = (channel: string): string => `CHAN:${fold(channel)}`;
71
+ const vhostPk = (account: string): string => `VH:${fold(account)}`;
72
+ const pendingVhostPk = (account: string): string => `PV:${fold(account)}`;
73
+ const memoPk = (id: number): string => `MEMO:${id}`;
74
+ const readMarkerPk = (account: string): string => `RM:${fold(account)}`;
75
+ const akillPk = (mask: string): string => `AK:${fold(mask)}`;
76
+ const jupePk = (name: string): string => `JP:${fold(name)}`;
77
+
78
+ /**
79
+ * DynamoDB-backed persistent services store. Extends the shared
80
+ * {@link PersistentServicesStore} write-behind base; the cache is hydrated
81
+ * from the snapshot loaded by {@link loadDynamoServicesStore} at
82
+ * construction.
83
+ *
84
+ * `applyWrites` translates each {@link ServicesWrite} into one (or more)
85
+ * `PutRequest` / `DeleteRequest` entries and runs them through
86
+ * `BatchWriteItem` (25 per request). `deleteChannel` / `resetChannelLevels`
87
+ * run a partition `Query` first to collect the keys to delete.
88
+ * `updateMemoRead` is a single-item `UpdateCommand` (it does not carry the
89
+ * full memo row, only the id).
90
+ */
91
+ export class DynamoServicesStore extends PersistentServicesStore {
92
+ private readonly docClient: DynamoDBDocumentClient;
93
+ private readonly tableName: string;
94
+
95
+ constructor(opts: {
96
+ docClient: DynamoDBDocumentClient;
97
+ tableName: string;
98
+ clock?: Clock;
99
+ snapshot?: ServicesSnapshot;
100
+ }) {
101
+ const superOpts: { clock?: Clock; snapshot?: ServicesSnapshot } = {};
102
+ if (opts.clock !== undefined) superOpts.clock = opts.clock;
103
+ if (opts.snapshot !== undefined) superOpts.snapshot = opts.snapshot;
104
+ super(superOpts);
105
+ this.docClient = opts.docClient;
106
+ this.tableName = opts.tableName;
107
+ }
108
+
109
+ protected async applyWrites(writes: ServicesWrite[]): Promise<void> {
110
+ const batch: Array<
111
+ | { PutRequest: { Item: Record<string, unknown> } }
112
+ | { DeleteRequest: { Key: Record<string, unknown> } }
113
+ > = [];
114
+ for (const w of writes) {
115
+ switch (w.kind) {
116
+ case 'upsertNick':
117
+ batch.push({ PutRequest: { Item: this.nickItem(w.row) } });
118
+ break;
119
+ case 'deleteNick':
120
+ batch.push({ DeleteRequest: { Key: { pk: nickPk(w.nick), sk: SINGLE_SK } } });
121
+ break;
122
+ case 'upsertChannel':
123
+ batch.push({ PutRequest: { Item: this.channelItem(w.row) } });
124
+ break;
125
+ case 'deleteChannel': {
126
+ const keys = await this.queryChannelKeys(w.channel, undefined);
127
+ for (const key of keys) batch.push({ DeleteRequest: { Key: key } });
128
+ break;
129
+ }
130
+ case 'upsertChannelAccess':
131
+ batch.push({ PutRequest: { Item: this.accessItem(w.row) } });
132
+ break;
133
+ case 'deleteChannelAccess':
134
+ batch.push({
135
+ DeleteRequest: {
136
+ Key: { pk: channelPk(w.channel), sk: `${ACCESS_SK_PREFIX}${fold(w.account)}` },
137
+ },
138
+ });
139
+ break;
140
+ case 'upsertChannelLevel':
141
+ batch.push({ PutRequest: { Item: this.levelItem(w.row) } });
142
+ break;
143
+ case 'resetChannelLevels': {
144
+ const keys = await this.queryChannelKeys(w.channel, LEVEL_SK_PREFIX);
145
+ for (const key of keys) batch.push({ DeleteRequest: { Key: key } });
146
+ break;
147
+ }
148
+ case 'upsertVhost':
149
+ batch.push({ PutRequest: { Item: this.vhostItem(w.row) } });
150
+ break;
151
+ case 'deleteVhost':
152
+ batch.push({ DeleteRequest: { Key: { pk: vhostPk(w.account), sk: SINGLE_SK } } });
153
+ break;
154
+ case 'upsertPendingVhost':
155
+ batch.push({ PutRequest: { Item: this.pendingVhostItem(w.row) } });
156
+ break;
157
+ case 'deletePendingVhost':
158
+ batch.push({ DeleteRequest: { Key: { pk: pendingVhostPk(w.account), sk: SINGLE_SK } } });
159
+ break;
160
+ case 'insertMemo':
161
+ batch.push({ PutRequest: { Item: this.memoItem(w.row) } });
162
+ break;
163
+ case 'updateMemoRead':
164
+ await this.updateMemoRead(w.id);
165
+ break;
166
+ case 'deleteMemo':
167
+ batch.push({ DeleteRequest: { Key: { pk: memoPk(w.id), sk: SINGLE_SK } } });
168
+ break;
169
+ case 'upsertReadMarker':
170
+ batch.push({ PutRequest: { Item: this.readMarkerItem(w.row) } });
171
+ break;
172
+ case 'upsertAkill':
173
+ batch.push({ PutRequest: { Item: this.akillItem(w.row) } });
174
+ break;
175
+ case 'deleteAkill':
176
+ batch.push({ DeleteRequest: { Key: { pk: akillPk(w.mask), sk: SINGLE_SK } } });
177
+ break;
178
+ case 'upsertJupe':
179
+ batch.push({ PutRequest: { Item: this.jupeItem(w.row) } });
180
+ break;
181
+ case 'deleteJupe':
182
+ batch.push({ DeleteRequest: { Key: { pk: jupePk(w.name), sk: SINGLE_SK } } });
183
+ break;
184
+ }
185
+ }
186
+ await this.batchWrite(batch);
187
+ }
188
+
189
+ // ----- item builders -----
190
+
191
+ private nickItem(row: ServicesNickRow): Record<string, unknown> {
192
+ return {
193
+ pk: nickPk(row.nick),
194
+ sk: SINGLE_SK,
195
+ type: 'nick',
196
+ nick: row.nick,
197
+ account: row.account,
198
+ email: row.email,
199
+ createdAt: row.createdAt,
200
+ enforce: row.enforce,
201
+ algorithm: row.credential.algorithm,
202
+ salt: row.credential.salt,
203
+ hash: row.credential.hash,
204
+ };
205
+ }
206
+
207
+ private channelItem(row: RegisteredChannel): Record<string, unknown> {
208
+ const item: Record<string, unknown> = {
209
+ pk: channelPk(row.channel),
210
+ sk: CHANNEL_META_SK,
211
+ type: 'channel',
212
+ channel: row.channel,
213
+ founder: row.founder,
214
+ createdAt: row.createdAt,
215
+ mlock: row.mlock,
216
+ restricted: row.restricted,
217
+ keepTopic: row.keepTopic,
218
+ };
219
+ if (row.topic !== undefined) {
220
+ item.topicText = row.topic.text;
221
+ item.topicSetter = row.topic.setter;
222
+ item.topicSetAt = row.topic.setAt;
223
+ }
224
+ return item;
225
+ }
226
+
227
+ private accessItem(row: ServicesAccessRow): Record<string, unknown> {
228
+ return {
229
+ pk: channelPk(row.channel),
230
+ sk: `${ACCESS_SK_PREFIX}${fold(row.account)}`,
231
+ type: 'chanaccess',
232
+ channel: row.channel,
233
+ account: row.account,
234
+ level: row.level,
235
+ };
236
+ }
237
+
238
+ private levelItem(row: ServicesLevelRow): Record<string, unknown> {
239
+ return {
240
+ pk: channelPk(row.channel),
241
+ sk: `${LEVEL_SK_PREFIX}${row.op}`,
242
+ type: 'chanlevel',
243
+ channel: row.channel,
244
+ op: row.op,
245
+ level: row.level,
246
+ };
247
+ }
248
+
249
+ private vhostItem(row: ServicesVhostRow): Record<string, unknown> {
250
+ return {
251
+ pk: vhostPk(row.account),
252
+ sk: SINGLE_SK,
253
+ type: 'vhost',
254
+ account: row.account,
255
+ vhost: row.vhost,
256
+ };
257
+ }
258
+
259
+ private pendingVhostItem(row: ServicesPendingVhostRow): Record<string, unknown> {
260
+ return {
261
+ pk: pendingVhostPk(row.account),
262
+ sk: SINGLE_SK,
263
+ type: 'pendingvhost',
264
+ account: row.account,
265
+ vhost: row.vhost,
266
+ };
267
+ }
268
+
269
+ private memoItem(row: MemoEntry): Record<string, unknown> {
270
+ return {
271
+ pk: memoPk(row.id),
272
+ sk: SINGLE_SK,
273
+ type: 'memo',
274
+ id: row.id,
275
+ from: row.from,
276
+ to: row.to,
277
+ body: row.body,
278
+ sentAt: row.sentAt,
279
+ read: row.read,
280
+ };
281
+ }
282
+
283
+ private readMarkerItem(row: { account: string; channel: string; msgid: string }): Record<
284
+ string,
285
+ unknown
286
+ > {
287
+ return {
288
+ pk: readMarkerPk(row.account),
289
+ sk: fold(row.channel),
290
+ type: 'readmarker',
291
+ account: row.account,
292
+ channel: row.channel,
293
+ msgid: row.msgid,
294
+ };
295
+ }
296
+
297
+ private akillItem(row: AkillEntry): Record<string, unknown> {
298
+ return {
299
+ pk: akillPk(row.mask),
300
+ sk: SINGLE_SK,
301
+ type: 'akill',
302
+ mask: row.mask,
303
+ reason: row.reason,
304
+ createdAt: row.createdAt,
305
+ };
306
+ }
307
+
308
+ private jupeItem(row: JupeEntry): Record<string, unknown> {
309
+ return {
310
+ pk: jupePk(row.name),
311
+ sk: SINGLE_SK,
312
+ type: 'jupe',
313
+ name: row.name,
314
+ reason: row.reason,
315
+ createdAt: row.createdAt,
316
+ };
317
+ }
318
+
319
+ // ----- write helpers -----
320
+
321
+ /**
322
+ * Queries the channel partition (`CHAN:<fold>`). When `skPrefix` is
323
+ * supplied, restricts to sort keys beginning with it (used by
324
+ * `resetChannelLevels` → `LVL:`). Returns the `(pk, sk)` pairs so the
325
+ * caller can build `DeleteRequest` entries without re-deriving the keys.
326
+ */
327
+ private async queryChannelKeys(
328
+ channel: string,
329
+ skPrefix: string | undefined,
330
+ ): Promise<Array<{ pk: string; sk: string }>> {
331
+ const pk = channelPk(channel);
332
+ const names = { '#pk': 'pk', '#sk': 'sk' };
333
+ const cmd =
334
+ skPrefix === undefined
335
+ ? new QueryCommand({
336
+ TableName: this.tableName,
337
+ KeyConditionExpression: '#pk = :pk',
338
+ ExpressionAttributeNames: names,
339
+ ExpressionAttributeValues: { ':pk': pk },
340
+ ProjectionExpression: '#pk, #sk',
341
+ })
342
+ : new QueryCommand({
343
+ TableName: this.tableName,
344
+ KeyConditionExpression: '#pk = :pk AND begins_with(#sk, :prefix)',
345
+ ExpressionAttributeNames: names,
346
+ ExpressionAttributeValues: { ':pk': pk, ':prefix': skPrefix },
347
+ ProjectionExpression: '#pk, #sk',
348
+ });
349
+ const out: Array<{ pk: string; sk: string }> = [];
350
+ const result = await this.docClient.send(cmd);
351
+ for (const item of result.Items ?? []) {
352
+ const p = item.pk;
353
+ const s = item.sk;
354
+ if (typeof p === 'string' && typeof s === 'string') out.push({ pk: p, sk: s });
355
+ }
356
+ return out;
357
+ }
358
+
359
+ private async updateMemoRead(id: number): Promise<void> {
360
+ await this.docClient.send(
361
+ new UpdateCommand({
362
+ TableName: this.tableName,
363
+ Key: { pk: memoPk(id), sk: SINGLE_SK },
364
+ UpdateExpression: 'SET #r = :r',
365
+ ExpressionAttributeNames: { '#r': 'read' },
366
+ ExpressionAttributeValues: { ':r': true },
367
+ }),
368
+ );
369
+ }
370
+
371
+ /**
372
+ * Drains the accumulated put/delete requests through `BatchWriteItem`
373
+ * (25 items per request, the DynamoDB per-batch cap). No-op when the
374
+ * flush produced only `updateMemoRead` ops (handled inline above).
375
+ */
376
+ private async batchWrite(
377
+ requests: Array<
378
+ | { PutRequest: { Item: Record<string, unknown> } }
379
+ | { DeleteRequest: { Key: Record<string, unknown> } }
380
+ >,
381
+ ): Promise<void> {
382
+ for (let i = 0; i < requests.length; i += 25) {
383
+ const chunk = requests.slice(i, i + 25);
384
+ const input: BatchWriteCommandInput = {
385
+ RequestItems: { [this.tableName]: chunk },
386
+ };
387
+ await this.docClient.send(new BatchWriteCommand(input));
388
+ }
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Scans the `Services` table into a {@link ServicesSnapshot}, paginating
394
+ * through `LastEvaluatedKey` so tables larger than the 1 MB scan page are
395
+ * fully loaded. Items with an unknown `type` or missing/ill-typed fields
396
+ * are silently skipped (defensive against schema drift).
397
+ */
398
+ async function scanSnapshot(
399
+ docClient: DynamoDBDocumentClient,
400
+ tableName: string,
401
+ ): Promise<ServicesSnapshot> {
402
+ const snap: ServicesSnapshot = {
403
+ nicks: [],
404
+ channels: [],
405
+ access: [],
406
+ levels: [],
407
+ vhosts: [],
408
+ pendingVhosts: [],
409
+ memos: [],
410
+ readMarkers: [],
411
+ akills: [],
412
+ jupes: [],
413
+ };
414
+ let exclusiveStartKey: Record<string, unknown> | undefined;
415
+ do {
416
+ const result = await docClient.send(
417
+ new ScanCommand({ TableName: tableName, ExclusiveStartKey: exclusiveStartKey }),
418
+ );
419
+ for (const item of result.Items ?? []) {
420
+ dispatchItem(item, snap);
421
+ }
422
+ exclusiveStartKey = result.LastEvaluatedKey;
423
+ } while (exclusiveStartKey !== undefined);
424
+ return snap;
425
+ }
426
+
427
+ /**
428
+ * Routes a scanned item into the right snapshot section based on its
429
+ * `type` discriminator, coercing fields defensively. Mutates `snap` in
430
+ * place; unknown types are skipped.
431
+ */
432
+ function dispatchItem(item: Record<string, unknown>, snap: ServicesSnapshot): void {
433
+ const type = item.type;
434
+ if (typeof type !== 'string') return;
435
+ switch (type) {
436
+ case 'nick': {
437
+ const row = coerceNick(item);
438
+ if (row !== undefined) snap.nicks.push(row);
439
+ return;
440
+ }
441
+ case 'channel': {
442
+ const row = coerceChannel(item);
443
+ if (row !== undefined) snap.channels.push(row);
444
+ return;
445
+ }
446
+ case 'chanaccess': {
447
+ if (
448
+ typeof item.channel === 'string' &&
449
+ typeof item.account === 'string' &&
450
+ typeof item.level === 'number'
451
+ ) {
452
+ snap.access.push({
453
+ channel: item.channel,
454
+ account: item.account,
455
+ level: item.level as ChannelAccessLevel,
456
+ });
457
+ }
458
+ return;
459
+ }
460
+ case 'chanlevel': {
461
+ if (
462
+ typeof item.channel === 'string' &&
463
+ typeof item.op === 'string' &&
464
+ typeof item.level === 'number'
465
+ ) {
466
+ snap.levels.push({
467
+ channel: item.channel,
468
+ op: item.op as ChannelLevelOp,
469
+ level: item.level,
470
+ });
471
+ }
472
+ return;
473
+ }
474
+ case 'vhost': {
475
+ if (typeof item.account === 'string' && typeof item.vhost === 'string') {
476
+ snap.vhosts.push({ account: item.account, vhost: item.vhost });
477
+ }
478
+ return;
479
+ }
480
+ case 'pendingvhost': {
481
+ if (typeof item.account === 'string' && typeof item.vhost === 'string') {
482
+ snap.pendingVhosts.push({ account: item.account, vhost: item.vhost });
483
+ }
484
+ return;
485
+ }
486
+ case 'memo': {
487
+ const memo = coerceMemo(item);
488
+ if (memo !== undefined) snap.memos.push(memo);
489
+ return;
490
+ }
491
+ case 'readmarker': {
492
+ if (
493
+ typeof item.account === 'string' &&
494
+ typeof item.channel === 'string' &&
495
+ typeof item.msgid === 'string'
496
+ ) {
497
+ snap.readMarkers.push({ account: item.account, channel: item.channel, msgid: item.msgid });
498
+ }
499
+ return;
500
+ }
501
+ case 'akill': {
502
+ if (
503
+ typeof item.mask === 'string' &&
504
+ typeof item.reason === 'string' &&
505
+ typeof item.createdAt === 'number'
506
+ ) {
507
+ snap.akills.push({ mask: item.mask, reason: item.reason, createdAt: item.createdAt });
508
+ }
509
+ return;
510
+ }
511
+ case 'jupe': {
512
+ if (
513
+ typeof item.name === 'string' &&
514
+ typeof item.reason === 'string' &&
515
+ typeof item.createdAt === 'number'
516
+ ) {
517
+ snap.jupes.push({ name: item.name, reason: item.reason, createdAt: item.createdAt });
518
+ }
519
+ return;
520
+ }
521
+ default:
522
+ return;
523
+ }
524
+ }
525
+
526
+ function coerceNick(item: Record<string, unknown>): ServicesNickRow | undefined {
527
+ if (
528
+ typeof item.nick !== 'string' ||
529
+ typeof item.account !== 'string' ||
530
+ typeof item.email !== 'string' ||
531
+ typeof item.createdAt !== 'number' ||
532
+ typeof item.enforce !== 'string' ||
533
+ typeof item.algorithm !== 'string' ||
534
+ typeof item.salt !== 'string' ||
535
+ typeof item.hash !== 'string'
536
+ ) {
537
+ return undefined;
538
+ }
539
+ return {
540
+ nick: item.nick,
541
+ account: item.account,
542
+ email: item.email,
543
+ createdAt: item.createdAt,
544
+ enforce: item.enforce as NickEnforcePolicy,
545
+ credential: {
546
+ account: item.account,
547
+ algorithm: 'scrypt',
548
+ salt: item.salt,
549
+ hash: item.hash,
550
+ },
551
+ };
552
+ }
553
+
554
+ function coerceChannel(item: Record<string, unknown>): RegisteredChannel | undefined {
555
+ if (
556
+ typeof item.channel !== 'string' ||
557
+ typeof item.founder !== 'string' ||
558
+ typeof item.createdAt !== 'number' ||
559
+ typeof item.mlock !== 'string' ||
560
+ typeof item.restricted !== 'boolean' ||
561
+ typeof item.keepTopic !== 'boolean'
562
+ ) {
563
+ return undefined;
564
+ }
565
+ const channel: RegisteredChannel = {
566
+ channel: item.channel,
567
+ founder: item.founder,
568
+ createdAt: item.createdAt,
569
+ mlock: item.mlock,
570
+ restricted: item.restricted,
571
+ keepTopic: item.keepTopic,
572
+ };
573
+ if (
574
+ typeof item.topicText === 'string' &&
575
+ typeof item.topicSetter === 'string' &&
576
+ typeof item.topicSetAt === 'number'
577
+ ) {
578
+ const topic: PersistedTopic = {
579
+ text: item.topicText,
580
+ setter: item.topicSetter,
581
+ setAt: item.topicSetAt,
582
+ };
583
+ channel.topic = topic;
584
+ }
585
+ return channel;
586
+ }
587
+
588
+ function coerceMemo(item: Record<string, unknown>): MemoEntry | undefined {
589
+ if (
590
+ typeof item.id !== 'number' ||
591
+ typeof item.from !== 'string' ||
592
+ typeof item.to !== 'string' ||
593
+ typeof item.body !== 'string' ||
594
+ typeof item.sentAt !== 'number' ||
595
+ typeof item.read !== 'boolean'
596
+ ) {
597
+ return undefined;
598
+ }
599
+ return {
600
+ id: item.id,
601
+ from: item.from,
602
+ to: item.to,
603
+ body: item.body,
604
+ sentAt: item.sentAt,
605
+ read: item.read,
606
+ };
607
+ }
608
+
609
+ /**
610
+ * Loads the full services snapshot from the `Services` table and returns a
611
+ * hydrated {@link DynamoServicesStore}. Returns `undefined` when the
612
+ * physical table name is absent (services not configured for this deploy).
613
+ *
614
+ * Scan failures are swallowed into an empty snapshot so the store can still
615
+ * mutate + flush — the deployer is responsible for provisioning the table
616
+ * (via {@link TABLE_DEFS}); a flush against a missing table surfaces the
617
+ * DynamoDB error to the caller.
618
+ *
619
+ * @param docClient DynamoDB client addressed at the deployment's tables.
620
+ * @param tableName Physical name of the `Services` table (`undefined` when
621
+ * `SERVICES_TABLE` is unset → services unbound).
622
+ * @param clock Optional injected clock (tests pass a `FakeClock`;
623
+ * production leaves it at {@link SystemClock}).
624
+ */
625
+ export async function loadDynamoServicesStore(
626
+ docClient: DynamoDBDocumentClient,
627
+ tableName: string | undefined,
628
+ clock: Clock = SystemClock,
629
+ ): Promise<DynamoServicesStore | undefined> {
630
+ if (tableName === undefined) return undefined;
631
+ let snapshot: ServicesSnapshot;
632
+ try {
633
+ snapshot = await scanSnapshot(docClient, tableName);
634
+ } catch {
635
+ snapshot = {
636
+ nicks: [],
637
+ channels: [],
638
+ access: [],
639
+ levels: [],
640
+ vhosts: [],
641
+ pendingVhosts: [],
642
+ memos: [],
643
+ readMarkers: [],
644
+ akills: [],
645
+ jupes: [],
646
+ };
647
+ }
648
+ return new DynamoServicesStore({ docClient, tableName, clock, snapshot });
649
+ }