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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ jobs:
- run: bun run test
- run: bun x playwright install --with-deps chromium
- run: bun run test:mcp-ui
- name: Test configured browser plugin in workerd
run: |
CANVAS_PLUGINS_CONFIG=src/test/plugins/config.ts bun run build:cloudflare-compiler
CANVAS_PLUGIN_TEST=1 bun test cloudflare/compiler.test.ts --test-name-pattern 'deployment browser plugin' --timeout 60000
# Rebuild the default deployment before the normal runtime and package checks.
- run: bun run test:cloudflare
- run: bun run typecheck:cloudflare
- name: Install celld 0.4.1
Expand Down
37 changes: 27 additions & 10 deletions cloudflare/artifact-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@ 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 { PluginError, PLUGIN_JSON_LIMIT, type PluginInvocationContext } from "./plugins";
import type { PluginRequest, PluginUser } from "../src/plugins/types";
import galleryBridge from "../dist/cloudflare/gallery-request.json";

export async function identify(request: Request, env: Env): Promise<{ privateKey: string } | Response> {
export async function identify(request: Request, env: Env): Promise<{ privateKey: string; user: PluginUser } | 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]) };
return { privateKey: JSON.stringify(["private", "better-auth", user.id]), user: { subject: user.id, authority: "better-auth" } };
}
const identity = await authenticate(request, env);
return identity instanceof Response ? identity : { privateKey: JSON.stringify(["private", env.ACCESS_TEAM_DOMAIN ?? "local", identity.subject]) };
return identity instanceof Response ? identity : { privateKey: JSON.stringify(["private", env.ACCESS_TEAM_DOMAIN ?? "local", identity.subject]), user: identity };
}
const scriptJson = (value: unknown) => JSON.stringify(value).replaceAll("<", "\\u003c");
function html(body: string, policy: string): Response {
Expand All @@ -29,10 +31,14 @@ export async function artifactRoute(request: Request, env: Env): Promise<Respons
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;
let plugins: PluginInvocationContext | undefined;
// Public links never acquire plugin authority from ambient management cookies.
if (link.kind === "canvas" && url.pathname === `/${slug}/_canvas/plugins` && link.access !== "private") return Response.json({ error: "Plugin sign-in required" }, { status: 401 });
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 });
plugins = { user: identity.user, env };
const origin = request.headers.get("Origin");
if (origin && origin !== url.origin) return new Response("Origin is not allowed", { status: 403 });
}
Expand All @@ -59,7 +65,15 @@ export async function artifactRoute(request: Request, env: Env): Promise<Respons
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);
const service = new CloudCanvasService(env.LIBRARIES.getByName(link.libraryKey), link.workspace, env.BACKENDS, link.libraryKey, undefined, plugins);
if (url.pathname === `/${slug}/_canvas/plugins`) {
if (request.method !== "POST") return new Response("Method not allowed", { status: 405, headers: { Allow: "POST" } });
let input: PluginRequest;
try { input = JSON.parse(await readRequestText(request, PLUGIN_JSON_LIMIT)); }
catch (error) { return Response.json({ error: error instanceof RangeError ? "Plugin input exceeds 256 KiB" : "Invalid JSON" }, { status: error instanceof RangeError ? 413 : 400 }); }
try { return Response.json({ result: await service.pluginCall(input) }, { headers: { "cache-control": "no-store" } }); }
catch (error) { return Response.json({ error: error instanceof PluginError ? error.message : "Plugin operation failed" }, { status: error instanceof PluginError ? error.status : 500 }); }
}
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.
Expand All @@ -80,21 +94,24 @@ export async function artifactRoute(request: Request, env: Env): Promise<Respons
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>`;
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" }, plugins: payload.plugins, ...(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;
if(event.source!==frame.contentWindow||!['canvas/http-request','canvas/plugin-request'].includes(data?.type)||typeof data.id!=='string'||data.id.length>64||pending.has(data.id))return;
const plugin=data.type==='canvas/plugin-request';
const responseType=plugin?'canvas/plugin-response':'canvas/http-response';
try{
if(pending.size>=16)throw new Error('Too many pending requests');
if(data.versionId!==${scriptJson(payload.versionId)})throw new Error('Invalid canvas version');
if(plugin&&!${scriptJson(!!payload.plugins)})throw new Error('Plugin sign-in required');
if(!plugin&&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 response=await fetch(plugin?${scriptJson(`/${slug}/_canvas/plugins`)}:${scriptJson(`/${slug}/_canvas/request`)},{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(plugin?data.request:{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)},'*');}
frame.contentWindow.postMessage({type:responseType,id:data.id,...(plugin?{result:result.result}:{response:result.response})},'*');
}catch(error){frame.contentWindow.postMessage({type:responseType,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'");
}
4 changes: 2 additions & 2 deletions cloudflare/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ function asResponse(result: Identity | Response): Response {

test("accepts a valid RS256 Cloudflare Access assertion from the configured JWKS", async () => {
const result = await authenticate(request(await token()), env);
expect(result).toEqual({ subject: "account-member-123" });
expect(result).toEqual({ subject: "account-member-123", authority: issuer });
expect(jwksRequests).toEqual([`${issuer}/cdn-cgi/access/certs`]);
});

Expand Down Expand Up @@ -91,7 +91,7 @@ test("requires the assertion and fails closed when Access configuration is absen

test("local bypass requires the explicit local environment and an exact loopback hostname", async () => {
for (const url of ["http://localhost:4785/mcp", "http://127.0.0.1:4785/mcp", "http://[::1]:4785/mcp"]) {
expect(await authenticate(request(undefined, url), { ENVIRONMENT: "local" })).toEqual({ subject: "local" });
expect(await authenticate(request(undefined, url), { ENVIRONMENT: "local" })).toEqual({ subject: "local", authority: "local" });
}
for (const url of ["https://canvas.example.com/mcp", "http://localhost.example.com/mcp", "http://127.0.0.2/mcp"]) {
const response = asResponse(await authenticate(request(undefined, url), { ENVIRONMENT: "local" }));
Expand Down
6 changes: 3 additions & 3 deletions cloudflare/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export type AuthEnvironment = {
ACCESS_TEAM_DOMAIN?: string;
ACCESS_AUD?: string;
};
export type Identity = { subject: string };
export type Identity = { subject: string; authority: string };

export function createAuthenticator(jwksFetch?: FetchImplementation) {
const keySets = new Map<string, JWTVerifyGetKey>();
Expand All @@ -14,7 +14,7 @@ export function createAuthenticator(jwksFetch?: FetchImplementation) {
// Only the explicitly selected local environment can skip Access, and only
// on loopback. A production deployment with missing settings stays closed.
if (env.ENVIRONMENT === "local" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) {
return { subject: "local" };
return { subject: "local", authority: "local" };
}
if (!env.ACCESS_AUD || !env.ACCESS_TEAM_DOMAIN || !/^[a-z0-9-]+\.cloudflareaccess\.com$/.test(env.ACCESS_TEAM_DOMAIN)) {
return Response.json({ error: "Configure ACCESS_TEAM_DOMAIN and ACCESS_AUD, then enable Cloudflare Access with managed OAuth for this Worker." }, { status: 503 });
Expand All @@ -35,7 +35,7 @@ export function createAuthenticator(jwksFetch?: FetchImplementation) {
requiredClaims: ["sub", "exp", "iat"],
});
if (typeof payload.sub !== "string" || !payload.sub) throw new Error("Missing identity");
return { subject: payload.sub };
return { subject: payload.sub, authority: issuer };
} catch {
return Response.json({ error: "Invalid Cloudflare Access assertion" }, { status: 401 });
}
Expand Down
38 changes: 38 additions & 0 deletions cloudflare/compiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,41 @@ test("a prebundled third-party Hono handler compiles and serves through the scri
await execution.dispose();
}
}, 60000);

// Build the fixture deployment, then run this gated integration test:
// CANVAS_PLUGINS_CONFIG=src/test/plugins/config.ts bun run build:cloudflare-compiler
// CANVAS_PLUGIN_TEST=1 bun test cloudflare/compiler.test.ts --test-name-pattern 'deployment browser plugin' --timeout 60000
// Restore the default deployment afterward with: bun run build:cloudflare-compiler
test.skipIf(process.env.CANVAS_PLUGIN_TEST !== "1")("deployment browser plugin compiles, checks types, and shares React hooks in workerd", async () => {
const source = `import { PluginCounter, type Label } from "@test/counter";
const label: Label = { prefix: "Plugin" };
export default function Canvas() { return <PluginCounter {...label} />; }`;
expect((await call(source, "typecheck")).diagnostics).toEqual([]);
const invalidSdkType = source.replace("type Label", "type Label, type Tone").replace("const label", 'const tone: Tone = "invalid-tone"; const label');
expect((await call(invalidSdkType, "typecheck")).diagnostics.some(item => item.message.includes("invalid-tone"))).toBe(true);
const invalid = await call(source.replace('prefix: "Plugin"', "prefix: 123"), "typecheck");
expect(invalid.diagnostics.some(item => item.message.includes("number"))).toBe(true);
for (const specifier of ["@test/counter/deep", "jose", "uninstalled"]) {
const denied = await call(source.replace("@test/counter", specifier));
expect(denied.ok).toBe(false);
expect(denied.diagnostics.some(item => item.message.includes("not allowed"))).toBe(true);
}
const result = await call(source);
expect(result.diagnostics).toEqual([]);
expect(result.ok).toBe(true);
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const errors: string[] = [];
page.on("pageerror", error => errors.push(error.message));
await page.setContent('<iframe sandbox="allow-scripts"></iframe>');
await page.locator("iframe").evaluate((element, js) => {
(element as HTMLIFrameElement).srcdoc = `<div id="root"></div><script type="module">${js.replace(/<\/script/gi, "<\\/script")}</script>`;
}, result.js!);
const frame = page.frameLocator("iframe");
await frame.getByRole("button", { name: "UGx1Z2luIDA", exact: true }).click();
await frame.getByRole("button", { name: "UGx1Z2luIDE", exact: true }).waitFor();
expect(errors).toEqual([]);
} finally { await browser.close(); }
expect(outbound).toEqual([]);
}, 60000);
9 changes: 6 additions & 3 deletions cloudflare/compiler.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import { createWorker } from "@cloudflare/worker-bundler";
import ts from "../dist/cloudflare/typescript.js";
import files from "../dist/cloudflare/compiler-files.json";
import browserPlugins from "../dist/cloudflare/plugin-browser.json";
import browserRuntime from "../dist/cloudflare/browser-runtime.json";
import { sandboxToDiagnostics, type Diagnostic } from "../src/diagnostics";
import { scanCanvasSource } from "../src/sandbox";

const sourcePath = "canvas.canvas.tsx";
const serverPath = "canvas.canvas.server.ts";
const sdkPath = "src/sdk/index.ts";
const compilerFiles: Record<string, string> = files;
const compilerFiles: Record<string, string> = { ...files, ...browserPlugins.files };
const options: ts.CompilerOptions = {
target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler, jsx: ts.JsxEmit.ReactJSX,
strict: true, noEmit: true, skipLibCheck: true, esModuleInterop: true,
allowSyntheticDefaultImports: true, baseUrl: "/",
paths: { "sidequery/canvas": [sdkPath], "herdr/canvas": [sdkPath], "cursor/canvas": [sdkPath] },
paths: { ...browserPlugins.paths, "@sidequery/canvas": [sdkPath], "sidequery/canvas": [sdkPath], "herdr/canvas": [sdkPath], "cursor/canvas": [sdkPath] },
types: [],
};
const normalize = (path: string) => path.replace(/^\//, "");
Expand Down Expand Up @@ -47,7 +48,7 @@ const host: ts.LanguageServiceHost = {
let languageService: ts.LanguageService | undefined;

export function typecheckCanvasSource(source: string): Diagnostic[] {
const violations = scanCanvasSource(source);
const violations = scanCanvasSource(source, Object.keys(browserPlugins.paths));
if (violations.length) return sandboxToDiagnostics(sourcePath, violations);
return typecheckSource(source, false);
}
Expand Down Expand Up @@ -186,9 +187,11 @@ async function compileSource(source: string) {
define: { "process.env.NODE_ENV": '"production"' },
virtualModules: {
"sidequery/canvas": browserRuntime.sdkModule,
"@sidequery/canvas": browserRuntime.sdkModule,
"herdr/canvas": browserRuntime.sdkModule,
"cursor/canvas": browserRuntime.sdkModule,
"react/jsx-runtime": "export const { jsx, jsxs, Fragment } = globalThis.__herdrCanvasRuntime.jsx;",
...browserPlugins.modules,
},
});
if (result.warnings?.length) throw new Error(result.warnings.join("\n"));
Expand Down
Loading
Loading