@frockbot/cloudflare 0.0.0 → 0.7.292

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 (88) hide show
  1. package/build-artifact.ts +130 -0
  2. package/build-flutter-web.ts +508 -0
  3. package/deployment-config/README.md +414 -0
  4. package/deployment-config/cli.ts +228 -0
  5. package/deployment-config/generate.ts +689 -0
  6. package/deployment-config/jsonc.ts +18 -0
  7. package/deployment-config/profile-schema.generated.ts +391 -0
  8. package/deployment-config/profile.schema.json +358 -0
  9. package/deployment-config/profile.ts +124 -0
  10. package/migrations/0001_better_auth.sql +15 -0
  11. package/migrations/0002_drop_account_issuer.sql +8 -0
  12. package/package.json +61 -5
  13. package/release-version.ts +20 -0
  14. package/src/account-admission.ts +179 -0
  15. package/src/account-deletion.ts +274 -0
  16. package/src/admin-entrypoint.ts +194 -0
  17. package/src/admin-identities.ts +26 -0
  18. package/src/audit.ts +103 -0
  19. package/src/auth-package.access.ts +24 -0
  20. package/src/auth-package.ts +36 -0
  21. package/src/avatar-state-cleanup.ts +107 -0
  22. package/src/billing-computer.ts +267 -0
  23. package/src/billing-readiness.ts +21 -0
  24. package/src/billing.ts +461 -0
  25. package/src/bot-capabilities.ts +480 -0
  26. package/src/bot-recovery.ts +1 -0
  27. package/src/bot-state-channel.ts +702 -0
  28. package/src/bot-state.ts +3995 -0
  29. package/src/bot-template-cleanup.ts +98 -0
  30. package/src/bot-title-cleanup.ts +104 -0
  31. package/src/brand-icon.png +0 -0
  32. package/src/brand-logo.ts +2 -0
  33. package/src/brand.ts +36 -0
  34. package/src/client-compatibility.ts +51 -0
  35. package/src/compaction-announcement-cleanup.ts +113 -0
  36. package/src/computer-egress.ts +110 -0
  37. package/src/computer-host.ts +92 -0
  38. package/src/computer-screenshot-cleanup.ts +78 -0
  39. package/src/contracts.ts +832 -0
  40. package/src/debug.ts +272 -0
  41. package/src/default-packages-marker-cleanup.ts +39 -0
  42. package/src/deployment-policy-admin-host.ts +121 -0
  43. package/src/deployment-policy.ts +426 -0
  44. package/src/directory-profile-cleanup.ts +101 -0
  45. package/src/durable-rpc.ts +753 -0
  46. package/src/durable-session.ts +21 -0
  47. package/src/entry-boundary.ts +103 -0
  48. package/src/frock-ai.ts +338 -0
  49. package/src/gateway.ts +1392 -0
  50. package/src/group-chat.ts +736 -0
  51. package/src/hidden-bot-notifications-cleanup.ts +32 -0
  52. package/src/index.ts +2798 -0
  53. package/src/insights.ts +15 -0
  54. package/src/machine-messages-cleanup.ts +121 -0
  55. package/src/machine-socket.ts +164 -0
  56. package/src/memory-records.ts +181 -0
  57. package/src/memory.ts +145 -0
  58. package/src/model-rates.ts +252 -0
  59. package/src/native-auth.ts +1006 -0
  60. package/src/native-sessions.ts +121 -0
  61. package/src/notification-state-cleanup.ts +18 -0
  62. package/src/ollama-web-search-cleanup.ts +69 -0
  63. package/src/package-page-shapes-cleanup.ts +190 -0
  64. package/src/plugin-egress.ts +31 -0
  65. package/src/plugin-page-route.ts +72 -0
  66. package/src/plugin-panels-cleanup.ts +101 -0
  67. package/src/prepared-input-cleanup.ts +105 -0
  68. package/src/production-secrets.ts +484 -0
  69. package/src/project-cleanup.ts +123 -0
  70. package/src/project-events-cleanup.ts +152 -0
  71. package/src/publication-state-cleanup.ts +92 -0
  72. package/src/push.ts +498 -0
  73. package/src/request-body.ts +165 -0
  74. package/src/routine-state-cleanup.ts +57 -0
  75. package/src/search.ts +88 -0
  76. package/src/sidebar-label-cleanup.ts +165 -0
  77. package/src/skill-index-cleanup.ts +53 -0
  78. package/src/supersede-cleanup.ts +162 -0
  79. package/src/test-chat-cleanup.ts +93 -0
  80. package/src/uploads.ts +486 -0
  81. package/src/user-application.ts +1068 -0
  82. package/src/user-configuration.ts +4491 -0
  83. package/src/voice-assistant.ts +4999 -0
  84. package/src/voice-dictation.ts +743 -0
  85. package/src/working-context-cleanup.ts +96 -0
  86. package/src/workspace.ts +201 -0
  87. package/wrangler.jsonc +385 -0
  88. package/README.md +0 -3
