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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/protect-primary-byline-from-plugin-data.md
Original file line number Diff line number Diff line change
@@ -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`.
3 changes: 2 additions & 1 deletion packages/cloudflare/src/sandbox/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const SYSTEM_COLUMNS = new Set([
"slug",
"status",
"author_id",
"primary_byline_id",
"created_at",
"updated_at",
"published_at",
Expand Down Expand Up @@ -603,7 +604,7 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
id,
typeof data.slug === "string" ? data.slug : null,
typeof data.status === "string" ? data.status : "draft",
typeof data.author_id === "string" ? data.author_id : null,
null,
now,
now,
1,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { afterEach, beforeEach } from "vitest";

import { createBridgeHandler } from "../../../../workerd/src/sandbox/bridge-handler.js";
import {
ATTRIBUTION_COLLECTION,
type ContentAttributionAccess,
type ContentAttributionFixture,
registerContentAttributionTests,
seedContentAttribution,
} from "../../utils/plugin-content-attribution.js";
import {
type DialectTestContext,
describeEachDialect,
setupForDialect,
teardownForDialect,
} from "../../utils/test-db.js";

describeEachDialect("Workerd bridge content attribution", (dialect) => {
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<T>(method: string, body: Record<string, unknown>): Promise<T> {
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);
});
196 changes: 196 additions & 0 deletions packages/core/tests/utils/plugin-content-attribution.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

export interface ContentAttributionAccess {
create(data: Record<string, unknown>): Promise<PluginContentItem>;
update(id: string, data: Record<string, unknown>): Promise<PluginContentItem>;
get(id: string): Promise<PluginContentItem | null>;
list(): Promise<{ items: PluginContentItem[] }>;
createMany?(items: Array<Record<string, unknown>>): Promise<PluginContentItem[]>;
}

export async function seedContentAttribution(db: Kysely<Database>) {
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<ReturnType<typeof seedContentAttribution>>;

interface StoredContent {
author_id: string | null;
primary_byline_id: string | null;
title: string;
draft_revision_id: string | null;
}

async function storedContent(db: Kysely<Database>, id: string): Promise<StoredContent> {
const { rows } = await sql<StoredContent>`
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 });
}
});
}
}
66 changes: 66 additions & 0 deletions packages/core/tests/workerd/plugin-content-attribution-d1.test.ts
Original file line number Diff line number Diff line change
@@ -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<Database>;
let fixture: ContentAttributionFixture;
let access: ContentAttributionAccess;
let ctx: ExecutionContext;

beforeAll(() => {
db = new Kysely<Database>({ 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 }));
});
3 changes: 2 additions & 1 deletion packages/workerd/src/sandbox/bridge-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const SYSTEM_COLUMNS = new Set([
"slug",
"status",
"author_id",
"primary_byline_id",
"created_at",
"updated_at",
"published_at",
Expand Down Expand Up @@ -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,
Expand Down
Loading