diff --git a/README.md b/README.md index a0e03a3..7c34e38 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,14 @@ remote MCP OAuth flow resolve to the same user identity. See [Cloudflare setup](docs/cloudflare.md) for provider examples, D1 migrations and admission rules. +## Scripts and direct URLs + +Hosted canvases and standalone TypeScript scripts can have user-chosen root URLs +such as `/sales-dashboard` and `/my-handler`. Canvas links open the interactive +UI with its backend; script links invoke a standard Workers HTTP handler. +Manage scripts, secrets, history, logs and link access through the gallery or MCP. +See [Scripts and direct canvas links](docs/scripts.md). + ## Targeted reads and edits Use `read` / `canvas_read` to retrieve only the needed working-source lines, and diff --git a/cloudflare/artifact-routes.ts b/cloudflare/artifact-routes.ts new file mode 100644 index 0000000..f8c2c73 --- /dev/null +++ b/cloudflare/artifact-routes.ts @@ -0,0 +1,100 @@ +import type { Env } from "./worker"; +import { authenticate } from "./auth"; +import { authenticateBetterAuth } from "./better-auth"; +import { CloudCanvasService } from "./service"; +import { readRequestText } from "./http"; +import { canvas_request as validateRequest } from "../dist/cloudflare/tool-validators.js"; +import type { CanvasHttpRequest } from "../src/httpTypes"; +import galleryBridge from "../dist/cloudflare/gallery-request.json"; + +export async function identify(request: Request, env: Env): Promise<{ privateKey: string } | Response> { + if (env.AUTH_MODE && !["access", "better-auth"].includes(env.AUTH_MODE)) return Response.json({ error: "Invalid AUTH_MODE" }, { status: 503 }); + if (env.AUTH_MODE === "better-auth") { + const user = await authenticateBetterAuth(request, env); + if (user instanceof Response) return user; + return { privateKey: JSON.stringify(["private", "better-auth", user.id]) }; + } + const identity = await authenticate(request, env); + return identity instanceof Response ? identity : { privateKey: JSON.stringify(["private", env.ACCESS_TEAM_DOMAIN ?? "local", identity.subject]) }; +} +const scriptJson = (value: unknown) => JSON.stringify(value).replaceAll("<", "\\u003c"); +function html(body: string, policy: string): Response { + return new Response(body, { headers: { "content-type": "text/html; charset=utf-8", "content-security-policy": policy, "cache-control": "private, no-store", "x-content-type-options": "nosniff" } }); +} + +/** Root slug dispatch is separate from management authentication. */ +export async function artifactRoute(request: Request, env: Env): Promise { + const url = new URL(request.url); + const slug = url.pathname.split("/")[1] ?? ""; + if (!slug || !/^[a-z0-9][a-z0-9-]*$/.test(slug)) return null; + const link = await env.LINKS.getByName("deployment").get(slug); + if (!link) return null; + if (link.access === "private") { + const identity = await identify(request, env); + if (identity instanceof Response) return identity; + if (link.libraryKey !== "team" && link.libraryKey !== identity.privateKey) return new Response("Not found", { status: 404 }); + const origin = request.headers.get("Origin"); + if (origin && origin !== url.origin) return new Response("Origin is not allowed", { status: 403 }); + } + if (link.kind === "script") { + const active = await env.SCRIPTS.getByName(link.libraryKey).active({ workspace: link.workspace, name: link.name, hash: link.script_hash }); + if (!active) return new Response("Script has no valid version", { status: 409 }); + // Management session credentials never become inputs to user-authored code. + // Public script Authorization headers remain available for application auth. + const headers = new Headers(request.headers); + headers.delete("cookie"); + headers.delete("cf-access-jwt-assertion"); + headers.delete("cf-access-client-id"); + headers.delete("cf-access-client-secret"); + if (link.access === "private") headers.delete("authorization"); + const input = new Request(request, { headers }); + const backend = env.SCRIPT_BACKENDS.getByName(JSON.stringify([link.libraryKey, link.workspace, link.name])); + const response = await backend.request({ ...active, request: input }); + const output = new Headers(response.headers); + output.delete("set-cookie"); + // HTML returned by scripts cannot acquire the management origin's authority. + output.append("content-security-policy", "sandbox allow-scripts allow-forms"); + output.set("x-content-type-options", "nosniff"); + output.set("cache-control", "private, no-store"); + return new Response(response.body, { status: response.status, statusText: response.statusText, headers: output }); + } + if (!link.version_id) return new Response("Canvas has no valid version", { status: 409 }); + const service = new CloudCanvasService(env.LIBRARIES.getByName(link.libraryKey), link.workspace, env.BACKENDS, link.libraryKey); + if (url.pathname === `/${slug}/_canvas/request`) { + if (request.method !== "POST") return new Response("Method not allowed", { status: 405, headers: { Allow: "POST" } }); + // Only the trusted wrapper can send browser requests, including public canvases. + const origin = request.headers.get("Origin"); + if (origin && origin !== url.origin) return new Response("Origin is not allowed", { status: 403 }); + let input: unknown; + try { input = JSON.parse(await readRequestText(request, 1024 * 1024)); } + catch { return new Response("Invalid request", { status: 400 }); } + if (!validateRequest(input)) return new Response("Invalid request", { status: 400 }); + const args = input as { version_id?: string; request: CanvasHttpRequest }; + // Bind requests to this link's active code; caller cannot choose another artifact. + if (args.version_id !== link.version_id) return Response.json({ error: "Canvas updated; reload the page" }, { status: 409 }); + return Response.json({ response: await service.request({ name: link.name, version_id: link.version_id }, args.request) }, { headers: { "cache-control": "no-store" } }); + } + if (url.pathname !== `/${slug}` && url.pathname !== `/${slug}/`) return new Response("Not found", { status: 404 }); + if (request.method !== "GET" && request.method !== "HEAD") return new Response("Method not allowed", { status: 405, headers: { Allow: "GET, HEAD" } }); + const snapshot = await service.snapshot({ version_id: link.version_id }); + const preview = await service.preview(snapshot); + if (!preview.ok || !preview._meta) return new Response(preview.check, { status: 400 }); + const payload = preview._meta.canvas; + const frame = `
`; + return html(`Canvas`, "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-src 'self' about:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'"); +} diff --git a/cloudflare/auth-flow.test.ts b/cloudflare/auth-flow.test.ts index 743f9b4..3ce1092 100644 --- a/cloudflare/auth-flow.test.ts +++ b/cloudflare/auth-flow.test.ts @@ -55,9 +55,12 @@ beforeAll(async () => { AUTH_DB: { type: "d1", id: "canvas-auth-test", name: "canvas-auth" }, LIBRARIES: { type: "durable-object", worker: "canvas-auth-test", exportName: "CanvasLibrary" }, BACKENDS: { type: "durable-object", worker: "canvas-auth-test", exportName: "CanvasBackend" }, + LINKS: { type: "durable-object", worker: "canvas-auth-test", exportName: "ArtifactLinks" }, + SCRIPTS: { type: "durable-object", worker: "canvas-auth-test", exportName: "ScriptLibrary" }, + SCRIPT_BACKENDS: { type: "durable-object", worker: "canvas-auth-test", exportName: "ScriptBackend" }, LOADER: { type: "worker-loader" }, ASSETS: { type: "assets" }, }, - exports: { CanvasLibrary: { type: "durable-object", storage: "sqlite" }, CanvasBackend: { type: "durable-object", storage: "sqlite" } }, + exports: { ArtifactLinks: { type: "durable-object", storage: "sqlite" }, ScriptLibrary: { type: "durable-object", storage: "sqlite" }, ScriptBackend: { type: "durable-object", storage: "sqlite" }, CanvasLibrary: { type: "durable-object", storage: "sqlite" }, CanvasBackend: { type: "durable-object", storage: "sqlite" } }, assets: { directory: join(import.meta.dir, "../dist/cloudflare/assets"), hasUserWorker: true, runWorkerFirst: true, htmlHandling: "none" }, }, dev: {} }] }); await runtime.ready; diff --git a/cloudflare/compiler.test.ts b/cloudflare/compiler.test.ts index 482cfb8..8e2dba5 100644 --- a/cloudflare/compiler.test.ts +++ b/cloudflare/compiler.test.ts @@ -197,3 +197,87 @@ test("ordinary hooks render and update through canonical and legacy hosted SDK i } finally { await browser.close(); } expect(outbound).toEqual([]); }, 60000); + +test("scripts compile arbitrary Workers handlers and reject missing handlers, invalid TypeScript, and unresolved imports", async () => { + for (const source of [ + 'export default { async fetch(request, env, ctx) { return new Response(await request.text(), {status: 202}); } };', + 'export default { async fetch(request: Request, env: ScriptEnv, ctx: ExecutionContext) { env.sql.exec("select 1"); return Response.json({secretPresent: Boolean(env.secrets.TOKEN)}); } } satisfies ExportedHandler;', + 'import { createHash } from "node:crypto"; export default {fetch() {return new Response(createHash("sha256").update("hello").digest("hex"));}};', + ]) { + const result=await call(source,"compile-script"); + expect(result.diagnostics).toEqual([]); + expect(result.ok).toBe(true); + } + for (const source of [ + 'export default { nope() {return new Response("no");} };', + 'export default { fetch() {return "not a Response";} };', + 'const count: number = "wrong"; export default {fetch() {return new Response(String(count));}};', + 'import missing from "missing-package"; export default {fetch() {return new Response(missing);}};', + ]) expect((await call(source,"compile-script")).ok).toBe(false); + expect((await call('import { H1 } from "sidequery/canvas"; export default function Canvas() { return

After script

; }')).ok).toBe(true); +},60000); + +test("a prebundled third-party Hono handler compiles and serves through the script runtime", async () => { + // This is the supported upload recipe: resolve dependencies locally, keep + // Workers builtins external, and disable semantic checking of generated JS. + const bundle = await Bun.build({ + entrypoints: [new URL("./hono-script-test-fixture.ts", import.meta.url).pathname], + target: "browser", format: "esm", minify: true, + external: ["node:*", "cloudflare:*"], banner: "// @ts-nocheck", + }); + if (!bundle.success) throw new Error(bundle.logs.join("\n")); + expect(bundle.outputs).toHaveLength(1); + const upload = await bundle.outputs[0]!.text(); + expect(new TextEncoder().encode(upload).byteLength).toBeLessThanOrEqual(256 * 1024); + const compiled = await call(upload, "compile-script"); + expect(compiled.diagnostics).toEqual([]); + expect(compiled.ok).toBe(true); + expect(outbound).toEqual([]); + + const host = await Bun.build({ + entrypoints: [new URL("./scripts-test-worker.ts", import.meta.url).pathname], + target: "node", format: "esm", external: ["cloudflare:workers"], + }); + if (!host.success) throw new Error(host.logs.join("\n")); + const execution = new Miniflare({ + cf: false, port: 0, + workers: [{ + config: { + name: "prebundled-script-test", type: "worker", + compatibilityDate: "2026-09-06", compatibilityFlags: ["nodejs_compat"], + manifest: { + mainModule: "test.js", modulesRoot: import.meta.dir, + modules: { "test.js": { type: "esm", contents: await host.outputs[0]!.text() } }, + }, + env: { + SCRIPTS: { type: "durable-object", worker: "prebundled-script-test", exportName: "ScriptLibrary" }, + SCRIPT_BACKENDS: { type: "durable-object", worker: "prebundled-script-test", exportName: "ScriptBackend" }, + LOADER: { type: "worker-loader" }, + }, + exports: { + ScriptLibrary: { type: "durable-object", storage: "sqlite" }, + ScriptBackend: { type: "durable-object", storage: "sqlite" }, + }, + }, + dev: {}, + }], + }); + try { + await execution.ready; + const validation = await execution.dispatchFetch("https://test.invalid/validate", { + method: "POST", body: JSON.stringify({ code: compiled.js, hash: "hono", secrets: {} }), + }); + expect(await validation.json()).toEqual({ ok: true }); + const response = await execution.dispatchFetch("https://test.invalid/run", { + method: "POST", body: JSON.stringify({ code: compiled.js, secrets: {}, body: "third-party\r\npayload" }), + }); + expect(response.status).toBe(201); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(await response.json()).toEqual({ + framework: "hono", segment: "path", query: "two", method: "POST", + header: "yes", body: "third-party\r\npayload", + }); + } finally { + await execution.dispose(); + } +}, 60000); diff --git a/cloudflare/compiler.ts b/cloudflare/compiler.ts index b3f99cc..ce06e7c 100644 --- a/cloudflare/compiler.ts +++ b/cloudflare/compiler.ts @@ -20,6 +20,7 @@ const options: ts.CompilerOptions = { const normalize = (path: string) => path.replace(/^\//, ""); let currentSource = ""; let serverMode = false; +let scriptMode = false; let sourceVersion = 0; const serverOptions: ts.CompilerOptions = { ...options, paths: {}, lib: ["lib.es2022.d.ts"] }; const read = (path: string) => normalize(path) === (serverMode ? serverPath : sourcePath) ? currentSource : compilerFiles[normalize(path)]; @@ -27,7 +28,7 @@ const read = (path: string) => normalize(path) === (serverMode ? serverPath : so // Typechecking is synchronous, so overlapping requests cannot observe another // source while checking. No per-user project/program cache accumulates here. const host: ts.LanguageServiceHost = { - getCompilationSettings: () => serverMode ? serverOptions : options, + getCompilationSettings: () => scriptMode ? { ...serverOptions, noImplicitAny: false, types: ["node"] } : serverMode ? serverOptions : options, getScriptFileNames: () => serverMode ? [`/${serverPath}`, "/node_modules/@cloudflare/workers-types/index.d.ts"] : [`/${sourcePath}`], getScriptVersion: path => [sourcePath, serverPath].includes(normalize(path)) ? String(sourceVersion) : "0", getProjectVersion: () => String(sourceVersion), @@ -55,7 +56,8 @@ export function typecheckCanvasServerSource(source: string): Diagnostic[] { return typecheckSource(source, true); } -function typecheckSource(source: string, server: boolean): Diagnostic[] { +function typecheckSource(source: string, server: boolean, script = false): Diagnostic[] { + scriptMode = script; serverMode = server; currentSource = source; sourceVersion++; @@ -74,7 +76,7 @@ function typecheckSource(source: string, server: boolean): Diagnostic[] { ...(position ? { line: position.line + 1, column: position.character + 1 } : {}), }; }); - if (server) diagnostics.push(...validateCanvasServerClass(program)); + if (server && !script) diagnostics.push(...validateCanvasServerClass(program)); return diagnostics; } @@ -136,7 +138,7 @@ export async function compileCanvasServerSource(source: string) { return result; } -async function queueCompile(source: string, server: boolean) { +async function queueCompile(source: string, server: boolean, script = false) { // esbuild WASM and semantic checking share the isolate's 128 MiB budget. // Serialize builds and bound retained request sources under load. if (pendingCompilations >= 8) return { ok: false, diagnostics: [{ severity: "error" as const, message: "Compiler is busy; retry shortly", file: server ? serverPath : sourcePath }] }; @@ -146,7 +148,7 @@ async function queueCompile(source: string, server: boolean) { // Wait on this request's own timer instead of another request's promise, so // disconnecting a preview cannot cancel the next build's esbuild operations. while (ticket !== activeCompilation) await new Promise(resolve => setTimeout(resolve, 5)); - try { return await (server ? compileServer(source) : compileSource(source)); } + try { return await (script ? compileScript(source) : server ? compileServer(source) : compileSource(source)); } finally { activeCompilation++; pendingCompilations--; } } @@ -208,3 +210,42 @@ async function compileSource(source: string) { return { ok: false, diagnostics: [{ severity: "error" as const, message: error instanceof Error ? error.message : String(error), file: sourcePath }] }; } } + +/** Scripts use Workers globals and can import supported builtins, without the canvas UI restrictions. */ +export function compileScriptSource(source: string) { return queueCompile(source, true, true); } + +async function compileScript(source: string) { + const declarations = '\ninterface ScriptEnv { secrets: Record; sql: SqlStorage }\ntype __CanvasScriptResult = Response | Promise;\n'; + const diagnostics = typecheckSource(source + declarations, true, true); + const program = languageService!.getProgram()!; + const file = program.getSourceFiles().find(item => normalize(item.fileName) === serverPath)!; + const checker = program.getTypeChecker(); + const module = checker.getSymbolAtLocation(file); + const exported = module && checker.getExportsOfModule(module).find(item => item.name === "default"); + const handler = exported && checker.getTypeOfSymbolAtLocation(exported, file); + const fetch = handler && checker.getPropertyOfType(handler, "fetch"); + const signatures = fetch ? checker.getTypeOfSymbolAtLocation(fetch, file).getCallSignatures() : []; + if (!signatures.length) diagnostics.push({severity: "error", file: "script.ts", message: "Script must default-export an object with a fetch(request, env, ctx) handler"}); + const resultType = file.statements.find((node): node is ts.TypeAliasDeclaration => ts.isTypeAliasDeclaration(node) && node.name.text === "__CanvasScriptResult"); + if (resultType && signatures.some(signature => !checker.isTypeAssignableTo(checker.getReturnTypeOfSignature(signature), checker.getTypeFromTypeNode(resultType.type)))) { + diagnostics.push({severity: "error", file: "script.ts", message: "Script fetch handler must return a Response or Promise"}); + } + if (diagnostics.length) return {ok: false, diagnostics: diagnostics.map(item => ({...item, file: item.file === serverPath ? "script.ts" : item.file}))}; + try { + const result = await createWorker({ files: { "script.ts": source }, entryPoint: "script.ts", target: "es2022", sourcemap: false }); + if (result.warnings?.length) throw new Error(result.warnings.join("\n")); + const js = result.modules[result.mainModule]; + if (typeof js !== "string") throw new Error("Script compiler did not return JavaScript"); + const tree = ts.createSourceFile("script.js", js, ts.ScriptTarget.ES2022, false, ts.ScriptKind.JS); + const checkImports = (node: ts.Node) => { + const specifier = ts.isImportDeclaration(node) || ts.isExportDeclaration(node) ? node.moduleSpecifier + : ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword ? node.arguments[0] : undefined; + if (specifier && (!ts.isStringLiteral(specifier) || !/^(node:|cloudflare:)/.test(specifier.text))) { + throw new Error("Script has an unresolved external import; use Workers runtime builtins or self-contained source"); + } + ts.forEachChild(node, checkImports); + }; + checkImports(tree); + return {ok: true, js, diagnostics: [] as Diagnostic[]}; + } catch (error) { return {ok: false, diagnostics: [{severity: "error" as const, file: "script.ts", message: error instanceof Error ? error.message : String(error)}]}; } +} diff --git a/cloudflare/dev.ts b/cloudflare/dev.ts index 9cf1706..4fbbf24 100644 --- a/cloudflare/dev.ts +++ b/cloudflare/dev.ts @@ -1,4 +1,4 @@ -import { compileCanvasSource, compileCanvasServerSource, typecheckCanvasSource, typecheckCanvasServerSource } from "./compiler"; +import { compileCanvasSource, compileCanvasServerSource, compileScriptSource, typecheckCanvasSource, typecheckCanvasServerSource } from "./compiler"; import { readRequestText } from "./http"; import type { ExecutionContext } from "@cloudflare/workers-types"; @@ -8,14 +8,15 @@ export default { async fetch(request: Request, _env: unknown, ctx: ExecutionContext): Promise { const url = new URL(request.url); if (request.method === "GET" && url.pathname === "/health") return Response.json({ ok: true, runtime: navigator.userAgent }); - if (request.method !== "POST" || !["/compile", "/compile-server", "/typecheck", "/typecheck-server"].includes(url.pathname)) return new Response("Not found", { status: 404 }); + if (request.method !== "POST" || !["/compile", "/compile-server", "/compile-script", "/typecheck", "/typecheck-server"].includes(url.pathname)) return new Response("Not found", { status: 404 }); let source: string; try { source = await readRequestText(request, 256 * 1024); } catch (error) { if (error instanceof RangeError) return new Response("Canvas source exceeds 256 KiB", { status: 413 }); throw error; } - const compilation = url.pathname === "/compile-server" ? compileCanvasServerSource(source) + const compilation = url.pathname === "/compile-script" ? compileScriptSource(source) + : url.pathname === "/compile-server" ? compileCanvasServerSource(source) : url.pathname === "/compile" ? compileCanvasSource(source) : undefined; if (compilation) ctx.waitUntil(compilation); return Response.json(compilation ? await compilation diff --git a/cloudflare/hono-script-test-fixture.ts b/cloudflare/hono-script-test-fixture.ts new file mode 100644 index 0000000..3c8d4bb --- /dev/null +++ b/cloudflare/hono-script-test-fixture.ts @@ -0,0 +1,13 @@ +import { Hono } from "hono"; + +const app = new Hono(); +app.post("/chosen/:segment", async context => context.json({ + framework: "hono", + segment: context.req.param("segment"), + query: context.req.query("one"), + method: context.req.method, + header: context.req.header("x-original"), + body: await context.req.text(), +}, 201)); + +export default app; diff --git a/cloudflare/links-test-worker.ts b/cloudflare/links-test-worker.ts new file mode 100644 index 0000000..7012729 --- /dev/null +++ b/cloudflare/links-test-worker.ts @@ -0,0 +1,10 @@ +export { ArtifactLinks } from "./links"; +import type { ArtifactLinks } from "./links"; +export default { + async fetch(request: Request, env: { LINKS: DurableObjectNamespace }) { + const { method, args } = await request.json() as { method: string; args: unknown[] }; + const links = env.LINKS.getByName("deployment") as unknown as Record Promise>; + try { return Response.json({ result: await links[method]!(...args) }); } + catch (error) { return Response.json({ error: String(error) }, { status: 400 }); } + }, +}; diff --git a/cloudflare/links.test.ts b/cloudflare/links.test.ts new file mode 100644 index 0000000..8148afc --- /dev/null +++ b/cloudflare/links.test.ts @@ -0,0 +1,81 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { Miniflare } from "miniflare"; +let runtime: Miniflare; +beforeAll(async () => { + const build = await Bun.build({ entrypoints: [new URL("./links-test-worker.ts", import.meta.url).pathname], target: "node", format: "esm", external: ["cloudflare:workers"] }); + if (!build.success) throw new Error(build.logs.join("\n")); + runtime = new Miniflare({ cf: false, port: 0, workers: [{ config: { + name: "links-test", type: "worker", compatibilityDate: "2026-09-06", + manifest: { mainModule: "test.js", modulesRoot: import.meta.dir, modules: { "test.js": { type: "esm", contents: await build.outputs[0]!.text() } } }, + env: { LINKS: { type: "durable-object", worker: "links-test", exportName: "ArtifactLinks" } }, + exports: { ArtifactLinks: { type: "durable-object", storage: "sqlite" } }, + }, dev: {} }] }); + await runtime.ready; +}); +afterAll(async () => { await runtime?.dispose(); }); +async function call(method: string, ...args: unknown[]): Promise { + const response = await runtime.dispatchFetch("http://localhost/", { method: "POST", body: JSON.stringify({ method, args }) }); + const result = await response.json() as { error?: string; result?: unknown }; + if (!response.ok) throw new Error(result.error); + return result.result; +} +const owner = { libraryKey: "alice", workspace: "default", kind: "script", name: "handler" }; +test("slugs are unique across libraries and kinds; code and access change atomically", async () => { + const generation = await call("begin", owner); + expect(await call("get", "chosen")).toBeNull(); + await call("commit", owner, generation, { slug: "chosen", access: "private", script_hash: "first" }); + await expect(call("check", { ...owner, libraryKey: "bob" }, "chosen")).rejects.toThrow("already in use"); + await expect(call("check", { ...owner, kind: "canvas" }, "chosen")).rejects.toThrow("already in use"); + for (const slug of ["api", "mcp", "gallery", "health", "sign-in", "consent", "bad/path", "Upper", "-bad", "bad-", ""]) { + await expect(call("check", owner, slug)).rejects.toThrow("Slug must"); + } + const pending = await call("begin", owner); + expect(await call("get", "chosen")).toMatchObject({ access: "private", script_hash: "first" }); + await call("commit", owner, pending, { access: "public", script_hash: "second" }); + expect(await call("get", "chosen")).toMatchObject({ access: "public", script_hash: "second" }); +}); +test("newer writes and explicit revocation supersede in-flight URL activations", async () => { + const stale = await call("begin", owner); + const latest = await call("begin", owner); + expect(await call("commit", owner, stale, { access: "public", script_hash: "stale" })).toBeNull(); + await call("commit", owner, latest, { script_hash: "latest" }); + const beforeRevoke = await call("begin", owner); + await call("set", { ...owner, slug: "renamed", access: "private" }); + expect(await call("get", "chosen")).toBeNull(); + expect(await call("get", "renamed")).toMatchObject({ access: "private", script_hash: "latest" }); + expect(await call("commit", owner, beforeRevoke, { access: "public", script_hash: "older" })).toBeNull(); + expect(await call("get", "renamed")).toMatchObject({ access: "private", script_hash: "latest" }); +}); + +test("invalid drafts retain chosen metadata without exposing a URL, and successful activation consumes it", async () => { + const target = { ...owner, name: "Display name with spaces" }; + const invalid = await call("begin", target); + expect(await call("stage", target, invalid, { slug: "readable-slug", access: "public" })).toBe(true); + expect(await call("draft", target)).toEqual({ slug: "readable-slug", access: "public" }); + expect(await call("get", "readable-slug")).toBeNull(); + expect(await call("find", target)).toBeNull(); + const fixed = await call("begin", target); + expect(await call("stage", target, invalid, { slug: "stale-slug" })).toBe(false); + expect(await call("commit", target, invalid, { script_hash: "stale" })).toBeNull(); + expect(await call("stage", target, fixed, {})).toBe(true); + await call("commit", target, fixed, { script_hash: "validated" }); + expect(await call("get", "readable-slug")).toMatchObject({ name: target.name, access: "public", script_hash: "validated" }); + expect(await call("draft", target)).toEqual({}); +}); + +test("explicit private access clears invalid pending public intent before subsequent source corrections", async () => { + const target = { ...owner, kind: "canvas", name: "private-canvas" }; + const first = await call("begin", target); + await call("commit", target, first, { slug: "private-canvas", access: "private", version_id: "first" }); + const invalid = await call("begin", target); + await call("stage", target, invalid, { slug: "pending-canvas", access: "public" }); + expect(await call("get", "private-canvas")).toMatchObject({ access: "private", version_id: "first" }); + expect(await call("get", "pending-canvas")).toBeNull(); + await call("set", { ...target, slug: "private-canvas", access: "private" }); + expect(await call("draft", target)).toEqual({}); + expect(await call("stage", target, invalid, { access: "public" })).toBe(false); + const fixed = await call("begin", target); + await call("commit", target, fixed, { version_id: "fixed" }); + expect(await call("get", "private-canvas")).toMatchObject({ access: "private", version_id: "fixed" }); + expect(await call("get", "pending-canvas")).toBeNull(); +}); diff --git a/cloudflare/links.ts b/cloudflare/links.ts new file mode 100644 index 0000000..3bb479d --- /dev/null +++ b/cloudflare/links.ts @@ -0,0 +1,90 @@ +import { DurableObject } from "cloudflare:workers"; + +export type ArtifactTarget = { libraryKey: string; workspace: string; kind: "canvas" | "script"; name: string }; +export type LinkUpdate = { slug?: string; access?: "private" | "public"; version_id?: string; script_hash?: string }; +export type ArtifactLink = ArtifactTarget & LinkUpdate & { slug: string; access: "private" | "public"; generation: number }; +const reserved = new Set(["api", "mcp", "gallery", "health", "sign-in", "consent"]); +export function validateSlug(value: unknown): string { + if (typeof value !== "string" || !/^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$/.test(value) || reserved.has(value)) { + throw new Error("Slug must be 1–80 lowercase letters, numbers or hyphens and must not be a reserved application route"); + } + return value; +} +function key(target: ArtifactTarget) { return JSON.stringify([target.libraryKey, target.workspace, target.kind, target.name]); } +/** Deployment-wide names; ownership is resolved before accessing any library. */ +export class ArtifactLinks extends DurableObject { + constructor(ctx: DurableObjectState, env: unknown) { + super(ctx, env); + ctx.storage.sql.exec("create table if not exists links (slug text primary key, target text not null unique, value text not null)"); + ctx.storage.sql.exec("create table if not exists generations (target text primary key, generation integer not null)"); + ctx.storage.sql.exec("create table if not exists pending_links (target text primary key, value text not null)"); + } + 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 ? JSON.parse(row.value) : 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 ? JSON.parse(row.value) : null; + } + check(target: ArtifactTarget, candidate: string): string { + const slug = validateSlug(candidate); + const taken = this.get(slug); + if (taken && key(taken) !== key(target)) throw new Error("Slug is already in use"); + return slug; + } + draft(target: ArtifactTarget): LinkUpdate { + const row = this.ctx.storage.sql.exec<{ value: string }>("select value from pending_links where target = ?", key(target)).toArray()[0]; + return row ? JSON.parse(row.value) : {}; + } + /** 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; + const previous = this.draft(target); + const pending = { ...previous, + ...(settings.slug === undefined ? {} : { slug: validateSlug(settings.slug) }), + ...(settings.access === undefined ? {} : { access: settings.access }), + }; + if (pending.access !== undefined && pending.access !== "private" && pending.access !== "public") throw new Error("Invalid access"); + this.ctx.storage.sql.exec("insert into pending_links values (?, ?) on conflict(target) do update set value = excluded.value", key(target), JSON.stringify(pending)); + return true; + } + private generation(target: ArtifactTarget): number { + return this.ctx.storage.sql.exec<{ generation: number }>("select generation from generations where target = ?", key(target)).toArray()[0]?.generation ?? 0; + } + /** Issue before saving/compiling. A later edit supersedes every older activation. */ + begin(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; + } + /** Swap executable revision, URL and access in one storage transaction. */ + commit(target: ArtifactTarget, generation: number, update: LinkUpdate): ArtifactLink | null { + return this.ctx.storage.transactionSync(() => { + if (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); + const access = update.access ?? previous?.access ?? "private"; + if (access !== "private" && access !== "public") throw new Error("Invalid access"); + const link: ArtifactLink = { ...previous, ...target, ...update, slug, access, generation }; + this.ctx.storage.sql.exec("delete from links where target = ?", key(target)); + this.ctx.storage.sql.exec("insert into links values (?, ?, ?)", slug, key(target), JSON.stringify(link)); + this.ctx.storage.sql.exec("delete from pending_links where target = ?", key(target)); + return link; + }); + } + set(input: ArtifactTarget & { slug: string; access?: "private" | "public" }): ArtifactLink { + return this.ctx.storage.transactionSync(() => { + this.check(input, input.slug); + // Explicit metadata actions replace draft intent, including pending public + // access, so a subsequent source fix cannot undo a revocation. + this.ctx.storage.sql.exec("delete from pending_links where target = ?", key(input)); + return this.commit(input, this.begin(input), input)!; + }); + } + activate(target: ArtifactTarget, generation: number, version_id: string): boolean { + if (!this.find(target)) return false; + return this.commit(target, generation, { version_id }) !== null; + } +} diff --git a/cloudflare/script-backend.ts b/cloudflare/script-backend.ts new file mode 100644 index 0000000..03947c3 --- /dev/null +++ b/cloudflare/script-backend.ts @@ -0,0 +1,110 @@ +import { DurableObject } from "cloudflare:workers"; +import { sha256 } from "./library"; + +export type ScriptLog = { timestamp: string; level: string; message: string }; +export type ScriptRequest = {code: string; hash: string; secrets: Record; request: Request}; +type Env = { LOADER: WorkerLoader }; +const runtimeConfig = { compatibilityDate: "2026-09-06", compatibilityFlags: ["nodejs_compat"], limits: { cpuMs: 30000, subRequests: 50 } }; +declare abstract class ScriptFacet extends DurableObject { takeLogs(): ScriptLog[]; } + +// Each script has its own isolate and native SQLite facet. Only the explicit +// script secrets are supplied; the application's environment is never copied. +const logging = `import { env } from "cloudflare:workers"; +let logs = []; +const clean = (value, secrets) => { + let message; + try { message = typeof value === "string" ? value : value instanceof Error ? value.message : JSON.stringify(value); } catch { message = "[unserializable]"; } + message = String(message); + for (const secret of Object.values(secrets)) if (secret) message = message.split(secret).join("[REDACTED]"); + return message.slice(0, 2048); +}; +for (const level of ["log", "info", "warn", "error", "debug"]) console[level] = (...args) => { + logs.push({timestamp: new Date().toISOString(), level, message: args.slice(0, 16).map(value => clean(value, env.secrets)).join(" ").slice(0, 2048)}); + if (logs.length > 100) logs.shift(); +}; +export function takeLogs() { const result = logs; logs = []; return result; } +`; +const adapter = `import { DurableObject } from "cloudflare:workers"; +import { takeLogs } from "./logging.js"; +import handler from "./user.js"; +export class ScriptRuntime extends DurableObject { + async fetch(request) { + try { + const response = await handler.fetch(request, {secrets: this.env.secrets, sql: this.ctx.storage.sql}, { + waitUntil: promise => this.ctx.waitUntil(Promise.resolve(promise).catch(error => {console.error(error);})), + passThroughOnException() {}, + }); + if (!(response instanceof Response)) throw new Error("Script handler must return a Response"); + return response; + } catch (error) { console.error(error); return new Response("Script execution failed", {status: 500}); } + } + takeLogs() { return takeLogs(); } +} +export default { fetch() { return new Response(typeof handler?.fetch === "function" ? "ready" : "invalid", {status: typeof handler?.fetch === "function" ? 200 : 400}); } }; +`; + +export class ScriptBackend extends DurableObject { + private activeKey?: string; + private facet?: Fetcher; + constructor(state: DurableObjectState, env: Env) { + super(state,env); + state.storage.sql.exec("create table if not exists execution_logs(id integer primary key autoincrement,timestamp text not null,level text not null,message text not null)"); + } + private cacheKey(input: Omit) { + return sha256(JSON.stringify([this.ctx.id.toString(), input.code, input.secrets, runtimeConfig, adapter, logging])); + } + private worker(input: Omit) { + const hash=this.cacheKey(input); + return this.env.LOADER.get(hash,async()=>({ + ...runtimeConfig, + mainModule:"adapter.js",modules:{"adapter.js":adapter,"logging.js":logging,"user.js":input.code}, + env:{secrets:input.secrets}, + })); + } + /** Load the module before moving the live revision, without touching its SQLite facet. */ + async validate(input: Omit) { + try { + const response=await this.worker(input).getEntrypoint().fetch("https://script.invalid/validate"); + if (!response.ok) throw new Error("invalid handler"); + return {ok:true}; + } catch { return {ok:false,error:"Script module could not initialize"}; } + } + async request(input: ScriptRequest): Promise { + // 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); + if (this.activeKey!==hash) { + if (this.facet) await this.collect(this.facet); + this.ctx.facets.abort("script", "Script code or secrets updated"); + this.activeKey=hash; + } + const facet=this.ctx.facets.get("script",()=> { + const worker=this.worker(input); + return {class:worker.getDurableObjectClass("ScriptRuntime")}; + }); + this.facet=facet; + const started=Date.now(); + try { + const response=await facet.fetch(input.request); + this.record({timestamp:new Date().toISOString(),level:"request",message:`${input.request.method} ${response.status} ${Date.now()-started}ms`}); + await this.collect(facet); + return response; + } catch { + this.record({timestamp:new Date().toISOString(),level:"error",message:"Script execution failed"}); + return new Response("Script execution failed",{status:500}); + } + } + private record(log: ScriptLog) { + this.ctx.storage.sql.exec("insert into execution_logs(timestamp,level,message) values(?,?,?)",log.timestamp,log.level,log.message.slice(0,2048)); + this.ctx.storage.sql.exec("delete from execution_logs where id not in (select id from execution_logs order by id desc limit 100)"); + } + private async collect(facet: Fetcher) { + try { for (const log of (await facet.takeLogs()).slice(-100)) this.record(log); } catch { /* An aborted isolate cannot supply pending logs. */ } + } + async logs(input: {limit?: number} = {}) { + const limit=input.limit??100; + if (!Number.isSafeInteger(limit) || limit<1 || limit>100) throw new Error("limit must be between 1 and 100"); + if (this.facet) await this.collect(this.facet); + return this.ctx.storage.sql.exec("select timestamp,level,message from execution_logs order by id desc limit ?",limit).toArray(); + } +} diff --git a/cloudflare/script-service-http.test.ts b/cloudflare/script-service-http.test.ts new file mode 100644 index 0000000..646feb5 --- /dev/null +++ b/cloudflare/script-service-http.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { scriptResponse } from "./script-service-http"; + +test("MCP script responses preserve status, headers, and binary bytes", async () => { + const bytes = new Uint8Array([0, 255, 13, 10, 128]); + const result = await scriptResponse(new Response(bytes, { status: 202, headers: { "x-handler": "accepted", "content-type": "application/octet-stream" } }), "POST"); + expect(result.status).toBe(202); + expect(result.headers).toContainEqual(["x-handler", "accepted"]); + expect(Uint8Array.from(atob(result.body!), character => character.charCodeAt(0))).toEqual(bytes); +}); + +test("MCP script response limits cancel streaming bodies before accumulating oversized output", async () => { + let cancelled = false; + const stream = new ReadableStream({ + pull(controller) { controller.enqueue(new Uint8Array(128 * 1024)); }, + cancel() { cancelled = true; }, + }); + await expect(scriptResponse(new Response(stream), "GET")).rejects.toThrow("MCP limit of 256 KiB"); + expect(cancelled).toBe(true); +}); + +test("MCP script responses omit bodies for HEAD and bodyless status codes", async () => { + expect(await scriptResponse(new Response(null, { status: 204 }), "POST")).not.toHaveProperty("body"); + expect(await scriptResponse(new Response(null), "HEAD")).not.toHaveProperty("body"); +}); diff --git a/cloudflare/script-service-http.ts b/cloudflare/script-service-http.ts new file mode 100644 index 0000000..a37700f --- /dev/null +++ b/cloudflare/script-service-http.ts @@ -0,0 +1,21 @@ +import type { CanvasHttpResponse } from "../src/httpTypes"; + +/** MCP transport is bounded; direct script URLs retain ordinary streaming HTTP. */ +export async function scriptResponse(response: Response, method: string): Promise { + const reader = response.body?.getReader(); + let binary = ""; + try { + if (reader) while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + if (binary.length + chunk.value.length > 256 * 1024) { + await reader.cancel(); + throw new Error("Script response body exceeds the MCP limit of 256 KiB; use its URL for larger responses"); + } + for (let i = 0; i < chunk.value.length; i += 8192) binary += String.fromCharCode(...chunk.value.subarray(i, i + 8192)); + } + } finally { reader?.releaseLock(); } + return { status: response.status, statusText: response.statusText, headers: [...response.headers], + ...([204, 205, 304].includes(response.status) || method === "HEAD" ? {} : { body: btoa(binary) }), + }; +} diff --git a/cloudflare/scripts-test-worker.ts b/cloudflare/scripts-test-worker.ts new file mode 100644 index 0000000..74ed45d --- /dev/null +++ b/cloudflare/scripts-test-worker.ts @@ -0,0 +1,19 @@ +export { ScriptLibrary } from "./scripts"; +export { ScriptBackend } from "./script-backend"; +import type { ScriptLibrary } from "./scripts"; +import type { ScriptBackend } from "./script-backend"; +type Env = { SCRIPTS: DurableObjectNamespace; SCRIPT_BACKENDS: DurableObjectNamespace }; +export default { async fetch(request: Request, env: Env) { + const url = new URL(request.url), name=url.searchParams.get("library")??"alice"; + if (url.pathname==="/run") { + const input=await request.json() as {code: string; secrets: Record; body?: string; method?: string}; + return env.SCRIPT_BACKENDS.getByName(name).request({code:input.code,hash:"unused",secrets:input.secrets,request:new Request("https://scripts.invalid/chosen/path?one=two",{method:input.method??"POST",headers:{"x-original":"yes"},body:input.body??"original body"})}); + } + if (url.pathname==="/logs") return Response.json(await env.SCRIPT_BACKENDS.getByName(name).logs({})); + if (url.pathname==="/validate") return Response.json(await env.SCRIPT_BACKENDS.getByName(name).validate(await request.json())); + try { + const method=url.pathname.slice(1); + const stub=env.SCRIPTS.getByName(name) as unknown as RecordPromise>; + return Response.json({result:await stub[method]!(await request.json())}); + } catch(error) {return Response.json({error:error instanceof Error?error.message:String(error)},{status:400});} +} }; diff --git a/cloudflare/scripts.test.ts b/cloudflare/scripts.test.ts new file mode 100644 index 0000000..8068372 --- /dev/null +++ b/cloudflare/scripts.test.ts @@ -0,0 +1,85 @@ +import {afterAll,beforeAll,expect,test} from "bun:test"; +import {Miniflare, Response as MFResponse} from "miniflare"; +let runtime: Miniflare; +beforeAll(async()=>{ + const build=await Bun.build({entrypoints:[new URL("./scripts-test-worker.ts",import.meta.url).pathname],target:"node",format:"esm",external:["cloudflare:workers"]}); + if (!build.success) throw new Error(build.logs.join("\n")); + runtime=new Miniflare({cf:false,port:0,workers:[{config:{name:"scripts-test",type:"worker",compatibilityDate:"2026-09-06",compatibilityFlags:["nodejs_compat"],manifest:{mainModule:"test.js",modulesRoot:import.meta.dir,modules:{"test.js":{type:"esm",contents:await build.outputs[0]!.text()}}},env:{SCRIPTS:{type:"durable-object",worker:"scripts-test",exportName:"ScriptLibrary"},SCRIPT_BACKENDS:{type:"durable-object",worker:"scripts-test",exportName:"ScriptBackend"},LOADER:{type:"worker-loader"}},exports:{ScriptLibrary:{type:"durable-object",storage:"sqlite"},ScriptBackend:{type:"durable-object",storage:"sqlite"}}},dev:{outboundService:{type:"fetcher",handler:()=>new MFResponse("upstream success")}}}]}); + await runtime.ready; +}); +afterAll(async()=>{await runtime?.dispose();}); +async function call(method: string,input: unknown,library="alice") { + const response=await runtime.dispatchFetch(`http://localhost/${method}?library=${library}`,{method:"POST",body:JSON.stringify(input)}); + const value=await response.json() as any; + if (!response.ok) throw new Error(value.error); + return value.result; +} +const identity={workspace:"default",name:"handler"}; +test("versioned drafts, atomic edits, last valid activation, secrets and library isolation",async()=>{ + const first=await call("writeDraft",{...identity,source:"first source"}); + const firstActivation=await call("activate",{...identity,source_hash:first.source_hash,code:"valid-code"}); + const draft=await call("writeDraft",{...identity,source:"invalid draft"}); + expect((await call("active",identity)).code).toBe("valid-code"); + expect((await call("active",{...identity,hash:firstActivation.hash})).code).toBe("valid-code"); + await expect(call("active",{...identity,hash:"missing"})).rejects.toThrow("version not found"); + await expect(call("activate",{...identity,source_hash:first.source_hash,code:"stale-code"})).rejects.toThrow("changed during validation"); + await expect(call("editDraft",{...identity,edits:[{old_text:"invalid",new_text:"valid"},{old_text:"missing",new_text:"x"}]})).rejects.toThrow("no changes written"); + expect((await call("readRange",identity)).source).toBe("invalid draft"); + const history=await call("history",identity); + expect(history).toHaveLength(2); + await call("restore",{workspace:"default",id:history[1].id}); + expect((await call("readRange",identity)).source).toBe("first source"); + expect((await call("active",identity)).code).toBe("valid-code"); + await call("activate",{...identity,source_hash:first.source_hash,code:"new-valid-code"}); + expect((await call("active",identity)).code).toBe("new-valid-code"); + expect((await call("active",{...identity,hash:firstActivation.hash})).code).toBe("valid-code"); + await call("setSecret",{...identity,key:"TOKEN",value:"private-token"}); + expect(await call("secretNames",identity)).toEqual(["TOKEN"]); + expect(JSON.stringify(await call("history",identity))).not.toContain("private-token"); + expect(JSON.stringify(await call("listDrafts",{}))).not.toContain("private-token"); + expect(await call("listDrafts",{},"bob")).toEqual([]); + await expect(call("version",{workspace:"other",id:history[0].id})).rejects.toThrow("not found"); + await expect(call("setSecret",{...identity,key:"TOKEN",value:"x".repeat(4097)})).rejects.toThrow("4 KiB"); + await call("setSecret",{...identity,key:"TOKEN",value:null}); + expect(await call("secretNames",identity)).toEqual([]); +}); +async function run(code: string,secrets: Record={},library="runtime",body="raw\r\nbody") { + return runtime.dispatchFetch(`http://localhost/run?library=${library}`,{method:"POST",body:JSON.stringify({code,secrets,body})}); +} +test("native arbitrary HTTP responses, request fidelity, outbound requests, SQLite and code updates",async()=>{ + const code=`export default {async fetch(request,env,ctx) { + env.sql.exec("create table if not exists count(n integer)"); + env.sql.exec("insert into count values (1)"); + return Response.json({url:request.url,method:request.method,header:request.headers.get("x-original"),body:await request.text(),count:env.sql.exec("select count(*) as n from count").one().n,upstream:await (await fetch("https://upstream.example/data")).text(),bindings:Object.keys(env).sort()},{status:201,headers:{"x-handler":"yes"}}); + }};`; + const response=await run(code); + expect(response.status).toBe(201); + expect(response.headers.get("x-handler")).toBe("yes"); + expect(await response.json()).toMatchObject({url:"https://scripts.invalid/chosen/path?one=two",method:"POST",header:"yes",body:"raw\r\nbody",count:1,upstream:"upstream success",bindings:["secrets","sql"]}); + expect(await (await run(code.replace("status:201","status:202"))).json()).toMatchObject({count:2}); + expect(await (await run(code,{},"other-runtime")).json()).toMatchObject({count:1}); +},30000); +test("secrets update and stay isolated; bounded console/error logs redact literal secret values",async()=>{ + const code=`export default {fetch(request,env) {console.log("token",env.secrets.TOKEN); for(let i=0;i<110;i++) console.info("log",i); console.error(env.secrets.TOKEN); return new Response(env.secrets.TOKEN);}};`; + expect(await (await run(code,{TOKEN:"first-secret"},"secret-a")).text()).toBe("first-secret"); + expect(await (await run(code,{TOKEN:"second-secret"},"secret-a")).text()).toBe("second-secret"); + expect(await (await run(code,{TOKEN:"third-secret"},"secret-b")).text()).toBe("third-secret"); + const logs=await (await runtime.dispatchFetch("http://localhost/logs?library=secret-a")).json() as any[]; + expect(logs.length).toBeLessThanOrEqual(100); + expect(JSON.stringify(logs)).not.toContain("first-secret"); + expect(JSON.stringify(logs)).not.toContain("second-secret"); + expect(JSON.stringify(logs)).toContain("[REDACTED]"); + const failure=await run(`export default {fetch(request,env) {throw new Error(env.secrets.TOKEN)}}`,{TOKEN:"hidden-error"},"errors"); + expect(failure.status).toBe(500); + expect(await failure.text()).toBe("Script execution failed"); +},30000); + +test("module-load validation rejects broken initialization without replacing the live facet",async()=>{ + const code='export default {fetch(request,env) {env.sql.exec("create table if not exists preserved(n integer)"); env.sql.exec("insert into preserved values(1)"); return new Response(String(env.sql.exec("select count(*) as n from preserved").one().n));}}'; + expect(await (await run(code,{},"validation")).text()).toBe("1"); + const response=await runtime.dispatchFetch("http://localhost/validate?library=validation",{method:"POST",body:JSON.stringify({code:'throw new Error("broken"); export default {fetch(){return new Response("bad")}}',hash:"broken",secrets:{}})}); + expect(await response.json()).toMatchObject({ok:false,error:"Script module could not initialize"}); + expect(await (await run(code,{},"validation")).text()).toBe("2"); + const streaming=await run('export default {fetch(){return new Response(new ReadableStream({start(c){c.enqueue(new TextEncoder().encode("streamed"));c.close();}}),{headers:{"content-type":"text/plain"}})}}',{},"streaming"); + expect(await streaming.text()).toBe("streamed"); +},30000); diff --git a/cloudflare/scripts.ts b/cloudflare/scripts.ts new file mode 100644 index 0000000..90276da --- /dev/null +++ b/cloudflare/scripts.ts @@ -0,0 +1,127 @@ +import { DurableObject } from "cloudflare:workers"; +import { sha256, MAX_SOURCE_BYTES, type CanvasEdit } from "./library"; + +const bytes = (value: string) => new TextEncoder().encode(value).byteLength; +function text(value: unknown, label: string, max = 4096): string { + if (typeof value !== "string" || !value.trim() || value.includes("\0") || bytes(value) > max) throw new Error(`invalid ${label}`); + return value.trim(); +} +function key(input: { workspace: string; name: string }) { + const workspace = text(input.workspace, "workspace"); + const name = text(input.name, "script name", 255).replace(/\.script\.ts$/, ""); + if (!name || name === "." || name === ".." || /[/\\]/.test(name)) throw new Error("invalid script name"); + return { workspace, name }; +} +function source(value: unknown): string { + if (typeof value !== "string" || bytes(value) > MAX_SOURCE_BYTES) throw new Error("script source must be a string of at most 256 KiB"); + return value; +} +function page(input: {offset?: number; limit?: number}) { + const offset = input.offset ?? 0, limit = input.limit ?? 100; + if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error("invalid pagination"); + return {offset, limit}; +} +type Row = { workspace: string; name: string; source: string; source_hash: string; updated_at: string; active_code: string | null; active_hash: string | null; secrets: string }; +type VersionRow = { id: string; workspace: string; name: string; revision: number; source: string; source_hash: string; created_at: string; reason: string; restored_from: string | null }; +export class ScriptLibrary extends DurableObject { + private sql: SqlStorage; + constructor(state: DurableObjectState, env: unknown) { + super(state, env); + this.sql = state.storage.sql; + this.sql.exec(`create table if not exists scripts (workspace text not null,name text not null,source text not null,source_hash text not null,updated_at text not null,active_code text,active_hash text,secrets text not null default '{}',primary key(workspace,name))`); + this.sql.exec(`create table if not exists script_versions (id text primary key,workspace text not null,name text not null,revision integer not null,source text not null,source_hash text not null,created_at text not null,reason text not null,restored_from text,unique(workspace,name,revision))`); + this.sql.exec(`create table if not exists compiled_scripts (workspace text not null,name text not null,hash text not null,code text not null,primary key(workspace,name,hash))`); + } + private row(input: {workspace: string; name: string}): Row { + const {workspace,name} = key(input); + const row = this.sql.exec("select * from scripts where workspace=? and name=?",workspace,name).toArray()[0]; + if (!row) throw new Error("script not found"); + return row; + } + private save(input: {workspace: string; name: string; source: string}, reason: string, restoredFrom: string | null = null) { + const {workspace,name} = key(input), code = source(input.source), source_hash = sha256(code), updated_at = new Date().toISOString(); + return this.ctx.storage.transactionSync(() => { + this.sql.exec(`insert into scripts(workspace,name,source,source_hash,updated_at) values(?,?,?,?,?) on conflict(workspace,name) do update set source=excluded.source,source_hash=excluded.source_hash,updated_at=excluded.updated_at`,workspace,name,code,source_hash,updated_at); + const previous = this.sql.exec<{revision: number; source_hash: string}>("select revision,source_hash from script_versions where workspace=? and name=? order by revision desc limit 1",workspace,name).toArray()[0]; + if (previous?.source_hash !== source_hash || reason === "restore") this.sql.exec("insert into script_versions values(?,?,?,?,?,?,?,?,?)",crypto.randomUUID(),workspace,name,(previous?.revision ?? 0)+1,code,source_hash,updated_at,reason,restoredFrom); + return {ok:true,workspace,name,path:`${workspace}/${name}.script.ts`,source:code,source_hash,updated_at}; + }); + } + writeDraft(input: {workspace: string; name: string; source: string}) { return this.save(input,"edit"); } + listDrafts(input: {workspace?: string; offset?: number; limit?: number} = {}) { + const {offset,limit} = page(input), workspace = input.workspace === undefined ? null : text(input.workspace,"workspace"); + return this.sql.exec<{workspace: string; name: string; source_hash: string; updated_at: string; active_hash: string | null}>("select workspace,name,source_hash,updated_at,active_hash from scripts where (? is null or workspace=?) order by workspace,name limit ? offset ?",workspace,workspace,limit,offset).toArray().map(row=>({...row,id:row.name,path:`${row.workspace}/${row.name}.script.ts`,kind:"script" as const})); + } + readRange(input: {workspace: string; name: string; start_line?: number; end_line?: number}) { + const row = this.row(input), lines = row.source.match(/[^\n]*\n|[^\n]+$/g) ?? [""]; + const start = input.start_line ?? 1, end = input.end_line ?? start+199; + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start<1 || endlines.length) throw new Error("invalid line range"); + const actualEnd = Math.min(end,lines.length); + return {workspace:row.workspace,name:row.name,path:`${row.workspace}/${row.name}.script.ts`,source:lines.slice(start-1,actualEnd).join(""),source_hash:row.source_hash,total_lines:lines.length,start_line:start,end_line:actualEnd,next_line:actualEnd100) throw new Error("edits must contain 1 to 100 edits"); + let code = row.source; + for (const edit of input.edits) { + if (!edit || typeof edit.old_text!=="string" || !edit.old_text || typeof edit.new_text!=="string") throw new Error("invalid edit"); + const at = code.indexOf(edit.old_text); + if (at<0) throw new Error("old_text not found; no changes written"); + if (code.indexOf(edit.old_text,at+1)!==-1) throw new Error("old_text is ambiguous; no changes written"); + code = code.slice(0,at)+edit.new_text+code.slice(at+edit.old_text.length); + source(code); + } + return {...this.save({...row,source:code},"edit"),changed:code!==row.source,applied:true,edits_applied:input.edits.length}; + } + activate(input: {workspace: string; name: string; source_hash: string; code: string}) { + const row = this.row(input); + if (row.source_hash!==input.source_hash) throw new Error("script changed during validation; retry"); + if (typeof input.code!=="string" || !input.code || bytes(input.code)>2*1024*1024) throw new Error("invalid compiled script"); + const hash=sha256(input.code); + this.ctx.storage.transactionSync(()=>{ + this.sql.exec("insert into compiled_scripts(workspace,name,hash,code) values(?,?,?,?) on conflict(workspace,name,hash) do nothing",row.workspace,row.name,hash,input.code); + this.sql.exec("update scripts set active_code=?,active_hash=? where workspace=? and name=?",input.code,hash,row.workspace,row.name); + }); + return {ok:true,source_hash:row.source_hash,hash}; + } + /** Internal execution lookup. Never return this response through management APIs. */ + active(input: {workspace: string; name: string; hash?: string}) { + const row = this.row(input); + if (input.hash!==undefined) { + const compiled=this.sql.exec<{code: string; hash: string}>("select code,hash from compiled_scripts where workspace=? and name=? and hash=?",row.workspace,row.name,input.hash).toArray()[0]; + if (!compiled) throw new Error("validated script version not found"); + return {...compiled,secrets:JSON.parse(row.secrets) as Record}; + } + if (!row.active_code || !row.active_hash) throw new Error("script has no validated version"); + return {code:row.active_code,hash:row.active_hash,secrets:JSON.parse(row.secrets) as Record}; + } + secretNames(input: {workspace: string; name: string}) { return Object.keys(JSON.parse(this.row(input).secrets) as object).sort(); } + /** Internal runtime configuration; never expose secret values through management responses. */ + executionSecrets(input: {workspace: string; name: string}) { return JSON.parse(this.row(input).secrets) as Record; } + setSecret(input: {workspace: string; name: string; key: string; value: string | null}) { + const row = this.row(input); + if (typeof input.key!=="string" || !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(input.key) || ["__proto__","constructor","prototype"].includes(input.key)) throw new Error("invalid secret key"); + if (input.value!==null && (typeof input.value!=="string" || bytes(input.value)>4096)) throw new Error("secret exceeds 4 KiB"); + const secrets = JSON.parse(row.secrets) as Record; + if (input.value===null) delete secrets[input.key]; else secrets[input.key]=input.value; + const encoded = JSON.stringify(secrets); + if (Object.keys(secrets).length>32 || bytes(encoded)>32768) throw new Error("script secrets exceed 32 keys or 32 KiB"); + this.sql.exec("update scripts set secrets=? where workspace=? and name=?",encoded,row.workspace,row.name); + return {ok:true,names:Object.keys(secrets).sort()}; + } + history(input: {workspace?: string; name?: string; offset?: number; limit?: number} = {}) { + const {offset,limit}=page(input), workspace=input.workspace===undefined?null:text(input.workspace,"workspace"), name=input.name===undefined?null:key({workspace:workspace??"default",name:input.name}).name; + return this.sql.exec>("select id,workspace,name,revision,source_hash,created_at,reason,restored_from from script_versions where (? is null or workspace=?) and (? is null or name=?) order by created_at desc,rowid desc limit ? offset ?",workspace,workspace,name,name,limit,offset).toArray().map(row=>({...row,version_id:row.id})); + } + version(input: {workspace: string; id: string}) { + const workspace=text(input.workspace,"workspace"), id=text(input.id,"version id"); + const row=this.sql.exec("select * from script_versions where workspace=? and id=?",workspace,id).toArray()[0]; + if (!row) throw new Error("version not found in this workspace"); + return row; + } + restore(input: {workspace: string; id: string}) { + const version=this.version(input); + return {...this.save(version,"restore",version.id),restored:true}; + } +} diff --git a/cloudflare/service.ts b/cloudflare/service.ts index 224b9a2..7e004c7 100644 --- a/cloudflare/service.ts +++ b/cloudflare/service.ts @@ -3,6 +3,13 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { CanvasLibrary, CanvasEdit } from "./library"; import { compileCanvasSource, compileCanvasServerSource, typecheckCanvasSource, typecheckCanvasServerSource } from "./compiler"; import type { CanvasBackend } from "./backend"; +import { nativeRequest } from "./backend"; +import type { ArtifactLinks, ArtifactTarget, LinkUpdate } from "./links"; +import type { ScriptLibrary } from "./scripts"; +import type { ScriptBackend } from "./script-backend"; +import { compileScriptSource } from "./compiler"; +import { scriptResponse } from "./script-service-http"; +export type HostedArtifacts = { links: DurableObjectStub; scripts: DurableObjectStub; scriptBackends: DurableObjectNamespace; origin: string }; import type { CanvasHttpRequest } from "../src/httpTypes"; import { createHash } from "node:crypto"; import { formatCanvasCheck } from "../src/diagnostics"; @@ -21,15 +28,22 @@ type Compiled = Awaited>; export class CloudCanvasService { constructor(readonly library: DurableObjectStub, readonly workspace: string, - readonly backends: DurableObjectNamespace, readonly libraryKey: string) {} + readonly backends: DurableObjectNamespace, readonly libraryKey: string, readonly hosted?: HostedArtifacts) {} async callTool(name: string, args: Record): Promise { + if (name.startsWith("script_")) return this.scriptTool(name, args); + if (name === "artifact_link") return this.artifactLink(args); switch (name) { - case "canvas_guide": return canvasGuideResult(); + case "canvas_guide": { + const guide = canvasGuideResult(); + if (this.hosted) guide.content.push({ type: "text", text: HOSTED_SCRIPT_GUIDE }); + return guide; + } case "canvas_list": { const offset = args.offset as number | undefined ?? 0; const canvases = await this.library.listDrafts({ workspace: this.workspace, offset }); - return text({ canvases, next_offset: canvases.length === 100 ? offset + 100 : null }); + const entries = await Promise.all(canvases.map(async canvas => ({ ...canvas, ...await this.linkDetails(this.target("canvas", canvas.id)) }))); + return text({ canvases: entries, next_offset: canvases.length === 100 ? offset + 100 : null }); } case "canvas_read": return text(await this.library.readRange({ workspace: this.workspace, name: args.name as string, part: args.part as "client" | "server" | undefined, start_line: args.start_line as number | undefined, end_line: args.end_line as number | undefined })); case "canvas_history": { @@ -38,9 +52,21 @@ export class CloudCanvasService { return text({ versions, next_offset: versions.length === 100 ? offset + 100 : null }); } case "canvas_version": return text(await this.library.version({ workspace: this.workspace, id: args.version_id as string, events_offset: args.events_offset as number | undefined })); - case "canvas_write": return this.mutationResult(await this.library.writeDraft({ workspace: this.workspace, name: args.name as string, source: args.contents as string, server_source: args.server as string | null | undefined })); - case "canvas_edit": return this.mutationResult(await this.library.editDraft({ workspace: this.workspace, name: args.name as string, part: args.part as "client" | "server" | undefined, edits: args.edits as CanvasEdit[], expected_hash: args.expected_hash as string | undefined })); - case "canvas_restore": return this.mutationResult(await this.library.restore({ workspace: this.workspace, id: args.version_id as string, runtime })); + case "canvas_write": { + const target = this.target("canvas", args.name as string); + if (args.slug !== undefined) await this.requireHosted().links.check(target, args.slug as string); + const generation = await this.hosted?.links.begin(target); + return this.mutationResult(await this.library.writeDraft({ workspace: this.workspace, name: args.name as string, source: args.contents as string, server_source: args.server as string | null | undefined }), generation, { ...(args.slug === undefined ? {} : { slug: args.slug as string }), ...(args.access === undefined ? {} : { access: args.access as "private" | "public" }) }); + } + case "canvas_edit": { + const generation = await this.hosted?.links.begin(this.target("canvas", args.name as string)); + return this.mutationResult(await this.library.editDraft({ workspace: this.workspace, name: args.name as string, part: args.part as "client" | "server" | undefined, edits: args.edits as CanvasEdit[], expected_hash: args.expected_hash as string | undefined }), generation); + } + case "canvas_restore": { + const version = await this.library.version({ workspace: this.workspace, id: args.version_id as string }); + const generation = await this.hosted?.links.begin(this.target("canvas", version.name)); + return this.mutationResult(await this.library.restore({ workspace: this.workspace, id: args.version_id as string, runtime }), generation); + } case "canvas_typecheck": { const snapshot = await this.snapshot({ name: args.name as string }); const diagnostics = [...typecheckCanvasSource(snapshot.source), ...(snapshot.server_source === null ? [] : typecheckCanvasServerSource(snapshot.server_source))]; @@ -71,7 +97,7 @@ export class CloudCanvasService { async preview(snapshot: Snapshot, compiled?: Compiled) { compiled ??= await this.compile(snapshot); - const base = { ok: compiled.ok, path: snapshot.path, check: formatCanvasCheck(compiled.diagnostics), diagnostics: compiled.diagnostics }; + const base = { ...await this.linkDetails(this.target("canvas", snapshot.name)), ok: compiled.ok, path: snapshot.path, check: formatCanvasCheck(compiled.diagnostics), diagnostics: compiled.diagnostics }; if (!compiled.ok || !compiled.js) return { ...base, ok: false, _meta: undefined }; const { version, event } = await this.library.recordServe({ workspace: snapshot.workspace, name: snapshot.name, source: snapshot.source, @@ -83,18 +109,32 @@ export class CloudCanvasService { return { ...base, ok: true, canvas, _meta: { canvas: { ...canvas, js: compiled.js, state: snapshot.state, server: snapshot.server_source !== null } satisfies CanvasAppPayload } }; } - private async mutationResult(mutation: Mutation): Promise { + private async mutationResult(mutation: Mutation, generation?: number | null, settings: LinkUpdate = {}): Promise { const { source, server_source, state, ...summary } = mutation; + const target = this.target("canvas", mutation.name); + if (generation != null) { + await this.hosted!.links.stage(target, generation, settings); + settings = { ...await this.hosted!.links.draft(target), ...settings }; + } const compiled = await this.compile(mutation); - const payload = { ...summary, applied: true, ok: compiled.ok, check: formatCanvasCheck(compiled.diagnostics), diagnostics: compiled.diagnostics }; + const payload = { ...summary, ...await this.linkDetails(this.target("canvas", mutation.name)), applied: true, ok: compiled.ok, check: formatCanvasCheck(compiled.diagnostics), diagnostics: compiled.diagnostics }; if (!compiled.ok) return { ...text(payload, true), structuredContent: payload }; // The draft is already committed. A delivery error must never look like a // rollback, and compilation must use that exact committed source snapshot. try { const { _meta, ...details } = await this.preview(mutation, compiled); - return { ...text(payload, !details.ok), structuredContent: { ...payload, preview: details }, ...(_meta ? { _meta } : {}) }; + const target = this.target("canvas", mutation.name); + if (generation != null && details.ok && "canvas" in details && (settings.slug !== undefined || await this.hosted!.links.find(target))) { + const link = await this.hosted!.links.commit(target, generation, { ...settings, version_id: details.canvas.versionId }); + if (!link) { + const superseded = { ...payload, ok: false, superseded: true, error: "A newer update superseded this URL activation" }; + return { ...text(superseded, true), structuredContent: superseded }; + } + } + const result = { ...payload, ...await this.linkDetails(target) }; + return { ...text(result, !details.ok), structuredContent: { ...result, preview: { ...details, ...await this.linkDetails(target) } }, ...(_meta ? { _meta } : {}) }; } catch (error) { - const failed = { ...payload, preview: { ok: false, error: error instanceof Error ? error.message : String(error) } }; + const failed = { ...payload, ok: false, preview: { ok: false, error: error instanceof Error ? error.message : String(error) } }; return { ...text(failed, true), structuredContent: failed }; } } @@ -117,6 +157,160 @@ export class CloudCanvasService { return backend.request({ code: compiled.js, hash, request }); } + private requireHosted(): HostedArtifacts { + if (!this.hosted) throw new Error("Standalone artifacts are unavailable in this runtime"); + return this.hosted; + } + + private target(kind: "canvas" | "script", name: string): ArtifactTarget { + return { libraryKey: this.libraryKey, workspace: this.workspace, kind, name: name.trim().replace(kind === "canvas" ? /\.canvas\.tsx$/ : /\.script\.ts$/, "") }; + } + + private async linkDetails(target: ArtifactTarget) { + const link = await this.hosted?.links.find(target); + if (link) return { slug: link.slug, access: link.access, url: `${this.hosted!.origin}/${link.slug}` }; + const pending = await this.hosted?.links.draft(target); + return pending?.slug ? { slug: pending.slug, access: pending.access ?? "private" } : {}; + } + + private async artifactLink(args: Record): Promise { + const hosted = this.requireHosted(); + if (args.kind !== "canvas" && args.kind !== "script") throw new Error("kind must be canvas or script"); + const target = this.target(args.kind, args.name as string); + const existing = await hosted.links.find(target); + if ((target.kind === "canvas" && existing?.version_id) || (target.kind === "script" && existing?.script_hash)) { + // Access changes must work even while the current draft is invalid. Keep + // serving the already validated revision when renaming or restricting it. + await hosted.links.set({ ...target, slug: args.slug as string, access: args.access as "private" | "public" | undefined }); + return text({ ok: true, ...await this.linkDetails(target) }); + } + await hosted.links.check(target, args.slug as string); + const generation = await hosted.links.begin(target); + const settings = { slug: args.slug as string, ...(args.access === undefined ? {} : { access: args.access as "private" | "public" }) }; + if (target.kind === "script") { + const active = await hosted.scripts.active({ workspace: this.workspace, name: target.name }); + const link = await hosted.links.commit(target, generation, { ...settings, script_hash: active.hash }); + return link ? text({ ok: true, ...await this.linkDetails(target) }) : text({ ok: false, superseded: true, error: "A newer update superseded this URL activation" }, true); + } + const snapshot = await this.snapshot({ name: target.name }); + const compiled = await this.compile(snapshot); + if (!compiled.ok) return text({ ok: false, diagnostics: compiled.diagnostics, check: formatCanvasCheck(compiled.diagnostics) }, true); + const { _meta, ...preview } = await this.preview(snapshot, compiled); + if (preview.ok && "canvas" in preview) { + const link = await hosted.links.commit(target, generation, { ...settings, version_id: preview.canvas.versionId }); + if (!link) return text({ ok: false, superseded: true, error: "A newer update superseded this URL activation" }, true); + } + return { ...text({ ...preview, ...await this.linkDetails(target) }), ...(_meta ? { _meta } : {}) }; + } + + async scriptReadSource(selection: { name?: string; version_id?: string }) { + const scripts = this.requireHosted().scripts; + if (selection.version_id) { + const version = await scripts.version({ workspace: this.workspace, id: selection.version_id }); + if (selection.name && version.name !== this.target("script", selection.name).name) throw new Error("Version does not belong to this script"); + return { ...version, path: `${this.workspace}/${version.name}.script.ts` }; + } + if (!selection.name) throw new Error("name or version_id is required"); + return scripts.readRange({ workspace: this.workspace, name: selection.name, end_line: Number.MAX_SAFE_INTEGER }); + } + + private async scriptTool(tool: string, args: Record): Promise { + const hosted = this.requireHosted(), scripts = hosted.scripts; + const name = args.name as string, input = { workspace: this.workspace, name }; + const offset = args.offset as number | undefined ?? 0; + switch (tool) { + case "script_guide": return { content: [{ type: "text", text: HOSTED_SCRIPT_GUIDE }] }; + case "script_list": { + const drafts = await scripts.listDrafts({ workspace: this.workspace, offset }); + const entries = await Promise.all(drafts.map(async draft => ({ ...draft, ...await this.linkDetails(this.target("script", draft.name)) }))); + return text({ scripts: entries, next_offset: entries.length === 100 ? offset + 100 : null }); + } + case "script_read": { + if (!args.version_id) return text(await scripts.readRange({ ...input, start_line: args.start_line as number | undefined, end_line: args.end_line as number | undefined })); + const version = await this.scriptReadSource({ name, version_id: args.version_id as string }); + const lines = version.source.match(/[^\n]*\n|[^\n]+$/g) ?? [""]; + const start = args.start_line as number | undefined ?? 1, end = args.end_line as number | undefined ?? start + 199; + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start || start > lines.length) throw new Error("invalid line range"); + const actualEnd = Math.min(end, lines.length); + return text({ ...version, source: lines.slice(start - 1, actualEnd).join(""), start_line: start, end_line: actualEnd, total_lines: lines.length, next_line: actualEnd < lines.length ? actualEnd + 1 : null }); + } + case "script_history": { + const versions = await scripts.history({ workspace: this.workspace, name, offset }); + return text({ versions, next_offset: versions.length === 100 ? offset + 100 : null }); + } + case "script_version": return text(await scripts.version({ workspace: this.workspace, id: args.version_id as string })); + case "script_logs": return text({ logs: await hosted.scriptBackends.getByName(JSON.stringify([this.libraryKey, this.workspace, this.target("script", name).name])).logs({ limit: args.limit as number | undefined }) }); + case "script_secrets": { + if (args.secrets !== undefined) { + if (!args.secrets || typeof args.secrets !== "object" || Array.isArray(args.secrets)) throw new Error("secrets must be an object"); + for (const [key, value] of Object.entries(args.secrets)) await scripts.setSecret({ ...input, key, value: value as string | null }); + } + return text({ ok: true, names: await scripts.secretNames(input) }); + } + case "script_run": { + const link = await hosted.links.find(this.target("script", name)); + if (!link?.script_hash) throw new Error("Script has no validated URL revision"); + const active = await scripts.active({ ...input, hash: link.script_hash }); + const incoming = nativeRequest(args.request as CanvasHttpRequest); + const request = new Request(new URL(new URL(incoming.url).pathname + new URL(incoming.url).search, hosted.origin), incoming); + const response = await hosted.scriptBackends.getByName(JSON.stringify([this.libraryKey, this.workspace, this.target("script", name).name])).request({ ...active, request }); + const envelope = await scriptResponse(response, incoming.method); + return { ...text({ status: envelope.status, ...(link ? { url: `${hosted.origin}/${link.slug}` } : {}) }), structuredContent: { response: envelope } }; + } + case "script_write": case "script_edit": case "script_restore": { + const target = this.target("script", name); + // Read pending intent only after taking this operation's generation. + // A concurrent explicit access change then either precedes this read or + // supersedes this generation; it cannot be undone by stale metadata. + const generation = await hosted.links.begin(target); + const settings: LinkUpdate = await hosted.links.draft(target); + if (tool === "script_write") { + const previous = await hosted.links.find(target); + settings.slug = args.slug as string | undefined ?? settings.slug ?? previous?.slug ?? target.name; + if (args.access !== undefined) settings.access = args.access as "private" | "public"; + } + if (settings.slug !== undefined) await hosted.links.check(target, settings.slug); + if (tool === "script_restore") { + const version = await scripts.version({ workspace: this.workspace, id: args.version_id as string }); + if (version.name !== target.name) throw new Error("Version does not belong to this script"); + } + const mutation = tool === "script_write" ? await scripts.writeDraft({ ...input, source: args.contents as string }) + : tool === "script_edit" ? await scripts.editDraft({ ...input, edits: args.edits as CanvasEdit[], expected_hash: args.expected_hash as string | undefined }) + : await scripts.restore({ workspace: this.workspace, id: args.version_id as string }); + await hosted.links.stage(target, generation, settings); + const compiled = await compileScriptSource(mutation.source); + if (compiled.ok && compiled.js) { + const secrets = await scripts.executionSecrets(input); + const backend = hosted.scriptBackends.getByName(JSON.stringify([this.libraryKey, this.workspace, target.name])); + const validation = await backend.validate({ code: compiled.js, hash: createHash("sha256").update(compiled.js).digest("hex"), secrets }); + if (validation.ok) { + try { + const activated = await scripts.activate({ ...input, source_hash: mutation.source_hash, code: compiled.js }); + const link = await hosted.links.commit(target, generation, { ...settings, script_hash: activated.hash }); + if (!link) { + const { source, ...summary } = mutation; + const superseded = { ...summary, ...await this.linkDetails(target), applied: true, ok: false, superseded: true, error: "A newer update superseded this URL activation" }; + return { ...text(superseded, true), structuredContent: superseded }; + } + } catch (error) { + const { source, ...summary } = mutation; + const message = error instanceof Error ? error.message : "Script URL activation failed"; + const failed = { ...summary, ...await this.linkDetails(target), applied: true, ok: false, ...(message.includes("changed during validation") ? { superseded: true } : {}), error: message }; + return { ...text(failed, true), structuredContent: failed }; + } + } else { + compiled.ok = false; + compiled.diagnostics.push({ severity: "error", message: "Script module could not initialize" }); + } + } + const { source, ...summary } = mutation; + const payload = { ...summary, ...await this.linkDetails(target), applied: true, ok: compiled.ok, check: formatCanvasCheck(compiled.diagnostics), diagnostics: compiled.diagnostics }; + return { ...text(payload, !compiled.ok), structuredContent: payload }; + } + default: throw new Error("Unknown script tool"); + } + } + async gallery(all: boolean, offset = 0): Promise { const artifacts = new Map(); const filter = all ? { offset } : { workspace: this.workspace, offset }; @@ -134,10 +328,50 @@ export class CloudCanvasService { artifact.working = true; artifacts.set(key, artifact); } - return { workspace: this.workspace, artifacts: [...artifacts.values()].sort((a, b) => a.name.localeCompare(b.name) || a.workspace.localeCompare(b.workspace)), nextOffset: versions.length === 100 || drafts.length === 100 ? offset + 100 : null }; + let scriptsHaveMore = false; + if (this.hosted) { + const drafts = await this.hosted.scripts.listDrafts(filter); + const history = await this.hosted.scripts.history(filter); + scriptsHaveMore = drafts.length === 100 || history.length === 100; + for (const version of history) { + const key = JSON.stringify(["script", version.workspace, version.name]); + const artifact = artifacts.get(key) ?? { key, name: version.name, workspace: version.workspace, working: false, kind: "script" as const, versions: [] }; + artifact.versions.push({ id: version.id, revision: version.revision, createdAt: version.created_at, reason: version.reason, serveCount: 0 }); + artifacts.set(key, artifact); + } + for (const draft of drafts) { + const key = JSON.stringify(["script", draft.workspace, draft.name]); + const artifact = artifacts.get(key) ?? { key, name: draft.name, workspace: draft.workspace, working: false, kind: "script" as const, versions: [] }; + artifact.working = true; + artifacts.set(key, artifact); + } + for (const artifact of artifacts.values()) { + artifact.kind ??= "canvas"; + Object.assign(artifact, await this.linkDetails({ libraryKey: this.libraryKey, workspace: artifact.workspace, name: artifact.name, kind: artifact.kind })); + } + } + return { ...(this.hosted ? { capabilities: { scripts: true, links: true } } : {}), workspace: this.workspace, artifacts: [...artifacts.values()].sort((a, b) => a.name.localeCompare(b.name) || a.workspace.localeCompare(b.workspace)), nextOffset: scriptsHaveMore || versions.length === 100 || drafts.length === 100 ? offset + 100 : null }; } } function text(value: unknown, isError = false): CallToolResult { return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], isError }; } + +const HOSTED_SCRIPT_GUIDE = `Hosted standalone scripts and root URLs + +Canvases and scripts coexist. Use canvas_write with an optional slug, or artifact_link({kind:"canvas",name,slug,access}), to give a canvas 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. Unbundled third-party imports, relative modules and package.json dependency installation are not supported by script_write. The server accepts one self-contained module, at most 256 KiB of UTF-8 source. + +Use script_secrets({name,secrets:{TOKEN:"value",OLD_TOKEN:null}}) to set/delete values, or omit secrets to list names. Values never appear in source history or management reads. Use env.secrets.TOKEN inside the handler. Avoid returning secrets in HTTP responses. Initialize database tables with create table if not exists; source edits/restores preserve the database. + +script_write and script_edit save source before validation. Inspect applied, ok, and diagnostics: failed drafts remain editable, while the URL keeps serving the last validated code. No separate publish operation is needed. script_read reads draft or historical source, script_history lists revisions, script_version reads one, and script_restore restores and validates a prior revision. script_list, script_read, and gallery views never execute handlers. + +script_run({name,request:{path:"/chosen-slug?key=value",method:"POST",headers:[["content-type","text/plain"]],body:"aGVsbG8="}}) explicitly invokes the last validated handler. The request path and query are passed exactly as provided, using the deployment origin; include the slug in path to reproduce a direct URL request. Body is base64. MCP requests and responses are limited to 256 KiB; use direct URLs for streaming or larger responses. script_logs reads bounded recent execution logs and errors. Direct URLs preserve incoming paths, methods and body bytes and return the handler response. Management cookies and Cloudflare Access credentials are stripped; private routes also strip Authorization. Public Authorization is passed to the handler. Script HTML gets an opaque-origin sandbox and responses cannot set cookies. Public handlers can implement their own authentication, including provider signature checks. Cloudflare Access policies protecting the entire hostname need a bypass for intended public paths. No schedules or separate publishing workflow are provided. Execution has 30 seconds CPU and 50 subrequests; logs retain 100 entries of up to 2 KiB with literal secret redaction. + +Third-party dependencies +Keep the editable source, package.json and lockfile locally. Install pinned packages with bun add --exact @. Typecheck your original TypeScript before bundling. Bundle locally with Bun.build({entrypoints:["handler.ts"],target:"browser",format:"esm",external:["node:*","cloudflare:*"],banner:"// @ts-nocheck",minify:true}). Check build.success, then read build.outputs[0].text() and pass that entire string as script_write.contents. The generated-JavaScript banner avoids implicit-any errors after type erasure; it is only for the generated bundle, not handwritten source. Retain your original sources and rebuild after edits rather than editing bundled internals. Workers cannot execute arbitrary Node processes/native addons; use Workers-compatible packages. If the bundle exceeds 256 KiB, reduce the dependency footprint; splitting it into relative imports is not supported. + +After writing, check ok/diagnostics and the returned URL. Creation loads the module for validation but does not invoke the handler. Explicit script_run or an HTTP request invokes it and may mutate state or call external services. Inspect the HTTP status/body and script_logs before reporting the handler working. script_guide and canvas_guide describe the hosted contracts; local Bun stdio does not expose script tools. +`; diff --git a/cloudflare/tool-contract.ts b/cloudflare/tool-contract.ts index 5cf229e..53d6cd3 100644 --- a/cloudflare/tool-contract.ts +++ b/cloudflare/tool-contract.ts @@ -46,3 +46,36 @@ CLOUD_MCP_TOOLS.push({ name: { type: "string" }, version_id: { type: "string" }, request: CANVAS_REQUEST_SCHEMA, }, required: ["request"], anyOf: [{ required: ["name"] }, { required: ["version_id"] }], additionalProperties: false }, }); + +const slugProperties = { + slug: { type: "string", minLength: 1, maxLength: 80, pattern: "^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$", description: "Chosen root URL slug, unique across this deployment." }, + access: { type: "string", enum: ["private", "public"], description: "Private uses library permissions; public allows external HTTP callers. Default private." }, +}; +const canvasWrite = CLOUD_MCP_TOOLS.find(tool => tool.name === "canvas_write")!; +Object.assign(canvasWrite.inputSchema.properties!, slugProperties); +function addScriptTool(name: string, description: string, properties: Record, required: string[] = []) { + CLOUD_MCP_TOOLS.push({ name, description, inputSchema: { type: "object", properties, required, additionalProperties: false } }); +} +const nameProperty = { name: { type: "string", minLength: 1, description: "Script name in this workspace." } }; +const pageProperty = { offset: { type: "integer", minimum: 0, default: 0 } }; +addScriptTool("artifact_link", "Give a canvas or script a chosen root URL. Canvas links require valid source and work independently of the gallery. Defaults to private access. Renaming a slug changes its URL.", { + kind: { type: "string", enum: ["canvas", "script"] }, name: { type: "string", minLength: 1 }, ...slugProperties, +}, ["kind", "name", "slug"]); +addScriptTool("script_guide", "Read the hosted script and root-URL authoring guide before creating a script. Covers runtime, access, storage, secrets, validation, HTTP requests, and bundling third-party dependencies.", {}); +CLOUD_MCP_TOOLS[CLOUD_MCP_TOOLS.length - 1]!.annotations = { readOnlyHint: true }; +addScriptTool("script_write", "Call script_guide first. Create or replace arbitrary Workers-compatible TypeScript exporting default { fetch(request, env, ctx) }. Saved drafts and history are retained even when validation fails; only valid updates replace the running code. Scripts have outbound fetch, env.secrets, and env.sql for persistent SQLite. Slug defaults to the name; URLs use /. One module only: Workers/Node builtins or prebundled third-party code (see script_guide). No package installation at request time. This loads the module for validation but does not execute the handler.", { + ...nameProperty, contents: { type: "string", maxLength: 262144 }, ...slugProperties, +}, ["name", "contents"]); +addScriptTool("script_read", "Read script source with bounded line ranges, optionally from a historical version. Does not execute the script.", { + ...nameProperty, version_id: { type: "string" }, start_line: { type: "integer", minimum: 1 }, end_line: { type: "integer", minimum: 1 }, +}, ["name"]); +addScriptTool("script_edit", "Apply ordered, uniquely matching text replacements atomically, validate, and update the same URL on success. Invalid drafts retain the last working handler.", { + ...nameProperty, edits: { type: "array", minItems: 1, maxItems: 100, items: { type: "object", properties: { old_text: { type: "string", minLength: 1 }, new_text: { type: "string" } }, required: ["old_text", "new_text"], additionalProperties: false } }, expected_hash: { type: "string", pattern: "^[a-f0-9]{64}$" }, +}, ["name", "edits"]); +addScriptTool("script_list", "List saved scripts and their URLs without running them. Paginated, 100 per response.", pageProperty); +addScriptTool("script_run", "Execute a script's last validated handler. request.path is passed unchanged at the deployment origin; include the slug to reproduce a direct URL request. Returns HTTP response headers, status, and base64 body (maximum 256 KiB); use its URL for streaming or larger responses. May perform arbitrary script-defined writes or outbound requests.", { ...nameProperty, request: CANVAS_REQUEST_SCHEMA }, ["name", "request"]); +addScriptTool("script_history", "List script source history, 100 entries per page.", { ...nameProperty, ...pageProperty }); +addScriptTool("script_version", "Read an immutable script source revision. Never returns secrets.", { version_id: { type: "string" } }, ["version_id"]); +addScriptTool("script_restore", "Restore a script revision as a new draft and activate it if validation succeeds. Persistent storage remains live.", { ...nameProperty, version_id: { type: "string" } }, ["name", "version_id"]); +addScriptTool("script_logs", "Read bounded recent execution logs and errors without executing the script.", { ...nameProperty, limit: { type: "integer", minimum: 1, maximum: 100 } }, ["name"]); +addScriptTool("script_secrets", "Set or delete per-script secrets (null deletes). Omit secrets to list names. Values are never returned or added to source history.", { ...nameProperty, secrets: { type: "object", maxProperties: 32, additionalProperties: { type: ["string", "null"], maxLength: 4096 } } }, ["name"]); diff --git a/cloudflare/worker.test.ts b/cloudflare/worker.test.ts index 3f7e207..559ec72 100644 --- a/cloudflare/worker.test.ts +++ b/cloudflare/worker.test.ts @@ -29,10 +29,13 @@ beforeAll(async () => { ENVIRONMENT: { type: "text", value: "local" }, LIBRARIES: { type: "durable-object", worker: "canvas-app-test", exportName: "CanvasLibrary" }, BACKENDS: { type: "durable-object", worker: "canvas-app-test", exportName: "CanvasBackend" }, + LINKS: { type: "durable-object", worker: "canvas-app-test", exportName: "ArtifactLinks" }, + SCRIPTS: { type: "durable-object", worker: "canvas-app-test", exportName: "ScriptLibrary" }, + SCRIPT_BACKENDS: { type: "durable-object", worker: "canvas-app-test", exportName: "ScriptBackend" }, LOADER: { type: "worker-loader" }, ASSETS: { type: "assets" }, }, - exports: { CanvasLibrary: { type: "durable-object", storage: "sqlite" }, CanvasBackend: { type: "durable-object", storage: "sqlite" } }, + exports: { ArtifactLinks: { type: "durable-object", storage: "sqlite" }, ScriptLibrary: { type: "durable-object", storage: "sqlite" }, ScriptBackend: { type: "durable-object", storage: "sqlite" }, CanvasLibrary: { type: "durable-object", storage: "sqlite" }, CanvasBackend: { type: "durable-object", storage: "sqlite" } }, assets: { directory: join(import.meta.dir, "../dist/cloudflare/assets"), hasUserWorker: true, runWorkerFirst: true, htmlHandling: "none" }, }, dev: {} }] }; runtime = new Miniflare(runtimeOptions); @@ -55,6 +58,10 @@ test("official HTTP MCP client lists contracts, writes, edits, restores and retr expect(guide.isError).not.toBe(true); const guideText = (guide.content as { text: string }[])[0]!.text; expect(guideText).toContain("sidequery/canvas"); + expect(tools.find(tool => tool.name === "script_guide")?.annotations?.readOnlyHint).toBe(true); + const scriptGuide = await client.callTool({ name: "script_guide", arguments: {} }); + expect(scriptGuide.isError).not.toBe(true); + expect((scriptGuide.content as { text: string }[])[0]!.text).toContain("Third-party dependencies"); expect((tools.find(tool => tool.name === "canvas_open")!.inputSchema.properties!.target as { enum: string[] }).enum).toEqual(["inline"]); const result = await client.callTool({ name: "canvas_write", arguments: { name: "overview", contents: source } }); expect(result.isError).not.toBe(true); @@ -270,6 +277,37 @@ test("verified users have isolated private libraries and can collaborate in the expect((await database(alice)).value).toBe(1); expect((await database(bob)).value).toBe(0); expect((await bob.callTool({ name: "canvas_request", arguments: { version_id: privateVersion, request: { path: "/counter" } } })).isError).toBe(true); + const script = 'export default {fetch(request: Request) { return Response.json({message:"owner script",authorization:request.headers.get("authorization")}); }}'; + expect((await alice.callTool({name:"script_write",arguments:{name:"private-handler",slug:"private-handler",contents:script}})).isError).not.toBe(true); + expect((await fetch(address+"/private-handler")).status).toBe(401); + expect((await fetch(address+"/private-handler",{headers:{"Cf-Access-Jwt-Assertion":bobToken}})).status).toBe(404); + expect((await fetch(address+"/private-handler",{headers:{"Cf-Access-Jwt-Assertion":aliceToken}})).status).toBe(200); + const invalidPublic = await alice.callTool({name:"script_write",arguments:{name:"private-handler",slug:"private-handler",access:"public",contents:"export default {fetch(}"}}); + expect(invalidPublic.isError).toBe(true); + expect((await fetch(address+"/private-handler")).status).toBe(401); + expect((await fetch(address+"/private-handler",{headers:{"Cf-Access-Jwt-Assertion":aliceToken}})).status).toBe(200); + expect((await bob.callTool({name:"script_read",arguments:{name:"private-handler"}})).isError).toBe(true); + expect((await bob.callTool({name:"script_write",arguments:{name:"private-handler",slug:"private-handler",contents:script}})).isError).toBe(true); + expect((await alice.callTool({name:"artifact_link",arguments:{kind:"script",name:"private-handler",slug:"private-handler",access:"public"}})).isError).not.toBe(true); + const external=await fetch(address+"/private-handler",{method:"POST",headers:{Origin:"https://sender.example",Authorization:"Bearer webhook-token"}}); + expect(external.status).toBe(200); + expect(await external.json()).toMatchObject({authorization:"Bearer webhook-token"}); + expect((await fetch(address+"/api/gallery")).status).toBe(401); + expect((await alice.callTool({name:"artifact_link",arguments:{kind:"canvas",name:"secret",slug:"private-canvas"}})).isError).not.toBe(true); + expect((await fetch(address+"/private-canvas")).status).toBe(401); + expect((await fetch(address+"/private-canvas",{headers:{"Cf-Access-Jwt-Assertion":bobToken}})).status).toBe(404); + expect((await fetch(address+"/private-canvas",{headers:{"Cf-Access-Jwt-Assertion":aliceToken}})).status).toBe(200); + expect((await alice.callTool({name:"canvas_write",arguments:{name:"secret",slug:"private-canvas",access:"public",contents:"export default function {"}})).isError).toBe(true); + expect((await fetch(address+"/private-canvas")).status).toBe(401); + expect((await alice.callTool({name:"artifact_link",arguments:{kind:"canvas",name:"secret",slug:"private-canvas",access:"public"}})).isError).not.toBe(true); + expect((await fetch(address+"/private-canvas")).status).toBe(200); + expect((await alice.callTool({name:"artifact_link",arguments:{kind:"canvas",name:"secret",slug:"private-canvas",access:"private"}})).isError).not.toBe(true); + expect((await fetch(address+"/private-canvas")).status).toBe(401); + // Restore a valid draft for the existing gallery assertion below. + expect((await alice.callTool({name:"canvas_write",arguments:{name:"secret",contents:source}})).isError).not.toBe(true); + expect((await teamAlice.callTool({name:"script_write",arguments:{name:"shared-handler",slug:"shared-handler",contents:script}})).isError).not.toBe(true); + expect((await fetch(address+"/shared-handler",{headers:{"Cf-Access-Jwt-Assertion":bobToken}})).status).toBe(200); + expect((await alice.callTool({name:"script_write",arguments:{name:"untrusted-page",slug:"untrusted-page",access:"public",contents:"export default {fetch(){return new Response(\"
pending
\",{headers:{\"content-type\":\"text/html\"}})}}"}})).isError).not.toBe(true); const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage({ extraHTTPHeaders: { "Cf-Access-Jwt-Assertion": aliceToken } }); @@ -281,9 +319,118 @@ test("verified users have isolated private libraries and can collaborate in the await page.frameLocator("iframe").getByRole("heading", { name: "Team canvas" }).waitFor(); expect(await page.getByRole("button", { name: "secret", exact: true }).count()).toBe(0); expect(new URL(page.url()).searchParams.get("library")).toBe("team"); + const isolated = await browser.newPage({extraHTTPHeaders:{"Cf-Access-Jwt-Assertion":aliceToken}}); + await isolated.goto(address+"/untrusted-page"); + await isolated.waitForFunction(()=>document.querySelector('#result')?.textContent!=="pending"); + expect(JSON.parse(await isolated.locator('#result').innerText())).toEqual({cookieBlocked:true,storageBlocked:true,requestBlocked:true}); } finally { await browser.close(); } } finally { await Promise.all(clients.map(remote => remote.close())); await production.dispose(); } }, 60000); + +test("standalone scripts serve arbitrary HTTP responses at chosen root slugs and retain last valid code", async () => { + const code = `export default { async fetch(request: Request, env: ScriptEnv) { + env.sql.exec("create table if not exists hits(value integer)"); + env.sql.exec("insert into hits values (1)"); + console.log("handled", env.secrets.TOKEN ?? "unset"); + if (new URL(request.url).pathname.endsWith("/html")) return new Response("

Script page

", {headers:{"content-type":"text/html", "set-cookie":"danger=yes"}}); + return Response.json({url:request.url,method:request.method,header:request.headers.get("x-example"),body:await request.text(),count:env.sql.exec("select count(*) as n from hits").one().n,secret:env.secrets.TOKEN??null,cookie:request.headers.get("cookie"),assertion:request.headers.get("cf-access-jwt-assertion"),serviceSecret:request.headers.get("cf-access-client-secret"),serviceId:request.headers.get("cf-access-client-id")}, {status:201,headers:{"x-result":"script"}}); + }};`; + const saved = await client.callTool({ name: "script_write", arguments: { name: "http-script", slug: "my-handler", access: "public", contents: code } }); + expect(saved.isError).not.toBe(true); + expect(payload(saved)).toMatchObject({ url: `${origin}/my-handler`, slug: "my-handler", ok: true }); + await client.callTool({ name: "script_secrets", arguments: { name: "http-script", secrets: { TOKEN: "fixture-sensitive-value" } } }); + const response = await fetch(`${origin}/my-handler/extra?param=value`, { method: "POST", headers: { "x-example": "kept", Origin: "https://external.example", Cookie: "session=management", "Cf-Access-Jwt-Assertion": "private-assertion", "Cf-Access-Client-Secret": "management-service-secret", "Cf-Access-Client-Id": "management-service-id" }, body: ' { "hello": "世界" }\n' }); + expect(response.status).toBe(201); + expect(response.headers.get("x-result")).toBe("script"); + expect(await response.json()).toMatchObject({ url: `${origin}/my-handler/extra?param=value`, method: "POST", header: "kept", body: ' { "hello": "世界" }\n', count: 1, secret: "fixture-sensitive-value", cookie: null, assertion: null, serviceSecret: null, serviceId: null }); + const logs = await client.callTool({ name: "script_logs", arguments: { name: "http-script" } }); + expect(JSON.stringify(logs)).not.toContain("fixture-sensitive-value"); + expect(JSON.stringify(logs)).toContain("REDACTED"); + const sourceRead = await client.callTool({ name: "script_read", arguments: { name: "http-script" } }); + expect(payload(sourceRead).source).toBe(code); + const history = payload(await client.callTool({ name: "script_history", arguments: { name: "http-script" } })); + expect(JSON.stringify(history)).not.toContain("fixture-sensitive-value"); + const updated = await client.callTool({ name: "script_edit", arguments: { name: "http-script", edits: [{ old_text: '"x-result":"script"', new_text: '"x-result":"updated"' }] } }); + expect(updated.isError).not.toBe(true); + const updatedResponse = await fetch(`${origin}/my-handler`); + expect(updatedResponse.headers.get("x-result")).toBe("updated"); + expect((await updatedResponse.json() as { count: number }).count).toBe(2); + const invalid = await client.callTool({ name: "script_write", arguments: { name: "http-script", contents: "export default { fetch( }" } }); + expect(invalid.isError).toBe(true); + expect(payload(invalid)).toMatchObject({ applied: true, ok: false, slug: "my-handler" }); + expect((await fetch(`${origin}/my-handler`)).headers.get("x-result")).toBe("updated"); + const badModule = await client.callTool({name:"script_write",arguments:{name:"http-script",contents:'throw new Error("module failed"); export default {fetch() {return new Response("bad");}};'}}); + expect(badModule.isError).toBe(true); + expect(payload(badModule)).toMatchObject({applied:true,ok:false}); + expect((await fetch(`${origin}/my-handler`)).headers.get("x-result")).toBe("updated"); + const restored = await client.callTool({ name: "script_restore", arguments: { name: "http-script", version_id: history.versions[0].id } }); + expect(restored.isError).not.toBe(true); + expect((await fetch(`${origin}/my-handler`)).headers.get("x-result")).toBe("script"); + const duplicate = await client.callTool({ name: "script_write", arguments: { name: "different", slug: "my-handler", contents: code } }); + expect(duplicate.isError).toBe(true); + expect((await client.callTool({ name: "script_read", arguments: { name: "different" } })).isError).toBe(true); + expect((await client.callTool({ name: "script_write", arguments: { name: "reserved", slug: "api", contents: code } })).isError).toBe(true); + const page = await fetch(`${origin}/my-handler/html`); + expect(await page.text()).toBe("

Script page

"); + expect(page.headers.get("set-cookie")).toBeNull(); + expect(page.headers.get("content-security-policy")).toBe("sandbox allow-scripts allow-forms"); + const run = await client.callTool({ name: "script_run", arguments: { name: "http-script", request: { path: "/manual", method: "PATCH", body: btoa("manual body") } } }); + expect(run.isError).not.toBe(true); + const runBody = JSON.parse(atob((run.structuredContent as {response:{body:string}}).response.body)); + expect(runBody).toMatchObject({method:"PATCH",body:"manual body"}); + const gallery = await (await fetch(`${origin}/api/gallery?workspace=test`)).json() as {artifacts:{name:string;kind?:string;url?:string}[]}; + expect(gallery.artifacts.find(item=>item.name==="http-script")).toMatchObject({kind:"script",url:`${origin}/my-handler`}); + const source = await fetch(`${origin}/api/source?workspace=test&kind=script&name=http-script`); + expect(await source.text()).toBe(code); +}, 60000); + +test("root canvas URLs render interactive backends and keep valid revisions through broken drafts", async () => { + const saved = await client.callTool({ name: "canvas_write", arguments: { name: "linked-counter", slug: "my-counter", access: "public", contents: counterClient, server: counterServer } }); + expect(saved.isError).not.toBe(true); + expect(payload(saved).url).toBe(`${origin}/my-counter`); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.goto(`${origin}/my-counter`); + await page.frameLocator("iframe").getByText("Count: 0", {exact:true}).waitFor(); + await page.frameLocator("iframe").getByRole("button", {name:"Increment"}).click(); + await page.frameLocator("iframe").getByText("Count: 1", {exact:true}).waitFor(); + const invalid = await client.callTool({ name: "canvas_write", arguments: { name: "linked-counter", contents: "export default function {" } }); + expect(invalid.isError).toBe(true); + await page.reload(); + await page.frameLocator("iframe").getByText("Count: 1", {exact:true}).waitFor(); + const fixed = await client.callTool({ name: "canvas_write", arguments: { name: "linked-counter", contents: counterClient.replace("Count:", "Total:") } }); + expect(fixed.isError).not.toBe(true); + await page.reload(); + await page.frameLocator("iframe").getByText("Total: 1", {exact:true}).waitFor(); + const stolen = await client.callTool({ name: "artifact_link", arguments: { kind:"canvas",name:"linked-counter",slug:"my-handler" } }); + expect(stolen.isError).toBe(true); + } finally { await browser.close(); } + const version = (saved._meta as {canvas:{versionId:string}}).canvas.versionId; + const mismatch=await fetch(`${origin}/my-counter/_canvas/request`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({version_id:version,request:{path:"/counter"}})}); + expect(mismatch.status).toBe(409); + expect((await fetch(`${origin}/my-counter/_canvas/request`,{method:"POST",headers:{Origin:"https://evil.example"},body:"{}"})).status).toBe(403); +},60000); + +test("correcting initial invalid drafts retains the user-chosen slug and access", async () => { + const invalid = 'export default { fetch( }'; + const saved = await client.callTool({name:"script_write",arguments:{name:"Human name",slug:"chosen-human",access:"public",contents:invalid}}); + expect(saved.isError).toBe(true); + expect(payload(saved)).toMatchObject({slug:"chosen-human",access:"public",applied:true,ok:false}); + expect(payload(saved).url).toBeUndefined(); + const fixed = await client.callTool({name:"script_edit",arguments:{name:"Human name",edits:[{old_text:invalid,new_text:'export default {fetch(){return new Response("corrected");}}'}]}}); + expect(fixed.isError).not.toBe(true); + expect(payload(fixed).url).toBe(`${origin}/chosen-human`); + expect(await (await fetch(`${origin}/chosen-human`)).text()).toBe("corrected"); + const badCanvas='export default function {'; + const canvas = await client.callTool({name:"canvas_write",arguments:{name:"Human canvas",slug:"chosen-canvas",access:"public",contents:badCanvas}}); + expect(canvas.isError).toBe(true); + expect(payload(canvas)).toMatchObject({slug:"chosen-canvas",access:"public"}); + const corrected=await client.callTool({name:"canvas_edit",arguments:{name:"Human canvas",edits:[{old_text:badCanvas,new_text:source}]}}); + expect(corrected.isError).not.toBe(true); + expect(payload(corrected).url).toBe(`${origin}/chosen-canvas`); + expect((await fetch(`${origin}/chosen-canvas`)).status).toBe(200); +}); diff --git a/cloudflare/worker.ts b/cloudflare/worker.ts index b863db8..9940b1c 100644 --- a/cloudflare/worker.ts +++ b/cloudflare/worker.ts @@ -13,10 +13,18 @@ import { canvas_request as validateRequest } from "../dist/cloudflare/tool-valid import type { CanvasHttpRequest } from "../src/httpTypes"; import galleryBridge from "../dist/cloudflare/gallery-request.json"; -export { CanvasLibrary, CanvasBackend }; +import { ArtifactLinks } from "./links"; +import { ScriptLibrary } from "./scripts"; +import { ScriptBackend } from "./script-backend"; +import { artifactRoute } from "./artifact-routes"; +import * as toolValidators from "../dist/cloudflare/tool-validators.js"; +export { CanvasLibrary, CanvasBackend, ArtifactLinks, ScriptLibrary, ScriptBackend }; export type Env = AuthEnvironment & BetterAuthEnvironment & { LIBRARIES: DurableObjectNamespace; BACKENDS: DurableObjectNamespace; + LINKS: DurableObjectNamespace; + SCRIPTS: DurableObjectNamespace; + SCRIPT_BACKENDS: DurableObjectNamespace; ASSETS: Fetcher; DEFAULT_WORKSPACE?: string; }; @@ -47,6 +55,10 @@ app.all("/api/auth/*", async c => c.env.AUTH_MODE === "better-auth" app.all("/.well-known/*", async c => c.env.AUTH_MODE === "better-auth" ? (await getCanvasAuth(c.env)).handler(c.req.raw) : c.notFound()); app.use("*", async (c, next) => { + const artifactOperation = artifactRoute(c.req.raw, c.env); + c.executionCtx.waitUntil(artifactOperation); + const artifact = await artifactOperation; + if (artifact) return artifact; c.header("Cache-Control", "private, no-store"); c.header("X-Content-Type-Options", "nosniff"); const url = new URL(c.req.url); @@ -68,7 +80,7 @@ app.use("*", async (c, next) => { ? JSON.stringify(["private", "better-auth", user.id]) : JSON.stringify(["private", c.env.ACCESS_TEAM_DOMAIN ?? "local", identity.subject]); c.set("libraryScope", libraryScope); - c.set("service", new CloudCanvasService(c.env.LIBRARIES.getByName(libraryKey), workspace, c.env.BACKENDS, libraryKey)); + c.set("service", new CloudCanvasService(c.env.LIBRARIES.getByName(libraryKey), workspace, c.env.BACKENDS, libraryKey, { links: c.env.LINKS.getByName("deployment"), scripts: c.env.SCRIPTS.getByName(libraryKey), scriptBackends: c.env.SCRIPT_BACKENDS, origin: url.origin })); // Compilation uses shared isolate resources. Let an admitted operation finish // after a client disconnects rather than abandoning its native compiler I/O. const operation = next(); @@ -101,7 +113,22 @@ app.post("/api/canvas/request", async c => { const args = input as { name?: string; version_id?: string; request: CanvasHttpRequest }; return c.json({ response: await c.get("service").request({ name: args.name, version_id: args.version_id }, args.request) }); }); +app.post("/api/tools", async c => { + let input: { name?: string; arguments?: Record }; + try { input = JSON.parse(await readRequestText(c.req.raw, 1024 * 1024)); } + catch (error) { return c.json({ error: error instanceof RangeError ? "Request exceeds 1 MiB" : "Invalid JSON" }, error instanceof RangeError ? 413 : 400); } + if (!input || typeof input.name !== "string" || !Object.hasOwn(toolValidators, input.name)) return c.json({ error: "Unknown tool" }, 400); + const validate = toolValidators[input.name as keyof typeof toolValidators]; + if (!validate(input.arguments ?? {})) return c.json({ error: "Invalid tool arguments" }, 400); + return c.json(await c.get("service").callTool(input.name, input.arguments ?? {})); +}); app.get("/api/source", async c => { + if (c.req.query("kind") === "script") { + const source = await c.get("service").scriptReadSource({ name: c.req.query("name"), version_id: c.req.query("version") }); + c.header("Content-Type", "text/plain; charset=utf-8"); + if (c.req.query("download") === "1") c.header("Content-Disposition", 'attachment; filename="script.ts"'); + return c.body(source.source); + } const snapshot = await c.get("service").snapshot({ name: c.req.query("name"), version_id: c.req.query("version") }); c.header("Content-Type", "text/plain; charset=utf-8"); if (c.req.query("download") === "1") c.header("Content-Disposition", `attachment; filename="canvas.canvas.tsx"; filename*=UTF-8''${encodeURIComponent(snapshot.name + ".canvas.tsx")}`); diff --git a/docs/scripts.md b/docs/scripts.md new file mode 100644 index 0000000..82dab18 --- /dev/null +++ b/docs/scripts.md @@ -0,0 +1,110 @@ +# Scripts and direct canvas links + +The hosted gallery supports canvases and standalone scripts. Either can have a chosen root URL such as `/sales-dashboard` or `/stripe-webhook`. Canvas URLs open the interactive canvas and its backend; script URLs invoke the script's HTTP handler. Slugs are unique across the deployment, including across artifact types and libraries. Existing application routes are reserved. + +## Gallery + +Choose **New script**, enter a name and URL slug, and edit the TypeScript source. Select **Private** or **Public**, then **Save script**. Scripts appear alongside canvases in the library. Selecting a script loads its source without running it. + +For either artifact, the **URL /** field and access selector control its direct link. **Save link** updates the settings; **Open** visits the URL and **Copy URL** copies its absolute address. Opening a script URL sends a GET request and runs its handler. Updating the source keeps the chosen URL. Changing the slug changes the URL; old URLs are not aliases. + +The script editor's **Save script** validates and saves the working source. A successful update serves immediately, without a separate publishing step. A failed validation keeps the previous working revision and access settings serving. If the first save is invalid, its chosen slug is retained with the draft until a valid correction creates the URL. The **Version** selector lets you read older revisions; **Restore revision** makes the selected revision current. **Download source** downloads the selected source. + +Expand **Run** to choose an HTTP method, path, headers as a JSON object, and a text body for methods other than GET and HEAD. **Run script** invokes the current saved script, even when you are browsing a historical revision or have unsaved edits. The response displays its status, headers, and decoded text body. Binary bodies are shown as base64. Returned HTML is displayed as text in the runner. + +Expand **Logs** and select **Load logs** to inspect recent execution output. Under **Secrets**, enter a name and value and select **Save secret**; the value field clears after saving. Enter a name and select **Remove secret** to delete it. Stored secret values are never loaded into the editor or source history. + +These controls are available in the hosted gallery. The local gallery continues to support existing canvases. + +## Script handler + +A script implements a standard Workers fetch handler. It can return JSON, HTML, binary data, redirects, or any other HTTP response supported by the runtime. Webhooks are one application of this contract. + +```ts +export default { + async fetch(request, env, ctx) { + const url = new URL(request.url); + if (url.pathname.endsWith("/health")) { + return Response.json({ ok: true }); + } + return new Response("Hello", { + headers: { "content-type": "text/plain; charset=utf-8" }, + }); + }, +} satisfies ExportedHandler; +``` + +`ScriptEnv` is available during type checking. It contains `secrets: Record` and `sql: SqlStorage`. Outbound `fetch` is available. `ctx.waitUntil` can extend background work associated with a request. Scripts can import supported `cloudflare:` and `node:` builtins. Source is currently a single TypeScript module; third-party packages must be bundled into that source beforehand. Code must be compatible with the Workers runtime; this is not a general server process or scheduling system. + +Use `env.secrets.NAME` for a configured secret. For example, a handler can verify a provider signature against the original request body. Script HTTP requests preserve their methods, query strings, and body bytes. The gateway strips cookies and Cloudflare Access assertion/service-token headers; private routes also strip the authorization credential used for management authentication. + +Each script has its own persistent SQLite storage. The database survives source updates and revision restores: + +```ts +export default { + async fetch(request, env) { + env.sql.exec("create table if not exists visits (at text not null)"); + env.sql.exec("insert into visits values (?)", new Date().toISOString()); + const row = env.sql.exec<{ count: number }>( + "select count(*) as count from visits", + ).one(); + return Response.json(row); + }, +} satisfies ExportedHandler; +``` + +Do not assume an in-memory global persists between requests or updates. Each execution is limited to 30 seconds of CPU and 50 subrequests, in addition to the Workers runtime limits. Logs retain the latest 100 entries with messages capped at 2 KiB. Secret strings are redacted from captured console output; handlers still control their own response bodies and outbound requests. + +## Access + +Links default to **Private** and use the artifact's existing library permissions. Management APIs and the gallery remain authenticated. Setting a link to **Public** allows external callers to invoke or view it; a public script can implement its own authentication through request headers or signatures. + +If Cloudflare Access protects the deployment's entire hostname, configure an Access bypass for the intended public paths so external callers can reach the Worker. The artifact's access setting does not override an Access policy that blocks the request before it reaches the application. + +HTML served at artifact URLs is sandboxed with a response Content Security Policy to isolate it from the management origin. Script responses cannot set cookies through these routes. This allows HTML handlers while keeping the gallery session separate. + +## MCP + +Agents use `script_write`, `script_read`, `script_edit`, `script_list`, `script_history`, `script_restore`, `script_run`, `script_logs`, and `script_secrets` to manage scripts. `artifact_link` sets the slug and access for either `kind: "canvas"` or `kind: "script"`. Creation and link results include the resulting URL. + +`script_run` takes a request object with `path`, `method`, `headers` as an array of `[name, value]` pairs, and an optional base64 `body`. Its structured result contains `response` with `status`, `statusText`, `headers`, and an optional base64 `body`. For example: + +```json +{ + "name": "script_run", + "arguments": { + "name": "echo", + "request": { + "path": "/", + "method": "POST", + "headers": [["content-type", "text/plain"]], + "body": "aGVsbG8=" + } + } +} +``` + +## Third-party dependencies + +The hosted compiler does not install bun dependencies or resolve a multi-file project. For a Workers-compatible package, keep your original TypeScript, `package.json`, and lockfile locally. Install an exact package version with `bun add --exact @` and typecheck the original source with your project's TypeScript setup. + +Bundle the handler locally before calling `script_write`. For example, a Hono handler can be bundled with this `bundle.ts`: + +```ts +const build = await Bun.build({ + entrypoints: ["handler.ts"], + target: "browser", + format: "esm", + external: ["node:*", "cloudflare:*"], + banner: "// @ts-nocheck", + minify: true, +}); +if (!build.success) throw new Error(build.logs.join("\n")); +await Bun.write("handler.bundle.js", await build.outputs[0]!.text()); +``` + +Run `bun run bundle.ts`, read `handler.bundle.js`, and pass its complete contents as `script_write.contents`. The banner applies only to generated JavaScript: it prevents strict implicit-any diagnostics caused by erased TypeScript types. Typecheck handwritten source before bundling. Runtime module initialization and handler-shape validation still apply. + +Keep the bundle within the 256 KiB UTF-8 source limit. Packages requiring native addons or a Node process cannot run in Workers. Rebuild from original source when changing bundled scripts; do not use targeted text edits on generated dependency internals. Worker and Node builtins remain external because the runtime supplies them. Scripts cannot import dependencies through adjacent files or package URLs. + +Agents can call `script_guide` to retrieve this workflow through MCP. `script_write` points to that guide; hosted `canvas_guide` includes the same script guidance. diff --git a/e2e/gallery-scripts.test.ts b/e2e/gallery-scripts.test.ts new file mode 100644 index 0000000..3a75741 --- /dev/null +++ b/e2e/gallery-scripts.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test"; +import { chromium } from "playwright"; +import type { GalleryArtifact } from "../src/gallery/types"; + +test("gallery edits scripts without execution and runs requests only on demand", async () => { + const build = await Bun.build({ entrypoints: [new URL("../src/gallery/client.tsx", import.meta.url).pathname], target: "browser" }); + expect(build.success).toBe(true); + const js = await build.outputs[0]!.text(); + const artifacts: GalleryArtifact[] = [{ key: "script:hello", kind: "script", name: "hello", workspace: "default", working: true, versions: [], slug: "hello", url: "/hello", access: "private" }]; + let hosted = true; + const calls: { name: string; arguments: Record }[] = []; + const server = Bun.serve({ port: 0, hostname: "127.0.0.1", async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/client.js") return new Response(js, { headers: { "content-type": "text/javascript" } }); + if (url.pathname === "/api/session") return Response.json({}); + if (url.pathname === "/api/gallery") return Response.json({ workspace: "default", artifacts: hosted ? artifacts : [{ key: "local", name: "local", workspace: "default", working: true, versions: [] }], ...(hosted ? { capabilities: { scripts: true, links: true } } : {}) }); + if (url.pathname === "/gallery/preview") return new Response("

Canvas preview

", { headers: { "content-type": "text/html" } }); + if (url.pathname === "/api/source") return new Response('export default { fetch() { return new Response("hello"); } };'); + if (url.pathname === "/api/tools") { + const call = await request.json() as typeof calls[number]; calls.push(call); + if (call.name === "script_write" && call.arguments.name !== "hello") artifacts.push({ key: "script:new", kind: "script", name: call.arguments.name, workspace: "default", working: true, versions: [] }); + if (call.name === "artifact_link") return Response.json({ error: "Slug is already in use" }, { status: 409 }); + return Response.json({ structuredContent: { response: { status: 200, statusText: "OK", headers: [["content-type", "text/plain"]], body: Buffer.from(" 🌍").toString("base64") } } }); + } + return new Response('
', { headers: { "content-type": "text/html" } }); + } }); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.goto(server.url.href); + await page.getByRole("textbox", { name: "Script source", exact: true }).waitFor(); + expect(await page.locator("iframe").count()).toBe(0); + expect(calls).toEqual([]); + await page.getByRole("textbox", { name: "Script source", exact: true }).fill('export default { fetch() { return new Response("changed"); } };'); + await page.getByRole("button", { name: "Save script", exact: true }).click(); + await page.getByRole("status").filter({ hasText: "Script saved" }).waitFor(); + expect(calls.map(call => call.name)).toEqual(["script_write"]); + await page.getByRole("combobox", { name: "Request method" }).selectOption("POST"); + await page.getByRole("textbox", { name: "Request body" }).fill("hello 🌍"); + await page.getByRole("textbox", { name: "Request headers" }).fill('{"content-type":"text/plain"}'); + await page.getByRole("button", { name: "Run script" }).click(); + await page.getByLabel("Script response").waitFor(); + expect(calls[1]?.arguments.request).toEqual({ path: "/", method: "POST", headers: [["content-type", "text/plain"]], body: Buffer.from("hello 🌍").toString("base64") }); + expect(await page.getByLabel("Script response").textContent()).toContain("200 OK\ncontent-type: text/plain\n\n 🌍"); + await page.getByRole("button", { name: "Save link", exact: true }).click(); + await page.getByRole("status").filter({ hasText: "Slug is already in use" }).waitFor(); + await page.getByRole("button", { name: "New script" }).click(); + await page.getByRole("textbox", { name: "Script name", exact: true }).fill("new-handler"); + await page.getByRole("textbox", { name: "Script slug" }).fill("new-handler"); + await page.getByRole("button", { name: "Save script", exact: true }).click(); + await page.getByRole("button", { name: "new-handler Script", exact: true }).waitFor(); + expect(await page.getByRole("button", { name: "new-handler Script", exact: true }).getAttribute("aria-current")).toBe("true"); + expect(calls.filter(call => call.name === "script_run")).toHaveLength(1); + hosted = false; + await page.reload(); + await page.getByTitle("Preview of local").waitFor(); + expect(await page.getByRole("button", { name: "New script" }).count()).toBe(0); + expect(await page.getByRole("textbox", { name: "URL slug" }).count()).toBe(0); + } finally { await browser.close(); server.stop(true); } +}, 30000); diff --git a/package.json b/package.json index 30baae6..e550dc5 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,9 @@ "src/serve.ts", "src/service.ts", "src/typecheck.ts", - "src/viewer.ts" + "src/viewer.ts", + "src/gallery/hosted.tsx", + "docs/scripts.md" ], "publishConfig": { "access": "public" @@ -84,7 +86,7 @@ "mcp": "bun run src/cli.ts mcp", "test": "bun test src --timeout 30000", "test:e2e": "bun test e2e/herdr.e2e.test.ts", - "test:mcp-ui": "bun test e2e/mcp-app.test.ts --timeout 30000", + "test:mcp-ui": "bun test e2e/mcp-app.test.ts e2e/gallery-scripts.test.ts --timeout 30000", "typecheck": "tsc --noEmit", "dev:cloudflare": "wrangler dev --local --var ENVIRONMENT:local", "prepare:celld": "bun run scripts/prepare-celld.ts", diff --git a/scripts/prepare-cloudflare.ts b/scripts/prepare-cloudflare.ts index d6d835b..4825412 100644 --- a/scripts/prepare-cloudflare.ts +++ b/scripts/prepare-cloudflare.ts @@ -22,7 +22,7 @@ async function collect(directory: string, accept: (path: string) => boolean) { } await collect("src/sdk", path => /\.(ts|tsx)$/.test(path) && !path.endsWith(".test.ts")); files["src/httpTypes.ts"] = await readFile(join(root, "src/httpTypes.ts"), "utf8"); -for (const name of ["react", "react-dom", "scheduler", "@types/react", "@types/react-dom", "csstype"]) { +for (const name of ["react", "react-dom", "scheduler", "@types/react", "@types/react-dom", "csstype", "@types/node", "undici-types"]) { await collect(`node_modules/${name}`, path => path.endsWith(".d.ts") || path.endsWith("/package.json")); } await collect("node_modules/typescript/lib", path => /\/lib\.[^/]+\.d\.ts$/.test(path)); diff --git a/skills/herdr-canvas/SKILL.md b/skills/herdr-canvas/SKILL.md index 64ab63d..3fbd3f4 100644 --- a/skills/herdr-canvas/SKILL.md +++ b/skills/herdr-canvas/SKILL.md @@ -1,15 +1,29 @@ --- name: herdr-canvas -description: Create, typecheck, and open Sidequery Canvas artifacts through MCP or CLI, with optional Herdr integration. +description: Create and edit Sidequery canvases or hosted HTTP scripts, choose root URL slugs, and use storage, secrets and bundled third-party dependencies through MCP; optional local CLI and Herdr integration. --- # Sidequery Canvas Use this skill when the user would benefit from a standalone visual artifact beside the agent: metrics, tables, reviews, charts, or a small interactive -tool. Do not dump that output as a markdown table. +tool, or asks for a hosted script, HTTP endpoint, API, HTML handler, webhook, or direct artifact URL. -## Location +## Runtime and artifact choice + +Keep canvases for React UIs; create standalone scripts for arbitrary Workers-compatible HTTP handlers. Both can have a user/AI-chosen root URL at `/`, with no type prefix or namespace. Discover the available MCP tools first: hosted HTTP MCP exposes `script_*` and `artifact_link`; local Bun stdio and filesystem commands remain canvas-only. + +Read `canvas_guide` before writing a canvas and `script_guide` before writing a script. These tools provide the current runtime contract without needing a repository checkout. For hosted scripts, URLs, access, persistence, diagnostics and third-party dependencies, read [docs/scripts.md](../../docs/scripts.md). For dependencies specifically, follow its **Third-party dependencies** recipe: install pinned Workers-compatible packages locally, typecheck original source, prebundle into one module, and send the generated contents to `script_write`. The hosted compiler does not install packages. Keep original source and lockfiles for subsequent edits. + +## Hosted scripts and URLs + +Use `script_write({name,slug,contents,access})` with a standard default-exported Workers handler. `env.secrets` contains explicitly configured per-script values, `env.sql` provides persistent SQLite, and outbound `fetch` is available. The MCP guide has the complete supported shape and limits. + +Use `script_read` and `script_edit` for source changes; check `applied`, `ok` and diagnostics. Invalid drafts retain the working endpoint and chosen slug. Use `script_secrets` rather than embedding credentials, `script_history`/`script_version` for source history, and `script_restore` when restoration is requested. + +Use `artifact_link` for either artifact kind, or `slug` on creation. Private is the default; use public only when external access is intended. Return the exact URL supplied by the tool. Explicit `script_run` and visiting a script URL execute user code; reading, listing and editing do not invoke its handler. Validate intended requests and inspect response status/body and `script_logs` before claiming it works. + +## Local canvas location Write exactly one file per canvas: @@ -40,7 +54,7 @@ Check returned diagnostics: typecheck errors leave edits applied. For argument shapes and CLI examples, read the "Targeted reads and edits" section in [README.md](../../README.md). -## File rules +## Canvas browser file rules - Default-export a React component. - Import from `sidequery/canvas`; `herdr/canvas` and `cursor/canvas` remain compatibility aliases. Read `canvas_guide` for SDK and host contracts. @@ -68,15 +82,11 @@ export default function BillingReview() { ## Opening -`canvas_open` / `herdr-canvas open` will typecheck, bundle with Bun, and open a -pane owned by `herdr.canvas`. The Canvas pane manages its loopback server and -runs Terminal Browser internally in app mode. Never hand the user a localhost -URL as the Canvas result. Link the canvas source file in your reply only as a -secondary reference after verifying the Canvas pane opened. +`canvas_open` defaults to the inline MCP App viewer. Hosted `/` links open a standalone interactive canvas with its backend; return the tool-provided URL when a link is requested. -Required runtime: `terminal-browser` from `zenbu-labs/terminal-browser`. +For explicit Herdr pane opening, use `canvas_open({name,target:"herdr"})` on a supported local server or the CLI `canvas open `. This requires Herdr and Terminal Browser; the pane owns its loopback server and browser lifecycle. Verify the pane opened before reporting success. Ordinary canvas creation and hosted URLs do not require Herdr. -## History +## Canvas history When retrieving an earlier artifact, use `canvas_history` (optional `name`) and `canvas_version` (`version_id`). History includes deleted working files. diff --git a/src/gallery/client.tsx b/src/gallery/client.tsx index 0714b06..a5997ba 100644 --- a/src/gallery/client.tsx +++ b/src/gallery/client.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import { authClient, signInUrl } from "../auth/client-api"; import type { GalleryArtifact, GalleryData } from "./types"; +import { LinkSettings, ScriptPanel } from "./hosted"; type Scope = "current" | "all"; type DetailTab = "preview" | "source"; @@ -44,6 +45,19 @@ const styles = ` .artifact-row[aria-current="true"] { background: #303030; } .artifact-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .workspace-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: 11px; margin-top: 3px; } + .artifact-detail { display: flex; flex-direction: column; min-width: 0; min-height: 0; } + .artifact-detail .canvas-stage { flex: 1; } + .link-settings, .script-fields { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } + .link-settings { padding: 8px; border-bottom: 1px solid var(--line); } + .link-settings label { display: flex; align-items: center; gap: 4px; } + .script-panel { width: 100%; padding: 16px; overflow: auto; } + .script-panel label { display: block; } + .script-panel textarea { display: block; width: 100%; min-height: 70px; margin: 6px 0 12px; color: inherit; background: #191919; border: 1px solid var(--line); padding: 10px; font: 12px/1.6 "SFMono-Regular", Consolas, monospace; resize: vertical; } + .script-panel .source-editor { min-height: 280px; } + .script-panel details { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 12px; } + .script-panel summary { cursor: pointer; } + .script-panel pre { white-space: pre-wrap; overflow-wrap: anywhere; } + .muted { color: var(--muted); } .canvas-stage { position: relative; display: flex; min-width: 0; min-height: 0; overflow: hidden; } .preview-frame { display: block; width: 100%; height: 100%; border: 0; } .preview-loading { position: absolute; inset: 0; display: grid; place-items: center; background: var(--page); color: var(--muted); pointer-events: none; } @@ -110,6 +124,7 @@ function artifactUrl(path: "/api/source" | "/gallery/preview", artifact: Gallery const library = new URLSearchParams(window.location.search).get("library"); if (library) params.set("library", library); params.set("workspace", artifact.workspace); + if (artifact.kind === "script") params.set("kind", "script"); if (version === WORKING_VERSION) params.set("name", artifact.name); else params.set("version", version); if (download) params.set("download", "1"); @@ -129,6 +144,7 @@ function formatDate(value: string): string { } function App() { + const [creatingScript, setCreatingScript] = useState(false); const [scope, setScope] = useState("current"); const [query, setQuery] = useState(""); const [gallery, setGallery] = useState(null); @@ -143,6 +159,7 @@ function App() { const [user, setUser] = useState(null); const [signingOut, setSigningOut] = useState(false); const [accountError, setAccountError] = useState(""); + const createdScriptName = useRef(null); const galleryController = useRef(null); const galleryRequest = useRef(0); const sourceRequest = useRef(0); @@ -183,7 +200,10 @@ function App() { setGallery(payload); setRefreshEpoch((epoch) => epoch + 1); + const created = createdScriptName.current; + createdScriptName.current = null; setSelectedKey((current) => { + if (created) return payload.artifacts.find(artifact => artifact.kind === "script" && artifact.name === created)?.key ?? current; if (current && payload.artifacts.some((artifact) => artifact.key === current)) return current; return payload.artifacts[0]?.key ?? null; }); @@ -245,7 +265,7 @@ function App() { setSelectedVersion(resolvedVersion); }, [resolvedVersion]); - const previewUrl = selectedArtifact && resolvedVersion + const previewUrl = selectedArtifact && selectedArtifact.kind !== "script" && resolvedVersion ? `${artifactUrl("/gallery/preview", selectedArtifact, resolvedVersion)}&refresh=${refreshEpoch}` : ""; const downloadUrl = selectedArtifact && resolvedVersion @@ -286,7 +306,7 @@ function App() { }, [selectedArtifact]); useEffect(() => { - if (tab !== "source" || !selectedArtifact || !resolvedVersion) { + if (tab !== "source" || !selectedArtifact || selectedArtifact.kind === "script" || !resolvedVersion) { setSource({ status: "idle", text: "", error: "" }); return; } @@ -325,6 +345,7 @@ function App() { }, [previewUrl, tab]); const selectArtifact = (artifact: GalleryArtifact) => { + setCreatingScript(false); setSelectedKey(artifact.key); setSelectedVersion(artifact.working ? WORKING_VERSION : [...artifact.versions].sort((a, b) => b.revision - a.revision)[0]?.id ?? null); }; @@ -352,7 +373,7 @@ function App() { const url = URL.createObjectURL(await response.blob()); const anchor = document.createElement("a"); anchor.href = url; - anchor.download = `${selectedArtifact?.name ?? "canvas"}.canvas.tsx`; + anchor.download = `${selectedArtifact?.name ?? "canvas"}${selectedArtifact?.kind === "script" ? ".ts" : ".canvas.tsx"}`; anchor.click(); URL.revokeObjectURL(url); } catch (error) { @@ -378,7 +399,7 @@ function App() { setQuery(event.target.value)} placeholder="Search" @@ -391,8 +412,9 @@ function App() { + {gallery?.capabilities?.scripts ? : null}
- {selectedArtifact && resolvedVersion ? ( + {!creatingScript && selectedArtifact && resolvedVersion ? ( <> -
+ {selectedArtifact.kind !== "script" ?
-
+
: null} Download source ) : null} @@ -420,7 +442,7 @@ function App() { {accountError ?

{accountError}

: null}
-
) : filteredArtifacts.length === 0 ? ( -

{query ? "No matches" : "No canvases"}

+

{query ? "No matches" : gallery?.capabilities?.scripts ? "No canvases or scripts" : "No canvases"}

) : filteredArtifacts.map((artifact) => ( ))} +
+ {!creatingScript && selectedArtifact && gallery?.capabilities?.links ? : null}
- {!selectedArtifact ? ( + {creatingScript ? ( + setCreatingScript(false)} onSaved={async name => { createdScriptName.current = name; await loadGallery(); setCreatingScript(false); setSelectedVersion("working"); setQuery(""); }} /> + ) : selectedArtifact?.kind === "script" ? ( + { setSelectedVersion("working"); await loadGallery(); }} /> + ) : !selectedArtifact ? (

Select a canvas

) : !resolvedVersion ? (

No readable version

@@ -471,6 +500,7 @@ function App() {

Loading source…

)}
+
diff --git a/src/gallery/hosted.tsx b/src/gallery/hosted.tsx new file mode 100644 index 0000000..dcc75ff --- /dev/null +++ b/src/gallery/hosted.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from "react"; +import type { GalleryArtifact } from "./types"; + +type ToolResult = { error?: string; isError?: boolean; content?: { type: string; text?: string }[]; structuredContent?: unknown }; +export async function galleryTool(workspace: string, name: string, args: Record): Promise { + const params = new URLSearchParams({ workspace }); + const library = new URLSearchParams(window.location.search).get("library"); + if (library) params.set("library", library); + const response = await fetch(`/api/tools?${params}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, arguments: args }) }); + const result = await response.json() as ToolResult; + const text = result.content?.filter(item => item.type === "text").map(item => item.text ?? "").join("\n"); + if (!response.ok || result.isError) throw new Error(result.error || text || `Request failed (${response.status})`); + return result.structuredContent ?? text ?? result; +} +const message = (error: unknown) => error instanceof Error ? error.message : String(error); +const show = (value: unknown) => typeof value === "string" ? value : JSON.stringify(value, null, 2); +export function formatScriptResponse(value: unknown): string { + if (!value || typeof value !== "object" || !("response" in value)) return show(value); + const response = (value as { response: { status: number; statusText?: string; headers: [string, string][]; body?: string } }).response; + const heading = [`${response.status} ${response.statusText ?? ""}`.trim(), ...response.headers.map(([name, value]) => `${name}: ${value}`)].join("\n"); + if (!response.body) return heading; + const bytes = Uint8Array.from(atob(response.body), character => character.charCodeAt(0)); + try { return heading + "\n\n" + new TextDecoder("utf-8", { fatal: true }).decode(bytes); } + catch { return heading + `\n\n[Binary body: ${bytes.length} bytes]\nBase64: ${response.body}`; } +} +export function encodeRequestBody(body: string): string { + return btoa(Array.from(new TextEncoder().encode(body), byte => String.fromCharCode(byte)).join("")); +} +export function LinkSettings({ artifact, onSaved }: { artifact: GalleryArtifact; onSaved: () => Promise }) { + const [slug, setSlug] = useState(artifact.slug ?? ""); + const [access, setAccess] = useState(artifact.access ?? "private"); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(""); + useEffect(() => { setSlug(artifact.slug ?? ""); setAccess(artifact.access ?? "private"); }, [artifact.slug, artifact.access]); + return
{ + event.preventDefault(); setBusy(true); setStatus(""); + try { await galleryTool(artifact.workspace, "artifact_link", { kind: artifact.kind ?? "canvas", name: artifact.name, slug, access }); await onSaved(); setStatus("Link saved"); } + catch (error) { setStatus(message(error)); } finally { setBusy(false); } + }}> + + + + {artifact.url ? <>Open : null} + {status} +
; +} +const initialSource = `export default {\n async fetch(request, env, ctx) {\n return Response.json({ message: "Hello" });\n },\n} satisfies ExportedHandler;\n`; +export function ScriptPanel({ artifact, workspace, version, sourceUrl, onSaved, onCancel }: { artifact?: GalleryArtifact; workspace: string; version?: string; sourceUrl?: string; onSaved: (name: string) => Promise; onCancel?: () => void }) { + const [name, setName] = useState(artifact?.name ?? ""); + const [slug, setSlug] = useState(artifact?.slug ?? ""); + const [access, setAccess] = useState(artifact?.access ?? "private"); + const [contents, setContents] = useState(artifact ? "" : initialSource); + const [loaded, setLoaded] = useState(!artifact); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [status, setStatus] = useState(""); + const [output, setOutput] = useState(""); + const [logs, setLogs] = useState(""); + const [path, setPath] = useState("/"); + const [method, setMethod] = useState("GET"); + const [headers, setHeaders] = useState("{}"); + const [body, setBody] = useState(""); + const [secretName, setSecretName] = useState(""); + const [secretValue, setSecretValue] = useState(""); + const historical = !!artifact && version !== "working"; + useEffect(() => { + if (!sourceUrl) return; + const controller = new AbortController(); setLoaded(false); setError(""); + void fetch(sourceUrl, { signal: controller.signal }).then(async response => { if (!response.ok) throw new Error(`Source request failed (${response.status})`); return response.text(); }).then(text => { if (!controller.signal.aborted) { setContents(text); setLoaded(true); } }).catch(error => { if (!controller.signal.aborted) setError(message(error)); }); + return () => controller.abort(); + }, [sourceUrl]); + async function perform(action: () => Promise) { + setBusy(true); setError(""); setStatus(""); + try { await action(); } catch (error) { setError(message(error)); } finally { setBusy(false); } + } + return
+ {!artifact ?
: null} + {error ?

{error}

: null} + {status ?

{status}

: null} +