Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/__tests__/plugins/definePlugin.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
14 changes: 14 additions & 0 deletions src/core/plugin-sdk/builders/definePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
Loading