@frockbot/plugin-settings 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/frockbot.json +55 -0
  2. package/package.json +43 -6
  3. package/src/backend.test.ts +397 -0
  4. package/src/backend.ts +262 -0
  5. package/src/client/BotPanel.vue +57 -0
  6. package/src/client/BotSettingsSurface.vue +1076 -0
  7. package/src/client/BotSettingsTrigger.vue +26 -0
  8. package/src/client/ConnectionsSurface.vue +813 -0
  9. package/src/client/ModelsSurface.vue +419 -0
  10. package/src/client/PackageAccounts.vue +330 -0
  11. package/src/client/PackageCatalogSurface.vue +584 -0
  12. package/src/client/PackageSettingsForm.vue +150 -0
  13. package/src/client/PackageSettingsSection.vue +66 -0
  14. package/src/client/PluginsSurface.vue +412 -0
  15. package/src/client/PluginsTrigger.vue +62 -0
  16. package/src/client/UserProfileTrigger.vue +223 -0
  17. package/src/client/UserSettingsSurface.vue +242 -0
  18. package/src/client/assignment-operations.ts +22 -0
  19. package/src/client/bot-settings.test.ts +218 -0
  20. package/src/client/bot-settings.ts +150 -0
  21. package/src/client/index.test.ts +113 -0
  22. package/src/client/index.ts +78 -0
  23. package/src/client/package-settings.test.ts +63 -0
  24. package/src/client/package-settings.ts +77 -0
  25. package/src/client/package-surfaces.test.ts +147 -0
  26. package/src/client/package-surfaces.ts +93 -0
  27. package/src/client/user-display-name.test.ts +44 -0
  28. package/src/client/user-display-name.ts +16 -0
  29. package/src/env.d.ts +6 -0
  30. package/src/index.ts +1 -0
  31. package/src/manifest.ts +3 -0
  32. package/src/user.test.ts +1277 -0
  33. package/src/user.ts +1221 -0
  34. package/tsconfig.json +15 -0
  35. package/vite.config.ts +30 -0
  36. package/README.md +0 -3
