diff --git a/.changeset/protect-primary-byline-from-plugin-data.md b/.changeset/protect-primary-byline-from-plugin-data.md new file mode 100644 index 0000000000..adf4b6861b --- /dev/null +++ b/.changeset/protect-primary-byline-from-plugin-data.md @@ -0,0 +1,6 @@ +--- +"@emdash-cms/cloudflare": patch +"@emdash-cms/sandbox-workerd": patch +--- + +Fixes sandboxed `ctx.content.create()` accepting `author_id` and `primary_byline_id` from plugin data on Cloudflare and Workerd. Those values are ignored during creation, and sandboxed reads omit the raw `primary_byline_id` field from `item.data`. diff --git a/packages/cloudflare/src/sandbox/bridge.ts b/packages/cloudflare/src/sandbox/bridge.ts index d10c8305bc..d0a9461204 100644 --- a/packages/cloudflare/src/sandbox/bridge.ts +++ b/packages/cloudflare/src/sandbox/bridge.ts @@ -36,6 +36,7 @@ const SYSTEM_COLUMNS = new Set([ "slug", "status", "author_id", + "primary_byline_id", "created_at", "updated_at", "published_at", @@ -603,7 +604,7 @@ export class PluginBridge extends WorkerEntrypoint { + let ctx: DialectTestContext; + let fixture: ContentAttributionFixture; + let access: ContentAttributionAccess; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + fixture = await seedContentAttribution(ctx.db); + const handler = createBridgeHandler({ + pluginId: "attribution-plugin", + version: "1.0.0", + capabilities: ["read:content", "write:content"], + allowedHosts: [], + storageCollections: [], + db: ctx.db, + emailSend: () => null, + }); + + async function invoke(method: string, body: Record): Promise { + const response = await handler( + new Request(`http://bridge/content/${method}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ collection: ATTRIBUTION_COLLECTION, ...body }), + }), + ); + const envelope = (await response.json()) as { result: T; error?: string }; + if (envelope.error) throw new Error(envelope.error); + return envelope.result; + } + + access = { + create: (data) => invoke("create", { data }), + update: (id, data) => invoke("update", { id, data }), + get: (id) => invoke("get", { id }), + list: () => invoke("list", {}), + createMany: (items) => invoke("createMany", { items }), + }; + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + registerContentAttributionTests(() => ({ fixture, access }), true); +}); diff --git a/packages/core/tests/utils/plugin-content-attribution.ts b/packages/core/tests/utils/plugin-content-attribution.ts new file mode 100644 index 0000000000..f192164ff6 --- /dev/null +++ b/packages/core/tests/utils/plugin-content-attribution.ts @@ -0,0 +1,196 @@ +import { type Kysely, sql } from "kysely"; +import { expect, it } from "vitest"; + +import { BylineRepository } from "../../src/database/repositories/byline.js"; +import { ContentRepository } from "../../src/database/repositories/content.js"; +import { RevisionRepository } from "../../src/database/repositories/revision.js"; +import { UserRepository } from "../../src/database/repositories/user.js"; +import type { Database } from "../../src/database/types.js"; +import { SchemaRegistry } from "../../src/schema/registry.js"; + +export const ATTRIBUTION_COLLECTION = "attribution_posts"; + +interface PluginContentItem { + id: string; + data: Record; +} + +export interface ContentAttributionAccess { + create(data: Record): Promise; + update(id: string, data: Record): Promise; + get(id: string): Promise; + list(): Promise<{ items: PluginContentItem[] }>; + createMany?(items: Array>): Promise; +} + +export async function seedContentAttribution(db: Kysely) { + const registry = new SchemaRegistry(db); + await registry.createCollection({ + slug: ATTRIBUTION_COLLECTION, + label: "Posts", + supports: ["drafts", "revisions"], + }); + await registry.createField(ATTRIBUTION_COLLECTION, { + slug: "title", + label: "Title", + type: "string", + }); + + const users = new UserRepository(db); + const originalAuthor = await users.create({ email: "author@example.com", role: "author" }); + const otherAuthor = await users.create({ email: "other@example.com", role: "author" }); + const bylines = new BylineRepository(db); + const originalByline = await bylines.create({ + slug: "original-author", + displayName: "Original author", + userId: originalAuthor.id, + }); + const otherByline = await bylines.create({ + slug: "other-author", + displayName: "Other author", + userId: otherAuthor.id, + }); + const original = await new ContentRepository(db).create({ + type: ATTRIBUTION_COLLECTION, + slug: "original-post", + status: "published", + authorId: originalAuthor.id, + locale: "en", + data: { title: "Original title" }, + }); + await bylines.setContentBylines(ATTRIBUTION_COLLECTION, original.id, [ + { bylineId: originalByline.id }, + ]); + + return { + db, + originalId: original.id, + originalAuthorId: originalAuthor.id, + originalBylineId: originalByline.translationGroup ?? originalByline.id, + otherAuthorId: otherAuthor.id, + otherBylineId: otherByline.translationGroup ?? otherByline.id, + }; +} + +export type ContentAttributionFixture = Awaited>; + +interface StoredContent { + author_id: string | null; + primary_byline_id: string | null; + title: string; + draft_revision_id: string | null; +} + +async function storedContent(db: Kysely, id: string): Promise { + const { rows } = await sql` + SELECT author_id, primary_byline_id, title, draft_revision_id + FROM ${sql.ref(`ec_${ATTRIBUTION_COLLECTION}`)} WHERE id = ${id} + `.execute(db); + const row = rows[0]; + if (!row) throw new Error(`Missing content row: ${id}`); + return row; +} + +export function registerContentAttributionTests( + getContext: () => { fixture: ContentAttributionFixture; access: ContentAttributionAccess }, + includeBatchCreate = false, +): void { + it.each(["author_id", "primary_byline_id"] as const)( + "ignores an existing %s supplied in plugin creation data", + async (column) => { + const { fixture, access } = getContext(); + const suppliedId = column === "author_id" ? fixture.otherAuthorId : fixture.otherBylineId; + const created = await access.create({ title: "Plugin title", [column]: suppliedId }); + + expect(await storedContent(fixture.db, created.id)).toMatchObject({ + author_id: null, + primary_byline_id: null, + title: "Plugin title", + }); + expect(created.data).toEqual({ title: "Plugin title" }); + expect((await access.get(created.id))?.data).toEqual({ title: "Plugin title" }); + const listed = (await access.list()).items.find((item) => item.id === created.id); + expect(listed?.data).toEqual({ title: "Plugin title" }); + }, + ); + + it.each(["get", "list"] as const)( + "keeps host attribution out of content/%s data", + async (method) => { + const { fixture, access } = getContext(); + const item = + method === "get" + ? await access.get(fixture.originalId) + : (await access.list()).items.find((entry) => entry.id === fixture.originalId); + + expect(item?.data).toEqual({ title: "Original title" }); + }, + ); + + it("preserves host attribution when a plugin stages and the host publishes an edit", async () => { + const { fixture, access } = getContext(); + const updated = await access.update(fixture.originalId, { + title: "Revised title", + author_id: fixture.otherAuthorId, + primary_byline_id: fixture.otherBylineId, + }); + + expect(updated.data).toEqual({ title: "Revised title" }); + const stored = await storedContent(fixture.db, fixture.originalId); + expect(stored).toMatchObject({ + author_id: fixture.originalAuthorId, + primary_byline_id: fixture.originalBylineId, + title: "Original title", + draft_revision_id: expect.any(String), + }); + if (!stored.draft_revision_id) throw new Error("The plugin edit did not create a draft"); + const revision = await new RevisionRepository(fixture.db).findById(stored.draft_revision_id); + expect(revision?.data).toEqual({ title: "Revised title" }); + + const published = await new ContentRepository(fixture.db).publish( + ATTRIBUTION_COLLECTION, + fixture.originalId, + ); + expect(published).toMatchObject({ + authorId: fixture.originalAuthorId, + primaryBylineId: fixture.originalBylineId, + data: { title: "Revised title" }, + }); + const credits = await fixture.db + .selectFrom("_emdash_content_bylines") + .select("byline_id") + .where("collection_slug", "=", ATTRIBUTION_COLLECTION) + .where("content_id", "=", fixture.originalId) + .execute(); + expect(credits).toEqual([{ byline_id: fixture.originalBylineId }]); + }); + + if (includeBatchCreate) { + it("ignores attribution references in every item of a plugin batch create", async () => { + const { fixture, access } = getContext(); + if (!access.createMany) throw new Error("Batch creation is unavailable"); + const created = await access.createMany([ + { + title: "First post", + author_id: fixture.originalAuthorId, + primary_byline_id: fixture.originalBylineId, + }, + { + title: "Second post", + author_id: fixture.otherAuthorId, + primary_byline_id: fixture.otherBylineId, + }, + ]); + + expect(created).toHaveLength(2); + for (const item of created) { + expect(await storedContent(fixture.db, item.id)).toMatchObject({ + author_id: null, + primary_byline_id: null, + title: item.data.title, + }); + expect((await access.get(item.id))?.data).toEqual({ title: item.data.title }); + } + }); + } +} diff --git a/packages/core/tests/workerd/plugin-content-attribution-d1.test.ts b/packages/core/tests/workerd/plugin-content-attribution-d1.test.ts new file mode 100644 index 0000000000..f2d4a5528a --- /dev/null +++ b/packages/core/tests/workerd/plugin-content-attribution-d1.test.ts @@ -0,0 +1,66 @@ +import { createExecutionContext, env, waitOnExecutionContext } from "cloudflare:test"; +import { Kysely } from "kysely"; +import { afterAll, afterEach, beforeAll, beforeEach, describe } from "vitest"; + +import { RawBindingD1Dialect } from "../../../cloudflare/src/db/d1-dialect.js"; +import { PluginBridge } from "../../../cloudflare/src/sandbox/bridge.js"; +import { runMigrations } from "../../src/database/migrations/runner.js"; +import type { Database } from "../../src/database/types.js"; +import { + ATTRIBUTION_COLLECTION, + type ContentAttributionAccess, + type ContentAttributionFixture, + registerContentAttributionTests, + seedContentAttribution, +} from "../utils/plugin-content-attribution.js"; +import { resetD1Schema } from "./d1-schema.js"; + +declare module "cloudflare:test" { + interface ProvidedEnv { + DB: D1Database; + } +} + +describe("Cloudflare bridge content attribution on D1", () => { + let db: Kysely; + let fixture: ContentAttributionFixture; + let access: ContentAttributionAccess; + let ctx: ExecutionContext; + + beforeAll(() => { + db = new Kysely({ dialect: new RawBindingD1Dialect({ database: env.DB }) }); + }); + + beforeEach(async () => { + await resetD1Schema(db); + await runMigrations(db); + fixture = await seedContentAttribution(db); + const bridgeContext = Object.assign(createExecutionContext(), { + props: { + pluginId: "attribution-plugin", + pluginVersion: "1.0.0", + capabilities: ["content:read", "content:write"], + allowedHosts: [], + storageCollections: [], + }, + }); + ctx = bridgeContext; + const bridge = new PluginBridge(bridgeContext, { DB: env.DB }); + access = { + create: (data) => bridge.contentCreate(ATTRIBUTION_COLLECTION, data), + update: (id, data) => bridge.contentUpdate(ATTRIBUTION_COLLECTION, id, data), + get: (id) => bridge.contentGet(ATTRIBUTION_COLLECTION, id), + list: () => bridge.contentList(ATTRIBUTION_COLLECTION, {}), + }; + }); + + afterEach(async () => { + if (ctx) await waitOnExecutionContext(ctx); + }); + + afterAll(async () => { + await db.destroy(); + }); + + registerContentAttributionTests(() => ({ fixture, access })); +}); diff --git a/packages/workerd/src/sandbox/bridge-handler.ts b/packages/workerd/src/sandbox/bridge-handler.ts index 71359c9dae..dc31938d2a 100644 --- a/packages/workerd/src/sandbox/bridge-handler.ts +++ b/packages/workerd/src/sandbox/bridge-handler.ts @@ -74,6 +74,7 @@ const SYSTEM_COLUMNS = new Set([ "slug", "status", "author_id", + "primary_byline_id", "created_at", "updated_at", "published_at", @@ -796,7 +797,7 @@ async function contentCreate( id, slug: typeof data.slug === "string" ? data.slug : null, status: typeof data.status === "string" ? data.status : "draft", - author_id: typeof data.author_id === "string" ? data.author_id : null, + author_id: null, created_at: now, updated_at: now, version: 1,