Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions cloudflare/artifact-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
5 changes: 4 additions & 1 deletion cloudflare/auth-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))!;
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions cloudflare/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,15 @@ export class ArtifactBackend extends DurableObject<BackendEnv> {
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 ?? [] } };
Expand All @@ -95,6 +103,7 @@ export class ArtifactBackend extends DurableObject<BackendEnv> {
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);
}
Expand Down Expand Up @@ -131,6 +140,7 @@ export class ArtifactBackend extends DurableObject<BackendEnv> {
}

private async execute(input: BackendCode, request: Request, claimedRun?: { id: string; started: number }): Promise<Response> {
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");
Expand Down
7 changes: 7 additions & 0 deletions cloudflare/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,13 @@ export class ArtifactFiles extends DurableObject<FilesEnv> {
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<FileReply> {
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") {
Expand Down Expand Up @@ -111,6 +117,7 @@ export class ArtifactFiles extends DurableObject<FilesEnv> {
async fetch(request: Request): Promise<Response> {
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];
Expand Down
38 changes: 33 additions & 5 deletions cloudflare/links.ts
Original file line number Diff line number Diff line change
@@ -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<ArtifactBackend>; SCRIPT_BACKENDS?: DurableObjectNamespace<ScriptBackend>; FILE_BACKENDS?: DurableObjectNamespace<ArtifactFiles> };

export type ArtifactTarget = { libraryKey: string; workspace: string; kind: "artifact" | "script"; name: string };
export type LiveRevision = { id: string; revision: number; revision_token: string };
Expand Down Expand Up @@ -42,6 +47,21 @@ export class ArtifactLinks extends DurableObject<unknown> {
return result;
}
catalog(input: Parameters<LibraryOwnership["catalog"]>[0]) { return this.ownership.catalog(input); }
trash(input: Parameters<LibraryOwnership["trash"]>[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); }
Expand All @@ -51,15 +71,18 @@ export class ArtifactLinks extends DurableObject<unknown> {

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;
}
Expand All @@ -69,7 +92,7 @@ export class ArtifactLinks extends DurableObject<unknown> {
}
/** 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) }),
Expand All @@ -84,6 +107,10 @@ export class ArtifactLinks extends DurableObject<unknown> {
}
/** 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;
Expand All @@ -93,7 +120,7 @@ export class ArtifactLinks extends DurableObject<unknown> {
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);
Expand All @@ -107,6 +134,7 @@ export class ArtifactLinks extends DurableObject<unknown> {
}
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.
Expand Down
Loading
Loading