package/src/user.ts ADDED
@@ -0,0 +1,1221 @@
1
+ import {
2
+ configurationCommandFingerprintV1,
3
+ ConfigurationConflictError,
4
+ ConfigurationDecodeError,
5
+ decodePackageSettingsPatchV1,
6
+ MAX_PACKAGE_SETTINGS_V1,
7
+ decodeConnectionDependencyRequirementV1,
8
+ decodeOperationReceiptV1,
9
+ decodeUserConfigurationExecuteRpcV1,
10
+ decodeUserConfigurationReadRpcV1,
11
+ decodeUserSettingsViewV1,
12
+ MAX_USER_CONNECTIONS_V1,
13
+ USER_PROFILE_PLACEHOLDER_NAME_V1,
14
+ type ConnectionDependencyRequirementV1,
15
+ type ConnectionView,
16
+ type JsonValue,
17
+ type OperationReceiptV1,
18
+ type PackageInstallationView,
19
+ type UserConfigurationCommandV1,
20
+ type UserSettingsViewV1,
21
+ } from "@frockbot/configuration-core";
22
+ import {
23
+ decodeCatalogContentHashV1,
24
+ decodeCatalogGenerationIdV1,
25
+ type CatalogEntryV1,
26
+ type CatalogIndexV1,
27
+ type CatalogPinV1,
28
+ } from "@frockbot/catalog-core";
29
+ import type { ConnectionCommandV1 } from "@frockbot/connection-core";
30
+ import type { PackageSettingDefinition } from "@frockbot/kernel-composition";
31
+ import type { Plugin } from "cordis";
32
+
33
+ const STATE_KEY = "user-configuration";
34
+ const DEFAULT_PACKAGES_BOOTSTRAP_KEY = "user-default-packages-bootstrap:v1";
35
+ /**
36
+ * The pinned Catalog generation lives beside the settings view rather than in
37
+ * it, so pinning on a read never bumps the settings revision a client is
38
+ * holding an `expectedRevision` against. It is projected into the view when
39
+ * the view is read.
40
+ */
41
+ const CATALOG_PIN_KEY = "user-catalog-pin";
42
+ const IDENTITY_KEY = "user-id";
43
+ const RECEIPT_PREFIX = "configuration-receipt:";
44
+ const MAX_CONNECTION_DEPENDENCIES = 256;
45
+
46
+ type ConnectionDependency = {
47
+ botId: string;
48
+ generation: string;
49
+ packageId: string;
50
+ capabilityId: string;
51
+ claimOrder: number;
52
+ status: "pending" | "acknowledged";
53
+ };
54
+
55
+ interface StoredConfigurationReceipt {
56
+ commandFingerprint: string;
57
+ receipt: OperationReceiptV1;
58
+ }
59
+
60
+ export interface UserSettingsTransaction {
61
+ get<T>(key: string): Promise<T | undefined>;
62
+ put<T>(key: string, value: T): Promise<void>;
63
+ put(entries: Record<string, unknown>): Promise<void>;
64
+ }
65
+
66
+ export interface UserSettingsStorage extends UserSettingsTransaction {
67
+ transaction<T>(
68
+ callback: (storage: UserSettingsTransaction) => Promise<T>,
69
+ ): Promise<T>;
70
+ }
71
+
72
+ /**
73
+ * A provider Package registers one Connection command owner per Package so the
74
+ * Settings Contribution can adjudicate Connection command authority without
75
+ * naming any provider.
76
+ */
77
+ export interface ConnectionCommandOwner {
78
+ readonly packageId: string;
79
+ lookupConnectionCommand(
80
+ accountId: string,
81
+ commandId: string,
82
+ ): Promise<unknown>;
83
+ }
84
+
85
+ /**
86
+ * A Package-owned, idempotent bootstrap that runs before the User settings
87
+ * projection is returned. The Package keeps its own marker and durable state;
88
+ * Settings supplies only the read lifecycle that makes first use deterministic.
89
+ */
90
+ export interface UserConfigurationReadBootstrap {
91
+ readonly packageId: string;
92
+ bootstrap(userId: string): Promise<void>;
93
+ }
94
+
95
+ /**
96
+ * The remote Package Catalog, as the User Durable Object sees it. A host that
97
+ * omits it keeps the compiled-in behaviour exactly: `availablePackages` is
98
+ * still the only source of installable Packages.
99
+ *
100
+ * Neither method reaches R2 or the network from this Contribution — the
101
+ * adapter that owns the bucket implements them, so this Package names no
102
+ * Cloudflare type and stays testable with a plain object.
103
+ */
104
+ export interface UserPackageCatalogHost {
105
+ /**
106
+ * The generation the Catalog currently points at, with the content hash of
107
+ * its index bytes. `undefined` when the deployment has no Catalog yet, which
108
+ * leaves the User unpinned rather than failing a read.
109
+ */
110
+ readCurrentIndex(): Promise<
111
+ { pin: CatalogPinV1; index: CatalogIndexV1 } | undefined
112
+ >;
113
+ /**
114
+ * One entry from an exact, immutable generation. `undefined` when that
115
+ * generation does not contain the entry.
116
+ */
117
+ readEntry(
118
+ generation: string,
119
+ catalogId: string,
120
+ ): Promise<CatalogEntryV1 | undefined>;
121
+ }
122
+
123
+ /**
124
+ * One Package this application can execute, as the User Durable Object needs
125
+ * to see it: its identity, and the settings its manifest declares.
126
+ *
127
+ * The definitions travel with the version, not with the Package id: a Package
128
+ * that narrows a setting in a later version must validate a write against the
129
+ * version this User actually has installed.
130
+ */
131
+ export interface AvailableUserPackage {
132
+ packageId: string;
133
+ version: string;
134
+ /**
135
+ * True when the immutable application manifest declares a Connection Type
136
+ * or Capability for this Package. These are the Packages a new User owns
137
+ * from their first configuration read.
138
+ */
139
+ installByDefault?: boolean;
140
+ /**
141
+ * `configuration.settings` from this version's manifest. Absent is the same
142
+ * as empty and means the Package offers no User-level setting, so every
143
+ * `user/set-package-settings` naming it is refused.
144
+ */
145
+ settings?: readonly PackageSettingDefinition[];
146
+ }
147
+
148
+ export interface UserSettingsBackendHost {
149
+ storage: UserSettingsStorage;
150
+ availablePackages: readonly AvailableUserPackage[];
151
+ catalog?: UserPackageCatalogHost;
152
+ }
153
+
154
+ function initialState(): UserSettingsViewV1 {
155
+ return {
156
+ schemaVersion: 1,
157
+ revision: 0,
158
+ profile: { name: USER_PROFILE_PLACEHOLDER_NAME_V1 },
159
+ packages: [],
160
+ connections: [],
161
+ };
162
+ }
163
+
164
+ function decodeStoredConfigurationReceipt(
165
+ input: unknown,
166
+ ): StoredConfigurationReceipt {
167
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
168
+ throw new Error("Stored configuration receipt is invalid");
169
+ }
170
+ const value = input as Record<string, unknown>;
171
+ if (
172
+ Object.keys(value).some(
173
+ (key) => key !== "commandFingerprint" && key !== "receipt",
174
+ ) ||
175
+ typeof value.commandFingerprint !== "string"
176
+ ) {
177
+ throw new Error("Stored configuration receipt is invalid");
178
+ }
179
+ return {
180
+ commandFingerprint: value.commandFingerprint,
181
+ receipt: decodeOperationReceiptV1(value.receipt),
182
+ };
183
+ }
184
+
185
+ function requireMatchingConfigurationReceipt(
186
+ stored: StoredConfigurationReceipt,
187
+ commandFingerprint: string,
188
+ commandId: string,
189
+ ): OperationReceiptV1 {
190
+ if (stored.commandFingerprint !== commandFingerprint) {
191
+ throw new Error(
192
+ `Configuration command idempotency key "${commandId}" was reused for a different command`,
193
+ );
194
+ }
195
+ return stored.receipt;
196
+ }
197
+
198
+ function connectionDependencies(
199
+ connection: ConnectionView,
200
+ ): ConnectionDependency[] {
201
+ const value = connection.safeMetadata.dependentAssignments;
202
+ if (!Array.isArray(value)) return [];
203
+ return value.flatMap((candidate) => {
204
+ if (
205
+ !candidate ||
206
+ typeof candidate !== "object" ||
207
+ Array.isArray(candidate)
208
+ ) {
209
+ return [];
210
+ }
211
+ const dependency = candidate as Record<string, unknown>;
212
+ if (
213
+ typeof dependency.botId !== "string" ||
214
+ typeof dependency.generation !== "string" ||
215
+ typeof dependency.packageId !== "string" ||
216
+ typeof dependency.capabilityId !== "string" ||
217
+ (dependency.claimOrder !== undefined &&
218
+ (!Number.isSafeInteger(dependency.claimOrder) ||
219
+ (dependency.claimOrder as number) < 0)) ||
220
+ (dependency.status !== "pending" && dependency.status !== "acknowledged")
221
+ ) {
222
+ return [];
223
+ }
224
+ return [
225
+ {
226
+ botId: dependency.botId,
227
+ generation: dependency.generation,
228
+ packageId: dependency.packageId,
229
+ capabilityId: dependency.capabilityId,
230
+ claimOrder:
231
+ dependency.claimOrder === undefined
232
+ ? 0
233
+ : (dependency.claimOrder as number),
234
+ status: dependency.status,
235
+ } satisfies ConnectionDependency,
236
+ ];
237
+ });
238
+ }
239
+
240
+ function withConnectionDependencies(
241
+ connection: ConnectionView,
242
+ dependencies: ConnectionDependency[],
243
+ ): ConnectionView {
244
+ return {
245
+ ...connection,
246
+ safeMetadata: {
247
+ ...connection.safeMetadata,
248
+ dependentAssignments: dependencies,
249
+ },
250
+ };
251
+ }
252
+
253
+ /**
254
+ * An install may only name the generation this User is pinned to. Refusing
255
+ * anything else is what makes "Composition consumes immutable,
256
+ * content-addressed artifacts" true of an install: a client holding a stale
257
+ * index cannot install an entry that generation never contained.
258
+ */
259
+ function assertPinnedGeneration(
260
+ commandGeneration: string | undefined,
261
+ pinnedGeneration: string | undefined,
262
+ ): void {
263
+ if (!pinnedGeneration) {
264
+ throw new Error("Package Catalog generation is not pinned");
265
+ }
266
+ if (commandGeneration !== pinnedGeneration) {
267
+ throw new Error(
268
+ `Package Catalog generation "${commandGeneration}" is not the pinned generation "${pinnedGeneration}"`,
269
+ );
270
+ }
271
+ }
272
+
273
+ function withCatalogPin(
274
+ settings: UserSettingsViewV1,
275
+ pin: CatalogPinV1 | undefined,
276
+ ): UserSettingsViewV1 {
277
+ return pin
278
+ ? {
279
+ ...settings,
280
+ catalogGeneration: pin.generation,
281
+ catalogIndexHash: pin.indexHash,
282
+ }
283
+ : settings;
284
+ }
285
+
286
+ /**
287
+ * The setting values one installation carries after a partial update.
288
+ *
289
+ * `values` on the installation row *is* the store: the Catalog install path of
290
+ * ADR 0014 writes setup values there, and this writes the same field, so a
291
+ * Package has exactly one durable bag of configuration and the projection the
292
+ * client already reads needs no second source.
293
+ */
294
+ function mergePackageSettingValues(
295
+ current: Record<string, JsonValue> | undefined,
296
+ patch: Record<string, string | number | boolean>,
297
+ ): Record<string, JsonValue> {
298
+ const merged: Record<string, JsonValue> = { ...(current ?? {}) };
299
+ for (const [settingId, value] of Object.entries(patch)) {
300
+ merged[settingId] = value;
301
+ }
302
+ if (Object.keys(merged).length > MAX_PACKAGE_SETTINGS_V1) {
303
+ throw new ConfigurationDecodeError("Package settings are too many");
304
+ }
305
+ return merged;
306
+ }
307
+
308
+ function applyUserCommand(
309
+ current: UserSettingsViewV1,
310
+ command: UserConfigurationCommandV1,
311
+ settingDefinitions: (
312
+ packageId: string,
313
+ version: string,
314
+ ) => readonly PackageSettingDefinition[],
315
+ ): UserSettingsViewV1 {
316
+ const revision = current.revision + 1;
317
+ switch (command.type) {
318
+ case "user/update-profile":
319
+ return { ...current, revision, profile: command.profile };
320
+ case "user/set-new-bot-model":
321
+ return {
322
+ ...current,
323
+ revision,
324
+ newBotModelTemplate: command.model,
325
+ newBotModelTemplateSource: command.source,
326
+ };
327
+ case "user/install-package": {
328
+ const existing = current.packages.find(
329
+ (pkg) => pkg.packageId === command.packageId,
330
+ );
331
+ // One store, two writers: a Catalog install's setup values and
332
+ // `user/set-package-settings` write the same bag, and a reinstall — a
333
+ // version bump, say — carries the configuration forward rather than
334
+ // silently returning the Package to its defaults.
335
+ const values = {
336
+ ...(existing?.values ?? {}),
337
+ ...structuredClone(command.values ?? {}),
338
+ };
339
+ return {
340
+ ...current,
341
+ revision,
342
+ packages: [
343
+ ...current.packages.filter(
344
+ (pkg) => pkg.packageId !== command.packageId,
345
+ ),
346
+ {
347
+ packageId: command.packageId,
348
+ version: command.version,
349
+ state: existing?.state === "failed" ? "failed" : "installed",
350
+ failure: existing?.failure,
351
+ // A Catalog install records where it came from; the compiled-in
352
+ // path records nothing new, so an old row keeps its exact shape.
353
+ ...(command.catalogId === undefined
354
+ ? {}
355
+ : {
356
+ catalogId: command.catalogId,
357
+ catalogGeneration: command.catalogGeneration,
358
+ provenance: "catalog" as const,
359
+ }),
360
+ ...(Object.keys(values).length === 0 ? {} : { values }),
361
+ },
362
+ ],
363
+ };
364
+ }
365
+ case "user/uninstall-package": {
366
+ // Removing the row is the whole effect. Assignments that depend on it
367
+ // are not touched: `capabilityAssignmentFailureV1` resolves them as
368
+ // unavailable tombstones the User can repair (ADR 0003), and
369
+ // Connections are the User's own and outlive any Package.
370
+ if (
371
+ !current.packages.some((pkg) => pkg.packageId === command.packageId)
372
+ ) {
373
+ throw new Error(`Package "${command.packageId}" is not installed`);
374
+ }
375
+ return {
376
+ ...current,
377
+ revision,
378
+ packages: current.packages.filter(
379
+ (pkg) => pkg.packageId !== command.packageId,
380
+ ),
381
+ };
382
+ }
383
+ case "user/set-package-settings": {
384
+ const installed = current.packages.find(
385
+ (pkg) => pkg.packageId === command.packageId,
386
+ );
387
+ if (!installed) {
388
+ throw new ConfigurationDecodeError(
389
+ `Package "${command.packageId}" is not installed`,
390
+ );
391
+ }
392
+ // Validated against the manifest of the version this User has, not the
393
+ // one the client happened to be looking at.
394
+ const patch = decodePackageSettingsPatchV1(
395
+ settingDefinitions(installed.packageId, installed.version),
396
+ command.values,
397
+ );
398
+ return {
399
+ ...current,
400
+ revision,
401
+ packages: current.packages.map((pkg) =>
402
+ pkg.packageId === command.packageId
403
+ ? {
404
+ ...pkg,
405
+ values: mergePackageSettingValues(pkg.values, patch),
406
+ }
407
+ : pkg,
408
+ ),
409
+ };
410
+ }
411
+ case "user/set-package-enabled": {
412
+ const installed = current.packages.some(
413
+ (pkg) => pkg.packageId === command.packageId,
414
+ );
415
+ if (!installed) {
416
+ throw new Error(`Package "${command.packageId}" is not installed`);
417
+ }
418
+ return {
419
+ ...current,
420
+ revision,
421
+ packages: current.packages.map((pkg) =>
422
+ pkg.packageId === command.packageId
423
+ ? {
424
+ ...pkg,
425
+ state: command.enabled ? "installed" : "disabled",
426
+ failure: undefined,
427
+ }
428
+ : pkg,
429
+ ),
430
+ };
431
+ }
432
+ }
433
+ }
434
+
435
+ export class UserSettingsBackendContribution {
436
+ private readonly availablePackages: ReadonlySet<string>;
437
+
438
+ /** The immutable first-party installation rows written on first read. */
439
+ private readonly defaultPackages: readonly PackageInstallationView[];
440
+
441
+ /** Declared User-level settings, by Package id and version. */
442
+ private readonly packageSettingDefinitions: ReadonlyMap<
443
+ string,
444
+ readonly PackageSettingDefinition[]
445
+ >;
446
+
447
+ private readonly connectionOwners = new Map<string, ConnectionCommandOwner>();
448
+
449
+ private readonly readBootstraps = new Map<
450
+ string,
451
+ UserConfigurationReadBootstrap
452
+ >();
453
+
454
+ constructor(private readonly host: UserSettingsBackendHost) {
455
+ this.availablePackages = new Set(
456
+ host.availablePackages.map(
457
+ ({ packageId, version }) => `${packageId}\u0000${version}`,
458
+ ),
459
+ );
460
+ this.packageSettingDefinitions = new Map(
461
+ host.availablePackages.map((pkg) => [
462
+ `${pkg.packageId}\u0000${pkg.version}`,
463
+ pkg.settings ?? [],
464
+ ]),
465
+ );
466
+ this.defaultPackages = host.availablePackages.flatMap((pkg) =>
467
+ pkg.installByDefault
468
+ ? [
469
+ {
470
+ packageId: pkg.packageId,
471
+ version: pkg.version,
472
+ state: "installed" as const,
473
+ provenance: "first-party" as const,
474
+ },
475
+ ]
476
+ : [],
477
+ );
478
+ }
479
+
480
+ /**
481
+ * Persist the application's first-party Package availability exactly once.
482
+ *
483
+ * The marker, rows, and revision bump share one transaction. A later
484
+ * uninstall therefore leaves the marker behind and cannot be undone by a
485
+ * read, while concurrent first reads converge on the same durable state.
486
+ */
487
+ private async bootstrapDefaultPackages(
488
+ userId: string,
489
+ storage?: UserSettingsTransaction,
490
+ ): Promise<UserSettingsViewV1> {
491
+ if (this.defaultPackages.length === 0) {
492
+ await this.assertIdentity(userId, storage ?? this.host.storage);
493
+ return this.readSnapshot(storage ?? this.host.storage);
494
+ }
495
+ const bootstrap = async (transaction: UserSettingsTransaction) => {
496
+ await this.assertIdentity(userId, transaction);
497
+ const marker = await transaction.get<unknown>(
498
+ DEFAULT_PACKAGES_BOOTSTRAP_KEY,
499
+ );
500
+ if (marker !== undefined) {
501
+ if (
502
+ !marker ||
503
+ typeof marker !== "object" ||
504
+ Array.isArray(marker) ||
505
+ Object.keys(marker).length !== 1 ||
506
+ (marker as { schemaVersion?: unknown }).schemaVersion !== 1
507
+ ) {
508
+ throw new Error("Stored default Package bootstrap is invalid");
509
+ }
510
+ return this.readSnapshot(transaction);
511
+ }
512
+ const current = await this.readSnapshot(transaction);
513
+ const installedPackageIds = new Set(
514
+ current.packages.map((pkg) => pkg.packageId),
515
+ );
516
+ const additions = this.defaultPackages.filter(
517
+ (pkg) => !installedPackageIds.has(pkg.packageId),
518
+ );
519
+ const next = {
520
+ ...current,
521
+ revision: current.revision + 1,
522
+ packages: [
523
+ ...current.packages,
524
+ ...additions.map((pkg) => structuredClone(pkg)),
525
+ ],
526
+ } satisfies UserSettingsViewV1;
527
+ await transaction.put({
528
+ [STATE_KEY]: next,
529
+ [DEFAULT_PACKAGES_BOOTSTRAP_KEY]: { schemaVersion: 1 },
530
+ });
531
+ return structuredClone(next);
532
+ };
533
+ return storage
534
+ ? bootstrap(storage)
535
+ : this.host.storage.transaction(bootstrap);
536
+ }
537
+
538
+ /**
539
+ * The settings one installed version declares. A version this application
540
+ * cannot execute declares none, so a write against it is refused rather than
541
+ * stored against a manifest nobody here has.
542
+ */
543
+ private settingDefinitions(
544
+ packageId: string,
545
+ version: string,
546
+ ): readonly PackageSettingDefinition[] {
547
+ return (
548
+ this.packageSettingDefinitions.get(`${packageId}\u0000${version}`) ?? []
549
+ );
550
+ }
551
+
552
+ async readConfiguration(input: unknown): Promise<UserSettingsViewV1> {
553
+ const request = decodeUserConfigurationReadRpcV1(input);
554
+ for (const bootstrap of this.readBootstraps.values()) {
555
+ await bootstrap.bootstrap(request.userId);
556
+ }
557
+ // The first read that finds a Catalog pins its generation, so every later
558
+ // install is validated against one immutable, content-addressed set of
559
+ // artifacts rather than whatever the pointer happens to name that second.
560
+ const pin = await this.pinCatalogGeneration(request.userId);
561
+ return withCatalogPin(await this.read(request.userId), pin);
562
+ }
563
+
564
+ /**
565
+ * The Catalog generation this User is pinned to, pinning it on first sight.
566
+ * `undefined` when the deployment has no Catalog, which is not a failure:
567
+ * compiled-in Packages install through the unchanged path either way.
568
+ */
569
+ async pinCatalogGeneration(
570
+ userId: string,
571
+ ): Promise<CatalogPinV1 | undefined> {
572
+ const catalog = this.host.catalog;
573
+ if (!catalog) return undefined;
574
+ const stored = await this.readCatalogPin(this.host.storage);
575
+ if (stored) return stored;
576
+ const current = await catalog.readCurrentIndex();
577
+ if (!current) return undefined;
578
+ return this.host.storage.transaction(async (storage) => {
579
+ await this.assertIdentity(userId, storage);
580
+ const existing = await this.readCatalogPin(storage);
581
+ if (existing) return existing;
582
+ const pin: CatalogPinV1 = {
583
+ generation: decodeCatalogGenerationIdV1(current.pin.generation),
584
+ indexHash: decodeCatalogContentHashV1(current.pin.indexHash),
585
+ };
586
+ await storage.put(CATALOG_PIN_KEY, pin);
587
+ return pin;
588
+ });
589
+ }
590
+
591
+ private async readCatalogPin(
592
+ storage: UserSettingsTransaction,
593
+ ): Promise<CatalogPinV1 | undefined> {
594
+ const stored = await storage.get<unknown>(CATALOG_PIN_KEY);
595
+ if (stored === undefined) return undefined;
596
+ if (!stored || typeof stored !== "object" || Array.isArray(stored)) {
597
+ throw new Error("Stored Catalog pin is invalid");
598
+ }
599
+ const value = stored as Record<string, unknown>;
600
+ if (
601
+ Object.keys(value).some(
602
+ (key) => key !== "generation" && key !== "indexHash",
603
+ )
604
+ ) {
605
+ throw new Error("Stored Catalog pin is invalid");
606
+ }
607
+ return {
608
+ generation: decodeCatalogGenerationIdV1(value.generation),
609
+ indexHash: decodeCatalogContentHashV1(value.indexHash),
610
+ };
611
+ }
612
+
613
+ /**
614
+ * Resolve a Catalog install against the pinned generation, before the
615
+ * durable transaction opens: reading an entry is object-storage I/O, and a
616
+ * Durable Object transaction is not the place for it. The pinned generation
617
+ * is checked again inside the transaction, so a pin that moved between the
618
+ * two loses the race rather than admitting a stale install.
619
+ */
620
+ private async resolveCatalogInstall(command: {
621
+ packageId: string;
622
+ version: string;
623
+ catalogId: string;
624
+ catalogGeneration: string;
625
+ }): Promise<CatalogEntryV1> {
626
+ const catalog = this.host.catalog;
627
+ if (!catalog) {
628
+ throw new Error("Package Catalog is not available");
629
+ }
630
+ const pin = await this.readCatalogPin(this.host.storage);
631
+ if (!pin) {
632
+ throw new Error("Package Catalog generation is not pinned");
633
+ }
634
+ assertPinnedGeneration(command.catalogGeneration, pin.generation);
635
+ const entry = await catalog.readEntry(pin.generation, command.catalogId);
636
+ if (!entry) {
637
+ throw new Error(
638
+ `Catalog entry "${command.catalogId}" is not in pinned Catalog generation "${pin.generation}"`,
639
+ );
640
+ }
641
+ if (
642
+ entry.packageId !== command.packageId ||
643
+ entry.version !== command.version
644
+ ) {
645
+ throw new Error(
646
+ `Catalog entry "${command.catalogId}" does not offer Package "${command.packageId}" at version "${command.version}"`,
647
+ );
648
+ }
649
+ return entry;
650
+ }
651
+
652
+ async executeConfiguration(input: unknown): Promise<OperationReceiptV1> {
653
+ const request = decodeUserConfigurationExecuteRpcV1(input);
654
+ const { command } = request;
655
+ const commandFingerprint = configurationCommandFingerprintV1(command);
656
+ await this.assertIdentity(request.userId);
657
+ const catalogInstall =
658
+ command.type === "user/install-package" &&
659
+ command.catalogId !== undefined &&
660
+ command.catalogGeneration !== undefined
661
+ ? await this.resolveCatalogInstall({
662
+ packageId: command.packageId,
663
+ version: command.version,
664
+ catalogId: command.catalogId,
665
+ catalogGeneration: command.catalogGeneration,
666
+ })
667
+ : undefined;
668
+ return this.host.storage.transaction((storage) =>
669
+ this.applyConfigurationCommand(
670
+ request.userId,
671
+ command,
672
+ storage,
673
+ commandFingerprint,
674
+ catalogInstall,
675
+ ),
676
+ );
677
+ }
678
+
679
+ /**
680
+ * Apply one already-decoded built-in User command inside a caller-owned
681
+ * transaction. Provider bootstraps use this so their Connection, marker and
682
+ * default-model change commit atomically through the normal reducer and
683
+ * receipt path.
684
+ */
685
+ async executeConfigurationCommand(
686
+ userId: string,
687
+ command: UserConfigurationCommandV1,
688
+ storage: UserSettingsTransaction,
689
+ ): Promise<OperationReceiptV1> {
690
+ if (
691
+ command.type === "user/install-package" &&
692
+ command.catalogId !== undefined
693
+ ) {
694
+ throw new Error(
695
+ "Catalog installs must be resolved through executeConfiguration",
696
+ );
697
+ }
698
+ return this.applyConfigurationCommand(
699
+ userId,
700
+ command,
701
+ storage,
702
+ configurationCommandFingerprintV1(command),
703
+ );
704
+ }
705
+
706
+ private async applyConfigurationCommand(
707
+ userId: string,
708
+ command: UserConfigurationCommandV1,
709
+ storage: UserSettingsTransaction,
710
+ commandFingerprint: string,
711
+ catalogInstall?: CatalogEntryV1,
712
+ ): Promise<OperationReceiptV1> {
713
+ await this.assertIdentity(userId, storage);
714
+ const receiptKey = `${RECEIPT_PREFIX}${command.commandId}`;
715
+ const storedReceipt = await storage.get<unknown>(receiptKey);
716
+ if (storedReceipt !== undefined) {
717
+ return requireMatchingConfigurationReceipt(
718
+ decodeStoredConfigurationReceipt(storedReceipt),
719
+ commandFingerprint,
720
+ command.commandId,
721
+ );
722
+ }
723
+ if (command.type === "user/install-package") {
724
+ if (catalogInstall) {
725
+ assertPinnedGeneration(
726
+ command.catalogGeneration,
727
+ (await this.readCatalogPin(storage))?.generation,
728
+ );
729
+ } else if (
730
+ !this.availablePackages.has(
731
+ `${command.packageId}\u0000${command.version}`,
732
+ )
733
+ ) {
734
+ throw new Error("Package is not available in this application");
735
+ }
736
+ }
737
+ const storedSettings = await storage.get<unknown>(STATE_KEY);
738
+ const current =
739
+ storedSettings === undefined
740
+ ? initialState()
741
+ : decodeUserSettingsViewV1(storedSettings);
742
+ if (command.type === "user/set-package-enabled" && command.enabled) {
743
+ const installed = current.packages.find(
744
+ (pkg) => pkg.packageId === command.packageId,
745
+ );
746
+ if (
747
+ installed &&
748
+ !this.availablePackages.has(
749
+ `${installed.packageId}\u0000${installed.version}`,
750
+ )
751
+ ) {
752
+ throw new Error("Package is not available in this application");
753
+ }
754
+ }
755
+ if (command.expectedRevision !== current.revision) {
756
+ throw new ConfigurationConflictError(current.revision);
757
+ }
758
+ const next = applyUserCommand(current, command, (packageId, version) =>
759
+ this.settingDefinitions(packageId, version),
760
+ );
761
+ const receipt: OperationReceiptV1 = {
762
+ schemaVersion: 1,
763
+ commandId: command.commandId,
764
+ revision: next.revision,
765
+ status: "applied",
766
+ };
767
+ await storage.put({
768
+ [STATE_KEY]: next,
769
+ [receiptKey]: { commandFingerprint, receipt },
770
+ });
771
+ return receipt;
772
+ }
773
+
774
+ async readSnapshot(
775
+ storage: UserSettingsTransaction = this.host.storage,
776
+ ): Promise<UserSettingsViewV1> {
777
+ const stored = await storage.get<unknown>(STATE_KEY);
778
+ return stored === undefined
779
+ ? initialState()
780
+ : decodeUserSettingsViewV1(stored);
781
+ }
782
+
783
+ async read(
784
+ userId: string,
785
+ storage?: UserSettingsTransaction,
786
+ ): Promise<UserSettingsViewV1> {
787
+ return this.bootstrapDefaultPackages(userId, storage);
788
+ }
789
+
790
+ async createConnection(
791
+ userId: string,
792
+ connection: ConnectionView,
793
+ storage?: UserSettingsTransaction,
794
+ ): Promise<ConnectionView> {
795
+ const create = async (transaction: UserSettingsTransaction) => {
796
+ await this.assertIdentity(userId, transaction);
797
+ const current = await this.readSnapshot(transaction);
798
+ const existing = current.connections.find(
799
+ (candidate) => candidate.connectionId === connection.connectionId,
800
+ );
801
+ if (existing) return existing;
802
+ const retained = current.connections.filter(
803
+ (candidate) => candidate.state !== "revoked",
804
+ );
805
+ if (retained.length >= MAX_USER_CONNECTIONS_V1) {
806
+ throw new Error("User Connection limit reached");
807
+ }
808
+ const next = {
809
+ ...current,
810
+ revision: current.revision + 1,
811
+ connections: [...retained, structuredClone(connection)],
812
+ } satisfies UserSettingsViewV1;
813
+ await transaction.put(STATE_KEY, next);
814
+ return structuredClone(connection);
815
+ };
816
+ if (storage) return create(storage);
817
+ return this.host.storage.transaction(create);
818
+ }
819
+
820
+ async replaceConnection(
821
+ userId: string,
822
+ connectionId: string,
823
+ expectedGeneration: string | undefined,
824
+ nextConnection: ConnectionView,
825
+ storage?: UserSettingsTransaction,
826
+ ): Promise<ConnectionView> {
827
+ const replace = async (transaction: UserSettingsTransaction) => {
828
+ await this.assertIdentity(userId, transaction);
829
+ const current = await this.readSnapshot(transaction);
830
+ const existing = current.connections.find(
831
+ (candidate) => candidate.connectionId === connectionId,
832
+ );
833
+ if (!existing) throw new Error("Connection is unavailable");
834
+ if (existing.generation !== expectedGeneration) {
835
+ throw new Error("Connection generation changed");
836
+ }
837
+ if (nextConnection.connectionId !== connectionId) {
838
+ throw new Error("Connection identity cannot change");
839
+ }
840
+ const next = {
841
+ ...current,
842
+ revision: current.revision + 1,
843
+ connections: current.connections.map((candidate) =>
844
+ candidate.connectionId === connectionId
845
+ ? structuredClone(nextConnection)
846
+ : candidate,
847
+ ),
848
+ } satisfies UserSettingsViewV1;
849
+ await transaction.put(STATE_KEY, next);
850
+ return structuredClone(nextConnection);
851
+ };
852
+ if (storage) return replace(storage);
853
+ return this.host.storage.transaction(replace);
854
+ }
855
+
856
+ async getConnection(
857
+ userId: string,
858
+ connectionId: string,
859
+ storage?: UserSettingsTransaction,
860
+ ): Promise<ConnectionView | undefined> {
861
+ const settings = await this.read(userId, storage);
862
+ const connection = settings.connections.find(
863
+ (candidate) => candidate.connectionId === connectionId,
864
+ );
865
+ return connection ? structuredClone(connection) : undefined;
866
+ }
867
+
868
+ registerConfigurationReadBootstrap(
869
+ bootstrap: UserConfigurationReadBootstrap,
870
+ ): () => void {
871
+ if (this.readBootstraps.has(bootstrap.packageId)) {
872
+ throw new Error(
873
+ `Package "${bootstrap.packageId}" already registered a User bootstrap`,
874
+ );
875
+ }
876
+ this.readBootstraps.set(bootstrap.packageId, bootstrap);
877
+ return () => {
878
+ if (this.readBootstraps.get(bootstrap.packageId) === bootstrap) {
879
+ this.readBootstraps.delete(bootstrap.packageId);
880
+ }
881
+ };
882
+ }
883
+
884
+ registerConnectionCommandOwner(owner: ConnectionCommandOwner): () => void {
885
+ if (this.connectionOwners.has(owner.packageId)) {
886
+ throw new Error(
887
+ `Connection Package "${owner.packageId}" is already registered`,
888
+ );
889
+ }
890
+ this.connectionOwners.set(owner.packageId, owner);
891
+ return () => {
892
+ if (this.connectionOwners.get(owner.packageId) === owner) {
893
+ this.connectionOwners.delete(owner.packageId);
894
+ }
895
+ };
896
+ }
897
+
898
+ /**
899
+ * Resolve the Package that owns a Connection command from the durable
900
+ * Connection projection, falling back to the unique registered owner that
901
+ * still retains the command receipt after its projection was compacted.
902
+ */
903
+ async resolveConnectionCommandOwner(
904
+ userId: string,
905
+ command: ConnectionCommandV1,
906
+ ): Promise<string> {
907
+ const projected =
908
+ command.type === "connection/create-api-key" ||
909
+ command.type === "connection/create"
910
+ ? command.packageId
911
+ : (await this.getConnection(userId, command.connectionId))?.packageId;
912
+ if (projected) return projected;
913
+ const retained: string[] = [];
914
+ for (const owner of this.connectionOwners.values()) {
915
+ if (
916
+ (await owner.lookupConnectionCommand(userId, command.commandId)) !==
917
+ undefined
918
+ ) {
919
+ retained.push(owner.packageId);
920
+ }
921
+ }
922
+ if (retained.length > 1) {
923
+ throw new Error("Connection command authority is ambiguous");
924
+ }
925
+ const [ownerPackageId] = retained;
926
+ if (!ownerPackageId) throw new Error("Connection is unavailable");
927
+ return ownerPackageId;
928
+ }
929
+
930
+ async isPackageInstalled(
931
+ userId: string,
932
+ packageId: string,
933
+ ): Promise<boolean> {
934
+ const settings = await this.read(userId);
935
+ return settings.packages.some(
936
+ (pkg) => pkg.packageId === packageId && pkg.state === "installed",
937
+ );
938
+ }
939
+
940
+ /**
941
+ * The durable state of one Bot's dependency on one Connection. `absent` is
942
+ * the answer for a Connection this object does not hold, so a reconciling
943
+ * saga can distinguish "never claimed" from "claimed and pending".
944
+ */
945
+ async readConnectionDependency(
946
+ userId: string,
947
+ connectionId: string,
948
+ botId: string,
949
+ generation: string,
950
+ ): Promise<"absent" | "pending" | "acknowledged"> {
951
+ const connection = await this.getConnection(userId, connectionId);
952
+ if (!connection) return "absent";
953
+ const dependency = connectionDependencies(connection).find(
954
+ (candidate) =>
955
+ candidate.botId === botId && candidate.generation === generation,
956
+ );
957
+ return dependency?.status ?? "absent";
958
+ }
959
+
960
+ async claimConnectionDependency(
961
+ userId: string,
962
+ connectionId: string,
963
+ botId: string,
964
+ generation: string,
965
+ requirement: ConnectionDependencyRequirementV1,
966
+ storage?: UserSettingsTransaction,
967
+ ): Promise<boolean> {
968
+ const decoded = decodeConnectionDependencyRequirementV1(requirement);
969
+ return this.transitionConnectionDependency(
970
+ userId,
971
+ connectionId,
972
+ (current, settings) => {
973
+ const installation = settings.packages.find(
974
+ (pkg) =>
975
+ pkg.packageId === decoded.packageId &&
976
+ pkg.version === decoded.packageVersion &&
977
+ pkg.state === "installed",
978
+ );
979
+ if (
980
+ !installation ||
981
+ current.state !== "ready" ||
982
+ current.packageId !== decoded.packageId ||
983
+ !decoded.connectionTypeIds.includes(current.connectionTypeId)
984
+ ) {
985
+ return undefined;
986
+ }
987
+ const existing = connectionDependencies(current);
988
+ const replay = existing.find(
989
+ (dependency) =>
990
+ dependency.botId === botId && dependency.generation === generation,
991
+ );
992
+ if (replay) {
993
+ return replay.packageId === decoded.packageId &&
994
+ replay.capabilityId === decoded.capabilityId
995
+ ? current
996
+ : undefined;
997
+ }
998
+ if (existing.length >= MAX_CONNECTION_DEPENDENCIES) return undefined;
999
+ return withConnectionDependencies(current, [
1000
+ ...existing,
1001
+ {
1002
+ botId,
1003
+ generation,
1004
+ packageId: decoded.packageId,
1005
+ capabilityId: decoded.capabilityId,
1006
+ claimOrder: settings.revision + 1,
1007
+ status: "pending",
1008
+ },
1009
+ ]);
1010
+ },
1011
+ storage,
1012
+ );
1013
+ }
1014
+
1015
+ async acknowledgeConnectionDependency(
1016
+ userId: string,
1017
+ connectionId: string,
1018
+ botId: string,
1019
+ generation: string,
1020
+ ): Promise<boolean> {
1021
+ return this.host.storage.transaction(async (storage) => {
1022
+ await this.assertIdentity(userId, storage);
1023
+ const current = await this.readSnapshot(storage);
1024
+ const target = current.connections.find(
1025
+ (connection) => connection.connectionId === connectionId,
1026
+ );
1027
+ if (
1028
+ !target ||
1029
+ target.state === "revoking" ||
1030
+ target.state === "revoked"
1031
+ ) {
1032
+ return false;
1033
+ }
1034
+ const matched = connectionDependencies(target).find(
1035
+ (dependency) =>
1036
+ dependency.botId === botId && dependency.generation === generation,
1037
+ );
1038
+ if (!matched) return false;
1039
+ const latestClaimOrder = Math.max(
1040
+ ...current.connections.flatMap((connection) =>
1041
+ connectionDependencies(connection).flatMap((dependency) =>
1042
+ dependency.botId === botId &&
1043
+ dependency.packageId === matched.packageId &&
1044
+ dependency.capabilityId === matched.capabilityId
1045
+ ? [dependency.claimOrder]
1046
+ : [],
1047
+ ),
1048
+ ),
1049
+ );
1050
+ if (matched.claimOrder < latestClaimOrder) return false;
1051
+ if (
1052
+ matched.status === "acknowledged" &&
1053
+ !current.connections.some((connection) =>
1054
+ connectionDependencies(connection).some(
1055
+ (dependency) =>
1056
+ dependency.botId === botId &&
1057
+ dependency.packageId === matched.packageId &&
1058
+ dependency.capabilityId === matched.capabilityId &&
1059
+ (connection.connectionId !== connectionId ||
1060
+ dependency.generation !== generation),
1061
+ ),
1062
+ )
1063
+ ) {
1064
+ return true;
1065
+ }
1066
+ const connections = current.connections.map((connection) => {
1067
+ const dependencies = connectionDependencies(connection);
1068
+ const nextDependencies = dependencies.flatMap((dependency) => {
1069
+ const sameAuthority =
1070
+ dependency.botId === botId &&
1071
+ dependency.packageId === matched.packageId &&
1072
+ dependency.capabilityId === matched.capabilityId;
1073
+ if (!sameAuthority) return [dependency];
1074
+ if (
1075
+ connection.connectionId === connectionId &&
1076
+ dependency.generation === generation
1077
+ ) {
1078
+ return [{ ...dependency, status: "acknowledged" as const }];
1079
+ }
1080
+ return [];
1081
+ });
1082
+ return nextDependencies.length === dependencies.length &&
1083
+ nextDependencies.every(
1084
+ (dependency, index) => dependency === dependencies[index],
1085
+ )
1086
+ ? connection
1087
+ : withConnectionDependencies(connection, nextDependencies);
1088
+ });
1089
+ await storage.put(STATE_KEY, {
1090
+ ...current,
1091
+ revision: current.revision + 1,
1092
+ connections,
1093
+ } satisfies UserSettingsViewV1);
1094
+ return true;
1095
+ });
1096
+ }
1097
+
1098
+ async releaseConnectionDependency(
1099
+ userId: string,
1100
+ connectionId: string,
1101
+ botId: string,
1102
+ generation: string,
1103
+ ): Promise<boolean> {
1104
+ return this.host.storage.transaction(async (storage) => {
1105
+ await this.assertIdentity(userId, storage);
1106
+ const current = await this.readSnapshot(storage);
1107
+ const target = current.connections.find(
1108
+ (connection) => connection.connectionId === connectionId,
1109
+ );
1110
+ if (!target) return true;
1111
+ const existing = connectionDependencies(target);
1112
+ const matching = existing.filter(
1113
+ (dependency) =>
1114
+ dependency.botId === botId && dependency.generation === generation,
1115
+ );
1116
+ if (matching.length === 0) return true;
1117
+ if (matching.some((dependency) => dependency.status !== "acknowledged")) {
1118
+ return false;
1119
+ }
1120
+ const remaining = existing.filter(
1121
+ (dependency) =>
1122
+ dependency.botId !== botId || dependency.generation !== generation,
1123
+ );
1124
+ const connections = current.connections.map((connection) =>
1125
+ connection.connectionId === connectionId
1126
+ ? withConnectionDependencies(connection, remaining)
1127
+ : connection,
1128
+ );
1129
+ await storage.put(STATE_KEY, {
1130
+ ...current,
1131
+ revision: current.revision + 1,
1132
+ connections,
1133
+ } satisfies UserSettingsViewV1);
1134
+ return true;
1135
+ });
1136
+ }
1137
+
1138
+ async compensateConnectionDependency(
1139
+ userId: string,
1140
+ connectionId: string,
1141
+ botId: string,
1142
+ generation: string,
1143
+ ): Promise<boolean> {
1144
+ return this.transitionConnectionDependency(
1145
+ userId,
1146
+ connectionId,
1147
+ (current) => {
1148
+ const existing = connectionDependencies(current);
1149
+ const remaining = existing.filter(
1150
+ (dependency) =>
1151
+ dependency.botId !== botId ||
1152
+ dependency.generation !== generation ||
1153
+ dependency.status !== "pending",
1154
+ );
1155
+ return remaining.length === existing.length
1156
+ ? undefined
1157
+ : withConnectionDependencies(current, remaining);
1158
+ },
1159
+ );
1160
+ }
1161
+
1162
+ private async transitionConnectionDependency(
1163
+ userId: string,
1164
+ connectionId: string,
1165
+ transition: (
1166
+ connection: ConnectionView,
1167
+ settings: UserSettingsViewV1,
1168
+ ) => ConnectionView | undefined,
1169
+ transaction?: UserSettingsTransaction,
1170
+ ): Promise<boolean> {
1171
+ const apply = async (storage: UserSettingsTransaction) => {
1172
+ await this.assertIdentity(userId, storage);
1173
+ const current = await this.readSnapshot(storage);
1174
+ const connection = current.connections.find(
1175
+ (candidate) => candidate.connectionId === connectionId,
1176
+ );
1177
+ if (!connection) return false;
1178
+ const nextConnection = transition(connection, current);
1179
+ if (!nextConnection) return false;
1180
+ if (nextConnection === connection) return true;
1181
+ await storage.put(STATE_KEY, {
1182
+ ...current,
1183
+ revision: current.revision + 1,
1184
+ connections: current.connections.map((candidate) =>
1185
+ candidate.connectionId === connectionId ? nextConnection : candidate,
1186
+ ),
1187
+ } satisfies UserSettingsViewV1);
1188
+ return true;
1189
+ };
1190
+ return transaction
1191
+ ? apply(transaction)
1192
+ : this.host.storage.transaction(apply);
1193
+ }
1194
+
1195
+ private async assertIdentity(
1196
+ userId: string,
1197
+ storage: UserSettingsTransaction = this.host.storage,
1198
+ ): Promise<void> {
1199
+ const existing = await storage.get<unknown>(IDENTITY_KEY);
1200
+ if (existing !== undefined && typeof existing !== "string") {
1201
+ throw new Error("Stored User authority is invalid");
1202
+ }
1203
+ if (existing && existing !== userId) {
1204
+ throw new Error("User authority does not match durable identity");
1205
+ }
1206
+ if (!existing) await storage.put(IDENTITY_KEY, userId);
1207
+ }
1208
+ }
1209
+
1210
+ export function createUserSettingsBackendContribution(
1211
+ host: UserSettingsBackendHost,
1212
+ ): UserSettingsBackendContribution {
1213
+ return new UserSettingsBackendContribution(host);
1214
+ }
1215
+
1216
+ export function createUserSettingsBackendPlugin(
1217
+ host: UserSettingsBackendHost,
1218
+ lifecycle: { mount(value: UserSettingsBackendContribution): () => void },
1219
+ ): Plugin {
1220
+ return () => lifecycle.mount(createUserSettingsBackendContribution(host));
1221
+ }