diff --git a/.changeset/plugin-taxonomy-read.md b/.changeset/plugin-taxonomy-read.md new file mode 100644 index 0000000000..977dbc6757 --- /dev/null +++ b/.changeset/plugin-taxonomy-read.md @@ -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. diff --git a/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx b/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx index 54a3e9cce2..e8d182fd96 100644 --- a/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx @@ -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 diff --git a/docs/src/content/docs/plugins/creating-plugins/capabilities.mdx b/docs/src/content/docs/plugins/creating-plugins/capabilities.mdx index 28625a1be7..c47a60aef1 100644 --- a/docs/src/content/docs/plugins/creating-plugins/capabilities.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/capabilities.mdx @@ -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` | @@ -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. @@ -69,7 +71,7 @@ When a sandbox runner is active, the runtime enforces: -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. diff --git a/docs/src/content/docs/plugins/creating-plugins/manifest.mdx b/docs/src/content/docs/plugins/creating-plugins/manifest.mdx index 86f6e3b455..67793a449c 100644 --- a/docs/src/content/docs/plugins/creating-plugins/manifest.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/manifest.mdx @@ -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`. | diff --git a/packages/admin/src/lib/api/marketplace.ts b/packages/admin/src/lib/api/marketplace.ts index 21c6375c66..5a27c6fb0f 100644 --- a/packages/admin/src/lib/api/marketplace.ts +++ b/packages/admin/src/lib/api/marketplace.ts @@ -221,6 +221,7 @@ export const CAPABILITY_LABELS: Record = { // 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`, diff --git a/packages/admin/tests/lib/marketplace.test.ts b/packages/admin/tests/lib/marketplace.test.ts index 2e47d2c2d5..4e0a35030b 100644 --- a/packages/admin/tests/lib/marketplace.test.ts +++ b/packages/admin/tests/lib/marketplace.test.ts @@ -259,6 +259,7 @@ describe("CAPABILITY_LABELS", () => { // Canonical "content:read", "content:write", + "taxonomies:read", "media:read", "media:write", "users:read", diff --git a/packages/cloudflare/src/sandbox/bridge.ts b/packages/cloudflare/src/sandbox/bridge.ts index 8cbccfadea..b169356e34 100644 --- a/packages/cloudflare/src/sandbox/bridge.ts +++ b/packages/cloudflare/src/sandbox/bridge.ts @@ -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 { + 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 | 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): { + id: string; + taxonomy: string; + slug: string; + label: string; + parentId: string | null; + data: Record | 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 */ @@ -625,6 +690,120 @@ export class PluginBridge extends WorkerEntrypoint 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 | 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 | 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 // ========================================================================= diff --git a/packages/cloudflare/src/sandbox/types.ts b/packages/cloudflare/src/sandbox/types.ts index eb421a08bf..8ffe7b6616 100644 --- a/packages/cloudflare/src/sandbox/types.ts +++ b/packages/cloudflare/src/sandbox/types.ts @@ -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 | null; + locale: string; + translationGroup: string | null; +} + /** * Media item shape returned by bridge media operations. * Matches core's MediaItem from plugins/types.ts. @@ -156,6 +184,14 @@ export interface PluginBridgeBinding { data: Record, ): Promise; contentDelete(collection: string, id: string): Promise; + // Taxonomies (read-only, gated on taxonomies:read) + taxonomyList(opts?: { locale?: string }): Promise; + taxonomyTerms(taxonomy: string, opts?: { locale?: string }): Promise; + taxonomyEntryTerms( + collection: string, + entryId: string, + opts?: { taxonomy?: string; locale?: string }, + ): Promise; // Media mediaGet(id: string): Promise; mediaList(opts?: { diff --git a/packages/cloudflare/src/sandbox/wrapper.ts b/packages/cloudflare/src/sandbox/wrapper.ts index 967dabd3d8..fae6d00877 100644 --- a/packages/cloudflare/src/sandbox/wrapper.ts +++ b/packages/cloudflare/src/sandbox/wrapper.ts @@ -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), @@ -170,6 +177,7 @@ function createContext(env) { storage, kv, content, + taxonomies, media, http, log, diff --git a/packages/cloudflare/tests/sandbox/bridge-taxonomy.test.ts b/packages/cloudflare/tests/sandbox/bridge-taxonomy.test.ts new file mode 100644 index 0000000000..6d903734b2 --- /dev/null +++ b/packages/cloudflare/tests/sandbox/bridge-taxonomy.test.ts @@ -0,0 +1,212 @@ +/** + * Tests for the PluginBridge taxonomy methods (taxonomyList, taxonomyTerms, + * taxonomyEntryTerms): capability enforcement, SQL/parameter wiring for the + * locale/taxonomy filters, and D1 row mapping (JSON parsing, int→bool, + * nullable columns). + */ + +import { describe, expect, it, vi } from "vitest"; + +// PluginBridge extends WorkerEntrypoint from cloudflare:workers, which is +// not importable under plain vitest. Substitute a minimal base class that +// stores ctx/env the way the runtime does. +vi.mock("cloudflare:workers", () => ({ + WorkerEntrypoint: class { + ctx: unknown; + env: unknown; + constructor(ctx: unknown, env: unknown) { + this.ctx = ctx; + this.env = env; + } + }, +})); + +import { PluginBridge } from "../../src/sandbox/bridge.js"; + +type Row = Record; + +interface RecordedQuery { + sql: string; + params: unknown[]; +} + +/** Minimal fake D1Database: records SQL + bound params, returns canned rows. */ +function fakeD1(rows: Row[], recorded: RecordedQuery[]) { + return { + prepare(sql: string) { + const statement = { + params: [] as unknown[], + bind(...args: unknown[]) { + statement.params = args; + return statement; + }, + async all() { + recorded.push({ sql, params: statement.params }); + return { results: rows }; + }, + async first() { + recorded.push({ sql, params: statement.params }); + return rows[0] ?? null; + }, + async run() { + recorded.push({ sql, params: statement.params }); + return { meta: { changes: 0 } }; + }, + }; + return statement; + }, + }; +} + +function makeBridge(capabilities: string[], rows: Row[] = []) { + const recorded: RecordedQuery[] = []; + const ctx = { + props: { + pluginId: "test-plugin", + pluginVersion: "1.0.0", + capabilities, + allowedHosts: [], + storageCollections: [], + }, + }; + const env = { DB: fakeD1(rows, recorded) }; + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- fake ctx/env stand in for the Workers runtime injections + const bridge = new PluginBridge(ctx as never, env as never); + return { bridge, recorded }; +} + +const TERM_ROW: Row = { + id: "term-1", + name: "category", + slug: "news", + label: "News", + parent_id: null, + data: '{"color":"red"}', + locale: "en", + translation_group: "tg-1", +}; + +describe("PluginBridge taxonomy methods — capability enforcement", () => { + it("rejects all three methods without taxonomies:read", async () => { + // content:read must not grant taxonomy access. + const { bridge } = makeBridge(["content:read"]); + await expect(bridge.taxonomyList()).rejects.toThrow(/taxonomies:read/); + await expect(bridge.taxonomyTerms("category")).rejects.toThrow(/taxonomies:read/); + await expect(bridge.taxonomyEntryTerms("posts", "p1")).rejects.toThrow(/taxonomies:read/); + }); +}); + +describe("taxonomyList", () => { + it("maps rows: int→bool, JSON collections, nullable label_singular", async () => { + const { bridge } = makeBridge( + ["taxonomies:read"], + [ + { + name: "category", + label: "Categories", + label_singular: "Category", + hierarchical: 1, + collections: '["posts","pages"]', + locale: "en", + }, + { + name: "tag", + label: "Tags", + label_singular: null, + hierarchical: 0, + collections: "not-json", + locale: "en", + }, + ], + ); + + const defs = await bridge.taxonomyList(); + expect(defs).toEqual([ + { + name: "category", + label: "Categories", + labelSingular: "Category", + hierarchical: true, + collections: ["posts", "pages"], + locale: "en", + }, + { + name: "tag", + label: "Tags", + labelSingular: null, + hierarchical: false, + collections: [], + locale: "en", + }, + ]); + }); + + it("filters by locale only when provided", async () => { + const { bridge, recorded } = makeBridge(["taxonomies:read"]); + await bridge.taxonomyList(); + await bridge.taxonomyList({ locale: "de" }); + + expect(recorded[0]?.sql).not.toContain("locale"); + expect(recorded[0]?.params).toEqual([]); + expect(recorded[1]?.sql).toContain("WHERE locale = ?"); + expect(recorded[1]?.params).toEqual(["de"]); + }); +}); + +describe("taxonomyTerms", () => { + it("maps rows including JSON data and translation group", async () => { + const { bridge } = makeBridge(["taxonomies:read"], [TERM_ROW]); + const terms = await bridge.taxonomyTerms("category"); + expect(terms).toEqual([ + { + id: "term-1", + taxonomy: "category", + slug: "news", + label: "News", + parentId: null, + data: { color: "red" }, + locale: "en", + translationGroup: "tg-1", + }, + ]); + }); + + it("returns null data for malformed JSON", async () => { + const { bridge } = makeBridge(["taxonomies:read"], [{ ...TERM_ROW, data: "{broken" }]); + const terms = await bridge.taxonomyTerms("category"); + expect(terms[0]?.data).toBeNull(); + }); + + it("binds the taxonomy name and appends the locale filter when provided", async () => { + const { bridge, recorded } = makeBridge(["taxonomies:read"]); + await bridge.taxonomyTerms("category"); + await bridge.taxonomyTerms("category", { locale: "fr" }); + + expect(recorded[0]?.sql).toContain("WHERE name = ?"); + expect(recorded[0]?.params).toEqual(["category"]); + expect(recorded[1]?.sql).toContain("AND locale = ?"); + expect(recorded[1]?.params).toEqual(["category", "fr"]); + }); +}); + +describe("taxonomyEntryTerms", () => { + it("joins the pivot on translation_group and binds collection + entry", async () => { + const { bridge, recorded } = makeBridge(["taxonomies:read"], [TERM_ROW]); + const terms = await bridge.taxonomyEntryTerms("posts", "post-1"); + + expect(recorded[0]?.sql).toContain( + "JOIN taxonomies ON taxonomies.translation_group = content_taxonomies.taxonomy_id", + ); + expect(recorded[0]?.params).toEqual(["posts", "post-1"]); + expect(terms[0]?.taxonomy).toBe("category"); + }); + + it("appends taxonomy and locale filters in order when provided", async () => { + const { bridge, recorded } = makeBridge(["taxonomies:read"]); + await bridge.taxonomyEntryTerms("posts", "post-1", { taxonomy: "tag", locale: "de" }); + + expect(recorded[0]?.sql).toContain("AND taxonomies.name = ?"); + expect(recorded[0]?.sql).toContain("AND taxonomies.locale = ?"); + expect(recorded[0]?.params).toEqual(["posts", "post-1", "tag", "de"]); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cb8f07701c..9685dc6dca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -247,6 +247,10 @@ export type { MediaAccess, HttpAccess, LogAccess, + TaxonomyAccess, + TaxonomyDefInfo, + TaxonomyTermInfo, + TaxonomyReadOptions, PluginHooks, HookConfig, HookName, diff --git a/packages/core/src/plugins/context.ts b/packages/core/src/plugins/context.ts index 123a362b7f..ffa6d50536 100644 --- a/packages/core/src/plugins/context.ts +++ b/packages/core/src/plugins/context.ts @@ -13,6 +13,7 @@ import { MediaRepository } from "../database/repositories/media.js"; import { OptionsRepository } from "../database/repositories/options.js"; import { PluginStorageRepository } from "../database/repositories/plugin-storage.js"; import { SeoRepository } from "../database/repositories/seo.js"; +import { TaxonomyRepository, type Taxonomy } from "../database/repositories/taxonomy.js"; import { UserRepository } from "../database/repositories/user.js"; import { withTransaction } from "../database/transaction.js"; import type { Database } from "../database/types.js"; @@ -52,6 +53,10 @@ import type { QueryOptions, ContentListOptions, MediaListOptions, + TaxonomyAccess, + TaxonomyDefInfo, + TaxonomyTermInfo, + TaxonomyReadOptions, } from "./types.js"; // ============================================================================= @@ -194,6 +199,37 @@ async function assertSeoEnabled( return hasSeo; } +/** + * Parse the `collections` JSON column into a string array (`[]` on anything + * else). Mirrors the guards in the Cloudflare/workerd bridges so an + * in-process plugin degrades on malformed data instead of crashing. + */ +function parseCollectionsColumn(value: string | null): string[] { + if (!value) return []; + try { + const parsed: unknown = JSON.parse(value); + return Array.isArray(parsed) + ? parsed.filter((item): item is string => typeof item === "string") + : []; + } catch { + return []; + } +} + +/** Map a repository `Taxonomy` row to the plugin-facing term shape. */ +function taxonomyToTermInfo(term: Taxonomy): TaxonomyTermInfo { + return { + id: term.id, + taxonomy: term.name, + slug: term.slug, + label: term.label, + parentId: term.parentId, + data: term.data, + locale: term.locale, + translationGroup: term.translationGroup, + }; +} + /** * Create read-only content access */ @@ -278,6 +314,48 @@ export function createContentAccess(db: Kysely): ContentAccess { }; } +/** + * Create read-only taxonomy access (gated on `taxonomies:read`). + */ +export function createTaxonomyAccess(db: Kysely): TaxonomyAccess { + const taxonomyRepo = new TaxonomyRepository(db); + + return { + async getAll(options?: TaxonomyReadOptions): Promise { + let query = db.selectFrom("_emdash_taxonomy_defs").selectAll(); + if (options?.locale !== undefined) query = query.where("locale", "=", options.locale); + const rows = await query.orderBy("name", "asc").execute(); + return rows.map((row) => ({ + name: row.name, + label: row.label, + labelSingular: row.label_singular, + hierarchical: row.hierarchical === 1, + collections: parseCollectionsColumn(row.collections), + locale: row.locale, + })); + }, + + async getTerms(taxonomy: string, options?: TaxonomyReadOptions): Promise { + const terms = await taxonomyRepo.findByName(taxonomy, { locale: options?.locale }); + return terms.map(taxonomyToTermInfo); + }, + + async getEntryTerms( + collection: string, + entryId: string, + options?: TaxonomyReadOptions & { taxonomy?: string }, + ): Promise { + const terms = await taxonomyRepo.getTermsForEntry( + collection, + entryId, + options?.taxonomy, + options?.locale, + ); + return terms.map(taxonomyToTermInfo); + }, + }; +} + /** * Create full content access with write operations. * @@ -1015,6 +1093,12 @@ export class PluginContextFactory { content = createContentAccess(db); } + // Capability-gated: taxonomies (read-only) + let taxonomies: TaxonomyAccess | undefined; + if (capabilities.has("taxonomies:read")) { + taxonomies = createTaxonomyAccess(db); + } + // Capability-gated: media // `upload()` only needs `storage`; `getUploadUrl()` is derived from // storage when no explicit provider is wired. Granting write access on @@ -1078,6 +1162,7 @@ export class PluginContextFactory { storage, kv, content, + taxonomies, media, http, log, diff --git a/packages/core/src/plugins/define-plugin.ts b/packages/core/src/plugins/define-plugin.ts index 296a2d1438..1cea2d70e7 100644 --- a/packages/core/src/plugins/define-plugin.ts +++ b/packages/core/src/plugins/define-plugin.ts @@ -142,6 +142,7 @@ function defineNativePlugin( "network:request:unrestricted", "content:read", "content:write", + "taxonomies:read", "media:read", "media:write", "users:read", diff --git a/packages/core/src/plugins/index.ts b/packages/core/src/plugins/index.ts index 29548ab7dd..75b5879a4d 100644 --- a/packages/core/src/plugins/index.ts +++ b/packages/core/src/plugins/index.ts @@ -118,6 +118,10 @@ export type { MediaItem, ContentListOptions, MediaListOptions, + TaxonomyAccess, + TaxonomyDefInfo, + TaxonomyTermInfo, + TaxonomyReadOptions, // Hook types PluginHooks, diff --git a/packages/core/src/plugins/manifest-schema.ts b/packages/core/src/plugins/manifest-schema.ts index 20257b43ea..da6716ecf6 100644 --- a/packages/core/src/plugins/manifest-schema.ts +++ b/packages/core/src/plugins/manifest-schema.ts @@ -27,6 +27,7 @@ export const CURRENT_PLUGIN_CAPABILITIES = [ "network:request:unrestricted", "content:read", "content:write", + "taxonomies:read", "media:read", "media:write", "users:read", @@ -246,6 +247,7 @@ const declaredAccessSchema = z.object({ content: z .object({ read: accessConstraints.optional(), write: accessConstraints.optional() }) .optional(), + taxonomies: z.object({ read: accessConstraints.optional() }).optional(), media: z .object({ read: accessConstraints.optional(), write: accessConstraints.optional() }) .optional(), diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 397659a6ec..981172f0c1 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -244,6 +244,45 @@ export type ContentWriteInput = Record & { seo?: ContentItemSeoInput; }; +/** + * Taxonomy definition returned from the taxonomy API (e.g. "category", "tag"). + */ +export interface TaxonomyDefInfo { + name: string; + label: string; + labelSingular: string | null; + hierarchical: boolean; + /** Collections this taxonomy is attached to (e.g. `["posts"]`). */ + collections: string[]; + locale: string; +} + +/** + * Taxonomy term returned from the taxonomy API. Flat shape — for hierarchical + * taxonomies the tree is reconstructed via `parentId` (which stores the + * parent's locale-agnostic `translationGroup`). + */ +export interface TaxonomyTermInfo { + id: string; + /** Taxonomy name this term belongs to (e.g. "category"). */ + taxonomy: string; + slug: string; + label: string; + parentId: string | null; + /** Term metadata as edited in the admin (`description` etc.). */ + data: Record | null; + locale: string; + translationGroup: string | null; +} + +/** + * Options accepted by taxonomy read operations. Omitting `locale` returns + * rows for every locale. + */ +export interface TaxonomyReadOptions { + locale?: string; +} + /** * Content access interface - capability-gated */ @@ -258,6 +297,23 @@ export interface ContentAccess { delete?(collection: string, id: string): Promise; } +/** + * Taxonomy access interface — capability-gated on `taxonomies:read`. + * Read-only: there is no plugin-facing taxonomy write API. + */ +export interface TaxonomyAccess { + /** List taxonomy definitions. */ + getAll(options?: TaxonomyReadOptions): Promise; + /** All terms of a taxonomy, ordered by label. */ + getTerms(taxonomy: string, options?: TaxonomyReadOptions): Promise; + /** Terms assigned to a content entry, optionally scoped to one taxonomy. */ + getEntryTerms( + collection: string, + entryId: string, + options?: TaxonomyReadOptions & { taxonomy?: string }, + ): Promise; +} + /** * Full content access with write operations */ @@ -413,6 +469,9 @@ export interface PluginContext { }); }); + describe("taxonomy read access", () => { + beforeEach(async () => { + // Migrations seed the default `category`/`tag` defs — clear them + // so the assertions below only see the fixtures. + await sql`DELETE FROM _emdash_taxonomy_defs`.execute(db); + await sql` + INSERT INTO _emdash_taxonomy_defs (id, name, label, label_singular, hierarchical, collections, locale, translation_group) + VALUES + ('def-genre', 'genre', 'Genres', 'Genre', 1, '["posts"]', 'en', 'def-genre'), + ('def-topic', 'topic', 'Topics', 'Topic', 0, '["posts"]', 'en', 'def-topic') + `.execute(db); + await sql` + INSERT INTO taxonomies (id, name, slug, label, parent_id, data, locale, translation_group) + VALUES + ('term-news', 'genre', 'news', 'News', NULL, NULL, 'en', 'term-news'), + ('term-sub', 'genre', 'sub-news', 'Sub News', 'term-news', NULL, 'en', 'term-sub'), + ('term-news-fr', 'genre', 'actualites', 'Actualités', NULL, NULL, 'fr', 'term-news'), + ('term-ai', 'topic', 'ai', 'AI', NULL, '{"description":"Artificial intelligence"}', 'en', 'term-ai') + `.execute(db); + // The pivot stores the term's translation_group, so one + // assignment spans every locale of the term. + await sql` + INSERT INTO content_taxonomies (collection, entry_id, taxonomy_id) + VALUES + ('posts', 'post-1', 'term-news'), + ('posts', 'post-1', 'term-ai') + `.execute(db); + }); + + it("lists taxonomy definitions", async () => { + const access = createTaxonomyAccess(db); + const defs = await access.getAll(); + + expect(defs).toHaveLength(2); + const category = defs.find((d) => d.name === "genre"); + expect(category).toMatchObject({ + label: "Genres", + labelSingular: "Genre", + hierarchical: true, + collections: ["posts"], + locale: "en", + }); + const tag = defs.find((d) => d.name === "topic"); + expect(tag?.hierarchical).toBe(false); + }); + + it("filters taxonomy definitions by locale", async () => { + const access = createTaxonomyAccess(db); + const defs = await access.getAll({ locale: "fr" }); + expect(defs).toHaveLength(0); + }); + + it("lists terms of a taxonomy", async () => { + const access = createTaxonomyAccess(db); + const terms = await access.getTerms("genre"); + + expect(terms.map((t) => t.slug)).toEqual(["actualites", "news", "sub-news"]); + const sub = terms.find((t) => t.slug === "sub-news"); + expect(sub).toMatchObject({ + taxonomy: "genre", + label: "Sub News", + parentId: "term-news", + locale: "en", + translationGroup: "term-sub", + }); + }); + + it("scopes terms to a locale", async () => { + const access = createTaxonomyAccess(db); + const terms = await access.getTerms("genre", { locale: "en" }); + expect(terms.map((t) => t.slug)).toEqual(["news", "sub-news"]); + }); + + it("parses term data JSON", async () => { + const access = createTaxonomyAccess(db); + const terms = await access.getTerms("topic"); + + expect(terms).toHaveLength(1); + expect(terms[0]!.data).toEqual({ description: "Artificial intelligence" }); + }); + + it("returns an empty list for an unknown taxonomy", async () => { + const access = createTaxonomyAccess(db); + const terms = await access.getTerms("nonexistent"); + expect(terms).toEqual([]); + }); + + it("returns terms assigned to an entry", async () => { + const access = createTaxonomyAccess(db); + const terms = await access.getEntryTerms("posts", "post-1", { locale: "en" }); + + expect(terms.map((t) => t.slug).toSorted()).toEqual(["ai", "news"]); + }); + + it("scopes entry terms to one taxonomy", async () => { + const access = createTaxonomyAccess(db); + const terms = await access.getEntryTerms("posts", "post-1", { + taxonomy: "genre", + locale: "en", + }); + + expect(terms).toHaveLength(1); + expect(terms[0]!.slug).toBe("news"); + }); + + it("resolves entry terms into the requested locale", async () => { + // The assignment points at the term's translation_group, so + // asking for 'fr' surfaces the French translation of the term. + const access = createTaxonomyAccess(db); + const terms = await access.getEntryTerms("posts", "post-1", { + taxonomy: "genre", + locale: "fr", + }); + + expect(terms).toHaveLength(1); + expect(terms[0]!.slug).toBe("actualites"); + }); + + it("is exposed on the context only with taxonomies:read", async () => { + const factory = new PluginContextFactory({ db }); + + const withCap = factory.createContext( + createTestPlugin({ id: "tax-reader", capabilities: ["taxonomies:read"] }), + ); + expect(withCap.taxonomies).toBeDefined(); + const defs = await withCap.taxonomies!.getAll(); + expect(defs).toHaveLength(2); + + // content:read alone does NOT grant taxonomy access... + const contentOnly = factory.createContext( + createTestPlugin({ id: "content-reader", capabilities: ["content:read"] }), + ); + expect(contentOnly.taxonomies).toBeUndefined(); + expect(contentOnly.content).toBeDefined(); + + // ...and taxonomies:read alone does not grant content access. + const taxOnly = factory.createContext( + createTestPlugin({ id: "tax-only", capabilities: ["taxonomies:read"] }), + ); + expect(taxOnly.content).toBeUndefined(); + }); + }); + describe("createContentAccessWithWrite", () => { it("includes read methods", async () => { const access = createContentAccessWithWrite(db); diff --git a/packages/marketplace/src/routes/author.ts b/packages/marketplace/src/routes/author.ts index 5bce562a24..4cefc0ea2d 100644 --- a/packages/marketplace/src/routes/author.ts +++ b/packages/marketplace/src/routes/author.ts @@ -257,6 +257,7 @@ const VALID_CAPABILITIES = [ "network:request:unrestricted", "content:read", "content:write", + "taxonomies:read", "media:read", "media:write", "users:read", diff --git a/packages/plugin-cli/src/manifest/schema.ts b/packages/plugin-cli/src/manifest/schema.ts index 44e1316f7a..5bb08959e2 100644 --- a/packages/plugin-cli/src/manifest/schema.ts +++ b/packages/plugin-cli/src/manifest/schema.ts @@ -346,6 +346,7 @@ const CURRENT_CAPABILITIES = new Set([ "network:request:unrestricted", "content:read", "content:write", + "taxonomies:read", "media:read", "media:write", "users:read", diff --git a/packages/plugin-types/src/index.ts b/packages/plugin-types/src/index.ts index 9a4777aba2..6d934382a6 100644 --- a/packages/plugin-types/src/index.ts +++ b/packages/plugin-types/src/index.ts @@ -48,6 +48,8 @@ export type PluginCapability = // Content | "content:read" | "content:write" + // Taxonomies (read-only; there is no plugin-facing taxonomy write API) + | "taxonomies:read" // Media | "media:read" | "media:write" @@ -181,6 +183,7 @@ export type AccessConstraints = Record; */ export interface DeclaredAccess { content?: { read?: AccessConstraints; write?: AccessConstraints }; + taxonomies?: { read?: AccessConstraints }; media?: { read?: AccessConstraints; write?: AccessConstraints }; network?: { request?: { allowedHosts?: string[] } }; email?: { send?: AccessConstraints; events?: AccessConstraints; transport?: AccessConstraints }; @@ -213,6 +216,7 @@ export function capabilitiesToDeclaredAccess( out.content = { read: {} }; if (caps.has("content:write")) out.content.write = {}; } + if (caps.has("taxonomies:read")) out.taxonomies = { read: {} }; if (caps.has("media:read") || caps.has("media:write")) { out.media = { read: {} }; if (caps.has("media:write")) out.media.write = {}; @@ -255,6 +259,7 @@ export function declaredAccessToCapabilities(declaredAccess: DeclaredAccess): { caps.add("content:write"); caps.add("content:read"); } + if (declaredAccess.taxonomies?.read) caps.add("taxonomies:read"); if (declaredAccess.media?.read) caps.add("media:read"); if (declaredAccess.media?.write) { caps.add("media:write"); diff --git a/packages/plugin-types/tests/capabilities.test.ts b/packages/plugin-types/tests/capabilities.test.ts index cae8b986a3..1eb200476d 100644 --- a/packages/plugin-types/tests/capabilities.test.ts +++ b/packages/plugin-types/tests/capabilities.test.ts @@ -91,6 +91,9 @@ describe("declaredAccess facet mapping", () => { page: { fragments: {} }, }); expect(capabilitiesToDeclaredAccess(["users:read"], [])).toEqual({ users: { read: {} } }); + expect(capabilitiesToDeclaredAccess(["taxonomies:read"], [])).toEqual({ + taxonomies: { read: {} }, + }); }); it("distinguishes host-restricted from unrestricted network", () => { @@ -155,6 +158,7 @@ describe("declaredAccess <-> capabilities round-trip (total over the vocabulary) "hooks.email-transport:register", "hooks.page-fragments:register", "users:read", + "taxonomies:read", ]; function* states() { @@ -183,7 +187,7 @@ describe("declaredAccess <-> capabilities round-trip (total over the vocabulary) expect(new Set(back.allowedHosts)).toEqual(new Set(input.allowedHosts)); count++; } - // 3 content x 3 media x 5 network x 2^5 singleton subsets. - expect(count).toBe(1440); + // 3 content x 3 media x 5 network x 2^6 singleton subsets. + expect(count).toBe(2880); }); }); diff --git a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/releaseExtension.json b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/releaseExtension.json index ff1c5c87c3..140247984c 100644 --- a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/releaseExtension.json +++ b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/releaseExtension.json @@ -24,6 +24,11 @@ "ref": "#contentAccess", "description": "Access to site content (posts, pages, custom collections)." }, + "taxonomies": { + "type": "ref", + "ref": "#taxonomiesAccess", + "description": "Access to taxonomy definitions and terms." + }, "media": { "type": "ref", "ref": "#mediaAccess", @@ -75,6 +80,21 @@ "type": "object", "description": "Constraint object for content.write. No constraint keys are normatively defined here; the object is open to additional unrecognised keys that future runtimes may interpret." }, + "taxonomiesAccess": { + "type": "object", + "description": "Operations under taxonomies. Read-only: there is no plugin-facing taxonomy write operation.", + "properties": { + "read": { + "type": "ref", + "ref": "#taxonomiesReadConstraints", + "description": "Plugin may read taxonomy definitions, their terms, and the terms assigned to content entries." + } + } + }, + "taxonomiesReadConstraints": { + "type": "object", + "description": "Constraint object for taxonomies.read. No constraint keys are normatively defined here." + }, "mediaAccess": { "type": "object", "description": "Operations under media.", diff --git a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/releaseExtension.ts b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/releaseExtension.ts index 5b3c1fd86b..c3615db15c 100644 --- a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/releaseExtension.ts +++ b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/releaseExtension.ts @@ -70,6 +70,12 @@ const _declaredAccessSchema = /*#__PURE__*/ v.object({ get page() { return /*#__PURE__*/ v.optional(pageAccessSchema); }, + /** + * Access to taxonomy definitions and terms. + */ + get taxonomies() { + return /*#__PURE__*/ v.optional(taxonomiesAccessSchema); + }, /** * Access to site user records. */ @@ -224,6 +230,26 @@ const _pageFragmentsConstraintsSchema = /*#__PURE__*/ v.object({ ), ), }); +const _taxonomiesAccessSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.package.releaseExtension#taxonomiesAccess", + ), + ), + /** + * Plugin may read taxonomy definitions, their terms, and the terms assigned to content entries. + */ + get read() { + return /*#__PURE__*/ v.optional(taxonomiesReadConstraintsSchema); + }, +}); +const _taxonomiesReadConstraintsSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.package.releaseExtension#taxonomiesReadConstraints", + ), + ), +}); const _usersAccessSchema = /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.optional( /*#__PURE__*/ v.literal( @@ -264,6 +290,9 @@ type networkRequestConstraints$schematype = type pageAccess$schematype = typeof _pageAccessSchema; type pageFragmentsConstraints$schematype = typeof _pageFragmentsConstraintsSchema; +type taxonomiesAccess$schematype = typeof _taxonomiesAccessSchema; +type taxonomiesReadConstraints$schematype = + typeof _taxonomiesReadConstraintsSchema; type usersAccess$schematype = typeof _usersAccessSchema; type usersReadConstraints$schematype = typeof _usersReadConstraintsSchema; @@ -283,6 +312,8 @@ export interface networkAccessSchema extends networkAccess$schematype {} export interface networkRequestConstraintsSchema extends networkRequestConstraints$schematype {} export interface pageAccessSchema extends pageAccess$schematype {} export interface pageFragmentsConstraintsSchema extends pageFragmentsConstraints$schematype {} +export interface taxonomiesAccessSchema extends taxonomiesAccess$schematype {} +export interface taxonomiesReadConstraintsSchema extends taxonomiesReadConstraints$schematype {} export interface usersAccessSchema extends usersAccess$schematype {} export interface usersReadConstraintsSchema extends usersReadConstraints$schematype {} @@ -312,6 +343,10 @@ export const networkRequestConstraintsSchema = export const pageAccessSchema = _pageAccessSchema as pageAccessSchema; export const pageFragmentsConstraintsSchema = _pageFragmentsConstraintsSchema as pageFragmentsConstraintsSchema; +export const taxonomiesAccessSchema = + _taxonomiesAccessSchema as taxonomiesAccessSchema; +export const taxonomiesReadConstraintsSchema = + _taxonomiesReadConstraintsSchema as taxonomiesReadConstraintsSchema; export const usersAccessSchema = _usersAccessSchema as usersAccessSchema; export const usersReadConstraintsSchema = _usersReadConstraintsSchema as usersReadConstraintsSchema; @@ -356,6 +391,12 @@ export interface PageAccess extends v.InferInput {} export interface PageFragmentsConstraints extends v.InferInput< typeof pageFragmentsConstraintsSchema > {} +export interface TaxonomiesAccess extends v.InferInput< + typeof taxonomiesAccessSchema +> {} +export interface TaxonomiesReadConstraints extends v.InferInput< + typeof taxonomiesReadConstraintsSchema +> {} export interface UsersAccess extends v.InferInput {} export interface UsersReadConstraints extends v.InferInput< typeof usersReadConstraintsSchema diff --git a/packages/workerd/src/sandbox/bridge-handler.ts b/packages/workerd/src/sandbox/bridge-handler.ts index 775b23e09f..9562529985 100644 --- a/packages/workerd/src/sandbox/bridge-handler.ts +++ b/packages/workerd/src/sandbox/bridge-handler.ts @@ -201,6 +201,25 @@ async function dispatch( requireStringArray(body, "ids"), ); + // ── Taxonomies (read-only) ────────────────────────────────────── + // `taxonomies:read` is a post-rename capability: it has no legacy + // alias, so the canonical name is checked directly. + case "taxonomy/list": + requireCapability(opts, "taxonomies:read"); + return taxonomyList(db, optionalString(body, "locale")); + case "taxonomy/terms": + requireCapability(opts, "taxonomies:read"); + return taxonomyTerms(db, requireString(body, "taxonomy"), optionalString(body, "locale")); + case "taxonomy/entryTerms": + requireCapability(opts, "taxonomies:read"); + return taxonomyEntryTerms( + db, + requireString(body, "collection"), + requireString(body, "entryId"), + optionalString(body, "taxonomy"), + optionalString(body, "locale"), + ); + // ── Media ─────────────────────────────────────────────────────── case "media/get": requireCapability(opts, "read:media"); @@ -919,6 +938,111 @@ async function contentDeleteMany( }); } +// ── Taxonomy Operations (read-only) ────────────────────────────────────── + +/** Type guard for plain JSON objects. */ +function isJsonObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Parse the `collections` JSON column into a string array (`[]` on anything else). */ +function parseCollectionsColumn(value: string | null): string[] { + if (!value) return []; + try { + const parsed: unknown = JSON.parse(value); + return Array.isArray(parsed) + ? parsed.filter((item): item is string => typeof item === "string") + : []; + } catch { + return []; + } +} + +/** + * Convert a `taxonomies` row to the term shape exposed over the bridge. + * Matches the Cloudflare PluginBridge and core's TaxonomyTermInfo. + */ +function rowToTaxonomyTerm(row: { + id: string; + name: string; + slug: string; + label: string; + parent_id: string | null; + data: string | null; + locale: string; + translation_group: string | null; +}) { + let data: Record | null = null; + if (row.data) { + try { + const parsed: unknown = JSON.parse(row.data); + if (isJsonObject(parsed)) data = parsed; + } catch { + data = null; + } + } + return { + id: row.id, + taxonomy: row.name, + slug: row.slug, + label: row.label, + parentId: row.parent_id, + data, + locale: row.locale, + translationGroup: row.translation_group, + }; +} + +async function taxonomyList(db: Kysely, locale?: string) { + let query = db.selectFrom("_emdash_taxonomy_defs").selectAll(); + if (locale !== undefined) query = query.where("locale", "=", locale); + const rows = await query.orderBy("name", "asc").execute(); + return rows.map((row) => ({ + name: row.name, + label: row.label, + labelSingular: row.label_singular, + hierarchical: row.hierarchical === 1, + collections: parseCollectionsColumn(row.collections), + locale: row.locale, + })); +} + +async function taxonomyTerms(db: Kysely, taxonomy: string, locale?: string) { + // `id asc` is a stable tiebreaker for terms sharing a label, matching + // core's TaxonomyRepository.findByName ordering. + let query = db + .selectFrom("taxonomies") + .selectAll() + .where("name", "=", taxonomy) + .orderBy("label", "asc") + .orderBy("id", "asc"); + if (locale !== undefined) query = query.where("locale", "=", locale); + const rows = await query.execute(); + return rows.map(rowToTaxonomyTerm); +} + +async function taxonomyEntryTerms( + db: Kysely, + collection: string, + entryId: string, + taxonomy?: string, + locale?: string, +) { + // The pivot stores the term's translation_group in taxonomy_id, so the + // join resolves an assignment into each locale's term row. + let query = db + .selectFrom("content_taxonomies") + .innerJoin("taxonomies", "taxonomies.translation_group", "content_taxonomies.taxonomy_id") + .selectAll("taxonomies") + .where("content_taxonomies.collection", "=", collection) + .where("content_taxonomies.entry_id", "=", entryId) + .orderBy("taxonomies.locale", "asc"); + if (taxonomy !== undefined) query = query.where("taxonomies.name", "=", taxonomy); + if (locale !== undefined) query = query.where("taxonomies.locale", "=", locale); + const rows = await query.execute(); + return rows.map(rowToTaxonomyTerm); +} + // ── Media Operations ───────────────────────────────────────────────────── function rowToMediaItem(row: { diff --git a/packages/workerd/src/sandbox/wrapper.ts b/packages/workerd/src/sandbox/wrapper.ts index f48aea83c4..ce2fd9b26d 100644 --- a/packages/workerd/src/sandbox/wrapper.ts +++ b/packages/workerd/src/sandbox/wrapper.ts @@ -123,6 +123,13 @@ function createContext() { deleteMany: (collection, ids) => bridgeCall("content/deleteMany", { collection, ids }), }; + // Taxonomy access (read-only) - capability enforced by the bridge + const taxonomies = { + getAll: (opts) => bridgeCall("taxonomy/list", { ...opts }), + getTerms: (taxonomy, opts) => bridgeCall("taxonomy/terms", { taxonomy, ...opts }), + getEntryTerms: (collection, entryId, opts) => bridgeCall("taxonomy/entryTerms", { collection, entryId, ...opts }), + }; + const media = { get: (id) => bridgeCall("media/get", { id }), list: (opts) => bridgeCall("media/list", opts || {}), @@ -317,6 +324,7 @@ function createContext() { storage, kv, content, + taxonomies, media, http, log, diff --git a/packages/workerd/test/bridge-handler.test.ts b/packages/workerd/test/bridge-handler.test.ts index 191c025050..71c764f709 100644 --- a/packages/workerd/test/bridge-handler.test.ts +++ b/packages/workerd/test/bridge-handler.test.ts @@ -226,6 +226,142 @@ describe("Bridge Handler Conformance", () => { expect(result.error).toContain("Missing capability: read:content"); }); + it("rejects taxonomy read without taxonomies:read capability", async () => { + // content:read does not grant taxonomy access — it's a separate + // capability (and a new one, so the canonical name is checked). + const handler = makeHandler({ capabilities: ["read:content"] }); + const result = await call(handler, "taxonomy/list", {}); + expect(result.error).toContain("Missing capability: taxonomies:read"); + }); + + it("allows taxonomy read with taxonomies:read", async () => { + await db.schema + .createTable("_emdash_taxonomy_defs") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("name", "text", (col) => col.notNull()) + .addColumn("label", "text", (col) => col.notNull()) + .addColumn("label_singular", "text") + .addColumn("hierarchical", "integer", (col) => col.notNull().defaultTo(0)) + .addColumn("collections", "text") + .addColumn("locale", "text", (col) => col.notNull().defaultTo("en")) + .addColumn("translation_group", "text") + .execute(); + await db + .insertInto("_emdash_taxonomy_defs" as any) + .values({ + id: "def-genre", + name: "genre", + label: "Genres", + label_singular: "Genre", + hierarchical: 1, + collections: '["posts"]', + locale: "en", + translation_group: "def-genre", + }) + .execute(); + + const handler = makeHandler({ capabilities: ["taxonomies:read"] }); + const result = await call(handler, "taxonomy/list", {}); + expect(result.error).toBeUndefined(); + const defs = result.result as Array<{ name: string; hierarchical: boolean }>; + expect(defs).toHaveLength(1); + expect(defs[0]).toMatchObject({ name: "genre", hierarchical: true, collections: ["posts"] }); + }); + + it("resolves taxonomy terms and entry terms through the pivot join", async () => { + await db.schema + .createTable("taxonomies") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("name", "text", (col) => col.notNull()) + .addColumn("slug", "text", (col) => col.notNull()) + .addColumn("label", "text", (col) => col.notNull()) + .addColumn("parent_id", "text") + .addColumn("data", "text") + .addColumn("locale", "text", (col) => col.notNull().defaultTo("en")) + .addColumn("translation_group", "text") + .execute(); + await db.schema + .createTable("content_taxonomies") + .addColumn("collection", "text", (col) => col.notNull()) + .addColumn("entry_id", "text", (col) => col.notNull()) + .addColumn("taxonomy_id", "text", (col) => col.notNull()) + .execute(); + await db + .insertInto("taxonomies" as any) + .values([ + { + id: "term-en", + name: "genre", + slug: "scifi", + label: "Sci-Fi", + data: '{"description":"Space"}', + locale: "en", + translation_group: "tg-scifi", + }, + { + id: "term-de", + name: "genre", + slug: "scifi", + label: "Science-Fiction", + locale: "de", + translation_group: "tg-scifi", + }, + { + id: "term-other", + name: "genre", + slug: "fantasy", + label: "Fantasy", + locale: "en", + translation_group: "tg-fantasy", + }, + ]) + .execute(); + // The pivot stores the term's translation_group, not a row id. + await db + .insertInto("content_taxonomies" as any) + .values({ collection: "posts", entry_id: "post-1", taxonomy_id: "tg-scifi" }) + .execute(); + + const denied = makeHandler({ capabilities: ["read:content"] }); + expect((await call(denied, "taxonomy/terms", { taxonomy: "genre" })).error).toContain( + "Missing capability: taxonomies:read", + ); + expect( + (await call(denied, "taxonomy/entryTerms", { collection: "posts", entryId: "post-1" })) + .error, + ).toContain("Missing capability: taxonomies:read"); + + const handler = makeHandler({ capabilities: ["taxonomies:read"] }); + + // terms: locale filter + data JSON parsing + translation group + const terms = await call(handler, "taxonomy/terms", { taxonomy: "genre", locale: "en" }); + expect(terms.error).toBeUndefined(); + const termRows = terms.result as Array>; + expect(termRows.map((t) => t.slug)).toEqual(["fantasy", "scifi"]); + expect(termRows[1]).toMatchObject({ + id: "term-en", + data: { description: "Space" }, + translationGroup: "tg-scifi", + }); + + // entryTerms: pivot join on translation_group resolves both locales + const entryTerms = await call(handler, "taxonomy/entryTerms", { + collection: "posts", + entryId: "post-1", + }); + expect(entryTerms.error).toBeUndefined(); + const entryRows = entryTerms.result as Array>; + expect(entryRows.map((t) => t.id)).toEqual(["term-de", "term-en"]); + + // entryTerms: locale narrows to one row per assignment + const localized = await call(handler, "taxonomy/entryTerms", { + collection: "posts", + entryId: "post-1", + locale: "de", + }); + expect((localized.result as unknown[]).length).toBe(1); + }); + it("rejects user read without read:users capability", async () => { const handler = makeHandler({ capabilities: [] }); const result = await call(handler, "users/get", { id: "user-1" });