From 5f6d34c155304d7a53e368fe30e2d196e7643223 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 7 Sep 2026 12:39:29 -0700 Subject: [PATCH] Add deployment plugins for canvases --- .github/workflows/ci.yml | 5 ++ cloudflare/artifact-routes.ts | 37 +++++--- cloudflare/auth.test.ts | 4 +- cloudflare/auth.ts | 6 +- cloudflare/compiler.test.ts | 38 ++++++++ cloudflare/compiler.ts | 9 +- cloudflare/plugins.test.ts | 78 ++++++++++++++++ cloudflare/plugins.ts | 68 ++++++++++++++ cloudflare/service.ts | 15 +++- cloudflare/tool-contract.ts | 9 ++ cloudflare/worker.test.ts | 36 +++++++- cloudflare/worker.ts | 16 +++- docs/runtime-plugins.md | 74 ++++++++++++++++ e2e/browser-plugins.test.ts | 26 ++++++ e2e/mcp-app.test.ts | 29 ++++++ e2e/plugin-bridge.test.ts | 37 ++++++++ package.json | 12 ++- scripts/package.integration.test.ts | 14 +++ scripts/prepare-browser-plugins.ts | 103 ++++++++++++++++++++++ scripts/prepare-cloudflare.ts | 4 + scripts/prepare-plugins.test.ts | 32 +++++++ scripts/prepare-plugins.ts | 86 ++++++++++++++++++ src/compile.ts | 17 ++-- src/gallery/client.tsx | 16 ++-- src/history.ts | 3 +- src/mcp/app-contract.ts | 1 + src/mcp/guide.ts | 8 +- src/plugins/browser.test.ts | 72 +++++++++++++++ src/plugins/browser.ts | 14 +++ src/plugins/config.ts | 35 ++++++++ src/plugins/types.ts | 16 ++++ src/runtime/cloud-runtime.ts | 8 ++ src/runtime/gallery-request.ts | 22 ++++- src/runtime/mcp-app.ts | 7 ++ src/sandbox.ts | 10 +-- src/sdk/hooks.ts | 2 + src/sdk/index.ts | 1 + src/sdk/plugins.test.ts | 38 ++++++++ src/sdk/plugins.ts | 25 ++++++ src/test/plugins/browser.tsx | 9 ++ src/test/plugins/conditional/browser.d.ts | 1 + src/test/plugins/conditional/browser.js | 1 + src/test/plugins/conditional/node.d.ts | 1 + src/test/plugins/conditional/node.js | 3 + src/test/plugins/conditional/package.json | 11 +++ src/test/plugins/config.ts | 5 ++ src/test/plugins/label.ts | 5 ++ src/test/plugins/untyped.d.ts | 2 + src/test/plugins/untyped.js | 1 + src/typecheck.ts | 19 +++- 50 files changed, 1038 insertions(+), 53 deletions(-) create mode 100644 cloudflare/plugins.test.ts create mode 100644 cloudflare/plugins.ts create mode 100644 docs/runtime-plugins.md create mode 100644 e2e/browser-plugins.test.ts create mode 100644 e2e/plugin-bridge.test.ts create mode 100644 scripts/prepare-browser-plugins.ts create mode 100644 scripts/prepare-plugins.test.ts create mode 100644 scripts/prepare-plugins.ts create mode 100644 src/plugins/browser.test.ts create mode 100644 src/plugins/browser.ts create mode 100644 src/plugins/config.ts create mode 100644 src/plugins/types.ts create mode 100644 src/sdk/plugins.test.ts create mode 100644 src/sdk/plugins.ts create mode 100644 src/test/plugins/browser.tsx create mode 100644 src/test/plugins/conditional/browser.d.ts create mode 100644 src/test/plugins/conditional/browser.js create mode 100644 src/test/plugins/conditional/node.d.ts create mode 100644 src/test/plugins/conditional/node.js create mode 100644 src/test/plugins/conditional/package.json create mode 100644 src/test/plugins/config.ts create mode 100644 src/test/plugins/label.ts create mode 100644 src/test/plugins/untyped.d.ts create mode 100644 src/test/plugins/untyped.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f44c7e..52c2bb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/cloudflare/artifact-routes.ts b/cloudflare/artifact-routes.ts index f8c2c73..28c1808 100644 --- a/cloudflare/artifact-routes.ts +++ b/cloudflare/artifact-routes.ts @@ -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 { @@ -29,10 +31,14 @@ export async function artifactRoute(request: Request, env: Env): Promise
`; + 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.test.ts b/cloudflare/auth.test.ts index 5674ff3..83e9fb4 100644 --- a/cloudflare/auth.test.ts +++ b/cloudflare/auth.test.ts @@ -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`]); }); @@ -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" })); diff --git a/cloudflare/auth.ts b/cloudflare/auth.ts index 08f1d38..a7d57b0 100644 --- a/cloudflare/auth.ts +++ b/cloudflare/auth.ts @@ -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(); @@ -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 }); @@ -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 }); } diff --git a/cloudflare/compiler.test.ts b/cloudflare/compiler.test.ts index 8e2dba5..3401daa 100644 --- a/cloudflare/compiler.test.ts +++ b/cloudflare/compiler.test.ts @@ -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 ; }`; + 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(''); + await page.locator("iframe").evaluate((element, js) => { + (element as HTMLIFrameElement).srcdoc = `
`; + }, 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); diff --git a/cloudflare/compiler.ts b/cloudflare/compiler.ts index ce06e7c..70ea822 100644 --- a/cloudflare/compiler.ts +++ b/cloudflare/compiler.ts @@ -1,6 +1,7 @@ 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"; @@ -8,13 +9,13 @@ 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 = files; +const compilerFiles: Record = { ...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(/^\//, ""); @@ -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); } @@ -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")); diff --git a/cloudflare/plugins.test.ts b/cloudflare/plugins.test.ts new file mode 100644 index 0000000..c375950 --- /dev/null +++ b/cloudflare/plugins.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test"; +import Ajv from "ajv"; +import { createPluginDispatcher, PluginError, PLUGIN_JSON_LIMIT } from "./plugins"; +import type { CanvasPlugin, PluginOperation, PluginContext } from "../src/plugins/config"; +import { createAuthenticator } from "./auth"; + +const schema = { type: "object", properties: { value: { type: "integer" } }, required: ["value"], additionalProperties: false }; +function setup(handler: PluginOperation["handler"], extra: Partial = {}, timeout = 30_000) { + const plugin: CanvasPlugin = { name: "data", description: "Test data", secrets: ["DATA_KEY"], operations: { read: { description: "Read", inputSchema: schema, outputSchema: schema, handler, ...extra } } }; + const ajv = new Ajv({ strict: true }); + const input = ajv.compile(schema), output = ajv.compile(schema); + return createPluginDispatcher([plugin], { input: (_plugin, _op, value) => input(value), output: (_plugin, _op, value) => output(value) }, timeout); +} +const request = { plugin: "data", operation: "read", input: { value: 1 } }; +const context = { user: { subject: "alice", authority: "https://team.cloudflareaccess.com" }, env: { DATA_KEY: "provider-secret", OTHER_SECRET: "hidden", LIBRARIES: {} } }; + +test("passes verified local caller identity and only declared secrets to handler", async () => { + const user = await createAuthenticator()(new Request("http://localhost:4785/api/plugins/call"), { ENVIRONMENT: "local" }); + if (user instanceof Response) throw new Error("Local authentication failed"); + let received: PluginContext | undefined; + const dispatch = setup((input, ctx) => { received = ctx; return input; }); + expect(await dispatch(request, { user, env: context.env })).toEqual({ value: 1 }); + expect(received?.user).toEqual({ subject: "local", authority: "local" }); + expect({ ...received?.secrets }).toEqual({ DATA_KEY: "provider-secret" }); + expect(Object.keys(received!)).toEqual(["user", "secrets", "signal"]); +}); + +test("requires authentication, checks operation existence separately, and rejects selector authority", async () => { + const dispatch = setup(input => input); + await expect(dispatch(request)).rejects.toMatchObject({ status: 401 }); + await expect(dispatch({ ...request, operation: "missing" }, context)).rejects.toMatchObject({ status: 404 }); + await expect(dispatch({ ...request, operation: "toString" }, context)).rejects.toMatchObject({ status: 404 }); + await expect(dispatch({ ...request, input: {} }, context)).rejects.toMatchObject({ status: 400 }); + await expect(dispatch({ ...request, library: "team" } as typeof request, context)).rejects.toMatchObject({ status: 400 }); +}); + +test("authorize uses actual caller, defaults authenticated, and does not execute forbidden handler", async () => { + let calls = 0; + const dispatch = setup(input => { calls++; return input; }, { authorize: user => user.subject === "bob" }); + await expect(dispatch(request, context)).rejects.toMatchObject({ status: 403 }); + expect(calls).toBe(0); + expect(await dispatch(request, { ...context, user: { ...context.user, subject: "bob" } })).toEqual({ value: 1 }); + expect(calls).toBe(1); +}); + +test("bounds JSON and validates handler output", async () => { + await expect(setup(input => input)({ ...request, input: "x".repeat(PLUGIN_JSON_LIMIT) }, context)).rejects.toMatchObject({ status: 413 }); + for (const output of [{ value: "wrong" }, undefined, { value: NaN }, { value: "x".repeat(PLUGIN_JSON_LIMIT) }]) { + await expect(setup(() => output)(request, context)).rejects.toMatchObject({ status: 500 }); + } +}); + +test("sanitizes handler and authorization errors, including thrown plugin errors", async () => { + await expect(setup(() => { throw new Error("provider-secret"); })(request, context)).rejects.toMatchObject({ message: "Plugin operation failed", status: 500 }); + await expect(setup(() => { throw new PluginError("provider-secret", 400); })(request, context)).rejects.toMatchObject({ message: "Plugin operation failed", status: 500 }); + await expect(setup(input => input, { authorize: () => { throw new Error("provider-secret"); } })(request, context)).rejects.toMatchObject({ message: "Plugin authorization failed", status: 500 }); +}); + +test("missing declared secret fails without exposing its binding name or calling handler", async () => { + let calls = 0; + await expect(setup(input => { calls++; return input; })(request, { ...context, env: {} })).rejects.toMatchObject({ status: 500, message: "Plugin configuration unavailable" }); + expect(calls).toBe(0); +}); + +test("times out handler and aborts its signal", async () => { + let signal: AbortSignal | undefined; + const dispatch = setup((_input, ctx) => { signal = ctx.signal; return new Promise(() => {}); }, {}, 10); + await expect(dispatch(request, context)).rejects.toMatchObject({ status: 504 }); + expect(signal?.aborted).toBe(true); +}); + +test("timed out authorization never starts handler", async () => { + let calls = 0; + const dispatch = setup(input => { calls++; return input; }, { authorize: () => new Promise(resolve => setTimeout(() => resolve(true), 20)) }, 5); + await expect(dispatch(request, context)).rejects.toMatchObject({ status: 504 }); + await new Promise(resolve => setTimeout(resolve, 30)); + expect(calls).toBe(0); +}); diff --git a/cloudflare/plugins.ts b/cloudflare/plugins.ts new file mode 100644 index 0000000..39149cd --- /dev/null +++ b/cloudflare/plugins.ts @@ -0,0 +1,68 @@ +import type { CanvasPlugin } from "../src/plugins/config"; +import type { PluginRequest, PluginUser } from "../src/plugins/types"; +import installed from "../dist/cloudflare/plugin-server"; +import { validatePluginInput, validatePluginOutput } from "../dist/cloudflare/plugin-validators.js"; +import catalog from "../dist/cloudflare/plugin-catalog.json"; + +export const PLUGIN_JSON_LIMIT = 256 * 1024; +export const PLUGIN_GUIDE = `Deployment plugins provide installed browser modules and authenticated server functions. Call plugins_list to discover operation names, schemas, and read-only hints. Call canvas_plugin_call with {plugin, operation, input}, or use pluginCall from sidequery/canvas in a hosted canvas. Calls run as the authenticated deployment user, independently of canvas, version, workspace, or library selectors. Plugins may restrict users and must check row-level access in their handlers. Public standalone canvases cannot call plugins. Provider credentials stay on the server. Canvas backends and scripts do not receive this bridge.`; +export const pluginCatalog = catalog; +export type PluginInvocationContext = { user: PluginUser; env: object }; +export class PluginError extends Error { + constructor(message: string, readonly status: 400 | 401 | 403 | 404 | 413 | 500 | 504) { super(message); } +} + +function jsonValue(value: unknown, output = false): unknown { + let serialized: string | undefined; + try { + serialized = JSON.stringify(value, (_key, part) => { + if (typeof part === "number" && !Number.isFinite(part) || ["undefined", "function", "symbol", "bigint"].includes(typeof part)) throw new Error(); + return part; + }); + } catch { throw new PluginError(output ? "Plugin returned invalid JSON" : "Input must be JSON", output ? 500 : 400); } + if (serialized === undefined) throw new PluginError(output ? "Plugin returned invalid JSON" : "Input must be JSON", output ? 500 : 400); + if (new TextEncoder().encode(serialized).byteLength > PLUGIN_JSON_LIMIT) throw new PluginError(output ? "Plugin result exceeds 256 KiB" : "Plugin input exceeds 256 KiB", output ? 500 : 413); + return JSON.parse(serialized); +} + +export function createPluginDispatcher(plugins: readonly CanvasPlugin[], validators: { + input: typeof validatePluginInput; output: typeof validatePluginOutput; +}, timeoutMs = 30_000) { + return async (request: PluginRequest, context?: PluginInvocationContext): Promise => { + if (!context?.user.subject || !context.user.authority) throw new PluginError("Plugin sign-in required", 401); + if (!request || typeof request !== "object" || typeof request.plugin !== "string" || typeof request.operation !== "string" || !Object.hasOwn(request, "input") || Object.keys(request).some(key => !["plugin", "operation", "input"].includes(key))) throw new PluginError("Invalid plugin request", 400); + const plugin = plugins.find(plugin => plugin.name === request.plugin); + const operation = plugin?.operations && Object.hasOwn(plugin.operations, request.operation) ? plugin.operations[request.operation] : undefined; + if (!plugin || !operation) throw new PluginError("Plugin operation not found", 404); + const input = jsonValue(request.input); + if (!validators.input(request.plugin, request.operation, input)) throw new PluginError("Invalid plugin input", 400); + // Handlers are trusted deployment code. The signal cancels cooperative I/O; + // the deadline bounds asynchronous work even if it ignores cancellation. + const controller = new AbortController(); + let timer: ReturnType; + const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => { controller.abort(); reject(new PluginError("Plugin operation timed out", 504)); }, timeoutMs); }); + try { + return await Promise.race([timeout, (async () => { + const user = Object.freeze({ subject: context.user.subject, authority: context.user.authority }); + let authorized: boolean; + try { authorized = operation.authorize ? await operation.authorize(user) : true; } + catch { throw new PluginError("Plugin authorization failed", 500); } + if (!authorized) throw new PluginError("Plugin operation is not allowed", 403); + if (controller.signal.aborted) throw new PluginError("Plugin operation timed out", 504); + const secrets: Record = Object.create(null); + for (const name of plugin.secrets ?? []) { + const value = Object.hasOwn(context.env, name) ? (context.env as Record)[name] : undefined; + if (typeof value !== "string") throw new PluginError("Plugin configuration unavailable", 500); + secrets[name] = value; + } + let result: unknown; + try { result = await operation.handler(input, { user, secrets: Object.freeze(secrets), signal: controller.signal }); } + catch { throw new PluginError("Plugin operation failed", 500); } + const output = jsonValue(result, true); + if (!validators.output(request.plugin, request.operation, output)) throw new PluginError("Plugin returned invalid output", 500); + return output; + })()]); + } finally { clearTimeout(timer!); } + }; +} +export const dispatchPlugin = createPluginDispatcher(installed, { input: validatePluginInput, output: validatePluginOutput }); diff --git a/cloudflare/service.ts b/cloudflare/service.ts index 02a1078..a886e47 100644 --- a/cloudflare/service.ts +++ b/cloudflare/service.ts @@ -16,6 +16,9 @@ import type { GalleryArtifact, GalleryData } from "../src/gallery/types"; import { runtime } from "../dist/cloudflare/identity.json"; import { canvasGuideResult } from "../src/mcp/guide"; +import { dispatchPlugin, pluginCatalog, PLUGIN_GUIDE, type PluginInvocationContext } from "./plugins"; +import type { PluginRequest } from "../src/plugins/types"; + type Snapshot = { workspace: string; name: string; path: string; source: string; server_source: string | null; @@ -29,7 +32,7 @@ export class CloudCanvasService { private readonly scripts: CloudScriptService; constructor(readonly library: DurableObjectStub, readonly workspace: string, - readonly backends: DurableObjectNamespace, readonly libraryKey: string, readonly hosted?: HostedArtifacts) { + readonly backends: DurableObjectNamespace, readonly libraryKey: string, readonly hosted?: HostedArtifacts, readonly plugins?: PluginInvocationContext) { this.artifacts = new ArtifactService(workspace, libraryKey, hosted); this.scripts = new CloudScriptService(this.artifacts); } @@ -38,6 +41,12 @@ export class CloudCanvasService { if (name.startsWith("script_")) return this.scripts.callTool(name, args); if (name === "artifact_link") return this.artifactLink(args); switch (name) { + case "plugins_list": return { ...text({ plugins: pluginCatalog }), structuredContent: { plugins: pluginCatalog } }; + case "plugin_guide": return text(PLUGIN_GUIDE); + case "canvas_plugin_call": { + const result = await this.pluginCall(args as PluginRequest); + return { ...text({ result }), structuredContent: { result } }; + } case "canvas_guide": { const guide = canvasGuideResult(); if (this.hosted) guide.content.push({ type: "text", text: HOSTED_SCRIPT_GUIDE }); @@ -95,6 +104,8 @@ export class CloudCanvasService { } } + pluginCall(request: PluginRequest) { return dispatchPlugin(request, this.plugins); } + snapshot(selection: { name?: string; version_id?: string; event_id?: string }): Promise { return this.library.preview({ workspace: this.workspace, ...selection }); } @@ -110,7 +121,7 @@ export class CloudCanvasService { ...(snapshot.version_id ? { version_id: snapshot.version_id } : {}), }); const canvas = { name: version.name, versionId: version.id, eventId: event.id, sourceHash: version.source_hash }; - return { ...base, ok: true, canvas, _meta: { canvas: { ...canvas, js: compiled.js, state: snapshot.state, server: snapshot.server_source !== null } satisfies CanvasAppPayload } }; + return { ...base, ok: true, canvas, _meta: { canvas: { ...canvas, js: compiled.js, state: snapshot.state, server: snapshot.server_source !== null, ...(this.plugins ? { plugins: true } : {}) } satisfies CanvasAppPayload } }; } private async mutationResult(mutation: Mutation, generation?: number | null, settings: LinkUpdate = {}): Promise { diff --git a/cloudflare/tool-contract.ts b/cloudflare/tool-contract.ts index 39dd83f..8a34835 100644 --- a/cloudflare/tool-contract.ts +++ b/cloudflare/tool-contract.ts @@ -79,3 +79,12 @@ addScriptTool("script_version", "Read an immutable script source revision. Never 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"]); + +addScriptTool("plugins_list", "List deployment-installed plugins, browser availability, and operation schemas and read-only hints. Does not execute operations.", {}); +CLOUD_MCP_TOOLS[CLOUD_MCP_TOOLS.length - 1]!.annotations = { readOnlyHint: true }; +addScriptTool("plugin_guide", "Read the authenticated deployment plugin calling and authoring guide.", {}); +CLOUD_MCP_TOOLS[CLOUD_MCP_TOOLS.length - 1]!.annotations = { readOnlyHint: true }; +addScriptTool("canvas_plugin_call", "Call a deployment-installed server function as the authenticated user. May perform writes or outbound requests; consult plugins_list for operation schemas and read-only hints. Canvas or library selectors do not confer authority.", { + plugin: { type: "string", minLength: 1 }, operation: { type: "string", minLength: 1 }, input: {}, +}, ["plugin", "operation", "input"]); +CLOUD_MCP_TOOLS[CLOUD_MCP_TOOLS.length - 1]!.annotations = { readOnlyHint: false, destructiveHint: true, openWorldHint: true }; diff --git a/cloudflare/worker.test.ts b/cloudflare/worker.test.ts index 559ec72..551be71 100644 --- a/cloudflare/worker.test.ts +++ b/cloudflare/worker.test.ts @@ -89,6 +89,29 @@ test("official HTTP MCP client lists contracts, writes, edits, restores and retr await expect(client.callTool({ name: "canvas_open", arguments: { name: "overview", target: "herdr" } })).rejects.toThrow("Invalid tool arguments"); }, 60000); +test("plugin HTTP and MCP routes expose catalog hints and validate requests", async () => { + const tools = (await client.listTools()).tools; + for (const name of ["plugins_list", "plugin_guide"]) expect(tools.find(tool => tool.name === name)?.annotations?.readOnlyHint).toBe(true); + expect(tools.find(tool => tool.name === "canvas_plugin_call")?.annotations?.readOnlyHint).toBe(false); + const list = await client.callTool({ name: "plugins_list", arguments: {} }); + expect(list.isError).not.toBe(true); + expect(Array.isArray((list.structuredContent as { plugins: unknown[] }).plugins)).toBe(true); + expect((await client.callTool({ name: "plugin_guide", arguments: {} })).isError).not.toBe(true); + const call = { plugin: "missing-plugin-for-test", operation: "read", input: {} }; + const missing = await client.callTool({ name: "canvas_plugin_call", arguments: call }); + expect(missing.isError).toBe(true); + expect(JSON.stringify(missing)).toContain("Plugin operation not found"); + await expect(client.callTool({ name: "canvas_plugin_call", arguments: { ...call, library: "team" } })).rejects.toThrow("Invalid tool arguments"); + for (const library of ["private", "team"]) { + const response = await fetch(`${origin}/api/plugins/call?library=${library}`, { method: "POST", body: JSON.stringify(call) }); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Plugin operation not found" }); + } + expect((await fetch(`${origin}/api/plugins/call`, { method: "POST", body: "{}" })).status).toBe(400); + expect((await fetch(`${origin}/api/plugins/call`, { method: "POST", body: " ".repeat(256 * 1024 + 1) })).status).toBe(413); + expect((await fetch(`${origin}/api/plugins/call`, { method: "POST", headers: { Origin: "https://evil.example" }, body: JSON.stringify(call) })).status).toBe(403); +}); + test("invalid drafts remain readable with diagnostics and no preview", async () => { const result = await client.callTool({ name: "canvas_write", arguments: { name: "invalid", contents: 'const n: number = "bad"; export default function Canvas() { return
{n}
; }' } }); expect(result.isError).toBe(true); @@ -192,7 +215,7 @@ test("all hosted surfaces fail closed off loopback, and mutations enforce origin expect((await fetch(`${origin}/api/source?workspace=other&version=${version}`)).status).toBe(404); const production = new Miniflare({ cf: false, port: 0, workers: [{ ...runtimeOptions.workers![0]!, config: { ...runtimeOptions.workers![0]!.config, env: { ...runtimeOptions.workers![0]!.config.env, ENVIRONMENT: { type: "text", value: "production" } } } }] }); try { - for (const path of ["/", "/index.html", "/gallery.js", "/mcp", "/api/gallery", "/api/source", "/gallery/preview", "/api/canvas/request"]) { + for (const path of ["/", "/index.html", "/gallery.js", "/mcp", "/api/gallery", "/api/source", "/gallery/preview", "/api/canvas/request", "/api/plugins/call"]) { expect((await production.dispatchFetch(`https://canvas.example${path}`)).status).toBe(503); } } finally { await production.dispose(); } @@ -297,10 +320,19 @@ test("verified users have isolated private libraries and can collaborate in the 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); + const pluginBody = JSON.stringify({ plugin: "missing-plugin-for-test", operation: "read", input: {} }); + expect((await fetch(address+"/private-canvas/_canvas/plugins",{method:"POST",body:pluginBody})).status).toBe(401); + expect((await fetch(address+"/private-canvas/_canvas/plugins",{method:"POST",headers:{"Cf-Access-Jwt-Assertion":bobToken},body:pluginBody})).status).toBe(404); + const privatePlugin = await fetch(address+"/private-canvas/_canvas/plugins",{method:"POST",headers:{"Cf-Access-Jwt-Assertion":aliceToken},body:pluginBody}); + expect(privatePlugin.status).toBe(404); + expect(await privatePlugin.json()).toEqual({error:"Plugin operation not found"}); 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); + for (const headers of [{}, {"Cf-Access-Jwt-Assertion":aliceToken}, {Cookie:"session=ambient"}]) { + expect((await fetch(address+"/private-canvas/_canvas/plugins",{method:"POST",headers,body:pluginBody})).status).toBe(401); + } 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. @@ -328,7 +360,7 @@ test("verified users have isolated private libraries and can collaborate in the await Promise.all(clients.map(remote => remote.close())); await production.dispose(); } -}, 60000); +}, 120000); 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) { diff --git a/cloudflare/worker.ts b/cloudflare/worker.ts index 9940b1c..0b68474 100644 --- a/cloudflare/worker.ts +++ b/cloudflare/worker.ts @@ -11,6 +11,8 @@ import { readRequestText } from "./http"; import { CanvasBackend } from "./backend"; import { canvas_request as validateRequest } from "../dist/cloudflare/tool-validators.js"; import type { CanvasHttpRequest } from "../src/httpTypes"; +import { PluginError, PLUGIN_JSON_LIMIT } from "./plugins"; +import type { PluginRequest } from "../src/plugins/types"; import galleryBridge from "../dist/cloudflare/gallery-request.json"; import { ArtifactLinks } from "./links"; @@ -67,7 +69,7 @@ app.use("*", async (c, next) => { if (c.env.AUTH_MODE && !["access", "better-auth"].includes(c.env.AUTH_MODE)) return c.json({ error: "AUTH_MODE must be access or better-auth" }, 503); const user = c.env.AUTH_MODE === "better-auth" ? await authenticateBetterAuth(c.req.raw, c.env) : null; if (user instanceof Response) return user; - const identity = user ? { subject: user.id } : await authenticate(c.req.raw, c.env); + const identity = user ? { subject: user.id, authority: "better-auth" } : await authenticate(c.req.raw, c.env); if (identity instanceof Response) return identity; c.set("user", user); const workspace = (url.searchParams.get("workspace") ?? c.env.DEFAULT_WORKSPACE ?? "default").trim(); @@ -80,7 +82,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, { links: c.env.LINKS.getByName("deployment"), scripts: c.env.SCRIPTS.getByName(libraryKey), scriptBackends: c.env.SCRIPT_BACKENDS, origin: url.origin })); + 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 }, { user: identity, env: c.env })); // 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(); @@ -105,6 +107,12 @@ app.get("/api/gallery", async c => { if (!Number.isSafeInteger(offset) || offset < 0) return c.json({ error: "Invalid offset" }, 400); return c.json({ ...await c.get("service").gallery(c.req.query("all") === "1", offset), libraryScope: c.get("libraryScope") }); }); +app.post("/api/plugins/call", async c => { + let input: PluginRequest; + try { input = JSON.parse(await readRequestText(c.req.raw, PLUGIN_JSON_LIMIT)); } + catch (error) { return c.json({ error: error instanceof RangeError ? "Plugin input exceeds 256 KiB" : "Invalid JSON" }, error instanceof RangeError ? 413 : 400); } + return c.json({ result: await c.get("service").pluginCall(input) }); +}); app.post("/api/canvas/request", async c => { let input: unknown; try { input = JSON.parse(await readRequestText(c.req.raw, 1024 * 1024)); } @@ -143,11 +151,11 @@ app.get("/gallery/preview", async c => { // Enforce isolation even if somebody opens the preview URL directly instead // of through the gallery's sandboxed iframe. Preview state is page-local. c.header("Content-Security-Policy", "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'self'"); - return c.html(`Sidequery Canvas preview
`); + return c.html(`Sidequery Canvas preview
`); }); app.get("/", c => c.env.ASSETS.fetch(new Request(new URL("/index.html", c.req.url)))); app.get("/gallery", c => c.env.ASSETS.fetch(new Request(new URL("/index.html", c.req.url)))); app.get("/gallery.js", c => c.env.ASSETS.fetch(c.req.raw)); -app.onError((error, c) => c.json({ error: error.message }, error instanceof CanvasAuthConfigurationError ? 503 : /not found/.test(error.message) ? 404 : 400)); +app.onError((error, c) => c.json({ error: error.message }, error instanceof PluginError ? error.status : error instanceof CanvasAuthConfigurationError ? 503 : /not found/.test(error.message) ? 404 : 400)); export default app; diff --git a/docs/runtime-plugins.md b/docs/runtime-plugins.md new file mode 100644 index 0000000..bd7b8d0 --- /dev/null +++ b/docs/runtime-plugins.md @@ -0,0 +1,74 @@ +# Deployment plugins + +A deployment can install ordinary browser packages and expose authenticated server functions. Canvases import the browser packages and call functions through the existing viewer bridge. Routing is a separate feature. + +## Configure a deployment + +Install dependencies with Bun and add `canvas.plugins.ts` at the repository root. `CANVAS_PLUGINS_CONFIG` can select another config file, relative to the repository root. Build both the compiler Worker and app Worker with the same configuration, then deploy them normally. The local CLI compiler reads the same generated browser registry. + +```ts +import { definePlugins } from "@sidequery/canvas/plugins"; + +export default definePlugins([ + { + name: "@company/ui", + description: "Company components and API client", + browser: "@company/canvas-ui", + }, + { + name: "company-directory", + description: "Look up directory records", + secrets: ["DIRECTORY_TOKEN"], + operations: { + lookup: { + description: "Look up a directory record by ID", + readOnly: true, + inputSchema: { + type: "object", + properties: { id: { type: "string", minLength: 1 } }, + required: ["id"], + additionalProperties: false, + }, + async handler(input: { id: string }, { user, secrets, signal }) { + // Enforce record-level access for user.subject + user.authority here. + const response = await fetch( + `https://directory.example/records/${encodeURIComponent(input.id)}`, + { headers: { authorization: `Bearer ${secrets.DIRECTORY_TOKEN}` }, signal }, + ); + if (!response.ok) throw new Error("Directory lookup failed"); + return response.json(); + }, + }, + }, + }, +]); +``` + +Configure secret values as app Worker bindings through the existing deployment tooling. The config lists binding names, never values. Plugins are trusted deployment code; the binding selection is an explicit handler interface, not isolation from other installed server code. + +A browser entry can be an installed package or a source path relative to the config. Declarations are collected at build time; use `types` for an explicit declaration entry when needed. Only configured public package names are available to canvas imports. Add separate entries for public submodules under distinct names. Browser entries must bundle into one browser-compatible JavaScript module. React and the Canvas SDK share the host runtime. Browser bundles and their declarations must contain only public code; they are delivered to viewers. + +## Call a function + +An installed browser library can wrap the bridge with its own typed API: + +```ts +import { pluginCall } from "sidequery/canvas"; + +type DirectoryRecord = { id: string; name: string }; +export function lookupRecord(id: string, signal?: AbortSignal) { + return pluginCall("company-directory", "lookup", { id }, { signal }); +} +``` + +Canvas source can import that wrapper or call `pluginCall` directly. `plugins_list` exposes installed names, descriptions, JSON schemas and read-only hints through MCP; `plugin_guide` explains the bridge. `canvas_plugin_call` uses `{ plugin, operation, input }` and returns `{ result }` in structured content. The generic return type is the library author's contract; optional `outputSchema` validates results on the server. + +## Authentication and behavior + +Every call requires an authenticated deployment user. Handlers receive `{ user: { subject, authority }, secrets, signal }`. Omitting `authorize` allows any authenticated deployment user; an optional `authorize(user)` can restrict the whole operation, and the handler must enforce any input-dependent or record-level access. Workspace, library, canvas and version selectors do not change the caller's authority. + +Hosted gallery previews, authenticated private standalone canvases and hosted MCP Apps use the bridge. Public standalone canvases cannot call functions, even if the browser has a signed-in session. Local compilation supports the built browser libraries; a view without an authenticated hosted bridge rejects function calls. Canvas backends and scripts do not receive the bridge. + +Inputs and outputs are JSON, limited to 256 KiB. Inputs are validated against `inputSchema`; `outputSchema` is optional. Calls time out after 30 seconds, and handlers receive an abort signal. Cancellation stops waiting in the browser; it does not roll back server work. Handlers should respect the signal and provide their own idempotency where needed. Server exceptions return generic errors without provider details or secrets. + +Plugins follow normal deployment upgrades. There is no per-canvas plugin version or retained historical runtime: saved canvases use the deployment's current plugin packages. Keep APIs compatible when upgrading. This foundation does not manage provider OAuth, install packages at runtime, add artifact permission manifests or implement routing. diff --git a/e2e/browser-plugins.test.ts b/e2e/browser-plugins.test.ts new file mode 100644 index 0000000..0cb7dea --- /dev/null +++ b/e2e/browser-plugins.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test"; +import { chromium } from "playwright"; +import { prepareBrowserPlugins } from "../scripts/prepare-browser-plugins"; +import { compileCanvas } from "../src/compile"; +import { tempDir, writeCanvas } from "../src/test/fixtures"; +import plugins from "../src/test/plugins/config"; +const registry = await prepareBrowserPlugins(plugins, new URL("../src/test/plugins", import.meta.url).pathname); +const source = `import { PluginCounter } from "@test/counter"; +export default function Canvas() { return ; }`; + +test("prepared plugin hooks share the renderer React and execute an installed transitive library", async () => { + const result = await compileCanvas(writeCanvas(tempDir(), "plugin", source), undefined, registry); + 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('
'); + await page.addScriptTag({ type: "module", content: result.js! }); + await page.getByRole("button", { name: "UGx1Z2luIDA", exact: true }).click(); + await page.getByRole("button", { name: "UGx1Z2luIDE", exact: true }).waitFor(); + expect(errors).toEqual([]); + } finally { await browser.close(); } +}, 30000); diff --git a/e2e/mcp-app.test.ts b/e2e/mcp-app.test.ts index ff808b6..68944c6 100644 --- a/e2e/mcp-app.test.ts +++ b/e2e/mcp-app.test.ts @@ -475,3 +475,32 @@ test("ordinary React hooks update component state through the canonical and lega await page.close(); } }, 30000); + + +test("routes plugin calls through MCP and removes the bridge for an unavailable view", async () => { + const source = `import { Button, pluginCall, useState } from "sidequery/canvas"; +export default function Canvas() { + const [value, setValue] = useState("idle"); + return <>

{value}

; +}`; + const meta = await resultFor(source, "plugin-call"); + meta.canvas.plugins = true; + const { page, app, errors } = await openHost(); + await page.evaluate(() => window.mcpHost!.setServerToolResult({ content: [], structuredContent: { result: "Found one" } })); + await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], _meta: meta }), meta); + await app.getByRole("button", { name: "Lookup" }).click(); + await app.getByText("Found one").waitFor(); + expect(await page.evaluate(() => window.mcpHost!.serverToolCalls.map(({ name, arguments: args }) => ({ name, arguments: args })))).toEqual([ + { name: "canvas_plugin_call", arguments: { plugin: "directory", operation: "lookup", input: { id: "one" } } }, + ]); + await page.evaluate(() => window.mcpHost!.setServerToolResult({ content: [{ type: "text", text: "Operation denied" }], isError: true })); + await app.getByRole("button", { name: "Lookup" }).click(); + await app.getByText("Operation denied").waitFor(); + meta.canvas.plugins = false; + await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], _meta: meta }), meta); + await app.getByRole("button", { name: "Lookup" }).click(); + await app.getByText("Plugin functions are unavailable in this view").waitFor(); + expect(await page.evaluate(() => window.mcpHost!.serverToolCalls.length)).toBe(2); + expect(errors).toEqual([]); + await page.close(); +}, 30_000); diff --git a/e2e/plugin-bridge.test.ts b/e2e/plugin-bridge.test.ts new file mode 100644 index 0000000..d37fb29 --- /dev/null +++ b/e2e/plugin-bridge.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test"; +import { chromium } from "playwright"; + +test("gallery and standalone frame bridge requires enablement and accepts only its parent response", async () => { + const build = await Bun.build({ entrypoints: [new URL("../src/runtime/gallery-request.ts", import.meta.url).pathname], target: "browser", format: "iife" }); + expect(build.success).toBe(true); + const bridge = await build.outputs[0]!.text(); + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("", { headers: { "content-type": "text/html" } }) }); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + page.setDefaultTimeout(5000); + const errors: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + await page.goto(`http://127.0.0.1:${server.port}`); + await page.setContent(''); + await page.evaluate(bridge => { + window.addEventListener("message", event => { + const frame = document.querySelector("#canvas")!; + if (event.source !== frame.contentWindow || event.data?.type !== "canvas/plugin-request") return; + // A sibling's forged response has the right ID but the wrong source. + document.querySelector("#other")!.contentWindow!.postMessage(event.data, "*"); + setTimeout(() => frame.contentWindow!.postMessage({ type: "canvas/plugin-response", id: event.data.id, result: event.data.request.input }, "*"), 100); + }); + document.querySelector("#other")!.srcdoc = `