From 4f8f34d834ccdea03de4863aa5d64fee5e5b7993 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Mon, 3 Aug 2026 09:32:39 +1000 Subject: [PATCH 1/4] Wire contract evolution through Connect --- Cargo.lock | 16 +- Cargo.toml | 2 +- apps/desktop/package.json | 2 +- apps/desktop/src/renderer/global.d.ts | 1 + apps/desktop/src/renderer/view-model.ts | 6 +- apps/editor/playwright.config.ts | 5 +- apps/editor/src/App.tsx | 57 +++++- apps/editor/src/ConnectApp.tsx | 4 +- apps/editor/src/Dialog.tsx | 7 +- apps/editor/src/TypeBrowser.test.tsx | 3 +- apps/editor/src/contract-catalog.test.ts | 27 ++- apps/editor/src/contract-catalog.ts | 40 ++--- apps/editor/src/demo-gateway.ts | 89 ++++++++- apps/editor/src/gateway.test.ts | 28 ++- apps/editor/src/gateway.ts | 31 +++- apps/editor/src/model.ts | 13 +- .../tests/authorization-recovery.spec.ts | 2 +- apps/editor/tests/editor.spec.ts | 18 +- apps/portal/package.json | 2 +- apps/portal/src/api.ts | 1 + apps/portal/src/authorization-view.tsx | 12 +- apps/portal/src/compatibility.ts | 4 +- apps/portal/src/portal-model.ts | 2 +- crates/connect-agent/src/mirrors.rs | 2 + crates/connect-agent/src/mirrors/commands.rs | 7 +- crates/connect-agent/src/mirrors/runtime.rs | 1 + crates/connect-agent/src/mirrors/support.rs | 2 + .../src/mirrors/synchronization.rs | 22 +++ .../src/runtime_notifications.rs | 13 +- crates/connect-agent/src/server.rs | 13 +- crates/connect-agent/src/server/account.rs | 4 + crates/connect-agent/src/server/operations.rs | 2 + crates/connect-core/src/registry.rs | 12 +- .../src/registry/operation_execution.rs | 117 +++++++++--- .../connect-core/src/registry/operations.rs | 157 ++++++++++++---- crates/connect-core/src/registry/scope.rs | 5 +- crates/connect-core/src/registry/tests.rs | 18 +- .../src/registry/tests/operations.rs | 85 +++++++-- .../connect-core/src/registry/tests/scope.rs | 1 + .../0023_type_pack_lock_resources.sql | 6 + crates/connect-hosted-provider/src/http.rs | 16 +- .../connect-hosted-provider/src/provider.rs | 44 ++++- .../src/provider/authority_snapshots.rs | 1 + .../src/provider/capabilities.rs | 8 +- .../src/provider/operation_dispatch.rs | 170 +++++++++++++----- .../src/provider/operation_types.rs | 156 +++++++++++----- .../src/provider/tests.rs | 16 +- .../connect-hosted-provider/src/workspace.rs | 80 ++++++--- .../src/workspace/contract_setup_tests.rs | 49 +++-- .../connect-mirror/src/directory_rebuild.rs | 1 + crates/connect-protocol/src/applications.rs | 33 ++++ crates/connect-protocol/src/tests.rs | 15 +- crates/connect-runtime/src/lib.rs | 49 +++-- crates/connect-runtime/src/timers.rs | 1 + crates/connect-testbed-adapter/src/main.rs | 1 + deploy/docker/Cargo.lock.hosted-provider | 14 +- docs/architecture.md | 13 +- docs/releasing.md | 6 +- package.json | 2 +- packages/client/README.md | 53 ++++-- packages/client/package.json | 2 +- packages/client/src/collection-client.ts | 14 +- packages/client/src/connection.ts | 14 +- packages/client/src/index.test.ts | 26 ++- packages/client/src/index.ts | 6 +- packages/client/src/operation-helpers.ts | 2 +- packages/devkit/README.md | 19 +- packages/devkit/package.json | 5 +- packages/devkit/src/index.test.ts | 77 ++++++-- packages/devkit/src/index.ts | 115 +++++++++++- packages/management/package.json | 2 +- packages/pickle/package.json | 2 +- packages/pickle/src/index.test.ts | 3 +- packages/pickle/src/index.ts | 7 +- packages/pickle/src/resources.ts | 14 +- packages/protocol/package.json | 2 +- .../schemas/connect-protocol.v1.schema.json | 10 +- .../schemas/encrypted-relay.v1.schema.json | 2 +- .../protocol/schemas/mdbase-app.schema.json | 8 +- packages/protocol/schemas/sync.v1.schema.json | 2 +- packages/protocol/src/index.ts | 92 +++++++++- packages/protocol/src/operations.ts | 3 +- packages/protocol/test/schema.test.mjs | 16 +- packages/sync/package.json | 2 +- packages/sync/src/adoption-snapshot.ts | 2 +- packages/sync/src/adoption.test.ts | 5 +- packages/sync/src/mirror-path-policy.ts | 17 ++ packages/sync/src/node.test.ts | 7 + packages/ui/access.ts | 6 +- packages/ui/contract-setup.test.ts | 7 +- packages/ui/contract-setup.ts | 6 +- packages/ui/package.json | 2 +- packages/webhooks/package.json | 2 +- pnpm-lock.yaml | 3 + scripts/e2e.mjs | 27 ++- scripts/hosted-provider-e2e.mjs | 65 ++++++- services/mcp/package.json | 2 +- services/mcp/src/mcp.ts | 2 +- services/mcp/src/oauth.ts | 2 +- .../0012_notification_contract_digests.sql | 114 ++++++++++++ services/server/package.json | 2 +- services/server/src/app.test.ts | 59 ++++-- services/server/src/collection-access.ts | 2 +- services/server/src/db.test.ts | 18 +- .../authorizations/approval-service.ts | 26 ++- .../src/features/authorizations/routes.ts | 2 +- services/server/src/features/grants/policy.ts | 6 +- .../server/src/features/grants/service.ts | 2 +- .../server/src/features/hosted/service.ts | 2 +- services/server/src/grant-planner.test.ts | 10 +- services/server/src/grant-planner.ts | 4 +- services/server/src/hosted-provider.test.ts | 23 ++- services/server/src/hosted-provider.ts | 2 + services/server/src/hosted.ts | 4 +- services/server/src/manifest.test.ts | 14 +- services/server/src/manifest.ts | 4 +- services/server/src/notifications.test.ts | 9 +- services/server/src/protocol-schemas.ts | 3 +- 118 files changed, 1947 insertions(+), 526 deletions(-) create mode 100644 crates/connect-hosted-provider/migrations/0023_type_pack_lock_resources.sql create mode 100644 services/server/migrations/0012_notification_contract_digests.sql diff --git a/Cargo.lock b/Cargo.lock index a99cba766..3a0689d2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2554,6 +2554,7 @@ dependencies = [ "rusqlite", "semver", "serde", + "serde_jcs", "serde_json", "serde_yaml", "sha2 0.10.9", @@ -2566,7 +2567,7 @@ dependencies = [ [[package]] name = "mdbase-cli" -version = "0.1.0-beta.24" +version = "0.1.0-beta.25" dependencies = [ "clap", "directories", @@ -2594,11 +2595,12 @@ dependencies = [ "rand 0.8.7", "serde", "serde_json", + "serde_yaml", ] [[package]] name = "mdbase-connect-core" -version = "0.1.0-beta.24" +version = "0.1.0-beta.25" dependencies = [ "chrono", "directories", @@ -2622,7 +2624,7 @@ dependencies = [ [[package]] name = "mdbase-connect-daemon" -version = "0.1.0-beta.24" +version = "0.1.0-beta.25" dependencies = [ "async-trait", "axum", @@ -2657,7 +2659,7 @@ dependencies = [ [[package]] name = "mdbase-connect-hosted-provider" -version = "0.1.0-beta.24" +version = "0.1.0-beta.25" dependencies = [ "aes-gcm", "async-trait", @@ -2700,7 +2702,7 @@ dependencies = [ [[package]] name = "mdbase-connect-mirror" -version = "0.1.0-beta.24" +version = "0.1.0-beta.25" dependencies = [ "async-trait", "chrono", @@ -2723,7 +2725,7 @@ dependencies = [ [[package]] name = "mdbase-connect-protocol" -version = "0.1.0-beta.24" +version = "0.1.0-beta.25" dependencies = [ "aes-gcm", "base64", @@ -2741,7 +2743,7 @@ dependencies = [ [[package]] name = "mdbase-connect-runtime" -version = "0.1.0-beta.24" +version = "0.1.0-beta.25" dependencies = [ "chrono", "mdbase", diff --git a/Cargo.toml b/Cargo.toml index f828bff34..6d6c57016 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.1.0-beta.24" +version = "0.1.0-beta.25" edition = "2021" license = "MIT" repository = "https://github.com/mdbase-dev/mdbase-connect" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index df94446ee..49565b195 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@mdbase/connect-desktop", "productName": "mdbase connect", - "version": "0.1.0-beta.24", + "version": "0.1.0-beta.25", "description": "Connect applications to authorized mdbase collections.", "author": "mdbase", "private": true, diff --git a/apps/desktop/src/renderer/global.d.ts b/apps/desktop/src/renderer/global.d.ts index 875efdc04..5c7b513da 100644 --- a/apps/desktop/src/renderer/global.d.ts +++ b/apps/desktop/src/renderer/global.d.ts @@ -61,6 +61,7 @@ interface CollectionSummary { interface ContractRequirement { id: string; version: string; + digest: string; } interface CollectionContractDescriptor extends ContractRequirement { diff --git a/apps/desktop/src/renderer/view-model.ts b/apps/desktop/src/renderer/view-model.ts index fea4c8eac..475d17c38 100644 --- a/apps/desktop/src/renderer/view-model.ts +++ b/apps/desktop/src/renderer/view-model.ts @@ -12,7 +12,7 @@ export interface AuthorizationCollection { types: CollectionTypeDescriptor[]; } -export const allOperations = ["describe", "changes", "read", "query", "list_views", "execute_view", "read_view_source", "create", "update", "rename", "delete", "create_view_source", "update_view_source", "delete_view_source", "validate", "read_type", "create_type", "update_type", "install_type_pack", "list_timers", "put_timer", "cancel_timer", "reconcile_timers"]; +export const allOperations = ["describe", "changes", "read", "query", "list_views", "execute_view", "read_view_source", "create", "update", "rename", "delete", "create_view_source", "update_view_source", "delete_view_source", "validate", "read_type", "create_type", "update_type", "apply_type_pack", "list_timers", "put_timer", "cancel_timer", "reconcile_timers"]; export function neededProvisions( requirements: ApplicationRequirements, @@ -32,7 +32,7 @@ const MDBASE_03_OPERATIONS = new Set([ "read_type", "create_type", "update_type", - "install_type_pack" + "apply_type_pack" ]); export function hostedCollectionCompatible( @@ -180,7 +180,7 @@ function authorityPromotionPhaseLabel( } export function hasContract(contracts: ContractRequirement[], required: ContractRequirement) { return contracts.some((contract) => sameContract(contract, required)); } -function sameContract(left: ContractRequirement, right: ContractRequirement) { return left.id === right.id && left.version === right.version; } +function sameContract(left: ContractRequirement, right: ContractRequirement) { return left.id === right.id && left.version === right.version && left.digest === right.digest; } export function provisionNames(provisions: TypePackProvision[]) { return provisions .map((provision) => provision.manifest.name ?? provision.manifest.id) diff --git a/apps/editor/playwright.config.ts b/apps/editor/playwright.config.ts index e5891865d..6db0e1800 100644 --- a/apps/editor/playwright.config.ts +++ b/apps/editor/playwright.config.ts @@ -2,6 +2,9 @@ import { defineConfig, devices } from "@playwright/test"; const port = Number(process.env.MDBASE_EDITOR_E2E_PORT ?? 42_873); const baseURL = `http://127.0.0.1:${port}/`; +const webServerCommand = + process.env.MDBASE_EDITOR_E2E_WEB_SERVER_COMMAND ?? + `pnpm build:e2e && pnpm preview --port ${port} --strictPort`; export default defineConfig({ testDir: "./tests", @@ -13,7 +16,7 @@ export default defineConfig({ trace: "retain-on-failure" }, webServer: { - command: `pnpm build:e2e && pnpm preview --port ${port} --strictPort`, + command: webServerCommand, url: `${baseURL}?demo=10`, reuseExistingServer: false, timeout: 120_000 diff --git a/apps/editor/src/App.tsx b/apps/editor/src/App.tsx index d90cca08b..757e736be 100644 --- a/apps/editor/src/App.tsx +++ b/apps/editor/src/App.tsx @@ -10,7 +10,7 @@ import { WarningCircleIcon as CircleAlert, XIcon as X } from "./icons"; -import { ConnectOutcomeError, type CollectionChange, type CollectionDescription, type CollectionTypeDescriptor, type MutationProgress } from "@mdbase-dev/connect"; +import { ConnectOutcomeError, type CollectionChange, type CollectionDescription, type CollectionTypeDescriptor, type MutationProgress, type TypePackAssessment, type TypePackProvision } from "@mdbase-dev/connect"; import { useCallback, useDeferredValue, @@ -112,6 +112,7 @@ interface Confirmation { confirmLabel: string; cancelLabel?: string; tone?: "default" | "danger"; + initialFocus?: "confirm" | "cancel"; onConfirm: () => void | Promise; } @@ -1568,9 +1569,55 @@ export function App({ gateway }: { gateway: CollectionGateway }) { } async function installCatalogPack(pack: ContractCatalogPack) { - const previousTypes = new Set(description?.types.map((type) => type.name) ?? []); const provision = await loadTypePackProvision(pack); - const installed = await gateway.installTypePack(provision); + const assessment = await gateway.assessTypePack(provision); + if (!assessment.applicable) { + const conflicts = assessment.resources.filter(({ action }) => action === "conflict"); + const adoptable = conflicts.filter((resource) => + resource.mode === "managed" && resource.current_digest && !resource.installed_digest); + if (adoptable.length && adoptable.length === conflicts.length) { + const adoptions = Object.fromEntries(adoptable.map((resource) => [ + resource.target, + resource.current_digest! + ])); + setConfirmation({ + title: `Let “${pack.displayName}” manage these definitions?`, + body: <> +

The collection has older unmanaged files at the pack’s managed paths.

+ +

The editor will replace these reviewed files and record their exact source, version, and digest in mdbase.lock.yaml. Future upgrades stop instead of overwriting an unexpected edit.

+ , + confirmLabel: "Adopt and update", + cancelLabel: "Not now", + initialFocus: "cancel", + onConfirm: async () => { + try { + const reviewed = await gateway.assessTypePack(provision, adoptions); + if (!reviewed.applicable) { + throw new Error(reviewed.resources.find(({ action }) => action === "conflict")?.reason + ?? "The definitions changed while they were being reviewed."); + } + await finishCatalogPackInstall(pack, provision, reviewed, adoptions); + } catch (error) { + setTypeError(gatewayError(error)); + } + } + }); + return; + } + throw new Error(conflicts[0]?.reason ?? "This pack conflicts with collection definitions."); + } + await finishCatalogPackInstall(pack, provision, assessment); + } + + async function finishCatalogPackInstall( + pack: ContractCatalogPack, + provision: TypePackProvision, + assessment: TypePackAssessment, + adoptions: Record = {} + ) { + const previousTypes = new Set(description?.types.map((type) => type.name) ?? []); + const installed = await gateway.applyTypePack(provision, assessment, adoptions); const next = await refreshDescription(); const addedTypes = next.types.filter((type) => !previousTypes.has(type.name)); const primaryType = pack.primaryType @@ -1761,6 +1808,7 @@ export function App({ gateway }: { gateway: CollectionGateway }) { confirmLabel={confirmation.confirmLabel} cancelLabel={confirmation.cancelLabel} tone={confirmation.tone} + initialFocus={confirmation.initialFocus} onConfirm={confirmation.onConfirm} onClose={() => setConfirmation(undefined)} />} @@ -2044,7 +2092,7 @@ export function App({ gateway }: { gateway: CollectionGateway }) { loading={contractCatalog.status === "loading"} error={contractCatalog.status === "error" ? contractCatalog.message : undefined} canInstall={Boolean(connectionSummary?.operations.some((operation) => - operation === "all" || operation === "install_type_pack" + operation === "all" || operation === "apply_type_pack" ))} leadingActions={editorLeadingActions} onInstall={installCatalogPack} @@ -2158,6 +2206,7 @@ export function App({ gateway }: { gateway: CollectionGateway }) { confirmLabel={confirmation.confirmLabel} cancelLabel={confirmation.cancelLabel} tone={confirmation.tone} + initialFocus={confirmation.initialFocus} onConfirm={confirmation.onConfirm} onClose={() => setConfirmation(undefined)} />} diff --git a/apps/editor/src/ConnectApp.tsx b/apps/editor/src/ConnectApp.tsx index e1e1b85b7..9ac5edd46 100644 --- a/apps/editor/src/ConnectApp.tsx +++ b/apps/editor/src/ConnectApp.tsx @@ -44,7 +44,7 @@ const desktopReleaseUrl = "https://github.com/mdbase-dev/mdbase-connect/releases const allOperations = [ "describe", "changes", "read", "query", "list_views", "execute_view", "read_view_source", "validate", "create", "update", "delete", "rename", "create_view_source", "update_view_source", "delete_view_source", - "read_type", "create_type", "update_type", "install_type_pack", "list_timers", "put_timer", "cancel_timer", + "read_type", "create_type", "update_type", "apply_type_pack", "list_timers", "put_timer", "cancel_timer", "reconcile_timers" ]; @@ -603,7 +603,7 @@ function permissionSummary(grants: ApplicationAccessGroup["grants"]): str ].filter(Boolean); const capabilities: string[] = []; if (verbs.length > 0) capabilities.push(`${joinWords(verbs)} records`); - if (["create_type", "update_type", "install_type_pack", "create_view_source", "update_view_source", "delete_view_source"].some((operation) => operations.has(operation))) capabilities.push("manage types"); + if (["create_type", "update_type", "apply_type_pack", "create_view_source", "update_view_source", "delete_view_source"].some((operation) => operations.has(operation))) capabilities.push("manage types"); if (["put_timer", "cancel_timer", "reconcile_timers"].some((operation) => operations.has(operation))) capabilities.push("manage timers"); if (capabilities.length === 0) return `${operations.size} ${operations.size === 1 ? "permission" : "permissions"}`; const summary = capabilities.join("; "); diff --git a/apps/editor/src/Dialog.tsx b/apps/editor/src/Dialog.tsx index b50cb1731..f645b55ae 100644 --- a/apps/editor/src/Dialog.tsx +++ b/apps/editor/src/Dialog.tsx @@ -92,12 +92,13 @@ export function Dialog({ titleId, className, role = "dialog", onClose, children ); } -export function ConfirmDialog({ title, body, confirmLabel, cancelLabel = "Cancel", tone = "default", onConfirm, onClose }: { +export function ConfirmDialog({ title, body, confirmLabel, cancelLabel = "Cancel", tone = "default", initialFocus, onConfirm, onClose }: { title: string; body: ReactNode; confirmLabel: string; cancelLabel?: string; tone?: "default" | "danger"; + initialFocus?: "confirm" | "cancel"; onConfirm: () => void | Promise; onClose: () => void; }) { @@ -121,13 +122,13 @@ export function ConfirmDialog({ title, body, confirmLabel, cancelLabel = "Cancel