@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.
- package/build-artifact.ts +130 -0
- package/build-flutter-web.ts +508 -0
- package/deployment-config/README.md +414 -0
- package/deployment-config/cli.ts +228 -0
- package/deployment-config/generate.ts +689 -0
- package/deployment-config/jsonc.ts +18 -0
- package/deployment-config/profile-schema.generated.ts +391 -0
- package/deployment-config/profile.schema.json +358 -0
- package/deployment-config/profile.ts +124 -0
- package/migrations/0001_better_auth.sql +15 -0
- package/migrations/0002_drop_account_issuer.sql +8 -0
- package/package.json +61 -5
- package/release-version.ts +20 -0
- package/src/account-admission.ts +179 -0
- package/src/account-deletion.ts +274 -0
- package/src/admin-entrypoint.ts +194 -0
- package/src/admin-identities.ts +26 -0
- package/src/audit.ts +103 -0
- package/src/auth-package.access.ts +24 -0
- package/src/auth-package.ts +36 -0
- package/src/avatar-state-cleanup.ts +107 -0
- package/src/billing-computer.ts +267 -0
- package/src/billing-readiness.ts +21 -0
- package/src/billing.ts +461 -0
- package/src/bot-capabilities.ts +480 -0
- package/src/bot-recovery.ts +1 -0
- package/src/bot-state-channel.ts +702 -0
- package/src/bot-state.ts +3995 -0
- package/src/bot-template-cleanup.ts +98 -0
- package/src/bot-title-cleanup.ts +104 -0
- package/src/brand-icon.png +0 -0
- package/src/brand-logo.ts +2 -0
- package/src/brand.ts +36 -0
- package/src/client-compatibility.ts +51 -0
- package/src/compaction-announcement-cleanup.ts +113 -0
- package/src/computer-egress.ts +110 -0
- package/src/computer-host.ts +92 -0
- package/src/computer-screenshot-cleanup.ts +78 -0
- package/src/contracts.ts +832 -0
- package/src/debug.ts +272 -0
- package/src/default-packages-marker-cleanup.ts +39 -0
- package/src/deployment-policy-admin-host.ts +121 -0
- package/src/deployment-policy.ts +426 -0
- package/src/directory-profile-cleanup.ts +101 -0
- package/src/durable-rpc.ts +753 -0
- package/src/durable-session.ts +21 -0
- package/src/entry-boundary.ts +103 -0
- package/src/frock-ai.ts +338 -0
- package/src/gateway.ts +1392 -0
- package/src/group-chat.ts +736 -0
- package/src/hidden-bot-notifications-cleanup.ts +32 -0
- package/src/index.ts +2798 -0
- package/src/insights.ts +15 -0
- package/src/machine-messages-cleanup.ts +121 -0
- package/src/machine-socket.ts +164 -0
- package/src/memory-records.ts +181 -0
- package/src/memory.ts +145 -0
- package/src/model-rates.ts +252 -0
- package/src/native-auth.ts +1006 -0
- package/src/native-sessions.ts +121 -0
- package/src/notification-state-cleanup.ts +18 -0
- package/src/ollama-web-search-cleanup.ts +69 -0
- package/src/package-page-shapes-cleanup.ts +190 -0
- package/src/plugin-egress.ts +31 -0
- package/src/plugin-page-route.ts +72 -0
- package/src/plugin-panels-cleanup.ts +101 -0
- package/src/prepared-input-cleanup.ts +105 -0
- package/src/production-secrets.ts +484 -0
- package/src/project-cleanup.ts +123 -0
- package/src/project-events-cleanup.ts +152 -0
- package/src/publication-state-cleanup.ts +92 -0
- package/src/push.ts +498 -0
- package/src/request-body.ts +165 -0
- package/src/routine-state-cleanup.ts +57 -0
- package/src/search.ts +88 -0
- package/src/sidebar-label-cleanup.ts +165 -0
- package/src/skill-index-cleanup.ts +53 -0
- package/src/supersede-cleanup.ts +162 -0
- package/src/test-chat-cleanup.ts +93 -0
- package/src/uploads.ts +486 -0
- package/src/user-application.ts +1068 -0
- package/src/user-configuration.ts +4491 -0
- package/src/voice-assistant.ts +4999 -0
- package/src/voice-dictation.ts +743 -0
- package/src/working-context-cleanup.ts +96 -0
- package/src/workspace.ts +201 -0
- package/wrangler.jsonc +385 -0
- 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
|
+
}
|