diff --git a/src/__tests__/plugins/definePlugin.test.ts b/src/__tests__/plugins/definePlugin.test.ts new file mode 100644 index 000000000..100135e4e --- /dev/null +++ b/src/__tests__/plugins/definePlugin.test.ts @@ -0,0 +1,55 @@ +/** + * Regression test for a real gap found building a content-editor-kind + * plugin (wyre.approvals, 2026-09-08): `definePlugin()` silently dropped + * `contentAccess` from the manifest it returns, even though it's a real + * field on `DefinePluginConfig`/`PluginManifest` and the CLI's own + * `init --kind content-editor` scaffold writes it into the generated + * `instatic-plugin.config.ts`. Any plugin declaring a `cms.content.*` + * permission then failed manifest validation with "contentAccess is + * required..." despite having declared it correctly — the config field + * existed, the builder just never copied it into its output. + */ +import { describe, expect, it } from 'bun:test' +import { definePlugin } from '@core/plugin-sdk' +import { parsePluginManifest } from '@core/plugins/manifest' + +describe('definePlugin — contentAccess', () => { + it('carries contentAccess through into the returned manifest', () => { + const { manifest } = definePlugin({ + id: 'acme.example', + name: 'Example', + version: '0.1.0', + permissions: ['cms.content.read', 'cms.content.publish'], + contentAccess: [{ table: 'pages', modes: ['read', 'publish'] }], + }) + + expect(manifest.contentAccess).toEqual([{ table: 'pages', modes: ['read', 'publish'] }]) + }) + + it('omits contentAccess entirely when not declared (matches every other optional field)', () => { + const { manifest } = definePlugin({ + id: 'acme.example', + name: 'Example', + version: '0.1.0', + permissions: ['cms.storage'], + }) + + expect(manifest.contentAccess).toBeUndefined() + }) + + it('the resulting manifest passes parsePluginManifest — the actual failure mode this fixes', () => { + const { manifest } = definePlugin({ + id: 'acme.example', + name: 'Example', + version: '0.1.0', + permissions: ['cms.content.read', 'cms.content.publish'], + contentAccess: [{ table: 'pages', modes: ['read', 'publish'] }], + }) + + // Before the fix this threw: "contentAccess is required when any + // cms.content.* permission is granted" — parsing the manifest built by + // definePlugin() itself failed even though the author had declared + // contentAccess correctly in instatic-plugin.config.ts. + expect(() => parsePluginManifest(manifest)).not.toThrow() + }) +}) diff --git a/src/core/plugin-sdk/builders/definePlugin.ts b/src/core/plugin-sdk/builders/definePlugin.ts index 369849fbb..93953a384 100644 --- a/src/core/plugin-sdk/builders/definePlugin.ts +++ b/src/core/plugin-sdk/builders/definePlugin.ts @@ -33,6 +33,7 @@ import type { PluginPermission, PluginResource, } from '../types' +import type { ContentAccessEntry } from '../contentSchemas' import type { PluginModuleDefinition } from '../modules' import type { PluginPackContents } from './definePack' import { @@ -83,6 +84,16 @@ export interface DefinePluginConfig { */ resources?: PluginResource[] + /** + * Per-table allowlist for the `cms.content.*` SDK surface. Required + * (non-empty) when `permissions` includes any of `cms.content.read` / + * `cms.content.write` / `cms.content.publish` / `cms.content.delete` — + * `parsePluginManifest` fails closed otherwise. Each entry's `modes[]` + * must be covered by the matching `cms.content.*` permission (a `write` + * mode with no `cms.content.write` permission is rejected at parse time). + */ + contentAccess?: ContentAccessEntry[] + /** * Admin pages registered by the plugin (markdown / map / resource / app). * Auto-deduped against the page id. @@ -188,6 +199,9 @@ export function definePlugin(config: DefinePluginConfig): PluginDefinition { ...(config.frontend !== undefined ? { frontend: { assets: config.frontend.assets.map((asset) => ({ ...asset })) } } : {}), + ...(config.contentAccess + ? { contentAccess: config.contentAccess.map((entry) => ({ ...entry, modes: [...entry.modes] })) } + : {}), } return { manifest,