Skip to content

Commit 229e641

Browse files
swisskymarcusbellamyshaw-cell
authored andcommitted
feat(plugins): read-only taxonomy access via new taxonomies:read capability (emdash-cms#1719)
* feat(plugins): read-only taxonomy access via new taxonomies:read capability * test(cloudflare): cover PluginBridge taxonomy methods Review follow-up: capability enforcement, locale/taxonomy filter SQL wiring, and D1 row mapping (JSON parsing, int→bool, nullable columns) for taxonomyList/taxonomyTerms/taxonomyEntryTerms. * test/fix: review follow-ups for taxonomies:read Guard the in-process collections JSON parse like both bridges (an in-process plugin no longer crashes on malformed definition data), and extend the workerd conformance suite to taxonomy/terms and taxonomy/entryTerms: capability gating, locale filtering, data JSON parsing, and the pivot join on translation_group.
1 parent 1e1d31c commit 229e641

26 files changed

Lines changed: 1094 additions & 3 deletions

File tree

.changeset/plugin-taxonomy-read.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"emdash": minor
3+
"@emdash-cms/cloudflare": minor
4+
"@emdash-cms/sandbox-workerd": minor
5+
"@emdash-cms/plugin-types": minor
6+
"@emdash-cms/plugin-cli": minor
7+
"@emdash-cms/registry-lexicons": minor
8+
"@emdash-cms/admin": patch
9+
---
10+
11+
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.

docs/src/content/docs/plugins/creating-plugins/api-routes.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,7 @@ interface PluginContext {
370370
url(path: string): string;
371371
cron?: CronAccess;
372372
content?: ContentAccess; // when content:read or content:write declared
373+
taxonomies?: TaxonomyAccess; // when taxonomies:read declared
373374
media?: MediaAccess; // when media:read or media:write declared
374375
http?: HttpAccess; // when network:request declared
375376
users?: UserAccess; // when users:read declared

docs/src/content/docs/plugins/creating-plugins/capabilities.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Declare only what the plugin actually needs. Capability declarations are also wh
3131
| ----------------------------------- | --------------------------------------------------------------------------------- |
3232
| `content:read` | `ctx.content.get()`, `ctx.content.list()` |
3333
| `content:write` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` (implies `content:read`) |
34+
| `taxonomies:read` | `ctx.taxonomies.getAll()`, `ctx.taxonomies.getTerms()`, `ctx.taxonomies.getEntryTerms()` |
3435
| `media:read` | `ctx.media.get()`, `ctx.media.list()` |
3536
| `media:write` | `ctx.media.getUploadUrl()`, `ctx.media.upload()`, `ctx.media.delete()` (implies `media:read`) |
3637
| `network:request` | `ctx.http.fetch()` — restricted to `allowedHosts` |
@@ -44,6 +45,7 @@ Declare only what the plugin actually needs. Capability declarations are also wh
4445
A few things worth knowing:
4546

4647
- **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.
48+
- **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.
4749
- **`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`.
4850
- **`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.
4951

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

7072
<Steps>
7173

72-
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.
74+
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.
7375

7476
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.
7577

docs/src/content/docs/plugins/creating-plugins/manifest.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ The recognised names:
108108
| Capability | Grants |
109109
| ----------------------------------- | --------------------------------------------------- |
110110
| `content:read` / `content:write` | Read / mutate site content via `ctx`. |
111+
| `taxonomies:read` | Read taxonomy definitions and terms (read-only). |
111112
| `media:read` / `media:write` | Read / write media. |
112113
| `users:read` | Read user records. |
113114
| `email:send` | Send email via `ctx`. |

packages/admin/src/lib/api/marketplace.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ export const CAPABILITY_LABELS: Record<string, MessageDescriptor> = {
221221
// Canonical
222222
"content:read": msg`Read your content`,
223223
"content:write": msg`Create, update, and delete content`,
224+
"taxonomies:read": msg`Read your taxonomies and terms`,
224225
"media:read": msg`Access your media library`,
225226
"media:write": msg`Upload and manage media`,
226227
"users:read": msg`Read user accounts`,

packages/admin/tests/lib/marketplace.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ describe("CAPABILITY_LABELS", () => {
259259
// Canonical
260260
"content:read",
261261
"content:write",
262+
"taxonomies:read",
262263
"media:read",
263264
"media:write",
264265
"users:read",

packages/cloudflare/src/sandbox/bridge.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,71 @@ function rowToContentItem(
113113
};
114114
}
115115

116+
/** Narrow an unknown D1 column value to a string ("" when it isn't one). */
117+
function columnString(value: unknown): string {
118+
return typeof value === "string" ? value : "";
119+
}
120+
121+
/** Narrow an unknown, nullable D1 column value to `string | null`. */
122+
function columnNullableString(value: unknown): string | null {
123+
return typeof value === "string" ? value : null;
124+
}
125+
126+
/** Parse a JSON string column into a string array (`[]` on anything else). */
127+
function columnStringArray(value: unknown): string[] {
128+
if (typeof value !== "string" || !value) return [];
129+
try {
130+
const parsed: unknown = JSON.parse(value);
131+
return Array.isArray(parsed)
132+
? parsed.filter((item): item is string => typeof item === "string")
133+
: [];
134+
} catch {
135+
return [];
136+
}
137+
}
138+
139+
/** Type guard for plain JSON objects. */
140+
function isJsonObject(value: unknown): value is Record<string, unknown> {
141+
return typeof value === "object" && value !== null && !Array.isArray(value);
142+
}
143+
144+
/** Parse a JSON string column into an object (`null` on anything else). */
145+
function columnJsonObject(value: unknown): Record<string, unknown> | null {
146+
if (typeof value !== "string" || !value) return null;
147+
try {
148+
const parsed: unknown = JSON.parse(value);
149+
return isJsonObject(parsed) ? parsed : null;
150+
} catch {
151+
return null;
152+
}
153+
}
154+
155+
/**
156+
* Convert a `taxonomies` row to the term shape exposed over the bridge.
157+
* Matches core's TaxonomyTermInfo from plugins/types.ts.
158+
*/
159+
function rowToTaxonomyTerm(row: Record<string, unknown>): {
160+
id: string;
161+
taxonomy: string;
162+
slug: string;
163+
label: string;
164+
parentId: string | null;
165+
data: Record<string, unknown> | null;
166+
locale: string;
167+
translationGroup: string | null;
168+
} {
169+
return {
170+
id: columnString(row.id),
171+
taxonomy: columnString(row.name),
172+
slug: columnString(row.slug),
173+
label: columnString(row.label),
174+
parentId: columnNullableString(row.parent_id),
175+
data: columnJsonObject(row.data),
176+
locale: columnString(row.locale),
177+
translationGroup: columnNullableString(row.translation_group),
178+
};
179+
}
180+
116181
/**
117182
* Environment bindings required by PluginBridge
118183
*/
@@ -625,6 +690,120 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
625690
return (result.meta?.changes ?? 0) > 0;
626691
}
627692

693+
// =========================================================================
694+
// Taxonomy Operations (read-only) - gated on taxonomies:read
695+
// =========================================================================
696+
697+
async taxonomyList(opts: { locale?: string } = {}): Promise<
698+
Array<{
699+
name: string;
700+
label: string;
701+
labelSingular: string | null;
702+
hierarchical: boolean;
703+
collections: string[];
704+
locale: string;
705+
}>
706+
> {
707+
const { capabilities } = this.ctx.props;
708+
if (!capabilities.includes("taxonomies:read")) {
709+
throw new Error("Missing capability: taxonomies:read");
710+
}
711+
let sql = "SELECT * FROM _emdash_taxonomy_defs";
712+
const params: unknown[] = [];
713+
if (opts.locale !== undefined) {
714+
sql += " WHERE locale = ?";
715+
params.push(opts.locale);
716+
}
717+
sql += " ORDER BY name ASC";
718+
const results = await this.env.DB.prepare(sql)
719+
.bind(...params)
720+
.all();
721+
return (results.results ?? []).map((row) => ({
722+
name: columnString(row.name),
723+
label: columnString(row.label),
724+
labelSingular: columnNullableString(row.label_singular),
725+
hierarchical: row.hierarchical === 1,
726+
collections: columnStringArray(row.collections),
727+
locale: columnString(row.locale),
728+
}));
729+
}
730+
731+
async taxonomyTerms(
732+
taxonomy: string,
733+
opts: { locale?: string } = {},
734+
): Promise<
735+
Array<{
736+
id: string;
737+
taxonomy: string;
738+
slug: string;
739+
label: string;
740+
parentId: string | null;
741+
data: Record<string, unknown> | null;
742+
locale: string;
743+
translationGroup: string | null;
744+
}>
745+
> {
746+
const { capabilities } = this.ctx.props;
747+
if (!capabilities.includes("taxonomies:read")) {
748+
throw new Error("Missing capability: taxonomies:read");
749+
}
750+
let sql = "SELECT * FROM taxonomies WHERE name = ?";
751+
const params: unknown[] = [taxonomy];
752+
if (opts.locale !== undefined) {
753+
sql += " AND locale = ?";
754+
params.push(opts.locale);
755+
}
756+
// `id ASC` is a stable tiebreaker for terms sharing a label, matching
757+
// core's TaxonomyRepository.findByName ordering.
758+
sql += " ORDER BY label ASC, id ASC";
759+
const results = await this.env.DB.prepare(sql)
760+
.bind(...params)
761+
.all();
762+
return (results.results ?? []).map(rowToTaxonomyTerm);
763+
}
764+
765+
async taxonomyEntryTerms(
766+
collection: string,
767+
entryId: string,
768+
opts: { taxonomy?: string; locale?: string } = {},
769+
): Promise<
770+
Array<{
771+
id: string;
772+
taxonomy: string;
773+
slug: string;
774+
label: string;
775+
parentId: string | null;
776+
data: Record<string, unknown> | null;
777+
locale: string;
778+
translationGroup: string | null;
779+
}>
780+
> {
781+
const { capabilities } = this.ctx.props;
782+
if (!capabilities.includes("taxonomies:read")) {
783+
throw new Error("Missing capability: taxonomies:read");
784+
}
785+
// The pivot stores the term's translation_group in taxonomy_id, so the
786+
// join resolves an assignment into each locale's term row.
787+
let sql =
788+
"SELECT taxonomies.* FROM content_taxonomies " +
789+
"JOIN taxonomies ON taxonomies.translation_group = content_taxonomies.taxonomy_id " +
790+
"WHERE content_taxonomies.collection = ? AND content_taxonomies.entry_id = ?";
791+
const params: unknown[] = [collection, entryId];
792+
if (opts.taxonomy !== undefined) {
793+
sql += " AND taxonomies.name = ?";
794+
params.push(opts.taxonomy);
795+
}
796+
if (opts.locale !== undefined) {
797+
sql += " AND taxonomies.locale = ?";
798+
params.push(opts.locale);
799+
}
800+
sql += " ORDER BY taxonomies.locale ASC";
801+
const results = await this.env.DB.prepare(sql)
802+
.bind(...params)
803+
.all();
804+
return (results.results ?? []).map(rowToTaxonomyTerm);
805+
}
806+
628807
// =========================================================================
629808
// Media Operations - capability-gated
630809
// =========================================================================

packages/cloudflare/src/sandbox/types.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,34 @@ interface BridgeContentItem {
108108
updatedAt: string;
109109
}
110110

111+
/**
112+
* Taxonomy definition shape returned by bridge taxonomy operations.
113+
* Matches core's TaxonomyDefInfo from plugins/types.ts.
114+
*/
115+
interface BridgeTaxonomyDef {
116+
name: string;
117+
label: string;
118+
labelSingular: string | null;
119+
hierarchical: boolean;
120+
collections: string[];
121+
locale: string;
122+
}
123+
124+
/**
125+
* Taxonomy term shape returned by bridge taxonomy operations.
126+
* Matches core's TaxonomyTermInfo from plugins/types.ts.
127+
*/
128+
interface BridgeTaxonomyTerm {
129+
id: string;
130+
taxonomy: string;
131+
slug: string;
132+
label: string;
133+
parentId: string | null;
134+
data: Record<string, unknown> | null;
135+
locale: string;
136+
translationGroup: string | null;
137+
}
138+
111139
/**
112140
* Media item shape returned by bridge media operations.
113141
* Matches core's MediaItem from plugins/types.ts.
@@ -156,6 +184,14 @@ export interface PluginBridgeBinding {
156184
data: Record<string, unknown>,
157185
): Promise<BridgeContentItem>;
158186
contentDelete(collection: string, id: string): Promise<boolean>;
187+
// Taxonomies (read-only, gated on taxonomies:read)
188+
taxonomyList(opts?: { locale?: string }): Promise<BridgeTaxonomyDef[]>;
189+
taxonomyTerms(taxonomy: string, opts?: { locale?: string }): Promise<BridgeTaxonomyTerm[]>;
190+
taxonomyEntryTerms(
191+
collection: string,
192+
entryId: string,
193+
opts?: { taxonomy?: string; locale?: string },
194+
): Promise<BridgeTaxonomyTerm[]>;
159195
// Media
160196
mediaGet(id: string): Promise<BridgeMediaItem | null>;
161197
mediaList(opts?: {

packages/cloudflare/src/sandbox/wrapper.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ function createContext(env) {
103103
delete: (collection, id) => bridge.contentDelete(collection, id)
104104
};
105105
106+
// Taxonomy access (read-only) - proxies to bridge (capability enforced by bridge)
107+
const taxonomies = {
108+
getAll: (opts) => bridge.taxonomyList(opts),
109+
getTerms: (taxonomy, opts) => bridge.taxonomyTerms(taxonomy, opts),
110+
getEntryTerms: (collection, entryId, opts) => bridge.taxonomyEntryTerms(collection, entryId, opts)
111+
};
112+
106113
// Media access - proxies to bridge (capability enforced by bridge)
107114
const media = {
108115
get: (id) => bridge.mediaGet(id),
@@ -170,6 +177,7 @@ function createContext(env) {
170177
storage,
171178
kv,
172179
content,
180+
taxonomies,
173181
media,
174182
http,
175183
log,

0 commit comments

Comments
 (0)