@@ -0,0 +1,426 @@
1
+ import {
2
+ decodeAccountAccessReadRequestV1,
3
+ decodeAccountAccessV1,
4
+ decodeAccountDeletionAccessRequestV1,
5
+ decodeAdmissionIdentityV1,
6
+ decodeDeploymentPolicyReadRequestV1,
7
+ decodeDeploymentPolicyV1,
8
+ decodeEmailInvitationV1,
9
+ decodeIdentityCreationRequestV1,
10
+ decodeInviteEmailRequestV1,
11
+ decodeSetAccountAccessRequestV1,
12
+ decodeSetAdmissionModeRequestV1,
13
+ type AccountAccessV1,
14
+ type AccountAccessViewV1,
15
+ type AccountAdmissionDecisionV1,
16
+ type AdmissionIdentityV1,
17
+ type DeploymentPolicyV1,
18
+ type EmailInvitationV1,
19
+ } from "@frockbot/app/admin/shared";
20
+ import {
21
+ claimEmailUsernameV1,
22
+ readEmailUsernameV1,
23
+ releaseEmailUsernameV1,
24
+ resolveEmailUsernameV1,
25
+ } from "@frockbot/app/email/directory";
26
+ import { isEmailUsernameV1 } from "@frockbot/app/email/shared";
27
+ import { DurableObject } from "cloudflare:workers";
28
+ import {
29
+ evaluateAdmissionV1,
30
+ identityMayBeCreatedV1,
31
+ } from "./account-admission.js";
32
+ import {
33
+ decodeModelRatesReadRequestV1,
34
+ type HostedModelRatesV1,
35
+ type HostedModelRatesViewV1,
36
+ } from "@frockbot/app/billing/rates";
37
+ import {
38
+ currentHostedModelRatesV1,
39
+ hostedModelRatesViewV1,
40
+ reportUnpricedServedModelV1,
41
+ saveHostedModelRatesV1,
42
+ seedHostedModelRatesStorageV1,
43
+ type ModelRatesWriteV1,
44
+ } from "./model-rates.js";
45
+ import { decodeRpcEnvelopeV1, rpcIdentifier } from "./durable-rpc.js";
46
+
47
+ const POLICY_KEY = "deployment:admission:v1";
48
+ const ACCESS_PREFIX = "account:access:v1:";
49
+ const INVITATION_PREFIX = "invitation:email:v1:";
50
+ export const DEPLOYMENT_POLICY_SINGLETON_NAME = "frockbot-deployment-policy";
51
+
52
+ /** An email username, in the shape and outside the names nobody may hold. */
53
+ const EMAIL_USERNAME = (value: unknown, label: string): unknown => {
54
+ if (!isEmailUsernameV1(value)) throw new Error(`${label} is invalid`);
55
+ return value;
56
+ };
57
+
58
+ /** Written by admission itself, so an audit can tell a sign-in from an admin. */
59
+ export const ADMISSION_UPDATED_BY = "admission";
60
+ /** Written when the account's own deletion ends its access. */
61
+ export const DELETION_UPDATED_BY = "account-deletion";
62
+
63
+ /** The signups-switch record this authority replaced; see `cleanRetiredDeploymentPolicyV1`. */
64
+ export const RETIRED_SIGNUPS_POLICY_KEY = "deployment:policy:v1";
65
+ export const RETIRED_SIGNUPS_POLICY_RECEIPT_KEY =
66
+ "maintenance:retired-signups-policy:2026-09-14";
67
+
68
+ /**
69
+ * Deletes the retired signups-switch record, which nothing decodes any more.
70
+ *
71
+ * Scoped to that one key, so accounts, invitations and the admission policy
72
+ * are untouched, and repeatable: it runs whenever the object starts, and a
73
+ * second run finds the receipt and does nothing. It carries no value forward
74
+ * — a deployment whose signups were open starts `closed`, the fail-safe mode,
75
+ * until an admin chooses one.
76
+ */
77
+ export function cleanRetiredDeploymentPolicyV1(
78
+ storage: DurableObjectStorage,
79
+ ): void {
80
+ storage.transactionSync(() => {
81
+ if (storage.kv.get(RETIRED_SIGNUPS_POLICY_RECEIPT_KEY) !== undefined) {
82
+ return;
83
+ }
84
+ const present = storage.kv.get(RETIRED_SIGNUPS_POLICY_KEY) !== undefined;
85
+ storage.kv.delete(RETIRED_SIGNUPS_POLICY_KEY);
86
+ storage.kv.put(RETIRED_SIGNUPS_POLICY_RECEIPT_KEY, {
87
+ at: new Date().toISOString(),
88
+ deleted: present ? 1 : 0,
89
+ });
90
+ });
91
+ }
92
+
93
+ /**
94
+ * A compare-and-swap answered as a value. A thrown error's class does not
95
+ * survive the Durable Object RPC boundary, and a lost race is an ordinary
96
+ * answer, not a failure.
97
+ */
98
+ export type RevisionedWriteV1<T> =
99
+ | { status: "applied"; value: T }
100
+ | { status: "conflict"; currentRevision: number };
101
+
102
+ function defaultPolicy(): DeploymentPolicyV1 {
103
+ return {
104
+ schemaVersion: 1,
105
+ revision: 0,
106
+ admission: { mode: "closed" },
107
+ updatedAt: new Date().toISOString(),
108
+ updatedBy: "deployment-default",
109
+ };
110
+ }
111
+
112
+ function nextRevision(current: number, label: string): number {
113
+ if (current >= Number.MAX_SAFE_INTEGER) {
114
+ throw new Error(`${label} revision is exhausted`);
115
+ }
116
+ return current + 1;
117
+ }
118
+
119
+ /**
120
+ * The deployment's beta-access authority: the admission mode, each account's
121
+ * access record and the email invitations not yet redeemed. It also holds the
122
+ * versioned hosted model rate table (`./model-rates.ts`), which is equally
123
+ * deployment-wide and equally an administrator's to change, and the email
124
+ * usernames (`app/email/directory.ts`): one account per username across the
125
+ * deployment, and the one object the whole deployment shares is what can say
126
+ * whose a message's username is before any User's object is addressed.
127
+ *
128
+ * One object, and every read-decide-write in it is a synchronous storage
129
+ * transaction, so two sign-ins, or a sign-in racing an admin, are serialized
130
+ * here rather than reconciled later. That is what stops a sign-in that read
131
+ * `invited` from writing `active` over a pause that landed in between.
132
+ */
133
+ export class DeploymentPolicy extends DurableObject<Record<string, never>> {
134
+ constructor(ctx: DurableObjectState, env: Record<string, never>) {
135
+ super(ctx, env);
136
+ void ctx.blockConcurrencyWhile(async () => {
137
+ cleanRetiredDeploymentPolicyV1(ctx.storage);
138
+ seedHostedModelRatesStorageV1(ctx.storage);
139
+ });
140
+ }
141
+
142
+ private get kv() {
143
+ return this.ctx.storage.kv;
144
+ }
145
+
146
+ private policy(): DeploymentPolicyV1 {
147
+ const stored = this.kv.get<unknown>(POLICY_KEY);
148
+ return stored === undefined
149
+ ? defaultPolicy()
150
+ : decodeDeploymentPolicyV1(stored);
151
+ }
152
+
153
+ private access(userId: string): AccountAccessV1 | null {
154
+ const stored = this.kv.get<unknown>(ACCESS_PREFIX + userId);
155
+ if (stored === undefined) return null;
156
+ const access = decodeAccountAccessV1(stored);
157
+ if (access.userId !== userId) {
158
+ throw new Error("stored account access names another account");
159
+ }
160
+ return access;
161
+ }
162
+
163
+ private invitation(email: string | undefined): EmailInvitationV1 | null {
164
+ if (email === undefined) return null;
165
+ const stored = this.kv.get<unknown>(INVITATION_PREFIX + email);
166
+ return stored === undefined ? null : decodeEmailInvitationV1(stored);
167
+ }
168
+
169
+ async readPolicy(input: unknown): Promise<DeploymentPolicyV1> {
170
+ decodeDeploymentPolicyReadRequestV1(input);
171
+ return this.policy();
172
+ }
173
+
174
+ async setAdmissionMode(
175
+ input: unknown,
176
+ ): Promise<RevisionedWriteV1<DeploymentPolicyV1>> {
177
+ const request = decodeSetAdmissionModeRequestV1(input);
178
+ return this.ctx.storage.transactionSync<
179
+ RevisionedWriteV1<DeploymentPolicyV1>
180
+ >(() => {
181
+ const current = this.policy();
182
+ if (request.command.revision !== current.revision) {
183
+ return { status: "conflict", currentRevision: current.revision };
184
+ }
185
+ const next: DeploymentPolicyV1 = {
186
+ schemaVersion: 1,
187
+ revision: nextRevision(current.revision, "deployment policy"),
188
+ admission: { mode: request.command.mode },
189
+ updatedAt: new Date().toISOString(),
190
+ updatedBy: request.updatedBy,
191
+ };
192
+ this.kv.put(POLICY_KEY, next);
193
+ return { status: "applied", value: next };
194
+ });
195
+ }
196
+
197
+ async readAccountAccess(input: unknown): Promise<AccountAccessViewV1> {
198
+ const { userId } = decodeAccountAccessReadRequestV1(input);
199
+ return { schemaVersion: 1, userId, access: this.access(userId) };
200
+ }
201
+
202
+ /**
203
+ * An admin's decision about one account. Compare-and-swap on the record's
204
+ * revision, which admission also advances, so an admin who read `invited`
205
+ * and chose `paused` learns the account activated meanwhile instead of
206
+ * silently overwriting what they did not see.
207
+ */
208
+ async setAccountAccess(
209
+ input: unknown,
210
+ ): Promise<RevisionedWriteV1<AccountAccessV1>> {
211
+ const request = decodeSetAccountAccessRequestV1(input);
212
+ return this.ctx.storage.transactionSync<RevisionedWriteV1<AccountAccessV1>>(
213
+ () => {
214
+ const current = this.access(request.userId);
215
+ const revision = current?.revision ?? 0;
216
+ if (request.command.revision !== revision) {
217
+ return { status: "conflict", currentRevision: revision };
218
+ }
219
+ const next: AccountAccessV1 = {
220
+ schemaVersion: 1,
221
+ userId: request.userId,
222
+ state: request.command.state,
223
+ revision: nextRevision(revision, "account access"),
224
+ updatedAt: new Date().toISOString(),
225
+ updatedBy: request.updatedBy,
226
+ };
227
+ this.kv.put(ACCESS_PREFIX + request.userId, next);
228
+ return { status: "applied", value: next };
229
+ },
230
+ );
231
+ }
232
+
233
+ /** Idempotent: inviting an address twice keeps the first invitation. */
234
+ async inviteEmail(input: unknown): Promise<EmailInvitationV1> {
235
+ const request = decodeInviteEmailRequestV1(input);
236
+ return this.ctx.storage.transactionSync(() => {
237
+ const existing = this.invitation(request.command.email);
238
+ if (existing) return existing;
239
+ const invitation: EmailInvitationV1 = {
240
+ schemaVersion: 1,
241
+ email: request.command.email,
242
+ invitedAt: new Date().toISOString(),
243
+ invitedBy: request.invitedBy,
244
+ };
245
+ this.kv.put(INVITATION_PREFIX + invitation.email, invitation);
246
+ return invitation;
247
+ });
248
+ }
249
+
250
+ /**
251
+ * Commits browser/native admission before a User can be provisioned.
252
+ * Deciding and activating are one transaction, and an invitation is spent
253
+ * in the same one, so it binds to exactly one account.
254
+ */
255
+ async admitAccount(input: unknown): Promise<AccountAdmissionDecisionV1> {
256
+ const identity = decodeAdmissionIdentityV1(input);
257
+ return this.ctx.storage.transactionSync(() => {
258
+ const access = this.access(identity.userId);
259
+ const evaluation = this.evaluateAccount(identity, access);
260
+ if (evaluation.activate) {
261
+ const next: AccountAccessV1 = {
262
+ schemaVersion: 1,
263
+ userId: identity.userId,
264
+ state: "active",
265
+ revision: nextRevision(access?.revision ?? 0, "account access"),
266
+ updatedAt: new Date().toISOString(),
267
+ updatedBy: ADMISSION_UPDATED_BY,
268
+ };
269
+ this.kv.put(ACCESS_PREFIX + identity.userId, next);
270
+ }
271
+ if (evaluation.redeemInvitation && identity.email !== undefined) {
272
+ this.kv.delete(INVITATION_PREFIX + identity.email);
273
+ }
274
+ return evaluation.decision;
275
+ });
276
+ }
277
+
278
+ private evaluateAccount(
279
+ identity: AdmissionIdentityV1,
280
+ access: AccountAccessV1 | null,
281
+ ) {
282
+ return evaluateAdmissionV1({
283
+ mode: this.policy().admission.mode,
284
+ identity,
285
+ access,
286
+ invitation: identity.emailVerified
287
+ ? this.invitation(identity.email)
288
+ : null,
289
+ });
290
+ }
291
+
292
+ async checkAccount(input: unknown): Promise<AccountAdmissionDecisionV1> {
293
+ const identity = decodeAdmissionIdentityV1(input);
294
+ return this.ctx.storage.transactionSync(
295
+ () =>
296
+ this.evaluateAccount(identity, this.access(identity.userId)).decision,
297
+ );
298
+ }
299
+
300
+ /**
301
+ * The User's email username is now this one, unless another account holds
302
+ * it; the one they held before is released in the same write.
303
+ */
304
+ async claimEmailUsername(
305
+ input: unknown,
306
+ ): Promise<{ schemaVersion: 1; status: "claimed" | "taken" }> {
307
+ const request = decodeRpcEnvelopeV1(input, {
308
+ userId: rpcIdentifier,
309
+ username: EMAIL_USERNAME,
310
+ });
311
+ const claim = this.ctx.storage.transactionSync(() =>
312
+ claimEmailUsernameV1(this.kv, {
313
+ userId: request.userId as string,
314
+ username: request.username as string,
315
+ }),
316
+ );
317
+ return { schemaVersion: 1, status: claim.status };
318
+ }
319
+
320
+ /** The User has no email username any more. */
321
+ async releaseEmailUsername(input: unknown): Promise<{ schemaVersion: 1 }> {
322
+ const request = decodeRpcEnvelopeV1(input, { userId: rpcIdentifier });
323
+ this.ctx.storage.transactionSync(() =>
324
+ releaseEmailUsernameV1(this.kv, request.userId as string),
325
+ );
326
+ return { schemaVersion: 1 };
327
+ }
328
+
329
+ /** The User a username belongs to, or `null`. */
330
+ async resolveEmailUsername(
331
+ input: unknown,
332
+ ): Promise<{ schemaVersion: 1; userId: string | null }> {
333
+ const request = decodeRpcEnvelopeV1(input, { username: EMAIL_USERNAME });
334
+ return {
335
+ schemaVersion: 1,
336
+ userId:
337
+ resolveEmailUsernameV1(this.kv, request.username as string) ?? null,
338
+ };
339
+ }
340
+
341
+ /** A User's email username, or `null`. */
342
+ async readEmailUsername(
343
+ input: unknown,
344
+ ): Promise<{ schemaVersion: 1; username: string | null }> {
345
+ const request = decodeRpcEnvelopeV1(input, { userId: rpcIdentifier });
346
+ return {
347
+ schemaVersion: 1,
348
+ username: readEmailUsernameV1(this.kv, request.userId as string) ?? null,
349
+ };
350
+ }
351
+
352
+ async mayCreateIdentity(input: unknown): Promise<boolean> {
353
+ const request = decodeIdentityCreationRequestV1(input);
354
+ return identityMayBeCreatedV1(
355
+ this.policy().admission.mode,
356
+ request,
357
+ request.emailVerified ? this.invitation(request.email) : null,
358
+ );
359
+ }
360
+
361
+ /** The hosted model prices every Bot reserves and settles against. */
362
+ async readModelRates(input: unknown): Promise<HostedModelRatesV1> {
363
+ decodeModelRatesReadRequestV1(input);
364
+ return currentHostedModelRatesV1(this.ctx.storage);
365
+ }
366
+
367
+ async readModelRatesView(input: unknown): Promise<HostedModelRatesViewV1> {
368
+ decodeModelRatesReadRequestV1(input);
369
+ return hostedModelRatesViewV1(this.ctx.storage);
370
+ }
371
+
372
+ async saveModelRates(input: unknown): Promise<ModelRatesWriteV1> {
373
+ return saveHostedModelRatesV1(this.ctx.storage, input);
374
+ }
375
+
376
+ async reportUnpricedServedModel(input: unknown): Promise<void> {
377
+ reportUnpricedServedModelV1(this.ctx.storage, input);
378
+ }
379
+
380
+ /**
381
+ * The first step of deleting an account: `ended`, whatever the record said
382
+ * and whether or not there was one, so no request of the account's starts
383
+ * anything while its data is being destroyed. Not a compare-and-swap — the
384
+ * person already confirmed, and an admin's concurrent write must not
385
+ * reopen an account mid-deletion. Repeating it changes nothing.
386
+ */
387
+ async closeAccountForDeletion(input: unknown): Promise<AccountAccessV1> {
388
+ const { userId } = decodeAccountDeletionAccessRequestV1(input);
389
+ return this.ctx.storage.transactionSync(() => {
390
+ const current = this.access(userId);
391
+ if (
392
+ current?.state === "ended" &&
393
+ current.updatedBy === DELETION_UPDATED_BY
394
+ )
395
+ return current;
396
+ const next: AccountAccessV1 = {
397
+ schemaVersion: 1,
398
+ userId,
399
+ state: "ended",
400
+ revision: nextRevision(current?.revision ?? 0, "account access"),
401
+ updatedAt: new Date().toISOString(),
402
+ updatedBy: DELETION_UPDATED_BY,
403
+ };
404
+ this.kv.put(ACCESS_PREFIX + userId, next);
405
+ return next;
406
+ });
407
+ }
408
+
409
+ /**
410
+ * The last trace of a deleted account here: its access record, any
411
+ * invitation still waiting under its address, and its email username, which
412
+ * anyone may take again. Called only once the identity itself is gone, so no
413
+ * session is left that could be admitted afresh.
414
+ */
415
+ async forgetAccount(input: unknown): Promise<{ schemaVersion: 1 }> {
416
+ const request = decodeAccountDeletionAccessRequestV1(input);
417
+ this.ctx.storage.transactionSync(() => {
418
+ this.kv.delete(ACCESS_PREFIX + request.userId);
419
+ if (request.email !== undefined) {
420
+ this.kv.delete(INVITATION_PREFIX + request.email);
421
+ }
422
+ releaseEmailUsernameV1(this.kv, request.userId);
423
+ });
424
+ return { schemaVersion: 1 };
425
+ }
426
+ }
@@ -0,0 +1,101 @@
1
+ // Disposable cleanup for directory rows written before they carried a
2
+ // current profile. Seeds the projection from the creation name. Each Bot
3
+ // also queues one mirror of its settings so a rename made before this
4
+ // shape converges the next time that Bot wakes. Neither path scans other Bots.
5
+
6
+ import {
7
+ decodeBotSettingsViewV1,
8
+ migrateStoredBotSettingsV1,
9
+ } from "@frockbot/core/configuration";
10
+ import { directoryProfileFromBotProfileV1 } from "@frockbot/app/flock/profile-mirror";
11
+ import {
12
+ PROFILE_MIRROR_KEY_V1,
13
+ queueProfileMirrorV1,
14
+ } from "@frockbot/app/flock/profile-mirror";
15
+
16
+ const USER_RECEIPT = "maintenance:directory-profile:2026-09-22";
17
+ const BOT_RECEIPT = "maintenance:profile-mirror:2026-09-22";
18
+ const DIRECTORY_KEY = "flock:directory:v1";
19
+ const BOT_CONFIGURATION_KEY = "bot-configuration";
20
+
21
+ function record(value: unknown): Record<string, unknown> | undefined {
22
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
23
+ return undefined;
24
+ }
25
+ return value as Record<string, unknown>;
26
+ }
27
+
28
+ export async function cleanDirectoryProfileTestState(
29
+ storage: DurableObjectStorage,
30
+ ): Promise<void> {
31
+ await storage.transaction(async (tx) => {
32
+ if (await tx.get(USER_RECEIPT)) return;
33
+ const stored = record(await tx.get(DIRECTORY_KEY));
34
+ let seeded = 0;
35
+ if (stored && Array.isArray(stored.bots)) {
36
+ const bots = stored.bots.map((entry) => {
37
+ const registration = record(entry);
38
+ if (!registration || registration.currentProfile !== undefined) {
39
+ return entry;
40
+ }
41
+ if (typeof registration.initialName !== "string") return entry;
42
+ seeded += 1;
43
+ return {
44
+ ...registration,
45
+ currentProfile: {
46
+ name: registration.initialName,
47
+ ...(typeof registration.initialDescription === "string"
48
+ ? { description: registration.initialDescription }
49
+ : {}),
50
+ sourceRevision: 0,
51
+ },
52
+ };
53
+ });
54
+ if (seeded > 0) await tx.put(DIRECTORY_KEY, { ...stored, bots });
55
+ }
56
+ await tx.put(USER_RECEIPT, {
57
+ schemaVersion: 1,
58
+ seeded,
59
+ completedAt: new Date().toISOString(),
60
+ });
61
+ });
62
+ }
63
+
64
+ export async function cleanBotProfileMirrorTestState(
65
+ storage: DurableObjectStorage,
66
+ ): Promise<void> {
67
+ await storage.transaction(async (tx) => {
68
+ if (await tx.get(BOT_RECEIPT)) return;
69
+ let queued = false;
70
+ if ((await tx.get(PROFILE_MIRROR_KEY_V1)) === undefined) {
71
+ const stored = await tx.get(BOT_CONFIGURATION_KEY);
72
+ if (stored !== undefined) {
73
+ try {
74
+ const settings = decodeBotSettingsViewV1(
75
+ migrateStoredBotSettingsV1(stored),
76
+ );
77
+ await queueProfileMirrorV1(
78
+ tx,
79
+ directoryProfileFromBotProfileV1(
80
+ settings.profile,
81
+ settings.revision,
82
+ ),
83
+ Date.now(),
84
+ );
85
+ queued = true;
86
+ } catch {
87
+ queued = false;
88
+ }
89
+ }
90
+ }
91
+ await tx.put(BOT_RECEIPT, {
92
+ schemaVersion: 1,
93
+ queued,
94
+ completedAt: new Date().toISOString(),
95
+ });
96
+ });
97
+ if ((await storage.get(PROFILE_MIRROR_KEY_V1)) === undefined) return;
98
+ const due = Date.now();
99
+ const alarm = await storage.getAlarm();
100
+ if (alarm === null || alarm > due) await storage.setAlarm(due);
101
+ }