Skip to content
1 change: 1 addition & 0 deletions docs/features/content-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ The schema for a collection. One row per collection.
| `primary_field_id`| text | Field id used as the row's display name in grids / pickers |
| `fields_json` | jsonb | `DataField[]` — the schema |
| `system` | boolean | `true` for seeded tables (`posts`, `pages`, `components`, `layouts`) |
| `created_by_plugin_id` | text | Plugin that created the table via `cms.content.tables.create`; null for user/import-created tables. The plugin host's `@own-created` contentAccess marker resolves against it. |
| `created_*`, `updated_*` | - | Standard audit fields |

### `data_rows`
Expand Down
11 changes: 7 additions & 4 deletions docs/features/plugin-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -692,14 +692,17 @@ Plugins read and write CMS content (pages, posts, custom tables) through `api.cm
| `cms.content.delete` | High | Soft-delete entries |
| `cms.content.tables.manage` | Dangerous | Create user-managed tables (never system tables) |

The manifest's `contentAccess[]` lists every table whose entries the plugin can touch, with per-table modes. The host fails closed without both the permission and the allowlist entry for entry reads/writes/publishes/deletes. `content.tables.create(...)` is different: it requires `cms.content.tables.manage`, creates a new user-managed table, and does not require a pre-existing `contentAccess[]` row for that table.
The manifest's `contentAccess[]` lists every table whose entries the plugin can touch, with per-table modes. The host fails closed without both the permission and a covering allowlist entry for entry reads/writes/publishes/deletes. `content.tables.create(...)` is different: it requires `cms.content.tables.manage`, creates a new user-managed table, and does not require a pre-existing `contentAccess[]` row for that table.

An entry's `table` is a concrete slug, or the **`@own-created` marker** (`OWN_CREATED_TABLES_MARKER` in the SDK): it covers every table this plugin itself created through `content.tables.create(...)`. The create handler stamps the host-authenticated plugin id on the table row (`data_tables.created_by_plugin_id`), and the marker resolves against that stored creator — never against the slug — so it is durable across restarts and admin-side slug renames, and one plugin's marker never reaches another plugin's (or a user's) tables. This is the shape importer/migration plugins need: their table names are chosen by the operator at runtime and can't be pre-declared. Entries combine as a union — an operation is allowed when any matching entry declares the mode — and marker modes go through the same install-time modes↔permissions coherence check as slug entries.

```jsonc
{
"permissions": ["cms.content.read", "cms.content.write"],
"permissions": ["cms.content.read", "cms.content.write", "cms.content.tables.manage"],
"contentAccess": [
{ "table": "pages", "modes": ["read", "write"] },
{ "table": "posts", "modes": ["read"] }
{ "table": "posts", "modes": ["read"] },
{ "table": "@own-created", "modes": ["read", "write"] }
]
}
```
Expand Down Expand Up @@ -754,7 +757,7 @@ const snap = await api.cms.content.getPublishedSnapshot(entryId)
const { count } = await api.cms.content.republishAll()
```

`tables.create(input)` accepts the plugin-facing field projection, then maps it to the host's canonical `DataField` schema before storage. `richText` fields default to Markdown format, `select` / `multiSelect` option `value`s become stable option IDs, and `relation.targetTableSlug` must resolve to an existing table slug. `repeater` accepts a one-level `fields` schema made from ordinary authorable fields; nested relation slugs are resolved through the same gate, while recursive repeaters, `pageTree`, and `fieldSchema` item fields are rejected by the boundary schema.
`tables.create(input)` accepts the plugin-facing field projection, then maps it to the host's canonical `DataField` schema before storage. The created table records the calling plugin as `createdByPluginId`, which the `@own-created` contentAccess marker resolves against for all subsequent entry access; the table slug is restricted to kebab-case (leading letter), reserving the `@` namespace for markers. `richText` fields default to Markdown format, `select` / `multiSelect` option `value`s become stable option IDs, and `relation.targetTableSlug` must resolve to an existing table slug. `repeater` accepts a one-level `fields` schema made from ordinary authorable fields; nested relation slugs are resolved through the same gate, while recursive repeaters, `pageTree`, and `fieldSchema` item fields are rejected by the boundary schema.

