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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
100 changes: 100 additions & 0 deletions cloudflare/artifact-routes.ts
Original file line number Diff line number Diff line change
@@ -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<Response | null> {
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 = `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'"><style>body{margin:0;background:#181818;color:#f0f0f0;font-family:system-ui,sans-serif}#root{padding:24px}</style></head><body><div id="root"></div><script>window.__herdrCanvas=${scriptJson({ canvasId: payload.name, state: payload.state, theme: { kind: "dark" }, ...(payload.server ? { serverVersionId: payload.versionId } : {}) })};${galleryBridge.replace(/<\/script/gi, "<\\/script")}</script><script type="module">${payload.js.replace(/<\/script/gi, "<\\/script")}</script></body></html>`;
return html(`<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>Canvas</title><style>html,body,iframe{width:100%;height:100%;margin:0;border:0;display:block;background:#181818}</style></head><body><iframe title="Canvas" sandbox="allow-scripts"></iframe><script>
const frame=document.querySelector('iframe');frame.srcdoc=${scriptJson(frame)};
const pending=new Set();
window.addEventListener('message',async event=>{
const data=event.data;
if(event.source!==frame.contentWindow||data?.type!=='canvas/http-request'||typeof data.id!=='string'||data.id.length>64||pending.has(data.id))return;
try{
if(pending.size>=16)throw new Error('Too many pending requests');
if(data.versionId!==${scriptJson(payload.versionId)})throw new Error('Invalid canvas version');
pending.add(data.id);
const response=await fetch(${scriptJson(`/${slug}/_canvas/request`)},{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({version_id:${scriptJson(payload.versionId)},request:data.request})});
const result=await response.json();if(!response.ok)throw new Error(result.error||'Canvas request failed');
frame.contentWindow.postMessage({type:'canvas/http-response',id:data.id,response:result.response},'*');
}catch(error){frame.contentWindow.postMessage({type:'canvas/http-response',id:data.id,error:String(error.message||error)},'*');}
finally{pending.delete(data.id);}
});</script></body></html>`, "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'");
}
5 changes: 4 additions & 1 deletion cloudflare/auth-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
84 changes: 84 additions & 0 deletions cloudflare/compiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ScriptEnv>;',
'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 <H1>After script</H1>; }')).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);
Loading
Loading