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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/plugin-taxonomy-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"emdash": minor
"@emdash-cms/cloudflare": minor
"@emdash-cms/sandbox-workerd": minor
"@emdash-cms/plugin-types": minor
"@emdash-cms/plugin-cli": minor
"@emdash-cms/registry-lexicons": minor
"@emdash-cms/admin": patch
---

Adds a `taxonomies:read` plugin capability with read-only taxonomy access: plugins that declare it get `ctx.taxonomies` to list taxonomy definitions (`getAll()`), fetch the terms of a taxonomy (`getTerms()`), and read the terms assigned to a content entry (`getEntryTerms()`) — in-process and in both sandbox runners.
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ interface PluginContext {
url(path: string): string;
cron?: CronAccess;
content?: ContentAccess; // when content:read or content:write declared
taxonomies?: TaxonomyAccess; // when taxonomies:read declared
media?: MediaAccess; // when media:read or media:write declared
http?: HttpAccess; // when network:request declared
users?: UserAccess; // when users:read declared
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Declare only what the plugin actually needs. Capability declarations are also wh
| ----------------------------------- | --------------------------------------------------------------------------------- |
| `content:read` | `ctx.content.get()`, `ctx.content.list()` |
| `content:write` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` (implies `content:read`) |
| `taxonomies:read` | `ctx.taxonomies.getAll()`, `ctx.taxonomies.getTerms()`, `ctx.taxonomies.getEntryTerms()` |
| `media:read` | `ctx.media.get()`, `ctx.media.list()` |
| `media:write` | `ctx.media.getUploadUrl()`, `ctx.media.upload()`, `ctx.media.delete()` (implies `media:read`) |
| `network:request` | `ctx.http.fetch()` — restricted to `allowedHosts` |
Expand All @@ -44,6 +45,7 @@ Declare only what the plugin actually needs. Capability declarations are also wh
A few things worth knowing:

- **Implications.** `content:write` automatically implies `content:read`; `media:write` implies `media:read`; `network:request:unrestricted` implies `network:request`. You don't need to list both.
- **Taxonomies are a separate, read-only surface.** `taxonomies:read` grants access to taxonomy definitions, their terms, and the terms assigned to an entry via `ctx.taxonomies`. It is independent of `content:read` — declare both if the plugin reads content *and* its classification. There is no taxonomy *write* access from plugins.
- **`network:request:unrestricted` exists for user-configured URLs.** A webhook plugin where the operator types in the destination URL needs to reach hosts that aren't in the manifest. Plugins that always call known APIs should use `network:request` + `allowedHosts`.
- **`email:send` is gated by configuration, not just the capability.** A plugin can declare `email:send`, but `ctx.email` will only be populated if some other plugin has registered an `email:deliver` transport.

Expand All @@ -69,7 +71,7 @@ When a sandbox runner is active, the runtime enforces:

<Steps>

1. **Capability gating.** The PluginContext factory only populates `ctx.content`, `ctx.media`, `ctx.http`, `ctx.users`, `ctx.email` when the corresponding capability is declared. Calling a method on an undeclared capability isn't possible — there's no object there.
1. **Capability gating.** The PluginContext factory only populates `ctx.content`, `ctx.taxonomies`, `ctx.media`, `ctx.http`, `ctx.users`, `ctx.email` when the corresponding capability is declared. Calling a method on an undeclared capability isn't possible — there's no object there.

2. **Storage and KV scoping.** Every storage and KV operation is scoped to the plugin's slug. A plugin can't read another plugin's KV or its storage collections, and it can only access storage collections it declared in the manifest.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ The recognised names:
| Capability | Grants |
| ----------------------------------- | --------------------------------------------------- |
| `content:read` / `content:write` | Read / mutate site content via `ctx`. |
| `taxonomies:read` | Read taxonomy definitions and terms (read-only). |
| `media:read` / `media:write` | Read / write media. |
| `users:read` | Read user records. |
| `email:send` | Send email via `ctx`. |
Expand Down
1 change: 1 addition & 0 deletions packages/admin/src/lib/api/marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ export const CAPABILITY_LABELS: Record<string, MessageDescriptor> = {
// Canonical
"content:read": msg`Read your content`,
"content:write": msg`Create, update, and delete content`,
"taxonomies:read": msg`Read your taxonomies and terms`,
"media:read": msg`Access your media library`,
"media:write": msg`Upload and manage media`,
"users:read": msg`Read user accounts`,
Expand Down
1 change: 1 addition & 0 deletions packages/admin/tests/lib/marketplace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ describe("CAPABILITY_LABELS", () => {
// Canonical
"content:read",
"content:write",
"taxonomies:read",
"media:read",
"media:write",
"users:read",
Expand Down
179 changes: 179 additions & 0 deletions packages/cloudflare/src/sandbox/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,71 @@ function rowToContentItem(
};
}

/** Narrow an unknown D1 column value to a string ("" when it isn't one). */
function columnString(value: unknown): string {
return typeof value === "string" ? value : "";
}

/** Narrow an unknown, nullable D1 column value to `string | null`. */
function columnNullableString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}

/** Parse a JSON string column into a string array (`[]` on anything else). */
function columnStringArray(value: unknown): string[] {
if (typeof value !== "string" || !value) return [];
try {
const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === "string")
: [];
} catch {
return [];
}
}

/** Type guard for plain JSON objects. */
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

/** Parse a JSON string column into an object (`null` on anything else). */
function columnJsonObject(value: unknown): Record<string, unknown> | null {
if (typeof value !== "string" || !value) return null;
try {
const parsed: unknown = JSON.parse(value);
return isJsonObject(parsed) ? parsed : null;
} catch {
return null;
}
}

/**
* Convert a `taxonomies` row to the term shape exposed over the bridge.
* Matches core's TaxonomyTermInfo from plugins/types.ts.
*/
function rowToTaxonomyTerm(row: Record<string, unknown>): {
id: string;
taxonomy: string;
slug: string;
label: string;
parentId: string | null;
data: Record<string, unknown> | null;
locale: string;
translationGroup: string | null;
} {
return {
id: columnString(row.id),
taxonomy: columnString(row.name),
slug: columnString(row.slug),
label: columnString(row.label),
parentId: columnNullableString(row.parent_id),
data: columnJsonObject(row.data),
locale: columnString(row.locale),
translationGroup: columnNullableString(row.translation_group),
};
}

/**
* Environment bindings required by PluginBridge
*/
Expand Down Expand Up @@ -625,6 +690,120 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
return (result.meta?.changes ?? 0) > 0;
}

// =========================================================================
// Taxonomy Operations (read-only) - gated on taxonomies:read
// =========================================================================

async taxonomyList(opts: { locale?: string } = {}): Promise<
Array<{
name: string;
label: string;
labelSingular: string | null;
hierarchical: boolean;
collections: string[];
locale: string;
}>
> {
const { capabilities } = this.ctx.props;
if (!capabilities.includes("taxonomies:read")) {
throw new Error("Missing capability: taxonomies:read");
}
let sql = "SELECT * FROM _emdash_taxonomy_defs";
const params: unknown[] = [];
if (opts.locale !== undefined) {
sql += " WHERE locale = ?";
params.push(opts.locale);
}
sql += " ORDER BY name ASC";
const results = await this.env.DB.prepare(sql)
.bind(...params)
.all();
return (results.results ?? []).map((row) => ({
name: columnString(row.name),
label: columnString(row.label),
labelSingular: columnNullableString(row.label_singular),
hierarchical: row.hierarchical === 1,
collections: columnStringArray(row.collections),
locale: columnString(row.locale),
}));
}

async taxonomyTerms(
taxonomy: string,
opts: { locale?: string } = {},
): Promise<
Array<{
id: string;
taxonomy: string;
slug: string;
label: string;
parentId: string | null;
data: Record<string, unknown> | null;
locale: string;
translationGroup: string | null;
}>
> {
const { capabilities } = this.ctx.props;
if (!capabilities.includes("taxonomies:read")) {
throw new Error("Missing capability: taxonomies:read");
}
let sql = "SELECT * FROM taxonomies WHERE name = ?";
const params: unknown[] = [taxonomy];
if (opts.locale !== undefined) {
sql += " AND locale = ?";
params.push(opts.locale);
}
// `id ASC` is a stable tiebreaker for terms sharing a label, matching
// core's TaxonomyRepository.findByName ordering.
sql += " ORDER BY label ASC, id ASC";
const results = await this.env.DB.prepare(sql)
.bind(...params)
.all();
return (results.results ?? []).map(rowToTaxonomyTerm);
}

async taxonomyEntryTerms(
collection: string,
entryId: string,
opts: { taxonomy?: string; locale?: string } = {},
): Promise<
Array<{
id: string;
taxonomy: string;
slug: string;
label: string;
parentId: string | null;
data: Record<string, unknown> | null;
locale: string;
translationGroup: string | null;
}>
> {
const { capabilities } = this.ctx.props;
if (!capabilities.includes("taxonomies:read")) {
throw new Error("Missing capability: taxonomies:read");
}
// The pivot stores the term's translation_group in taxonomy_id, so the
// join resolves an assignment into each locale's term row.
let sql =
"SELECT taxonomies.* FROM content_taxonomies " +
"JOIN taxonomies ON taxonomies.translation_group = content_taxonomies.taxonomy_id " +
"WHERE content_taxonomies.collection = ? AND content_taxonomies.entry_id = ?";
const params: unknown[] = [collection, entryId];
if (opts.taxonomy !== undefined) {
sql += " AND taxonomies.name = ?";
params.push(opts.taxonomy);
}
if (opts.locale !== undefined) {
sql += " AND taxonomies.locale = ?";
params.push(opts.locale);
}
sql += " ORDER BY taxonomies.locale ASC";
const results = await this.env.DB.prepare(sql)
.bind(...params)
.all();
return (results.results ?? []).map(rowToTaxonomyTerm);
}

// =========================================================================
// Media Operations - capability-gated
// =========================================================================
Expand Down
36 changes: 36 additions & 0 deletions packages/cloudflare/src/sandbox/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,34 @@ interface BridgeContentItem {
updatedAt: string;
}

/**
* Taxonomy definition shape returned by bridge taxonomy operations.
* Matches core's TaxonomyDefInfo from plugins/types.ts.
*/
interface BridgeTaxonomyDef {
name: string;
label: string;
labelSingular: string | null;
hierarchical: boolean;
collections: string[];
locale: string;
}

/**
* Taxonomy term shape returned by bridge taxonomy operations.
* Matches core's TaxonomyTermInfo from plugins/types.ts.
*/
interface BridgeTaxonomyTerm {
id: string;
taxonomy: string;
slug: string;
label: string;
parentId: string | null;
data: Record<string, unknown> | null;
locale: string;
translationGroup: string | null;
}

/**
* Media item shape returned by bridge media operations.
* Matches core's MediaItem from plugins/types.ts.
Expand Down Expand Up @@ -156,6 +184,14 @@ export interface PluginBridgeBinding {
data: Record<string, unknown>,
): Promise<BridgeContentItem>;
contentDelete(collection: string, id: string): Promise<boolean>;
// Taxonomies (read-only, gated on taxonomies:read)
taxonomyList(opts?: { locale?: string }): Promise<BridgeTaxonomyDef[]>;
taxonomyTerms(taxonomy: string, opts?: { locale?: string }): Promise<BridgeTaxonomyTerm[]>;
taxonomyEntryTerms(
collection: string,
entryId: string,
opts?: { taxonomy?: string; locale?: string },
): Promise<BridgeTaxonomyTerm[]>;
// Media
mediaGet(id: string): Promise<BridgeMediaItem | null>;
mediaList(opts?: {
Expand Down
8 changes: 8 additions & 0 deletions packages/cloudflare/src/sandbox/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ function createContext(env) {
delete: (collection, id) => bridge.contentDelete(collection, id)
};

// Taxonomy access (read-only) - proxies to bridge (capability enforced by bridge)
const taxonomies = {
getAll: (opts) => bridge.taxonomyList(opts),
getTerms: (taxonomy, opts) => bridge.taxonomyTerms(taxonomy, opts),
getEntryTerms: (collection, entryId, opts) => bridge.taxonomyEntryTerms(collection, entryId, opts)
};

// Media access - proxies to bridge (capability enforced by bridge)
const media = {
get: (id) => bridge.mediaGet(id),
Expand Down Expand Up @@ -170,6 +177,7 @@ function createContext(env) {
storage,
kv,
content,
taxonomies,
media,
http,
log,
Expand Down
Loading
Loading