diff --git a/.changeset/workerd-capability-vocabulary.md b/.changeset/workerd-capability-vocabulary.md new file mode 100644 index 0000000000..35f3571266 --- /dev/null +++ b/.changeset/workerd-capability-vocabulary.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/sandbox-workerd": patch +--- + +Fixes sandboxed plugins being denied on the workerd runner when their manifest declares current capability names such as `content:read`, `media:write`, `users:read` or `network:request`. Manifests using older capability aliases keep working, and permission errors now name the current capability. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04a1a1228b..b194910733 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,6 +130,14 @@ jobs: # Render tests use the Astro Vite plugin (vitest.repro.config.ts); # they can't run under the plain-node config in test:unit. - run: pnpm --filter emdash exec vitest run --config vitest.repro.config.ts + # Sandbox capability enforcement lives in the workerd package, which + # test:unit does not cover. Only the files that need no workerd or + # Miniflare startup run here. + - run: >- + pnpm --filter @emdash-cms/sandbox-workerd exec vitest run + test/bridge-capability-aliases.test.ts + test/wrapper-marshal.test.ts + test/plugin-integration.test.ts test-smoke: name: Smoke Tests diff --git a/packages/workerd/src/sandbox/bridge-handler.ts b/packages/workerd/src/sandbox/bridge-handler.ts index 71359c9dae..1e76dac2d2 100644 --- a/packages/workerd/src/sandbox/bridge-handler.ts +++ b/packages/workerd/src/sandbox/bridge-handler.ts @@ -19,6 +19,7 @@ import { createHttpAccess, createSandboxRouteErrorEnvelope, createUnrestrictedHttpAccess, + normalizeCapabilities, PluginStorageRepository, resolveContentCreateLocale, } from "emdash"; @@ -122,6 +123,13 @@ export interface BridgeHandlerOptions { export function createBridgeHandler( opts: BridgeHandlerOptions, ): (request: Request) => Promise { + // Capability arrays may contain legacy aliases from older manifests; + // everything below compares against current names only. + const resolved: BridgeHandlerOptions = { + ...opts, + capabilities: normalizeCapabilities(opts.capabilities), + }; + return async (request: Request): Promise => { try { const url = new URL(request.url); @@ -139,7 +147,7 @@ export function createBridgeHandler( } } - const result = await dispatch(opts, method, body); + const result = await dispatch(resolved, method, body); return Response.json({ result }); } catch (error) { const sandboxRouteError = createSandboxRouteErrorEnvelope(error); @@ -180,13 +188,13 @@ async function dispatch( // ── Content ───────────────────────────────────────────────────── case "content/get": - requireCapability(opts, "read:content"); + requireCapability(opts, "content:read"); return contentGet(db, requireString(body, "collection"), requireString(body, "id")); case "content/list": - requireCapability(opts, "read:content"); + requireCapability(opts, "content:read"); return contentList(db, requireString(body, "collection"), body); case "content/create": - requireCapability(opts, "write:content"); + requireCapability(opts, "content:write"); const createOptions = optionalRecord(body, "options"); const locale = resolveContentCreateLocale( createOptions ? optionalString(createOptions, "locale") : undefined, @@ -200,7 +208,7 @@ async function dispatch( locale, ); case "content/update": - requireCapability(opts, "write:content"); + requireCapability(opts, "content:write"); await opts.beforeContentWrite?.(); return contentUpdate( db, @@ -209,11 +217,11 @@ async function dispatch( requireRecord(body, "data"), ); case "content/delete": - requireCapability(opts, "write:content"); + requireCapability(opts, "content:write"); await opts.beforeContentWrite?.(); return contentDelete(db, requireString(body, "collection"), requireString(body, "id")); case "content/createMany": - requireCapability(opts, "write:content"); + requireCapability(opts, "content:write"); const createManyLocale = resolveContentCreateLocale(undefined, opts.i18nConfig ?? null); await opts.beforeContentWrite?.(); return contentCreateMany( @@ -223,7 +231,7 @@ async function dispatch( createManyLocale, ); case "content/updateMany": - requireCapability(opts, "write:content"); + requireCapability(opts, "content:write"); await opts.beforeContentWrite?.(); return contentUpdateMany( db, @@ -231,7 +239,7 @@ async function dispatch( requireUpdateManyItems(body, "items"), ); case "content/deleteMany": - requireCapability(opts, "write:content"); + requireCapability(opts, "content:write"); await opts.beforeContentWrite?.(); return contentDeleteMany( db, @@ -260,13 +268,13 @@ async function dispatch( // ── Media ─────────────────────────────────────────────────────── case "media/get": - requireCapability(opts, "read:media"); + requireCapability(opts, "media:read"); return mediaGet(db, requireString(body, "id")); case "media/list": - requireCapability(opts, "read:media"); + requireCapability(opts, "media:read"); return mediaList(db, body); case "media/upload": - requireCapability(opts, "write:media"); + requireCapability(opts, "media:write"); return mediaUpload( db, requireString(body, "filename"), @@ -276,12 +284,12 @@ async function dispatch( opts.storage, ); case "media/delete": - requireCapability(opts, "write:media"); + requireCapability(opts, "media:write"); return mediaDelete(db, requireString(body, "id"), opts.storage); // ── HTTP ──────────────────────────────────────────────────────── case "http/fetch": - requireCapability(opts, "network:fetch"); + requireCapability(opts, "network:request"); return httpFetch(requireString(body, "url"), body.init, opts); // ── Email ─────────────────────────────────────────────────────── @@ -296,13 +304,13 @@ async function dispatch( // ── Users ─────────────────────────────────────────────────────── case "users/get": - requireCapability(opts, "read:users"); + requireCapability(opts, "users:read"); return userGet(db, requireString(body, "id")); case "users/getByEmail": - requireCapability(opts, "read:users"); + requireCapability(opts, "users:read"); return userGetByEmail(db, requireString(body, "email")); case "users/list": - requireCapability(opts, "read:users"); + requireCapability(opts, "users:read"); return userList(db, body); // ── Storage (document store, scoped to declared collections) ──── @@ -540,18 +548,24 @@ function requireOrderBy( function requireCapability(opts: BridgeHandlerOptions, capability: string): void { // Strict capability check matching the Cloudflare PluginBridge. // We do NOT imply write → read here: a plugin that declares only - // write:content cannot call ctx.content.get/list. The plugin must - // declare read:content explicitly. This matches the Cloudflare bridge + // content:write cannot call ctx.content.get/list. The plugin must + // declare content:read explicitly. This matches the Cloudflare bridge // behavior and ensures sandboxed plugins behave the same on both runners. // // Note: the in-process PluginContextFactory in core does build the read // API onto the write object, so a trusted plugin can read with only - // write:content. The sandbox bridges are stricter on purpose — they + // content:write. The sandbox bridges are stricter on purpose — they // enforce the manifest as written. // - // The one exception: network:fetch:any is documented as a strict - // superset of network:fetch, so the broader capability satisfies it. - if (capability === "network:fetch" && opts.capabilities.includes("network:fetch:any")) return; + // The one exception: network:request:unrestricted is documented as a + // strict superset of network:request, so the broader capability + // satisfies it. + if ( + capability === "network:request" && + opts.capabilities.includes("network:request:unrestricted") + ) { + return; + } if (!opts.capabilities.includes(capability)) { // Error message matches Cloudflare PluginBridge format throw new Error(`Missing capability: ${capability}`); @@ -1424,7 +1438,7 @@ async function httpFetch( headers: Record; bodyBase64: string; }> { - const hasAnyFetch = opts.capabilities.includes("network:fetch:any"); + const hasAnyFetch = opts.capabilities.includes("network:request:unrestricted"); const httpAccess = hasAnyFetch ? createUnrestrictedHttpAccess(opts.pluginId) : createHttpAccess(opts.pluginId, opts.allowedHosts || []); diff --git a/packages/workerd/src/sandbox/capnp.ts b/packages/workerd/src/sandbox/capnp.ts index 4fb0db69ce..1908b5c3db 100644 --- a/packages/workerd/src/sandbox/capnp.ts +++ b/packages/workerd/src/sandbox/capnp.ts @@ -124,7 +124,7 @@ export function generateCapnpConfig(options: CapnpOptions): string { // // In other words: plugins cannot reach the internet by calling plain // fetch(). They must use ctx.http.fetch(), which goes through the - // http/fetch bridge handler, which enforces network:fetch capability + // http/fetch bridge handler, which enforces network:request capability // and the allowedHosts allowlist. lines.push(` globalOutbound = "emdash-backing",`); // Note: workerd capnp config does not support per-worker cpu/memory diff --git a/packages/workerd/src/sandbox/dev-runner.ts b/packages/workerd/src/sandbox/dev-runner.ts index b34b2db524..cd0278b960 100644 --- a/packages/workerd/src/sandbox/dev-runner.ts +++ b/packages/workerd/src/sandbox/dev-runner.ts @@ -190,7 +190,7 @@ export class MiniflareDevRunner implements SandboxRunner { // outboundService intercepts all fetch() calls from this worker. // Calls to http://bridge/... go to the Node bridge handler. - // Other calls pass through for network:fetch. + // Other calls pass through for network:request. workerConfigs.push({ name: pluginId.replace(SAFE_ID_RE, "_"), // The wrapper imports "sandbox-plugin.js", so we provide both @@ -207,13 +207,13 @@ export class MiniflareDevRunner implements SandboxRunner { // Only allow bridge calls. Any other outbound fetch is blocked // to enforce that all network access goes through ctx.http.fetch // (which routes via the bridge with capability + host validation). - // Without this, plugins could bypass network:fetch / allowedHosts + // Without this, plugins could bypass network:request / allowedHosts // by calling plain fetch() directly. if (url.hostname === "bridge") { return bridgeHandler(request); } return new Response( - `Direct fetch() blocked in sandbox. Plugin "${manifest.id}" must use ctx.http.fetch() (requires network:fetch capability).`, + `Direct fetch() blocked in sandbox. Plugin "${manifest.id}" must use ctx.http.fetch() (requires network:request capability).`, { status: 403 }, ); }, diff --git a/packages/workerd/src/sandbox/wrapper.ts b/packages/workerd/src/sandbox/wrapper.ts index c935f34fa9..0990cf2596 100644 --- a/packages/workerd/src/sandbox/wrapper.ts +++ b/packages/workerd/src/sandbox/wrapper.ts @@ -11,7 +11,7 @@ * - Exposes an HTTP fetch handler for hook/route invocation */ -import type { PluginManifest } from "emdash"; +import { normalizeCapabilities, type PluginManifest } from "emdash"; const TRAILING_SLASH_RE = /\/$/; const NEWLINE_RE = /[\n\r]/g; @@ -38,8 +38,10 @@ export interface WrapperOptions { export function generatePluginWrapper(manifest: PluginManifest, options: WrapperOptions): string { const site = options.site ?? { name: "", url: "", locale: "en" }; - const hasReadUsers = manifest.capabilities.includes("read:users"); - const hasEmailSend = manifest.capabilities.includes("email:send"); + // Manifests written before the capability rename carry legacy names. + const capabilities = normalizeCapabilities(manifest.capabilities ?? []); + const hasReadUsers = capabilities.includes("users:read"); + const hasEmailSend = capabilities.includes("email:send"); return ` // ============================================================================= diff --git a/packages/workerd/test/bridge-capability-aliases.test.ts b/packages/workerd/test/bridge-capability-aliases.test.ts new file mode 100644 index 0000000000..bc423cf9b4 --- /dev/null +++ b/packages/workerd/test/bridge-capability-aliases.test.ts @@ -0,0 +1,268 @@ +/** + * Capability vocabulary tests for the workerd sandbox. + * + * Manifests reach the bridge in either vocabulary: current names from a + * freshly published plugin, or legacy aliases carried by an older + * manifest. Both must authorize the same operations, and denials must + * name the current capability. + */ + +import Database from "better-sqlite3"; +import { Kysely, SqliteDialect } from "kysely"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +import { createBridgeHandler } from "../src/sandbox/bridge-handler.js"; + +function createTestDb() { + const sqlite = new Database(":memory:"); + const db = new Kysely({ + dialect: new SqliteDialect({ database: sqlite }), + }); + return { db, sqlite }; +} + +async function setupTables(db: Kysely) { + await db.schema + .createTable("_plugin_storage") + .addColumn("plugin_id", "text", (col) => col.notNull()) + .addColumn("collection", "text", (col) => col.notNull()) + .addColumn("id", "text", (col) => col.notNull()) + .addColumn("data", "text", (col) => col.notNull()) + .addColumn("created_at", "text", (col) => col.notNull()) + .addColumn("updated_at", "text", (col) => col.notNull()) + .addPrimaryKeyConstraint("pk_plugin_storage", ["plugin_id", "collection", "id"]) + .execute(); + + await db.schema + .createTable("ec_posts") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("slug", "text") + .addColumn("status", "text") + .addColumn("author_id", "text") + .addColumn("created_at", "text") + .addColumn("updated_at", "text") + .addColumn("published_at", "text") + .addColumn("scheduled_at", "text") + .addColumn("deleted_at", "text") + .addColumn("version", "integer") + .addColumn("live_revision_id", "text") + .addColumn("draft_revision_id", "text") + .addColumn("locale", "text") + .addColumn("translation_group", "text") + .addColumn("title", "text") + .execute(); + + await db.schema + .createTable("media") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("filename", "text") + .addColumn("mime_type", "text") + .addColumn("size", "integer") + .addColumn("storage_key", "text") + .addColumn("created_at", "text") + .execute(); + + await db.schema + .createTable("users") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("email", "text", (col) => col.notNull()) + .addColumn("name", "text") + .addColumn("role", "integer", (col) => col.notNull()) + .addColumn("created_at", "text", (col) => col.notNull()) + .execute(); +} + +describe("Bridge Handler capability vocabulary", () => { + let db: Kysely; + let sqlite: Database.Database; + + beforeEach(async () => { + const ctx = createTestDb(); + db = ctx.db; + sqlite = ctx.sqlite; + await setupTables(db); + }); + + afterEach(async () => { + await db.destroy(); + sqlite.close(); + }); + + function makeHandler(capabilities: string[], allowedHosts: string[] = []) { + return createBridgeHandler({ + pluginId: "test-plugin", + version: "1.0.0", + capabilities, + allowedHosts, + storageCollections: [], + db, + emailSend: () => null, + }); + } + + async function call( + capabilities: string[], + method: string, + body: Record = {}, + allowedHosts: string[] = [], + ) { + const request = new Request(`http://bridge/${method}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const response = await makeHandler(capabilities, allowedHosts)(request); + return response.json() as Promise<{ result?: any; error?: string }>; + } + + describe.each([ + { + domain: "content read", + current: "content:read", + legacy: "read:content", + method: "content/list", + body: { collection: "posts" }, + }, + { + domain: "content write", + current: "content:write", + legacy: "write:content", + method: "content/create", + body: { collection: "posts", data: { title: "Drafted" } }, + }, + { + domain: "media read", + current: "media:read", + legacy: "read:media", + method: "media/get", + body: { id: "missing" }, + }, + { + domain: "media write", + current: "media:write", + legacy: "write:media", + method: "media/delete", + body: { id: "missing" }, + }, + { + domain: "users read", + current: "users:read", + legacy: "read:users", + method: "users/get", + body: { id: "missing" }, + }, + ])("$domain", ({ current, legacy, method, body }) => { + it("authorizes the current capability name", async () => { + const result = await call([current], method, body); + expect(result.error).toBeUndefined(); + }); + + it("authorizes the legacy capability name", async () => { + const result = await call([legacy], method, body); + expect(result.error).toBeUndefined(); + }); + + it("denies with the current capability name when undeclared", async () => { + const result = await call([], method, body); + expect(result.error).toBe(`Missing capability: ${current}`); + }); + }); + + describe("network", () => { + const BLOCKED_URL = "http://blocked.test/resource"; + + it.each(["network:request", "network:fetch"])( + "applies the allowedHosts policy with %s", + async (capability) => { + const result = await call([capability], "http/fetch", { url: BLOCKED_URL }, [ + "allowed.test", + ]); + expect(result.error).toContain("not allowed to fetch from host"); + }, + ); + + it.each(["network:request:unrestricted", "network:fetch:any"])( + "skips the allowedHosts policy with %s", + async (capability) => { + const result = await call([capability], "http/fetch", { url: BLOCKED_URL }); + expect(result.error).not.toContain("Missing capability"); + expect(result.error).not.toContain("allowedHosts"); + }, + ); + + it("denies with the current capability name when undeclared", async () => { + const result = await call([], "http/fetch", { url: BLOCKED_URL }); + expect(result.error).toBe("Missing capability: network:request"); + }); + }); + + describe("capability implication", () => { + it("does not let content:write authorize reads", async () => { + const result = await call(["content:write"], "content/list", { collection: "posts" }); + expect(result.error).toBe("Missing capability: content:read"); + }); + + it("does not let write:content authorize reads", async () => { + const result = await call(["write:content"], "content/list", { collection: "posts" }); + expect(result.error).toBe("Missing capability: content:read"); + }); + }); + + describe("unknown capability names", () => { + it.each([ + { + domain: "content read", + method: "content/list", + body: { collection: "posts" }, + denied: "content:read", + }, + { + domain: "content write", + method: "content/create", + body: { collection: "posts", data: { title: "Drafted" } }, + denied: "content:write", + }, + { domain: "media read", method: "media/list", body: {}, denied: "media:read" }, + { domain: "users read", method: "users/list", body: {}, denied: "users:read" }, + { + domain: "network", + method: "http/fetch", + body: { url: "https://example.com/" }, + denied: "network:request", + }, + ])( + "$domain stays denied and names the current capability", + async ({ method, body, denied }) => { + const result = await call(["content:everything", "made:up"], method, body); + expect(result.error).toBe(`Missing capability: ${denied}`); + }, + ); + }); + + describe("publishable manifest", () => { + const MANIFEST_CAPABILITIES = [ + "content:read", + "content:write", + "media:read", + "users:read", + "network:request", + ]; + + it("authorizes every operation the manifest declares", async () => { + const list = await call(MANIFEST_CAPABILITIES, "content/list", { collection: "posts" }); + expect(list.error).toBeUndefined(); + + const created = await call(MANIFEST_CAPABILITIES, "content/create", { + collection: "posts", + data: { title: "Drafted" }, + }); + expect(created.error).toBeUndefined(); + + const media = await call(MANIFEST_CAPABILITIES, "media/get", { id: "missing" }); + expect(media.error).toBeUndefined(); + + const user = await call(MANIFEST_CAPABILITIES, "users/get", { id: "missing" }); + expect(user.error).toBeUndefined(); + }); + }); +}); diff --git a/packages/workerd/test/bridge-handler.test.ts b/packages/workerd/test/bridge-handler.test.ts index db71150c5c..eaf3c22c94 100644 --- a/packages/workerd/test/bridge-handler.test.ts +++ b/packages/workerd/test/bridge-handler.test.ts @@ -180,16 +180,16 @@ describe("Bridge Handler Conformance", () => { // ── Capability Enforcement ──────────────────────────────────────────── describe("capability enforcement", () => { - it("rejects content read without read:content capability", async () => { + it("rejects content read without content:read capability", async () => { const handler = makeHandler({ capabilities: [] }); const result = await call(handler, "content/get", { collection: "posts", id: "123", }); - expect(result.error).toContain("Missing capability: read:content"); + expect(result.error).toContain("Missing capability: content:read"); }); - it("allows content read with read:content", async () => { + it("allows content read with content:read", async () => { // Create a content table first await db.schema .createTable("ec_posts") @@ -198,7 +198,7 @@ describe("Bridge Handler Conformance", () => { .addColumn("title", "text") .execute(); - const handler = makeHandler({ capabilities: ["read:content"] }); + const handler = makeHandler({ capabilities: ["content:read"] }); const result = await call(handler, "content/get", { collection: "posts", id: "123", @@ -208,11 +208,11 @@ describe("Bridge Handler Conformance", () => { expect(result.result).toBeNull(); }); - it("write:content does NOT imply read:content (matches Cloudflare bridge)", async () => { + it("content:write does NOT imply content:read (matches Cloudflare bridge)", async () => { // The bridge enforces capabilities strictly: a plugin that declares - // only write:content cannot call ctx.content.get/list. This matches + // only content:write cannot call ctx.content.get/list. This matches // the Cloudflare PluginBridge behavior. The plugin must declare - // read:content explicitly to read. + // content:read explicitly to read. await db.schema .createTable("ec_posts") .addColumn("id", "text", (col) => col.primaryKey()) @@ -220,18 +220,18 @@ describe("Bridge Handler Conformance", () => { .addColumn("title", "text") .execute(); - const handler = makeHandler({ capabilities: ["write:content"] }); + const handler = makeHandler({ capabilities: ["content:write"] }); const result = await call(handler, "content/get", { collection: "posts", id: "123", }); - expect(result.error).toContain("Missing capability: read:content"); + expect(result.error).toContain("Missing capability: content:read"); }); it("rejects taxonomy read without taxonomies:read capability", async () => { // content:read does not grant taxonomy access — it's a separate // capability (and a new one, so the canonical name is checked). - const handler = makeHandler({ capabilities: ["read:content"] }); + const handler = makeHandler({ capabilities: ["content:read"] }); const result = await call(handler, "taxonomy/list", {}); expect(result.error).toContain("Missing capability: taxonomies:read"); }); @@ -331,7 +331,7 @@ describe("Bridge Handler Conformance", () => { .values({ collection: "posts", entry_id: "post-1", taxonomy_id: "tg-scifi" }) .execute(); - const denied = makeHandler({ capabilities: ["read:content"] }); + const denied = makeHandler({ capabilities: ["content:read"] }); expect((await call(denied, "taxonomy/terms", { taxonomy: "genre" })).error).toContain( "Missing capability: taxonomies:read", ); @@ -371,14 +371,14 @@ describe("Bridge Handler Conformance", () => { expect((localized.result as unknown[]).length).toBe(1); }); - it("rejects user read without read:users capability", async () => { + it("rejects user read without users:read capability", async () => { const handler = makeHandler({ capabilities: [] }); const result = await call(handler, "users/get", { id: "user-1" }); - expect(result.error).toContain("Missing capability: read:users"); + expect(result.error).toContain("Missing capability: users:read"); }); - it("allows user read with read:users", async () => { - const handler = makeHandler({ capabilities: ["read:users"] }); + it("allows user read with users:read", async () => { + const handler = makeHandler({ capabilities: ["users:read"] }); const result = await call(handler, "users/get", { id: "user-1" }); expect(result.error).toBeUndefined(); const user = result.result as { id: string; email: string }; @@ -386,12 +386,12 @@ describe("Bridge Handler Conformance", () => { expect(user.email).toBe("test@example.com"); }); - it("rejects network fetch without network:fetch capability", async () => { + it("rejects network fetch without network:request capability", async () => { const handler = makeHandler({ capabilities: [] }); const result = await call(handler, "http/fetch", { url: "https://example.com", }); - expect(result.error).toContain("Missing capability: network:fetch"); + expect(result.error).toContain("Missing capability: network:request"); }); it("rejects email send without email:send capability", async () => { @@ -484,7 +484,7 @@ describe("Bridge Handler Conformance", () => { }); it("returns error for missing required parameters", async () => { - const handler = makeHandler({ capabilities: ["read:content"] }); + const handler = makeHandler({ capabilities: ["content:read"] }); const result = await call(handler, "content/get", {}); expect(result.error).toContain("Missing required string parameter"); }); @@ -507,7 +507,7 @@ describe("Bridge Handler Conformance", () => { .execute(); } - const handler = makeHandler({ capabilities: ["read:content"] }); + const handler = makeHandler({ capabilities: ["content:read"] }); const result = await call(handler, "content/list", { collection: "posts", limit: -5, @@ -544,7 +544,7 @@ describe("Bridge Handler Conformance", () => { .execute(); } - const handler = makeHandler({ capabilities: ["read:media"] }); + const handler = makeHandler({ capabilities: ["media:read"] }); const result = await call(handler, "media/list", { limit: -5 }); expect(result.error).toBeUndefined(); const list = result.result as { items: unknown[] }; @@ -618,7 +618,7 @@ describe("Bridge Handler Conformance", () => { }); }); const handler = makeHandler({ - capabilities: ["write:content"], + capabilities: ["content:write"], beforeContentWrite, }); @@ -663,7 +663,7 @@ describe("Bridge Handler Conformance", () => { }); it("contentCreateMany rolls back when a mid-batch insert fails", async () => { - const handler = makeHandler({ capabilities: ["write:content"] }); + const handler = makeHandler({ capabilities: ["content:write"] }); // Pre-insert a row that will collide with item index 2's slug. await call(handler, "content/create", { collection: "atomic_posts", @@ -697,7 +697,7 @@ describe("Bridge Handler Conformance", () => { }); it("contentCreateMany commits all when no item fails", async () => { - const handler = makeHandler({ capabilities: ["write:content"] }); + const handler = makeHandler({ capabilities: ["content:write"] }); const result = await call(handler, "content/createMany", { collection: "atomic_posts", items: [ diff --git a/packages/workerd/test/dev-runner-route-error.test.ts b/packages/workerd/test/dev-runner-route-error.test.ts index 4ff85e7208..c351e3acc6 100644 --- a/packages/workerd/test/dev-runner-route-error.test.ts +++ b/packages/workerd/test/dev-runner-route-error.test.ts @@ -32,7 +32,7 @@ describe("Miniflare sandbox route errors", () => { { id: "content-writer", version: "1.0.0", - capabilities: ["write:content"], + capabilities: ["content:write"], allowedHosts: [], storage: {}, hooks: [], diff --git a/packages/workerd/test/miniflare-isolation.test.ts b/packages/workerd/test/miniflare-isolation.test.ts index 2eaa8ccc4f..006cbaca21 100644 --- a/packages/workerd/test/miniflare-isolation.test.ts +++ b/packages/workerd/test/miniflare-isolation.test.ts @@ -107,7 +107,7 @@ describe("miniflare plugin isolation", () => { it("plugins are isolated from each other", async () => { // Two plugins with different service bindings. - // Plugin A has BRIDGE binding (read:content). + // Plugin A has BRIDGE binding (content:read). // Plugin B has NO bridge binding (no capabilities). // Use separate Miniflare instances to test isolation, // since dispatchFetch always hits the first worker. diff --git a/packages/workerd/test/plugin-integration.test.ts b/packages/workerd/test/plugin-integration.test.ts index 63dc6c2c36..1eda0112c1 100644 --- a/packages/workerd/test/plugin-integration.test.ts +++ b/packages/workerd/test/plugin-integration.test.ts @@ -11,7 +11,7 @@ * Tests are modeled after the sandboxed-test plugin's routes: * - kv/test: set, get, delete a KV entry * - storage/test: put, get, count in a declared storage collection - * - content/list: list content with read:content capability + * - content/list: list content with content:read capability * - content lifecycle: create, read, update, soft-delete */ @@ -151,14 +151,14 @@ describe("Plugin integration: sandboxed-test plugin operations", () => { /** * Create a bridge handler matching the sandboxed-test plugin's capabilities: - * read:content, network:fetch with allowedHosts: ["httpbin.org"] + * content:read, network:request with allowedHosts: ["httpbin.org"] * storage: { events: { indexes: ["timestamp", "type"] } } */ function makePluginHandler() { return createBridgeHandler({ pluginId: "sandboxed-test", version: "0.0.1", - capabilities: ["read:content", "network:fetch"], + capabilities: ["content:read", "network:request"], allowedHosts: ["httpbin.org"], storageCollections: ["events"], db, @@ -238,7 +238,7 @@ describe("Plugin integration: sandboxed-test plugin operations", () => { // ── Mirrors sandboxed-test plugin's content/list route ─────────────── - it("Content list with read:content capability", async () => { + it("Content list with content:read capability", async () => { const handler = makePluginHandler(); // Seed some content @@ -283,14 +283,14 @@ describe("Plugin integration: sandboxed-test plugin operations", () => { // ── Content lifecycle: create, read, update, soft-delete ───────────── - describe("content lifecycle (requires read:content + write:content)", () => { + describe("content lifecycle (requires content:read + content:write)", () => { function makeWriteHandler(i18nConfig?: { defaultLocale: string; locales: string[] } | null) { - // Bridge enforces capabilities strictly: write:content does NOT - // imply read:content. Plugins that need both must declare both. + // Bridge enforces capabilities strictly: content:write does NOT + // imply content:read. Plugins that need both must declare both. return createBridgeHandler({ pluginId: "sandboxed-test", version: "0.0.1", - capabilities: ["read:content", "write:content"], + capabilities: ["content:read", "content:write"], allowedHosts: [], storageCollections: [], i18nConfig, @@ -519,17 +519,17 @@ describe("Plugin integration: sandboxed-test plugin operations", () => { // ── Capability enforcement matches real plugin config ───────────────── - it("sandboxed-test plugin cannot write content (only has read:content)", async () => { + it("sandboxed-test plugin cannot write content (only has content:read)", async () => { const handler = makePluginHandler(); const result = await call(handler, "content/create", { collection: "posts", data: { title: "Should fail" }, }); - expect(result.error).toContain("Missing capability: write:content"); + expect(result.error).toContain("Missing capability: content:write"); }); it("write-only plugin cannot read content (no implicit upgrade)", async () => { - // Plugins with only write:content cannot call ctx.content.get/list. + // Plugins with only content:write cannot call ctx.content.get/list. // This matches the Cloudflare PluginBridge: capabilities are enforced // strictly as declared in the manifest. A plugin that needs both // reads and writes must declare both capabilities. @@ -551,7 +551,7 @@ describe("Plugin integration: sandboxed-test plugin operations", () => { const writeOnlyHandler = createBridgeHandler({ pluginId: "write-only-plugin", version: "1.0.0", - capabilities: ["write:content"], + capabilities: ["content:write"], allowedHosts: [], storageCollections: [], db, @@ -563,15 +563,15 @@ describe("Plugin integration: sandboxed-test plugin operations", () => { collection: "pages", id: "any", }); - expect(getResult.error).toContain("Missing capability: read:content"); + expect(getResult.error).toContain("Missing capability: content:read"); // content/list should also fail const listResult = await call(writeOnlyHandler, "content/list", { collection: "pages", }); - expect(listResult.error).toContain("Missing capability: read:content"); + expect(listResult.error).toContain("Missing capability: content:read"); - // content/create should still succeed (has write:content) + // content/create should still succeed (has content:write) const createResult = await call(writeOnlyHandler, "content/create", { collection: "pages", data: { title: "Allowed" }, @@ -580,11 +580,11 @@ describe("Plugin integration: sandboxed-test plugin operations", () => { }); it("write-only media plugin cannot read media", async () => { - // Same enforcement for media: write:media does NOT imply read:media. + // Same enforcement for media: media:write does NOT imply media:read. const writeOnlyHandler = createBridgeHandler({ pluginId: "write-only-media", version: "1.0.0", - capabilities: ["write:media"], + capabilities: ["media:write"], allowedHosts: [], storageCollections: [], db, @@ -592,10 +592,10 @@ describe("Plugin integration: sandboxed-test plugin operations", () => { }); const getResult = await call(writeOnlyHandler, "media/get", { id: "any" }); - expect(getResult.error).toContain("Missing capability: read:media"); + expect(getResult.error).toContain("Missing capability: media:read"); const listResult = await call(writeOnlyHandler, "media/list", {}); - expect(listResult.error).toContain("Missing capability: read:media"); + expect(listResult.error).toContain("Missing capability: media:read"); }); it("sandboxed-test plugin cannot send email (not in capabilities)", async () => { diff --git a/packages/workerd/test/workerd-integration.test.ts b/packages/workerd/test/workerd-integration.test.ts index 1c3dda9345..36fe665648 100644 --- a/packages/workerd/test/workerd-integration.test.ts +++ b/packages/workerd/test/workerd-integration.test.ts @@ -59,6 +59,45 @@ async function setupTables(db: Kysely) { .addColumn("version", "integer", (col) => col.defaultTo(1)) .addColumn("live_revision_id", "text") .addColumn("draft_revision_id", "text") + .addColumn("locale", "text") + .addColumn("translation_group", "text") + .execute(); + + await db.schema + .createTable("users") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("email", "text", (col) => col.notNull()) + .addColumn("name", "text") + .addColumn("role", "integer", (col) => col.notNull()) + .addColumn("created_at", "text", (col) => col.notNull()) + .execute(); +} + +async function seedCapabilityFixtures(db: Kysely) { + await db + .insertInto("users" as any) + .values({ + id: "user-1", + email: "test@example.com", + name: "Test User", + role: 50, + created_at: "2026-01-01T00:00:00.000Z", + }) + .execute(); + + await db + .insertInto("ec_posts" as any) + .values({ + id: "post-1", + slug: "seeded", + status: "published", + title: "Seeded", + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + version: 1, + locale: "en", + translation_group: "post-1", + }) .execute(); } @@ -118,6 +157,29 @@ export default { }; `; +/** Exercises each capability-gated context API from inside the isolate. */ +const CAPABILITY_PROBE_PLUGIN = ` +export default { + hooks: {}, + routes: { + "read": { + handler: async (_routeCtx, ctx) => ctx.content.list("posts") + }, + "write": { + handler: async (routeCtx, ctx) => ctx.content.create("posts", { title: routeCtx.input.title }) + }, + "user": { + handler: async (_routeCtx, ctx) => ctx.users.get("user-1") + }, + "surface": { + handler: async (_routeCtx, ctx) => ({ users: typeof ctx.users }) + } + } +}; +`; + +const ROUTE_META = { method: "POST", url: "/api/test", headers: {} } as const; + describe.skipIf(!workerdAvailable)("WorkerdSandboxRunner integration", () => { let db: Kysely; let sqlite: Database.Database; @@ -321,7 +383,7 @@ describe.skipIf(!workerdAvailable)("WorkerdSandboxRunner integration", () => { { id: "test-content-write", version: "1.0.0", - capabilities: ["write:content"], + capabilities: ["content:write"], allowedHosts: [], storage: {}, }, @@ -395,4 +457,104 @@ describe.skipIf(!workerdAvailable)("WorkerdSandboxRunner integration", () => { expect(r1.input.from).toBe("a"); expect(r2.input.from).toBe("b"); }, 30_000); + + // Capability names reach the bridge in two vocabularies: current names + // from a freshly published manifest, or legacy aliases carried by an + // older manifest. Driving them from inside the + // isolate covers the whole chain: generated wrapper -> HTTP -> backing + // service -> token claims -> bridge handler. + describe("capability vocabulary", () => { + async function loadProbe(id: string, capabilities: string[]) { + await seedCapabilityFixtures(db); + return runner.load( + { id, version: "1.0.0", capabilities, allowedHosts: [], storage: {} }, + CAPABILITY_PROBE_PLUGIN, + ); + } + + async function storedTitles() { + const rows = await db + .selectFrom("ec_posts" as any) + .select("title") + .execute(); + return rows.map((row: any) => row.title); + } + + describe.each([ + { + vocabulary: "current", + id: "probe-current", + capabilities: ["content:read", "content:write", "users:read"], + title: "Written with current names", + }, + { + vocabulary: "legacy", + id: "probe-legacy", + capabilities: ["read:content", "write:content", "read:users"], + title: "Written with legacy names", + }, + ])("$vocabulary capability names", ({ id, capabilities, title }) => { + it("reads content through the sandbox", async () => { + const plugin = await loadProbe(id, capabilities); + + const result = (await plugin.invokeRoute("read", {}, ROUTE_META)) as { + items: Array<{ data: Record }>; + }; + + expect(result.items).toHaveLength(1); + expect(result.items[0]?.data.title).toBe("Seeded"); + }, 30_000); + + it("writes content through the sandbox", async () => { + const plugin = await loadProbe(id, capabilities); + + await plugin.invokeRoute("write", { title }, ROUTE_META); + + expect(await storedTitles()).toContain(title); + }, 30_000); + + it("reads users through the sandbox", async () => { + const plugin = await loadProbe(id, capabilities); + + const user = (await plugin.invokeRoute("user", {}, ROUTE_META)) as { email: string }; + + expect(user.email).toBe("test@example.com"); + }, 30_000); + + it("exposes the users API on the plugin context", async () => { + const plugin = await loadProbe(id, capabilities); + + const surface = (await plugin.invokeRoute("surface", {}, ROUTE_META)) as { users: string }; + + expect(surface.users).toBe("object"); + }, 30_000); + }); + + it("denies an undeclared capability with the current name", async () => { + const plugin = await loadProbe("probe-none", []); + + await expect(plugin.invokeRoute("read", {}, ROUTE_META)).rejects.toThrow( + "Missing capability: content:read", + ); + }, 30_000); + + it("does not let a write-only plugin read content", async () => { + const plugin = await loadProbe("probe-write-only", ["content:write"]); + + await plugin.invokeRoute("write", { title: "Write only" }, ROUTE_META); + expect(await storedTitles()).toContain("Write only"); + + await expect(plugin.invokeRoute("read", {}, ROUTE_META)).rejects.toThrow( + "Missing capability: content:read", + ); + }, 30_000); + + it("withholds the users API when the capability is undeclared", async () => { + const plugin = await loadProbe("probe-no-users", ["content:read"]); + + const surface = (await plugin.invokeRoute("surface", {}, ROUTE_META)) as { users: string }; + + expect(surface.users).toBe("undefined"); + }, 30_000); + }); });