Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/features/content-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,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 @@ -682,14 +682,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 @@ -744,7 +747,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 @@ -148,7 +148,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
11 changes: 11 additions & 0 deletions server/db/migrations-pg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1156,4 +1156,15 @@ export const pgMigrations: Migration[] = [
where trim(lower(display_name)) = trim(lower(email));
`,
},
{
// 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: '025_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 @@ -1224,4 +1224,15 @@ export const sqliteMigrations: Migration[] = [
where trim(lower(display_name)) = trim(lower(email));
`,
},
{
// 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: '025_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 @@ -216,7 +216,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, {
id: table.id,
name: table.name,
Expand All @@ -227,6 +230,7 @@ export async function handleImportRoute(
pluralLabel: table.pluralLabel,
primaryFieldId: table.primaryFieldId,
fields: table.fields,
createdByPluginId: table.createdByPluginId ?? null,
})
tablesAffected++
}
Expand Down Expand Up @@ -293,6 +297,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 @@ -343,6 +348,7 @@ export async function handleImportRoute(
pluralLabel: table.pluralLabel,
primaryFieldId: table.primaryFieldId,
fields: table.fields,
createdByPluginId: table.createdByPluginId ?? null,
})
if (!inserted) {
await updateDataTable(tx, table.id, {
Expand Down
207 changes: 207 additions & 0 deletions server/plugins/host/handlers/content.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/**
* 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 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, 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, 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