`republishAll` fires the full publish pipeline (`publish.before` → `publish.html` → `publish.after`), so other plugins' filters and listeners participate.

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/architecture-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ See [docs/features/spotlight.md](../features/spotlight.md).
| `plugin-sandbox-invariants.test.ts` | No `node:`, `bun:`, `require(`, `process.binding` in plugin bundles; network permission gate is centralized in `apiDispatch.ts` and driven by `TARGET_PERMISSIONS`. |
| `plugin-boot-resilience.test.ts` | One bad plugin doesn't bring the server down. Crashes are isolated. |
| `plugin-cms-content-surface.test.ts` | All five `cms.content.*` permissions are wired across all sync-points (permission values, capability matrix, permission alias builder, SDK type surface, host-side dispatch). |
| `plugin-content-access-enforced.test.ts` | `cms.content.*` permission grant is enforced centrally in `apiDispatch.ts` (driven by `TARGET_PERMISSIONS`); per-table handlers additionally call `assertContentTableAccess` (manifest `contentAccess[]` allowlist). |
| `plugin-content-access-enforced.test.ts` | `cms.content.*` permission grant is enforced centrally in `apiDispatch.ts` (driven by `TARGET_PERMISSIONS`); per-table handlers additionally call `assertContentTableAccess` (manifest `contentAccess[]` allowlist — slug entries + the `@own-created` marker); `tables.create` records `createdByPluginId`, which the marker resolves against. |
| `plugin-content-tree-via-engine.test.ts` | Plugin content handlers reach page-tree mutations through `applyTreeOperation` from `@core/page-tree`, not by deep-importing `mutations.ts` directly. |
| `plugin-host-import-boundaries.test.ts` | Worker transport (`server/plugins/host/`) does not import `apiDispatch` — prevents circular dependency between the pool and the dispatch layer. |
| `plugin-host-ui-runtime-parity.test.ts` | Plugin host UI surfaces match the SDK's declared shape. |
Expand Down
12 changes: 9 additions & 3 deletions server/branches/entities/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,18 +118,24 @@ export const tableAdapter: BranchEntityAdapter<'table', TableContent> = {
},

