@frockbot/cloudflare 0.0.0 → 0.7.291

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,4491 @@
1
+ import {
2
+ BillingError,
3
+ BillingLedger,
4
+ type BillingBalance,
5
+ type ComplimentaryGrant,
6
+ type PaidAccessState,
7
+ type UsageReservation,
8
+ type UsageSettlement,
9
+ } from "@frockbot/app/billing/ledger";
10
+ import {
11
+ dailySpendV1,
12
+ isSpendLimitScopeV1,
13
+ localDayStartV1,
14
+ readSpendLimitsV1,
15
+ readSpendingRowsV1,
16
+ setSpendLimitV1,
17
+ SPEND_LIMIT_MAX_MICROS_V1,
18
+ spendScopePausedV1,
19
+ spendSpikeV1,
20
+ spentOnScopeSinceV1,
21
+ spendingCreditV1,
22
+ spendingReportV1,
23
+ type SpendingCreditV1,
24
+ spendWindowV1,
25
+ type SpendDimensionV1,
26
+ type SpendingReportV1,
27
+ type SpendPeriodV1,
28
+ } from "@frockbot/app/billing/spending";
29
+ import { accountPayments, type BillingEnv } from "./billing.js";
30
+ import {
31
+ hostedBillingEnabledV1,
32
+ type BillingSwitchEnv,
33
+ } from "./billing-readiness.js";
34
+ import { isPublicIdentifier } from "@frockbot/core/configuration";
35
+ import {
36
+ accessEmailV1,
37
+ decodeSetUserFeaturesRequestV1,
38
+ decodeUserFeaturesReadRequestV1,
39
+ decodeUserFeaturesV1,
40
+ defaultUserFeaturesV1,
41
+ type UserFeaturesV1,
42
+ } from "@frockbot/app/admin/shared";
43
+ import {
44
+ decodePushRegistration,
45
+ registerPushDevice,
46
+ deliverPush,
47
+ type PushUpdate,
48
+ } from "./push.js";
49
+ import { decodeThemeDocumentV1, decodeBotLookV1 } from "@frockbot/core/theme";
50
+ import { decodeProtocol } from "@frockbot/core/protocol-schemas";
51
+ import { DurableObject } from "cloudflare:workers";
52
+ import { cleanUserAvatarTestState } from "./avatar-state-cleanup.js";
53
+ import { cleanDirectoryProfileTestState } from "./directory-profile-cleanup.js";
54
+ import { cleanGroupChatLabelsV1 } from "./sidebar-label-cleanup.js";
55
+ import {
56
+ decodeNativeSessionOperation,
57
+ nativeSessionOperation,
58
+ } from "./native-sessions.js";
59
+ import {
60
+ createFoundationUserBackendContributions,
61
+ type FoundationConnectionUserBackendContribution,
62
+ type MountedFoundationUserBackend,
63
+ } from "@frockbot/app/user";
64
+ import {
65
+ decodeConnectionCommandIdV1,
66
+ decodeConnectionCommandV1,
67
+ } from "@frockbot/core/connection";
68
+ import type { ConnectUserBackendContribution } from "@frockbot/app/connect/user";
69
+ import type { ConnectEventV1 } from "@frockbot/app/connect/events";
70
+ import type { ConnectTriggerOfferV1 } from "@frockbot/app/connect/triggers";
71
+ import { decodeRoutineCommandV1 } from "@frockbot/app/routines/shared";
72
+ import {
73
+ decodeBotSettingsViewV1,
74
+ userTimezoneV1,
75
+ type UserSettingsViewV1,
76
+ decodeUserConfigurationExecuteRpcV1,
77
+ decodeUserConfigurationReadRpcV1,
78
+ } from "@frockbot/core/configuration";
79
+ import {
80
+ decodeRoutineCommandReceiptV1,
81
+ decodeRoutineListViewV1,
82
+ } from "@frockbot/app/routines/shared";
83
+ import {
84
+ decodeBotDirectoryProfileV1,
85
+ decodeBotLifecycleCommandV1,
86
+ decodeBotLifecycleReceiptV1,
87
+ decodeBotLifecycleViewV1,
88
+ decodeCreateBotCommandV1,
89
+ decodeAvatarIdentityViewV1,
90
+ decodeFlockReceiptV1,
91
+ decodeUpdateAvatarCommandV1,
92
+ decodeUpdateVoiceCommandV1,
93
+ decodeUpdateLookCommandV1,
94
+ decodeBotVoiceForFlockV1,
95
+ decodeVoiceIdentityViewV1,
96
+ decodeLookIdentityViewV1,
97
+ BotNotFoundError,
98
+ } from "@frockbot/app/flock/shared";
99
+ import {
100
+ releaseSubagentSlotV1,
101
+ reserveSubagentSlotV1,
102
+ type SubagentSlotReceiptV1,
103
+ } from "@frockbot/app/subagents/quota";
104
+ import {
105
+ releaseAgentTurnSlotV1,
106
+ reserveAgentTurnSlotV1,
107
+ type AgentTurnSlotReceiptV1,
108
+ } from "@frockbot/app/flock/quota";
109
+ import {
110
+ DEVICE_CALL_WAIT_MS,
111
+ decodeMachineModuleCallResultV1,
112
+ decodeMachineModuleEventsV1,
113
+ decodeMachineModuleReportsV1,
114
+ machineTokenClaimsV1,
115
+ type MachineModuleEventReceiptV1,
116
+ type MachineModuleEventsReceiptV1,
117
+ type MachineModuleReportsReceiptV1,
118
+ type MachinePlatformV1,
119
+ type MachineSocketFrameV1,
120
+ } from "@frockbot/core/machine-protocol";
121
+ import {
122
+ generationCarriesModuleV1,
123
+ machineModulesV1,
124
+ } from "@frockbot/app/machine/modules";
125
+ import {
126
+ MachineModuleCallsV1,
127
+ type DeviceCallOutcomeV1,
128
+ } from "@frockbot/app/machine/module-calls";
129
+ import {
130
+ readPluginModuleReportsV1,
131
+ recordPluginModuleReportsV1,
132
+ } from "@frockbot/app/plugins/module-reports";
133
+ import {
134
+ listeningEventsV1,
135
+ moduleEventDeliveryV1,
136
+ moduleEventSeenV1,
137
+ moduleEventTargetsV1,
138
+ pluginTriggerIndexBuiltV1,
139
+ readModuleRoutingV1,
140
+ readPluginTriggerRoutinesV1,
141
+ rebuildPluginTriggerIndexV1,
142
+ recordModuleEventV1,
143
+ refuseModuleEventV1,
144
+ syncPluginTriggerRoutineV1,
145
+ trimModuleEventsSeenV1,
146
+ type PluginTriggerRoutineEntryV1,
147
+ } from "@frockbot/app/plugins/module-events";
148
+ import { DurableWorkspaceGenerations } from "@frockbot/core/durable";
149
+ import {
150
+ COMPOSITION_CURRENT_KEY,
151
+ decodeCompositionGenerationV1,
152
+ decodeCompositionPinV1,
153
+ type CompositionFailureInputV1,
154
+ type CompositionOriginV1,
155
+ } from "@frockbot/core/durable";
156
+ import {
157
+ readUserCompositionV1,
158
+ reconcileInstalledProviderPluginsV1,
159
+ userCompositionFailuresV1,
160
+ userCompositionStoreV1,
161
+ } from "@frockbot/app/composition/user";
162
+ import {
163
+ DEPLOYMENT_PLUGIN_CATALOG_V1,
164
+ marketplacePluginPackageIdsV1,
165
+ } from "@frockbot/app/plugins/catalog";
166
+ import {
167
+ CONNECTIONS_CATALOG_QUERY_MAX_V1,
168
+ CONNECTIONS_FRAME_PROVIDERS_MAX_V1,
169
+ type ConnectionsCatalogQueryV1,
170
+ } from "@frockbot/app/settings/frame";
171
+ import { cleanUndecodableSkillIndexesV1 } from "./skill-index-cleanup.js";
172
+ import { cleanUndecodableConnectCatalogsV1 } from "@frockbot/app/connect/account-catalog";
173
+ import { reseedInstructionRootV1 } from "@frockbot/app/skills/reseed";
174
+ import {
175
+ base64ToBytes,
176
+ beginDurableSkillPublicationV1,
177
+ commitDurableSkillPublicationV1,
178
+ heldSkillRevisionsV1,
179
+ holdSkillIndexRevisionsV1,
180
+ readDurableSkillIndexV1,
181
+ readDurableSkillSnapshotV1,
182
+ releaseSkillIndexHoldV1,
183
+ releaseUnreferencedSkillSnapshotsV1,
184
+ } from "@frockbot/app/skills/index-store";
185
+ import { decodeWorkspaceGenerationV1 } from "@frockbot/core/contracts";
186
+ import { workspaceObjectPrefixV1 } from "@frockbot/core/workspace-store";
187
+ import { cleanRetiredMemoryFactObjectsV1 } from "@frockbot/app/memory/cleanup";
188
+ import { createR2ObjectBucketV1 } from "./workspace.js";
189
+ import { cleanUserAppletsV1 } from "./plugin-panels-cleanup.js";
190
+ import { cleanDefaultPackagesMarkerV1 } from "./default-packages-marker-cleanup.js";
191
+ import { cleanRetiredOllamaWebSearchV1 } from "./ollama-web-search-cleanup.js";
192
+ import { cleanUserMachineMessagesV1 } from "./machine-messages-cleanup.js";
193
+ import { ComputerLoginsLedgerV1 } from "@frockbot/app/shell/computer-logins";
194
+ import {
195
+ MACHINE_SOCKET_INTERNAL_PATH_V1,
196
+ broadcastMachineFrameV1,
197
+ connectedMachinePlatformsV1,
198
+ durableObjectMachineSocketsV1,
199
+ machineSocketTagV1,
200
+ readMachineSocketCallV1,
201
+ type MachineSocketAttachmentV1,
202
+ } from "./machine-socket.js";
203
+ import type { FlockUserTransaction } from "@frockbot/app/flock/user";
204
+ import {
205
+ decodeWorkspaceGenerationRecordV1,
206
+ decodeWorkspaceRootV1,
207
+ isWorkspaceSharedMemoryRootV1,
208
+ normalizeWorkspaceRelativePathV1,
209
+ type WorkspaceGenerationRecordV1,
210
+ type WorkspaceRootV1,
211
+ } from "@frockbot/core/contracts";
212
+ import { memoryMembershipRevisionV1 } from "@frockbot/app/memory/engine-tools";
213
+ import { cleanRetiredProjectsV1 } from "./project-cleanup.js";
214
+ import { cleanRetiredBotTemplatesV1 } from "./bot-template-cleanup.js";
215
+ import {
216
+ groupChatScopeV1,
217
+ memoryScopeKeyV1,
218
+ } from "@frockbot/app/memory/records";
219
+ import {
220
+ createUserMemoryEngineV1,
221
+ dispatchMemoryOperateV1,
222
+ drainDurableMemoryV1,
223
+ durableObjectHasSqlV1,
224
+ type MemoryOperateActionV1,
225
+ } from "./memory-records.js";
226
+ import type { MemoryEngineV1 } from "@frockbot/app/memory/engine";
227
+ import type {
228
+ MemoryAiBinding,
229
+ MemoryVectorIndex,
230
+ } from "@frockbot/app/memory/types";
231
+ import {
232
+ SEARCH_MAX_ROW_PAGE_V1,
233
+ decodeSearchQueryV1,
234
+ type ClientSearchRebuildReceiptV1,
235
+ type SearchIndexResultsV1,
236
+ } from "@frockbot/app/search";
237
+ import type { BotSearchRpc } from "./search.js";
238
+ import {
239
+ AUDIT_ACTIVITY_FILTER_NAMES_V1,
240
+ AUDIT_ACTIVITY_MAX_ROWS_V1,
241
+ AUDIT_KINDS_V1,
242
+ AUDIT_MAX_CURSOR_LENGTH_V1,
243
+ AUDIT_MAX_ENTRY_PAGE_V1,
244
+ AUDIT_MAX_RESULTS_V1,
245
+ type AuditActivityFilterV1,
246
+ type AuditActivityPageV1,
247
+ type AuditRebuildReceiptV1,
248
+ type ClientAuditPageV1,
249
+ } from "@frockbot/app/audit";
250
+ import type { BotAuditRpc } from "./audit.js";
251
+ import type { WorkerLoader } from "./contracts.js";
252
+ import {
253
+ decodeRpcEnvelopeV1,
254
+ rpcArray,
255
+ rpcBotId,
256
+ rpcBoolean,
257
+ rpcDecoded,
258
+ rpcIdentifier,
259
+ rpcInteger,
260
+ rpcPattern,
261
+ rpcString,
262
+ rpcText,
263
+ rpcEnum,
264
+ rpcDecodedValue,
265
+ rpcJsonRecord,
266
+ rpcJsonSnapshotV1,
267
+ rpcObject,
268
+ } from "./durable-rpc.js";
269
+ import { answeredEntryV1, loggedEntryV1 } from "./entry-boundary.js";
270
+ import {
271
+ releaseBotUploadQuotaV1,
272
+ releaseUploadQuotaV1,
273
+ reserveUploadQuotaV1,
274
+ type UploadQuotaAnswerV1,
275
+ } from "@frockbot/app/uploads/quota";
276
+ import { isUploadIdV1, UPLOAD_MAX_BYTES_V1 } from "@frockbot/core/contracts";
277
+ import {
278
+ createSecretVaultV1,
279
+ type SecretVaultV1,
280
+ } from "@frockbot/app/secrets/user";
281
+ import {
282
+ isSecretIdV1,
283
+ isSecretRequestIdV1,
284
+ SECRET_LIMITS_V1,
285
+ secretOriginV1,
286
+ } from "@frockbot/app/secrets/shared";
287
+ import {
288
+ GroupChatUserStoreV1,
289
+ type GroupChatChangeV1,
290
+ type GroupChatUserStorageV1,
291
+ } from "@frockbot/app/groups/user";
292
+ import {
293
+ GroupChatNotFoundError,
294
+ answerGroupRpcV1,
295
+ decodeGroupChatCommandV1,
296
+ groupChatObjectNameV1,
297
+ type GroupActorV1,
298
+ type GroupChatCommandV1,
299
+ } from "@frockbot/app/groups/shared";
300
+ import type { BotUserConfigurationRpcTargetV1 } from "@frockbot/app/shell/durable-rpc-targets";
301
+ import {
302
+ ACCOUNT_DELETED_KEY_V1,
303
+ ACCOUNT_DELETION_KEY_V1,
304
+ AccountDeletedError,
305
+ accountDeletionRetryDelayMsV1,
306
+ advanceAccountDeletionV1,
307
+ beginAccountDeletionV1,
308
+ readAccountDeletionV1,
309
+ type AccountDeletionRecordV1,
310
+ type AccountDeletionTombstoneV1,
311
+ } from "@frockbot/app/account/deletion";
312
+ import { sha256HexV1 } from "@frockbot/core/crypto";
313
+ import {
314
+ runAccountDeletionStepV1,
315
+ type AccountDeletionEnvV1,
316
+ type AccountDeletionUserSeamsV1,
317
+ } from "./account-deletion.js";
318
+ import {
319
+ computerHostBindingV1,
320
+ createComputerHostV1,
321
+ } from "./computer-host.js";
322
+ import type { AuthPackageEnvironmentV1 } from "#auth-package";
323
+ import { BRAND_V1 } from "#brand";
324
+ import type { VoiceAssistant } from "./voice-assistant.js";
325
+ import {
326
+ InboundEmailUserStoreV1,
327
+ type InboundEmailUserHostV1,
328
+ } from "@frockbot/app/email/user";
329
+ import {
330
+ emailDomainV1,
331
+ normalizeSenderAddressV1,
332
+ type BotEmailSenderV1,
333
+ type InboundEmailRouteDecisionV1,
334
+ type InboundEmailStateV1,
335
+ } from "@frockbot/app/email/shared";
336
+ import { DEPLOYMENT_POLICY_SINGLETON_NAME } from "./deployment-policy.js";
337
+
338
+ /** The durable key pinning the User this object was provisioned for. */
339
+ const USER_IDENTITY_KEY = "user:identity";
340
+ /** The durable key holding what an administrator turned on for this User. */
341
+ const USER_FEATURES_KEY = "user:features:v1";
342
+ /** One receipt per "Delete my Computer" press that destroyed a Computer. */
343
+ const COMPUTER_TEARDOWN_RECEIPT_PREFIX = "computer:teardown:";
344
+
345
+ interface UserConfigurationEnv
346
+ extends BillingEnv, AccountDeletionEnvV1, AuthPackageEnvironmentV1 {
347
+ FCM_SERVICE_ACCOUNT?: string;
348
+ ALLOW_DEVELOPMENT_AUTH?: string;
349
+ /** Where every Bot's email address is, and what it sends from. */
350
+ EMAIL_DOMAIN?: string;
351
+ BETTER_AUTH_URL?: string;
352
+ CREDENTIAL_KEYRING?: string;
353
+ /** The Connected apps provider key. Absent, nothing can be connected. */
354
+ COMPOSIO_API_KEY?: string;
355
+ /**
356
+ * Signs every machine token and pairing code. Absent closes the door: a
357
+ * pairing is refused rather than offered under a signature nothing could
358
+ * verify.
359
+ */
360
+ MACHINE_TOKEN_SECRET?: string;
361
+ /** Bot authority: archive and restore are carried to the Bot Durable Object. */
362
+ BOT_STATES: DurableObjectNamespace;
363
+ /** Each Group Chat's own object, told about every change to its group. */
364
+ GROUP_CHATS?: DurableObjectNamespace;
365
+ /**
366
+ * This object's own namespace. Every caller reaches a User Durable Object
367
+ * through `idFromName(userId)`, so the namespace is how the object checks
368
+ * that the `userId` an RPC carries is the one it *is*.
369
+ */
370
+ USER_CONFIGURATIONS: DurableObjectNamespace;
371
+ /** Immutable published application source and artifact bytes. */
372
+ APPLICATION_ARTIFACTS: R2Bucket;
373
+ /** The loader that health-checks a candidate artifact before activation. */
374
+ USER_APPLICATIONS: WorkerLoader;
375
+ MEMORY_FILES?: R2Bucket;
376
+ /** Derived Memory vectors for User and shared scopes. Same Worker binding as Bot. */
377
+ MEMORY_INDEX?: MemoryVectorIndex;
378
+ /** Workers AI embeddings for User and shared Memory. */
379
+ AI?: MemoryAiBinding;
380
+ /**
381
+ * The Plugin worker loader. The User object never loads anything with it;
382
+ * it reads it to know whether this deployment can run a Plugin at all,
383
+ * which is what decides whether the seeded catalog is reconciled in.
384
+ */
385
+ BOT_PACKAGES?: WorkerLoader;
386
+ }
387
+
388
+ /** A mailbox exactly as normalized, or a refusal of the whole request. */
389
+ function requiredSenderAddress(value: unknown): string {
390
+ const address = normalizeSenderAddressV1(value);
391
+ if (address === undefined || address !== value) {
392
+ throw new Error("RPC request address is invalid");
393
+ }
394
+ return address;
395
+ }
396
+
397
+ /** The page of a Bot's projected rows a rebuild pulls, one Bot at a time. */
398
+ const SEARCH_REBUILD_BOT_LIMIT = 200;
399
+
400
+ export class UserConfiguration
401
+ extends DurableObject<UserConfigurationEnv>
402
+ implements BotUserConfigurationRpcTargetV1
403
+ {
404
+ constructor(ctx: DurableObjectState, env: UserConfigurationEnv) {
405
+ super(ctx, env);
406
+ // Before any request or alarm can read retired stored shapes.
407
+ this.ctx.blockConcurrencyWhile(async () => {
408
+ // A deleted account is its tombstone and nothing else: no cleanup may
409
+ // write a receipt back into it.
410
+ if (
411
+ (await this.ctx.storage.get<unknown>(ACCOUNT_DELETED_KEY_V1)) !==
412
+ undefined
413
+ )
414
+ return;
415
+ // A deleting one still reads its shapes, but reseeds no Skills into
416
+ // the bucket it is emptying and arms no alarm but the deletion's.
417
+ const deleting =
418
+ (await readAccountDeletionV1(this.ctx.storage)) !== undefined;
419
+ await cleanUserAppletsV1(this.ctx.storage);
420
+ await cleanUserAvatarTestState(this.ctx.storage);
421
+ await cleanDirectoryProfileTestState(this.ctx.storage);
422
+ await cleanGroupChatLabelsV1(this.ctx.storage);
423
+ await cleanDefaultPackagesMarkerV1(this.ctx.storage);
424
+ await cleanUndecodableSkillIndexesV1(this.ctx.storage);
425
+ await cleanUndecodableConnectCatalogsV1(this.ctx.storage);
426
+ await cleanRetiredOllamaWebSearchV1(this.ctx.storage);
427
+ // Before anything decodes a machine record or a queued command.
428
+ await cleanUserMachineMessagesV1(this.ctx.storage);
429
+ await cleanRetiredBotTemplatesV1(
430
+ this.ctx.storage,
431
+ this.env.APPLICATION_ARTIFACTS,
432
+ );
433
+ const userId = await this.ctx.storage.get<string>(USER_IDENTITY_KEY);
434
+ const objects = this.env.MEMORY_FILES
435
+ ? createR2ObjectBucketV1(this.env.MEMORY_FILES)
436
+ : undefined;
437
+ await cleanRetiredProjectsV1(this.ctx.storage, {
438
+ ...(typeof userId === "string" ? { userId } : {}),
439
+ ...(objects
440
+ ? {
441
+ bucket: {
442
+ list: async (options) => {
443
+ const page = await objects.list(options);
444
+ return {
445
+ keys: page.objects.map((object) => object.key),
446
+ ...(page.cursor ? { cursor: page.cursor } : {}),
447
+ truncated: page.truncated,
448
+ };
449
+ },
450
+ delete: (key) => objects.delete(key),
451
+ },
452
+ }
453
+ : {}),
454
+ ...(durableObjectHasSqlV1(this.ctx.storage)
455
+ ? { engine: createUserMemoryEngineV1(this.ctx.storage) }
456
+ : {}),
457
+ });
458
+ if (typeof userId === "string" && this.env.MEMORY_FILES && !deleting) {
459
+ await reseedInstructionRootV1({
460
+ storage: this.ctx.storage,
461
+ bucket: createR2ObjectBucketV1(this.env.MEMORY_FILES),
462
+ root: { kind: "user-instructions", userId },
463
+ receiptKey: "maintenance:skill-index:user:2026-09-22",
464
+ });
465
+ const bucket = createR2ObjectBucketV1(this.env.MEMORY_FILES);
466
+ await cleanRetiredMemoryFactObjectsV1(
467
+ this.ctx.storage,
468
+ {
469
+ list: async (options) => {
470
+ const page = await bucket.list(options);
471
+ return {
472
+ keys: page.objects.map((object) => object.key),
473
+ ...(page.cursor ? { cursor: page.cursor } : {}),
474
+ truncated: page.truncated,
475
+ };
476
+ },
477
+ delete: (key) => bucket.delete(key),
478
+ },
479
+ workspaceObjectPrefixV1({ kind: "user-memory", userId }),
480
+ );
481
+ }
482
+ if (!deleting && durableObjectHasSqlV1(this.ctx.storage)) {
483
+ const memoryDue = createUserMemoryEngineV1(
484
+ this.ctx.storage,
485
+ ).nextWakeupAt();
486
+ if (
487
+ memoryDue !== undefined &&
488
+ (await this.ctx.storage.getAlarm()) === null
489
+ ) {
490
+ await this.ctx.storage.setAlarm(memoryDue);
491
+ }
492
+ }
493
+ });
494
+ // A desktop's keep-alive is answered without waking this object, so an
495
+ // idle machine socket costs nothing while it hibernates.
496
+ this.ctx.setWebSocketAutoResponse(
497
+ new WebSocketRequestResponsePair("ping", "pong"),
498
+ );
499
+ }
500
+
501
+ /** This User's Profile timezone, for deciding whether a save moved it. */
502
+ private async routineTimezone(userId: string): Promise<string> {
503
+ return userTimezoneV1(
504
+ (await (await this.settingsContribution()).read(userId)).profile,
505
+ );
506
+ }
507
+
508
+ /**
509
+ * Push a moved Profile timezone to every Bot, so an alarm that fires without
510
+ * a Turn already holds it.
511
+ *
512
+ * A Bot that cannot be reached is left behind rather than failing the save
513
+ * that already committed: the mount re-projects the zone on the next Turn,
514
+ * and reporting a conflict for a change that succeeded is the worse answer.
515
+ */
516
+ private async propagateRoutineTimezone(
517
+ userId: string,
518
+ before: string,
519
+ ): Promise<void> {
520
+ const settings = await (await this.settingsContribution()).read(userId);
521
+ const timezone = userTimezoneV1(settings.profile);
522
+ if (timezone === before) return;
523
+ const directory = await (await this.flockContribution()).listBots();
524
+ await Promise.all(
525
+ directory.bots.map(async (bot) => {
526
+ const id = this.env.BOT_STATES.idFromName(`${userId}:${bot.botId}`);
527
+ const rpc = this.env.BOT_STATES.get(id) as unknown as {
528
+ refreshRoutineTimezone(input: unknown): Promise<unknown>;
529
+ };
530
+ try {
531
+ await rpc.refreshRoutineTimezone({
532
+ schemaVersion: 1,
533
+ userId,
534
+ botId: bot.botId,
535
+ timezone,
536
+ revision: settings.revision,
537
+ });
538
+ } catch {
539
+ // Left to the next mount, which projects the zone on every Turn.
540
+ }
541
+ }),
542
+ );
543
+ }
544
+
545
+ async reconcileBilling(input: {
546
+ userId: string;
547
+ command: Parameters<BillingLedger["reconcile"]>[0];
548
+ }) {
549
+ await this.assertUserIdentity(input.userId);
550
+ this.billing().reconcile(input.command);
551
+ }
552
+ private billing() {
553
+ return new BillingLedger(this.ctx.storage, BRAND_V1.productName);
554
+ }
555
+ async readBilling(input: { userId: string; before?: number }) {
556
+ await this.assertUserIdentity(input.userId);
557
+ return this.billing().snapshot(input.before);
558
+ }
559
+ /** Where the account's credit went, from the ledger's rollups. */
560
+ async readSpending(input: {
561
+ userId: string;
562
+ period: SpendPeriodV1;
563
+ groupBy: SpendDimensionV1;
564
+ filters: Partial<Record<SpendDimensionV1, string>>;
565
+ }): Promise<SpendingReportV1> {
566
+ await this.assertUserIdentity(input.userId);
567
+ const ledger = this.billing();
568
+ const [timezone, directory] = await Promise.all([
569
+ this.routineTimezone(input.userId),
570
+ (await this.flockContribution()).listBots(),
571
+ ]);
572
+ const query = {
573
+ ...spendWindowV1(
574
+ input.period,
575
+ Date.now(),
576
+ ledger.get<PaidAccessState>("paidAccess")?.periodStart,
577
+ ),
578
+ groupBy: input.groupBy,
579
+ filters: input.filters,
580
+ };
581
+ return spendingReportV1(
582
+ query,
583
+ readSpendingRowsV1(this.ctx.storage.sql, query),
584
+ {
585
+ userId: input.userId,
586
+ bots: Object.fromEntries(
587
+ directory.bots.map((bot) => [
588
+ bot.botId,
589
+ bot.currentProfile?.name ?? bot.initialName,
590
+ ]),
591
+ ),
592
+ },
593
+ timezone,
594
+ this.spendingCredit(ledger),
595
+ this.spendingLimits(localDayStartV1(Date.now(), timezone)),
596
+ );
597
+ }
598
+ /** Every daily limit the account set, and what each has spent today. */
599
+ private spendingLimits(dayStart: number) {
600
+ const sql = this.ctx.storage.sql;
601
+ return new Map(
602
+ [...readSpendLimitsV1(sql)].map(([scope, dailyMicros]) => [
603
+ scope,
604
+ {
605
+ dailyMicros,
606
+ todayMicros: spentOnScopeSinceV1(sql, scope, dayStart),
607
+ },
608
+ ]),
609
+ );
610
+ }
611
+ /** The person's last midnight, cached briefly: every paid call asks. */
612
+ private async dayStart(userId: string): Promise<number> {
613
+ const now = Date.now();
614
+ if (!this.cachedTimezone || this.cachedTimezone.until < now)
615
+ this.cachedTimezone = {
616
+ timezone: await this.routineTimezone(userId),
617
+ until: now + 60_000,
618
+ };
619
+ return localDayStartV1(now, this.cachedTimezone.timezone);
620
+ }
621
+ private cachedTimezone: { timezone: string; until: number } | undefined;
622
+
623
+ /** Set or clear one Bot's or Routine's daily limit. */
624
+ async setSpendingLimit(input: {
625
+ userId: string;
626
+ scope: string;
627
+ dailyMicros: number | null;
628
+ }): Promise<void> {
629
+ await this.assertUserIdentity(input.userId);
630
+ if (
631
+ !isSpendLimitScopeV1(input.scope) ||
632
+ (input.dailyMicros !== null &&
633
+ (!Number.isSafeInteger(input.dailyMicros) ||
634
+ input.dailyMicros <= 0 ||
635
+ input.dailyMicros > SPEND_LIMIT_MAX_MICROS_V1))
636
+ )
637
+ throw new BillingError("Invalid daily limit", 400);
638
+ this.billing();
639
+ setSpendLimitV1(this.ctx.storage.sql, input.scope, input.dailyMicros);
640
+ }
641
+
642
+ /**
643
+ * Whether any of these Routine or Bot limits has paused them today. An
644
+ * account with no limits answers without reading anything else.
645
+ */
646
+ async readSpendingPaused(input: {
647
+ userId: string;
648
+ scopes: string[];
649
+ }): Promise<boolean> {
650
+ await this.assertUserIdentity(input.userId);
651
+ this.billing();
652
+ const sql = this.ctx.storage.sql;
653
+ if (readSpendLimitsV1(sql).size === 0) return false;
654
+ const dayStart = await this.dayStart(input.userId);
655
+ return input.scopes
656
+ .filter(isSpendLimitScopeV1)
657
+ .some((scope) => spendScopePausedV1(sql, scope, dayStart));
658
+ }
659
+
660
+ /**
661
+ * A day well above a Routine's usual, told once a day: the first ask that
662
+ * finds one gets it, and every later ask that day gets nothing.
663
+ */
664
+ async claimSpendingSpike(input: {
665
+ userId: string;
666
+ scope: string;
667
+ }): Promise<{ todayMicros: number; usualMicros: number } | null> {
668
+ await this.assertUserIdentity(input.userId);
669
+ if (!isSpendLimitScopeV1(input.scope)) return null;
670
+ const ledger = this.billing();
671
+ const dayStart = await this.dayStart(input.userId);
672
+ const spike = spendSpikeV1(this.ctx.storage.sql, input.scope, dayStart);
673
+ if (!spike) return null;
674
+ // One key per scope, holding the day it was last told.
675
+ const told = `spike:${input.scope}`;
676
+ if (ledger.get<number>(told) === dayStart) return null;
677
+ ledger.set(told, dayStart);
678
+ return spike;
679
+ }
680
+ /**
681
+ * How long the account's credit lasts at its recent pace. None where the
682
+ * deployment does not bill: there is no credit to run out.
683
+ */
684
+ private spendingCredit(ledger: BillingLedger): SpendingCreditV1 | null {
685
+ if (!hostedBillingEnabledV1(this.env as BillingSwitchEnv)) return null;
686
+ const now = Date.now();
687
+ const balance = ledger.balance();
688
+ return spendingCreditV1(
689
+ // Without a subscription only complimentary credit is spendable.
690
+ balance.subscribed
691
+ ? balance.includedMicros +
692
+ balance.complimentaryMicros +
693
+ balance.purchasedMicros
694
+ : balance.complimentaryMicros,
695
+ dailySpendV1(this.ctx.storage.sql, now),
696
+ balance.subscribed
697
+ ? (ledger.get<PaidAccessState>("paidAccess")?.periodEnd ?? null)
698
+ : null,
699
+ now,
700
+ );
701
+ }
702
+ async readBillingBalance(input: { userId: string }): Promise<BillingBalance> {
703
+ await this.assertUserIdentity(input.userId);
704
+ return this.billing().balance();
705
+ }
706
+ /** An administrator's hand-granted credit. Returns the balance it left. */
707
+ async grantComplimentaryCredit(input: {
708
+ userId: string;
709
+ command: ComplimentaryGrant;
710
+ }): Promise<BillingBalance> {
711
+ await this.assertUserIdentity(input.userId);
712
+ const ledger = this.billing();
713
+ ledger.grantComplimentary(input.command);
714
+ return ledger.balance();
715
+ }
716
+ async billingCheckout(input: {
717
+ userId: string;
718
+ command: { id: string; kind: "subscription" | "topup"; cents?: number };
719
+ }) {
720
+ await this.assertUserIdentity(input.userId);
721
+ await this.assertAccountOpen();
722
+ return accountPayments(this.billing(), this.env, input.userId).checkout(
723
+ input.command,
724
+ );
725
+ }
726
+ async billingPortal(input: { userId: string; commandId: string }) {
727
+ await this.assertUserIdentity(input.userId);
728
+ await this.assertAccountOpen();
729
+ return accountPayments(this.billing(), this.env, input.userId).portal(
730
+ input.commandId,
731
+ );
732
+ }
733
+ async billingWebhook(input: {
734
+ userId: string;
735
+ event: Record<string, unknown>;
736
+ }) {
737
+ await this.assertUserIdentity(input.userId);
738
+ await accountPayments(this.billing(), this.env, input.userId).webhook(
739
+ input.event,
740
+ );
741
+ }
742
+ async reserveUsage(input: { userId: string; reservation: UsageReservation }) {
743
+ await this.assertUserIdentity(input.userId);
744
+ await this.assertAccountOpen();
745
+ const ledger = this.billing();
746
+ // Only an account with a limit pays for reading its timezone.
747
+ const limited = readSpendLimitsV1(this.ctx.storage.sql).size > 0;
748
+ return ledger.reserve(
749
+ input.reservation,
750
+ limited ? await this.dayStart(input.userId) : undefined,
751
+ );
752
+ }
753
+ async settleUsage(input: { userId: string; settlement: UsageSettlement }) {
754
+ await this.assertUserIdentity(input.userId);
755
+ this.billing().settle(input.settlement);
756
+ }
757
+ async requirePaidAccount(input: { userId: string }) {
758
+ await this.assertUserIdentity(input.userId);
759
+ this.billing().requireSubscription();
760
+ }
761
+
762
+ /**
763
+ * Counts one upload against the account's upload space, once for each Bot
764
+ * and file, in one transaction so two uploads cannot both take the last of
765
+ * it (`app/uploads/quota.ts`).
766
+ */
767
+ async reserveUploadQuota(input: unknown): Promise<UploadQuotaAnswerV1> {
768
+ const request = decodeRpcEnvelopeV1(input, {
769
+ userId: rpcIdentifier,
770
+ botId: rpcBotId,
771
+ uploadId: rpcPattern(/^[0-9a-f]{64}$/, 64),
772
+ bytes: rpcInteger({ minimum: 1, maximum: UPLOAD_MAX_BYTES_V1 }),
773
+ });
774
+ await this.assertUserIdentity(request.userId as string);
775
+ // A deleting account takes no new file: its Bots' uploads are being
776
+ // removed, and one counted now could land after its Bot's were.
777
+ await this.assertAccountOpen();
778
+ return this.ctx.storage.transaction((transaction) =>
779
+ reserveUploadQuotaV1(transaction, {
780
+ botId: request.botId as string,
781
+ uploadId: request.uploadId as string,
782
+ bytes: request.bytes as number,
783
+ }),
784
+ );
785
+ }
786
+
787
+ /**
788
+ * Gives back the space of uploads one Bot deleted while it lives on — a
789
+ * demonstration the person sent it, once its Skill is decided. Idempotent.
790
+ * It works on a deleting account too: giving space back starts nothing.
791
+ */
792
+ async releaseUploadQuota(input: unknown): Promise<{ released: number }> {
793
+ const request = decodeRpcEnvelopeV1(input, {
794
+ userId: rpcIdentifier,
795
+ botId: rpcBotId,
796
+ uploadIds: rpcDecoded((value) => {
797
+ if (
798
+ !Array.isArray(value) ||
799
+ value.length === 0 ||
800
+ value.length > 16 ||
801
+ !value.every((entry) => isUploadIdV1(entry))
802
+ ) {
803
+ throw new Error("uploadIds must name one to sixteen uploads");
804
+ }
805
+ return value as string[];
806
+ }),
807
+ });
808
+ await this.assertUserIdentity(request.userId as string);
809
+ return this.ctx.storage.transaction((transaction) =>
810
+ releaseUploadQuotaV1(transaction, {
811
+ botId: request.botId as string,
812
+ uploadIds: request.uploadIds as string[],
813
+ }),
814
+ );
815
+ }
816
+
817
+ /** Gives back the upload space of a Bot that has been deleted. Idempotent. */
818
+ async releaseBotUploadQuota(input: unknown): Promise<{ released: number }> {
819
+ const request = decodeRpcEnvelopeV1(input, {
820
+ userId: rpcIdentifier,
821
+ botId: rpcBotId,
822
+ });
823
+ await this.assertUserIdentity(request.userId as string);
824
+ let released = 0;
825
+ for (;;) {
826
+ const page = await this.ctx.storage.transaction((transaction) =>
827
+ releaseBotUploadQuotaV1(transaction, request.botId as string),
828
+ );
829
+ released += page.released;
830
+ if (!page.more) return { released };
831
+ }
832
+ }
833
+
834
+ async registerPush(input: { userId: string; registration: unknown }) {
835
+ await this.assertUserIdentity(input.userId);
836
+ await this.assertAccountOpen();
837
+ await registerPushDevice(
838
+ this.ctx.storage,
839
+ decodePushRegistration(input.registration),
840
+ );
841
+ return { ok: true };
842
+ }
843
+
844
+ async deliverPush(input: { userId: string; update: PushUpdate }) {
845
+ await this.assertUserIdentity(input.userId);
846
+ await this.assertAccountOpen();
847
+ if (
848
+ !isPublicIdentifier(input.update.botId) ||
849
+ !/^message-[0-9]{20}$/.test(input.update.cursor) ||
850
+ !["message", "read"].includes(input.update.kind)
851
+ )
852
+ throw new Error("Invalid push update");
853
+ await deliverPush(
854
+ this.ctx.storage,
855
+ input.userId,
856
+ input.update,
857
+ this.env.FCM_SERVICE_ACCOUNT,
858
+ );
859
+ }
860
+
861
+ /**
862
+ * A member of a Group Chat called the person's attention with `@User`.
863
+ * The group is what the alert opens; its cursor is the group's own.
864
+ */
865
+ async deliverGroupPush(input: unknown) {
866
+ const request = decodeRpcEnvelopeV1(input, {
867
+ userId: rpcIdentifier,
868
+ groupId: rpcPattern(/^g-[0-9a-f]{20}$/, 22),
869
+ botId: rpcBotId,
870
+ seq: rpcInteger({ minimum: 1, maximum: 99_999_999_999 }),
871
+ title: rpcText(200),
872
+ body: rpcText(240),
873
+ });
874
+ const userId = request.userId as string;
875
+ await this.assertUserIdentity(userId);
876
+ await this.assertAccountOpen();
877
+ await deliverPush(
878
+ this.ctx.storage,
879
+ userId,
880
+ {
881
+ botId: request.botId as string,
882
+ groupId: request.groupId as string,
883
+ cursor: `message-${String(request.seq).padStart(20, "0")}`,
884
+ kind: "message",
885
+ title: request.title as string,
886
+ body: request.body as string,
887
+ notify: true,
888
+ },
889
+ this.env.FCM_SERVICE_ACCOUNT,
890
+ );
891
+ return { schemaVersion: 1 } as const;
892
+ }
893
+
894
+ async nativeSession(input: unknown) {
895
+ try {
896
+ const operation = decodeNativeSessionOperation(input);
897
+ if (operation.action === "issue") {
898
+ await this.assertUserIdentity(operation.userId);
899
+ await this.assertAccountOpen();
900
+ } else if (
901
+ // Deleting the account signs every device out at once: a session
902
+ // that still read back could be admitted afresh once the access
903
+ // record is forgotten.
904
+ (await this.accountClosing()) ||
905
+ (await this.addressedUser(operation.userId)) === undefined
906
+ ) {
907
+ return {
908
+ schemaVersion: 1 as const,
909
+ status: "ok" as const,
910
+ record: null,
911
+ };
912
+ }
913
+ const record = this.ctx.storage.transactionSync(() =>
914
+ nativeSessionOperation(this.ctx.storage.kv, operation, Date.now()),
915
+ );
916
+ return { schemaVersion: 1 as const, status: "ok" as const, record };
917
+ } catch {
918
+ // RPC refusals are data. Expected failures must not escape a DO entry
919
+ // as unhandled promise rejections in workerd.
920
+ return { schemaVersion: 1 as const, status: "refused" as const };
921
+ }
922
+ }
923
+
924
+ private mounted: Promise<MountedFoundationUserBackend> | undefined;
925
+
926
+ private contributions(): Promise<MountedFoundationUserBackend> {
927
+ if (!this.mounted) {
928
+ this.mounted = createFoundationUserBackendContributions({
929
+ brand: BRAND_V1,
930
+ storage: this.ctx.storage,
931
+ machineSockets: durableObjectMachineSocketsV1(this.ctx),
932
+ readSecret: (name) =>
933
+ name === "MACHINE_TOKEN_SECRET"
934
+ ? this.env.MACHINE_TOKEN_SECRET
935
+ : name === "BETTER_AUTH_URL"
936
+ ? this.env.BETTER_AUTH_URL
937
+ : name === "COMPOSIO_API_KEY"
938
+ ? this.env.COMPOSIO_API_KEY
939
+ : this.env.CREDENTIAL_KEYRING,
940
+ // The transcript index (parity register row 52). It lives on this
941
+ // object's own SQL storage because "The User's Durable Object is the
942
+ // authority for everything User-scoped", and it is a *projection*:
943
+ // its rows are read back out of the Bots' own stored runs by
944
+ // `rebuildSearchIndex`, so it holds no authority of its own.
945
+ search: {
946
+ sql: this.ctx.storage.sql,
947
+ projectBotRows: (botId, cursor) => {
948
+ // Every caller of a rebuild has already passed
949
+ // `assertFlockIdentity`, so this object knows which User it is;
950
+ // a rebuild that reached here without one would address an
951
+ // arbitrary Bot object, so it refuses instead.
952
+ const userId = this.identity;
953
+ if (!userId) {
954
+ throw new Error(
955
+ "this User Durable Object has no proven identity to rebuild for",
956
+ );
957
+ }
958
+ const id = this.env.BOT_STATES.idFromName(`${userId}:${botId}`);
959
+ // SAFETY: BOT_STATES is bound to BotState; generated RPC methods are not represented by workers-types.
960
+ const rpc = this.env.BOT_STATES.get(id) as unknown as BotSearchRpc;
961
+ return rpc
962
+ .projectSearchRows({
963
+ schemaVersion: 1,
964
+ userId,
965
+ botId,
966
+ ...(cursor === undefined ? {} : { cursor }),
967
+ })
968
+ .then(rpcJsonSnapshotV1);
969
+ },
970
+ },
971
+ // The audit table (parity register rows 30 and 30b). Same object,
972
+ // same SQL storage, same discipline as the transcript index: every
973
+ // row is a projection of the Bots' own durable session events, and
974
+ // `rebuildAuditIndex` reads them back from that authority.
975
+ audit: {
976
+ sql: this.ctx.storage.sql,
977
+ projectBotEntries: (botId, cursor) => {
978
+ const userId = this.identity;
979
+ if (!userId) {
980
+ throw new Error(
981
+ "this User Durable Object has no proven identity to rebuild for",
982
+ );
983
+ }
984
+ const id = this.env.BOT_STATES.idFromName(`${userId}:${botId}`);
985
+ // SAFETY: BOT_STATES is bound to BotState; generated RPC methods are not represented by workers-types.
986
+ const rpc = this.env.BOT_STATES.get(id) as unknown as BotAuditRpc;
987
+ return rpc
988
+ .projectAuditEntries({
989
+ schemaVersion: 1,
990
+ userId,
991
+ botId,
992
+ ...(cursor === undefined ? {} : { cursor }),
993
+ })
994
+ .then(rpcJsonSnapshotV1);
995
+ },
996
+ },
997
+ commandBotLifecycle: async (userId, command) => {
998
+ const id = this.env.BOT_STATES.idFromName(
999
+ `${userId}:${command.botId}`,
1000
+ );
1001
+ // SAFETY: BOT_STATES is bound to BotState; generated RPC methods are not represented by workers-types.
1002
+ const rpc = this.env.BOT_STATES.get(id) as unknown as {
1003
+ executeLifecycle(input: unknown): Promise<unknown>;
1004
+ };
1005
+ return decodeBotLifecycleReceiptV1(
1006
+ await rpc.executeLifecycle({
1007
+ schemaVersion: 1,
1008
+ userId,
1009
+ botId: command.botId,
1010
+ command,
1011
+ }),
1012
+ );
1013
+ },
1014
+ readBotLifecycle: async (userId, botId) => {
1015
+ const id = this.env.BOT_STATES.idFromName(`${userId}:${botId}`);
1016
+ // SAFETY: BOT_STATES is bound to BotState; generated RPC methods are not represented by workers-types.
1017
+ const rpc = this.env.BOT_STATES.get(id) as unknown as {
1018
+ readLifecycle(input: unknown): Promise<unknown>;
1019
+ };
1020
+ return decodeBotLifecycleViewV1(
1021
+ await rpc.readLifecycle({ schemaVersion: 1, userId, botId }),
1022
+ );
1023
+ },
1024
+ });
1025
+ }
1026
+ return this.mounted;
1027
+ }
1028
+
1029
+ /**
1030
+ * The User this Durable Object is.
1031
+ *
1032
+ * Comparing an RPC's `userId` with a root's `userId` proves only that the
1033
+ * request agrees with itself; both come from the caller. A User Durable
1034
+ * Object is addressed by `idFromName(userId)`, so its identity is the name
1035
+ * it was constructed for: the id derived from the claimed `userId` must be
1036
+ * this object's own id. That identity is also pinned in durable storage the
1037
+ * first time it is asserted — provisioning is the first RPC a new User
1038
+ * object ever receives — so the check survives eviction.
1039
+ *
1040
+ * A missing namespace binding is a refusal, not a fallback. Without it the
1041
+ * only remaining check is the pin, and the pin trusts whoever called first:
1042
+ * an object that has never been addressed would take its identity from the
1043
+ * caller and then defend it for ever. The binding is present in production
1044
+ * and every test binds it, so its absence is a broken deployment rather than
1045
+ * a state to serve requests in.
1046
+ */
1047
+ private identity: string | undefined;
1048
+
1049
+ /**
1050
+ * Whether this instance has already given the account General, or found it
1051
+ * given. Separate from `identity`, which an alarm restores with no request
1052
+ * behind it.
1053
+ */
1054
+ private bootstrapped = false;
1055
+
1056
+ private async assertUserIdentity(userId: string): Promise<string> {
1057
+ if (this.identity !== userId) await this.proveUserIdentity(userId);
1058
+ if (!this.bootstrapped) {
1059
+ // Every admitted request reaches its User through here, so this is where
1060
+ // an account is given General: before the first thing it reads, and not
1061
+ // only when a client happens to read the directory. The memo makes it
1062
+ // once per instance, and the Flock's marker makes it once ever.
1063
+ await (await this.flockContribution()).provisionGeneral();
1064
+ this.bootstrapped = true;
1065
+ }
1066
+ return userId;
1067
+ }
1068
+
1069
+ private async proveUserIdentity(userId: string): Promise<void> {
1070
+ const namespace = this.env.USER_CONFIGURATIONS;
1071
+ if (!namespace) {
1072
+ throw new Error(
1073
+ "the User Durable Object namespace is unbound, so this object cannot prove which User it is",
1074
+ );
1075
+ }
1076
+ if (!namespace.idFromName(userId).equals(this.ctx.id)) {
1077
+ throw new Error(
1078
+ "this User Durable Object is the authority for a different User",
1079
+ );
1080
+ }
1081
+ // A deleted account is never provisioned again, whoever asks: a late
1082
+ // webhook or a stray Bot call would otherwise pin a fresh identity and
1083
+ // give it a General.
1084
+ await this.assertNotDeleted();
1085
+ const pinned = await this.ctx.storage.get<string>(USER_IDENTITY_KEY);
1086
+ if (pinned !== undefined && pinned !== userId) {
1087
+ throw new Error(
1088
+ "this User Durable Object is the authority for a different User",
1089
+ );
1090
+ }
1091
+ if (pinned === undefined) {
1092
+ await this.ctx.storage.put(USER_IDENTITY_KEY, userId);
1093
+ }
1094
+ this.identity = userId;
1095
+ }
1096
+
1097
+ private async assertNotDeleted(): Promise<void> {
1098
+ if (
1099
+ (await this.ctx.storage.get<unknown>(ACCOUNT_DELETED_KEY_V1)) !==
1100
+ undefined
1101
+ )
1102
+ throw new AccountDeletedError();
1103
+ }
1104
+
1105
+ /**
1106
+ * Refuses work that would start something while the account is being
1107
+ * deleted: a Turn, a spend, a Bot, a group, a connection, a payment, a push,
1108
+ * a saved secret or a fill of one.
1109
+ * Each would either reach something the deletion already removed or leave
1110
+ * something behind it. The saga's own calls — a Bot reading its
1111
+ * registration to tear itself down — do not come through here.
1112
+ */
1113
+ private async assertAccountOpen(): Promise<void> {
1114
+ if (await this.accountClosing()) throw new AccountDeletedError();
1115
+ }
1116
+
1117
+ /** Whether this account is being deleted, or has been. */
1118
+ private async accountClosing(): Promise<boolean> {
1119
+ return (
1120
+ (await this.ctx.storage.get<unknown>(ACCOUNT_DELETION_KEY_V1)) !==
1121
+ undefined ||
1122
+ (await this.ctx.storage.get<unknown>(ACCOUNT_DELETED_KEY_V1)) !==
1123
+ undefined
1124
+ );
1125
+ }
1126
+
1127
+ /**
1128
+ * The identity this object can prove with no caller present.
1129
+ *
1130
+ * `assertUserIdentity` checks a `userId` a caller claimed, and an alarm has
1131
+ * no caller: a fresh instance woken by its own alarm after an eviction is
1132
+ * the normal case, not the exception, so the work an alarm exists to do
1133
+ * cannot depend on an RPC having populated the in-memory field first.
1134
+ *
1135
+ * The pin is that claim made durable, so it is re-derived here and put
1136
+ * through the same namespace check before it is trusted — a pin alone would
1137
+ * be whatever the first caller said. An object that cannot prove an identity
1138
+ * this way has no User-scoped recovery to run.
1139
+ */
1140
+ private async provenIdentity(): Promise<string | undefined> {
1141
+ if (this.identity) return this.identity;
1142
+ const pinned = await this.ctx.storage.get<string>(USER_IDENTITY_KEY);
1143
+ if (pinned === undefined) return undefined;
1144
+ const namespace = this.env.USER_CONFIGURATIONS;
1145
+ if (!namespace || !namespace.idFromName(pinned).equals(this.ctx.id)) {
1146
+ return undefined;
1147
+ }
1148
+ this.identity = pinned;
1149
+ return pinned;
1150
+ }
1151
+
1152
+ private async settingsContribution(): Promise<
1153
+ MountedFoundationUserBackend["settings"]
1154
+ > {
1155
+ return (await this.contributions()).settings;
1156
+ }
1157
+
1158
+ private async connectionContribution(
1159
+ packageId: string,
1160
+ ): Promise<FoundationConnectionUserBackendContribution> {
1161
+ const contribution = (await this.contributions()).connections.get(
1162
+ packageId,
1163
+ );
1164
+ if (!contribution) {
1165
+ throw new Error(`Connection Package "${packageId}" is unavailable`);
1166
+ }
1167
+ return contribution;
1168
+ }
1169
+
1170
+ private async connectContribution(): Promise<ConnectUserBackendContribution> {
1171
+ return (await this.contributions()).connect;
1172
+ }
1173
+
1174
+ private botRoutinesStub(userId: string, botId: string) {
1175
+ const id = this.env.BOT_STATES.idFromName(`${userId}:${botId}`);
1176
+ return this.env.BOT_STATES.get(id) as unknown as {
1177
+ deliverConnectEvent(input: unknown): Promise<unknown>;
1178
+ executeRoutineCommand(input: unknown): Promise<unknown>;
1179
+ deliverPluginModuleEvent(
1180
+ input: unknown,
1181
+ ): Promise<{ status: string; reason?: string }>;
1182
+ listPluginTriggerRoutines(
1183
+ input: unknown,
1184
+ ): Promise<
1185
+ Array<{ routineId: string; pluginId: string; trigger: string }>
1186
+ >;
1187
+ };
1188
+ }
1189
+
1190
+ private async flockContribution(): Promise<
1191
+ MountedFoundationUserBackend["flock"]
1192
+ > {
1193
+ return (await this.contributions()).flock;
1194
+ }
1195
+
1196
+ /**
1197
+ * Checks that this object is the one `userId` names, without provisioning
1198
+ * it: the durable pin is read and compared, never written. What comes back
1199
+ * is the pin, so a caller can tell an unprovisioned User from a provisioned
1200
+ * one.
1201
+ */
1202
+ private async addressedUser(userId: string): Promise<string | undefined> {
1203
+ const namespace = this.env.USER_CONFIGURATIONS;
1204
+ if (!namespace || !namespace.idFromName(userId).equals(this.ctx.id)) {
1205
+ throw new Error(
1206
+ "this User Durable Object is the authority for a different User",
1207
+ );
1208
+ }
1209
+ await this.assertNotDeleted();
1210
+ const pinned = await this.ctx.storage.get<string>(USER_IDENTITY_KEY);
1211
+ if (pinned !== undefined && pinned !== userId) {
1212
+ throw new Error(
1213
+ "this User Durable Object is the authority for a different User",
1214
+ );
1215
+ }
1216
+ return pinned;
1217
+ }
1218
+
1219
+ // --- Deleting the account ---------------------------------------------------
1220
+ //
1221
+ // The person confirmed, so everything they own goes now. The saga is
1222
+ // `@frockbot/app/account/deletion`; its steps are `./account-deletion.ts`.
1223
+
1224
+ /**
1225
+ * Starts deleting this account, or joins the deletion already under way.
1226
+ *
1227
+ * The record is written first, which closes this object to new work at
1228
+ * once, and access is ended before the answer so the person's other devices
1229
+ * are refused from their next request rather than from the alarm's. The
1230
+ * rest runs on the alarm, which is set before anything else can fail.
1231
+ */
1232
+ async beginAccountDeletion(input: unknown) {
1233
+ const request = decodeRpcEnvelopeV1(
1234
+ input,
1235
+ { userId: rpcIdentifier, commandId: rpcIdentifier },
1236
+ { email: rpcString(320) },
1237
+ );
1238
+ const userId = await this.assertUserIdentity(request.userId as string);
1239
+ // Only an address the access authority would itself accept, or its last
1240
+ // step could never forget the invitation kept under it.
1241
+ const email = accessEmailV1(request.email);
1242
+ const { record } = await beginAccountDeletionV1(this.ctx.storage, {
1243
+ userId,
1244
+ commandId: request.commandId as string,
1245
+ ...(email === undefined ? {} : { email }),
1246
+ });
1247
+ await this.ctx.storage.setAlarm(Date.now());
1248
+ try {
1249
+ await runAccountDeletionStepV1(
1250
+ this.env,
1251
+ this.accountDeletionSeams(userId),
1252
+ "access",
1253
+ record,
1254
+ );
1255
+ } catch {
1256
+ // The saga's own first step repeats it.
1257
+ }
1258
+ return {
1259
+ schemaVersion: 1 as const,
1260
+ status: "deleting" as const,
1261
+ requestedAt: record.requestedAt,
1262
+ };
1263
+ }
1264
+
1265
+ /**
1266
+ * "Delete my Computer": its files and browser logins go, and the next Bot
1267
+ * that needs a Computer gets a new, empty one.
1268
+ *
1269
+ * Receipted by the command, so a retried press after a success never
1270
+ * destroys the fresh Computer a Bot may have opened since. A press that
1271
+ * failed wrote no receipt and can simply be pressed again: tearing down a
1272
+ * Computer that is already gone is a teardown.
1273
+ */
1274
+ async deleteComputer(input: unknown) {
1275
+ const request = decodeRpcEnvelopeV1(input, {
1276
+ userId: rpcIdentifier,
1277
+ commandId: rpcIdentifier,
1278
+ });
1279
+ const userId = await this.assertUserIdentity(request.userId as string);
1280
+ await this.assertAccountOpen();
1281
+ const receiptKey = `${COMPUTER_TEARDOWN_RECEIPT_PREFIX}${request.commandId as string}`;
1282
+ if ((await this.ctx.storage.get<unknown>(receiptKey)) !== undefined)
1283
+ return { schemaVersion: 1 as const, status: "deleted" as const };
1284
+ const binding = computerHostBindingV1(this.env);
1285
+ const host = binding ? createComputerHostV1(binding) : undefined;
1286
+ if (!host?.teardown)
1287
+ return { schemaVersion: 1 as const, status: "unavailable" as const };
1288
+ // The kept sign-ins go with the Computer, on both sides of the teardown:
1289
+ // before it, so an Update under way stops before it opens a new machine;
1290
+ // after it, so a capture a Turn's end kept in between is dropped too.
1291
+ const logins = this.computerLogins();
1292
+ await logins.forget(new Date().toISOString());
1293
+ await host.teardown({ userId });
1294
+ await logins.forget(new Date().toISOString());
1295
+ await this.ctx.storage.put(receiptKey, {
1296
+ schemaVersion: 1,
1297
+ deletedAt: new Date().toISOString(),
1298
+ });
1299
+ return { schemaVersion: 1 as const, status: "deleted" as const };
1300
+ }
1301
+
1302
+ #deletionPass: Promise<void> | undefined;
1303
+
1304
+ /**
1305
+ * One pass of the deletion saga, and never two at once: a pass that finds
1306
+ * another running waits for it rather than repeating its steps beside it.
1307
+ */
1308
+ private advanceAccountDeletion(): Promise<void> {
1309
+ this.#deletionPass ??= this.#advanceAccountDeletion().finally(() => {
1310
+ this.#deletionPass = undefined;
1311
+ });
1312
+ return this.#deletionPass;
1313
+ }
1314
+
1315
+ /**
1316
+ * The next alarm is armed before the pass, so a pass that dies half way —
1317
+ * an eviction, a thrown step the entry boundary swallowed — is picked up
1318
+ * again rather than stranding the account half deleted.
1319
+ */
1320
+ async #advanceAccountDeletion(): Promise<void> {
1321
+ if (
1322
+ (await this.ctx.storage.get<unknown>(ACCOUNT_DELETED_KEY_V1)) !==
1323
+ undefined
1324
+ ) {
1325
+ await this.ctx.storage.deleteAlarm();
1326
+ return;
1327
+ }
1328
+ await this.ctx.storage.setAlarm(Date.now() + 60_000);
1329
+ const progress = await advanceAccountDeletionV1({
1330
+ storage: this.ctx.storage,
1331
+ run: (step, record: AccountDeletionRecordV1) =>
1332
+ runAccountDeletionStepV1(
1333
+ this.env,
1334
+ this.accountDeletionSeams(record.userId),
1335
+ step,
1336
+ record,
1337
+ ),
1338
+ erase: (tombstone) => this.eraseAccount(tombstone),
1339
+ });
1340
+ const delay = accountDeletionRetryDelayMsV1(progress);
1341
+ if (delay !== undefined)
1342
+ await this.ctx.storage.setAlarm(Date.now() + delay);
1343
+ }
1344
+
1345
+ /**
1346
+ * The object's own storage, last: every key and every table, the alarm,
1347
+ * then the tombstone. Nothing else runs in between, so no request can read
1348
+ * or write the half-wiped object. What is in memory goes too, so nothing
1349
+ * cached from before can write the account back.
1350
+ *
1351
+ * The tombstone cannot be written before the wipe, which would take it
1352
+ * too. An object that died between the two would be empty with nothing
1353
+ * saying why — by then with no identity anyone could sign in as, no access
1354
+ * record, and nothing external left to reach.
1355
+ */
1356
+ private async eraseAccount(
1357
+ tombstone: AccountDeletionTombstoneV1,
1358
+ ): Promise<void> {
1359
+ await this.ctx.blockConcurrencyWhile(async () => {
1360
+ await this.ctx.storage.deleteAlarm();
1361
+ await this.ctx.storage.deleteAll();
1362
+ await this.ctx.storage.put(ACCOUNT_DELETED_KEY_V1, tombstone);
1363
+ });
1364
+ this.mounted = undefined;
1365
+ this.identity = undefined;
1366
+ this.bootstrapped = false;
1367
+ this.#memoryEngine = undefined;
1368
+ }
1369
+
1370
+ private accountDeletionSeams(userId: string): AccountDeletionUserSeamsV1 {
1371
+ const sql = durableObjectHasSqlV1(this.ctx.storage);
1372
+ return {
1373
+ deleteGroupChats: async (cursor) => {
1374
+ const store = this.groupChats();
1375
+ // The groups are named in the saga's cursor before any is deleted:
1376
+ // a delete commits here before its group's own object is destroyed,
1377
+ // and a retry that re-listed would no longer see the group whose
1378
+ // object it still owes a destroy. Replaying the same command carries
1379
+ // that destroy again.
1380
+ if (cursor === undefined) {
1381
+ const groupIds = (await store.list()).groups.map(
1382
+ (group) => group.groupId,
1383
+ );
1384
+ return groupIds.length === 0
1385
+ ? { status: "complete" }
1386
+ : { status: "pending", cursor: groupIds.join(",") };
1387
+ }
1388
+ for (const groupId of cursor.split(",")) {
1389
+ let result;
1390
+ try {
1391
+ result = await store.execute(
1392
+ userId,
1393
+ {
1394
+ type: "group/delete",
1395
+ commandId: `account-deletion-${groupId}`,
1396
+ groupId,
1397
+ },
1398
+ { kind: "user" },
1399
+ );
1400
+ } catch (error) {
1401
+ // Deleted meanwhile by a command of its own, which carried it.
1402
+ if (error instanceof GroupChatNotFoundError) continue;
1403
+ throw error;
1404
+ }
1405
+ if (result.change) await this.carryGroupChange(userId, result.change);
1406
+ }
1407
+ return { status: "complete" };
1408
+ },
1409
+ deleteBots: async () => {
1410
+ const flock = await this.flockContribution();
1411
+ // A lifecycle saga already retrying — an archive, a delete the person
1412
+ // started — settles first; this Bot's delete joins on the next pass.
1413
+ await flock.alarm();
1414
+ for (const bot of (await flock.listBots()).bots) {
1415
+ try {
1416
+ await flock.executeLifecycle(userId, {
1417
+ schemaVersion: 1,
1418
+ type: "bot/delete",
1419
+ commandId: `account-deletion-${(await sha256HexV1(bot.botId)).slice(0, 32)}`,
1420
+ botId: bot.botId,
1421
+ });
1422
+ } catch {
1423
+ // Another operation holds the Bot, or it left meanwhile. Either
1424
+ // way the directory decides, on the next pass.
1425
+ }
1426
+ }
1427
+ return (await flock.listBots()).bots.length === 0
1428
+ ? { status: "complete" }
1429
+ : { status: "pending" };
1430
+ },
1431
+ recordedPaymentCustomer: () =>
1432
+ sql ? this.billing().get<string>("customer") : undefined,
1433
+ vectorIdsAfter: (cursor, limit) =>
1434
+ sql ? this.memoryEngine().vectorIdsAfter(cursor, limit) : [],
1435
+ deleteIdentity: async () => {
1436
+ const { AUTH_PACKAGE_V1 } = await import("#auth-package");
1437
+ await AUTH_PACKAGE_V1.create(this.env, {
1438
+ productName: BRAND_V1.productName,
1439
+ }).deleteStoredIdentity?.(userId);
1440
+ },
1441
+ eraseVoice: async () => {
1442
+ const { getAgentByName } = await import("agents");
1443
+ const voice = await getAgentByName(
1444
+ this.env.VOICE_ASSISTANTS as DurableObjectNamespace<VoiceAssistant>,
1445
+ userId,
1446
+ );
1447
+ await voice.eraseAccount({ schemaVersion: 1, userId });
1448
+ },
1449
+ revokeMcpGrants: async (giveUp) =>
1450
+ (await this.contributions()).mcp.revokeGrantsForDeletion({
1451
+ userId,
1452
+ giveUp,
1453
+ }),
1454
+ };
1455
+ }
1456
+
1457
+ // --- Account features ------------------------------------------------------
1458
+ //
1459
+ // What an administrator turned on for this User. Neither RPC pins the
1460
+ // identity: an admin reads and sets features for accounts that have signed
1461
+ // up but never been admitted, and doing so must not provision them.
1462
+
1463
+ async readFeatures(input: unknown): Promise<UserFeaturesV1> {
1464
+ const request = decodeUserFeaturesReadRequestV1(input);
1465
+ await this.addressedUser(request.userId);
1466
+ const stored = await this.ctx.storage.get<unknown>(USER_FEATURES_KEY);
1467
+ return stored === undefined
1468
+ ? defaultUserFeaturesV1()
1469
+ : decodeUserFeaturesV1(stored);
1470
+ }
1471
+
1472
+ async setFeatures(input: unknown): Promise<UserFeaturesV1> {
1473
+ const request = decodeSetUserFeaturesRequestV1(input);
1474
+ await this.addressedUser(request.userId);
1475
+ const current = decodeUserFeaturesV1(
1476
+ (await this.ctx.storage.get<unknown>(USER_FEATURES_KEY)) ??
1477
+ defaultUserFeaturesV1(),
1478
+ );
1479
+ const next: UserFeaturesV1 = {
1480
+ schemaVersion: 1,
1481
+ pluginAuthoring:
1482
+ request.command.pluginAuthoring ?? current.pluginAuthoring,
1483
+ plugins: request.command.plugins ?? current.plugins,
1484
+ updatedAt: new Date().toISOString(),
1485
+ updatedBy: request.updatedBy,
1486
+ };
1487
+ await this.ctx.storage.put(USER_FEATURES_KEY, next);
1488
+ return next;
1489
+ }
1490
+
1491
+ async readConfiguration(input: unknown): Promise<UserSettingsViewV1> {
1492
+ const request = decodeUserConfigurationReadRpcV1(input);
1493
+ await this.assertUserIdentity(request.userId);
1494
+ const settings = await (
1495
+ await this.settingsContribution()
1496
+ ).readConfiguration(request);
1497
+ if (request.view === 2) return settings;
1498
+ return (await this.settingsContribution()).previousSettingsView(settings);
1499
+ }
1500
+
1501
+ async readConnectionsFrame(input: unknown) {
1502
+ const count = (minimum: number) =>
1503
+ rpcInteger({ minimum, maximum: CONNECTIONS_FRAME_PROVIDERS_MAX_V1 });
1504
+ const request = decodeRpcEnvelopeV1(
1505
+ input,
1506
+ { userId: rpcIdentifier },
1507
+ {
1508
+ catalog: rpcObject({
1509
+ query: rpcText(CONNECTIONS_CATALOG_QUERY_MAX_V1),
1510
+ kinds: rpcArray(rpcEnum(["model", "connector"]), 2),
1511
+ installed: rpcBoolean,
1512
+ cursor: count(0),
1513
+ limit: count(1),
1514
+ }),
1515
+ },
1516
+ );
1517
+ await this.assertUserIdentity(request.userId as string);
1518
+ return (await this.settingsContribution()).readConnectionsFrame(
1519
+ request.userId as string,
1520
+ request.catalog as ConnectionsCatalogQueryV1 | undefined,
1521
+ // A provider served by a Plugin is not an offer on a deployment with no
1522
+ // worker loader to mount it: adding it would choose a model no Turn can
1523
+ // run.
1524
+ this.env.BOT_PACKAGES ? [] : marketplacePluginPackageIdsV1(),
1525
+ );
1526
+ }
1527
+
1528
+ // The User's Composition (ADR 0026): the installed Plugin set, its
1529
+ // generations, last known good and quarantine. A Bot mirrors the pin
1530
+ // before every admission and records activation outcomes here.
1531
+
1532
+ async readComposition(input: unknown) {
1533
+ const request = decodeRpcEnvelopeV1(input, { userId: rpcIdentifier });
1534
+ const userId = await this.assertUserIdentity(request.userId as string);
1535
+ const features = decodeUserFeaturesV1(
1536
+ (await this.ctx.storage.get<unknown>(USER_FEATURES_KEY)) ??
1537
+ defaultUserFeaturesV1(),
1538
+ );
1539
+ const settings = await (await this.settingsContribution()).read(userId);
1540
+ return readUserCompositionV1(
1541
+ { ctx: this.ctx },
1542
+ {
1543
+ userId,
1544
+ // A deployment with no Worker Loader cannot mount a Plugin at all, so
1545
+ // it seeds none: a member nothing can mount would fail every Turn of
1546
+ // every Bot on the account rather than the one Plugin.
1547
+ catalog: this.env.BOT_PACKAGES
1548
+ ? DEPLOYMENT_PLUGIN_CATALOG_V1
1549
+ : ([] as typeof DEPLOYMENT_PLUGIN_CATALOG_V1),
1550
+ adminOpened: features.plugins,
1551
+ installedPackageIds: settings.packages
1552
+ .filter((pkg) => pkg.state === "installed")
1553
+ .map((pkg) => pkg.packageId),
1554
+ },
1555
+ );
1556
+ }
1557
+
1558
+ /**
1559
+ * Configuration, features and Composition in one call. Secrets and
1560
+ * credential leases stay on their own RPCs. Composition reconciliation can
1561
+ * fail without dropping the settings the Turn still needs.
1562
+ */
1563
+ async prepareAccount(input: unknown) {
1564
+ const request = decodeRpcEnvelopeV1(input, { userId: rpcIdentifier });
1565
+ const userId = await this.assertUserIdentity(request.userId as string);
1566
+ // Every Turn of every Bot prepares here first, so this is where a
1567
+ // deleting account stops starting Turns.
1568
+ await this.assertAccountOpen();
1569
+ const features = decodeUserFeaturesV1(
1570
+ (await this.ctx.storage.get<unknown>(USER_FEATURES_KEY)) ??
1571
+ defaultUserFeaturesV1(),
1572
+ );
1573
+ const settings = await (
1574
+ await this.settingsContribution()
1575
+ ).readConfiguration({ schemaVersion: 1, userId });
1576
+ let composition:
1577
+ Awaited<ReturnType<typeof readUserCompositionV1>> | undefined;
1578
+ try {
1579
+ composition = await readUserCompositionV1(
1580
+ { ctx: this.ctx },
1581
+ {
1582
+ userId,
1583
+ catalog: this.env.BOT_PACKAGES
1584
+ ? DEPLOYMENT_PLUGIN_CATALOG_V1
1585
+ : ([] as typeof DEPLOYMENT_PLUGIN_CATALOG_V1),
1586
+ adminOpened: features.plugins,
1587
+ installedPackageIds: settings.packages
1588
+ .filter((pkg) => pkg.state === "installed")
1589
+ .map((pkg) => pkg.packageId),
1590
+ },
1591
+ );
1592
+ } catch {
1593
+ composition = undefined;
1594
+ }
1595
+ const skillIndex = await readDurableSkillIndexV1(this.skillIndexStorage(), {
1596
+ kind: "user-instructions",
1597
+ userId,
1598
+ });
1599
+ return {
1600
+ schemaVersion: 1 as const,
1601
+ features,
1602
+ settings,
1603
+ skillIndexRevision: skillIndex.deleted ? "" : skillIndex.revision,
1604
+ ...(composition ? { composition } : {}),
1605
+ };
1606
+ }
1607
+
1608
+ /** Revision stamps only. Does not reconcile Composition or bootstrap packages. */
1609
+ async readAccountPreparationStamp(input: unknown) {
1610
+ const request = decodeRpcEnvelopeV1(input, { userId: rpcIdentifier });
1611
+ await this.assertUserIdentity(request.userId as string);
1612
+ const features = decodeUserFeaturesV1(
1613
+ (await this.ctx.storage.get<unknown>(USER_FEATURES_KEY)) ??
1614
+ defaultUserFeaturesV1(),
1615
+ );
1616
+ const settings = await (await this.settingsContribution()).readSnapshot();
1617
+ const pin = await this.ctx.storage.get<unknown>(COMPOSITION_CURRENT_KEY);
1618
+ return {
1619
+ schemaVersion: 1 as const,
1620
+ revision: settings.revision,
1621
+ features: {
1622
+ pluginAuthoring: features.pluginAuthoring,
1623
+ plugins: [...features.plugins],
1624
+ },
1625
+ compositionGenerationId:
1626
+ pin === undefined ? "" : decodeCompositionPinV1(pin).generationId,
1627
+ skillIndexRevision: await this.userSkillIndexRevision(
1628
+ request.userId as string,
1629
+ ),
1630
+ };
1631
+ }
1632
+
1633
+ async readCompositionGeneration(input: unknown) {
1634
+ const request = decodeRpcEnvelopeV1(input, {
1635
+ userId: rpcIdentifier,
1636
+ generationId: rpcString(256),
1637
+ });
1638
+ await this.assertUserIdentity(request.userId as string);
1639
+ return userCompositionStoreV1({ ctx: this.ctx }).read(
1640
+ request.generationId as string,
1641
+ );
1642
+ }
1643
+
1644
+ async proposeComposition(input: unknown) {
1645
+ const request = decodeRpcEnvelopeV1(
1646
+ input,
1647
+ {
1648
+ userId: rpcIdentifier,
1649
+ generation: rpcDecoded(decodeCompositionGenerationV1),
1650
+ },
1651
+ {
1652
+ pin: rpcBoolean,
1653
+ expectedCurrentGenerationId: rpcString(256),
1654
+ },
1655
+ );
1656
+ await this.assertUserIdentity(request.userId as string);
1657
+ await userCompositionStoreV1({ ctx: this.ctx }).propose(
1658
+ request.generation as ReturnType<typeof decodeCompositionGenerationV1>,
1659
+ {
1660
+ ...(request.pin === undefined ? {} : { pin: request.pin as boolean }),
1661
+ ...(request.expectedCurrentGenerationId === undefined
1662
+ ? {}
1663
+ : {
1664
+ expectedCurrentGenerationId:
1665
+ request.expectedCurrentGenerationId as string,
1666
+ }),
1667
+ },
1668
+ );
1669
+ }
1670
+
1671
+ async commitComposition(input: unknown) {
1672
+ const request = decodeRpcEnvelopeV1(input, {
1673
+ userId: rpcIdentifier,
1674
+ generationId: rpcString(256),
1675
+ });
1676
+ await this.assertUserIdentity(request.userId as string);
1677
+ await this.changingActiveGeneration(() =>
1678
+ userCompositionStoreV1({ ctx: this.ctx }).commit(
1679
+ request.generationId as string,
1680
+ ),
1681
+ );
1682
+ }
1683
+
1684
+ async failComposition(input: unknown) {
1685
+ const request = decodeRpcEnvelopeV1(input, {
1686
+ userId: rpcIdentifier,
1687
+ generationId: rpcString(256),
1688
+ quarantined: rpcBoolean,
1689
+ });
1690
+ await this.assertUserIdentity(request.userId as string);
1691
+ await this.changingActiveGeneration(() =>
1692
+ userCompositionStoreV1({ ctx: this.ctx }).fail(
1693
+ request.generationId as string,
1694
+ { quarantined: request.quarantined as boolean },
1695
+ ),
1696
+ );
1697
+ }
1698
+
1699
+ /**
1700
+ * The generation the account runs: the last one a Turn mounted and
1701
+ * committed. The pointer can name a pending proposal no Turn has mounted
1702
+ * yet, and a desktop should not start code that may never activate.
1703
+ */
1704
+ private activeGeneration() {
1705
+ return userCompositionStoreV1({ ctx: this.ctx }).lastKnownGood();
1706
+ }
1707
+
1708
+ /**
1709
+ * Runs a commit or a failure, and sends every open machine socket the new
1710
+ * module list when the active generation moved. A commit of the generation
1711
+ * already active, which every Turn after the first makes, sends nothing.
1712
+ * The sockets are told best-effort: the change itself is what the caller
1713
+ * asked for, and a desktop that missed it is sent the list on reconnect.
1714
+ */
1715
+ private async changingActiveGeneration(change: () => Promise<void>) {
1716
+ const store = userCompositionStoreV1({ ctx: this.ctx });
1717
+ // Only the pointer is compared, so the commit every Turn makes decodes
1718
+ // nothing it did not already.
1719
+ const before = await store.lastKnownGoodId();
1720
+ await change();
1721
+ if ((await store.lastKnownGoodId()) === before) return;
1722
+ await this.pushMachineModules();
1723
+ }
1724
+
1725
+ /**
1726
+ * Sends every open machine socket its module list again: the active
1727
+ * generation moved, a Routine started or stopped listening, or an event
1728
+ * moved a last key. Best-effort: a desktop that missed it is sent the list
1729
+ * on reconnect.
1730
+ */
1731
+ private async pushMachineModules(): Promise<void> {
1732
+ if (this.ctx.getWebSockets().length === 0) return;
1733
+ await loggedEntryV1("Machine modules push", async () => {
1734
+ const active = await this.activeGeneration();
1735
+ const routing = await readModuleRoutingV1(
1736
+ this.ctx.storage,
1737
+ await this.pluginTriggerRoutines(),
1738
+ );
1739
+ const serverTime = new Date().toISOString();
1740
+ broadcastMachineFrameV1(this.ctx, (platform) => ({
1741
+ type: "modules",
1742
+ modules: machineModulesV1(active, platform, routing),
1743
+ serverTime,
1744
+ }));
1745
+ });
1746
+ }
1747
+
1748
+ /**
1749
+ * Every Routine, on any of the User's Bots, whose trigger names a Plugin's
1750
+ * trigger. Routines written before this index existed never registered, so
1751
+ * the first read asks each Bot once; after that each Routine keeps it
1752
+ * current itself.
1753
+ */
1754
+ private async pluginTriggerRoutines(): Promise<
1755
+ PluginTriggerRoutineEntryV1[]
1756
+ > {
1757
+ if (!(await pluginTriggerIndexBuiltV1(this.ctx.storage))) {
1758
+ const userId = await this.provenIdentity();
1759
+ if (userId === undefined) return [];
1760
+ const directory = await (await this.flockContribution()).listBots();
1761
+ const bots = await Promise.all(
1762
+ directory.bots.map(async (bot) => {
1763
+ try {
1764
+ return {
1765
+ botId: bot.botId,
1766
+ routines: await this.botRoutinesStub(
1767
+ userId,
1768
+ bot.botId,
1769
+ ).listPluginTriggerRoutines({
1770
+ schemaVersion: 1,
1771
+ userId,
1772
+ botId: bot.botId,
1773
+ }),
1774
+ };
1775
+ } catch (error) {
1776
+ // One Bot that cannot answer must not hold every other Bot's
1777
+ // Routines out of the index; its own next change registers it.
1778
+ console.error(
1779
+ `Plugin trigger index rebuild failed for a Bot: ${
1780
+ error instanceof Error ? error.message : String(error)
1781
+ }`,
1782
+ );
1783
+ return { botId: bot.botId, routines: [] };
1784
+ }
1785
+ }),
1786
+ );
1787
+ await rebuildPluginTriggerIndexV1(this.ctx.storage, bots);
1788
+ }
1789
+ return readPluginTriggerRoutinesV1(this.ctx.storage);
1790
+ }
1791
+
1792
+ /**
1793
+ * One Routine saying whether it listens to a Plugin's trigger, each time it
1794
+ * is created, changed, paused, resumed or deleted.
1795
+ */
1796
+ async syncPluginTriggerRoutine(input: unknown): Promise<void> {
1797
+ const request = decodeRpcEnvelopeV1(
1798
+ input,
1799
+ {
1800
+ userId: rpcIdentifier,
1801
+ botId: rpcBotId,
1802
+ routineId: rpcIdentifier,
1803
+ },
1804
+ {
1805
+ listening: rpcObject({
1806
+ pluginId: rpcPattern(/^[a-z][a-z0-9-]{0,63}$/, 64),
1807
+ trigger: rpcPattern(/^[a-z][a-z0-9_-]{0,63}$/, 64),
1808
+ }),
1809
+ },
1810
+ );
1811
+ await this.assertUserIdentity(request.userId as string);
1812
+ // An unbuilt index is rebuilt from every Bot on its first read, this
1813
+ // Routine included; nothing listens to it before then.
1814
+ const built = await pluginTriggerIndexBuiltV1(this.ctx.storage);
1815
+ const before = listeningEventsV1(
1816
+ await readPluginTriggerRoutinesV1(this.ctx.storage),
1817
+ );
1818
+ await syncPluginTriggerRoutineV1(this.ctx.storage, {
1819
+ botId: request.botId as string,
1820
+ routineId: request.routineId as string,
1821
+ ...(request.listening === undefined
1822
+ ? {}
1823
+ : {
1824
+ listening: request.listening as {
1825
+ pluginId: string;
1826
+ trigger: string;
1827
+ },
1828
+ }),
1829
+ });
1830
+ if (!built) return;
1831
+ const after = listeningEventsV1(
1832
+ await readPluginTriggerRoutinesV1(this.ctx.storage),
1833
+ );
1834
+ if (
1835
+ before.size !== after.size ||
1836
+ [...after].some((event) => !before.has(event))
1837
+ ) {
1838
+ await this.pushMachineModules();
1839
+ }
1840
+ }
1841
+
1842
+ async revertComposition(input: unknown) {
1843
+ const request = decodeRpcEnvelopeV1(input, {
1844
+ userId: rpcIdentifier,
1845
+ toGenerationId: rpcString(256),
1846
+ origin: rpcJsonRecord,
1847
+ });
1848
+ await this.assertUserIdentity(request.userId as string);
1849
+ const origin = request.origin as Record<string, unknown>;
1850
+ if (
1851
+ origin.kind !== "revert" ||
1852
+ origin.revertsTo !== request.toGenerationId ||
1853
+ origin.userId !== request.userId
1854
+ ) {
1855
+ throw new Error("Composition revert origin does not match its request");
1856
+ }
1857
+ return userCompositionStoreV1({ ctx: this.ctx }).revert(
1858
+ request.toGenerationId as string,
1859
+ origin as Extract<CompositionOriginV1, { kind: "revert" }>,
1860
+ );
1861
+ }
1862
+
1863
+ async listCompositionGenerations(input: unknown) {
1864
+ const request = decodeRpcEnvelopeV1(
1865
+ input,
1866
+ {
1867
+ userId: rpcIdentifier,
1868
+ limit: rpcInteger({ minimum: 1, maximum: 100 }),
1869
+ },
1870
+ { cursor: rpcString(512) },
1871
+ );
1872
+ await this.assertUserIdentity(request.userId as string);
1873
+ return userCompositionStoreV1({ ctx: this.ctx }).list({
1874
+ limit: request.limit as number,
1875
+ ...(request.cursor === undefined
1876
+ ? {}
1877
+ : { cursor: request.cursor as string }),
1878
+ });
1879
+ }
1880
+
1881
+ async recordCompositionFailure(input: unknown) {
1882
+ const request = decodeRpcEnvelopeV1(input, {
1883
+ userId: rpcIdentifier,
1884
+ failure: rpcJsonRecord,
1885
+ });
1886
+ await this.assertUserIdentity(request.userId as string);
1887
+ return userCompositionFailuresV1({ ctx: this.ctx }).record(
1888
+ request.failure as unknown as CompositionFailureInputV1,
1889
+ );
1890
+ }
1891
+
1892
+ async listCompositionFailures(input: unknown) {
1893
+ const request = decodeRpcEnvelopeV1(input, {
1894
+ userId: rpcIdentifier,
1895
+ generationId: rpcString(256),
1896
+ });
1897
+ await this.assertUserIdentity(request.userId as string);
1898
+ return userCompositionFailuresV1({ ctx: this.ctx }).list(
1899
+ request.generationId as string,
1900
+ );
1901
+ }
1902
+
1903
+ async readCompositionQuarantine(input: unknown) {
1904
+ const request = decodeRpcEnvelopeV1(input, {
1905
+ userId: rpcIdentifier,
1906
+ generationId: rpcString(256),
1907
+ });
1908
+ await this.assertUserIdentity(request.userId as string);
1909
+ return userCompositionFailuresV1({ ctx: this.ctx }).quarantine(
1910
+ request.generationId as string,
1911
+ );
1912
+ }
1913
+
1914
+ async clearCompositionFailures(input: unknown) {
1915
+ const request = decodeRpcEnvelopeV1(input, {
1916
+ userId: rpcIdentifier,
1917
+ generationId: rpcString(256),
1918
+ });
1919
+ await this.assertUserIdentity(request.userId as string);
1920
+ await userCompositionFailuresV1({ ctx: this.ctx }).clear(
1921
+ request.generationId as string,
1922
+ );
1923
+ }
1924
+
1925
+ async readSettingsFrame(input: unknown) {
1926
+ const request = decodeRpcEnvelopeV1(
1927
+ input,
1928
+ {
1929
+ userId: rpcIdentifier,
1930
+ home: rpcEnum(["application", "models"]),
1931
+ },
1932
+ {
1933
+ identityName: rpcString(100),
1934
+ identityEmail: rpcString(320),
1935
+ identityImage: rpcString(2048),
1936
+ },
1937
+ );
1938
+ await this.assertUserIdentity(request.userId as string);
1939
+ return (await this.settingsContribution()).readSettingsFrame(
1940
+ request.userId as string,
1941
+ request.home as "application" | "models",
1942
+ {
1943
+ ...(request.identityName
1944
+ ? { name: request.identityName as string }
1945
+ : {}),
1946
+ ...(request.identityEmail
1947
+ ? { email: request.identityEmail as string }
1948
+ : {}),
1949
+ ...(request.identityImage
1950
+ ? { image: request.identityImage as string }
1951
+ : {}),
1952
+ },
1953
+ );
1954
+ }
1955
+
1956
+ async readSettingsOptions(input: unknown) {
1957
+ const request = decodeRpcEnvelopeV1(input, {
1958
+ userId: rpcIdentifier,
1959
+ query: rpcDecoded((value) =>
1960
+ decodeProtocol("SettingsOptionsQuery", value),
1961
+ ),
1962
+ });
1963
+ await this.assertUserIdentity(request.userId as string);
1964
+ return (await this.settingsContribution()).readSettingsOptions(
1965
+ request.userId as string,
1966
+ request.query,
1967
+ );
1968
+ }
1969
+
1970
+ async changeSettings(input: unknown) {
1971
+ const request = decodeRpcEnvelopeV1(input, {
1972
+ userId: rpcIdentifier,
1973
+ home: rpcEnum(["application", "models"]),
1974
+ command: rpcDecoded((value) =>
1975
+ decodeProtocol("SettingsChangeCommand", value),
1976
+ ),
1977
+ });
1978
+ await this.assertUserIdentity(request.userId as string);
1979
+ const command = request.command as { sectionId?: string };
1980
+ const profileSave =
1981
+ request.home === "application" && command.sectionId === "profile";
1982
+ const before = profileSave
1983
+ ? await this.routineTimezone(request.userId as string)
1984
+ : undefined;
1985
+ const receipt = await (
1986
+ await this.settingsContribution()
1987
+ ).changeSettings(
1988
+ request.userId as string,
1989
+ request.home as "application" | "models",
1990
+ request.command,
1991
+ );
1992
+ if (receipt.status === "applied" && before !== undefined) {
1993
+ await this.propagateRoutineTimezone(request.userId as string, before);
1994
+ }
1995
+ return receipt;
1996
+ }
1997
+
1998
+ async executeConfiguration(input: unknown) {
1999
+ const request = decodeUserConfigurationExecuteRpcV1(input);
2000
+ await this.assertUserIdentity(request.userId);
2001
+ const before =
2002
+ request.command.type === "user/update-profile"
2003
+ ? await this.routineTimezone(request.userId)
2004
+ : undefined;
2005
+ const receipt = await (
2006
+ await this.settingsContribution()
2007
+ ).executeConfiguration(request);
2008
+ if (receipt.status === "applied" && before !== undefined) {
2009
+ await this.propagateRoutineTimezone(request.userId, before);
2010
+ }
2011
+ if (
2012
+ receipt.status === "applied" &&
2013
+ (request.command.type === "user/install-package" ||
2014
+ request.command.type === "user/choose-model-provider" ||
2015
+ request.command.type === "user/uninstall-package" ||
2016
+ request.command.type === "user/set-package-enabled")
2017
+ ) {
2018
+ // A Package an account installs or uninstalls can be one this
2019
+ // deployment serves through a Plugin (ADR 0032): the artifact follows
2020
+ // the Package into this User's Composition, by the account's own
2021
+ // command and by no default. The Marketplace's Add is
2022
+ // `choose-model-provider`, so it is one of these too. A failed reconciliation is not a failed
2023
+ // command — the settings write already landed — so it is swallowed
2024
+ // here and repaired by the next install, uninstall or read.
2025
+ await this.reconcileInstalledPlugins(request.userId).catch(
2026
+ () => undefined,
2027
+ );
2028
+ }
2029
+ return receipt;
2030
+ }
2031
+
2032
+ /**
2033
+ * The provider Plugins this account's installed Packages carry, reconciled
2034
+ * into its Composition. Idempotent: a generation is proposed only when the
2035
+ * set of `installed` members differs from what the Packages say.
2036
+ */
2037
+ private async reconcileInstalledPlugins(userId: string): Promise<void> {
2038
+ const settings = await (await this.settingsContribution()).read(userId);
2039
+ await reconcileInstalledProviderPluginsV1({
2040
+ store: userCompositionStoreV1({ ctx: this.ctx }),
2041
+ userId,
2042
+ // A deployment with no Worker Loader mounts no Plugin, so it installs
2043
+ // none: a member nothing can mount would fail every Turn of every Bot.
2044
+ catalog: this.env.BOT_PACKAGES ? DEPLOYMENT_PLUGIN_CATALOG_V1 : [],
2045
+ installedPackageIds: settings.packages
2046
+ .filter((pkg) => pkg.state === "installed")
2047
+ .map((pkg) => pkg.packageId),
2048
+ });
2049
+ }
2050
+
2051
+ async executeConnection(input: unknown) {
2052
+ const request = decodeRpcEnvelopeV1(input, {
2053
+ userId: rpcIdentifier,
2054
+ command: rpcDecoded(decodeConnectionCommandV1),
2055
+ });
2056
+ const command = request.command as ReturnType<
2057
+ typeof decodeConnectionCommandV1
2058
+ >;
2059
+ const accountId = request.userId as string;
2060
+ await this.assertAccountOpen();
2061
+ const packageId = await (
2062
+ await this.settingsContribution()
2063
+ ).resolveConnectionCommandOwner(accountId, command);
2064
+ return (await this.connectionContribution(packageId)).executeConnection(
2065
+ accountId,
2066
+ command,
2067
+ );
2068
+ }
2069
+
2070
+ async listConnectTriggers(input: unknown): Promise<ConnectTriggerOfferV1[]> {
2071
+ const request = decodeRpcEnvelopeV1(input, { userId: rpcIdentifier });
2072
+ const userId = await this.assertUserIdentity(request.userId as string);
2073
+ return (await this.connectContribution()).listTriggers(userId);
2074
+ }
2075
+
2076
+ async upsertConnectTrigger(
2077
+ input: unknown,
2078
+ ): Promise<{ instanceId: string; routineId: string }> {
2079
+ const request = decodeRpcEnvelopeV1(
2080
+ input,
2081
+ {
2082
+ userId: rpcIdentifier,
2083
+ commandId: rpcIdentifier,
2084
+ botId: rpcBotId,
2085
+ routineId: rpcIdentifier,
2086
+ connectionId: rpcIdentifier,
2087
+ triggerType: rpcString(128),
2088
+ },
2089
+ {
2090
+ config: rpcJsonRecord,
2091
+ },
2092
+ );
2093
+ const userId = await this.assertUserIdentity(request.userId as string);
2094
+ await this.assertAccountOpen();
2095
+ return (await this.connectContribution()).upsertTrigger({
2096
+ userId,
2097
+ commandId: request.commandId as string,
2098
+ botId: request.botId as string,
2099
+ routineId: request.routineId as string,
2100
+ connectionId: request.connectionId as string,
2101
+ triggerType: request.triggerType as string,
2102
+ ...(request.config === undefined
2103
+ ? {}
2104
+ : {
2105
+ config: request.config as Record<string, string | number | boolean>,
2106
+ }),
2107
+ });
2108
+ }
2109
+
2110
+ async deleteConnectTrigger(input: unknown): Promise<void> {
2111
+ const request = decodeRpcEnvelopeV1(input, {
2112
+ userId: rpcIdentifier,
2113
+ commandId: rpcIdentifier,
2114
+ botId: rpcBotId,
2115
+ routineId: rpcIdentifier,
2116
+ });
2117
+ await this.assertUserIdentity(request.userId as string);
2118
+ await (
2119
+ await this.connectContribution()
2120
+ ).deleteTrigger({
2121
+ commandId: request.commandId as string,
2122
+ botId: request.botId as string,
2123
+ routineId: request.routineId as string,
2124
+ });
2125
+ }
2126
+
2127
+ /**
2128
+ * One verified provider event. The door already checked the HMAC; this
2129
+ * object maps the instance and either fires or pauses the Routine.
2130
+ *
2131
+ * It does not provision the User: an event for nobody is ignored, not a
2132
+ * reason to create an account.
2133
+ */
2134
+ async handleConnectEvent(input: unknown): Promise<{
2135
+ status: "accepted" | "ignored";
2136
+ fireId?: string;
2137
+ }> {
2138
+ const request = decodeRpcEnvelopeV1(input, {
2139
+ userId: rpcIdentifier,
2140
+ event: rpcDecodedValue,
2141
+ });
2142
+ const userId = request.userId as string;
2143
+ if (await this.accountClosing()) return { status: "ignored" };
2144
+ const pinned = await this.addressedUser(userId);
2145
+ if (!pinned) return { status: "ignored" };
2146
+ const event = request.event as ConnectEventV1;
2147
+ if (event.userId !== userId) return { status: "ignored" };
2148
+ const connect = await this.connectContribution();
2149
+ if (event.kind === "connected_account.expired") {
2150
+ if (!event.connectedAccountId) return { status: "ignored" };
2151
+ const failed = await connect.failExpiredAccount(
2152
+ userId,
2153
+ event.connectedAccountId,
2154
+ );
2155
+ if (!failed) return { status: "ignored" };
2156
+ for (const routine of failed.routines) {
2157
+ await this.pauseConnectRoutine(
2158
+ userId,
2159
+ routine.botId,
2160
+ routine.routineId,
2161
+ `connect-expired-${event.eventId}-${routine.routineId}`,
2162
+ );
2163
+ }
2164
+ return { status: "accepted" };
2165
+ }
2166
+ if (!event.triggerInstanceId) return { status: "ignored" };
2167
+ const instance = await connect.resolveTrigger(event.triggerInstanceId);
2168
+ if (!instance) return { status: "ignored" };
2169
+ if (event.kind === "trigger.disabled") {
2170
+ await this.pauseConnectRoutine(
2171
+ userId,
2172
+ instance.botId,
2173
+ instance.routineId,
2174
+ `connect-disabled-${event.eventId}`,
2175
+ );
2176
+ return { status: "accepted" };
2177
+ }
2178
+ const receipt = (await this.botRoutinesStub(
2179
+ userId,
2180
+ instance.botId,
2181
+ ).deliverConnectEvent({
2182
+ schemaVersion: 1,
2183
+ userId,
2184
+ botId: instance.botId,
2185
+ routineId: instance.routineId,
2186
+ eventId: event.eventId,
2187
+ payload: event.payload,
2188
+ })) as { status?: string; fireId?: string };
2189
+ return receipt.status === "accepted" || receipt.status === "duplicate"
2190
+ ? {
2191
+ status: "accepted",
2192
+ ...(receipt.fireId ? { fireId: receipt.fireId } : {}),
2193
+ }
2194
+ : { status: "ignored" };
2195
+ }
2196
+
2197
+ private async pauseConnectRoutine(
2198
+ userId: string,
2199
+ botId: string,
2200
+ routineId: string,
2201
+ commandId: string,
2202
+ ): Promise<void> {
2203
+ await this.botRoutinesStub(userId, botId)
2204
+ .executeRoutineCommand({
2205
+ schemaVersion: 1,
2206
+ userId,
2207
+ botId,
2208
+ command: decodeRoutineCommandV1({
2209
+ schemaVersion: 1,
2210
+ type: "routine/pause",
2211
+ commandId,
2212
+ botId,
2213
+ routineId,
2214
+ }),
2215
+ })
2216
+ .catch(() => undefined);
2217
+ }
2218
+
2219
+ async lookupConnectionCommand(input: unknown) {
2220
+ const request = decodeRpcEnvelopeV1(input, {
2221
+ userId: rpcIdentifier,
2222
+ packageId: rpcIdentifier,
2223
+ commandId: rpcIdentifier,
2224
+ });
2225
+ return (
2226
+ await this.connectionContribution(request.packageId as string)
2227
+ ).lookupConnectionCommand(
2228
+ request.userId as string,
2229
+ decodeConnectionCommandIdV1(request.commandId),
2230
+ );
2231
+ }
2232
+
2233
+ async getConnection(input: unknown) {
2234
+ const request = decodeRpcEnvelopeV1(input, {
2235
+ userId: rpcIdentifier,
2236
+ connectionId: rpcIdentifier,
2237
+ });
2238
+ return (await this.settingsContribution()).getConnection(
2239
+ request.userId as string,
2240
+ request.connectionId as string,
2241
+ );
2242
+ }
2243
+
2244
+ async leaseModelCredential(input: unknown) {
2245
+ const request = decodeRpcEnvelopeV1(input, {
2246
+ userId: rpcIdentifier,
2247
+ connectionId: rpcIdentifier,
2248
+ providerModelId: rpcString(256),
2249
+ effectId: rpcIdentifier,
2250
+ connectionGeneration: rpcIdentifier,
2251
+ });
2252
+ const connection = await (
2253
+ await this.settingsContribution()
2254
+ ).getConnection(request.userId as string, request.connectionId as string);
2255
+ if (!connection) throw new Error("Connection is unavailable");
2256
+ return (
2257
+ await this.connectionContribution(connection.packageId)
2258
+ ).leaseModelCredential({
2259
+ accountId: request.userId as string,
2260
+ connectionId: request.connectionId as string,
2261
+ providerModelId: request.providerModelId as string,
2262
+ effectId: request.effectId as string,
2263
+ connectionGeneration: request.connectionGeneration as string,
2264
+ });
2265
+ }
2266
+
2267
+ /**
2268
+ * An expiring lease over a Connection's credential for a tool
2269
+ * Contribution's mount. The Package that owns the Connection is resolved
2270
+ * from the durable projection, so a caller cannot name a Package the
2271
+ * Connection does not belong to, and a Package whose Connections carry no
2272
+ * credential refuses by not implementing the seam at all.
2273
+ */
2274
+ async leaseToolCredential(input: unknown) {
2275
+ const request = decodeRpcEnvelopeV1(input, {
2276
+ userId: rpcIdentifier,
2277
+ connectionId: rpcIdentifier,
2278
+ effectId: rpcIdentifier,
2279
+ connectionGeneration: rpcIdentifier,
2280
+ });
2281
+ const connection = await (
2282
+ await this.settingsContribution()
2283
+ ).getConnection(request.userId as string, request.connectionId as string);
2284
+ if (!connection) throw new Error("Connection is unavailable");
2285
+ const contribution = await this.connectionContribution(
2286
+ connection.packageId,
2287
+ );
2288
+ if (!contribution.leaseToolCredential) {
2289
+ throw new Error("Connection Package offers no tool credential");
2290
+ }
2291
+ return contribution.leaseToolCredential({
2292
+ accountId: request.userId as string,
2293
+ connectionId: request.connectionId as string,
2294
+ effectId: request.effectId as string,
2295
+ connectionGeneration: request.connectionGeneration as string,
2296
+ });
2297
+ }
2298
+
2299
+ async settleToolCredential(input: unknown) {
2300
+ const request = decodeRpcEnvelopeV1(input, {
2301
+ userId: rpcIdentifier,
2302
+ connectionId: rpcIdentifier,
2303
+ effectId: rpcIdentifier,
2304
+ });
2305
+ const connection = await (
2306
+ await this.settingsContribution()
2307
+ ).getConnection(request.userId as string, request.connectionId as string);
2308
+ if (!connection) return;
2309
+ const contribution = await this.connectionContribution(
2310
+ connection.packageId,
2311
+ );
2312
+ await contribution.settleToolCredential?.({
2313
+ accountId: request.userId as string,
2314
+ connectionId: request.connectionId as string,
2315
+ effectId: request.effectId as string,
2316
+ });
2317
+ }
2318
+
2319
+ private async secretVault(): Promise<SecretVaultV1> {
2320
+ return createSecretVaultV1({
2321
+ storage: this.ctx.storage,
2322
+ credentials: (await this.contributions()).credentials,
2323
+ });
2324
+ }
2325
+
2326
+ /**
2327
+ * Seals one value a person typed on a Bot's secret-request card.
2328
+ *
2329
+ * The Bot Durable Object is the caller, having checked the request it
2330
+ * recorded; this object is the one that holds the value, sealed, and
2331
+ * answers only what the secret is called and where it may be used. A
2332
+ * refusal names the field, never what was in it.
2333
+ */
2334
+ async storeSecret(input: unknown) {
2335
+ const request = decodeRpcEnvelopeV1(
2336
+ input,
2337
+ {
2338
+ userId: rpcIdentifier,
2339
+ botId: rpcBotId,
2340
+ requestId: rpcPattern(/^secret-request-[0-9a-f]{32}$/, 128),
2341
+ label: rpcString(SECRET_LIMITS_V1.label),
2342
+ payment: rpcBoolean,
2343
+ value: rpcString(SECRET_LIMITS_V1.value),
2344
+ },
2345
+ {
2346
+ origin: (value, label) => secretOriginV1(value, label),
2347
+ },
2348
+ );
2349
+ const userId = await this.assertUserIdentity(request.userId as string);
2350
+ await this.assertAccountOpen();
2351
+ if (!isSecretRequestIdV1(request.requestId)) {
2352
+ throw new Error("RPC request.requestId is invalid");
2353
+ }
2354
+ const { status: _, ...secret } = await (
2355
+ await this.secretVault()
2356
+ ).store({
2357
+ accountId: userId,
2358
+ botId: request.botId as string,
2359
+ requestId: request.requestId,
2360
+ label: request.label as string,
2361
+ ...(request.origin === undefined
2362
+ ? {}
2363
+ : { origin: request.origin as string }),
2364
+ payment: request.payment as boolean,
2365
+ value: request.value as string,
2366
+ });
2367
+ return secret;
2368
+ }
2369
+
2370
+ /** What a saved secret is called and where it may be filled; never its value. */
2371
+ async describeSecret(input: unknown) {
2372
+ const request = decodeRpcEnvelopeV1(input, {
2373
+ userId: rpcIdentifier,
2374
+ secretId: rpcPattern(/^secret-[0-9a-f]{32}$/, 64),
2375
+ });
2376
+ await this.assertUserIdentity(request.userId as string);
2377
+ const secret = isSecretIdV1(request.secretId)
2378
+ ? await (await this.secretVault()).describe(request.secretId)
2379
+ : undefined;
2380
+ return { schemaVersion: 1 as const, secret: secret ?? null };
2381
+ }
2382
+
2383
+ /** An expiring lease over one secret's sealed value, for one fill. */
2384
+ async leaseSecret(input: unknown) {
2385
+ const request = decodeRpcEnvelopeV1(input, {
2386
+ userId: rpcIdentifier,
2387
+ secretId: rpcPattern(/^secret-[0-9a-f]{32}$/, 64),
2388
+ effectId: rpcString(256),
2389
+ });
2390
+ const userId = await this.assertUserIdentity(request.userId as string);
2391
+ await this.assertAccountOpen();
2392
+ return (await this.secretVault()).lease({
2393
+ accountId: userId,
2394
+ secretId: request.secretId as string,
2395
+ effectId: request.effectId as string,
2396
+ });
2397
+ }
2398
+
2399
+ async settleSecret(input: unknown) {
2400
+ const request = decodeRpcEnvelopeV1(input, {
2401
+ userId: rpcIdentifier,
2402
+ secretId: rpcPattern(/^secret-[0-9a-f]{32}$/, 64),
2403
+ effectId: rpcString(256),
2404
+ });
2405
+ const userId = await this.assertUserIdentity(request.userId as string);
2406
+ await (
2407
+ await this.secretVault()
2408
+ ).settle({
2409
+ accountId: userId,
2410
+ secretId: request.secretId as string,
2411
+ effectId: request.effectId as string,
2412
+ });
2413
+ }
2414
+
2415
+ /** The User's saved secrets, for Settings. */
2416
+ async listSecrets(input: unknown) {
2417
+ const request = decodeRpcEnvelopeV1(input, { userId: rpcIdentifier });
2418
+ await this.assertUserIdentity(request.userId as string);
2419
+ return (await this.secretVault()).list();
2420
+ }
2421
+
2422
+ async deleteSecret(input: unknown) {
2423
+ const request = decodeRpcEnvelopeV1(input, {
2424
+ userId: rpcIdentifier,
2425
+ secretId: rpcPattern(/^secret-[0-9a-f]{32}$/, 64),
2426
+ });
2427
+ await this.assertUserIdentity(request.userId as string);
2428
+ return {
2429
+ schemaVersion: 1 as const,
2430
+ ...(await (await this.secretVault()).remove(request.secretId as string)),
2431
+ };
2432
+ }
2433
+
2434
+ async settleModelCredential(input: unknown) {
2435
+ const request = decodeRpcEnvelopeV1(input, {
2436
+ userId: rpcIdentifier,
2437
+ connectionId: rpcIdentifier,
2438
+ packageId: rpcIdentifier,
2439
+ effectId: rpcIdentifier,
2440
+ });
2441
+ await (await this.settingsContribution()).read(request.userId as string);
2442
+ const contribution = (await this.contributions()).connections.get(
2443
+ request.packageId as string,
2444
+ );
2445
+ if (!contribution) {
2446
+ throw new Error("Connection Package Contribution is unavailable");
2447
+ }
2448
+ await contribution.settleModelCredential({
2449
+ accountId: request.userId as string,
2450
+ connectionId: request.connectionId as string,
2451
+ effectId: request.effectId as string,
2452
+ });
2453
+ }
2454
+
2455
+ /**
2456
+ * The per-User concurrent-subagent bound.
2457
+ *
2458
+ * A Bot's own bound is countable in its Durable Object; a User's is not,
2459
+ * because a User's Bots are separate objects. So the slot is held here, and
2460
+ * the Bot's Durable Object reserves one before it dispatches and releases it
2461
+ * when the task settles. Both halves are idempotent on `(botId, taskId)`.
2462
+ */
2463
+ async reserveSubagentSlot(input: unknown): Promise<SubagentSlotReceiptV1> {
2464
+ const request = decodeRpcEnvelopeV1(input, {
2465
+ userId: rpcIdentifier,
2466
+ botId: rpcBotId,
2467
+ taskId: rpcString(128),
2468
+ reservedAt: rpcString(64),
2469
+ });
2470
+ return reserveSubagentSlotV1(this.ctx.storage, {
2471
+ schemaVersion: 1,
2472
+ userId: request.userId as string,
2473
+ botId: request.botId as string,
2474
+ taskId: request.taskId as string,
2475
+ reservedAt: request.reservedAt as string,
2476
+ });
2477
+ }
2478
+
2479
+ async releaseSubagentSlot(
2480
+ input: unknown,
2481
+ ): Promise<{ schemaVersion: 1; status: "released"; held: number }> {
2482
+ const request = decodeRpcEnvelopeV1(input, {
2483
+ userId: rpcIdentifier,
2484
+ botId: rpcBotId,
2485
+ taskId: rpcString(128),
2486
+ });
2487
+ return releaseSubagentSlotV1(this.ctx.storage, {
2488
+ botId: request.botId as string,
2489
+ taskId: request.taskId as string,
2490
+ });
2491
+ }
2492
+
2493
+ /** User-wide agent-lane budget shared by every Bot. */
2494
+ async reserveAgentTurnSlot(input: unknown): Promise<AgentTurnSlotReceiptV1> {
2495
+ const request = decodeRpcEnvelopeV1(input, {
2496
+ userId: rpcIdentifier,
2497
+ requesterId: rpcString(256),
2498
+ runId: rpcString(256),
2499
+ reservedAt: rpcString(64),
2500
+ });
2501
+ await this.assertUserIdentity(request.userId as string);
2502
+ return reserveAgentTurnSlotV1(this.ctx.storage, {
2503
+ schemaVersion: 1,
2504
+ userId: request.userId as string,
2505
+ requesterId: request.requesterId as string,
2506
+ runId: request.runId as string,
2507
+ reservedAt: request.reservedAt as string,
2508
+ });
2509
+ }
2510
+
2511
+ async releaseAgentTurnSlot(
2512
+ input: unknown,
2513
+ ): Promise<{ schemaVersion: 1; status: "released"; held: number }> {
2514
+ const request = decodeRpcEnvelopeV1(input, {
2515
+ userId: rpcIdentifier,
2516
+ requesterId: rpcString(256),
2517
+ runId: rpcString(256),
2518
+ });
2519
+ await this.assertUserIdentity(request.userId as string);
2520
+ return releaseAgentTurnSlotV1(this.ctx.storage, {
2521
+ requesterId: request.requesterId as string,
2522
+ runId: request.runId as string,
2523
+ });
2524
+ }
2525
+
2526
+ // ---------------------------------------------------------------------
2527
+ // Shared Memory roots.
2528
+ //
2529
+ // The User's Durable Object is the authority for everything User-scoped,
2530
+ // the User Memory root's generation records among it. The Bot's Durable
2531
+ // Object does the writing — it is the Memory Package's host — but a shared
2532
+ // root's generations are recorded here, so two Bots writing one root record
2533
+ // into one ledger and their ids order against each other.
2534
+ //
2535
+ // Every one of these refuses a root that is not a shared Memory root of
2536
+ // *this* User. The Bot object is a caller like any other; authority follows
2537
+ // the root, not the caller.
2538
+ // ---------------------------------------------------------------------
2539
+
2540
+ private readonly workspaceGenerations = new DurableWorkspaceGenerations({
2541
+ state: this.ctx,
2542
+ });
2543
+
2544
+ /** `userId` must already have passed `assertUserIdentity`. */
2545
+ private sharedMemoryRoot(userId: string, value: unknown): WorkspaceRootV1 {
2546
+ const root = decodeWorkspaceRootV1(value);
2547
+ if (!isWorkspaceSharedMemoryRootV1(root) || root.userId !== userId) {
2548
+ throw new Error(
2549
+ "the User Durable Object records generations for its own shared Memory roots only",
2550
+ );
2551
+ }
2552
+ return root;
2553
+ }
2554
+
2555
+ private sharedMemoryRecord(
2556
+ userId: string,
2557
+ value: unknown,
2558
+ ): WorkspaceGenerationRecordV1 {
2559
+ const entry = decodeWorkspaceGenerationRecordV1(value);
2560
+ this.sharedMemoryRoot(userId, entry.root);
2561
+ return entry;
2562
+ }
2563
+
2564
+ async mintWorkspaceGeneration(input: unknown): Promise<string> {
2565
+ const request = decodeRpcEnvelopeV1(input, {
2566
+ userId: rpcIdentifier,
2567
+ at: rpcString(64),
2568
+ root: rpcDecodedValue,
2569
+ });
2570
+ const userId = await this.assertUserIdentity(request.userId as string);
2571
+ this.sharedMemoryRoot(userId, request.root);
2572
+ const at = new Date(request.at as string);
2573
+ if (!Number.isFinite(at.getTime())) {
2574
+ throw new Error("RPC request.at is invalid");
2575
+ }
2576
+ return this.workspaceGenerations.mint(at);
2577
+ }
2578
+
2579
+ async currentWorkspaceGeneration(
2580
+ input: unknown,
2581
+ ): Promise<WorkspaceGenerationRecordV1 | undefined> {
2582
+ const request = decodeRpcEnvelopeV1(input, {
2583
+ userId: rpcIdentifier,
2584
+ root: rpcDecodedValue,
2585
+ path: rpcString(1_024),
2586
+ });
2587
+ const root = this.sharedMemoryRoot(
2588
+ await this.assertUserIdentity(request.userId as string),
2589
+ request.root,
2590
+ );
2591
+ return this.workspaceGenerations.current(
2592
+ root,
2593
+ normalizeWorkspaceRelativePathV1(request.path),
2594
+ );
2595
+ }
2596
+
2597
+ async recordWorkspaceGeneration(input: unknown): Promise<void> {
2598
+ const request = decodeRpcEnvelopeV1(input, {
2599
+ userId: rpcIdentifier,
2600
+ entry: rpcDecodedValue,
2601
+ });
2602
+ await this.workspaceGenerations.record(
2603
+ this.sharedMemoryRecord(
2604
+ await this.assertUserIdentity(request.userId as string),
2605
+ request.entry,
2606
+ ),
2607
+ );
2608
+ }
2609
+
2610
+ async tombstoneWorkspaceGeneration(input: unknown): Promise<void> {
2611
+ const request = decodeRpcEnvelopeV1(input, {
2612
+ userId: rpcIdentifier,
2613
+ entry: rpcDecodedValue,
2614
+ });
2615
+ await this.workspaceGenerations.tombstone(
2616
+ this.sharedMemoryRecord(
2617
+ await this.assertUserIdentity(request.userId as string),
2618
+ request.entry,
2619
+ ),
2620
+ );
2621
+ }
2622
+
2623
+ async conflictWorkspaceGeneration(input: unknown): Promise<void> {
2624
+ const request = decodeRpcEnvelopeV1(input, {
2625
+ userId: rpcIdentifier,
2626
+ entry: rpcDecodedValue,
2627
+ });
2628
+ await this.workspaceGenerations.conflict(
2629
+ this.sharedMemoryRecord(
2630
+ await this.assertUserIdentity(request.userId as string),
2631
+ request.entry,
2632
+ ),
2633
+ );
2634
+ }
2635
+
2636
+ async listWorkspaceConflicts(
2637
+ input: unknown,
2638
+ ): Promise<WorkspaceGenerationRecordV1[]> {
2639
+ const request = decodeRpcEnvelopeV1(input, {
2640
+ userId: rpcIdentifier,
2641
+ root: rpcDecodedValue,
2642
+ path: rpcString(1_024),
2643
+ });
2644
+ const root = this.sharedMemoryRoot(
2645
+ await this.assertUserIdentity(request.userId as string),
2646
+ request.root,
2647
+ );
2648
+ return this.workspaceGenerations.conflicts(
2649
+ root,
2650
+ normalizeWorkspaceRelativePathV1(request.path),
2651
+ );
2652
+ }
2653
+
2654
+ private skillIndexStorage() {
2655
+ const storage = this.ctx.storage;
2656
+ return {
2657
+ get: (key: string) => storage.get(key),
2658
+ put: (key: string, value: unknown) => storage.put(key, value),
2659
+ delete: (key: string) => storage.delete(key),
2660
+ list: (options: { prefix?: string; limit?: number; start?: string }) =>
2661
+ storage.list(options),
2662
+ };
2663
+ }
2664
+
2665
+ private skillBodies() {
2666
+ const bucket = this.env.MEMORY_FILES;
2667
+ if (!bucket) throw new Error("no Workspace bucket is bound");
2668
+ const objects = createR2ObjectBucketV1(bucket);
2669
+ return {
2670
+ put: async (key: string, bytes: Uint8Array) => {
2671
+ await objects.put(key, bytes);
2672
+ },
2673
+ get: async (key: string) => {
2674
+ const object = await objects.get(key);
2675
+ return object ? object.bytes() : undefined;
2676
+ },
2677
+ delete: (key: string) => objects.delete(key),
2678
+ };
2679
+ }
2680
+
2681
+ private async userSkillIndexRevision(userId: string): Promise<string> {
2682
+ const index = await readDurableSkillIndexV1(this.skillIndexStorage(), {
2683
+ kind: "user-instructions",
2684
+ userId,
2685
+ });
2686
+ return index.deleted ? "" : index.revision;
2687
+ }
2688
+
2689
+ private userInstructionRoot(userId: string, value: unknown) {
2690
+ const root = decodeWorkspaceRootV1(value);
2691
+ if (root.kind !== "user-instructions" || root.userId !== userId) {
2692
+ throw new Error("skill index root is not this User's instruction root");
2693
+ }
2694
+ return root;
2695
+ }
2696
+
2697
+ async beginSkillIndex(input: unknown): Promise<void> {
2698
+ const request = decodeRpcEnvelopeV1(input, {
2699
+ userId: rpcIdentifier,
2700
+ root: rpcDecodedValue,
2701
+ path: rpcString(1_024),
2702
+ generationId: rpcString(128),
2703
+ ledgerPending: (value, label) => {
2704
+ if (typeof value !== "boolean") {
2705
+ throw new Error(`${label} must be a boolean`);
2706
+ }
2707
+ return value;
2708
+ },
2709
+ });
2710
+ const userId = await this.assertUserIdentity(request.userId as string);
2711
+ const root = this.userInstructionRoot(userId, request.root);
2712
+ await beginDurableSkillPublicationV1(
2713
+ this.skillIndexStorage(),
2714
+ root,
2715
+ normalizeWorkspaceRelativePathV1(request.path as string),
2716
+ request.generationId as string,
2717
+ request.ledgerPending === true,
2718
+ );
2719
+ }
2720
+
2721
+ async commitSkillIndex(input: unknown): Promise<void> {
2722
+ const request = decodeRpcEnvelopeV1(
2723
+ input,
2724
+ {
2725
+ userId: rpcIdentifier,
2726
+ root: rpcDecodedValue,
2727
+ path: rpcString(1_024),
2728
+ generation: rpcDecodedValue,
2729
+ deleted: (value, label) => {
2730
+ if (typeof value !== "boolean") {
2731
+ throw new Error(`${label} must be a boolean`);
2732
+ }
2733
+ return value;
2734
+ },
2735
+ },
2736
+ { bytesBase64: rpcString(200_000) },
2737
+ );
2738
+ const userId = await this.assertUserIdentity(request.userId as string);
2739
+ const root = this.userInstructionRoot(userId, request.root);
2740
+ const generation = decodeWorkspaceGenerationV1(request.generation);
2741
+ const bytes =
2742
+ typeof request.bytesBase64 === "string"
2743
+ ? base64ToBytes(request.bytesBase64)
2744
+ : undefined;
2745
+ const storage = this.skillIndexStorage();
2746
+ await commitDurableSkillPublicationV1(
2747
+ storage,
2748
+ this.skillBodies(),
2749
+ root,
2750
+ normalizeWorkspaceRelativePathV1(request.path as string),
2751
+ generation,
2752
+ bytes,
2753
+ request.deleted === true,
2754
+ );
2755
+ const held = await heldSkillRevisionsV1(storage);
2756
+ if (!held.truncated) {
2757
+ await releaseUnreferencedSkillSnapshotsV1(
2758
+ storage,
2759
+ this.skillBodies(),
2760
+ root,
2761
+ held.revisions,
2762
+ );
2763
+ }
2764
+ }
2765
+
2766
+ async readSkillIndex(input: unknown): Promise<object> {
2767
+ const request = decodeRpcEnvelopeV1(
2768
+ input,
2769
+ {
2770
+ userId: rpcIdentifier,
2771
+ root: rpcDecodedValue,
2772
+ },
2773
+ {
2774
+ revision: (value, label) => {
2775
+ if (typeof value !== "string" || value.length > 64) {
2776
+ throw new Error(`${label} is invalid`);
2777
+ }
2778
+ return value;
2779
+ },
2780
+ },
2781
+ );
2782
+ const userId = await this.assertUserIdentity(request.userId as string);
2783
+ const root = this.userInstructionRoot(userId, request.root);
2784
+ if (typeof request.revision === "string") {
2785
+ return readDurableSkillSnapshotV1(
2786
+ this.skillIndexStorage(),
2787
+ root,
2788
+ request.revision,
2789
+ );
2790
+ }
2791
+ return readDurableSkillIndexV1(this.skillIndexStorage(), root);
2792
+ }
2793
+
2794
+ async readConnectToolCatalog(input: unknown): Promise<object> {
2795
+ const request = decodeRpcEnvelopeV1(
2796
+ input,
2797
+ {
2798
+ userId: rpcIdentifier,
2799
+ connectionId: rpcIdentifier,
2800
+ generation: rpcString(128),
2801
+ },
2802
+ { toolName: rpcString(256) },
2803
+ );
2804
+ const userId = await this.assertUserIdentity(request.userId as string);
2805
+ // A connected app and an MCP server each keep their own directory; the
2806
+ // Package that owns the Connection is the one that answers for it.
2807
+ const connection = await (
2808
+ await this.settingsContribution()
2809
+ ).getConnection(userId, request.connectionId as string);
2810
+ const owner = connection
2811
+ ? (await this.contributions()).connections.get(connection.packageId)
2812
+ : undefined;
2813
+ if (!owner?.readToolCatalog) {
2814
+ return {
2815
+ kind: "stale-contract",
2816
+ message:
2817
+ "stale-contract: This connection was removed. Its tools are gone for this Turn.",
2818
+ };
2819
+ }
2820
+ return (await owner.readToolCatalog({
2821
+ userId,
2822
+ connectionId: request.connectionId as string,
2823
+ generation: request.generation as string,
2824
+ ...(typeof request.toolName === "string"
2825
+ ? { toolName: request.toolName }
2826
+ : {}),
2827
+ })) as object;
2828
+ }
2829
+
2830
+ async holdSkillIndex(input: unknown): Promise<void> {
2831
+ const request = decodeRpcEnvelopeV1(input, {
2832
+ userId: rpcIdentifier,
2833
+ runId: rpcString(128),
2834
+ revision: (value, label) => {
2835
+ if (typeof value !== "string" || value.length > 64) {
2836
+ throw new Error(`${label} is invalid`);
2837
+ }
2838
+ return value;
2839
+ },
2840
+ });
2841
+ await this.assertUserIdentity(request.userId as string);
2842
+ await holdSkillIndexRevisionsV1(
2843
+ this.skillIndexStorage(),
2844
+ request.runId as string,
2845
+ { botRevision: "", userRevision: request.revision as string },
2846
+ );
2847
+ }
2848
+
2849
+ async releaseSkillIndexHold(input: unknown): Promise<void> {
2850
+ const request = decodeRpcEnvelopeV1(input, {
2851
+ userId: rpcIdentifier,
2852
+ runId: rpcString(128),
2853
+ });
2854
+ const userId = await this.assertUserIdentity(request.userId as string);
2855
+ const storage = this.skillIndexStorage();
2856
+ await releaseSkillIndexHoldV1(storage, request.runId as string);
2857
+ if (!this.env.MEMORY_FILES) return;
2858
+ const held = await heldSkillRevisionsV1(storage);
2859
+ if (held.truncated) return;
2860
+ await releaseUnreferencedSkillSnapshotsV1(
2861
+ storage,
2862
+ this.skillBodies(),
2863
+ { kind: "user-instructions", userId },
2864
+ held.revisions,
2865
+ );
2866
+ }
2867
+
2868
+ /**
2869
+ * The User's sealed browser sign-ins and the debt an Update or a Reset
2870
+ * leaves. This object never holds the key: a Bot's object seals before it
2871
+ * calls and opens after it reads (`@frockbot/app/shell/computer-logins`).
2872
+ */
2873
+ async readComputerLogins(input: unknown): Promise<object> {
2874
+ const request = decodeRpcEnvelopeV1(input, {
2875
+ userId: rpcIdentifier,
2876
+ capture: rpcBoolean,
2877
+ });
2878
+ await this.assertUserIdentity(request.userId as string);
2879
+ return this.computerLogins().answer(request.capture as boolean);
2880
+ }
2881
+
2882
+ async keepComputerLogins(input: unknown): Promise<object> {
2883
+ const request = decodeRpcEnvelopeV1(input, {
2884
+ userId: rpcIdentifier,
2885
+ kept: rpcDecodedValue,
2886
+ });
2887
+ await this.assertUserIdentity(request.userId as string);
2888
+ // A closing account keeps nothing new: its deletion wipes this object.
2889
+ await this.assertAccountOpen();
2890
+ return { outcome: await this.computerLogins().keep(request.kept) };
2891
+ }
2892
+
2893
+ async oweComputerLogins(input: unknown): Promise<object> {
2894
+ const request = decodeRpcEnvelopeV1(input, {
2895
+ userId: rpcIdentifier,
2896
+ at: rpcString(64),
2897
+ });
2898
+ await this.assertUserIdentity(request.userId as string);
2899
+ await this.assertAccountOpen();
2900
+ return { outcome: await this.computerLogins().owe(request.at as string) };
2901
+ }
2902
+
2903
+ async settleComputerLogins(input: unknown): Promise<void> {
2904
+ const request = decodeRpcEnvelopeV1(input, {
2905
+ userId: rpcIdentifier,
2906
+ owedSince: rpcString(64),
2907
+ });
2908
+ await this.assertUserIdentity(request.userId as string);
2909
+ await this.computerLogins().settle(request.owedSince as string);
2910
+ }
2911
+
2912
+ private computerLogins(): ComputerLoginsLedgerV1 {
2913
+ return new ComputerLoginsLedgerV1(this.ctx.storage);
2914
+ }
2915
+
2916
+ /** The Group Chats a Bot is in: the group Memory scopes it may use. */
2917
+ async listMemoryGroups(input: unknown): Promise<{ groupIds: string[] }> {
2918
+ const request = decodeRpcEnvelopeV1(input, {
2919
+ userId: rpcIdentifier,
2920
+ botId: rpcBotId,
2921
+ });
2922
+ await this.assertFlockIdentity(request.userId as string);
2923
+ return {
2924
+ groupIds: await this.groupChats().groupsOf(request.botId as string),
2925
+ };
2926
+ }
2927
+
2928
+ #memoryEngine: MemoryEngineV1 | undefined;
2929
+
2930
+ private memoryEngine(): MemoryEngineV1 {
2931
+ this.#memoryEngine ??= createUserMemoryEngineV1(this.ctx.storage);
2932
+ return this.#memoryEngine;
2933
+ }
2934
+
2935
+ private async drainMemoryProcessing(): Promise<void> {
2936
+ await drainDurableMemoryV1(this.memoryEngine(), {
2937
+ ...(this.env.MEMORY_INDEX ? { vectors: this.env.MEMORY_INDEX } : {}),
2938
+ ...(this.env.AI ? { ai: this.env.AI } : {}),
2939
+ });
2940
+ }
2941
+
2942
+ /**
2943
+ * User and shared Group Chat Memory. Membership is loaded here and overwrites
2944
+ * anything the Bot RPC claimed, so a caller-supplied scope id is never enough.
2945
+ */
2946
+ async operateMemory(input: unknown): Promise<object> {
2947
+ const envelope = decodeRpcEnvelopeV1(input, {
2948
+ userId: rpcIdentifier,
2949
+ botId: rpcBotId,
2950
+ action: rpcEnum([
2951
+ "write",
2952
+ "forget",
2953
+ "recall",
2954
+ "expand",
2955
+ "browse",
2956
+ "preparedCore",
2957
+ ]),
2958
+ request: rpcDecodedValue,
2959
+ });
2960
+ const userId = envelope.userId as string;
2961
+ const botId = envelope.botId as string;
2962
+ await this.assertFlockIdentity(userId);
2963
+ const joined = await this.groupChats().groupsOf(botId);
2964
+ const inner = (envelope.request ?? {}) as Record<string, unknown>;
2965
+ const claimed = inner.authority;
2966
+ if (claimed && typeof claimed === "object" && !Array.isArray(claimed)) {
2967
+ inner.authority = {
2968
+ ...(claimed as Record<string, unknown>),
2969
+ userId,
2970
+ botId,
2971
+ actor:
2972
+ (claimed as { actor?: unknown }).actor === "user" ? "user" : "bot",
2973
+ joinedGroupChatIds: joined,
2974
+ membershipRevision: memoryMembershipRevisionV1(joined),
2975
+ };
2976
+ }
2977
+ const result = dispatchMemoryOperateV1(
2978
+ this.memoryEngine(),
2979
+ envelope.action as MemoryOperateActionV1,
2980
+ inner,
2981
+ );
2982
+ const due = this.memoryEngine().nextWakeupAt();
2983
+ if (due !== undefined) {
2984
+ const current = await this.ctx.storage.getAlarm();
2985
+ if (current === null || current > due) {
2986
+ await this.ctx.storage.setAlarm(due);
2987
+ }
2988
+ }
2989
+ return result as object;
2990
+ }
2991
+
2992
+ /**
2993
+ * One durable alarm serves every User-scoped owner of one. Cloudflare gives
2994
+ * a Durable Object a single alarm, so credential lease expiry, Connection
2995
+ * recovery, and publication recovery all run on every firing rather than
2996
+ * one of them silently displacing the others' schedule.
2997
+ */
2998
+ async alarm() {
2999
+ // An alarm has no caller: a throw here is an uncaught exception in the
3000
+ // object, and the next firing is the recovery.
3001
+ await loggedEntryV1("User configuration alarm", () => this.#alarm());
3002
+ }
3003
+
3004
+ async #alarm() {
3005
+ if (await this.accountClosing()) {
3006
+ // A deleting account's alarm is the deletion's alone: a credential
3007
+ // lease or a Memory drain run now would write to
3008
+ // what the saga is removing — a vector upserted behind the purge.
3009
+ await this.advanceAccountDeletion();
3010
+ return;
3011
+ }
3012
+ const contributions = await this.contributions();
3013
+ await contributions.credentials.expireLeases();
3014
+ for (const contribution of contributions.connections.values()) {
3015
+ await contribution.alarm?.();
3016
+ }
3017
+ // The Bot lifecycle sagas (archive and restore) resume on the same firing.
3018
+ await contributions.flock.alarm();
3019
+ // An archive that settled on a retry rather than on its command purges the
3020
+ // transcript index here. Purge is a delete of a projection, so sweeping
3021
+ // every archived Bot on every firing is idempotent and cheap, and it means
3022
+ // no archived Bot keeps rows because its saga finished out of band. Its
3023
+ // audit entries stay: an archived Bot's history is preserved.
3024
+ const lifecycles = await contributions.flock.listBotLifecycles();
3025
+ for (const lifecycle of lifecycles.lifecycles) {
3026
+ if (lifecycle.status === "archived") {
3027
+ contributions.search.purge(lifecycle.botId);
3028
+ }
3029
+ }
3030
+ // A delete that settled on a retry rather than on its own command left its
3031
+ // User-scoped projections behind, and the deleted Bot is no longer in any
3032
+ // lifecycle list to sweep from. The Flock Contribution keeps the to-do
3033
+ // list instead, and it is cleared only once the projections are gone.
3034
+ for (const botId of await contributions.flock.listDeletedBotIds()) {
3035
+ await this.forgetDeletedBot(botId);
3036
+ }
3037
+ if (durableObjectHasSqlV1(this.ctx.storage)) {
3038
+ await this.drainMemoryProcessing();
3039
+ const memoryDue = this.memoryEngine().nextWakeupAt();
3040
+ if (memoryDue !== undefined) {
3041
+ const current = await this.ctx.storage.getAlarm();
3042
+ if (current === null || current > memoryDue) {
3043
+ await this.ctx.storage.setAlarm(memoryDue);
3044
+ }
3045
+ }
3046
+ }
3047
+ }
3048
+
3049
+ /**
3050
+ * The User-scoped state one deleted Bot leaves behind: its transcript rows,
3051
+ * its audit entries and its Group Chat membership.
3052
+ *
3053
+ * Every step is a delete, so repeating it is free, and the to-do entry is
3054
+ * dropped last — a crash before that simply replays the sweep.
3055
+ */
3056
+ private async forgetDeletedBot(botId: string): Promise<void> {
3057
+ const contributions = await this.contributions();
3058
+ contributions.search.purge(botId);
3059
+ contributions.audit.purgeAuditForBot(botId);
3060
+ // Before the to-do entry goes, so a sweep interrupted here runs again.
3061
+ await this.inboundEmail().forgetBot(botId);
3062
+ await contributions.flock.forgetDeletedBot(botId);
3063
+ const userId = await this.provenIdentity();
3064
+ if (userId) {
3065
+ for (const change of await this.groupChats().forgetBot(botId)) {
3066
+ await this.carryGroupChange(userId, change);
3067
+ }
3068
+ }
3069
+ }
3070
+
3071
+ /** Group Chat membership and the User's list of groups. */
3072
+ private groupChats(): GroupChatUserStoreV1 {
3073
+ return new GroupChatUserStoreV1(
3074
+ this.ctx.storage as unknown as GroupChatUserStorageV1,
3075
+ async () =>
3076
+ (await (await this.flockContribution()).listBots()).bots.map((bot) => {
3077
+ const description =
3078
+ bot.currentProfile?.description ?? bot.initialDescription;
3079
+ return {
3080
+ botId: bot.botId,
3081
+ name: bot.currentProfile?.name ?? bot.initialName,
3082
+ ...(description ? { description } : {}),
3083
+ };
3084
+ }),
3085
+ );
3086
+ }
3087
+
3088
+ /** Tells a group's own object about a change committed here. */
3089
+ private async carryGroupChange(
3090
+ userId: string,
3091
+ change: GroupChatChangeV1,
3092
+ ): Promise<void> {
3093
+ if (change.kind === "delete" && durableObjectHasSqlV1(this.ctx.storage)) {
3094
+ // Deleting a group deletes what its members remembered in it.
3095
+ this.memoryEngine().purgeScope(
3096
+ memoryScopeKeyV1(groupChatScopeV1(userId, change.groupId)),
3097
+ );
3098
+ }
3099
+ const namespace = this.env.GROUP_CHATS;
3100
+ if (!namespace) return;
3101
+ const groupId =
3102
+ change.kind === "delete" ? change.groupId : change.context.group.groupId;
3103
+ // SAFETY: the binding names GroupChat; these are its reviewed RPC doors.
3104
+ const group = namespace.get(
3105
+ namespace.idFromName(groupChatObjectNameV1(userId, groupId)),
3106
+ ) as unknown as {
3107
+ recordEvent(input: unknown): Promise<unknown>;
3108
+ destroy(input: unknown): Promise<unknown>;
3109
+ };
3110
+ if (change.kind === "delete") {
3111
+ await group.destroy({ schemaVersion: 1, userId, groupId });
3112
+ return;
3113
+ }
3114
+ await group.recordEvent({
3115
+ schemaVersion: 1,
3116
+ userId,
3117
+ groupId,
3118
+ commandId: change.commandId,
3119
+ actor: change.actor,
3120
+ event: change.event,
3121
+ context: change.context,
3122
+ ...(change.initialize ? { initialize: true } : {}),
3123
+ });
3124
+ }
3125
+
3126
+ async listGroupChats(input: unknown) {
3127
+ const request = decodeRpcEnvelopeV1(input, { userId: rpcIdentifier });
3128
+ await this.assertFlockIdentity(request.userId as string);
3129
+ return answerGroupRpcV1(() => this.groupChats().list());
3130
+ }
3131
+
3132
+ /**
3133
+ * A command about the User's groups. The line it adds to the group's
3134
+ * thread is carried after the commit; a replay of the same command carries
3135
+ * it again, and the group writes it once.
3136
+ */
3137
+ async executeGroupChatCommand(input: unknown) {
3138
+ const request = decodeRpcEnvelopeV1(
3139
+ input,
3140
+ {
3141
+ userId: rpcIdentifier,
3142
+ command: rpcDecoded(decodeGroupChatCommandV1),
3143
+ },
3144
+ { actorBotId: rpcBotId },
3145
+ );
3146
+ const userId = request.userId as string;
3147
+ await this.assertFlockIdentity(userId);
3148
+ await this.assertAccountOpen();
3149
+ const actor: GroupActorV1 =
3150
+ typeof request.actorBotId === "string"
3151
+ ? { kind: "bot", botId: request.actorBotId }
3152
+ : { kind: "user" };
3153
+ return answerGroupRpcV1(async () => {
3154
+ const result = await this.groupChats().execute(
3155
+ userId,
3156
+ request.command as GroupChatCommandV1,
3157
+ actor,
3158
+ );
3159
+ if (result.change) await this.carryGroupChange(userId, result.change);
3160
+ return result.receipt;
3161
+ });
3162
+ }
3163
+
3164
+ /** What a group's own object needs before it acts: the group and names. */
3165
+ async readGroupChatContext(input: unknown) {
3166
+ const request = decodeRpcEnvelopeV1(input, {
3167
+ userId: rpcIdentifier,
3168
+ groupId: rpcPattern(/^g-[0-9a-f]{20}$/, 22),
3169
+ });
3170
+ await this.assertFlockIdentity(request.userId as string);
3171
+ return answerGroupRpcV1(() =>
3172
+ this.groupChats().context(request.groupId as string),
3173
+ );
3174
+ }
3175
+
3176
+ async listBots(input: unknown) {
3177
+ const request = decodeRpcEnvelopeV1(input, { userId: rpcIdentifier });
3178
+ await this.assertFlockIdentity(request.userId as string);
3179
+ return (await this.flockContribution()).listBots();
3180
+ }
3181
+
3182
+ async readFlockBootstrap(input: unknown) {
3183
+ const request = decodeRpcEnvelopeV1(input, { userId: rpcIdentifier });
3184
+ await this.assertFlockIdentity(request.userId as string);
3185
+ return (await this.flockContribution()).readBootstrap();
3186
+ }
3187
+
3188
+ async createBot(input: unknown) {
3189
+ const request = decodeRpcEnvelopeV1(input, {
3190
+ userId: rpcIdentifier,
3191
+ command: rpcDecoded(decodeCreateBotCommandV1),
3192
+ });
3193
+ await this.assertFlockIdentity(request.userId as string);
3194
+ await this.assertAccountOpen();
3195
+ return (await this.flockContribution()).createBot(
3196
+ request.userId as string,
3197
+ request.command as ReturnType<typeof decodeCreateBotCommandV1>,
3198
+ );
3199
+ }
3200
+
3201
+ async listBotLifecycles(input: unknown) {
3202
+ const request = decodeRpcEnvelopeV1(input, { userId: rpcIdentifier });
3203
+ await this.assertFlockIdentity(request.userId as string);
3204
+ return (await this.flockContribution()).listBotLifecycles();
3205
+ }
3206
+
3207
+ async executeBotLifecycle(input: unknown) {
3208
+ const request = decodeRpcEnvelopeV1(input, {
3209
+ userId: rpcIdentifier,
3210
+ command: rpcDecoded(decodeBotLifecycleCommandV1),
3211
+ });
3212
+ await this.assertFlockIdentity(request.userId as string);
3213
+ // The deletion deletes every Bot itself; a restore racing it would not.
3214
+ await this.assertAccountOpen();
3215
+ const command = request.command as ReturnType<
3216
+ typeof decodeBotLifecycleCommandV1
3217
+ >;
3218
+ const receipt = await (
3219
+ await this.flockContribution()
3220
+ ).executeLifecycle(request.userId as string, command);
3221
+ // Archiving a Bot removes its transcript from the index. The rows are a
3222
+ // projection, so this destroys nothing: restoring the Bot and rebuilding
3223
+ // brings every one of them back from the Bot's own stored runs. Its audit
3224
+ // entries stay, because archiving promises the history is preserved.
3225
+ if (command.type === "bot/archive" && receipt.status === "applied") {
3226
+ (await this.searchContribution()).purge(command.botId);
3227
+ }
3228
+ // Deleting a Bot destroys them rather than dropping a projection: nothing
3229
+ // is left to rebuild from. The sweep runs here on the common path and from
3230
+ // the alarm on every other one.
3231
+ if (command.type === "bot/delete" && receipt.status === "applied") {
3232
+ await this.forgetDeletedBot(command.botId);
3233
+ }
3234
+ return receipt;
3235
+ }
3236
+
3237
+ /**
3238
+ * The Bot's object is the authority on its avatar and checks the command's
3239
+ * revision; the directory here is what every Bot list draws from, and it
3240
+ * only ever held the appearance the Bot was created with. The mirror is
3241
+ * written once the Bot has applied the change, and what it writes is the
3242
+ * avatar the Bot reports wearing rather than the one the command asked
3243
+ * for: a replayed command — the Bot answers a stored receipt without
3244
+ * touching its identity — then mirrors what the Bot wears now, so a write
3245
+ * lost between the two heals on the retry without an older command
3246
+ * dragging the directory back.
3247
+ */
3248
+ async updateBotAvatar(input: unknown) {
3249
+ const request = decodeRpcEnvelopeV1(input, {
3250
+ userId: rpcIdentifier,
3251
+ botId: rpcBotId,
3252
+ command: rpcDecoded(decodeUpdateAvatarCommandV1),
3253
+ });
3254
+ const userId = request.userId as string;
3255
+ const botId = request.botId as string;
3256
+ const command = request.command as ReturnType<
3257
+ typeof decodeUpdateAvatarCommandV1
3258
+ >;
3259
+ await this.assertFlockIdentity(userId);
3260
+ const id = this.env.BOT_STATES.idFromName(`${userId}:${botId}`);
3261
+ // SAFETY: BOT_STATES is bound to BotState; generated RPC methods are not represented by workers-types.
3262
+ const bot = this.env.BOT_STATES.get(id) as unknown as {
3263
+ updateAvatar(input: unknown): Promise<unknown>;
3264
+ readAvatar(input: unknown): Promise<unknown>;
3265
+ };
3266
+ const receipt = decodeFlockReceiptV1(
3267
+ rpcJsonSnapshotV1(
3268
+ await bot.updateAvatar({ schemaVersion: 1, userId, botId, command }),
3269
+ ),
3270
+ );
3271
+ if (receipt.status === "applied") {
3272
+ const identity = decodeAvatarIdentityViewV1(
3273
+ rpcJsonSnapshotV1(
3274
+ await bot.readAvatar({ schemaVersion: 1, userId, botId }),
3275
+ ),
3276
+ );
3277
+ await (
3278
+ await this.flockContribution()
3279
+ ).mirrorAvatar(botId, identity.avatar);
3280
+ }
3281
+ return receipt;
3282
+ }
3283
+
3284
+ /** The Bot's own voice record, read through unchanged. */
3285
+ async readBotVoice(input: unknown) {
3286
+ const request = decodeRpcEnvelopeV1(input, {
3287
+ userId: rpcIdentifier,
3288
+ botId: rpcBotId,
3289
+ });
3290
+ const userId = request.userId as string;
3291
+ const botId = request.botId as string;
3292
+ await this.assertFlockIdentity(userId);
3293
+ return decodeVoiceIdentityViewV1(
3294
+ rpcJsonSnapshotV1(
3295
+ await this.botVoiceStub(userId, botId).readVoice({
3296
+ schemaVersion: 1,
3297
+ userId,
3298
+ botId,
3299
+ }),
3300
+ ),
3301
+ );
3302
+ }
3303
+
3304
+ /**
3305
+ * The voice half of the avatar mirror, and for the same reason: the Bot
3306
+ * object holds the revision and decides, and the User's directory — which is
3307
+ * what the voice session reads when it opens a call — is told afterwards.
3308
+ * What is mirrored is the voice the Bot reports, read back from it, so a
3309
+ * replayed command cannot drag the directory back to an older voice.
3310
+ */
3311
+ async updateBotVoice(input: unknown) {
3312
+ const request = decodeRpcEnvelopeV1(input, {
3313
+ userId: rpcIdentifier,
3314
+ botId: rpcBotId,
3315
+ command: rpcDecoded(decodeUpdateVoiceCommandV1),
3316
+ });
3317
+ const userId = request.userId as string;
3318
+ const botId = request.botId as string;
3319
+ const command = request.command as ReturnType<
3320
+ typeof decodeUpdateVoiceCommandV1
3321
+ >;
3322
+ await this.assertFlockIdentity(userId);
3323
+ const bot = this.botVoiceStub(userId, botId);
3324
+ const receipt = decodeFlockReceiptV1(
3325
+ rpcJsonSnapshotV1(
3326
+ await bot.updateVoice({ schemaVersion: 1, userId, botId, command }),
3327
+ ),
3328
+ );
3329
+ if (receipt.status === "applied") {
3330
+ const identity = decodeVoiceIdentityViewV1(
3331
+ rpcJsonSnapshotV1(
3332
+ await bot.readVoice({ schemaVersion: 1, userId, botId }),
3333
+ ),
3334
+ );
3335
+ if (identity.voice) {
3336
+ await (
3337
+ await this.flockContribution()
3338
+ ).mirrorVoice(botId, identity.voice);
3339
+ }
3340
+ }
3341
+ return receipt;
3342
+ }
3343
+
3344
+ async readBotLook(input: unknown) {
3345
+ const request = decodeRpcEnvelopeV1(input, {
3346
+ userId: rpcIdentifier,
3347
+ botId: rpcBotId,
3348
+ });
3349
+ const userId = request.userId as string;
3350
+ const botId = request.botId as string;
3351
+ await this.assertFlockIdentity(userId);
3352
+ return decodeLookIdentityViewV1(
3353
+ rpcJsonSnapshotV1(
3354
+ await this.botLookStub(userId, botId).readLook({
3355
+ schemaVersion: 1,
3356
+ userId,
3357
+ botId,
3358
+ }),
3359
+ ),
3360
+ );
3361
+ }
3362
+
3363
+ async updateBotLook(input: unknown) {
3364
+ const request = decodeRpcEnvelopeV1(input, {
3365
+ userId: rpcIdentifier,
3366
+ botId: rpcBotId,
3367
+ command: rpcDecoded(decodeUpdateLookCommandV1),
3368
+ });
3369
+ const userId = request.userId as string;
3370
+ const botId = request.botId as string;
3371
+ const command = request.command as ReturnType<
3372
+ typeof decodeUpdateLookCommandV1
3373
+ >;
3374
+ await this.assertFlockIdentity(userId);
3375
+ const bot = this.botLookStub(userId, botId);
3376
+ const receipt = decodeFlockReceiptV1(
3377
+ rpcJsonSnapshotV1(
3378
+ await bot.updateLook({ schemaVersion: 1, userId, botId, command }),
3379
+ ),
3380
+ );
3381
+ if (receipt.status === "applied") {
3382
+ const identity = decodeLookIdentityViewV1(
3383
+ rpcJsonSnapshotV1(
3384
+ await bot.readLook({ schemaVersion: 1, userId, botId }),
3385
+ ),
3386
+ );
3387
+ await (
3388
+ await this.flockContribution()
3389
+ ).mirrorLook(botId, identity.look, identity.document);
3390
+ }
3391
+ return receipt;
3392
+ }
3393
+
3394
+ async mirrorBotProfile(input: unknown) {
3395
+ const request = decodeRpcEnvelopeV1(input, {
3396
+ userId: rpcIdentifier,
3397
+ botId: rpcBotId,
3398
+ profile: rpcDecoded(decodeBotDirectoryProfileV1),
3399
+ });
3400
+ const userId = request.userId as string;
3401
+ await this.assertFlockIdentity(userId);
3402
+ return (await this.flockContribution()).mirrorProfile(
3403
+ request.botId as string,
3404
+ request.profile as ReturnType<typeof decodeBotDirectoryProfileV1>,
3405
+ );
3406
+ }
3407
+
3408
+ async mirrorBotLook(input: unknown) {
3409
+ const request = decodeRpcEnvelopeV1(
3410
+ input,
3411
+ {
3412
+ userId: rpcIdentifier,
3413
+ botId: rpcBotId,
3414
+ look: rpcDecoded(decodeBotLookV1),
3415
+ },
3416
+ { document: rpcDecoded(decodeThemeDocumentV1) },
3417
+ );
3418
+ const userId = request.userId as string;
3419
+ await this.assertFlockIdentity(userId);
3420
+ return (await this.flockContribution()).mirrorLook(
3421
+ request.botId as string,
3422
+ request.look as ReturnType<typeof decodeBotLookV1>,
3423
+ request.document === undefined
3424
+ ? undefined
3425
+ : (request.document as ReturnType<typeof decodeThemeDocumentV1>),
3426
+ );
3427
+ }
3428
+
3429
+ /** The Bot object's look surface. */
3430
+ private botLookStub(userId: string, botId: string) {
3431
+ const id = this.env.BOT_STATES.idFromName(`${userId}:${botId}`);
3432
+ // SAFETY: BOT_STATES is bound to BotState; generated RPC methods are not represented by workers-types.
3433
+ return this.env.BOT_STATES.get(id) as unknown as {
3434
+ readLook(input: unknown): Promise<unknown>;
3435
+ updateLook(input: unknown): Promise<unknown>;
3436
+ };
3437
+ }
3438
+
3439
+ /** The Bot object's voice surface. */
3440
+ private botVoiceStub(userId: string, botId: string) {
3441
+ const id = this.env.BOT_STATES.idFromName(`${userId}:${botId}`);
3442
+ // SAFETY: BOT_STATES is bound to BotState; generated RPC methods are not represented by workers-types.
3443
+ return this.env.BOT_STATES.get(id) as unknown as {
3444
+ readVoice(input: unknown): Promise<unknown>;
3445
+ updateVoice(input: unknown): Promise<unknown>;
3446
+ };
3447
+ }
3448
+
3449
+ async getBotRegistration(input: unknown) {
3450
+ const request = decodeRpcEnvelopeV1(input, {
3451
+ userId: rpcIdentifier,
3452
+ botId: rpcBotId,
3453
+ });
3454
+ await this.assertFlockIdentity(request.userId as string);
3455
+ return (await this.flockContribution()).registration(
3456
+ request.botId as string,
3457
+ );
3458
+ }
3459
+
3460
+ async hasBot(input: unknown) {
3461
+ const request = decodeRpcEnvelopeV1(input, {
3462
+ userId: rpcIdentifier,
3463
+ botId: rpcBotId,
3464
+ });
3465
+ await this.assertFlockIdentity(request.userId as string);
3466
+ const botId = request.botId as string;
3467
+ return {
3468
+ schemaVersion: 1,
3469
+ botId,
3470
+ registered: await (await this.flockContribution()).hasBot(botId),
3471
+ } as const;
3472
+ }
3473
+
3474
+ async isPackageInstalled(input: unknown) {
3475
+ const request = decodeRpcEnvelopeV1(input, {
3476
+ userId: rpcIdentifier,
3477
+ packageId: rpcIdentifier,
3478
+ });
3479
+ return (await this.settingsContribution()).isPackageInstalled(
3480
+ request.userId as string,
3481
+ request.packageId as string,
3482
+ );
3483
+ }
3484
+
3485
+ private async searchContribution(): Promise<
3486
+ MountedFoundationUserBackend["search"]
3487
+ > {
3488
+ return (await this.contributions()).search;
3489
+ }
3490
+
3491
+ /**
3492
+ * Rows for one settled Turn, from the Bot Durable Object that owns it.
3493
+ *
3494
+ * Idempotent on `(botId, runId, seq)`: a resumed Turn, a retried RPC, and a
3495
+ * rebuild all converge on the same rows, so the Bot may treat the call as
3496
+ * fire-and-forget without risking a duplicated transcript.
3497
+ */
3498
+ async indexSearchRows(input: unknown): Promise<{ indexed: number }> {
3499
+ const request = decodeRpcEnvelopeV1(input, {
3500
+ userId: rpcIdentifier,
3501
+ botId: rpcBotId,
3502
+ rows: rpcDecodedValue,
3503
+ });
3504
+ await this.assertFlockIdentity(request.userId as string);
3505
+ const botId = request.botId as string;
3506
+ if (!(await (await this.flockContribution()).hasBot(botId))) {
3507
+ throw new Error(`Bot "${botId}" is not registered to this User`);
3508
+ }
3509
+ if (!Array.isArray(request.rows)) {
3510
+ throw new Error("RPC request.rows must be an array");
3511
+ }
3512
+ if (request.rows.length > SEARCH_MAX_ROW_PAGE_V1) {
3513
+ throw new Error("RPC request.rows exceeds its bound");
3514
+ }
3515
+ // A Bot indexes its own transcript and no other's. The `botId` was proved
3516
+ // registered to this User above; a row naming a different Bot is refused
3517
+ // rather than quietly dropped.
3518
+ const foreign = request.rows.find(
3519
+ (row) =>
3520
+ !row ||
3521
+ typeof row !== "object" ||
3522
+ (row as { botId?: unknown }).botId !== botId,
3523
+ );
3524
+ if (foreign) {
3525
+ throw new Error("search rows name another Bot");
3526
+ }
3527
+ return (await this.searchContribution()).indexRows(request.rows);
3528
+ }
3529
+
3530
+ /** One page of hits across every Bot this User has. */
3531
+ async searchTranscripts(input: unknown): Promise<SearchIndexResultsV1> {
3532
+ const request = decodeRpcEnvelopeV1(input, {
3533
+ userId: rpcIdentifier,
3534
+ query: rpcDecoded(decodeSearchQueryV1),
3535
+ });
3536
+ await this.assertFlockIdentity(request.userId as string);
3537
+ return (await this.searchContribution()).search(request.query);
3538
+ }
3539
+
3540
+ /**
3541
+ * Throws the index away and re-projects it from the Bots' own stored runs.
3542
+ *
3543
+ * The index is disposable because this exists. It is also the backfill path
3544
+ * for a Bot whose turns predate the index, so one code path produces every
3545
+ * row the index has ever held.
3546
+ */
3547
+ async rebuildSearchIndex(
3548
+ input: unknown,
3549
+ ): Promise<ClientSearchRebuildReceiptV1> {
3550
+ const request = decodeRpcEnvelopeV1(input, { userId: rpcIdentifier });
3551
+ await this.assertFlockIdentity(request.userId as string);
3552
+ const outcome = await (await this.searchContribution()).rebuild();
3553
+ return {
3554
+ schemaVersion: 1,
3555
+ status: "rebuilt",
3556
+ indexedRows: outcome.indexedRows,
3557
+ bots: Math.min(outcome.bots, SEARCH_REBUILD_BOT_LIMIT),
3558
+ indexState: outcome.indexState,
3559
+ };
3560
+ }
3561
+
3562
+ /** Every row of one Bot leaves the index. Archiving a Bot calls this. */
3563
+ async purgeSearchIndex(input: unknown): Promise<{ removed: number }> {
3564
+ const request = decodeRpcEnvelopeV1(input, {
3565
+ userId: rpcIdentifier,
3566
+ botId: rpcBotId,
3567
+ });
3568
+ await this.assertFlockIdentity(request.userId as string);
3569
+ return (await this.searchContribution()).purge(request.botId as string);
3570
+ }
3571
+
3572
+ private async auditContribution(): Promise<
3573
+ MountedFoundationUserBackend["audit"]
3574
+ > {
3575
+ return (await this.contributions()).audit;
3576
+ }
3577
+
3578
+ /**
3579
+ * Audit entries for one settled Turn, from the Bot Durable Object that owns
3580
+ * it.
3581
+ *
3582
+ * Idempotent on `(botId, runId, occurrenceId)`, which is what makes the Bot
3583
+ * object's outbox safe: it delivers at least once, and a redelivery inserts
3584
+ * nothing.
3585
+ */
3586
+ async indexAuditEntries(input: unknown): Promise<{ indexed: number }> {
3587
+ const request = decodeRpcEnvelopeV1(input, {
3588
+ userId: rpcIdentifier,
3589
+ botId: rpcBotId,
3590
+ entries: rpcDecodedValue,
3591
+ });
3592
+ await this.assertFlockIdentity(request.userId as string);
3593
+ const botId = request.botId as string;
3594
+ if (!(await (await this.flockContribution()).hasBot(botId))) {
3595
+ throw new Error(`Bot "${botId}" is not registered to this User`);
3596
+ }
3597
+ if (!Array.isArray(request.entries)) {
3598
+ throw new Error("RPC request.entries must be an array");
3599
+ }
3600
+ if (request.entries.length > AUDIT_MAX_ENTRY_PAGE_V1) {
3601
+ throw new Error("RPC request.entries exceeds its bound");
3602
+ }
3603
+ // A Bot audits its own effects and no other's.
3604
+ const foreign = request.entries.find(
3605
+ (entry) =>
3606
+ !entry ||
3607
+ typeof entry !== "object" ||
3608
+ (entry as { botId?: unknown }).botId !== botId,
3609
+ );
3610
+ if (foreign) {
3611
+ throw new Error("audit entries name another Bot");
3612
+ }
3613
+ return (await this.auditContribution()).indexAuditEntries(request.entries);
3614
+ }
3615
+
3616
+ /**
3617
+ * Throws the audit table away and re-projects it from the Bots' own stored
3618
+ * runs. The receipt counts the outcomes the logs do not know, never a
3619
+ * silent gap.
3620
+ */
3621
+ async rebuildAuditIndex(input: unknown): Promise<AuditRebuildReceiptV1> {
3622
+ const request = decodeRpcEnvelopeV1(input, { userId: rpcIdentifier });
3623
+ await this.assertFlockIdentity(request.userId as string);
3624
+ return (await this.auditContribution()).rebuildAuditIndex();
3625
+ }
3626
+
3627
+ /**
3628
+ * One filtered, paged answer out of this User's audit table.
3629
+ *
3630
+ * User-scoped by construction: there is no cross-User store to reach into,
3631
+ * and the object refuses any RPC naming a User it is not.
3632
+ */
3633
+ async readAuditEntries(input: unknown): Promise<ClientAuditPageV1> {
3634
+ const request = decodeRpcEnvelopeV1(
3635
+ input,
3636
+ { userId: rpcIdentifier },
3637
+ {
3638
+ botId: rpcBotId,
3639
+ kind: rpcEnum(AUDIT_KINDS_V1),
3640
+ target: rpcString(160),
3641
+ before: rpcString(AUDIT_MAX_CURSOR_LENGTH_V1),
3642
+ limit: rpcInteger({ minimum: 1, maximum: AUDIT_MAX_RESULTS_V1 }),
3643
+ },
3644
+ );
3645
+ await this.assertFlockIdentity(request.userId as string);
3646
+ const contribution = await this.auditContribution();
3647
+ const page = contribution.query({
3648
+ ...(request.botId === undefined
3649
+ ? {}
3650
+ : { botId: request.botId as string }),
3651
+ ...(request.kind === undefined ? {} : { kind: request.kind as string }),
3652
+ ...(request.target === undefined
3653
+ ? {}
3654
+ : { target: request.target as string }),
3655
+ ...(request.before === undefined
3656
+ ? {}
3657
+ : { before: request.before as string }),
3658
+ ...(request.limit === undefined
3659
+ ? {}
3660
+ : { limit: request.limit as number }),
3661
+ });
3662
+ return {
3663
+ schemaVersion: 1,
3664
+ entries: page.entries,
3665
+ page: {
3666
+ truncated: page.nextCursor !== undefined,
3667
+ ...(page.nextCursor === undefined
3668
+ ? {}
3669
+ : { nextCursor: page.nextCursor }),
3670
+ },
3671
+ total: page.total,
3672
+ indexState: contribution.state(),
3673
+ };
3674
+ }
3675
+
3676
+ /** One page of Activity out of this User's audit table, a Turn to a row. */
3677
+ async readAuditActivity(input: unknown): Promise<AuditActivityPageV1> {
3678
+ const request = decodeRpcEnvelopeV1(
3679
+ input,
3680
+ { userId: rpcIdentifier },
3681
+ {
3682
+ botId: rpcBotId,
3683
+ filter: rpcEnum(AUDIT_ACTIVITY_FILTER_NAMES_V1),
3684
+ before: rpcString(AUDIT_MAX_CURSOR_LENGTH_V1),
3685
+ limit: rpcInteger({ minimum: 1, maximum: AUDIT_ACTIVITY_MAX_ROWS_V1 }),
3686
+ },
3687
+ );
3688
+ await this.assertFlockIdentity(request.userId as string);
3689
+ return (await this.auditContribution()).activity({
3690
+ ...(request.botId === undefined
3691
+ ? {}
3692
+ : { botId: request.botId as string }),
3693
+ ...(request.filter === undefined
3694
+ ? {}
3695
+ : { filter: request.filter as AuditActivityFilterV1 }),
3696
+ ...(request.before === undefined
3697
+ ? {}
3698
+ : { before: request.before as string }),
3699
+ ...(request.limit === undefined
3700
+ ? {}
3701
+ : { limit: request.limit as number }),
3702
+ });
3703
+ }
3704
+
3705
+ private async machineContribution(): Promise<
3706
+ MountedFoundationUserBackend["machines"]
3707
+ > {
3708
+ return (await this.contributions()).machines;
3709
+ }
3710
+
3711
+ /**
3712
+ * The registered-machine RPCs (parity register rows 48, 49, 57g).
3713
+ *
3714
+ * The four a machine reaches — its socket, claim, result, and the
3715
+ * enrollment that precedes them — arrive from the gateway's pre-session
3716
+ * `publicRoute`, so this object is the first place a *session* was never
3717
+ * involved. That is exactly why each carries the token's own claims and its
3718
+ * digest rather than a caller's assertion: the claims were verified against
3719
+ * the deployment secret at the edge, and the digest is checked here against
3720
+ * the machine record, which is the authority. `assertUserIdentity` still
3721
+ * runs, so a token naming another User cannot reach this object's state even
3722
+ * if the gateway addressed it wrongly.
3723
+ */
3724
+ async createMachinePairing(input: unknown) {
3725
+ const request = decodeRpcEnvelopeV1(input, { userId: rpcIdentifier });
3726
+ const userId = await this.assertUserIdentity(request.userId as string);
3727
+ return (await this.machineContribution()).createPairing(userId);
3728
+ }
3729
+
3730
+ async enrollMachine(input: unknown) {
3731
+ const request = decodeRpcEnvelopeV1(input, {
3732
+ userId: rpcIdentifier,
3733
+ machineId: rpcIdentifier,
3734
+ enrollment: rpcDecodedValue,
3735
+ });
3736
+ const userId = await this.assertUserIdentity(request.userId as string);
3737
+ return (await this.machineContribution()).enroll(
3738
+ {
3739
+ userId,
3740
+ machineId: request.machineId as string,
3741
+ nonce: "",
3742
+ },
3743
+ request.enrollment,
3744
+ );
3745
+ }
3746
+
3747
+ /**
3748
+ * A registered machine's socket: the only request this object answers over
3749
+ * `fetch`, because RPC cannot hand over a WebSocket.
3750
+ *
3751
+ * The gateway verified the token against the deployment secret; this is the
3752
+ * authoritative check against the machine record, then the socket is
3753
+ * accepted to hibernate and sent every command still waiting.
3754
+ */
3755
+ fetch(request: Request): Promise<Response> {
3756
+ return answeredEntryV1("Machine socket failed", () =>
3757
+ this.openMachineSocket(request),
3758
+ );
3759
+ }
3760
+
3761
+ private async openMachineSocket(request: Request): Promise<Response> {
3762
+ if (new URL(request.url).pathname !== MACHINE_SOCKET_INTERNAL_PATH_V1) {
3763
+ return Response.json({ error: "not found" }, { status: 404 });
3764
+ }
3765
+ if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
3766
+ return Response.json(
3767
+ { error: "WebSocket upgrade required" },
3768
+ { status: 426 },
3769
+ );
3770
+ }
3771
+ const machines = await this.machineContribution();
3772
+ let opened: Awaited<ReturnType<typeof machines.connect>>;
3773
+ let modules: MachineSocketFrameV1 | undefined;
3774
+ let machineId: string;
3775
+ let tokenDigest: string;
3776
+ try {
3777
+ const { userId, call } = readMachineSocketCallV1(request);
3778
+ await this.assertUserIdentity(userId);
3779
+ machineId = call.machineId;
3780
+ tokenDigest = call.tokenDigest;
3781
+ opened = await machines.connect(call.claims, tokenDigest, machineId);
3782
+ } catch (error) {
3783
+ const status =
3784
+ typeof error === "object" &&
3785
+ error !== null &&
3786
+ "status" in error &&
3787
+ typeof error.status === "number"
3788
+ ? error.status
3789
+ : 401;
3790
+ return Response.json({ error: "machine token is invalid" }, { status });
3791
+ }
3792
+ const platform = opened.record.platform;
3793
+ // Commands still flow while the Composition cannot be read; the list
3794
+ // arrives with the next generation change or reconnect.
3795
+ await loggedEntryV1("Machine modules frame", async () => {
3796
+ modules = await this.machineModulesFrame(platform);
3797
+ });
3798
+ // Nothing is awaited from here to the send, so a dispatch cannot land
3799
+ // between the pending read above and the socket being registered.
3800
+ const [client, server] = Object.values(new WebSocketPair());
3801
+ this.ctx.acceptWebSocket(server!, [machineSocketTagV1(machineId)]);
3802
+ server!.serializeAttachment({
3803
+ machineId,
3804
+ tokenDigest,
3805
+ keyVersion: opened.record.keyVersion,
3806
+ platform,
3807
+ } satisfies MachineSocketAttachmentV1);
3808
+ server!.send(JSON.stringify(opened.frame));
3809
+ if (modules) server!.send(JSON.stringify(modules));
3810
+ return new Response(null, { status: 101, webSocket: client });
3811
+ }
3812
+
3813
+ private async machineModulesFrame(
3814
+ platform: MachinePlatformV1,
3815
+ ): Promise<MachineSocketFrameV1> {
3816
+ return {
3817
+ type: "modules",
3818
+ modules: machineModulesV1(
3819
+ await this.activeGeneration(),
3820
+ platform,
3821
+ await readModuleRoutingV1(
3822
+ this.ctx.storage,
3823
+ await this.pluginTriggerRoutines(),
3824
+ ),
3825
+ ),
3826
+ serverTime: new Date().toISOString(),
3827
+ };
3828
+ }
3829
+
3830
+ // The machine socket is server-push only: anything but the auto-answered
3831
+ // keep-alive is a client that does not speak this protocol.
3832
+ webSocketMessage(socket: WebSocket): Promise<void> {
3833
+ return loggedEntryV1("Machine socket message", () =>
3834
+ socket.close(1003, "server-push channel"),
3835
+ );
3836
+ }
3837
+
3838
+ webSocketClose(
3839
+ socket: WebSocket,
3840
+ code: number,
3841
+ reason: string,
3842
+ ): Promise<void> {
3843
+ return loggedEntryV1("Machine socket close", async () => {
3844
+ try {
3845
+ socket.close(code, reason);
3846
+ } catch {
3847
+ // Already closed.
3848
+ }
3849
+ await this.machineSocketClosed(socket);
3850
+ });
3851
+ }
3852
+
3853
+ webSocketError(socket: WebSocket): Promise<void> {
3854
+ return loggedEntryV1("Machine socket error", async () => {
3855
+ try {
3856
+ socket.close(1011, "socket error");
3857
+ } catch {
3858
+ // Already closed.
3859
+ }
3860
+ await this.machineSocketClosed(socket);
3861
+ });
3862
+ }
3863
+
3864
+ private async machineSocketClosed(socket: WebSocket): Promise<void> {
3865
+ const attachment =
3866
+ socket.deserializeAttachment() as MachineSocketAttachmentV1 | null;
3867
+ if (!attachment) return;
3868
+ await (await this.machineContribution()).disconnected(attachment.machineId);
3869
+ }
3870
+
3871
+ async claimMachineCommand(input: unknown) {
3872
+ const request = decodeRpcEnvelopeV1(input, {
3873
+ userId: rpcIdentifier,
3874
+ machineId: rpcIdentifier,
3875
+ commandId: rpcIdentifier,
3876
+ claims: rpcDecodedValue,
3877
+ tokenDigest: rpcPattern(/^[0-9a-f]{64}$/, 64),
3878
+ });
3879
+ await this.assertUserIdentity(request.userId as string);
3880
+ return (await this.machineContribution()).claim(
3881
+ machineTokenClaimsV1(request.claims),
3882
+ request.tokenDigest as string,
3883
+ request.machineId as string,
3884
+ request.commandId as string,
3885
+ );
3886
+ }
3887
+
3888
+ async recordMachineResult(input: unknown) {
3889
+ const request = decodeRpcEnvelopeV1(input, {
3890
+ userId: rpcIdentifier,
3891
+ machineId: rpcIdentifier,
3892
+ commandId: rpcIdentifier,
3893
+ claims: rpcDecodedValue,
3894
+ tokenDigest: rpcPattern(/^[0-9a-f]{64}$/, 64),
3895
+ result: rpcDecodedValue,
3896
+ });
3897
+ await this.assertUserIdentity(request.userId as string);
3898
+ return (await this.machineContribution()).recordResult(
3899
+ machineTokenClaimsV1(request.claims),
3900
+ request.tokenDigest as string,
3901
+ request.machineId as string,
3902
+ request.commandId as string,
3903
+ request.result,
3904
+ );
3905
+ }
3906
+
3907
+ /**
3908
+ * Whether a machine may fetch the module artifact `contentHash`: only one
3909
+ * the active generation carries. The Worker serves the bytes; this object
3910
+ * is the authority on which bytes are the account's to hand out.
3911
+ */
3912
+ async readMachineModule(input: unknown): Promise<{ found: boolean }> {
3913
+ const request = decodeRpcEnvelopeV1(input, {
3914
+ userId: rpcIdentifier,
3915
+ machineId: rpcIdentifier,
3916
+ contentHash: rpcPattern(/^[0-9a-f]{64}$/, 64),
3917
+ claims: rpcDecodedValue,
3918
+ tokenDigest: rpcPattern(/^[0-9a-f]{64}$/, 64),
3919
+ });
3920
+ await this.assertUserIdentity(request.userId as string);
3921
+ await (
3922
+ await this.machineContribution()
3923
+ ).authorize(
3924
+ machineTokenClaimsV1(request.claims),
3925
+ request.tokenDigest as string,
3926
+ request.machineId as string,
3927
+ );
3928
+ return {
3929
+ found: generationCarriesModuleV1(
3930
+ await this.activeGeneration(),
3931
+ request.contentHash as string,
3932
+ ),
3933
+ };
3934
+ }
3935
+
3936
+ /** What a machine's module host said about the modules it runs. */
3937
+ async recordMachineModuleReports(
3938
+ input: unknown,
3939
+ ): Promise<MachineModuleReportsReceiptV1> {
3940
+ const request = decodeRpcEnvelopeV1(input, {
3941
+ userId: rpcIdentifier,
3942
+ machineId: rpcIdentifier,
3943
+ claims: rpcDecodedValue,
3944
+ tokenDigest: rpcPattern(/^[0-9a-f]{64}$/, 64),
3945
+ reports: rpcDecoded(decodeMachineModuleReportsV1),
3946
+ });
3947
+ await this.assertUserIdentity(request.userId as string);
3948
+ const record = await (
3949
+ await this.machineContribution()
3950
+ ).authorize(
3951
+ machineTokenClaimsV1(request.claims),
3952
+ request.tokenDigest as string,
3953
+ request.machineId as string,
3954
+ );
3955
+ const receipt = await recordPluginModuleReportsV1(this.ctx.storage, {
3956
+ generation: await this.activeGeneration(),
3957
+ machineId: record.machineId,
3958
+ machineLabel: record.label,
3959
+ reports: (
3960
+ request.reports as ReturnType<typeof decodeMachineModuleReportsV1>
3961
+ ).reports,
3962
+ now: new Date(),
3963
+ });
3964
+ return { schemaVersion: 1, ...receipt };
3965
+ }
3966
+
3967
+ /** How long a device call waits for its module; a test may shorten it. */
3968
+ deviceCallWaitMs = DEVICE_CALL_WAIT_MS;
3969
+ private deviceCalls: MachineModuleCallsV1 | undefined;
3970
+
3971
+ /** Plugins' calls to their device modules (ADR 0037). */
3972
+ private moduleCalls(): MachineModuleCallsV1 {
3973
+ this.deviceCalls ??= new MachineModuleCallsV1({
3974
+ storage: this.ctx.storage,
3975
+ waitMs: this.deviceCallWaitMs,
3976
+ // A machine runs the module if it is connected and was sent it: the
3977
+ // active generation carries it for the machine's platform, with the call.
3978
+ candidates: async (request) => {
3979
+ const connected = connectedMachinePlatformsV1(this.ctx);
3980
+ if (connected.size === 0) return [];
3981
+ const active = await this.activeGeneration();
3982
+ return [...connected]
3983
+ .filter(([, platform]) =>
3984
+ machineModulesV1(active, platform).some(
3985
+ (module) =>
3986
+ module.pluginId === request.pluginId &&
3987
+ module.moduleId === request.moduleId &&
3988
+ module.calls.includes(request.call),
3989
+ ),
3990
+ )
3991
+ .map(([machineId]) => machineId)
3992
+ .sort();
3993
+ },
3994
+ push: (machineId, frame) =>
3995
+ durableObjectMachineSocketsV1(this.ctx).push(machineId, frame),
3996
+ });
3997
+ return this.deviceCalls;
3998
+ }
3999
+
4000
+ /**
4001
+ * One Plugin tool's call to its device module, from the Bot's object,
4002
+ * which already checked the Plugin declares the module and the call. The
4003
+ * answer waits for the desktop at most until the call's deadline.
4004
+ */
4005
+ async callDeviceModule(input: unknown): Promise<DeviceCallOutcomeV1> {
4006
+ const request = decodeRpcEnvelopeV1(
4007
+ input,
4008
+ {
4009
+ userId: rpcIdentifier,
4010
+ botId: rpcBotId,
4011
+ callId: rpcPattern(/^mc-[0-9a-f]{40}$/, 43),
4012
+ pluginId: rpcPattern(/^[a-z][a-z0-9-]{0,63}$/, 64),
4013
+ moduleId: rpcPattern(/^[a-z][a-z0-9-]{0,31}$/, 32),
4014
+ call: rpcString(64),
4015
+ // Bounded by the Bot's object, which refused an oversized one before
4016
+ // anything was recorded.
4017
+ input: rpcDecoded((value) => value),
4018
+ },
4019
+ { deviceId: rpcIdentifier },
4020
+ );
4021
+ await this.assertUserIdentity(request.userId as string);
4022
+ return this.moduleCalls().call({
4023
+ callId: request.callId as string,
4024
+ botId: request.botId as string,
4025
+ pluginId: request.pluginId as string,
4026
+ moduleId: request.moduleId as string,
4027
+ call: request.call as string,
4028
+ input: request.input,
4029
+ ...(request.deviceId === undefined
4030
+ ? {}
4031
+ : { deviceId: request.deviceId as string }),
4032
+ });
4033
+ }
4034
+
4035
+ async claimMachineModuleCall(input: unknown) {
4036
+ const request = decodeRpcEnvelopeV1(input, {
4037
+ userId: rpcIdentifier,
4038
+ machineId: rpcIdentifier,
4039
+ callId: rpcIdentifier,
4040
+ claims: rpcDecodedValue,
4041
+ tokenDigest: rpcPattern(/^[0-9a-f]{64}$/, 64),
4042
+ });
4043
+ await this.assertUserIdentity(request.userId as string);
4044
+ const record = await (
4045
+ await this.machineContribution()
4046
+ ).authorize(
4047
+ machineTokenClaimsV1(request.claims),
4048
+ request.tokenDigest as string,
4049
+ request.machineId as string,
4050
+ );
4051
+ return this.moduleCalls().claim(record.machineId, request.callId as string);
4052
+ }
4053
+
4054
+ /**
4055
+ * A module call's answer. One after its deadline reached nobody, so it is
4056
+ * put in the Plugin's module reports, where the Bot and the Work view can
4057
+ * see what happened after the Turn moved on.
4058
+ */
4059
+ async recordMachineModuleCallResult(input: unknown) {
4060
+ const request = decodeRpcEnvelopeV1(input, {
4061
+ userId: rpcIdentifier,
4062
+ machineId: rpcIdentifier,
4063
+ callId: rpcIdentifier,
4064
+ claims: rpcDecodedValue,
4065
+ tokenDigest: rpcPattern(/^[0-9a-f]{64}$/, 64),
4066
+ result: rpcDecoded(decodeMachineModuleCallResultV1),
4067
+ });
4068
+ await this.assertUserIdentity(request.userId as string);
4069
+ const record = await (
4070
+ await this.machineContribution()
4071
+ ).authorize(
4072
+ machineTokenClaimsV1(request.claims),
4073
+ request.tokenDigest as string,
4074
+ request.machineId as string,
4075
+ );
4076
+ const calls = this.moduleCalls();
4077
+ const callId = request.callId as string;
4078
+ const result = request.result as ReturnType<
4079
+ typeof decodeMachineModuleCallResultV1
4080
+ >;
4081
+ const receipt = await calls.result(record.machineId, callId, result);
4082
+ if (receipt.status === "late") {
4083
+ await loggedEntryV1("Late module call report", async () => {
4084
+ const call = await calls.describe(callId);
4085
+ if (!call) return;
4086
+ const answer = result.ok
4087
+ ? `answered ${JSON.stringify(result.value)}`
4088
+ : `failed: ${result.error}`;
4089
+ await recordPluginModuleReportsV1(this.ctx.storage, {
4090
+ generation: await this.activeGeneration(),
4091
+ machineId: record.machineId,
4092
+ machineLabel: record.label,
4093
+ reports: [
4094
+ {
4095
+ pluginId: call.pluginId,
4096
+ moduleId: call.moduleId,
4097
+ kind: "log",
4098
+ level: "error",
4099
+ text: `call "${call.call}" ${answer} after its deadline; the Turn that asked was not told`.slice(
4100
+ 0,
4101
+ 2_000,
4102
+ ),
4103
+ },
4104
+ ],
4105
+ now: new Date(),
4106
+ });
4107
+ });
4108
+ }
4109
+ return receipt;
4110
+ }
4111
+
4112
+ /**
4113
+ * Events a machine's device modules emitted (ADR 0037), each routed to
4114
+ * every Routine that listens and answered only once that is durable. A
4115
+ * Bot that cannot be reached fails the post rather than the event: the
4116
+ * desktop sends it again, and each Routine's own replay guard, keyed by the
4117
+ * event's key, lets a retry fire only what the first attempt did not.
4118
+ */
4119
+ async recordMachineModuleEvents(
4120
+ input: unknown,
4121
+ ): Promise<MachineModuleEventsReceiptV1> {
4122
+ const request = decodeRpcEnvelopeV1(input, {
4123
+ userId: rpcIdentifier,
4124
+ machineId: rpcIdentifier,
4125
+ claims: rpcDecodedValue,
4126
+ tokenDigest: rpcPattern(/^[0-9a-f]{64}$/, 64),
4127
+ events: rpcDecoded(decodeMachineModuleEventsV1),
4128
+ });
4129
+ const userId = await this.assertUserIdentity(request.userId as string);
4130
+ const record = await (
4131
+ await this.machineContribution()
4132
+ ).authorize(
4133
+ machineTokenClaimsV1(request.claims),
4134
+ request.tokenDigest as string,
4135
+ request.machineId as string,
4136
+ );
4137
+ const { events } = request.events as ReturnType<
4138
+ typeof decodeMachineModuleEventsV1
4139
+ >;
4140
+ const generation = await this.activeGeneration();
4141
+ const routines = await this.pluginTriggerRoutines();
4142
+ const liveBots = new Set(
4143
+ (await (await this.flockContribution()).listBots()).bots.map(
4144
+ (bot) => bot.botId,
4145
+ ),
4146
+ );
4147
+ const receipts: MachineModuleEventReceiptV1[] = [];
4148
+ let moved = false;
4149
+ for (const event of events) {
4150
+ const now = new Date();
4151
+ const refused = refuseModuleEventV1(generation, event);
4152
+ if (refused !== undefined) {
4153
+ receipts.push({ status: "dropped", reason: refused });
4154
+ continue;
4155
+ }
4156
+ const origin = {
4157
+ machineId: record.machineId,
4158
+ pluginId: event.pluginId,
4159
+ key: event.key,
4160
+ now,
4161
+ };
4162
+ if (await moduleEventSeenV1(this.ctx.storage, origin)) {
4163
+ receipts.push({ status: "duplicate" });
4164
+ continue;
4165
+ }
4166
+ await Promise.all(
4167
+ moduleEventTargetsV1(routines, event, liveBots).map(async (routine) =>
4168
+ this.botRoutinesStub(userId, routine.botId).deliverPluginModuleEvent({
4169
+ schemaVersion: 1,
4170
+ userId,
4171
+ botId: routine.botId,
4172
+ ...(await moduleEventDeliveryV1({
4173
+ machineId: record.machineId,
4174
+ event,
4175
+ routine,
4176
+ })),
4177
+ }),
4178
+ ),
4179
+ );
4180
+ moved =
4181
+ (await recordModuleEventV1(this.ctx.storage, {
4182
+ machineId: record.machineId,
4183
+ event,
4184
+ now,
4185
+ })) || moved;
4186
+ receipts.push({ status: "admitted" });
4187
+ }
4188
+ await trimModuleEventsSeenV1(this.ctx.storage, new Date());
4189
+ if (moved) await this.pushMachineModules();
4190
+ return { schemaVersion: 1, receipts };
4191
+ }
4192
+
4193
+ /** One Plugin's module reports, for its Bot's `plugin_module_reports`. */
4194
+ async readPluginModuleReports(input: unknown) {
4195
+ const request = decodeRpcEnvelopeV1(input, {
4196
+ userId: rpcIdentifier,
4197
+ pluginId: rpcPattern(/^[a-z][a-z0-9-]{0,63}$/, 64),
4198
+ });
4199
+ await this.assertUserIdentity(request.userId as string);
4200
+ return readPluginModuleReportsV1(
4201
+ this.ctx.storage,
4202
+ request.pluginId as string,
4203
+ );
4204
+ }
4205
+
4206
+ /**
4207
+ * One machine and the two counters a control tool checks its quota against.
4208
+ *
4209
+ * The tool refuses before it asks a person anything, so a card the User
4210
+ * approves and the queue then refuses is not a question that wasted their
4211
+ * attention.
4212
+ */
4213
+ async describeMachineTarget(input: unknown) {
4214
+ const request = decodeRpcEnvelopeV1(input, {
4215
+ userId: rpcIdentifier,
4216
+ machineId: rpcIdentifier,
4217
+ });
4218
+ await this.assertUserIdentity(request.userId as string);
4219
+ return (await this.machineContribution()).describeTarget(
4220
+ request.machineId as string,
4221
+ );
4222
+ }
4223
+
4224
+ /**
4225
+ * Take every finished machine command waiting to be told to a Bot.
4226
+ *
4227
+ * Drained by the Worker that just answered the machine. It is not this
4228
+ * object's job to call another Durable Object: a live reference from one to
4229
+ * another keeps the caller resident, and presence and eviction are the two
4230
+ * things this registry is built not to depend on.
4231
+ */
4232
+ async takeMachineDeliveries(input: unknown) {
4233
+ const request = decodeRpcEnvelopeV1(input, { userId: rpcIdentifier });
4234
+ await this.assertUserIdentity(request.userId as string);
4235
+ return (await this.machineContribution()).takeDeliveries();
4236
+ }
4237
+
4238
+ async listMachines(input: unknown) {
4239
+ const request = decodeRpcEnvelopeV1(input, { userId: rpcIdentifier });
4240
+ await this.assertUserIdentity(request.userId as string);
4241
+ return (await this.machineContribution()).list();
4242
+ }
4243
+
4244
+ async revokeMachine(input: unknown) {
4245
+ const request = decodeRpcEnvelopeV1(input, {
4246
+ userId: rpcIdentifier,
4247
+ machineId: rpcIdentifier,
4248
+ });
4249
+ await this.assertUserIdentity(request.userId as string);
4250
+ return (await this.machineContribution()).revoke(
4251
+ request.machineId as string,
4252
+ );
4253
+ }
4254
+
4255
+ /**
4256
+ * Put one approved command on a machine's queue.
4257
+ *
4258
+ * The caller that matters is R3's approval settlement — a command reaches a
4259
+ * User's laptop only after a human decided — and it is here in R2 so the
4260
+ * queue's own rules have a door.
4261
+ */
4262
+ async dispatchMachineCommand(input: unknown) {
4263
+ const request = decodeRpcEnvelopeV1(input, {
4264
+ userId: rpcIdentifier,
4265
+ command: rpcDecodedValue,
4266
+ });
4267
+ await this.assertUserIdentity(request.userId as string);
4268
+ return (await this.machineContribution()).dispatch(request.command);
4269
+ }
4270
+
4271
+ /** One command's full result, read on demand rather than pushed. */
4272
+ async readMachineResult(input: unknown) {
4273
+ const request = decodeRpcEnvelopeV1(input, {
4274
+ userId: rpcIdentifier,
4275
+ commandId: rpcIdentifier,
4276
+ });
4277
+ await this.assertUserIdentity(request.userId as string);
4278
+ return (await this.machineContribution()).readResult(
4279
+ request.commandId as string,
4280
+ );
4281
+ }
4282
+
4283
+ private async assertFlockIdentity(userId: string): Promise<void> {
4284
+ await this.assertUserIdentity(userId);
4285
+ await (
4286
+ await this.settingsContribution()
4287
+ ).readConfiguration({ schemaVersion: 1, userId });
4288
+ }
4289
+
4290
+ // --- Inbound email -----------------------------------------------------------
4291
+ //
4292
+ // Which Bots receive email, and the account's confirmed senders. The
4293
+ // `email()` handler asks `routeInboundEmail` about every message that got
4294
+ // past its own checks; the settings routes read and change the rest. The
4295
+ // username is the deployment directory's, not this object's.
4296
+
4297
+ /** The User's inbound email, over this object's storage and Bot directory. */
4298
+ private inboundEmail(): InboundEmailUserStoreV1 {
4299
+ return new InboundEmailUserStoreV1({
4300
+ storage: this.ctx.storage as unknown as InboundEmailUserHostV1["storage"],
4301
+ bots: async () => {
4302
+ const flock = await this.flockContribution();
4303
+ const [directory, lifecycles] = await Promise.all([
4304
+ flock.listBots(),
4305
+ flock.listBotLifecycles(),
4306
+ ]);
4307
+ const status = new Map(
4308
+ lifecycles.lifecycles.map((lifecycle) => [
4309
+ lifecycle.botId,
4310
+ lifecycle.status,
4311
+ ]),
4312
+ );
4313
+ return directory.bots
4314
+ .filter((bot) => status.get(bot.botId) !== "deleted")
4315
+ .map((bot) => ({
4316
+ botId: bot.botId,
4317
+ name: bot.currentProfile?.name ?? bot.initialName,
4318
+ registeredAt: bot.registeredAt,
4319
+ active: status.get(bot.botId) === "active",
4320
+ }));
4321
+ },
4322
+ });
4323
+ }
4324
+
4325
+ /** One of the User's Bots, or `BotNotFoundError`. */
4326
+ private async requireInboundEmailBot(botId: string): Promise<void> {
4327
+ if (!(await (await this.flockContribution()).hasBot(botId))) {
4328
+ throw new BotNotFoundError(botId);
4329
+ }
4330
+ }
4331
+
4332
+ async readInboundEmail(input: unknown): Promise<InboundEmailStateV1> {
4333
+ const request = decodeRpcEnvelopeV1(input, {
4334
+ userId: rpcIdentifier,
4335
+ botId: rpcBotId,
4336
+ });
4337
+ await this.assertUserIdentity(request.userId as string);
4338
+ await this.requireInboundEmailBot(request.botId as string);
4339
+ return this.inboundEmail().state(request.botId as string);
4340
+ }
4341
+
4342
+ /** Whether a Bot receives email. */
4343
+ async setBotEmailEnabled(input: unknown) {
4344
+ const request = decodeRpcEnvelopeV1(input, {
4345
+ userId: rpcIdentifier,
4346
+ botId: rpcBotId,
4347
+ enabled: rpcBoolean,
4348
+ });
4349
+ await this.assertUserIdentity(request.userId as string);
4350
+ const botId = request.botId as string;
4351
+ await this.requireInboundEmailBot(botId);
4352
+ try {
4353
+ await this.inboundEmail().setEnabled(botId, request.enabled as boolean);
4354
+ } catch (error) {
4355
+ if (error instanceof Error && error.name === "InboundEmailCommandError") {
4356
+ return {
4357
+ schemaVersion: 1 as const,
4358
+ status: "rejected" as const,
4359
+ reason: error.message,
4360
+ };
4361
+ }
4362
+ throw error;
4363
+ }
4364
+ return { schemaVersion: 1 as const, status: "applied" as const };
4365
+ }
4366
+
4367
+ /**
4368
+ * What one of the User's Bots sends email as, for that Bot's kernel: its
4369
+ * address, which needs the account's username and the Bot's switch, and the
4370
+ * owner's own addresses it may write to without a draft card.
4371
+ */
4372
+ async readBotEmailSender(input: unknown): Promise<BotEmailSenderV1> {
4373
+ const request = decodeRpcEnvelopeV1(input, {
4374
+ userId: rpcIdentifier,
4375
+ botId: rpcBotId,
4376
+ });
4377
+ const userId = request.userId as string;
4378
+ await this.assertUserIdentity(userId);
4379
+ const domain = emailDomainV1(this.env);
4380
+ if (!domain) {
4381
+ return this.inboundEmail().sender(request.botId as string, {});
4382
+ }
4383
+ const [username, signInEmail] = await Promise.all([
4384
+ this.emailUsername(userId),
4385
+ this.emailSignIn(userId),
4386
+ ]);
4387
+ return this.inboundEmail().sender(request.botId as string, {
4388
+ domain,
4389
+ ...(username ? { username } : {}),
4390
+ ...(signInEmail ? { signInEmail } : {}),
4391
+ });
4392
+ }
4393
+
4394
+ /** The account's email username, which the deployment's directory holds. */
4395
+ private async emailUsername(userId: string): Promise<string | undefined> {
4396
+ const policy = this.env.DEPLOYMENT_POLICY;
4397
+ if (!policy) return undefined;
4398
+ // SAFETY: the binding names DeploymentPolicy; this is its username read.
4399
+ const directory = policy.get(
4400
+ policy.idFromName(DEPLOYMENT_POLICY_SINGLETON_NAME),
4401
+ ) as unknown as { readEmailUsername(input: unknown): Promise<unknown> };
4402
+ const { username } = (await directory.readEmailUsername({
4403
+ schemaVersion: 1,
4404
+ userId,
4405
+ })) as { username?: unknown };
4406
+ return typeof username === "string" ? username : undefined;
4407
+ }
4408
+
4409
+ /**
4410
+ * The address the identity provider verified, read from the stored identity
4411
+ * and never from a session: the one owner address that needs no code.
4412
+ */
4413
+ private async emailSignIn(userId: string): Promise<string | undefined> {
4414
+ const { AUTH_PACKAGE_V1 } = await import("#auth-package");
4415
+ const identity = await AUTH_PACKAGE_V1.create(this.env, {
4416
+ productName: BRAND_V1.productName,
4417
+ }).storedIdentity?.(userId);
4418
+ return identity?.emailVerified
4419
+ ? normalizeSenderAddressV1(identity.email)
4420
+ : undefined;
4421
+ }
4422
+
4423
+ /** Add an address that may email the User's Bots, or take one away. */
4424
+ async commandInboundEmailSender(input: unknown) {
4425
+ const request = decodeRpcEnvelopeV1(
4426
+ input,
4427
+ {
4428
+ userId: rpcIdentifier,
4429
+ action: rpcEnum(["add", "remove"] as const),
4430
+ address: rpcDecoded(requiredSenderAddress),
4431
+ },
4432
+ { signInEmail: rpcDecoded(requiredSenderAddress) },
4433
+ );
4434
+ await this.assertUserIdentity(request.userId as string);
4435
+ const store = this.inboundEmail();
4436
+ const address = request.address as string;
4437
+ try {
4438
+ if (request.action === "remove") {
4439
+ await store.removeSender(address);
4440
+ } else {
4441
+ await store.addSender(address, {
4442
+ ...(request.signInEmail
4443
+ ? { signInEmail: request.signInEmail as string }
4444
+ : {}),
4445
+ now: Date.now(),
4446
+ });
4447
+ }
4448
+ } catch (error) {
4449
+ if (error instanceof Error && error.name === "InboundEmailCommandError") {
4450
+ return {
4451
+ schemaVersion: 1 as const,
4452
+ status: "rejected" as const,
4453
+ reason: error.message,
4454
+ };
4455
+ }
4456
+ throw error;
4457
+ }
4458
+ return { schemaVersion: 1 as const, status: "applied" as const };
4459
+ }
4460
+
4461
+ /**
4462
+ * Whether one authenticated message may reach one of this User's Bots, and
4463
+ * which. The `email()` handler asks only after the receiving server's DMARC
4464
+ * verdict passed for `sender`, and after the directory said the username
4465
+ * the message names is this User's.
4466
+ */
4467
+ async routeInboundEmail(
4468
+ input: unknown,
4469
+ ): Promise<InboundEmailRouteDecisionV1> {
4470
+ const request = decodeRpcEnvelopeV1(
4471
+ input,
4472
+ {
4473
+ userId: rpcIdentifier,
4474
+ slug: rpcPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 32),
4475
+ sender: rpcDecoded(requiredSenderAddress),
4476
+ codes: rpcArray(rpcPattern(/^[0-9A-HJKMNP-TV-Z]{8}$/, 8), 3),
4477
+ },
4478
+ { signInEmail: rpcDecoded(requiredSenderAddress) },
4479
+ );
4480
+ await this.assertUserIdentity(request.userId as string);
4481
+ return this.inboundEmail().route({
4482
+ slug: request.slug as string,
4483
+ sender: request.sender as string,
4484
+ ...(request.signInEmail
4485
+ ? { signInEmail: request.signInEmail as string }
4486
+ : {}),
4487
+ codes: request.codes as string[],
4488
+ now: Date.now(),
4489
+ });
4490
+ }
4491
+ }