diff --git a/docs/features/content-storage.md b/docs/features/content-storage.md index 283f5ec16..afcac24eb 100644 --- a/docs/features/content-storage.md +++ b/docs/features/content-storage.md @@ -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` diff --git a/docs/features/plugin-system.md b/docs/features/plugin-system.md index a4e4f94a7..b943be650 100644 --- a/docs/features/plugin-system.md +++ b/docs/features/plugin-system.md @@ -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"] } ] } ``` @@ -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. diff --git a/docs/reference/architecture-tests.md b/docs/reference/architecture-tests.md index 05738be2f..049835b63 100644 --- a/docs/reference/architecture-tests.md +++ b/docs/reference/architecture-tests.md @@ -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. | diff --git a/server/db/migrations-pg.ts b/server/db/migrations-pg.ts index d129ffaa2..abf4f61b9 100644 --- a/server/db/migrations-pg.ts +++ b/server/db/migrations-pg.ts @@ -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; + `, + }, ] diff --git a/server/db/migrations-sqlite.ts b/server/db/migrations-sqlite.ts index a4c5cdd74..d2d8ee1d7 100644 --- a/server/db/migrations-sqlite.ts +++ b/server/db/migrations-sqlite.ts @@ -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; + `, + }, ] diff --git a/server/handlers/cms/import.ts b/server/handlers/cms/import.ts index 0b068b622..c528d7a51 100644 --- a/server/handlers/cms/import.ts +++ b/server/handlers/cms/import.ts @@ -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, @@ -227,6 +230,7 @@ export async function handleImportRoute( pluralLabel: table.pluralLabel, primaryFieldId: table.primaryFieldId, fields: table.fields, + createdByPluginId: table.createdByPluginId ?? null, }) tablesAffected++ } @@ -293,6 +297,7 @@ export async function handleImportRoute( pluralLabel: table.pluralLabel, primaryFieldId: table.primaryFieldId, fields: table.fields, + createdByPluginId: table.createdByPluginId ?? null, }) if (inserted) tablesAffected++ } @@ -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, { diff --git a/server/plugins/host/handlers/content.test.ts b/server/plugins/host/handlers/content.test.ts new file mode 100644 index 000000000..810b19cc2 --- /dev/null +++ b/server/plugins/host/handlers/content.test.ts @@ -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 { + 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 { + 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 { + 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"') + }) +}) diff --git a/server/plugins/host/handlers/content.ts b/server/plugins/host/handlers/content.ts index f0909c354..2bb4bebf0 100644 --- a/server/plugins/host/handlers/content.ts +++ b/server/plugins/host/handlers/content.ts @@ -6,9 +6,11 @@ * `cms.content.*` permission family is enforced CENTRALLY in `apiDispatch.ts` * (driven by `TARGET_PERMISSIONS`) before any handler runs, so each handler: * - * 1. Calls `assertContentTableAccess` — enforces the manifest's - * `contentAccess[]` allowlist for the targeted table + mode (this is the - * per-table check the central permission gate cannot express). + * 1. Resolves the targeted table, then calls `assertContentTableAccess` — + * enforces the manifest's `contentAccess[]` allowlist for that table + + * mode (this is the per-table check the central permission gate cannot + * express). An entry matches by exact slug, or via the `@own-created` + * marker when the table's `createdByPluginId` is this plugin. * 2. Delegates to a repository function in `server/repositories/data/`. * 3. Emits the matching `content.entry.*` hook event so plugins can react. * 4. Replies via `replyApiOk` / lets the dispatcher's try/catch reply @@ -25,6 +27,7 @@ import { parsePageNodeTree } from '@core/page-tree' import { readPageTree, mutatePageTree } from '../../../ai/content/treeService' import { hookBus } from '@core/plugins/hookBus' import { + listDataTables, listDataTablesWithCounts, getDataTable, createDataTable, @@ -48,7 +51,7 @@ import { republishAllPages } from '../../../publish/republish' import { bumpPublishVersionSerialized } from '../../../publish/publishState' import { applyContentEntryCellsFilter } from '../../../publish/contentEvents' import type { DbClient } from '../../../db/client' -import { assertContentTableAccess } from '../registry' +import { assertContentTableAccess, hasContentTableAccess } from '../registry' import { buildContentTableIdLookup, pluginContentFieldsToDataFields } from '../contentFieldMapping' import { buildTableSlugLookup, @@ -122,10 +125,9 @@ export async function handleContentTablesList( entry: HostPluginRecord, db: DbClient, ): Promise { - const allowedSlugs = new Set((entry.manifest.contentAccess ?? []).map((e) => e.table)) const tables = await listDataTablesWithCounts(db) const summaries: ContentTableSummary[] = tables - .filter((t) => allowedSlugs.has(t.slug)) + .filter((t) => hasContentTableAccess(entry.manifest, t)) .map((t) => tableSummary(t, t.rowCount)) replyApiOk(msg.pluginId, msg.correlationId, summaries) } @@ -136,12 +138,12 @@ export async function handleContentTablesGet( db: DbClient, ): Promise { const [slug] = msg.args - assertContentTableAccess(entry, slug, 'read') const table = await resolveTableBySlug(db, slug).catch(() => null) if (!table) { replyApiOk(msg.pluginId, msg.correlationId, null) return } + assertContentTableAccess(entry, table, 'read') // One COUNT for this table + the id→slug lookup the relation-field // projection needs — no per-table COUNT subselects for tables we don't // return. @@ -171,6 +173,9 @@ export async function handleContentTablesCreate( ? await buildContentTableIdLookup(db) : new Map() const fields = pluginContentFieldsToDataFields(input.fields ?? [], tableIdBySlug) + // Record the creator so the manifest's `@own-created` contentAccess marker + // can grant this plugin entry access to the table afterwards. The id comes + // from the host-authenticated worker identity, never from plugin input. const created = await createDataTable(db, { name: input.name, slug: input.slug, @@ -180,6 +185,7 @@ export async function handleContentTablesCreate( pluralLabel: input.pluralLabel, primaryFieldId: input.primaryFieldId ?? 'title', fields, + createdByPluginId: msg.pluginId, }) const slugLookup = await buildTableSlugLookup(db) replyApiOk(msg.pluginId, msg.correlationId, tableSchema(created, 0, slugLookup)) @@ -195,8 +201,8 @@ export async function handleContentEntriesList( db: DbClient, ): Promise { const [tableSlug, options] = msg.args - assertContentTableAccess(entry, tableSlug, 'read') const table = await resolveTableBySlug(db, tableSlug) + assertContentTableAccess(entry, table, 'read') const result = await listDataRowsWithFilter(db, table.id, options) replyApiOk(msg.pluginId, msg.correlationId, { entries: result.rows.map((r) => rowToEntry(r, tableSlug)), @@ -210,8 +216,8 @@ export async function handleContentEntriesGet( db: DbClient, ): Promise { const [tableSlug, entryId] = msg.args - assertContentTableAccess(entry, tableSlug, 'read') const table = await resolveTableBySlug(db, tableSlug) + assertContentTableAccess(entry, table, 'read') const row = await getDataRow(db, entryId) if (!row || row.tableId !== table.id) { replyApiOk(msg.pluginId, msg.correlationId, null) @@ -226,8 +232,8 @@ export async function handleContentEntriesGetBySlug( db: DbClient, ): Promise { const [tableSlug, slug] = msg.args - assertContentTableAccess(entry, tableSlug, 'read') const table = await resolveTableBySlug(db, tableSlug) + assertContentTableAccess(entry, table, 'read') const row = await getDataRowBySlug(db, table.id, slug) replyApiOk(msg.pluginId, msg.correlationId, row ? rowToEntry(row, tableSlug) : null) } @@ -238,8 +244,8 @@ export async function handleContentEntriesCreate( db: DbClient, ): Promise { const [tableSlug, input] = msg.args - assertContentTableAccess(entry, tableSlug, 'write') const table = await resolveTableBySlug(db, tableSlug) + assertContentTableAccess(entry, table, 'write') const actor: PluginActor = { kind: 'plugin', pluginId: msg.pluginId } const cells = await applyContentEntryCellsFilter(input.cells, { tableSlug, @@ -263,8 +269,8 @@ export async function handleContentEntriesUpdate( db: DbClient, ): Promise { const [tableSlug, entryId, patch] = msg.args - assertContentTableAccess(entry, tableSlug, 'write') const table = await resolveTableBySlug(db, tableSlug) + assertContentTableAccess(entry, table, 'write') const existing = await getDataRow(db, entryId) if (!existing || existing.tableId !== table.id) { throw new Error(`Entry "${entryId}" not found in table "${tableSlug}"`) @@ -301,8 +307,8 @@ export async function handleContentEntriesDelete( db: DbClient, ): Promise { const [tableSlug, entryId] = msg.args - assertContentTableAccess(entry, tableSlug, 'delete') const table = await resolveTableBySlug(db, tableSlug) + assertContentTableAccess(entry, table, 'delete') const existing = await getDataRow(db, entryId) if (!existing || existing.tableId !== table.id) { throw new Error(`Entry "${entryId}" not found in table "${tableSlug}"`) @@ -322,8 +328,8 @@ export async function handleContentEntriesPublish( db: DbClient, ): Promise { const [tableSlug, entryId, options] = msg.args - assertContentTableAccess(entry, tableSlug, 'publish') const table = await resolveTableBySlug(db, tableSlug) + assertContentTableAccess(entry, table, 'publish') const existing = await getDataRow(db, entryId) if (!existing || existing.tableId !== table.id) { throw new Error(`Entry "${entryId}" not found in table "${tableSlug}"`) @@ -349,10 +355,10 @@ export async function handleContentEntriesMoveTable( db: DbClient, ): Promise { const [tableSlug, entryId, targetSlug] = msg.args - assertContentTableAccess(entry, tableSlug, 'write') - assertContentTableAccess(entry, targetSlug, 'write') const source = await resolveTableBySlug(db, tableSlug) const target = await resolveTableBySlug(db, targetSlug) + assertContentTableAccess(entry, source, 'write') + assertContentTableAccess(entry, target, 'write') const existing = await getDataRow(db, entryId) if (!existing || existing.tableId !== source.id) { throw new Error(`Entry "${entryId}" not found in table "${tableSlug}"`) @@ -372,8 +378,8 @@ export async function handleContentEntriesCreateMany( db: DbClient, ): Promise { const [tableSlug, inputs] = msg.args - assertContentTableAccess(entry, tableSlug, 'write') const table = await resolveTableBySlug(db, tableSlug) + assertContentTableAccess(entry, table, 'write') const actor: PluginActor = { kind: 'plugin', pluginId: msg.pluginId } // Apply the cells filter per-input before the transaction. The filter // runs INSIDE the same plugin's worker; running it inside the per-row @@ -396,8 +402,8 @@ export async function handleContentEntriesUpdateMany( db: DbClient, ): Promise { const [tableSlug, updates] = msg.args - assertContentTableAccess(entry, tableSlug, 'write') const table = await resolveTableBySlug(db, tableSlug) + assertContentTableAccess(entry, table, 'write') const actor: PluginActor = { kind: 'plugin', pluginId: msg.pluginId } // Read every targeted row in ONE IN-list query, then apply filter + diff @@ -445,8 +451,8 @@ export async function handleContentEntriesDeleteMany( db: DbClient, ): Promise { const [tableSlug, ids] = msg.args - assertContentTableAccess(entry, tableSlug, 'delete') const table = await resolveTableBySlug(db, tableSlug) + assertContentTableAccess(entry, table, 'delete') // Validate every id belongs to this table BEFORE the transaction so a // bad id aborts cleanly without partially-applied deletes. One IN-list // read for the whole batch; input order preserves first-bad-id errors. @@ -502,7 +508,7 @@ export async function handleContentTreeRead( ): Promise { const [entryId, fieldId] = msg.args const tree = await readPageTree(db, entryId, fieldId, { - assertAccess: (table) => assertContentTableAccess(entry, table.slug, 'read'), + assertAccess: (table) => assertContentTableAccess(entry, table, 'read'), }) replyApiOk(msg.pluginId, msg.correlationId, tree) } @@ -522,7 +528,7 @@ export async function handleContentTreeMutate( fieldId, operations, { kind: 'plugin', pluginId: msg.pluginId }, - { assertAccess: (table) => assertContentTableAccess(entry, table.slug, 'write') }, + { assertAccess: (table) => assertContentTableAccess(entry, table, 'write') }, ) replyApiOk(msg.pluginId, msg.correlationId, { tree, affectedNodeIds }) } @@ -534,7 +540,7 @@ export async function handleContentTreeReplace( ): Promise { const [entryId, fieldId, replacement] = msg.args const { row, table } = await resolvePageTreeField(db, entryId, fieldId) - assertContentTableAccess(entry, table.slug, 'write') + assertContentTableAccess(entry, table, 'write') const replacementTree = parsePageNodeTree( replacement, @@ -568,10 +574,17 @@ export async function handleContentSearch( db: DbClient, ): Promise { const [query, limit] = msg.args - const allowedSlugs = new Set((entry.manifest.contentAccess ?? []).map((e) => e.table)) + // Search results carry only `tableSlug`, so resolve the accessible set + // (declared slugs + own-created tables) against the table list — tiny, + // and the only way to honor the `@own-created` marker here. + const accessibleSlugs = new Set( + (await listDataTables(db)) + .filter((t) => hasContentTableAccess(entry.manifest, t)) + .map((t) => t.slug), + ) const all = await searchDataRows(db, query, limit) const filtered = all - .filter((r) => allowedSlugs.has(r.tableSlug)) + .filter((r) => accessibleSlugs.has(r.tableSlug)) .map((r) => ({ id: r.id, tableSlug: r.tableSlug, @@ -599,7 +612,7 @@ export async function handleContentSnapshot( replyApiOk(msg.pluginId, msg.correlationId, null) return } - assertContentTableAccess(entry, table.slug, 'read') + assertContentTableAccess(entry, table, 'read') const { rows } = await db<{ version_number: number diff --git a/server/plugins/host/registry.test.ts b/server/plugins/host/registry.test.ts new file mode 100644 index 000000000..5e1ba8777 --- /dev/null +++ b/server/plugins/host/registry.test.ts @@ -0,0 +1,141 @@ +/** + * `assertContentTableAccess` / `hasContentTableAccess` — the per-table + * authorization matrix for the `api.cms.content.*` surface. + * + * Locked here: + * - slug entries match by exact slug and enforce per-mode narrowing; + * - the `@own-created` marker matches ONLY tables whose `createdByPluginId` + * is the calling plugin (never by slug, never another plugin's tables, + * never user-created tables); + * - entries combine as a union — any matching entry declaring the mode + * allows the operation. + * + * Manifests are built through `parsePluginManifest` so the tests also cover + * the parser accepting the marker as a `contentAccess[].table` value. + */ +import { describe, expect, it } from 'bun:test' +import { parsePluginManifest } from '@core/plugins/manifest' +import { OWN_CREATED_TABLES_MARKER } from '@core/plugin-sdk' +import type { ContentAccessEntry } from '@core/plugin-sdk' +import type { DataTable } from '@core/data/schemas' +import { assertContentTableAccess, hasContentTableAccess } from './registry' +import type { HostPluginRecord } from './types' + +function pluginRecord(id: string, contentAccess: ContentAccessEntry[]): HostPluginRecord { + const manifest = parsePluginManifest({ + id, + name: 'Test plugin', + version: '1.0.0', + apiVersion: 1, + description: 'contentAccess matrix fixture', + permissions: ['cms.content.read', 'cms.content.write', 'cms.content.publish', 'cms.content.delete'], + contentAccess, + }) + return { + manifest, + routes: new Map(), + hookListeners: [], + hookFilters: [], + loopSources: [], + mediaAdapters: [], + mediaUrlTransformers: [], + inflightFetches: new Map(), + } +} + +function table(slug: string, createdByPluginId: string | null): DataTable { + return { + id: `tbl_${slug}`, + name: slug, + slug, + kind: 'data', + singularLabel: slug, + pluralLabel: slug, + routeBase: `/${slug}`, + primaryFieldId: 'title', + fields: [], + system: false, + createdByUserId: null, + createdByPluginId, + updatedByUserId: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + } +} + +describe('assertContentTableAccess — slug entries', () => { + const entry = pluginRecord('acme.seo', [{ table: 'posts', modes: ['read', 'write'] }]) + + it('allows a declared table + declared mode', () => { + expect(() => assertContentTableAccess(entry, table('posts', null), 'read')).not.toThrow() + expect(() => assertContentTableAccess(entry, table('posts', null), 'write')).not.toThrow() + }) + + it('rejects a declared table with an undeclared mode', () => { + expect(() => assertContentTableAccess(entry, table('posts', null), 'delete')) + .toThrow('not for mode "delete"') + }) + + it('rejects an undeclared table', () => { + expect(() => assertContentTableAccess(entry, table('customers', null), 'read')) + .toThrow('does not have contentAccess declared for table "customers"') + }) +}) + +describe('assertContentTableAccess — @own-created marker', () => { + const importer = pluginRecord('acme.importer', [ + { table: OWN_CREATED_TABLES_MARKER, modes: ['read', 'write'] }, + ]) + + it('allows tables created by this plugin, honoring the declared modes', () => { + const own = table('runtime-products', 'acme.importer') + expect(() => assertContentTableAccess(importer, own, 'read')).not.toThrow() + expect(() => assertContentTableAccess(importer, own, 'write')).not.toThrow() + expect(() => assertContentTableAccess(importer, own, 'delete')) + .toThrow('not for mode "delete"') + }) + + it('rejects tables created by a different plugin', () => { + expect(() => assertContentTableAccess(importer, table('runtime-products', 'other.importer'), 'read')) + .toThrow('does not have contentAccess declared') + }) + + it('rejects user-created tables (null creator)', () => { + expect(() => assertContentTableAccess(importer, table('customers', null), 'read')) + .toThrow('does not have contentAccess declared') + }) + + it('never matches by slug — a table literally slugged like the marker stays inaccessible', () => { + expect(() => assertContentTableAccess(importer, table(OWN_CREATED_TABLES_MARKER, null), 'read')) + .toThrow('does not have contentAccess declared') + }) +}) + +describe('assertContentTableAccess — union of matching entries', () => { + it('allows a mode declared by ANY matching entry', () => { + // The slug entry narrows `forms` to read; the marker adds write for the + // same (own-created) table. Union semantics: both modes are allowed. + const entry = pluginRecord('acme.forms', [ + { table: 'forms', modes: ['read'] }, + { table: OWN_CREATED_TABLES_MARKER, modes: ['write'] }, + ]) + const ownForms = table('forms', 'acme.forms') + expect(() => assertContentTableAccess(entry, ownForms, 'read')).not.toThrow() + expect(() => assertContentTableAccess(entry, ownForms, 'write')).not.toThrow() + expect(() => assertContentTableAccess(entry, ownForms, 'publish')) + .toThrow('not for mode "publish"') + }) +}) + +describe('hasContentTableAccess — list/search membership', () => { + it('covers declared slugs and own-created tables, nothing else', () => { + const entry = pluginRecord('acme.importer', [ + { table: 'posts', modes: ['read'] }, + { table: OWN_CREATED_TABLES_MARKER, modes: ['read', 'write'] }, + ]) + expect(hasContentTableAccess(entry.manifest, table('posts', null))).toBe(true) + expect(hasContentTableAccess(entry.manifest, table('runtime-products', 'acme.importer'))).toBe(true) + expect(hasContentTableAccess(entry.manifest, table('runtime-products', 'other.plugin'))).toBe(false) + expect(hasContentTableAccess(entry.manifest, table('customers', null))).toBe(false) + }) +}) diff --git a/server/plugins/host/registry.ts b/server/plugins/host/registry.ts index 0bb986061..e0729dc59 100644 --- a/server/plugins/host/registry.ts +++ b/server/plugins/host/registry.ts @@ -11,8 +11,9 @@ */ import type { DbClient } from '../../db/client' +import type { DataTable } from '@core/data/schemas' import type { PluginManifest, PluginPermission } from '@core/plugin-sdk' -import type { ContentAccessMode } from '@core/plugin-sdk/contentSchemas' +import { OWN_CREATED_TABLES_MARKER, type ContentAccessEntry, type ContentAccessMode } from '@core/plugin-sdk/contentSchemas' import type { HostPluginRecord } from './types' export const hostPlugins = new Map() @@ -33,27 +34,65 @@ export function assertHostPluginPermission( } } +/** + * Every manifest `contentAccess[]` entry that covers `table`. Two match forms: + * + * - a slug entry (`{ table: 'posts' }`) matches by exact slug; + * - the `@own-created` marker matches any table whose `createdByPluginId` + * is this plugin — the durable creator record written by + * `cms.content.tables.create` — so importer/migration plugins can reach + * tables whose names only exist at runtime. + * + * Marker entries never match by slug, so even a table literally slugged + * `@own-created` is only ever reachable by its creator (and no static entry + * can name it — the manifest slug pattern requires a leading letter). + */ +function matchingContentAccessEntries( + manifest: PluginManifest, + table: DataTable, +): ContentAccessEntry[] { + return (manifest.contentAccess ?? []).filter((row) => + row.table === OWN_CREATED_TABLES_MARKER + ? table.createdByPluginId === manifest.id + : row.table === table.slug, + ) +} + +/** + * Whether `table` is covered by any `contentAccess[]` entry, regardless of + * mode. Drives result filtering in `tables.list` / `search`, which show + * every declared table (same as always — mode narrowing applies to the + * per-table operations, not to list membership). + */ +export function hasContentTableAccess( + manifest: PluginManifest, + table: DataTable, +): boolean { + return matchingContentAccessEntries(manifest, table).length > 0 +} + /** * Authoritative check for `api.cms.content.*` table access. Each handler - * runs this BEFORE any repository call so a plugin that holds the - * permission but didn't list the table (or list the right mode) in its - * manifest's `contentAccess[]` fails closed. + * runs this on the RESOLVED table before any repository read/write, so a + * plugin that holds the permission but didn't cover the table (or the + * mode) in its manifest's `contentAccess[]` fails closed. Entries combine + * as a union: the operation is allowed when ANY matching entry declares + * the mode. */ export function assertContentTableAccess( entry: HostPluginRecord, - tableSlug: string, + table: DataTable, mode: ContentAccessMode, ): void { - const access = entry.manifest.contentAccess ?? [] - const found = access.find((row) => row.table === tableSlug) - if (!found) { + const matches = matchingContentAccessEntries(entry.manifest, table) + if (matches.length === 0) { throw new Error( - `Plugin "${entry.manifest.id}" does not have contentAccess declared for table "${tableSlug}"`, + `Plugin "${entry.manifest.id}" does not have contentAccess declared for table "${table.slug}"`, ) } - if (!found.modes.includes(mode)) { + if (!matches.some((row) => row.modes.includes(mode))) { throw new Error( - `Plugin "${entry.manifest.id}" has contentAccess for table "${tableSlug}" but not for mode "${mode}"`, + `Plugin "${entry.manifest.id}" has contentAccess for table "${table.slug}" but not for mode "${mode}"`, ) } } diff --git a/server/repositories/data/__tests__/tables.test.ts b/server/repositories/data/__tests__/tables.test.ts index 5da27e106..13b2e1a70 100644 --- a/server/repositories/data/__tests__/tables.test.ts +++ b/server/repositories/data/__tests__/tables.test.ts @@ -3,7 +3,7 @@ import { createSqliteClient } from '../../../db/sqlite' import { sqliteMigrations } from '../../../db/migrations-sqlite' import { runMigrations } from '../../../db/runMigrations' import type { DbClient } from '../../../db/client' -import { createDataTable, getDataTable, listDataTables } from '../tables' +import { createDataTable, getDataTable, getDataTableBySlug, listDataTables } from '../tables' async function freshDb(): Promise { const db = createSqliteClient(':memory:') @@ -57,3 +57,48 @@ describe('data_tables.system column', () => { expect(systemSlugs).toEqual(['components', 'layouts', 'pages', 'posts']) }) }) + +describe('data_tables.created_by_plugin_id column', () => { + let db: DbClient + + beforeEach(async () => { + db = await freshDb() + }) + + it('defaults to null for user-created tables (and the seeded system tables)', async () => { + const table = await createDataTable(db, { + name: 'Products', + slug: 'products', + kind: 'data', + singularLabel: 'Product', + pluralLabel: 'Products', + }) + expect(table.createdByPluginId).toBeNull() + + for (const id of ['pages', 'posts', 'components', 'layouts']) { + const system = await getDataTable(db, id) + expect(system?.createdByPluginId).toBeNull() + } + }) + + it('persists the creating plugin id and surfaces it on every read path', async () => { + const created = await createDataTable(db, { + name: 'Imported Products', + slug: 'imported-products', + kind: 'data', + singularLabel: 'Imported product', + pluralLabel: 'Imported products', + createdByPluginId: 'acme.importer', + }) + expect(created.createdByPluginId).toBe('acme.importer') + + const byId = await getDataTable(db, created.id) + expect(byId?.createdByPluginId).toBe('acme.importer') + + const bySlug = await getDataTableBySlug(db, 'imported-products') + expect(bySlug?.createdByPluginId).toBe('acme.importer') + + const listed = await listDataTables(db) + expect(listed.find((t) => t.slug === 'imported-products')?.createdByPluginId).toBe('acme.importer') + }) +}) diff --git a/server/repositories/data/tables.ts b/server/repositories/data/tables.ts index 663a5a31d..92d8dc6be 100644 --- a/server/repositories/data/tables.ts +++ b/server/repositories/data/tables.ts @@ -37,6 +37,8 @@ interface CreateDataTableInput { primaryFieldId?: string fields?: DataField[] createdByUserId?: string | null + /** Plugin id when created through `cms.content.tables.create`; null otherwise. */ + createdByPluginId?: string | null updatedByUserId?: string | null } @@ -68,6 +70,7 @@ interface DataTableRow { */ system: number | boolean created_by_user_id: string | null + created_by_plugin_id: string | null updated_by_user_id: string | null /** * Adapters normalize: PG returns Date, SQLite returns ISO string, test fakes @@ -90,6 +93,7 @@ function mapTable(row: DataTableRow): DataTable { fields: normalizeDataTableFields(row.fields_json), system: Boolean(row.system), createdByUserId: row.created_by_user_id ?? null, + createdByPluginId: row.created_by_plugin_id ?? null, updatedByUserId: row.updated_by_user_id ?? null, createdAt: isoDate(row.created_at), updatedAt: isoDate(row.updated_at), @@ -100,7 +104,7 @@ export async function listDataTables(db: DbClient): Promise { const { rows } = await db` select 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 from data_tables where deleted_at is null order by @@ -128,7 +132,7 @@ export async function listDataTablesWithCounts(db: DbClient): Promise` select t.id, t.name, t.slug, t.kind, t.route_base, t.singular_label, t.plural_label, t.primary_field_id, t.fields_json, t.system, - t.created_by_user_id, t.updated_by_user_id, t.created_at, t.updated_at, + t.created_by_user_id, t.created_by_plugin_id, t.updated_by_user_id, t.created_at, t.updated_at, coalesce( (select count(*) from data_rows r where r.table_id = t.id and r.deleted_at is null), 0 @@ -155,7 +159,7 @@ export async function getDataTable(db: DbClient, tableId: string): Promise` select 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 from data_tables where id = ${tableId} and deleted_at is null @@ -174,7 +178,7 @@ export async function getDataTableBySlug(db: DbClient, slug: string): Promise` select 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 from data_tables where slug = ${slug} and deleted_at is null @@ -264,6 +268,7 @@ export async function createDataTable( primary_field_id, fields_json, created_by_user_id, + created_by_plugin_id, updated_by_user_id ) values ( @@ -277,11 +282,12 @@ export async function createDataTable( ${input.primaryFieldId ?? 'title'}, ${fields}, ${input.createdByUserId ?? null}, + ${input.createdByPluginId ?? null}, ${input.updatedByUserId ?? input.createdByUserId ?? null} ) returning 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 ` // NOTE: table creation is pure data access. Entry templates are ordinary // page rows and are created explicitly through the site editor. @@ -315,7 +321,7 @@ export async function updateDataTable( and deleted_at is null returning 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 ` return rows[0] ? mapTable(rows[0]) : null } @@ -346,6 +352,7 @@ export async function insertDataTableIfAbsent( primary_field_id, fields_json, created_by_user_id, + created_by_plugin_id, updated_by_user_id ) values ( @@ -359,6 +366,7 @@ export async function insertDataTableIfAbsent( ${input.primaryFieldId ?? 'title'}, ${fields}, ${input.createdByUserId ?? null}, + ${input.createdByPluginId ?? null}, ${input.updatedByUserId ?? input.createdByUserId ?? null} ) on conflict (id) do nothing @@ -395,7 +403,7 @@ export async function softDeleteDataTable( and deleted_at is null returning 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 ` return rows[0] ? mapTable(rows[0]) : null } diff --git a/src/__tests__/architecture/plugin-content-access-enforced.test.ts b/src/__tests__/architecture/plugin-content-access-enforced.test.ts index 307f0a3c0..7ac774f13 100644 --- a/src/__tests__/architecture/plugin-content-access-enforced.test.ts +++ b/src/__tests__/architecture/plugin-content-access-enforced.test.ts @@ -7,12 +7,17 @@ * apiDispatch.ts (driven by `TARGET_PERMISSIONS`) before any handler runs, * so every content target carries a `cms.content.*` permission in the map. * - Every per-table handler (anything that takes a `tableSlug` arg) still - * calls `assertContentTableAccess` for the targeted slug + mode — the - * per-table check the central permission gate cannot express. + * calls `assertContentTableAccess` for the resolved table + mode — the + * per-table check the central permission gate cannot express. An entry + * matches by slug or via the `@own-created` marker (resolved against the + * table's `createdByPluginId`). + * - `cms.content.tables.create` records the creating plugin on the table + * row — the durable fact the `@own-created` marker resolves against. * * The matrix of (permission, mode) per handler is documented in the handler * header comment; this test enforces presence of the central pairing + the - * per-table helper — finer-grained mode coverage is in the per-handler tests. + * per-table helper — finer-grained mode coverage is in the per-handler tests + * (`server/plugins/host/registry.test.ts`, `.../handlers/content.test.ts`). */ import { describe, expect, it } from 'bun:test' @@ -89,4 +94,22 @@ describe('plugin content handlers — access enforced', () => { ).toBe(true) } }) + + it('tables.create records the creating plugin; the marker resolves against it', async () => { + // The `@own-created` contentAccess marker is only sound if BOTH halves + // hold: the create handler stamps the host-authenticated plugin id on + // the table row, and the registry matcher resolves the marker against + // that stored creator (never against the slug). + const source = await read('server/plugins/host/handlers/content.ts') + const create = extractContentHandlers(source).find((h) => h.name === 'handleContentTablesCreate') + expect(create, 'handleContentTablesCreate must exist').toBeDefined() + expect( + create?.body.includes('createdByPluginId: msg.pluginId'), + 'handleContentTablesCreate must record the caller as createdByPluginId', + ).toBe(true) + + const registry = await read('server/plugins/host/registry.ts') + expect(registry).toContain('OWN_CREATED_TABLES_MARKER') + expect(registry).toContain('table.createdByPluginId === manifest.id') + }) }) diff --git a/src/__tests__/plugins/pluginManifest.test.ts b/src/__tests__/plugins/pluginManifest.test.ts index 260e02cf2..501807037 100644 --- a/src/__tests__/plugins/pluginManifest.test.ts +++ b/src/__tests__/plugins/pluginManifest.test.ts @@ -520,3 +520,62 @@ describe('plugin manifest validation', () => { .toThrow('Missing required field "Title"') }) }) + +describe('contentAccess coherence', () => { + it('accepts slug entries and the @own-created marker, preserving both', () => { + const manifest = parsePluginManifest({ + id: 'acme.importer', + name: 'Importer', + version: '1.0.0', + apiVersion: 1, + permissions: ['cms.content.read', 'cms.content.write', 'cms.content.tables.manage'], + contentAccess: [ + { table: 'posts', modes: ['read'] }, + { table: '@own-created', modes: ['read', 'write'] }, + ], + }) + expect(manifest.contentAccess).toEqual([ + { table: 'posts', modes: ['read'] }, + { table: '@own-created', modes: ['read', 'write'] }, + ]) + }) + + it('requires contentAccess when a cms.content.* permission is declared — and points at the marker', () => { + expect(() => + parsePluginManifest({ + id: 'acme.importer', + name: 'Importer', + version: '1.0.0', + apiVersion: 1, + permissions: ['cms.content.write'], + }), + ).toThrow(/@own-created/) + }) + + it('rejects marker-like table names other than @own-created', () => { + // The `@` namespace is reserved for markers; only the one marker exists. + expect(() => + parsePluginManifest({ + id: 'acme.importer', + name: 'Importer', + version: '1.0.0', + apiVersion: 1, + permissions: ['cms.content.read'], + contentAccess: [{ table: '@all-tables', modes: ['read'] }], + }), + ).toThrow() + }) + + it('holds marker modes to the same permission coherence as slug entries', () => { + expect(() => + parsePluginManifest({ + id: 'acme.importer', + name: 'Importer', + version: '1.0.0', + apiVersion: 1, + permissions: ['cms.content.read'], + contentAccess: [{ table: '@own-created', modes: ['read', 'write'] }], + }), + ).toThrow('the matching permission "cms.content.write" is not in `permissions`') + }) +}) diff --git a/src/__tests__/server/dataCms.test.ts b/src/__tests__/server/dataCms.test.ts index 830f7f82d..9304b430d 100644 --- a/src/__tests__/server/dataCms.test.ts +++ b/src/__tests__/server/dataCms.test.ts @@ -72,6 +72,7 @@ describe('data CMS repository', () => { primary_field_id: 'title', fields_json: defaultFields, created_by_user_id: null, + created_by_plugin_id: null, updated_by_user_id: null, created_at: rowDate('2026-05-01T10:00:00Z'), updated_at: rowDate('2026-05-01T10:00:00Z'), @@ -93,6 +94,7 @@ describe('data CMS repository', () => { fields: defaultFields, system: false, createdByUserId: null, + createdByPluginId: null, updatedByUserId: null, createdAt: '2026-05-01T10:00:00.000Z', updatedAt: '2026-05-01T10:00:00.000Z', diff --git a/src/core/data/schemas.ts b/src/core/data/schemas.ts index 5752740be..44ff963eb 100644 --- a/src/core/data/schemas.ts +++ b/src/core/data/schemas.ts @@ -334,6 +334,14 @@ export const DataTableSchema = Type.Object({ */ system: Type.Boolean(), createdByUserId: Type.Union([Type.String(), Type.Null()]), + /** + * Plugin id when the table was created through the plugin surface + * (`cms.content.tables.create`); null for user/import-created tables. The + * plugin host's `@own-created` contentAccess marker matches against this. + * OPTIONAL because `DataTableSchema` also validates bundle archives exported + * before the column existed — server reads always populate it. + */ + createdByPluginId: Type.Optional(Type.Union([Type.String(), Type.Null()])), updatedByUserId: Type.Union([Type.String(), Type.Null()]), /** ISO datetime string from DB */ createdAt: Type.String(), diff --git a/src/core/plugin-sdk/capabilities.ts b/src/core/plugin-sdk/capabilities.ts index a0ac615b4..ac19291a4 100644 --- a/src/core/plugin-sdk/capabilities.ts +++ b/src/core/plugin-sdk/capabilities.ts @@ -142,14 +142,14 @@ export const PLUGIN_CAPABILITIES: PluginCapability[] = [ { permission: 'cms.content.read', label: 'Read CMS content', - description: 'Allows the plugin to list / read entries (pages, posts, custom tables) in the tables declared in its manifest\'s `contentAccess[]`. Includes reading tree-shaped fields and published snapshots.', + description: 'Allows the plugin to list / read entries (pages, posts, custom tables) in the tables declared in its manifest\'s `contentAccess[]` — by slug, or via the `@own-created` marker for tables the plugin itself created. Includes reading tree-shaped fields and published snapshots.', risk: 'low', surfaces: ['server', 'cms'], }, { permission: 'cms.content.write', label: 'Write CMS content', - description: 'Allows the plugin to create entries, update entry cells, mutate tree-shaped fields via the canonical mutation engine, and move entries between tables — for the tables declared in its manifest\'s `contentAccess[]`.', + description: 'Allows the plugin to create entries, update entry cells, mutate tree-shaped fields via the canonical mutation engine, and move entries between tables — for the tables declared in its manifest\'s `contentAccess[]` (by slug, or via the `@own-created` marker for tables the plugin itself created).', risk: 'high', surfaces: ['server', 'cms'], }, @@ -163,14 +163,14 @@ export const PLUGIN_CAPABILITIES: PluginCapability[] = [ { permission: 'cms.content.delete', label: 'Delete CMS content', - description: 'Allows the plugin to soft-delete entries in the tables declared in its manifest\'s `contentAccess[]`. Split out as its own permission so the common SEO / translator / AI cases don\'t carry delete capability.', + description: 'Allows the plugin to soft-delete entries in the tables declared in its manifest\'s `contentAccess[]` (by slug or `@own-created`). Split out as its own permission so the common SEO / translator / AI cases don\'t carry delete capability.', risk: 'high', surfaces: ['server', 'cms'], }, { permission: 'cms.content.tables.manage', label: 'Create CMS tables', - description: 'Allows the plugin to create user-managed content tables (never system tables — `pages`, `posts`, `components` are protected at the repository layer). Tables created by a plugin survive uninstall — listed as `dangerous` so the operator sees the warning.', + description: 'Allows the plugin to create user-managed content tables (never system tables — `pages`, `posts`, `components` are protected at the repository layer). The creating plugin is recorded on each table, and a `contentAccess[]` entry of `@own-created` grants it entry access to those tables. Tables created by a plugin survive uninstall — listed as `dangerous` so the operator sees the warning.', risk: 'dangerous', surfaces: ['server', 'cms'], }, diff --git a/src/core/plugin-sdk/cli/init.ts b/src/core/plugin-sdk/cli/init.ts index 78f4056f8..173a29e64 100644 --- a/src/core/plugin-sdk/cli/init.ts +++ b/src/core/plugin-sdk/cli/init.ts @@ -128,9 +128,10 @@ export default definePlugin({ permissions.cmsContentWrite, ], - // Per-table allowlist for the cms.content.* surface. The install - // consent dialog renders this verbatim so the operator sees the exact - // set of tables your plugin can touch before approving the install. + // Per-table allowlist for the cms.content.* surface — the operator can + // review exactly which tables your plugin touches before installing. + // Declare concrete slugs, or '@own-created' to cover tables your plugin + // creates at runtime via api.cms.content.tables.create(...). contentAccess: [ { table: 'pages', modes: ['read', 'write'] }, ], diff --git a/src/core/plugin-sdk/contentSchemas.ts b/src/core/plugin-sdk/contentSchemas.ts index 5dd9b9709..a17720394 100644 --- a/src/core/plugin-sdk/contentSchemas.ts +++ b/src/core/plugin-sdk/contentSchemas.ts @@ -63,7 +63,10 @@ export const ContentTableSchemaSchema = Type.Composite([ export type ContentTableSchema = Static export const CreateContentTableInputSchema = Type.Object({ - slug: Type.String(), + // Kebab-case only, matching the manifest slug rules. The leading-letter + // requirement also reserves the `@`-prefixed namespace for contentAccess + // markers (`@own-created`), so no real table can ever collide with one. + slug: Type.String({ pattern: '^[a-z][a-z0-9-]*$', maxLength: 80 }), name: Type.String(), kind: Type.Optional(DataTableKindSchema), routeBase: Type.Optional(Type.String()), @@ -178,7 +181,17 @@ export const ContentAccessModeSchema = Type.Union([ ]) export type ContentAccessMode = Static +/** + * `contentAccess[].table` marker granting access to every table THIS plugin + * created at runtime via `cms.content.tables.create` (matched against the + * table's `createdByPluginId`, never by slug). Importer/migration plugins + * whose table names are chosen by the operator at runtime declare this + * instead of a slug they cannot know at packaging time. + */ +export const OWN_CREATED_TABLES_MARKER = '@own-created' + export const ContentAccessEntrySchema = Type.Object({ + /** A table slug, or `OWN_CREATED_TABLES_MARKER` for self-created tables. */ table: Type.String(), modes: Type.Array(ContentAccessModeSchema, { minItems: 1 }), }, { additionalProperties: false }) diff --git a/src/core/plugin-sdk/types/manifest.ts b/src/core/plugin-sdk/types/manifest.ts index a20e1a660..ec5b9f0c9 100644 --- a/src/core/plugin-sdk/types/manifest.ts +++ b/src/core/plugin-sdk/types/manifest.ts @@ -88,9 +88,14 @@ export interface PluginManifest { networkAllowedHosts?: string[] /** * Per-table allowlist for the `api.cms.content.*` surface. Required when - * ANY of the `cms.content.*` permissions are granted. The install consent - * screen renders this verbatim so the operator approves the exact set of - * tables the plugin can touch before granting the permission. + * ANY of the `cms.content.*` permissions are granted. Ships in the + * manifest so the operator can review the exact set of tables the plugin + * can touch before installing. + * + * `table` is a concrete slug, or the `@own-created` marker + * (`OWN_CREATED_TABLES_MARKER`) covering every table the plugin itself + * creates at runtime via `cms.content.tables.create` — the shape + * importer/migration plugins need when the operator picks table names. * * Modes are checked AGAINST the granted permissions at install time: a * `mode: "publish"` entry requires `cms.content.publish`, etc. — and the diff --git a/src/core/plugin-sdk/types/permissions.ts b/src/core/plugin-sdk/types/permissions.ts index f9df72548..b84e529f7 100644 --- a/src/core/plugin-sdk/types/permissions.ts +++ b/src/core/plugin-sdk/types/permissions.ts @@ -19,10 +19,11 @@ export const PLUGIN_PERMISSION_VALUES = [ // CMS content — read/write/publish/delete the host's content tables // (`data_tables` + `data_rows`) through the `api.cms.content.*` surface. // Each mode is split so a typical plugin (SEO assistant, translator) - // doesn't carry the dangerous bits. The manifest must additionally list - // the targeted tables in `contentAccess[]`; the host fails closed without - // both the permission and the allowlist entry. See - // docs/features/plugin-system.md → "Content access". + // doesn't carry the dangerous bits. The manifest must additionally cover + // the targeted tables in `contentAccess[]` — by slug, or via the + // `@own-created` marker for tables the plugin created at runtime; the + // host fails closed without both the permission and a covering entry. + // See docs/features/plugin-system.md → "Content access". 'cms.content.read', 'cms.content.write', 'cms.content.publish', diff --git a/src/core/plugin-sdk/types/serverApi.ts b/src/core/plugin-sdk/types/serverApi.ts index a7a8b9979..cd7bec218 100644 --- a/src/core/plugin-sdk/types/serverApi.ts +++ b/src/core/plugin-sdk/types/serverApi.ts @@ -142,8 +142,9 @@ export interface ServerPluginApi { * out the surface. * * Each method asserts both the granted permission (`cms.content.*`) - * AND the table allowlist entry in the manifest's `contentAccess[]`. - * Plugins fail closed on either gap. + * AND the table allowlist entry in the manifest's `contentAccess[]` — + * a slug entry, or the `@own-created` marker for tables this plugin + * created via `tables.create`. Plugins fail closed on either gap. */ content: { tables: { diff --git a/src/core/plugins/manifest.ts b/src/core/plugins/manifest.ts index 4cd02b548..8e4902e3c 100644 --- a/src/core/plugins/manifest.ts +++ b/src/core/plugins/manifest.ts @@ -10,6 +10,7 @@ import type { import { isCompatiblePluginApiVersion, MIN_SUPPORTED_PLUGIN_API_VERSION, + OWN_CREATED_TABLES_MARKER, PLUGIN_API_VERSION, PLUGIN_PERMISSION_VALUES, permissionLabel as sdkPermissionLabel, @@ -272,12 +273,16 @@ const manifestSchema = Type.Object({ Type.String({ pattern: NETWORK_HOST_PATTERN.source, maxLength: 253 }), { maxItems: 50 }, )), - // Per-table allowlist for the `api.cms.content.*` surface. The host - // additionally enforces that each `mode` matches a granted permission - // at install time (`assertContentAccessCoherent` below). + // Per-table allowlist for the `api.cms.content.*` surface; each mode must + // match a granted permission (`assertContentAccessCoherent` below). `table` + // is a slug or the `@own-created` marker (see OWN_CREATED_TABLES_MARKER) — + // the slug pattern's leading-letter rule reserves the `@` namespace. contentAccess: Type.Optional(Type.Array( Type.Object({ - table: Type.String({ pattern: MANIFEST_SLUG_PATTERN.source, maxLength: 80 }), + table: Type.Union([ + Type.Literal(OWN_CREATED_TABLES_MARKER), + Type.String({ pattern: MANIFEST_SLUG_PATTERN.source, maxLength: 80 }), + ]), modes: Type.Array( Type.Union([ Type.Literal('read'), @@ -567,7 +572,8 @@ export function parsePluginManifest(input: unknown): PluginManifest { if (contentPerms.length > 0 && contentAccess.length === 0) { throw new Error( `Invalid plugin manifest: \`contentAccess\` is required when any \`cms.content.*\` ` + - `permission is granted. List the tables the plugin can touch.`, + `permission is granted. List the tables the plugin can touch, or declare ` + + `\`{ "table": "${OWN_CREATED_TABLES_MARKER}" }\` for tables the plugin creates at runtime.`, ) } if (contentAccess.length > 0) {