async copy(source, to) {
// The physical key is minted per row in TS (the single scheme).
// The physical key is minted per row in TS (the single scheme). Every
// other column is carried as-is — including `created_by_plugin_id`, which
// is ownership rather than content: the `@own-created` contentAccess
// marker resolves against it, so a copy without it would hold a table
// that no longer belongs to the plugin that made it.
const from = source.scope
for (const table of await source.tables()) {
await source.db`
insert into data_tables (
id, branch_id, name, slug, kind, route_base, singular_label,
plural_label, primary_field_id, fields_json, system,
created_by_user_id, updated_by_user_id, created_at, updated_at
created_by_user_id, created_by_plugin_id, updated_by_user_id,
created_at, updated_at
)
select ${physicalId(to.branchId, table.id)}, ${to.branchId}, name, slug, kind, route_base,
singular_label, plural_label, primary_field_id, fields_json, system,
created_by_user_id, updated_by_user_id, created_at, updated_at
created_by_user_id, created_by_plugin_id, updated_by_user_id,
created_at, updated_at
from data_tables
where id = ${physicalId(from.branchId, table.id)}
`
Expand Down
11 changes: 11 additions & 0 deletions server/db/migrations-pg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1359,4 +1359,15 @@ export const pgMigrations: Migration[] = [
id: '030_iso_timestamps',
sql: 'select 1',
},
{
// Which plugin created this table via `cms.content.tables.create` (null
// for user/import-created tables). The plugin host's `@own-created`
// contentAccess marker resolves against this column, so a plugin keeps
// access to tables it created at runtime — durable across restarts and
// admin-side slug renames. Nullable, no default: purely additive.
id: '031_data_tables_created_by_plugin',
sql: `
alter table data_tables add column created_by_plugin_id text;
`,
},
]
11 changes: 11 additions & 0 deletions server/db/migrations-sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1515,4 +1515,15 @@ export const sqliteMigrations: Migration[] = [
id: '030_iso_timestamps',
sql: isoTimestampRewrite030(),
},
{
// Which plugin created this table via `cms.content.tables.create` (null
// for user/import-created tables). The plugin host's `@own-created`
// contentAccess marker resolves against this column, so a plugin keeps
// access to tables it created at runtime — durable across restarts and
// admin-side slug renames. Nullable, no default: purely additive.
id: '031_data_tables_created_by_plugin',
sql: `
alter table data_tables add column created_by_plugin_id text;
`,
},
]
8 changes: 7 additions & 1 deletion server/handlers/cms/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,10 @@ export async function handleImportRoute(
})
tablesAffected++
} else if (!SYSTEM_TABLE_IDS.has(table.id)) {
// Custom table — insert with original id
// Custom table — insert with original id. `createdByPluginId` is
// access-control state (the `@own-created` contentAccess marker
// resolves against it), so a restore keeps plugin-created tables
// owned by their plugin.
await createDataTable(tx, scope, {
id: table.id,
name: table.name,
Expand All @@ -258,6 +261,7 @@ export async function handleImportRoute(
pluralLabel: table.pluralLabel,
primaryFieldId: table.primaryFieldId,
fields: table.fields,
createdByPluginId: table.createdByPluginId ?? null,
})
tablesAffected++
}
Expand Down Expand Up @@ -326,6 +330,7 @@ export async function handleImportRoute(
pluralLabel: table.pluralLabel,
primaryFieldId: table.primaryFieldId,
fields: table.fields,
createdByPluginId: table.createdByPluginId ?? null,
})
if (inserted) tablesAffected++
}
Expand Down Expand Up @@ -378,6 +383,7 @@ export async function handleImportRoute(
pluralLabel: table.pluralLabel,
primaryFieldId: table.primaryFieldId,
fields: table.fields,
createdByPluginId: table.createdByPluginId ?? null,
})
if (!inserted) {
await updateDataTable(tx, scope, table.id, {
Expand Down
208 changes: 208 additions & 0 deletions server/plugins/host/handlers/content.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* End-to-end host-side coverage for the `@own-created` content-access flow:
* a plugin creates a table through `cms.content.tables.create` (which records
* `created_by_plugin_id`), then reaches its entries through the ordinary
* `cms.content.entries.*` handlers via the `@own-created` marker — while
* every other plugin stays locked out of that table.
*
* Handlers reply through `replyApiOk`, which silently drops when no worker
* is registered for the plugin id — so success is asserted via repository
* reads and denial via the thrown access error (exactly what the dispatcher
* turns into an error reply in production).
*/
import { describe, expect, it, beforeEach } from 'bun:test'
import { createSqliteClient } from '../../../db/sqlite'
import { sqliteMigrations } from '../../../db/migrations-sqlite'
import { runMigrations } from '../../../db/runMigrations'
import type { DbClient } from '../../../db/client'
import { getDataTableBySlug, getDataRowBySlug } from '../../../repositories/data'
import { MAIN_SCOPE } from '../../../branches/scope'
import type { DataTable } from '@core/data/schemas'
import { parsePluginManifest } from '@core/plugins/manifest'
import { OWN_CREATED_TABLES_MARKER, type ContentAccessEntry } from '@core/plugin-sdk'
import { assertContentTableAccess } from '../registry'
import type { HostPluginRecord } from '../types'
import {
handleContentEntriesCreate,
handleContentEntriesList,
handleContentTablesCreate,
handleContentTablesGet,
} from './content'

async function freshDb(): Promise<DbClient> {
const db = createSqliteClient(':memory:')
await runMigrations(db, sqliteMigrations)
return db
}

function pluginRecord(id: string, contentAccess: ContentAccessEntry[]): HostPluginRecord {
const manifest = parsePluginManifest({
id,
name: 'Importer fixture',
version: '1.0.0',
apiVersion: 1,
description: 'own-created table access fixture',
permissions: ['cms.content.read', 'cms.content.write', 'cms.content.tables.manage'],
contentAccess,
})
return {
manifest,
routes: new Map(),
hookListeners: [],
hookFilters: [],
loopSources: [],
mediaAdapters: [],
mediaUrlTransformers: [],
inflightFetches: new Map(),
}
}

const IMPORTER = 'acme.importer'

async function mustGetTable(db: DbClient, slug: string): Promise<DataTable> {
const table = await getDataTableBySlug(db, MAIN_SCOPE, slug)
if (!table) throw new Error(`fixture table "${slug}" missing`)
return table
}

/** Create `imported-products` as the importer plugin via the real handler. */
async function createImportedProductsTable(db: DbClient, entry: HostPluginRecord): Promise<void> {
await handleContentTablesCreate(
{
kind: 'api-call',
correlationId: 'c-create-table',
pluginId: IMPORTER,
target: 'cms.content.tables.create',
args: [{
slug: 'imported-products',
name: 'Imported Products',
singularLabel: 'Imported product',
pluralLabel: 'Imported products',
fields: [
{ id: 'title', label: 'Title', type: 'text', required: true },
{ id: 'slug', label: 'Slug', type: 'text', required: false },
],
}],
},
entry,
db,
)
}

describe('cms.content.tables.create → @own-created access', () => {
let db: DbClient
const importer = pluginRecord(IMPORTER, [
{ table: OWN_CREATED_TABLES_MARKER, modes: ['read', 'write'] },
])

beforeEach(async () => {
db = await freshDb()
await createImportedProductsTable(db, importer)
})

it('records the creating plugin on the table row', async () => {
const created = await mustGetTable(db, 'imported-products')
expect(created.createdByPluginId).toBe(IMPORTER)
expect(created.system).toBe(false)
})

it('lets the creator write and read entries via the marker', async () => {
await handleContentEntriesCreate(
{
kind: 'api-call',
correlationId: 'c-create-entry',
pluginId: IMPORTER,
target: 'cms.content.entries.create',
args: ['imported-products', { slug: 'widget-1', cells: { title: 'Widget', slug: 'widget-1' } }],
},
importer,
db,
)
const table = await mustGetTable(db, 'imported-products')
const row = await getDataRowBySlug(db, MAIN_SCOPE, table.id, 'widget-1')
expect(row?.cells.title).toBe('Widget')

// Reads ride the same marker — the list handler resolves + asserts.
await expect(handleContentEntriesList(
{
kind: 'api-call',
correlationId: 'c-list',
pluginId: IMPORTER,
target: 'cms.content.entries.list',
args: ['imported-products', {}],
},
importer,
db,
)).resolves.toBeUndefined()

await expect(handleContentTablesGet(
{
kind: 'api-call',
correlationId: 'c-get-table',
pluginId: IMPORTER,
target: 'cms.content.tables.get',
args: ['imported-products'],
},
importer,
db,
)).resolves.toBeUndefined()
})

it('denies a different plugin carrying the same marker', async () => {
const other = pluginRecord('rival.importer', [
{ table: OWN_CREATED_TABLES_MARKER, modes: ['read', 'write'] },
])
await expect(handleContentEntriesCreate(
{
kind: 'api-call',
correlationId: 'c-rival',
pluginId: 'rival.importer',
target: 'cms.content.entries.create',
args: ['imported-products', { cells: { title: 'Hijack' } }],
},
other,
db,
)).rejects.toThrow('does not have contentAccess declared for table "imported-products"')
})

it('denies a plugin without the marker even for tables it could name', async () => {
// Declares the slug of ANOTHER plugin's created table statically — the
// slug entry matches, so this is allowed: static declarations are the
// operator-reviewed path for cross-plugin table access.
const staticDeclarer = pluginRecord('acme.reader', [
{ table: 'imported-products', modes: ['read'] },
])
await expect(handleContentEntriesList(
{
kind: 'api-call',
correlationId: 'c-static-read',
pluginId: 'acme.reader',
target: 'cms.content.entries.list',
args: ['imported-products', {}],
},
staticDeclarer,
db,
)).resolves.toBeUndefined()

// But its declared modes still bind: write was not declared.
await expect(handleContentEntriesCreate(
{
kind: 'api-call',
correlationId: 'c-static-write',
pluginId: 'acme.reader',
target: 'cms.content.entries.create',
args: ['imported-products', { cells: { title: 'Nope' } }],
},
staticDeclarer,
db,
)).rejects.toThrow('not for mode "write"')
})

it('still denies the creator a mode its marker entry does not declare', async () => {
// The importer declared read+write only — delete-capable handlers assert
// mode 'delete' and must fail even for own-created tables.
const table = await mustGetTable(db, 'imported-products')
expect(() => assertContentTableAccess(importer, table, 'delete'))
.toThrow('not for mode "delete"')
})
})
Loading
Loading