Skip to content

feat(plugins): read-only taxonomy access via new taxonomies:read capability#1719

Merged
ascorbic merged 3 commits into
emdash-cms:mainfrom
swissky:feat/plugin-taxonomy-read
Jul 10, 2026
Merged

feat(plugins): read-only taxonomy access via new taxonomies:read capability#1719
ascorbic merged 3 commits into
emdash-cms:mainfrom
swissky:feat/plugin-taxonomy-read

Conversation

@swissky

@swissky swissky commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Implements the proposal in Discussion #1718, updated per @ascorbic's feedback there: taxonomy reads are now gated behind a new, separate taxonomies:read capability instead of piggybacking on content:read. Plugins that declare it get a read-only ctx.taxonomies accessor, available in-process and in both sandbox runners with identical shapes and gating:

  • ctx.taxonomies.getAll(opts?) — taxonomy definitions (name, label, labelSingular, hierarchical, collections, locale)
  • ctx.taxonomies.getTerms(taxonomy, opts?) — all terms of a taxonomy, ordered by label (flat; hierarchy reconstructable via parentId, which carries the parent's locale-agnostic translation group)
  • ctx.taxonomies.getEntryTerms(collection, entryId, opts?) — terms assigned to a content entry, optionally scoped to one taxonomy

All three accept an optional locale; terms include their data (description etc.) and translationGroup for i18n-aware plugins. There is no implication from content:read/content:write — a plugin that reads content and its classification declares both. Taxonomy writes stay out of scope (admin-side invariants around translation groups and the pivot table shouldn't be re-exposed).

Naming note: taxonomies:read (plural) matches the existing taxonomies:read/taxonomies:manage admin permissions in rbac.ts. Happy to rename to the singular taxonomy:read if preferred.

Wiring (the full trust-contract surface for a new capability):

  • core: createTaxonomyAccess in plugins/context.ts (on top of TaxonomyRepository), gated in the context factory; taxonomies added to CURRENT_PLUGIN_CAPABILITIES and the manifest Zod schema; new TaxonomyAccess / TaxonomyDefInfo / TaxonomyTermInfo / TaxonomyReadOptions types exported.
  • @emdash-cms/plugin-types: taxonomies:read in the PluginCapability union, taxonomies.read in DeclaredAccess, both converters (capabilitiesToDeclaredAccess / declaredAccessToCapabilities) stay total and isomorphic.
  • @emdash-cms/registry-lexicons: taxonomies category in releaseExtension.json (+ regenerated types), so registry records can carry the declaration.
  • @emdash-cms/plugin-cli / @emdash-cms/marketplace: capability vocabulary updated (bundle/validate/publish accept it).
  • @emdash-cms/admin: consent-dialog label ("Read your taxonomies and terms").
  • @emdash-cms/cloudflare: PluginBridge methods taxonomyList / taxonomyTerms / taxonomyEntryTerms, gated on taxonomies:read, exposed as ctx.taxonomies in the sandbox wrapper.
  • @emdash-cms/sandbox-workerd: matching taxonomy/list|terms|entryTerms bridge-handler cases + wrapper accessor, behavior-parity with the Cloudflare bridge (canonical name checked — the new capability has no legacy alias).
  • Docs: capability reference, manifest reference, and the route-context shape updated.

Discussion: #1718

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: Expose read-only taxonomy access in the plugin content API #1718

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Cursor + Fable 5

Screenshots / test output

Integration tests in packages/core/tests/integration/plugins/capabilities.test.ts cover the read surface plus the gating in both directions (content:read alone does NOT expose ctx.taxonomies, and taxonomies:read alone does not expose ctx.content). The workerd bridge conformance suite asserts the same at the bridge level, and the plugin-types round-trip test now enumerates 2880 implication-closed capability states including the new one.

core plugins suites:      692 passed (30 files)
@emdash-cms/plugin-types:  32 passed
@emdash-cms/plugin-cli:   393 passed
@emdash-cms/cloudflare:   214 passed
@emdash-cms/sandbox-workerd: 73 passed
@emdash-cms/admin (marketplace): 24 passed

pnpm typecheck and pnpm lint (0 diagnostics) pass across the workspace.

@changeset-bot

changeset-bot Bot commented Jul 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 93522c7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
Name Type
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 Minor
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/registry-client Patch
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 593 lines across 12 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@pkg-pr-new

pkg-pr-new Bot commented Jul 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1719

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1719

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1719

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1719

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1719

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1719

emdash

npm i https://pkg.pr.new/emdash@1719

create-emdash

npm i https://pkg.pr.new/create-emdash@1719

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1719

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1719

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1719

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1719

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1719

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1719

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1719

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1719

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1719

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1719

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1719

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1719

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1719

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1719

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1719

commit: 93522c7

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR implements read-only taxonomy access for plugins through the existing content:read capability, as approved in Discussion #1718. The approach is sound: it reuses TaxonomyRepository, adds the three methods to ContentAccess, and mirrors them in both sandbox bridges/wrappers with consistent shapes and locale filtering. The changeset and docs are in place, and the in-process integration tests cover the core behavior well.

I found no logic bugs, regressions, or security issues in the new code. SQL is parameterized in both bridges, capability gating follows each bridge's established convention, and the i18n semantics (locale filtering + translation group exposure) match the repository design.

Two things to note:

  1. The new doc comment in ContentAccess uses the legacy capability name read:content; the canonical/runtime name is content:read. Minor cleanup.
  2. The bridge/wrapper wiring in @emdash-cms/cloudflare and @emdash-cms/sandbox-workerd is not covered by new tests — only the in-process core path is. Given that the bridge methods include their own row mapping and (in Cloudflare) raw SQL, bridge-level tests would strengthen the PR.

One broader architectural context: the workerd bridge still enforces legacy capability strings (read:content, read:media, etc.) because the workerd runner passes manifest capabilities through without normalizing them to canonical names. The new taxonomy cases follow that existing pattern, so they do not introduce a fresh inconsistency, but full parity with the Cloudflare bridge for canonical-manifest plugins remains blocked by that pre-existing runner behavior.

Comment thread packages/core/src/plugins/types.ts Outdated
get(collection: string, id: string): Promise<ContentItem | null>;
list(collection: string, options?: ContentListOptions): Promise<PaginatedResult<ContentItem>>;

// Taxonomy read operations (requires read:content). Terms classify content

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The new comment uses the deprecated capability name read:content. Capabilities are normalized to the canonical content:read name before reaching PluginContextFactory, so the docstring should match the runtime vocabulary.

Suggested change
// Taxonomy read operations (requires read:content). Terms classify content
// Taxonomy read operations (requires content:read). Terms classify content

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved by the switch to the dedicated capability: the docstring now reads "capability-gated on taxonomies:read" — the read:content wording is gone.

Comment on lines +204 to +206
// ── Taxonomies (read-only) ──────────────────────────────────────
case "taxonomy/list":
requireCapability(opts, "read:content");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The workerd bridge taxonomy wiring is currently only exercised by the in-process core tests indirectly. Consider adding bridge-handler-level tests (similar to the existing content capability enforcement cases in packages/workerd/test/bridge-handler.test.ts and plugin-integration.test.ts) that assert the taxonomy/list, taxonomy/terms, and taxonomy/entryTerms cases enforce capabilities, respect locale filtering, and return the expected term shapes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Covered since the taxonomies:read rework: packages/workerd/test/bridge-handler.test.ts now has explicit cases asserting taxonomy/list is rejected without taxonomies:read (including that content:read does not grant it) and allowed with it.

Comment on lines +706 to +708
> {
const { capabilities } = this.ctx.props;
if (!capabilities.includes("content:read")) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The Cloudflare bridge taxonomy methods add their own row parsing and raw SQL paths, but they are not covered by package-specific tests. Adding tests for taxonomyList, taxonomyTerms, and taxonomyEntryTerms would guard regressions in the D1 row mapping, JSON parsing, and locale/taxonomy filtering logic.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 49220f9packages/cloudflare/tests/sandbox/bridge-taxonomy.test.ts covers all three methods: capability enforcement (content:read doesn't grant access), the locale/taxonomy filter SQL + bound params, the pivot join on translation_group, and the row mapping (JSON collections/data parsing incl. malformed JSON, hierarchical int→bool, nullable label_singular/parent_id). 8 cases, all green.

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Jul 2, 2026
@swissky
swissky force-pushed the feat/plugin-taxonomy-read branch from 78d9e63 to 203dc15 Compare July 9, 2026 11:57
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review area/admin and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 9, 2026
@swissky swissky changed the title feat(plugins): read-only taxonomy access via content:read feat(plugins): read-only taxonomy access via new taxonomies:read capability Jul 9, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 9, 2026
Review follow-up: capability enforcement, locale/taxonomy filter SQL
wiring, and D1 row mapping (JSON parsing, int→bool, nullable columns)
for taxonomyList/taxonomyTerms/taxonomyEntryTerms.
@ascorbic ascorbic added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Jul 9, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR implements the new taxonomies:read plugin capability as approved in Discussion #1718, switching from the earlier approach of piggybacking on content:read. The new trust-contract surface is wired consistently across core, @emdash-cms/plugin-types, @emdash-cms/plugin-cli, @emdash-cms/registry-lexicons, @emdash-cms/admin, @emdash-cms/cloudflare, and @emdash-cms/sandbox-workerd, including the isomorphic DeclaredAccess round-trip and manifest schema changes.

What I checked:

  • Capability gating in PluginContextFactory, both sandbox bridges, and the workerd bridge handler — all correctly require the canonical taxonomies:read, with no legacy alias (as intended for a post-rename capability).
  • SQL safety in both bridges: Cloudflare uses parameterized ? placeholders and hardcoded table names; workerd uses Kysely with parameterized identifiers/values. No injection vector.
  • Locale filtering: getAll, getTerms, and getEntryTerms all accept and apply an optional locale, matching the repository semantics.
  • Trust-contract consistency: plugin-types converters, core manifest-schema, plugin-cli schema, marketplace author routes, admin labels, and registry lexicons all include taxonomies:read.
  • Tests: core integration tests cover the in-process API and capability separation; the Cloudflare bridge now has dedicated tests for row mapping, parameter binding, and gating.

No logic bugs, security issues, or regressions. Two non-blocking follow-ups would strengthen it: (1) the in-process createTaxonomyAccess.getAll should parse collections defensively to match the graceful handling in both bridges, and (2) the workerd bridge conformance suite only tests taxonomy/list — adding cases for taxonomy/terms and taxonomy/entryTerms would complete the bridge parity this PR otherwise achieves.

Comment thread packages/core/src/plugins/context.ts Outdated
label: row.label,
labelSingular: row.label_singular,
hierarchical: row.hierarchical === 1,
collections: row.collections ? (JSON.parse(row.collections) as string[]) : [],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] JSON.parse(row.collections) will throw if the definition row somehow holds malformed JSON. The Cloudflare and workerd bridges already guard this with try/catch and fall back to [], so an in-process plugin could crash on the same data that a sandboxed plugin would degrade from. Wrap the parse the same way to keep the three surfaces consistent.

Suggested change
collections: row.collections ? (JSON.parse(row.collections) as string[]) : [],
collections: row.collections ? parseStringArray(row.collections) : [],

(Sharing the existing parseCollectionsColumn-style helper from the bridges would work.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 93522c7 — the parse now goes through parseCollectionsColumn(), which try/catches JSON.parse, returns [] for anything that isn't an array, and filters non-string items — mirroring the guards in the Cloudflare/workerd bridges.

})
.execute();

const handler = makeHandler({ capabilities: ["taxonomies:read"] });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The workerd conformance suite only exercises taxonomy/list. Because taxonomy/terms and taxonomy/entryTerms include their own join logic and row mapping (especially the content_taxonomies.taxonomy_id → taxonomies.translation_group join), add analogous tests for those two handlers to ensure workerd parity with the Cloudflare bridge.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 93522c7 — the workerd conformance suite now exercises all three methods: capability rejection for taxonomy/terms and taxonomy/entryTerms, plus happy-path cases including the locale-filtered entryTerms join.

@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Jul 10, 2026
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.
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/approved Approved; no new commits since labels Jul 10, 2026
@swissky

swissky commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Both follow-ups from the approving review are in (93522c7): the in-process getAll now parses collections through the same guarded helper the bridges use (malformed JSON degrades to [] instead of throwing), and the workerd conformance suite covers taxonomy/terms and taxonomy/entryTerms — gating, locale filtering, data JSON parsing, and the translation_group pivot join.

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@ascorbic
ascorbic merged commit 7c5de08 into emdash-cms:main Jul 10, 2026
52 checks passed
@emdashbot emdashbot Bot mentioned this pull request Jul 10, 2026
marcusbellamyshaw-cell pushed a commit to Emdash-Bug-Testing/emdash that referenced this pull request Jul 22, 2026
…bility (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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/admin area/cloudflare area/core area/docs bot:review Trigger an emdashbot code review on this PR cla: signed overlap review/needs-rereview Author pushed changes since the last review size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants