diff --git a/README.md b/README.md index 856c76c..8ffc353 100644 --- a/README.md +++ b/README.md @@ -112,15 +112,24 @@ https://your-artifacts-host/mcp ``` For the local host, use `http://127.0.0.1:4786/mcp`. Remote clients use the -configured deployment's sign-in flow. Optional `workspace` and `library` query -parameters select a workspace or the team library. +configured deployment's sign-in flow. One connection supports personal and team +libraries: use `library_scope: "private"` or `library_scope: "team"` on a tool call, +or choose the library in the MCP gallery. Optional `workspace` and `library` URL +parameters remain supported as connection defaults. The connection advertises nine model tools: `artifact_guide`, `artifact_list`, `artifact_read`, `artifact_write`, `artifact_edit`, `artifact_open`, -`artifacts_discover`, `artifacts_query`, and `artifacts_execute`. All 48 hosted +`artifacts_discover`, `artifacts_query`, and `artifacts_execute`. All 53 hosted operations remain available through that same connection. Use `artifacts_discover` to find an operation and retrieve its exact argument schema and relevant guide; call it through `artifacts_query` for reads or `artifacts_execute` for actions. + +In the hosted gallery, **More actions → Delete** moves an artifact or script to +recoverable **Trash**. Links stop working and schedules pause; source history, +data, files and secrets are preserved. Restore it from Trash to recover its +original link. Schedules stay paused until explicitly resumed. MCP exposes +`artifact_delete`, `script_delete`, `artifacts_trash`, `artifact_undelete` and +`script_undelete` through discovery. Local file connections expose their supported artifact operations through the same interface. No configuration switch is needed. diff --git a/cloudflare/artifact-execution.ts b/cloudflare/artifact-execution.ts index 8554169..7ce38a3 100644 --- a/cloudflare/artifact-execution.ts +++ b/cloudflare/artifact-execution.ts @@ -59,6 +59,7 @@ export class ArtifactExecution { if (!["set", "pause", "resume"].includes(action)) throw new Error("Invalid schedule action"); return this.storage.transaction(async storage => { const previous = this.readSchedule(); + if (action !== "pause" && await storage.get("trashed")) throw new Error("Item is in Trash"); if (action !== "set" && !previous) throw new Error("No schedule configured"); const active = this.readActive(); if (action !== "pause" && !active?.code) throw new Error("Artifact has no validated server"); diff --git a/cloudflare/auth-flow.test.ts b/cloudflare/auth-flow.test.ts index 0dc4f24..2c0f506 100644 --- a/cloudflare/auth-flow.test.ts +++ b/cloudflare/auth-flow.test.ts @@ -276,7 +276,8 @@ test("real OIDC and MCP OAuth share per-user gallery, source and database bounda const body = await frame.locator("body").innerText({ timeout: 500 }).catch(() => "Preview body unavailable"); throw new Error(`${String(error)}\nGallery: ${bobPage.url()}\nPreview: ${await bobPage.locator("iframe").getAttribute("src")}\n${body}\n${previewRequests.join("\n")}\n${celldRuntime?.logs().split("\n").filter(line => /ERROR|WARN| at | at /.test(line)).join("\n") ?? ""}`); } - expect((await aliceContext.request.post(`${origin}/api/artifact/request`, { headers: { Origin: "https://evil.example" }, data: {} })).status()).toBe(403); + // A fresh connection: the preview wait can outlive the server's idle keep-alive socket. + expect((await aliceContext.request.post(`${origin}/api/artifact/request`, { headers: { Origin: "https://evil.example", Connection: "close" }, data: {} })).status()).toBe(403); const token = aliceProvider.savedTokens!.access_token; expect((await fetch(`${origin}/mcp`, { headers: { Authorization: `Bearer ${token.slice(0, -10)}tampered00` } })).status).toBe(401); const cookie = (await aliceContext.cookies()).find(cookie => cookie.name.endsWith("session_token"))!; @@ -513,6 +514,8 @@ test("a service token manages the team library over MCP and confers nothing outs expect(linked.isError, JSON.stringify(linked.content)).not.toBe(true); // The service account has no personal library to fall back to. + expect((await service.callTool({ name: "artifact_list", arguments: { library_scope: "private" } })).isError).toBe(true); + expect((await service.callTool({ name: "artifacts_execute", arguments: { operation: "artifact_delete", arguments: { name: "service-report", library_scope: "private" } } })).isError).toBe(true); for (const query of ["workspace=test", "workspace=test&library=private"]) { const response = await fetch(`${origin}/mcp?${query}`, { headers: bearer }); expect(response.status).toBe(403); diff --git a/cloudflare/backend.ts b/cloudflare/backend.ts index d82daee..88ca7be 100644 --- a/cloudflare/backend.ts +++ b/cloudflare/backend.ts @@ -81,7 +81,15 @@ export class ArtifactBackend extends DurableObject { return active ? { version_id: active.version_id, revision: active.revision, has_server: active.code !== null } : null; } runs(input: { limit?: number } = {}) { return this.execution.runs(input.limit); } + async setTrashed(trashed: boolean) { + if (trashed) await this.ctx.storage.put("trashed", true); + if (await this.execution.get()) await this.execution.update({ action: "pause" }); + await this.ctx.storage.deleteAlarm(); + if (!trashed) await this.ctx.storage.put("trashed", false); + // Restoration deliberately leaves schedules paused until explicitly resumed. + } async schedule(input: ArtifactScheduleInput = {}) { + if (input.action !== undefined && input.action !== "get" && input.action !== "pause" && await this.ctx.storage.get("trashed")) throw new Error("Item is in Trash"); if (input.request) { nativeRequest(input.request); input = { ...input, request: { ...input.request, method: input.request.method ?? "GET", headers: input.request.headers ?? [] } }; @@ -95,6 +103,7 @@ export class ArtifactBackend extends DurableObject { return this.execution.update(input); } async alarm() { + if (await this.ctx.storage.get("trashed")) return; const claimed = await this.execution.claim(); if (claimed) await this.runScheduled(claimed.schedule.request, "schedule", claimed); } @@ -131,6 +140,7 @@ export class ArtifactBackend extends DurableObject { } private async execute(input: BackendCode, request: Request, claimedRun?: { id: string; started: number }): Promise { + if (await this.ctx.storage.get("trashed")) throw new Error("Item is in Trash"); const active = input.version_id ? undefined : await this.execution.active(); const revision = input.version_id ?? (active?.hash === input.hash ? active.version_id : input.hash); const run = claimedRun ?? this.execution.start(revision, input.trigger ?? "http"); diff --git a/cloudflare/files.ts b/cloudflare/files.ts index 4c49f99..52c8c11 100644 --- a/cloudflare/files.ts +++ b/cloudflare/files.ts @@ -54,7 +54,13 @@ export class ArtifactFiles extends DurableObject { private prefix() { return `canvases/${this.ctx.id.toString()}/`; } private key(id: string) { return this.prefix() + id; } + async setTrashed(trashed: boolean) { + await this.ctx.storage.put("trashed", trashed); + if (trashed) this.ctx.storage.sql.exec("delete from file_grants"); + } + async request(input: ArtifactFileRequest): Promise { + if (await this.ctx.storage.get("trashed")) throw new ArtifactFileError("Item is in Trash", 404); validateFileRequest(input); if (!this.env.FILES) throw new ArtifactFileError("Artifact file storage is not configured", 503); if (input.operation === "list") { @@ -111,6 +117,7 @@ export class ArtifactFiles extends DurableObject { async fetch(request: Request): Promise { const headers = transferHeaders(); try { + if (await this.ctx.storage.get("trashed")) throw new ArtifactFileError("Transfer not found", 404); const match = new URL(request.url).pathname.match(TRANSFER_PATH); if (!match || match[1] !== this.ctx.id.toString() || !ID.test(match[2]!)) throw new ArtifactFileError("Transfer not found", 404); const row = this.ctx.storage.sql.exec<{ value: string }>("select value from file_grants where token = ? and expires > ?", match[2]!, Date.now()).toArray()[0]; diff --git a/cloudflare/links.ts b/cloudflare/links.ts index 1d50344..e18d804 100644 --- a/cloudflare/links.ts +++ b/cloudflare/links.ts @@ -1,6 +1,11 @@ import { DurableObject } from "cloudflare:workers"; import { LibraryOwnership, type LibrarySelection, type OwnershipEnvironment } from "./ownership"; import { GallerySubscriptions } from "./gallery-subscriptions"; +import type { ArtifactBackend } from "./backend"; +import type { ScriptBackend } from "./script-backend"; +import type { ArtifactFiles } from "./files"; + +type TrashEnvironment = { BACKENDS?: DurableObjectNamespace; SCRIPT_BACKENDS?: DurableObjectNamespace; FILE_BACKENDS?: DurableObjectNamespace }; export type ArtifactTarget = { libraryKey: string; workspace: string; kind: "artifact" | "script"; name: string }; export type LiveRevision = { id: string; revision: number; revision_token: string }; @@ -42,6 +47,21 @@ export class ArtifactLinks extends DurableObject { return result; } catalog(input: Parameters[0]) { return this.ownership.catalog(input); } + trash(input: Parameters[0]) { return this.ownership.trash(input); } + async setTrashed(selection: LibrarySelection & { name: string }, trashed: boolean) { + const target = await this.ownership.setTrashed(selection, trashed, async (target, value) => { + // Invalidate compiles admitted before deletion, including ones that finish + // after the item is restored. Source and storage remain recoverable. + if (value) this.beginGeneration(target); + const env = this.env as TrashEnvironment; + const backends = target.kind === "artifact" ? env.BACKENDS : env.SCRIPT_BACKENDS; + const identity = JSON.stringify([target.libraryKey, target.workspace, target.name]); + if (backends) await backends.getByName(identity).setTrashed(value); + if (target.kind === "artifact" && env.FILE_BACKENDS) await env.FILE_BACKENDS.getByName(identity).setTrashed(value); + }); + this.subscriptions.changed(selection.libraryKey, target.workspace); + return { workspace: target.workspace, kind: target.kind, name: target.name, trashed }; + } fetch(request: Request) { return this.subscriptions.accept(request); } changed(target: ArtifactTarget) { this.subscriptions.changed(this.ownership.owner(target), target.workspace); } nativeAppChanged(selection: { libraryKey: string; workspace: string }) { this.subscriptions.changed(selection.libraryKey, selection.workspace); } @@ -51,15 +71,18 @@ export class ArtifactLinks extends DurableObject { get(slug: string): ArtifactLink | null { const row = this.ctx.storage.sql.exec<{ value: string }>("select value from links where slug = ?", slug).toArray()[0]; - return row ? readLink(row.value) : null; + const link = row ? readLink(row.value) : null; + return link && !this.ownership.isTrashed(link) ? link : null; } find(target: ArtifactTarget): ArtifactLink | null { const row = this.ctx.storage.sql.exec<{ value: string }>("select value from links where target = ?", key(target)).toArray()[0]; - return row ? readLink(row.value) : null; + return row && !this.ownership.isTrashed(target) ? readLink(row.value) : null; } check(target: ArtifactTarget, candidate: string): string { const slug = validateSlug(candidate); - const taken = this.get(slug); + // Trashed links reserve their slug so restoring cannot hijack another URL. + const row = this.ctx.storage.sql.exec<{ value: string }>("select value from links where slug=?", slug).toArray()[0]; + const taken = row ? readLink(row.value) : null; if (taken && key(taken) !== key(target)) throw new Error("Slug is already in use"); return slug; } @@ -69,7 +92,7 @@ export class ArtifactLinks extends DurableObject { } /** Keep desired URL metadata with invalid drafts without exposing a route. */ stage(target: ArtifactTarget, generation: number, settings: LinkUpdate): boolean { - if (this.generation(target) !== generation) return false; + if (this.ownership.isTrashed(target) || this.generation(target) !== generation) return false; const previous = this.draft(target); const pending = { ...previous, ...(settings.slug === undefined ? {} : { slug: validateSlug(settings.slug) }), @@ -84,6 +107,10 @@ export class ArtifactLinks extends DurableObject { } /** Issue before saving/compiling. A later edit supersedes every older activation. */ begin(target: ArtifactTarget): number { + if (this.ownership.isTrashed(target)) throw new Error("Item is in Trash"); + return this.beginGeneration(target); + } + private beginGeneration(target: ArtifactTarget): number { const generation = this.generation(target) + 1; this.ctx.storage.sql.exec("insert into generations values (?, ?) on conflict(target) do update set generation = excluded.generation", key(target), generation); return generation; @@ -93,7 +120,7 @@ export class ArtifactLinks extends DurableObject { return this.ctx.storage.transactionSync(() => this.commitInTransaction(target, generation, update)); } private commitInTransaction(target: ArtifactTarget, generation: number, update: LinkUpdate): ArtifactLink | null { - if (this.generation(target) !== generation) return null; + if (this.ownership.isTrashed(target) || this.generation(target) !== generation) return null; const previous = this.find(target); update = { ...this.draft(target), ...update }; const slug = this.check(target, update.slug ?? previous?.slug ?? target.name); @@ -107,6 +134,7 @@ export class ArtifactLinks extends DurableObject { } set(input: ArtifactTarget & { slug: string; access?: "private" | "public" }): ArtifactLink { return this.ctx.storage.transactionSync(() => { + if (this.ownership.isTrashed(input)) throw new Error("Item is in Trash"); this.check(input, input.slug); // Explicit metadata actions replace draft intent, including pending public // access, so a subsequent source fix cannot undo a revocation. diff --git a/cloudflare/managed-service.ts b/cloudflare/managed-service.ts index a6aad12..70ebd34 100644 --- a/cloudflare/managed-service.ts +++ b/cloudflare/managed-service.ts @@ -19,7 +19,7 @@ import { CLOUD_MCP_TOOLS } from "./tool-contract"; import * as toolValidators from "../dist/cloudflare/tool-validators.js"; const text = (value: unknown): CallToolResult => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] }); -type Selection = { workspace?: string; name?: string; version_id?: string; event_id?: string }; +type Selection = { workspace?: string; name?: string; version_id?: string; event_id?: string; library_scope?: "private" | "team" }; /** Management requests select an owner; execution continues on immutable storage. */ export class ManagedArtifactService { @@ -44,6 +44,15 @@ export class ManagedArtifactService { this.plugins, this.fileStorage); } + private scoped(scope: unknown): ManagedArtifactService { + if (scope === undefined) return this; + if (scope !== "private" && scope !== "team") throw new Error("Library scope must be private or team"); + if (scope === "private" && this.privateKey === "team") throw new Error("Service tokens manage the team library only"); + const libraryKey = scope === "team" ? "team" : this.privateKey; + return libraryKey === this.libraryKey ? this : new ManagedArtifactService(this.env, this.workspace, libraryKey, + this.privateKey, this.origin, this.publicOrigin, this.plugins); + } + private selection(kind: "artifact" | "script", selection: Selection): LibrarySelection { return { libraryKey: this.libraryKey, workspace: this.workspace, kind, ...selection }; } @@ -57,7 +66,24 @@ export class ManagedArtifactService { private async callSelectedTool(target: ArtifactTarget, name: string, args: Record) { const changesGallery = /^(artifact|script)_(write|edit|restore|remix|import|open|link)$/.test(name); - try { return await this.physical(target).callTool(name, { ...args, workspace: target.workspace }); } + try { + const result = await this.physical(target).callTool(name, { ...args, workspace: target.workspace }); + const scope = this.libraryKey === "team" ? "team" : "private"; + // Both full previews and compact mutation results must reopen in the + // logical library, even after an item moves to different physical storage. + const scopedArtifact = (value: unknown) => value && typeof value === "object" ? { ...value, library_scope: scope } : value; + if (result._meta?.artifact) result._meta = { ...result._meta, artifact: scopedArtifact(result._meta.artifact) }; + const content = result.structuredContent; + if (content) { + const preview = content.preview as Record | undefined; + result.structuredContent = { ...content, + ...(content.artifact ? { artifact: scopedArtifact(content.artifact) } : {}), + ...(content.appPreview ? { appPreview: scopedArtifact(content.appPreview) } : {}), + ...(preview?.artifact ? { preview: { ...preview, artifact: scopedArtifact(preview.artifact) } } : {}), + }; + } + return result; + } finally { // A failed compile may still have saved a draft. Resolve current ownership // after the operation, including writes admitted before a library move. @@ -79,6 +105,10 @@ export class ManagedArtifactService { } async callTool(name: string, args: Record): Promise { + if (args.library_scope !== undefined) { + const { library_scope, ...input } = args; + return this.scoped(library_scope).callTool(name, input); + } if (isDiscoveryTool(name)) { return callDiscoveryTool(CLOUD_MCP_TOOLS, name, args, async (operation, input) => { validateToolArguments(toolValidators, operation, input); @@ -88,6 +118,17 @@ export class ManagedArtifactService { }); } if (ARTIFACTS_SETTINGS_TOOLS.some(tool => tool.name === name)) return settingsTool(this, name, args); + if (name === "artifacts_trash") { + const items = await this.links.trash({ libraryKey: this.libraryKey, workspace: args.all === true ? undefined : this.workspace, offset: args.offset as number | undefined }); + return text({ items, next_offset: items.length === 100 ? (args.offset as number | undefined ?? 0) + 100 : null }); + } + if (/^(artifact|script)_(delete|undelete)$/.test(name)) { + const kind = name.startsWith("script_") ? "script" : "artifact"; + const result = await this.links.setTrashed({ ...this.selection(kind, { + name: args.name as string, ...(typeof args.workspace === "string" ? { workspace: args.workspace } : {}), + }), name: args.name as string }, name.endsWith("_delete")); + return text({ ok: true, ...result }); + } if (name === "artifacts_tool") return galleryAction(this, args); if (name === "artifacts_source") return gallerySource(this, args); if (["artifacts_library", "artifacts_working", "artifacts_search", "artifacts_mentions"].includes(name)) return workspaceTool(this, name, args); @@ -155,8 +196,14 @@ export class ManagedArtifactService { } pluginCall(request: PluginRequest) { return this.physical({ libraryKey: this.libraryKey, workspace: this.workspace }).pluginCall(request); } - async snapshot(selection: Selection) { return (await this.service("artifact", selection)).snapshot(selection); } - async scriptReadSource(selection: Selection) { return (await this.service("script", selection)).scriptReadSource(selection); } + async snapshot(selection: Selection) { + const { library_scope, ...input } = selection; + return (await this.scoped(library_scope).service("artifact", input)).snapshot(input); + } + async scriptReadSource(selection: Selection) { + const { library_scope, ...input } = selection; + return (await this.scoped(library_scope).service("script", input)).scriptReadSource(input); + } async request(selection: Selection, request: ArtifactHttpRequest) { return (await this.service("artifact", selection)).request(selection, request); } async fileRequest(selection: Selection, request: ArtifactFileRequest) { return (await this.service("artifact", selection)).fileRequest(selection, request); } async preview(snapshot: Awaited>) { @@ -185,7 +232,7 @@ export class ManagedArtifactService { for (const row of rows) { const name = method === "listDrafts" && kind === "artifact" ? row.id! : row.name; const key = JSON.stringify(kind === "script" ? ["script", row.workspace, name] : [row.workspace, name]); - const item: GalleryArtifact = artifacts.get(key) ?? { key, name, workspace: row.workspace, working: false, kind, versions: [] }; + const item: GalleryArtifact = artifacts.get(key) ?? { key, name, workspace: row.workspace, library_scope: this.libraryKey === "team" ? "team" : "private", working: false, kind, versions: [] }; if (method === "listDrafts") item.working = true; else item.versions.push({ id: row.version_id ?? row.id!, revision: row.revision as number, createdAt: row.created_at!, reason: row.reason as string, serveCount: kind === "artifact" ? row.serve_count as number : 0 }); artifacts.set(key, item); @@ -205,7 +252,7 @@ export class ManagedArtifactService { ? await this.nativeController().list({ owner: this.libraryKey, workspace: all ? undefined : this.workspace, offset }) : null; hasMore ||= workers?.next_offset != null; - return { libraryScope: this.libraryKey === "team" ? "team" : "private", capabilities: { editing: true, scripts: true, links: true, moves: true, nativeApps: !!this.env.NATIVE_APPS, subscriptions: true, subscriptionTransport: "events" as const }, workspace: this.workspace, + return { libraryScope: this.libraryKey === "team" ? "team" : "private", capabilities: { editing: true, scripts: true, links: true, moves: true, trash: true, nativeApps: !!this.env.NATIVE_APPS, subscriptions: true, subscriptionTransport: "events" as const }, workspace: this.workspace, ...(workers ? { workerApps: workers.apps.map(app => ({ ...app, kind: "worker" as const, key: JSON.stringify(["worker", app.workspace, app.name]) })), nativeAppProviders: workers.providers } : {}), artifacts: [...artifacts.values()].sort((a, b) => a.name.localeCompare(b.name) || a.workspace.localeCompare(b.workspace)), nextOffset: hasMore ? offset + 100 : null }; } diff --git a/cloudflare/mcp-discovery.test.ts b/cloudflare/mcp-discovery.test.ts index 0ffbe44..383c715 100644 --- a/cloudflare/mcp-discovery.test.ts +++ b/cloudflare/mcp-discovery.test.ts @@ -128,7 +128,7 @@ afterAll(async () => { if (directory) await rm(directory, { recursive: true, force: true }); }); -integrationTest("one HTTP MCP connection discovers all 48 operations and performs complete artifact, script and native app workflows", async () => { +integrationTest("one HTTP MCP connection discovers all 53 operations and performs complete artifact, script and native app workflows", async () => { const listed = (await client.listTools()).tools; const model = listed.filter(tool => !((tool._meta?.ui as { visibility?: string[] } | undefined)?.visibility?.every(scope => scope === "app"))); expect(model.map(tool => tool.name).sort()).toEqual([ @@ -136,8 +136,10 @@ integrationTest("one HTTP MCP connection discovers all 48 operations and perform "artifacts_discover", "artifacts_execute", "artifacts_query", ]); const catalog = payload(await client.callTool({ name: "artifacts_discover", arguments: { limit: 50 } })); - expect(catalog.total).toBe(48); - for (const operation of catalog.operations) await inspect(operation.operation); + expect(catalog.total).toBe(53); + const rest = payload(await client.callTool({ name: "artifacts_discover", arguments: { offset: 50, limit: 50 } })); + expect(rest.next_offset).toBeNull(); + for (const operation of [...catalog.operations, ...rest.operations]) await inspect(operation.operation); const backend = await readFile(join(import.meta.dir, "../examples/counter.artifact.server.ts"), "utf8"); const guide = await invoke("artifact_guide"); expect(JSON.stringify(guide.content)).not.toContain("Hosted standalone scripts and root URLs"); @@ -198,6 +200,13 @@ integrationTest("one HTTP MCP connection discovers all 48 operations and perform const scriptArchive = payload(await invoke("script_export", { name: "compact-script" })); await invoke("script_import", { new_name: "compact-script-import", archive: scriptArchive }); expect(JSON.stringify(scriptArchive)).not.toContain("script-fixture-value"); + await invoke("artifact_delete", { name: "compact-import" }); + await invoke("script_delete", { name: "compact-script-import" }); + const trash = JSON.stringify(payload(await invoke("artifacts_trash"))); + expect(trash).toContain("compact-import"); + expect(trash).toContain("compact-script-import"); + await invoke("artifact_undelete", { name: "compact-import" }); + await invoke("script_undelete", { name: "compact-script-import" }); await invoke("plugin_guide"); const plugins = payload(await invoke("plugins_list")); @@ -225,7 +234,7 @@ integrationTest("one HTTP MCP connection discovers all 48 operations and perform expect((await ownerFetch(appUrl)).status).toBe(404); expect(await (await otherFetch(appUrl + "&library=team")).text()).toBe("native-original"); } - const expected = catalog.operations.map((operation: { operation: string }) => operation.operation).filter((name: string) => native || !name.startsWith("app_")); + const expected = [...catalog.operations, ...rest.operations].map((operation: { operation: string }) => operation.operation).filter((name: string) => native || !name.startsWith("app_")); expect([...seen].sort()).toEqual(expected.sort()); expect((await client.listTools()).tools).toEqual(listed); }, 180_000); diff --git a/cloudflare/mcp.ts b/cloudflare/mcp.ts index d8a7d40..a51effb 100644 --- a/cloudflare/mcp.ts +++ b/cloudflare/mcp.ts @@ -27,7 +27,7 @@ export async function handleCloudMcp(request: Request, service: Pick ({ resources: [resource] })); - server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [{ uriTemplate: "artifact://project?workspace={workspace}&kind={kind}&name={name}", name: "Artifact or script project", description: "Read a complete artifact or script project in the authenticated library.", mimeType: "application/json" }] })); + server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [{ uriTemplate: "artifact://project?workspace={workspace}&kind={kind}&name={name}{&library_scope}", name: "Artifact or script project", description: "Read a complete artifact or script project in the authenticated personal or team library.", mimeType: "application/json" }] })); server.setRequestHandler(ReadResourceRequestSchema, async ({ params }) => { if (params.uri.startsWith("artifact://")) { const { kind, ...selection } = parseArtifactResourceUri(params.uri); diff --git a/cloudflare/ownership.test.ts b/cloudflare/ownership.test.ts index 9a3fe07..33ac51f 100644 --- a/cloudflare/ownership.test.ts +++ b/cloudflare/ownership.test.ts @@ -52,6 +52,40 @@ test("gallery subscriptions isolate owners and workspaces and follow moved physi for (const kind of ["artifact", "script"] as const) { const binding = kind === "artifact" ? "LIBRARIES" : "SCRIPTS"; + test(`${kind}: Trash revokes admission and links, preserves revisions and blocks overwrites until restored by the owner`, async () => { + const workspace = `trash-${kind}`, name = "retained", original = { libraryKey: "trash-alice", workspace, kind, name }; + await call(binding, original.libraryKey, "writeDraft", { workspace, name, source: "retained source" }); + if (kind === "artifact") await call(binding, original.libraryKey, "recordServe", { workspace, name, source: "retained source", runtime: "test", mode: "live", initial_state: {} }); + const version = (await call(binding, original.libraryKey, "history", { workspace, name }))[0]; + const sourceBefore = await call(binding, original.libraryKey, "version", { workspace, id: version.version_id }); + await links("move", original, "team"); + const selection = { ...original, libraryKey: "team" }; + const generation = await links("begin", original); + await links("commit", original, generation, { slug: `trash-${kind}`, access: "public" }); + await expect(links("setTrashed", original, true)).rejects.toThrow("not found"); + expect(await links("setTrashed", selection, true)).toMatchObject({ trashed: true }); + expect(await links("catalog", { ...selection, method: "listDrafts" })).toEqual([]); + expect(await links("catalog", { ...selection, method: "history" })).toEqual([]); + await expect(links("admit", selection)).rejects.toThrow("not found"); + await expect(links("admit", { ...selection, name: undefined, version_id: version.version_id })).rejects.toThrow("not found"); + await expect(links("admit", selection, true)).rejects.toThrow("in Trash"); + await expect(links("move", selection, "trash-alice")).rejects.toThrow("not found"); + expect(await links("get", `trash-${kind}`)).toBeNull(); + expect(await links("find", original)).toBeNull(); + expect(await links("commit", original, generation, { slug: `trash-${kind}`, access: "public" })).toBeNull(); + await expect(links("check", { ...original, name: "another" }, `trash-${kind}`)).rejects.toThrow("already in use"); + expect(await links("trash", { libraryKey: "trash-alice" })).toEqual([]); + expect(await links("trash", { libraryKey: "team", workspace })).toMatchObject([{ workspace, kind, name }]); + await expect(links("setTrashed", original, false)).rejects.toThrow("not found"); + expect(await links("setTrashed", selection, false)).toMatchObject({ trashed: false }); + expect(await links("trash", { libraryKey: "team", workspace })).toEqual([]); + expect(await links("admit", selection)).toEqual(original); + expect(await call(binding, original.libraryKey, "version", { workspace, id: version.version_id })).toEqual(sourceBefore); + expect(await links("catalog", { ...selection, method: "history" })).toEqual([version]); + expect(await links("get", `trash-${kind}`)).toMatchObject({ access: "public", name }); + // A compile admitted before deletion cannot replace the restored link. + expect(await links("commit", original, generation, { slug: "late", access: "public" })).toBeNull(); + }); test(`${kind}: moves preserve physical source, projects, history, state/secrets and links; both directions enforce ownership`, async () => { const workspace = `preserve-${kind}`, name = "existing", original = { libraryKey: "alice", workspace, kind, name }; const source = "original source\n", project = { files: { "helper.ts": "export const value = 7;" }, dependencies: {}, lock: {} }; diff --git a/cloudflare/ownership.ts b/cloudflare/ownership.ts index f18edea..70b68c2 100644 --- a/cloudflare/ownership.ts +++ b/cloudflare/ownership.ts @@ -13,6 +13,7 @@ export type CatalogRow = { }; type OwnershipRow = { physical: string; owner: string; workspace: string; kind: string; name: string }; +export type TrashedItem = { workspace: string; kind: "artifact" | "script"; name: string; deleted_at: string }; const physicalKey = (target: ArtifactTarget) => JSON.stringify([target.libraryKey, target.workspace, target.kind, target.name]); const fromKey = (value: string): ArtifactTarget => { const [libraryKey, workspace, kind, name] = JSON.parse(value); @@ -42,6 +43,7 @@ export class LibraryOwnership { physical text primary key, owner text not null, workspace text not null, kind text not null, name text not null, unique(owner,workspace,kind,name) )`); + sql.exec("create table if not exists library_trash (physical text primary key, deleted_at text not null)"); } // Only admission and destination reservation are serialized. Source compilation, @@ -61,6 +63,10 @@ export class LibraryOwnership { return this.sql.exec<{ owner: string }>("select owner from library_ownership where physical=?", physicalKey(target)).toArray()[0]?.owner ?? target.libraryKey; } + isTrashed(target: ArtifactTarget): boolean { + return this.sql.exec("select 1 from library_trash where physical=?", physicalKey(target)).toArray().length !== 0; + } + private mapped(selection: LibrarySelection & { name: string }): ArtifactTarget | null { const row = this.sql.exec("select * from library_ownership where owner=? and workspace=? and kind=? and name=?", selection.libraryKey, selection.workspace, selection.kind, selection.name).toArray()[0]; if (row) return fromKey(row.physical); @@ -84,7 +90,7 @@ export class LibraryOwnership { return (await library.history({ workspace: target.workspace, name: target.name, limit: 1 })).length !== 0; } - private async resolve(selection: LibrarySelection): Promise { + private async resolve(selection: LibrarySelection, includeTrash = false): Promise { if (selection.kind !== "artifact" && selection.kind !== "script") throw new Error("kind must be artifact or script"); const name = selection.name === undefined ? undefined : ownershipName(selection.kind, selection.name); if (selection.version_id) { @@ -94,7 +100,7 @@ export class LibraryOwnership { try { version = await library.version({ workspace: selection.workspace, id: selection.version_id }); } catch (error) { if (!(error instanceof Error) || !error.message.includes("not found")) throw error; continue; } const target = { libraryKey, workspace: selection.workspace, kind: selection.kind, name: version.name }; - if (this.owner(target) !== selection.libraryKey) continue; + if (this.owner(target) !== selection.libraryKey || !includeTrash && this.isTrashed(target)) continue; if (name !== undefined && name !== target.name) throw new Error("Version does not belong to this item"); return target; } @@ -102,7 +108,7 @@ export class LibraryOwnership { } if (name === undefined) throw new Error("name or version_id is required"); const target = this.mapped({ ...selection, name }); - if (!target) throw new Error("item not found in this library"); + if (!target || !includeTrash && this.isTrashed(target)) throw new Error("item not found in this library"); return target; } @@ -115,11 +121,40 @@ export class LibraryOwnership { // item with that logical name gets fresh storage instead of overwriting it. libraryKey: `item:${crypto.randomUUID()}`, workspace: selection.workspace, kind: selection.kind, name, }; + if (this.isTrashed(target)) throw new Error("Item is in Trash; restore it before editing or choose a new name"); this.register(target, selection.libraryKey); return target; }); } + setTrashed(selection: LibrarySelection & { name: string }, trashed: boolean, suspend: (target: ArtifactTarget, trashed: boolean) => Promise) { + return this.serialized(async () => { + const target = await this.resolve(selection, true); + if (!await this.exists(target)) throw new Error("item not found in this library"); + this.register(target, selection.libraryKey); + if (trashed) { + // Revoke admission first. If suspension fails, retrying deletion repairs + // it without making the item accessible again. + this.sql.exec("insert or ignore into library_trash values(?,?)", physicalKey(target), new Date().toISOString()); + await suspend(target, true); + } else { + if (!this.isTrashed(target)) throw new Error("Item is not in Trash"); + await suspend(target, false); + this.sql.exec("delete from library_trash where physical=?", physicalKey(target)); + } + return target; + }); + } + + trash(input: { libraryKey: string; workspace?: string; offset?: number }): TrashedItem[] { + const offset = input.offset ?? 0; + if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("invalid pagination"); + return this.sql.exec(`select o.workspace,o.kind,o.name,t.deleted_at from library_trash t + join library_ownership o on o.physical=t.physical + where o.owner=? and (? is null or o.workspace=?) + order by t.deleted_at desc,o.workspace,o.kind,o.name limit 100 offset ?`, input.libraryKey, input.workspace ?? null, input.workspace ?? null, offset).toArray(); + } + admitRemix(selection: LibrarySelection, newName: string): Promise { return this.serialized(async () => { const source = await this.resolve(selection); @@ -128,7 +163,7 @@ export class LibraryOwnership { const target = { ...source, name }; // Provenance references live in the source library. Keep the existing remix // transaction there, reserving its new logical identity before execution. - if ((logical && await this.exists(logical)) || await this.exists(target) || this.owner(target) !== source.libraryKey && this.owner(target) !== selection.libraryKey) throw new Error("Destination name is already in use; choose a new name"); + if ((logical && await this.exists(logical)) || this.isTrashed(target) || await this.exists(target) || this.owner(target) !== source.libraryKey && this.owner(target) !== selection.libraryKey) throw new Error("Destination name is already in use; choose a new name"); const claimed = this.sql.exec("select * from library_ownership where owner=? and workspace=? and kind=? and name=?", selection.libraryKey, selection.workspace, selection.kind, name).toArray()[0]; if (claimed && claimed.physical !== physicalKey(target)) throw new Error("Destination name is already in use; choose a new name"); this.register(target, selection.libraryKey); @@ -166,7 +201,8 @@ export class LibraryOwnership { for (const row of rows) { const rowName = input.method === "listDrafts" && input.kind === "artifact" ? row.id! : row.name; if (name !== undefined && rowName !== name) continue; - if (this.owner({ libraryKey, workspace: row.workspace, name: rowName, kind: input.kind }) !== input.libraryKey) continue; + const target = { libraryKey, workspace: row.workspace, name: rowName, kind: input.kind }; + if (this.owner(target) !== input.libraryKey || this.isTrashed(target)) continue; result.push(row as CatalogRow); accepted++; } if (rows.length < 100) break; diff --git a/cloudflare/script-backend.ts b/cloudflare/script-backend.ts index beb68ea..a20dc76 100644 --- a/cloudflare/script-backend.ts +++ b/cloudflare/script-backend.ts @@ -113,6 +113,7 @@ export class ScriptBackend extends DurableObject { return this.execute({...active, request}); } private async execute(input: ScriptRequest): Promise { + if (await this.ctx.storage.get("trashed")) throw new Error("Item is in Trash"); // The namespace identity is deliberately part of the loader key: identical // scripts owned by different libraries must never share secrets or globals. const hash=this.cacheKey(input); @@ -153,14 +154,22 @@ export class ScriptBackend extends DurableObject { if (!Number.isSafeInteger(limit)||limit<1||limit>100) throw new Error("limit must be between 1 and 100"); return this.ctx.storage.sql.exec("select * from execution_runs order by started_at desc,rowid desc limit ?",limit).toArray(); } + async setTrashed(trashed: boolean) { + if (trashed) await this.ctx.storage.put("trashed", true); + if (await this.schedule()) await this.schedule({ action: "pause" }); + await this.ctx.storage.deleteAlarm(); + if (!trashed) await this.ctx.storage.put("trashed", false); + } async schedule(input: {identity?: ScheduleIdentity; interval_seconds?: number; cron?: string; timezone?: string; request?: ArtifactHttpRequest; action?: "get"|"set"|"pause"|"resume"|"run_now"} = {}): Promise { const action=input.action??"get"; + if (action !== "get" && action !== "pause" && await this.ctx.storage.get("trashed")) throw new Error("Item is in Trash"); if (!["get","set","pause","resume","run_now"].includes(action)) throw new Error("Unknown schedule action"); if (action==="get") return await this.ctx.storage.get("schedule")??null; if (action==="run_now") { await this.runScheduled("manual"); return await this.schedule(); } let next: ScriptSchedule|null=null; await this.ctx.storage.transaction(async storage => { const previous=await storage.get("schedule"); + if (action !== "pause" && await storage.get("trashed")) throw new Error("Item is in Trash"); if (action!=="set" && !previous) throw new Error("No schedule configured"); if (action==="set") { const timing=normalizeTiming(input); @@ -176,6 +185,7 @@ export class ScriptBackend extends DurableObject { return next; } async alarm() { + if (await this.ctx.storage.get("trashed")) return; // Commit the next occurrence before invoking user code. Alarm redelivery // then sees a future timestamp and cannot replay this occurrence. let due: {id:string;started:number;schedule:ScriptSchedule}|undefined; diff --git a/cloudflare/script-guide.ts b/cloudflare/script-guide.ts index b375313..7d944ab 100644 --- a/cloudflare/script-guide.ts +++ b/cloudflare/script-guide.ts @@ -1,5 +1,9 @@ export const HOSTED_SCRIPT_GUIDE = `Hosted standalone scripts and root URLs +One MCP connection supports both personal and team libraries. Pass library_scope:"private" or library_scope:"team" on any tool call; omit it to use the connection URL's default. The selector always uses authenticated ownership and cannot select another user's personal library. In artifacts_query/artifacts_execute, put the selector inside the discovered operation's arguments. + +artifact_delete and script_delete move owned items to recoverable Trash. URLs stop working, file-transfer grants are revoked and schedules pause. Names and slugs stay reserved; history, data, files and secrets are preserved. artifacts_trash lists this library's Trash (all:true includes every workspace), and artifact_undelete/script_undelete restore an item. Restored schedules stay paused until explicitly resumed. Requests admitted before deletion may finish. + Artifacts and scripts coexist. Use artifact_write with an optional slug, or artifact_link({kind:"artifact",name,slug,access}), to give an artifact a self-contained root URL. Script writes choose slug explicitly or default to the script name. Root slugs are unique across this deployment, use lowercase letters/numbers/hyphens, and exclude application routes. No namespace or type prefix is added. Names and slugs are separate. Private is the default; access:"public" enables external callers. Management remains authenticated. Create arbitrary Workers-compatible TypeScript using script_write({name,slug,contents,access}). Export default {async fetch(request, env, ctx) { return new Response("Hello"); }}. A handler can return HTML, JSON, binary responses, or streams, receive webhooks, or call external APIs through fetch. Supported runtime bindings are env.secrets (explicitly configured per-script values) and env.sql (persistent native SQLite, with .exec and bound parameters). ScriptEnv and ExecutionContext types are available. No application credentials are implicitly supplied. Workers-supported cloudflare: and node: imports are available. Use project.files for relative helper modules and project.dependencies for exact npm versions. The entrypoint accepts at most 256 KiB UTF-8; helpers support 64 files and 1 MiB total. The service resolves dependencies when their declarations change and archives integrity-verified source and types. Source compilation and execution use the archived lock without downloading dependencies. diff --git a/cloudflare/tool-contract.ts b/cloudflare/tool-contract.ts index 4c43777..fcc1e25 100644 --- a/cloudflare/tool-contract.ts +++ b/cloudflare/tool-contract.ts @@ -164,7 +164,22 @@ addScriptTool("app_secrets", "Atomically set/delete app secrets (null deletes); addScriptTool("app_move", "Move an app between authenticated private/team libraries without changing its physical app identity, resources, secrets or revisions. The former owner immediately loses future management and HTTP access.", { ...appName, library: { type: "string", enum: ["private", "team"] } }, ["name", "library"]); for (const tool of CLOUD_MCP_TOOLS.filter(tool => ["app_guide", "app_list", "app_read", "app_history"].includes(tool.name))) tool.annotations = { readOnlyHint: true }; -for (const tool of [...ARTIFACTS_WORKSPACE_TOOLS, ARTIFACTS_FILE_TOOL]) if (!CLOUD_MCP_TOOLS.some(existing => existing.name === tool.name)) CLOUD_MCP_TOOLS.push(tool as Tool); +for (const kind of ["artifact", "script"]) { + for (const action of ["delete", "undelete"]) { + addScriptTool(`${kind}_${action}`, action === "delete" + ? "Move an owned item to recoverable Trash. Disables its URL and pauses schedules. Preserves source history, state, data, files and secrets. The name and URL stay reserved. In-flight executions may finish." + : "Restore an item from Trash to its library with its original URL, history, data, files and secrets. Schedules remain paused until explicitly resumed.", { + name: { type: "string", minLength: 1 }, workspace: { type: "string", minLength: 1 }, + }, ["name"]); + CLOUD_MCP_TOOLS[CLOUD_MCP_TOOLS.length - 1]!.annotations = { readOnlyHint: false, destructiveHint: false, openWorldHint: false }; + } +} +addScriptTool("artifacts_trash", "List recoverable Trash in the selected library. Only the authenticated personal library or accessible team library is visible. Paginated, 100 entries per response.", { + ...pageProperty, all: { type: "boolean", default: false, description: "Include every workspace in this library." }, +}); +CLOUD_MCP_TOOLS[CLOUD_MCP_TOOLS.length - 1]!.annotations = { readOnlyHint: true }; + +for (const tool of [...ARTIFACTS_WORKSPACE_TOOLS, ARTIFACTS_FILE_TOOL]) if (!CLOUD_MCP_TOOLS.some(existing => existing.name === tool.name)) CLOUD_MCP_TOOLS.push(structuredClone(tool) as Tool); for (const tool of CLOUD_MCP_TOOLS) { if (["artifact_write", "artifact_edit", "artifact_import", "artifact_remix", "artifact_restore"].includes(tool.name)) tool.inputSchema.properties!.preview = { type: "boolean", default: true, description: "Deliver the preview after saving and activating a valid revision. False suppresses delivery only; it does not roll back the mutation." }; if (["artifact_request", "artifact_files", "artifact_read", "artifact_export"].includes(tool.name)) tool.inputSchema.properties!.workspace = { type: "string", minLength: 1, description: "Workspace in the authenticated library. Does not grant access." }; @@ -173,5 +188,15 @@ for (const tool of CLOUD_MCP_TOOLS) { } applyToolVisibility(CLOUD_MCP_TOOLS); CLOUD_MCP_TOOLS.push(galleryToolDefinition(CLOUD_MCP_TOOLS)); -CLOUD_MCP_TOOLS.push(...ARTIFACTS_SETTINGS_TOOLS); -CLOUD_MCP_TOOLS.push(...DISCOVERY_TOOLS); +CLOUD_MCP_TOOLS.push(...structuredClone(ARTIFACTS_SETTINGS_TOOLS)); +CLOUD_MCP_TOOLS.push(...structuredClone(DISCOVERY_TOOLS)); + +// Select a library for this call, never by changing shared connection state. +// Clone schemas borrowed from local tools before adding the hosted selector. +for (const tool of CLOUD_MCP_TOOLS) { + tool.inputSchema = structuredClone(tool.inputSchema); + tool.inputSchema.properties = { ...tool.inputSchema.properties, library_scope: { + type: "string", enum: ["private", "team"], + description: "Personal (private) or shared team library for this call. One MCP connection supports both. Omit to use the connection URL's default. Never grants access to another user's personal library.", + } }; +} diff --git a/cloudflare/worker.test.ts b/cloudflare/worker.test.ts index 21b391b..afd1ba0 100644 --- a/cloudflare/worker.test.ts +++ b/cloudflare/worker.test.ts @@ -117,6 +117,87 @@ test("hosted conditional saves return conflicts and invalid drafts can be correc expect((await write(good, null)).response.status).toBe(409); }); +test("gallery deletion moves an artifact to recoverable Trash, revokes files and preserves data with paused schedules", async () => { + const name = "trash-roundtrip"; + const call = async (tool: string, args: Record = {}) => { + const result = await client.callTool({ name: tool, arguments: { name, ...args } }); + expect(result.isError, JSON.stringify(result.content)).not.toBe(true); + return result.structuredContent ?? payload(result); + }; + const written = await call("artifact_write", { contents: counterClient, server: counterServer, access: "public", slug: name }); + const versionId = written.preview.artifact.versionId; + await call("artifact_secrets", { secrets: { RETAINED: "kept" } }); + const counter = async (method = "GET") => { + const result = await call("artifact_request", { request: { path: "/counter", method } }); + return JSON.parse(atob(result.response.body)); + }; + expect((await counter("POST")).value).toBe(1); + await call("artifact_schedule", { action: "set", interval_seconds: 3600, request: { path: "/counter", method: "POST" } }); + const upload = (await call("artifact_files", { request: { operation: "upload", name: "kept.txt", size: 4, type: "text/plain" } })).result; + expect((await fetch(upload.url, { method: "PUT", body: "kept" })).status).toBe(201); + const download = (await call("artifact_files", { request: { operation: "download", id: upload.file.id } })).result; + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.goto(`${origin}/?workspace=test&name=${name}`); + await page.getByRole("heading", { name, exact: true }).waitFor(); + await page.getByRole("button", { name: "More actions", exact: true }).click(); + await page.getByRole("menuitem", { name: "Delete", exact: true }).click(); + const panel = page.getByRole("form", { name: "Move to Trash", exact: true }); + await panel.getByRole("button", { name: "Cancel", exact: true }).click(); + expect((await fetch(`${origin}/${name}`)).status).toBe(200); + await page.getByRole("button", { name: "More actions", exact: true }).click(); + await page.getByRole("menuitem", { name: "Delete", exact: true }).click(); + await panel.getByRole("button", { name: "Move to Trash", exact: true }).click(); + await page.getByRole("button", { name, exact: true }).waitFor({ state: "detached" }); + expect((await fetch(`${origin}/${name}`)).status).toBe(404); + expect((await fetch(download.url)).status).toBe(404); + expect((await client.callTool({ name: "artifact_read", arguments: { name } })).isError).toBe(true); + expect((await client.callTool({ name: "artifact_open", arguments: { version_id: versionId } })).isError).toBe(true); + expect((await client.callTool({ name: "artifact_write", arguments: { name, contents: source } })).isError).toBe(true); + await page.getByRole("button", { name: "Trash", exact: true }).click(); + const trash = page.getByRole("region", { name: "Trash", exact: true }); + await trash.getByRole("button", { name: `Restore ${name}`, exact: true }).waitFor(); + await page.setViewportSize({ width: 390, height: 844 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + if (process.env.GALLERY_SCREENSHOT_DIR) await page.screenshot({ path: `${process.env.GALLERY_SCREENSHOT_DIR}/trash.png` }); + await trash.getByRole("button", { name: `Restore ${name}`, exact: true }).click(); + await trash.getByText("Trash is empty.", { exact: true }).waitFor(); + await trash.getByRole("button", { name: "Back to library", exact: true }).click(); + await page.getByRole("button", { name, exact: true }).waitFor(); + } finally { await browser.close(); } + expect((await counter()).value).toBe(1); + expect((await call("artifact_schedule")).schedule).toMatchObject({ paused: true, next_run_at: null }); + expect((await call("artifact_secrets")).names).toEqual(["RETAINED"]); + expect((await call("artifact_files", { request: { operation: "list" } })).result.files).toMatchObject([{ id: upload.file.id }]); + const restoredDownload = (await call("artifact_files", { request: { operation: "download", id: upload.file.id } })).result; + expect(await (await fetch(restoredDownload.url)).text()).toBe("kept"); + expect(payload(await client.callTool({ name: "artifact_version", arguments: { version_id: versionId } })).source).toBe(counterClient); + expect((await fetch(`${origin}/${name}`)).status).toBe(200); +}, 60_000); + +test("one MCP connection trashes and restores team scripts while keeping schedules paused", async () => { + const name = "team-trash-handler", library_scope = "team"; + const call = async (tool: string, args: Record = {}) => { + const result = await client.callTool({ name: tool, arguments: { name, library_scope, ...args } }); + expect(result.isError, JSON.stringify(result.content)).not.toBe(true); + return payload(result); + }; + await call("script_write", { contents: 'export default { fetch() { return new Response("retained"); } };' }); + await call("script_secrets", { secrets: { RETAINED: "kept" } }); + await call("script_schedule", { action: "set", interval_seconds: 3600 }); + await call("script_delete"); + expect((await fetch(`${origin}/${name}`)).status).toBe(404); + expect((await client.callTool({ name: "script_run", arguments: { name, library_scope, request: { path: "/" } } })).isError).toBe(true); + const trash = payload(await client.callTool({ name: "artifacts_query", arguments: { operation: "artifacts_trash", arguments: { library_scope } } })); + expect(trash.items).toMatchObject([{ name, kind: "script" }]); + expect(payload(await client.callTool({ name: "artifacts_trash", arguments: {} })).items).toEqual([]); + await call("script_undelete"); + expect(await (await fetch(`${origin}/${name}`)).text()).toBe("retained"); + expect((await call("script_schedule")).schedule).toMatchObject({ paused: true, next_run_at: null }); + expect((await call("script_secrets")).names).toEqual(["RETAINED"]); +}); + test("official HTTP MCP client lists contracts, writes, edits, restores and retrieves raw history", async () => { const tools = (await client.listTools()).tools; expect(tools.some(tool => tool.name === "artifact_write")).toBe(true); @@ -375,6 +456,24 @@ test("verified users have isolated private libraries and can collaborate in the expect((await teamBob.callTool({ name: "artifact_edit", arguments: { name: "shared", edits: [{ old_text: "Hosted artifact", new_text: "Team artifact" }] } })).isError).not.toBe(true); expect(payload(await teamAlice.callTool({ name: "artifact_read", arguments: { name: "shared" } })).source).toContain("Team artifact"); expect((await alice.callTool({ name: "artifact_read", arguments: { name: "shared" } })).isError).toBe(true); + // One authenticated connection can select either library independently on + // concurrent calls. A team URL remains a compatible default, not a boundary. + const [personalRead, teamRead] = await Promise.all([ + alice.callTool({ name: "artifact_read", arguments: { name: "secret", library_scope: "private" } }), + alice.callTool({ name: "artifact_read", arguments: { name: "shared", library_scope: "team" } }), + ]); + expect(payload(personalRead).source).toBe(source); + expect(payload(teamRead).source).toContain("Team artifact"); + expect(payload(await teamAlice.callTool({ name: "artifact_read", arguments: { name: "secret", library_scope: "private" } })).source).toBe(source); + expect((await teamBob.callTool({ name: "artifact_read", arguments: { name: "secret", library_scope: "private" } })).isError).toBe(true); + const mentions = await alice.callTool({ name: "artifacts_mentions", arguments: { query: "shared", library_scope: "team" } }); + const mention = (mentions.content as { uri: string }[])[0]!; + expect(mention.uri).toContain("library_scope=team"); + const mentioned = await alice.readResource({ uri: mention.uri }); + expect(JSON.parse((mentioned.contents[0] as { text: string }).text).source).toContain("Team artifact"); + const scopedPreview = await alice.callTool({ name: "artifact_open", arguments: { name: "shared", library_scope: "team" } }); + expect(scopedPreview._meta?.artifact).toMatchObject({ library_scope: "team" }); + expect((await bob.callTool({ name: "artifact_delete", arguments: { name: "secret", library_scope: "private" } })).isError).toBe(true); for (const path of [`/api/source?version=${privateVersion}`, `/gallery/preview?version=${privateVersion}`, `/api/source?name=secret&subject=alice`, `/api/source?library=team&version=${privateVersion}`]) { expect((await fetch(address + path + "&workspace=test", { headers: { "Cf-Access-Jwt-Assertion": bobToken } })).status).toBe(404); } @@ -969,7 +1068,13 @@ test("MCP workspace tools admit cross-workspace previews and resources while sup const uri = (mentions.structuredContent as any).items[0].uri; const resource = await client.readResource({ uri }); expect(JSON.parse((resource.contents[0] as { text: string }).text)).toMatchObject({ workspace: "workspace-tools", name: "workspace-preview", source }); - await expect(team.readResource({ uri })).rejects.toThrow(); + // A resource carries its library selector. The same person's connection + // can read it even when that connection defaults to the team library. + const fromTeamConnection = await team.readResource({ uri }); + expect(JSON.parse((fromTeamConnection.contents[0] as { text: string }).text)).toMatchObject({ source }); + const teamUri = new URL(uri); + teamUri.searchParams.set("library_scope", "team"); + await expect(team.readResource({ uri: teamUri.href })).rejects.toThrow(); expect((await team.callTool({ name: "artifacts_preview", arguments: { workspace: "workspace-tools", version_id: versionId } })).isError).toBe(true); const opened = await client.callTool({ name: "artifacts_preview", arguments: { workspace: "workspace-tools", version_id: versionId } }); expect(opened.isError).not.toBe(true); @@ -1013,7 +1118,11 @@ test("MCP gallery editor keeps full projects, workspace routing, CAS errors and const team = new Client({ name: "gallery-denied", version: "1" }); await team.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=${workspace}&library=team`), { fetch })); try { - await expect(team.readResource({ uri: scriptUri })).rejects.toThrow(); + const fromTeamConnection = await team.readResource({ uri: scriptUri }); + expect(JSON.parse((fromTeamConnection.contents[0] as { text: string }).text)).toMatchObject({ source: script }); + const teamUri = new URL(scriptUri); + teamUri.searchParams.set("library_scope", "team"); + await expect(team.readResource({ uri: teamUri.href })).rejects.toThrow(); expect((await team.callTool({ name: "artifacts_source", arguments: { workspace, kind: "script", name } })).isError).toBe(true); const deniedCatalog = await team.callTool({ name: "artifacts_library", arguments: {} }); expect((deniedCatalog._meta as any).workspace.gallery.artifacts.some((item: { name: string }) => item.name === name)).toBe(false); diff --git a/cloudflare/workspace-tools.ts b/cloudflare/workspace-tools.ts index eb946c7..726ff21 100644 --- a/cloudflare/workspace-tools.ts +++ b/cloudflare/workspace-tools.ts @@ -20,7 +20,7 @@ export async function workspaceTool(service: { workspace: string; productUrl?: s page = await service.gallery(true, page.nextOffset); matches.push(...page.artifacts.filter(item => item.name.toLocaleLowerCase().includes(query))); } - const links = matches.slice(0, 100).map(item => ({ type: "resource_link" as const, uri: artifactResourceUri(item, item.working ? undefined : item.versions[0]?.id), name: item.name, title: `${item.name} · ${item.kind ?? "artifact"} · ${item.workspace}`, mimeType: "application/json", description: `${item.kind === "script" ? "Script" : "React artifact"} project and revision in workspace ${item.workspace}` })); + const links = matches.slice(0, 100).map(item => ({ type: "resource_link" as const, uri: artifactResourceUri(item, item.working ? undefined : item.versions[0]?.id, gallery.libraryScope), name: item.name, title: `${item.name} · ${item.kind ?? "artifact"} · ${item.workspace}`, mimeType: "application/json", description: `${item.kind === "script" ? "Script" : "React artifact"} project and revision in workspace ${item.workspace}` })); return { content: links, structuredContent: { items: links } }; } return workspaceResult({ view, workspace: gallery.workspace, items, nextOffset: gallery.nextOffset ?? null, gallery: { ...gallery, artifacts: items }, ...(service.productUrl ? { productUrl: service.productUrl } : {}) }); diff --git a/docs/authentication.md b/docs/authentication.md index 5f0644d..6f2035d 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -40,15 +40,18 @@ credentials. The default local host needs no sign-in. and sign in with one of its configured providers. The deployment administrator must admit your account. Choose **My library** or **Team library** in the gallery. -**Coding agent:** add a remote HTTP MCP server using one of these URLs: +**Coding agent:** add one remote HTTP MCP server: ```text https://artifacts.example.com/mcp -https://artifacts.example.com/mcp?library=team&workspace=default ``` -The first selects your personal library and the default workspace. The second -selects the shared team library. Use the same hostname as the gallery. Your MCP +The connection can use both libraries. Select **Personal library** or **Team +library** in the MCP gallery, or pass `library_scope: "private"` or +`library_scope: "team"` on tool calls. The selector is independent for each call; +it never selects another person's personal library. Existing URLs such as +`/mcp?library=team&workspace=default` remain supported as defaults when a call +omits the selector. Use the same hostname as the gallery. Your MCP client must support OAuth: connect, complete provider sign-in in the browser, and approve access when prompted. Client registration is handled by the OAuth flow; you do not need to supply a client ID or secret for a supported public client. diff --git a/docs/cloudflare.md b/docs/cloudflare.md index e153bbb..32d9805 100644 --- a/docs/cloudflare.md +++ b/docs/cloudflare.md @@ -79,8 +79,9 @@ application and user policy, enable managed OAuth for MCP, and configure ### Connect users and agents Open the deployed hostname for the gallery. Add `https:///mcp` -to an OAuth-capable MCP client for a personal library, or append -`?library=team&workspace=` for the shared team library. See +to an OAuth-capable MCP client. One connection supports both libraries: select +one in the MCP gallery or use `library_scope: "private"` / `"team"` on tool calls. +Existing `?library=team&workspace=` URLs remain supported as defaults. See [connection instructions and supported credentials](authentication.md#connect-to-an-existing-deployment). Before switching modes on an existing deployment, read [the identity migration note](authentication.md#changing-authentication-modes-and-upgrading). diff --git a/e2e/gallery-onboarding.test.ts b/e2e/gallery-onboarding.test.ts index 2e4bf45..666c882 100644 --- a/e2e/gallery-onboarding.test.ts +++ b/e2e/gallery-onboarding.test.ts @@ -18,7 +18,7 @@ test("empty hosted library supplies copyable deployment MCP URL and starter prom const page = await context.newPage(); await page.goto(new URL("?library=team", server.url).href); const url = await page.getByLabel("Deployment MCP URL").inputValue(); - expect(url).toBe(new URL("/mcp?workspace=my-project&library=team", server.url).href); + expect(url).toBe(new URL("/mcp?workspace=my-project", server.url).href); const before = await page.locator(".agent-onboarding").boundingBox(); await page.getByRole("button", { name: "Copy MCP URL", exact: true }).click(); await page.getByText("MCP URL copied", { exact: true }).waitFor(); @@ -26,6 +26,7 @@ test("empty hosted library supplies copyable deployment MCP URL and starter prom const prompt = await page.getByLabel("Starter prompt", { exact: true }).inputValue(); expect(prompt).toContain("artifact_guide"); expect(prompt).toContain("private access"); + expect(prompt).toContain('library_scope: "team"'); await page.getByRole("button", { name: "Copy starter prompt", exact: true }).click(); await page.getByText("Starter prompt copied", { exact: true }).waitFor(); expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(prompt); diff --git a/e2e/mcp-app.test.ts b/e2e/mcp-app.test.ts index 29618dc..3eb3570 100644 --- a/e2e/mcp-app.test.ts +++ b/e2e/mcp-app.test.ts @@ -793,6 +793,55 @@ test("workspace keeps search and pinned revisions while its sandboxed preview re expect(errors).toEqual([]); }, 30_000); +test("one MCP gallery switches libraries and scopes source and Trash actions without another connection", async () => { + const meta = await resultFor(INTERACTIVE_ARTIFACT, "scoped"); + meta.artifact.workspace = "research"; + const payload = (scope: "private" | "team", empty = false) => ({ + ...workspaceFor(meta.artifact), gallery: { + workspace: "research", libraryScope: scope, nextOffset: null, + capabilities: { editing: true, links: true, scripts: true, trash: true }, + artifacts: empty ? [] : [{ name: "scoped", key: "scoped", workspace: "research", library_scope: scope, working: true, + versions: [{ id: meta.artifact.versionId, revision: 1, createdAt: "2026-11-02T00:00:00Z", reason: "save", serveCount: 0 }] }], + }, + }); + const personal = payload("private"), team = payload("team"), emptyTeam = payload("team", true); + const { page, app, errors } = await openHost({ proxy: true }); + await page.evaluate(({ meta, personal }) => { + window.mcpHost!.configure({ displayMode: "fullscreen" }); + window.mcpHost!.setServerToolResults({ + artifacts_search: [{ content: [], _meta: { workspace: personal } }], + artifacts_preview: [{ content: [], _meta: { artifact: { ...meta.artifact, library_scope: "private" } } }], + }); + }, { meta, personal }); + await page.evaluate(workspace => window.mcpHost!.sendResult({ content: [], _meta: { workspace } }), personal); + await app.frameLocator(".preview-frame").getByRole("button", { name: "Increment" }).click(); + await app.frameLocator(".preview-frame").getByText("Count: 1", { exact: true }).waitFor(); + await page.waitForFunction(() => window.mcpHost!.serverToolCalls.some(call => call.name === "artifacts_search")); + await page.evaluate(({ meta, team }) => window.mcpHost!.setServerToolResults({ + artifacts_search: [{ content: [], _meta: { workspace: team } }], + artifacts_preview: [{ content: [], _meta: { artifact: { ...meta.artifact, library_scope: "team" } } }], + artifacts_source: [{ content: [], structuredContent: { source: "export default () => null", project: { files: {}, dependencies: {}, lock: {} }, revision_token: "a".repeat(64) } }], + artifacts_tool: [{ content: [], structuredContent: { ok: true } }], + }), { meta, team }); + await app.getByRole("combobox", { name: "Library", exact: true }).selectOption("team"); + await app.frameLocator(".preview-frame").getByText("Count: 0", { exact: true }).waitFor(); + await app.getByRole("tab", { name: "Source", exact: true }).click(); + await app.getByRole("textbox", { name: "scoped.artifact.tsx", exact: true }).waitFor(); + await page.evaluate(emptyTeam => window.mcpHost!.setServerToolResults({ + artifacts_search: [{ content: [], _meta: { workspace: emptyTeam } }], + artifacts_tool: [{ content: [], structuredContent: { ok: true } }], + }), emptyTeam); + await app.getByRole("button", { name: "More actions", exact: true }).click(); + await app.getByRole("menuitem", { name: "Delete", exact: true }).click(); + await app.getByRole("button", { name: "Move to Trash", exact: true }).click(); + await app.getByRole("button", { name: "scoped", exact: true }).waitFor({ state: "detached" }); + const calls = await page.evaluate(() => window.mcpHost!.serverToolCalls); + expect(calls.find(call => call.name === "artifacts_source")?.arguments).toMatchObject({ library_scope: "team", workspace: "research", name: "scoped" }); + expect(calls.find(call => call.name === "artifacts_tool")?.arguments).toEqual({ library_scope: "team", workspace: "research", tool: "artifact_delete", arguments: { name: "scoped" } }); + expect(calls.filter(call => call.name === "artifacts_preview").map(call => call.arguments?.library_scope)).toContain("team"); + expect(errors).toEqual([]); +}, 30_000); + test("OpenAI deep links select a revision through the server before entering its internal route", async () => { const { ROUTING_ARTIFACT } = await import("../src/test/routing"); const meta = await resultFor(ROUTING_ARTIFACT, "linked"); diff --git a/package.json b/package.json index 35975ff..0269b3d 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "src/gallery/source-editor.tsx", "src/gallery/select.tsx", "src/gallery/move.tsx", + "src/gallery/trash.tsx", "src/gallery/theme.ts", "src/gallery/brand.ts", "src/gallery/Geist-OFL.txt", diff --git a/src/gallery/client.tsx b/src/gallery/client.tsx index 0c19e75..84fe29f 100644 --- a/src/gallery/client.tsx +++ b/src/gallery/client.tsx @@ -1,6 +1,7 @@ import { Tabs } from "@base-ui/react/tabs"; import { Menu } from "@base-ui/react/menu"; import { LibraryNavigation } from "./library-navigation"; +import { DeletePanel, TrashPanel } from "./trash"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import { authClient, signInUrl } from "../auth/client-api"; @@ -117,6 +118,8 @@ function App() { const [kindFilter, setKindFilter] = useState("all"); const [showLinks, setShowLinks] = useState(false); const [showMove, setShowMove] = useState(false); + const [showDelete, setShowDelete] = useState(false); + const [showTrash, setShowTrash] = useState(false); const [showImport, setShowImport] = useState(false); const [notice, setNotice] = useState(""); const [sourceVisited, setSourceVisited] = useState(null); @@ -408,6 +411,7 @@ function App() { setMobileDetail(true); setShowLinks(false); setShowMove(false); + setShowDelete(false); setCreatingKind(null); setShowImport(false); setNotice(""); @@ -447,6 +451,7 @@ function App() { setRemixing(false); setShowLinks(false); setShowMove(false); + setShowDelete(false); setMobileDetail(true); } async function createdItem(name: string, kind: "artifact" | "script") { @@ -555,6 +560,10 @@ function App() { {gallery?.workspace ? {gallery.workspace.split(/[\\/]/).filter(Boolean).at(-1) ?? gallery.workspace} : null}
+ {gallery?.capabilities?.trash ? : null} {gallery?.libraryScope ? event.target.select()} /> diff --git a/src/gallery/styles.ts b/src/gallery/styles.ts index 7808ac8..143e858 100644 --- a/src/gallery/styles.ts +++ b/src/gallery/styles.ts @@ -122,6 +122,11 @@ export const galleryStyles = ` .source-form > .script-fields { padding: 12px 16px; border-bottom: 1px solid var(--line); flex-shrink: 0; } .source-actions { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--line); flex-shrink: 0; } .source-actions p { margin: 0; color: var(--muted); } + .trash-panel { overflow: auto; } + .trash-panel > p { margin: 12px 16px; } + .trash-item { flex-wrap: wrap; } + .trash-item > span { flex: 1; min-width: 0; overflow-wrap: anywhere; } + .trash-item > button { flex-shrink: 0; } .gallery-app button.primary-action { background: var(--primary); color: var(--primary-text); border-color: var(--primary); font-weight: 600; } button.primary-action:disabled { opacity: .45; } /* Content actions are outlined; header, tab, and menu controls stay quiet. */ diff --git a/src/gallery/trash.tsx b/src/gallery/trash.tsx new file mode 100644 index 0000000..24fb1a5 --- /dev/null +++ b/src/gallery/trash.tsx @@ -0,0 +1,80 @@ +import { useCallback, useEffect, useState } from "react"; +import { confirmLeavingDrafts, useDrafts } from "./drafts"; +import { useGalleryTransport } from "./transport"; +import type { GalleryArtifact } from "./types"; + +type TrashedItem = { workspace: string; kind: "artifact" | "script"; name: string; deleted_at: string }; +type TrashPage = { items: TrashedItem[]; next_offset: number | null }; +function value(result: unknown): T { return (typeof result === "string" ? JSON.parse(result) : result) as T; } + +export function DeletePanel({ artifact, onCancel, onDeleted }: { + artifact: GalleryArtifact; onCancel: () => void; onDeleted: () => Promise; +}) { + const { tool } = useGalleryTransport(); + const { drafts } = useDrafts(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + return
{ + event.preventDefault(); + if (busy || !confirmLeavingDrafts(drafts)) return; + setBusy(true); setError(""); + try { + await tool(artifact.workspace, `${artifact.kind ?? "artifact"}_delete`, { name: artifact.name }); + await onDeleted(); + } catch (error) { setError(error instanceof Error ? error.message : String(error)); } + finally { setBusy(false); } + }}> +

Move {artifact.name} to Trash?

+

Its link will stop working and schedules will pause. History, data, files and secrets are kept. You can restore it from Trash. Requests already running may finish.

+
+ {error ?

{error}

: null} +
; +} + +export function TrashPanel({ workspace, onRestored, onClose }: { + workspace: string; onRestored: () => Promise; onClose: () => void; +}) { + const { tool } = useGalleryTransport(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const load = useCallback(async () => { + let offset = 0; + const items: TrashedItem[] = []; + do { + const page = value(await tool(workspace, "artifacts_trash", { all: true, offset })); + items.push(...page.items); + if (page.next_offset === null) break; + if (!Number.isSafeInteger(page.next_offset) || page.next_offset <= offset) throw new Error("Invalid Trash pagination"); + offset = page.next_offset; + } while (true); + return items; + }, [workspace, tool]); + useEffect(() => { + let cancelled = false; + void load().then(items => { if (!cancelled) setItems(items); }).catch(error => { + if (!cancelled) setError(error instanceof Error ? error.message : String(error)); + }).finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [load]); + return
+

Trash

+

Restore an item with its original link, history and stored data. Schedules stay paused until you resume them. Names and links are reserved while in Trash.

+ {loading ?

Loading Trash…

: null} + {error ?

{error}

: null} + {!loading && !error && items.length === 0 ?

Trash is empty.

: null} + {items.map(item =>
+ {item.name} · {item.kind} · {item.workspace} + +
)} +
; +} diff --git a/src/gallery/types.ts b/src/gallery/types.ts index 0386c5c..a151f49 100644 --- a/src/gallery/types.ts +++ b/src/gallery/types.ts @@ -1,5 +1,5 @@ export type GalleryVersion = { id: string; revision: number; createdAt: string; reason: string; serveCount: number }; -export type GalleryArtifact = { key: string; name: string; workspace: string; working: boolean; versions: GalleryVersion[]; kind?: "artifact" | "script"; slug?: string; url?: string; access?: "private" | "public"; live?: { id: string; revision: number; revision_token: string }; liveId?: string; draftRevision?: string }; +export type GalleryArtifact = { key: string; name: string; workspace: string; library_scope?: "private" | "team"; working: boolean; versions: GalleryVersion[]; kind?: "artifact" | "script"; slug?: string; url?: string; access?: "private" | "public"; live?: { id: string; revision: number; revision_token: string }; liveId?: string; draftRevision?: string }; export type GalleryWorker = { key: string; kind: "worker"; name: string; workspace: string; provider: string; status: string; revision_token: string }; export type GalleryItem = GalleryArtifact | GalleryWorker; -export type GalleryData = { workspace: string; artifacts: GalleryArtifact[]; workerApps?: GalleryWorker[]; nativeAppProviders?: string[]; libraryScope?: "private" | "team"; nextOffset?: number | null; capabilities?: { scripts?: boolean; links?: boolean; moves?: boolean; nativeApps?: boolean; subscriptions?: boolean; subscriptionTransport?: "events"; editing?: boolean } }; +export type GalleryData = { workspace: string; artifacts: GalleryArtifact[]; workerApps?: GalleryWorker[]; nativeAppProviders?: string[]; libraryScope?: "private" | "team"; nextOffset?: number | null; capabilities?: { scripts?: boolean; links?: boolean; moves?: boolean; trash?: boolean; nativeApps?: boolean; subscriptions?: boolean; subscriptionTransport?: "events"; editing?: boolean } }; diff --git a/src/gallery/workspace.tsx b/src/gallery/workspace.tsx index 27c0d4a..b443799 100644 --- a/src/gallery/workspace.tsx +++ b/src/gallery/workspace.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Tabs } from "@base-ui/react/tabs"; import { Menu } from "@base-ui/react/menu"; -import { DraftProvider, useDrafts, type SourceSnapshot } from "./drafts"; +import { DraftProvider, useDrafts, confirmLeavingDrafts, type SourceSnapshot } from "./drafts"; import { ArtifactSourcePanel, LinkSettings, ScriptPanel, type ScriptView } from "./hosted"; import { LibraryNavigation, type LibraryKindFilter } from "./library-navigation"; import { ProjectEditor } from "./project-editor"; @@ -10,8 +10,9 @@ import { Select } from "./select"; import { galleryStyles } from "./styles"; import { GalleryTransportProvider, useGalleryTransport, type GalleryTransport } from "./transport"; import type { GalleryArtifact, GalleryData } from "./types"; +import { DeletePanel, TrashPanel } from "./trash"; -export type WorkspaceSelection = { name?: string; workspace?: string; kind?: "artifact" | "script"; version_id?: string; route?: string; tab?: "preview" | "source" }; +export type WorkspaceSelection = { name?: string; workspace?: string; library_scope?: "private" | "team"; kind?: "artifact" | "script"; version_id?: string; route?: string; tab?: "preview" | "source" }; export type GalleryWorkspaceProps = { initial: GalleryData; view: "library" | "working"; @@ -22,10 +23,11 @@ export type GalleryWorkspaceProps = { attachedVersionId?: string; selection?: WorkspaceSelection; onSelectionChange?: (selection: WorkspaceSelection) => void; - openProduct?: () => Promise; + openProduct?: (scope?: "private" | "team") => Promise; openLink?: (url: string) => Promise; askCreate?: () => Promise; refreshKey?: number; + onLibraryChange?: (scope: "private" | "team") => Promise; }; /** Catalog pages may contain different revisions of the same project. */ @@ -128,7 +130,7 @@ export function GalleryWorkspace(props: GalleryWorkspaceProps) { return ; } -function Workspace({ initial, view, search, renderPreview, attach, attachedVersionId, selection, onSelectionChange, openProduct, openLink, askCreate, refreshKey }: GalleryWorkspaceProps) { +function Workspace({ initial, view, search, renderPreview, attach, attachedVersionId, selection, onSelectionChange, openProduct, openLink, askCreate, refreshKey, onLibraryChange }: GalleryWorkspaceProps) { const { drafts } = useDrafts(); const initialSelection = selectedGalleryArtifact(initial.artifacts, selection); const [gallery, setGallery] = useState(initial); @@ -149,7 +151,8 @@ function Workspace({ initial, view, search, renderPreview, attach, attachedVersi const [notice, setNotice] = useState(""); const [attaching, setAttaching] = useState(false); const [refresh, setRefresh] = useState(0); - const [disclosure, setDisclosure] = useState<"share" | "remix" | null>(null); + const [disclosure, setDisclosure] = useState<"share" | "remix" | "delete" | null>(null); + const [showTrash, setShowTrash] = useState(false); const [selectionLookup, setSelectionLookup] = useState<{ key: string; state: "loading" | "missing" | "error" }>(); const searchGeneration = useRef(0); const lookupGeneration = useRef(0); @@ -210,7 +213,7 @@ function Workspace({ initial, view, search, renderPreview, attach, attachedVersi const page = await readWorkspaceCatalog(search, searchQuery, () => generation === searchGeneration.current); if (!page) return; setGallery(page); - setCatalog(previous => mergeGalleryItems(previous, page.artifacts)); + setCatalog(previous => searchQuery ? mergeGalleryItems(previous, page.artifacts) : page.artifacts); return page; } catch (error) { if (generation === searchGeneration.current) setError(error instanceof Error ? error.message : String(error)); @@ -240,7 +243,7 @@ function Workspace({ initial, view, search, renderPreview, attach, attachedVersi useEffect(() => { if (activeTab === "source") setSourceVisited(detailId); }, [activeTab, detailId]); useEffect(() => { if (mobileDetail) headingRef.current?.focus(); }, [mobileDetail]); const notifySelection = (item: GalleryArtifact, selectedVersion: string, selectedRoute = "/", selectedTab = activeTab) => { - onSelectionChange?.({ workspace: item.workspace, kind: item.kind ?? "artifact", ...(selectedVersion === "working" ? { name: item.name } : { version_id: selectedVersion }), route: selectedRoute, tab: selectedTab === "preview" ? "preview" : "source" }); + onSelectionChange?.({ workspace: item.workspace, ...(gallery.libraryScope ? { library_scope: gallery.libraryScope } : {}), kind: item.kind ?? "artifact", ...(selectedVersion === "working" ? { name: item.name } : { version_id: selectedVersion }), route: selectedRoute, tab: selectedTab === "preview" ? "preview" : "source" }); }; function selectArtifact(item: GalleryArtifact, preferredTab?: "preview" | "source") { ++lookupGeneration.current; @@ -286,8 +289,13 @@ function Workspace({ initial, view, search, renderPreview, attach, attachedVersi
{error ?

{error}

: null} {notice ?

{notice}

: null} - {!showDetail ?
Library{artifact ? : null}
: null} -
+ {gallery.libraryScope && onLibraryChange ?
+ {gallery.capabilities?.trash ? : null} +
: null} + {!showDetail && !showTrash ?
Library{artifact ? : null}
: null} + {showTrash ? { await load(query); }} onClose={() => setShowTrash(false)} /> :
[...drafts.entries()].some(([key, draft]) => key.startsWith(`${item.key}:`) && draft.dirty)} @@ -320,13 +328,19 @@ function Workspace({ initial, view, search, renderPreview, attach, attachedVersi { setRefresh(value => value + 1); void load(query); }}>Refresh {gallery.capabilities?.links ? setDisclosure("share")}>Share : null} {editing ? setDisclosure("remix")}>Remix : null} + {gallery.capabilities?.trash ? setDisclosure("delete")}>Delete : null} : null} - {openProduct ? void perform(openProduct)}>Open library in browser : null} - {gallery.capabilities?.nativeApps && openProduct ? void perform(openProduct)}>Worker apps : null} + {openProduct ? void perform(() => openProduct(gallery.libraryScope))}>Open library in browser : null} + {gallery.capabilities?.nativeApps && openProduct ? void perform(() => openProduct(gallery.libraryScope))}>Worker apps : null}
: null}
{attachedRevision ?

Revision {attachedRevision.revision} attached

: attachedVersionId && attachedVersionId === resolvedVersion ?

Revision attached

: null} + {artifact && disclosure === "delete" ?
setDisclosure(null)} onDeleted={async () => { + setCatalog(items => items.filter(item => item.key !== artifact.key)); + setSelectedKey(null); setDisclosure(null); setMobileDetail(false); appliedSelection.current = selectionKey; + await load(query); + }} />
: null} {artifact && resolvedVersion && disclosure === "share" ?
Share {artifact.name}
{ await load(query); }} openLink={openLink} /> @@ -358,7 +372,7 @@ function Workspace({ initial, view, search, renderPreview, attach, attachedVersi } -
+
} ; } diff --git a/src/mcp/app-contract.ts b/src/mcp/app-contract.ts index ca2f605..efa911b 100644 --- a/src/mcp/app-contract.ts +++ b/src/mcp/app-contract.ts @@ -1,5 +1,5 @@ // Bump to refresh cached shells; keep older tool-result payloads readable. -const ARTIFACTS_APP_VERSION = 7; +const ARTIFACTS_APP_VERSION = 8; export const ARTIFACTS_APP_URI = `ui://artifacts/v${ARTIFACTS_APP_VERSION}/viewer.html`; /** Saved chat cards keep their original URI across compatible shell updates. */ @@ -25,5 +25,6 @@ export type ArtifactAppPayload = { plugins?: boolean; files?: boolean; workspace?: string; + library_scope?: "private" | "team"; revision?: number; }; diff --git a/src/mcp/discovery.test.ts b/src/mcp/discovery.test.ts index 34d5f13..e609c54 100644 --- a/src/mcp/discovery.test.ts +++ b/src/mcp/discovery.test.ts @@ -15,10 +15,12 @@ test("every hosted operation can be inspected through the stable nine-tool surfa "artifacts_discover", "artifacts_execute", "artifacts_query", ]); const catalog = operationCatalog(CLOUD_MCP_TOOLS); - expect(catalog).toHaveLength(48); + expect(catalog).toHaveLength(53); const noExecution = async () => { throw new Error("Discovery without docs must not execute anything"); }; const listing = await callDiscoveryTool(CLOUD_MCP_TOOLS, "artifacts_discover", { limit: 50 }, noExecution); - expect(listing.structuredContent).toMatchObject({ total: 48, next_offset: null }); + expect(listing.structuredContent).toMatchObject({ total: 53, next_offset: 50 }); + const lastPage = await callDiscoveryTool(CLOUD_MCP_TOOLS, "artifacts_discover", { offset: 50, limit: 50 }, noExecution); + expect(lastPage.structuredContent).toMatchObject({ total: 53, next_offset: null }); for (const operation of catalog) { const result = await callDiscoveryTool(CLOUD_MCP_TOOLS, "artifacts_discover", { operation: operation.name, include_docs: false }, noExecution); expect(result.structuredContent).toMatchObject({ operation: operation.name, inputSchema: operation.inputSchema }); diff --git a/src/mcp/discovery.ts b/src/mcp/discovery.ts index 821af52..847c1b3 100644 --- a/src/mcp/discovery.ts +++ b/src/mcp/discovery.ts @@ -72,7 +72,8 @@ export function compactToolSurface(tools: readonly Tool[]): Tool[] { }).map(tool => ({ ...tool, _meta: { ...tool._meta, ui: { ...(tool._meta?.ui as Record), visibility: ["app"] } }, })); - return [...direct, ...DISCOVERY_TOOLS, ...appTools]; + const discovery = DISCOVERY_TOOLS.map(tool => tools.find(candidate => candidate.name === tool.name) ?? tool); + return [...direct, ...discovery, ...appTools]; } // An explicit allowlist is intentional: newly added operations default to actions. @@ -81,6 +82,7 @@ const READ_OPERATIONS = new Set([ "artifact_guide", "artifact_list", "artifact_read", "artifact_history", "artifact_version", "artifact_export", "artifact_typecheck", "artifact_runs", "script_guide", "script_list", "script_read", "script_history", "script_version", "script_export", "script_logs", "script_runs", "app_guide", "app_list", "app_read", "app_history", "plugins_list", "plugin_guide", + "artifacts_trash", ]); const MIXED_OPERATIONS: Record = { artifact_schedule: "action omitted or get, with no schedule configuration fields", diff --git a/src/mcp/gallery-contract.ts b/src/mcp/gallery-contract.ts index 1421155..ff17c5d 100644 --- a/src/mcp/gallery-contract.ts +++ b/src/mcp/gallery-contract.ts @@ -6,6 +6,7 @@ export const GALLERY_TOOL_NAMES = [ "artifact_write", "artifact_restore", "artifact_remix", "artifact_import", "artifact_export", "artifact_link", "script_write", "script_restore", "script_remix", "script_import", "script_export", "script_run", "script_logs", "script_schedule", "script_runs", "artifact_schedule", "artifact_runs", "artifact_secrets", "script_secrets", + "artifact_delete", "artifact_undelete", "script_delete", "script_undelete", "artifacts_trash", ] as const; export function galleryToolDefinition(tools: readonly Tool[]): Tool { diff --git a/src/mcp/workspace-contract.ts b/src/mcp/workspace-contract.ts index 272a106..6be5184 100644 --- a/src/mcp/workspace-contract.ts +++ b/src/mcp/workspace-contract.ts @@ -59,16 +59,17 @@ export const ARTIFACTS_WORKSPACE_TOOLS: Tool[] = [ }, ]; -export function artifactResourceUri(item: Pick, versionId?: string): string { +export function artifactResourceUri(item: Pick, versionId?: string, libraryScope?: "private" | "team"): string { const query = new URLSearchParams({ workspace: item.workspace, kind: item.kind ?? "artifact", name: item.name }); if (versionId) query.set("version_id", versionId); + if (libraryScope) query.set("library_scope", libraryScope); return `artifact://project?${query}`; } /** Resource identity is descriptive; every read must repeat service authorization. */ export function parseArtifactResourceUri(uri: string) { const url = new URL(uri); - const allowed = new Set(["workspace", "kind", "name", "version_id"]); + const allowed = new Set(["workspace", "kind", "name", "version_id", "library_scope"]); if (url.protocol !== "artifact:" || url.hostname !== "project" || url.pathname || url.hash || [...url.searchParams.keys()].some(key => !allowed.has(key) || url.searchParams.getAll(key).length !== 1)) throw new Error("unknown artifact resource"); const workspace = url.searchParams.get("workspace"); @@ -77,7 +78,9 @@ export function parseArtifactResourceUri(uri: string) { if (!workspace || !name || (kind !== "artifact" && kind !== "script")) throw new Error("invalid artifact resource"); const version_id = url.searchParams.get("version_id"); if (version_id === "") throw new Error("invalid artifact version"); - return { workspace, kind, name, ...(version_id ? { version_id } : {}) } as { workspace: string; kind: "artifact" | "script"; name: string; version_id?: string }; + const library_scope = url.searchParams.get("library_scope"); + if (library_scope !== null && library_scope !== "private" && library_scope !== "team") throw new Error("invalid library scope"); + return { workspace, kind, name, ...(version_id ? { version_id } : {}), ...(library_scope ? { library_scope } : {}) } as { workspace: string; kind: "artifact" | "script"; name: string; version_id?: string; library_scope?: "private" | "team" }; } export function workspaceResult(payload: ArtifactWorkspacePayload) { diff --git a/src/runtime/mcp-app.ts b/src/runtime/mcp-app.ts index bc5fd2e..4b691bb 100644 --- a/src/runtime/mcp-app.ts +++ b/src/runtime/mcp-app.ts @@ -168,7 +168,7 @@ function theme(kind?: string) { async function serverRequest(artifact: ArtifactAppPayload, request: ArtifactHttpRequest): Promise { const result = await app.callServerTool({ name: "artifact_request", - arguments: { version_id: artifact.versionId, ...(artifact.workspace ? { workspace: artifact.workspace } : {}), request }, + arguments: { version_id: artifact.versionId, ...(artifact.workspace ? { workspace: artifact.workspace } : {}), ...(artifact.library_scope ? { library_scope: artifact.library_scope } : {}), request }, }); if (result.isError) { const message = result.content?.filter(item => item.type === "text").map(item => item.text).join("\n"); @@ -208,14 +208,14 @@ function clear() { updateDisplay(); } -type CompactSelection = { versionId?: string; eventId?: string; workspace?: string }; +type CompactSelection = { versionId?: string; eventId?: string; workspace?: string; library_scope?: "private" | "team" }; type CompactToolResult = { artifact?: CompactSelection; preview?: { artifact?: CompactSelection }; appPreview?: ArtifactAppPayload; appWorkspace?: ArtifactWorkspacePayload }; async function openSelection(selection: ArtifactSelection) { if (fileEditor?.hasUnsavedChanges()) throw new Error("Save or discard the file draft before opening another view."); if (!host.capabilities().serverTools) throw new Error("Browsing artifacts is unavailable in this host."); selectedArtifact = selection; - if (workspaceView) { workspaceView.update({ selection }); return; } + if (workspaceView) { await workspaceView.update({ selection }); return; } const request = ++opening; const artifact = await fetchArtifactPreview(app, selection); if (request !== opening) return; @@ -246,7 +246,7 @@ function createArtifactBridge(artifact: ArtifactAppPayload, route?: string, prev bridgeVersions.set(bridge, artifact.versionId); if (capabilities.modelContext) bridge.onModelContext = async view => { const epoch = contextEpoch; - const snapshot = view === null ? undefined : { artifact: { name: artifact.name, workspace: artifact.workspace, version_id: artifact.versionId, revision: artifact.revision }, view }; + const snapshot = view === null ? undefined : { artifact: { name: artifact.name, workspace: artifact.workspace, version_id: artifact.versionId, revision: artifact.revision, ...(artifact.library_scope ? { library_scope: artifact.library_scope } : {}) }, view }; const result = await host.updateContext(snapshot ? { structuredContent: snapshot, content: [{ type: "text", text: JSON.stringify(snapshot), _meta: { "openai/title": artifact.name } }], @@ -263,7 +263,7 @@ function createArtifactBridge(artifact: ArtifactAppPayload, route?: string, prev if (artifact.server === true && capabilities.serverTools) bridge.onRequest = request => serverRequest(artifact, request); if (artifact.files === true && capabilities.serverTools) { bridge.onFileRequest = async request => { - const result = await app.callServerTool({ name: "artifact_files", arguments: { version_id: artifact.versionId, ...(artifact.workspace ? { workspace: artifact.workspace } : {}), request } }); + const result = await app.callServerTool({ name: "artifact_files", arguments: { version_id: artifact.versionId, ...(artifact.workspace ? { workspace: artifact.workspace } : {}), ...(artifact.library_scope ? { library_scope: artifact.library_scope } : {}), request } }); if (result.isError) throw new Error(result.content?.filter(item => item.type === "text").map(item => item.text).join("\n") || "Artifact file request failed"); if (!result.structuredContent || !Object.hasOwn(result.structuredContent, "result")) throw new Error("Artifact file result was missing"); return result.structuredContent.result; @@ -284,7 +284,7 @@ function createArtifactBridge(artifact: ArtifactAppPayload, route?: string, prev } function renderArtifact(artifact: ArtifactAppPayload, route?: string, preserve = false) { - const previous = preserve && activeArtifact?.name === artifact.name && activeArtifact?.workspace === artifact.workspace ? hostWindow.__artifacts : undefined; + const previous = preserve && activeArtifact?.name === artifact.name && activeArtifact?.workspace === artifact.workspace && activeArtifact?.library_scope === artifact.library_scope ? hostWindow.__artifacts : undefined; clear(); activeArtifact = artifact; const bridge = createArtifactBridge(artifact, route, previous); @@ -304,10 +304,10 @@ function renderArtifact(artifact: ArtifactAppPayload, route?: string, preserve = } async function renderWorkspacePreview(container: HTMLElement, item: GalleryArtifact, version: string, route?: string) { - const selection = { workspace: item.workspace, ...(version === "working" ? { name: item.name } : { version_id: version }) }; + const selection = { workspace: item.workspace, ...(item.library_scope ? { library_scope: item.library_scope } : {}), ...(version === "working" ? { name: item.name } : { version_id: version }) }; const artifact = await fetchArtifactPreview(app, selection); if (!container.isConnected) return () => {}; - const key = JSON.stringify([item.key, version]); + const key = JSON.stringify([item.library_scope, item.key, version]); const previous = previewState.get(key); const bridge = createArtifactBridge(artifact, previous?.route?.path ?? route, previous); const preview = mountMcpPreview(container, artifact.js, bridge, message => { status.textContent = message; }); @@ -324,7 +324,7 @@ async function attachWorkspaceArtifact(item: GalleryArtifact, version: string) { await bridge.onModelContext({ ...bridge.modelContext, route: bridge.route?.path ?? "/" }); return; } - const artifact = await fetchArtifactPreview(app, { workspace: item.workspace, ...(version === "working" ? { name: item.name } : { version_id: version }) }); + const artifact = await fetchArtifactPreview(app, { workspace: item.workspace, ...(item.library_scope ? { library_scope: item.library_scope } : {}), ...(version === "working" ? { name: item.name } : { version_id: version }) }); const temporary = createArtifactBridge(artifact); try { if (!temporary.onModelContext) throw new Error("Adding context is unavailable in this chat"); @@ -355,8 +355,10 @@ function showWorkspace(workspace: ArtifactWorkspacePayload) { const result = await app.openLink({ url }); if (result.isError) throw new Error("The host could not open the link"); }, - ...(workspace.productUrl ? { openProduct: async () => { - const result = await app.openLink({ url: workspace.productUrl! }); + ...(workspace.productUrl ? { openProduct: async (scope?: "private" | "team") => { + const url = new URL(workspace.productUrl!); + if (scope) url.searchParams.set("library", scope); + const result = await app.openLink({ url: url.href }); if (result.isError) throw new Error("The host could not open the library"); } } : {}), ...(host.capabilities().actions.promptAgent ? { askCreate: async () => { @@ -364,6 +366,7 @@ function showWorkspace(workspace: ArtifactWorkspacePayload) { if (result.isError) throw new Error("The host could not send the request"); } } : {}), }); + void workspaceView.ready.catch(error => { status.textContent = String(error); }); updateDisplay(); } app.ontoolresult = result => { @@ -381,7 +384,7 @@ app.ontoolresult = result => { } const selected = compact?.artifact ?? compact?.preview?.artifact; if (selected?.versionId && host.capabilities().serverTools) { - void openSelection({ workspace: selected.workspace, version_id: selected.versionId, event_id: selected.eventId }).catch(error => { status.textContent = String(error); }); + void openSelection({ workspace: selected.workspace, version_id: selected.versionId, event_id: selected.eventId, ...(selected.library_scope ? { library_scope: selected.library_scope } : {}) }).catch(error => { status.textContent = String(error); }); return; } const workspace = (result._meta?.workspace ?? compact?.appWorkspace) as ArtifactWorkspacePayload | undefined; diff --git a/src/runtime/mcp-workspace.ts b/src/runtime/mcp-workspace.ts index e625926..e36c862 100644 --- a/src/runtime/mcp-workspace.ts +++ b/src/runtime/mcp-workspace.ts @@ -9,7 +9,7 @@ import { GalleryToolError, type GalleryTransport, type MutationResult } from ".. import type { SourceSnapshot } from "../gallery/drafts"; import { editableProject } from "../gallery/project-editor"; -export type ArtifactSelection = { name?: string; workspace?: string; version_id?: string; event_id?: string; route?: string; kind?: "artifact" | "script"; tab?: "preview" | "source" }; +export type ArtifactSelection = { name?: string; workspace?: string; library_scope?: "private" | "team"; version_id?: string; event_id?: string; route?: string; kind?: "artifact" | "script"; tab?: "preview" | "source" }; /** Deep links contain authenticated selectors, never file-transfer credentials. */ export function parseArtifactDeepLink(value: string): ArtifactSelection | null { @@ -23,8 +23,10 @@ export function parseArtifactDeepLink(value: string): ArtifactSelection | null { const route = url.searchParams.get("route") ?? "/"; const kind = url.searchParams.get("kind"); const tab = url.searchParams.get("tab"); + const library_scope = url.searchParams.get("library_scope"); + if (library_scope !== null && library_scope !== "private" && library_scope !== "team") return null; if ((!name && !version_id) || !route.startsWith("/") || route.startsWith("//") || route.includes("\\") || (kind && !["artifact", "script"].includes(kind)) || (tab && !["preview", "source"].includes(tab))) return null; - return { name: version_id ? undefined : name, workspace, version_id, route, ...(kind ? { kind: kind as "artifact" | "script" } : {}), ...(tab ? { tab: tab as "preview" | "source" } : {}) }; + return { name: version_id ? undefined : name, workspace, version_id, route, ...(library_scope ? { library_scope } : {}), ...(kind ? { kind: kind as "artifact" | "script" } : {}), ...(tab ? { tab: tab as "preview" | "source" } : {}) }; } export async function fetchArtifactPreview(app: App, selection: ArtifactSelection): Promise { @@ -56,16 +58,17 @@ function resultValue(result: Awaited>) { return value; } -export function createGalleryTransport(app: App): GalleryTransport { +export function createGalleryTransport(app: App, scope?: "private" | "team"): GalleryTransport { + const library = scope ? { library_scope: scope } : {}; return { async tool(workspace, tool, args) { - return resultValue(await app.callServerTool({ name: "artifacts_tool", arguments: { workspace, tool, arguments: args } })); + return resultValue(await app.callServerTool({ name: "artifacts_tool", arguments: { workspace, tool, arguments: args, ...library } })); }, async loadSource(sourceUrl, signal) { signal?.throwIfAborted(); const params = new URL(sourceUrl, "https://artifacts.invalid").searchParams; const selection = params.get("version_id") ? { version_id: params.get("version_id")! } : { name: params.get("name")! }; - const result = await app.callServerTool({ name: "artifacts_source", arguments: { workspace: params.get("workspace")!, kind: params.get("kind") === "script" ? "script" : "artifact", ...selection } }); + const result = await app.callServerTool({ name: "artifacts_source", arguments: { workspace: params.get("workspace")!, kind: params.get("kind") === "script" ? "script" : "artifact", ...selection, ...library } }); signal?.throwIfAborted(); const snapshot = resultValue(result) as SourceSnapshot; if (typeof snapshot?.source !== "string" || !snapshot.project) throw new Error("The server returned an incomplete project snapshot"); @@ -79,22 +82,49 @@ export type WorkspaceOptions = Pick { - const result = await app.callServerTool({ name: "artifacts_search", arguments: { query, offset, view: "library" } }); + let libraryRequest = 0; + let disposed = false; + const searchScope = async (query: string, offset: number, selectedScope = scope) => { + const result = await app.callServerTool({ name: "artifacts_search", arguments: { query, offset, view: "library", ...(selectedScope ? { library_scope: selectedScope } : {}) } }); if (result.isError) resultValue(result); const payload = (result._meta?.workspace ?? result.structuredContent?.appWorkspace) as ArtifactWorkspacePayload | undefined; if (!payload) throw new Error("The server returned no library catalog"); return workspaceGallery(payload); }; - const render = () => reactRoot.render(createElement(GalleryWorkspace, { initial: gallery, view: initial.view, transport, search, ...current, refreshKey })); + let transport = createGalleryTransport(app, scope); + const initialScope = scope; + let search = (query: string, offset: number) => searchScope(query, offset, initialScope); + const changeLibrary = async (selectedScope: "private" | "team", selection?: ArtifactSelection) => { + const generation = ++libraryRequest; + const page = await searchScope("", 0, selectedScope); + if (disposed || generation !== libraryRequest) return; + scope = selectedScope; gallery = page; + current = { ...current, selection }; + transport = createGalleryTransport(app, scope); + // Capture the selected scope so requests from the old view cannot change + // libraries halfway through pagination or a save. + search = (query, offset) => searchScope(query, offset, selectedScope); + render(); + }; + const render = () => reactRoot.render(createElement(GalleryWorkspace, { key: gallery.libraryScope, initial: gallery, view: initial.view, transport, search, ...current, + ...(gallery.libraryScope ? { onLibraryChange: changeLibrary } : {}), refreshKey })); render(); + const ready = options.selection?.library_scope && options.selection.library_scope !== scope + ? changeLibrary(options.selection.library_scope, options.selection) : Promise.resolve(); return { - update(value: Partial) { current = { ...current, ...value }; render(); }, + ready, + async update(value: Partial) { + if (value.selection?.library_scope && value.selection.library_scope !== scope) { + await changeLibrary(value.selection.library_scope, value.selection); + return; + } + current = { ...current, ...value }; render(); + }, refresh() { refreshKey++; render(); }, - dispose() { reactRoot.unmount(); }, + dispose() { disposed = true; ++libraryRequest; reactRoot.unmount(); }, }; }