Skip to content

Commit 78d9e63

Browse files
committed
feat(plugins): read-only taxonomy access via content:read
1 parent b291f1c commit 78d9e63

12 files changed

Lines changed: 591 additions & 2 deletions

File tree

.changeset/plugin-taxonomy-read.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"emdash": minor
3+
"@emdash-cms/cloudflare": minor
4+
"@emdash-cms/sandbox-workerd": minor
5+
---
6+
7+
Adds read-only taxonomy access to the plugin content API: plugins with `content:read` can now list taxonomy definitions, fetch the terms of a taxonomy, and read the terms assigned to a content entry via `ctx.content.getTaxonomies()`, `ctx.content.getTaxonomyTerms()`, and `ctx.content.getEntryTerms()` — in-process and in both sandbox runners.

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Declare only what the plugin actually needs. Capability declarations are also wh
2929

3030
| Capability | Grants access to |
3131
| ----------------------------------- | --------------------------------------------------------------------------------- |
32-
| `content:read` | `ctx.content.get()`, `ctx.content.list()` |
32+
| `content:read` | `ctx.content.get()`, `ctx.content.list()`, `ctx.content.getTaxonomies()`, `ctx.content.getTaxonomyTerms()`, `ctx.content.getEntryTerms()` |
3333
| `content:write` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` (implies `content:read`) |
3434
| `media:read` | `ctx.media.get()`, `ctx.media.list()` |
3535
| `media:write` | `ctx.media.getUploadUrl()`, `ctx.media.upload()`, `ctx.media.delete()` (implies `media:read`) |
@@ -44,6 +44,7 @@ Declare only what the plugin actually needs. Capability declarations are also wh
4444
A few things worth knowing:
4545

4646
- **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.
47+
- **Taxonomies are part of the content read surface.** `content:read` also grants read-only access to taxonomy definitions, their terms, and the terms assigned to an entry — terms classify content, so no separate capability is needed. There is no taxonomy *write* access from plugins.
4748
- **`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`.
4849
- **`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.
4950

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 content: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("content:read")) {
709+
throw new Error("Missing capability: content: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("content:read")) {
748+
throw new Error("Missing capability: content: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("content:read")) {
783+
throw new Error("Missing capability: content: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 content: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: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,10 @@ function createContext(env) {
100100
list: (collection, opts) => bridge.contentList(collection, opts),
101101
create: (collection, data) => bridge.contentCreate(collection, data),
102102
update: (collection, id, data) => bridge.contentUpdate(collection, id, data),
103-
delete: (collection, id) => bridge.contentDelete(collection, id)
103+
delete: (collection, id) => bridge.contentDelete(collection, id),
104+
getTaxonomies: (opts) => bridge.taxonomyList(opts),
105+
getTaxonomyTerms: (taxonomy, opts) => bridge.taxonomyTerms(taxonomy, opts),
106+
getEntryTerms: (collection, entryId, opts) => bridge.taxonomyEntryTerms(collection, entryId, opts)
104107
};
105108
106109
// Media access - proxies to bridge (capability enforced by bridge)

packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,9 @@ export type {
247247
MediaAccess,
248248
HttpAccess,
249249
LogAccess,
250+
TaxonomyDefInfo,
251+
TaxonomyTermInfo,
252+
TaxonomyReadOptions,
250253
PluginHooks,
251254
HookConfig,
252255
HookName,

packages/core/src/plugins/context.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { MediaRepository } from "../database/repositories/media.js";
1313
import { OptionsRepository } from "../database/repositories/options.js";
1414
import { PluginStorageRepository } from "../database/repositories/plugin-storage.js";
1515
import { SeoRepository } from "../database/repositories/seo.js";
16+
import { TaxonomyRepository, type Taxonomy } from "../database/repositories/taxonomy.js";
1617
import { UserRepository } from "../database/repositories/user.js";
1718
import { withTransaction } from "../database/transaction.js";
1819
import type { Database } from "../database/types.js";
@@ -51,6 +52,9 @@ import type {
5152
QueryOptions,
5253
ContentListOptions,
5354
MediaListOptions,
55+
TaxonomyDefInfo,
56+
TaxonomyTermInfo,
57+
TaxonomyReadOptions,
5458
} from "./types.js";
5559

5660
// =============================================================================
@@ -193,12 +197,27 @@ async function assertSeoEnabled(
193197
return hasSeo;
194198
}
195199

200+
/** Map a repository `Taxonomy` row to the plugin-facing term shape. */
201+
function taxonomyToTermInfo(term: Taxonomy): TaxonomyTermInfo {
202+
return {
203+
id: term.id,
204+
taxonomy: term.name,
205+
slug: term.slug,
206+
label: term.label,
207+
parentId: term.parentId,
208+
data: term.data,
209+
locale: term.locale,
210+
translationGroup: term.translationGroup,
211+
};
212+
}
213+
196214
/**
197215
* Create read-only content access
198216
*/
199217
export function createContentAccess(db: Kysely<Database>): ContentAccess {
200218
const contentRepo = new ContentRepository(db);
201219
const seoRepo = new SeoRepository(db);
220+
const taxonomyRepo = new TaxonomyRepository(db);
202221

203222
return {
204223
async get(collection: string, id: string): Promise<ContentItem | null> {
@@ -274,6 +293,42 @@ export function createContentAccess(db: Kysely<Database>): ContentAccess {
274293
hasMore: !!result.nextCursor,
275294
};
276295
},
296+
297+
async getTaxonomies(options?: TaxonomyReadOptions): Promise<TaxonomyDefInfo[]> {
298+
let query = db.selectFrom("_emdash_taxonomy_defs").selectAll();
299+
if (options?.locale !== undefined) query = query.where("locale", "=", options.locale);
300+
const rows = await query.orderBy("name", "asc").execute();
301+
return rows.map((row) => ({
302+
name: row.name,
303+
label: row.label,
304+
labelSingular: row.label_singular,
305+
hierarchical: row.hierarchical === 1,
306+
collections: row.collections ? (JSON.parse(row.collections) as string[]) : [],
307+
locale: row.locale,
308+
}));
309+
},
310+
311+
async getTaxonomyTerms(
312+
taxonomy: string,
313+
options?: TaxonomyReadOptions,
314+
): Promise<TaxonomyTermInfo[]> {
315+
const terms = await taxonomyRepo.findByName(taxonomy, { locale: options?.locale });
316+
return terms.map(taxonomyToTermInfo);
317+
},
318+
319+
async getEntryTerms(
320+
collection: string,
321+
entryId: string,
322+
options?: TaxonomyReadOptions & { taxonomy?: string },
323+
): Promise<TaxonomyTermInfo[]> {
324+
const terms = await taxonomyRepo.getTermsForEntry(
325+
collection,
326+
entryId,
327+
options?.taxonomy,
328+
options?.locale,
329+
);
330+
return terms.map(taxonomyToTermInfo);
331+
},
277332
};
278333
}
279334

packages/core/src/plugins/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ export type {
118118
MediaItem,
119119
ContentListOptions,
120120
MediaListOptions,
121+
TaxonomyDefInfo,
122+
TaxonomyTermInfo,
123+
TaxonomyReadOptions,
121124

122125
// Hook types
123126
PluginHooks,

0 commit comments

Comments
 (0)