From 6729af202ba9e8d93756edea22987c8f29f59209 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sun, 12 Jul 2026 13:50:23 +0200 Subject: [PATCH 01/36] =?UTF-8?q?feat(plugins):=20site=20plugins=20foundat?= =?UTF-8?q?ions=20=E2=80=94=20reserved=20site.*=20namespace,=20source=20co?= =?UTF-8?q?lumn,=20plugin=20file=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reserve the site.* plugin-id namespace on the zip-install boundary (readPluginPackage) so uploaded packages can't hijack a site plugin's identity, grants, settings, or secrets. The manifest parser keeps accepting site.* ids — generated site-plugin packages parse through it. - Migration 022 (both dialects): installed_plugins.source text not null default 'installed' — provenance for display + lifecycle routing only. - Plumb source through the plugins repository and InstalledPlugin type. - Add SiteFileType 'plugin' with an isolation gate pinning that plugin source never enters runtime scripts, user stylesheets, explorer sections, or any published-output pipeline. Co-Authored-By: Claude Fable 5 --- server/db/migrations-pg.ts | 11 +++ server/db/migrations-sqlite.ts | 11 +++ server/handlers/cms/plugins/shared.ts | 1 + server/plugins/package.ts | 12 +++ server/repositories/plugins.ts | 22 +++-- .../site-plugin-file-isolation.test.ts | 94 +++++++++++++++++++ .../server/pluginPackageNamespace.test.ts | 56 +++++++++++ .../server/pluginSourceColumn.test.ts | 59 ++++++++++++ src/core/files/schemas.ts | 4 + src/core/plugin-sdk/types/installedPlugin.ts | 10 ++ src/core/plugins/manifest.ts | 13 +++ 11 files changed, 285 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/architecture/site-plugin-file-isolation.test.ts create mode 100644 src/__tests__/server/pluginPackageNamespace.test.ts create mode 100644 src/__tests__/server/pluginSourceColumn.test.ts diff --git a/server/db/migrations-pg.ts b/server/db/migrations-pg.ts index 3b9799ab5..d770257cd 100644 --- a/server/db/migrations-pg.ts +++ b/server/db/migrations-pg.ts @@ -1359,4 +1359,15 @@ export const pgMigrations: Migration[] = [ id: '030_iso_timestamps', sql: 'select 1', }, + { + // Site plugins: provenance of an installed_plugins row. 'installed' = + // uploaded zip / JSON manifest; 'site-local' = generated from the site + // draft's plugins// source (docs/features/site-plugins.md). Display + + // lifecycle routing only — the runtime never branches on it. + id: '031_installed_plugins_source', + sql: ` + alter table installed_plugins + add column if not exists source text not null default 'installed'; + `, + }, ] diff --git a/server/db/migrations-sqlite.ts b/server/db/migrations-sqlite.ts index c88159de1..5a159a3f1 100644 --- a/server/db/migrations-sqlite.ts +++ b/server/db/migrations-sqlite.ts @@ -1515,4 +1515,15 @@ export const sqliteMigrations: Migration[] = [ id: '030_iso_timestamps', sql: isoTimestampRewrite030(), }, + { + // Site plugins: provenance of an installed_plugins row. 'installed' = + // uploaded zip / JSON manifest; 'site-local' = generated from the site + // draft's plugins// source (docs/features/site-plugins.md). Display + + // lifecycle routing only — the runtime never branches on it. + id: '031_installed_plugins_source', + sql: ` + alter table installed_plugins + add column source text not null default 'installed'; + `, + }, ] diff --git a/server/handlers/cms/plugins/shared.ts b/server/handlers/cms/plugins/shared.ts index 8cf45139b..bca2d8e89 100644 --- a/server/handlers/cms/plugins/shared.ts +++ b/server/handlers/cms/plugins/shared.ts @@ -125,6 +125,7 @@ function brokenPluginStub( lifecycleStatus: 'error', lastError: result.reason, grantedPermissions: [], + source: 'installed', manifest: stubManifest, settings: {}, installedAt: new Date(0).toISOString(), diff --git a/server/plugins/package.ts b/server/plugins/package.ts index 1652617e9..e70187acb 100644 --- a/server/plugins/package.ts +++ b/server/plugins/package.ts @@ -1,5 +1,6 @@ import { strFromU8, unzipSync } from 'fflate' import { + isReservedSitePluginId, parsePluginManifest, } from '@core/plugins/manifest' import { assertSandboxSafe } from '@core/plugins/sandboxScan' @@ -48,6 +49,17 @@ export async function readPluginPackage(file: File): Promise { // parsePluginManifest is a TypeBox schema validator — it accepts unknown // and throws on shape mismatch. Safe boundary. const manifest = parsePluginManifest(JSON.parse(manifestText)) + + // The `site.` namespace belongs to site plugins built from the site draft. + // An uploaded zip claiming it could hijack a site plugin's runtime + // identity, grants, settings, and secrets — reject at the zip boundary. + if (isReservedSitePluginId(manifest.id)) { + throw new Error( + `Plugin id "${manifest.id}" uses the reserved "site." namespace. ` + + `That namespace belongs to site plugins built from the site draft; ` + + `uploaded packages must use a vendor namespace (e.g. "acme.${manifest.id.slice(5)}").`, + ) + } const entrypoints = [ ...Object.values(manifest.entrypoints ?? {}), ...manifest.adminPages.flatMap((page) => diff --git a/server/repositories/plugins.ts b/server/repositories/plugins.ts index a150666df..2feda1d61 100644 --- a/server/repositories/plugins.ts +++ b/server/repositories/plugins.ts @@ -1,5 +1,6 @@ import type { InstalledPlugin, + InstalledPluginSource, PluginLifecycleStatus, PluginManifest, PluginPermission, @@ -44,6 +45,7 @@ interface InstalledPluginRow { granted_permissions_json?: unknown manifest_json: unknown settings_json?: unknown + source?: string | null installed_at: Date | string updated_at: Date | string } @@ -94,6 +96,7 @@ function mapInstalledPlugin(row: InstalledPluginRow): InstalledPluginResult { grantedPermissions: Array.isArray(grantedPermissions) ? grantedPermissions as PluginPermission[] : manifest.grantedPermissions ?? [], + source: row.source === 'site-local' ? 'site-local' : 'installed', manifest, settings, installedAt: isoDate(row.installed_at), @@ -171,7 +174,7 @@ function mapPluginRecord(row: PluginRecordRow): PluginRecord { export async function listInstalledPlugins(db: DbClient): Promise { const { rows } = await db` select id, name, version, enabled, lifecycle_status, last_error, - granted_permissions_json, manifest_json, settings_json, installed_at, updated_at + granted_permissions_json, manifest_json, settings_json, source, installed_at, updated_at from installed_plugins order by installed_at desc ` @@ -181,7 +184,7 @@ export async function listInstalledPlugins(db: DbClient): Promise { const { rows } = await db` select id, name, version, enabled, lifecycle_status, last_error, - granted_permissions_json, manifest_json, settings_json, installed_at, updated_at + granted_permissions_json, manifest_json, settings_json, source, installed_at, updated_at from installed_plugins where id = ${id} ` @@ -192,7 +195,9 @@ export async function installPlugin( db: DbClient, manifest: PluginManifest, grantedPermissions: PluginPermission[] = manifest.grantedPermissions ?? [], + opts: { source?: InstalledPluginSource } = {}, ): Promise { + const source: InstalledPluginSource = opts.source ?? 'installed' const manifestToStore = { ...manifest, grantedPermissions } const declared = manifest.settings ?? [] // Seed settings with the manifest's declared defaults so plugins reading @@ -204,8 +209,8 @@ export async function installPlugin( Object.entries(pluginSettingsDefaults(declared)).filter(([key]) => !secretIds.has(key)), ) const { rows } = await db` - insert into installed_plugins (id, name, version, manifest_json, granted_permissions_json, settings_json, enabled, lifecycle_status, last_error) - values (${manifest.id}, ${manifest.name}, ${manifest.version}, ${writeJson(manifestToStore)}, ${writeJson(grantedPermissions)}, ${writeJson(initialSettings)}, true, 'installed', null) + insert into installed_plugins (id, name, version, manifest_json, granted_permissions_json, settings_json, enabled, lifecycle_status, last_error, source) + values (${manifest.id}, ${manifest.name}, ${manifest.version}, ${writeJson(manifestToStore)}, ${writeJson(grantedPermissions)}, ${writeJson(initialSettings)}, true, 'installed', null, ${source}) on conflict (id) do update set name = excluded.name, version = excluded.version, @@ -214,9 +219,10 @@ export async function installPlugin( enabled = true, lifecycle_status = 'installed', last_error = null, + source = excluded.source, updated_at = ${nowIso()} returning id, name, version, enabled, lifecycle_status, last_error, - granted_permissions_json, manifest_json, settings_json, installed_at, updated_at + granted_permissions_json, manifest_json, settings_json, source, installed_at, updated_at ` // Secret settings with a non-empty manifest default get an encrypted row. // Insert-if-absent: the upgrade/rollback flows reuse this upsert and must @@ -240,7 +246,7 @@ export async function setPluginEnabled( update installed_plugins set enabled = ${enabled}, updated_at = ${nowIso()} where id = ${id} returning id, name, version, enabled, lifecycle_status, last_error, - granted_permissions_json, manifest_json, settings_json, installed_at, updated_at + granted_permissions_json, manifest_json, settings_json, source, installed_at, updated_at ` return rows[0] ? mapInstalledPlugin(rows[0]) : null } @@ -255,7 +261,7 @@ export async function setPluginLifecycleStatus( update installed_plugins set lifecycle_status = ${lifecycleStatus}, last_error = ${lastError}, updated_at = ${nowIso()} where id = ${id} returning id, name, version, enabled, lifecycle_status, last_error, - granted_permissions_json, manifest_json, settings_json, installed_at, updated_at + granted_permissions_json, manifest_json, settings_json, source, installed_at, updated_at ` return rows[0] ? mapInstalledPlugin(rows[0]) : null } @@ -287,7 +293,7 @@ export async function setPluginSettings( updated_at = ${nowIso()} where id = ${id} returning id, name, version, enabled, lifecycle_status, last_error, - granted_permissions_json, manifest_json, settings_json, installed_at, updated_at + granted_permissions_json, manifest_json, settings_json, source, installed_at, updated_at ` return rows[0] ? mapInstalledPlugin(rows[0]) : null } diff --git a/src/__tests__/architecture/site-plugin-file-isolation.test.ts b/src/__tests__/architecture/site-plugin-file-isolation.test.ts new file mode 100644 index 000000000..3fbc120fa --- /dev/null +++ b/src/__tests__/architecture/site-plugin-file-isolation.test.ts @@ -0,0 +1,94 @@ +/** + * `SiteFileType: 'plugin'` isolation gate. + * + * Site plugin source files live in the site draft but must NEVER reach any + * visitor/module surface: published script bundles, user stylesheets, + * `props._siteScripts`, module-readable file lists — or the site editor's + * explorer (they are edited exclusively in the Plugin IDE). The pipelines + * select by EXACT type (`type === 'script'` / `type === 'style'`), so + * 'plugin' files are excluded by NOT matching; this gate pins that. + */ +import { describe, expect, test } from 'bun:test' +import { collectAppliedStyles, collectRuntimeScripts, DEFAULT_SITE_RUNTIME } from '@core/site-runtime' +import { reconcileSiteExplorerOrganization } from '@core/page-tree' +import type { SiteFile } from '@core/files/schemas' + +const file = (id: string, path: string, type: SiteFile['type'], content = 'export {}'): SiteFile => ({ + id, + path, + type, + content, + createdAt: 1, + updatedAt: 1, +}) + +const page = { id: 'page-1' } + +describe('site plugin file isolation', () => { + test("'plugin' files never become runtime scripts", () => { + const files = [ + file('f1', 'plugins/newsletter/server/index.ts', 'plugin'), + file('f2', 'src/scripts/fx.ts', 'script'), + ] + const collected = collectRuntimeScripts({ + files, + runtime: DEFAULT_SITE_RUNTIME, + page, + target: 'published', + }) + expect(collected.map((entry) => entry.file.id)).toEqual(['f2']) + }) + + test("'plugin' files never become user stylesheets", () => { + const files = [ + file('f1', 'plugins/newsletter/frontend/styles.css', 'plugin', 'body { color: red }'), + file('f2', 'src/styles/site.css', 'style', 'body { margin: 0 }'), + ] + const collected = collectAppliedStyles({ + files, + runtime: DEFAULT_SITE_RUNTIME, + page, + }) + expect(collected.map((entry) => entry.file.id)).toEqual(['f2']) + }) + + test("site explorer reconciliation drops 'plugin' files from the scripts section", () => { + const files = [ + file('f1', 'plugins/demo/server/index.ts', 'plugin'), + file('f2', 'src/scripts/fx.ts', 'script'), + ] + // Reconciliation keeps rowOrder entries only for rows the section still + // derives from `files` — a 'plugin' file must never be a scripts row. + const organization = reconcileSiteExplorerOrganization( + { + pages: { expandedFolders: [], emptyFolders: [], rowOrder: [] }, + styles: { expandedFolders: [], emptyFolders: [], rowOrder: [] }, + scripts: { + expandedFolders: [], + emptyFolders: [], + rowOrder: [ + { kind: 'item', id: 'f1', parentPath: 'plugins/demo/server', order: 0 }, + { kind: 'item', id: 'f2', parentPath: 'src/scripts', order: 1 }, + ], + }, + templates: { folders: [], items: [] }, + components: { folders: [], items: [] }, + }, + { pages: [], visualComponents: [], files }, + ) + const scriptRowIds = organization.scripts.rowOrder.map((row) => row.id) + expect(scriptRowIds).toContain('f2') + expect(scriptRowIds).not.toContain('f1') + }) + + test("no published-output pipeline matches 'plugin' by type", async () => { + // Static gate: the string type === 'plugin' must not appear in any + // published-output pipeline — 'plugin' files are excluded by NOT + // matching, never included by matching. + const glob = new Bun.Glob('{server/publish,src/core/publisher,src/core/site-runtime}/**/*.ts') + for await (const path of glob.scan('.')) { + const text = await Bun.file(path).text() + expect(text.includes("type === 'plugin'"), `${path} matches 'plugin' file type`).toBe(false) + } + }) +}) diff --git a/src/__tests__/server/pluginPackageNamespace.test.ts b/src/__tests__/server/pluginPackageNamespace.test.ts new file mode 100644 index 000000000..da8b6abb1 --- /dev/null +++ b/src/__tests__/server/pluginPackageNamespace.test.ts @@ -0,0 +1,56 @@ +/** + * The `site.` plugin-id namespace is reserved for site plugins generated + * from the site draft. The zip-install boundary (`readPluginPackage`) must + * reject uploaded packages that claim it — otherwise a zip could hijack a + * site plugin's runtime identity, grants, settings, and secrets. The + * manifest PARSER keeps accepting `site.*` ids because generated site-plugin + * packages parse through it. + */ +import { describe, expect, it } from 'bun:test' +import { zipSync, strToU8 } from 'fflate' +import { isReservedSitePluginId, parsePluginManifest } from '@core/plugins/manifest' +import { readPluginPackage } from '../../../server/plugins/package' + +function pluginZip(files: Record): File { + const zipped = zipSync(Object.fromEntries( + Object.entries(files).map(([path, content]) => [path, strToU8(content)]), + )) + return new File([zipped], 'site-newsletter.zip', { type: 'application/zip' }) +} + +describe('site.* plugin id namespace', () => { + it('isReservedSitePluginId flags the reserved namespace', () => { + expect(isReservedSitePluginId('site.newsletter')).toBe(true) + expect(isReservedSitePluginId('site.a.b')).toBe(true) + expect(isReservedSitePluginId('acme.workflow')).toBe(false) + // 'sitemap.tools' must NOT be caught by a naive startsWith('site') + expect(isReservedSitePluginId('sitemap.tools')).toBe(false) + }) + + it('the zip boundary rejects site.* package ids', async () => { + const manifest = { + id: 'site.newsletter', + name: 'Newsletter', + version: '1.0.0', + apiVersion: 1, + permissions: [], + adminPages: [], + } + await expect(readPluginPackage(pluginZip({ + 'plugin.json': JSON.stringify(manifest), + }))).rejects.toThrow(/reserved "site\." namespace/) + }) + + it('the manifest parser still accepts site.* ids (generated packages)', () => { + const parsed = parsePluginManifest({ + id: 'site.newsletter', + name: 'Newsletter', + version: '1.0.1+abcd1234', + apiVersion: 1, + permissions: [], + resources: [], + adminPages: [], + }) + expect(parsed.id).toBe('site.newsletter') + }) +}) diff --git a/src/__tests__/server/pluginSourceColumn.test.ts b/src/__tests__/server/pluginSourceColumn.test.ts new file mode 100644 index 000000000..1e0013d82 --- /dev/null +++ b/src/__tests__/server/pluginSourceColumn.test.ts @@ -0,0 +1,59 @@ +/** + * `installed_plugins.source` — provenance column added by migration 022. + * Zip/JSON installs default to 'installed'; site plugin activations pass + * { source: 'site-local' }. The value must round-trip reads and survive the + * upgrade upsert. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { parsePluginManifest } from '@core/plugins/manifest' +import { getInstalledPlugin, installPlugin } from '../../../server/repositories/plugins' +import { createTestDb, type TestDb } from '../helpers/createTestDb' + +function manifest(id: string, version = '1.0.0') { + return parsePluginManifest({ + id, + name: id, + version, + apiVersion: 1, + permissions: [], + resources: [], + adminPages: [], + }) +} + +let testDb: TestDb + +beforeAll(async () => { + testDb = await createTestDb() +}) + +afterAll(async () => { + await testDb.cleanup() +}) + +describe('installed_plugins.source', () => { + test('defaults to installed and round-trips site-local', async () => { + const { db } = testDb + + const installed = await installPlugin(db, manifest('acme.demo'), []) + expect(installed.source).toBe('installed') + + const siteLocal = await installPlugin(db, manifest('site.demo', '1.0.1+aaaa1111'), [], { + source: 'site-local', + }) + expect(siteLocal.source).toBe('site-local') + + const read = await getInstalledPlugin(db, 'site.demo') + expect(read?.kind).toBe('ok') + if (read?.kind === 'ok') expect(read.plugin.source).toBe('site-local') + }) + + test('upgrade upsert preserves the site-local provenance', async () => { + const { db } = testDb + const upgraded = await installPlugin(db, manifest('site.demo', '1.0.2+bbbb2222'), [], { + source: 'site-local', + }) + expect(upgraded.source).toBe('site-local') + expect(upgraded.version).toBe('1.0.2+bbbb2222') + }) +}) diff --git a/src/core/files/schemas.ts b/src/core/files/schemas.ts index a4a13f4f8..38ecc7c6e 100644 --- a/src/core/files/schemas.ts +++ b/src/core/files/schemas.ts @@ -26,6 +26,10 @@ const SiteFileTypeSchema = Type.Union([ Type.Literal('asset'), // public/* — images, fonts, etc. (binary) Type.Literal('config'), // package.json, tsconfig.json, vite.config.ts, .env, etc. Type.Literal('doc'), // README.md, CHANGELOG.md — markdown docs + Type.Literal('plugin'), // plugins//** — site plugin source; NEVER enters + // published bundles, _siteScripts, or module-readable file + // lists, and never appears in the site editor (edited in + // the Plugin IDE). Gated by site-plugin-file-isolation.test.ts. ]) export type SiteFileType = Static diff --git a/src/core/plugin-sdk/types/installedPlugin.ts b/src/core/plugin-sdk/types/installedPlugin.ts index ede4685f8..91ddedff0 100644 --- a/src/core/plugin-sdk/types/installedPlugin.ts +++ b/src/core/plugin-sdk/types/installedPlugin.ts @@ -7,6 +7,15 @@ import type { PluginPermission } from './permissions' // Installed plugin — manifest + host bookkeeping // --------------------------------------------------------------------------- +/** + * Provenance of an installed plugin row. `'installed'` = uploaded zip / JSON + * manifest; `'site-local'` = generated from the site draft's + * `plugins//` source (docs/features/site-plugins.md). Provenance is + * for display labels and lifecycle-action routing only — the plugin runtime + * never branches on it. + */ +export type InstalledPluginSource = 'installed' | 'site-local' + export interface InstalledPlugin { id: string name: string @@ -15,6 +24,7 @@ export interface InstalledPlugin { lifecycleStatus: PluginLifecycleStatus lastError: string | null grantedPermissions: PluginPermission[] + source: InstalledPluginSource manifest: PluginManifest /** * Current user-edited settings values, keyed by setting id. Always diff --git a/src/core/plugins/manifest.ts b/src/core/plugins/manifest.ts index 50fe233c2..f000dcaa8 100644 --- a/src/core/plugins/manifest.ts +++ b/src/core/plugins/manifest.ts @@ -24,6 +24,19 @@ export { collectEnabledAdminPages, pluginAdminPageRoute } export { findPluginResource, validatePluginRecordData } from './resourceRecords' const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/ + +/** + * The `site.` id namespace is reserved for site plugins generated from the + * site draft (docs/features/site-plugins.md). Uploaded zip packages must + * never claim it — enforced at the zip boundary (`readPluginPackage`), NOT + * here in the parser, because generated site-plugin packages parse through + * `parsePluginManifest` with `site.*` ids. + */ +export const SITE_PLUGIN_ID_PREFIX = 'site.' + +export function isReservedSitePluginId(id: string): boolean { + return id.startsWith(SITE_PLUGIN_ID_PREFIX) +} /** * Used for resource IDs and admin page IDs — these become URL path segments, * so they are restricted to lowercase kebab-case. From c3140d00defc5b3cea72a8235a0f24c8ca48a38b Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sun, 12 Jul 2026 14:00:30 +0200 Subject: [PATCH 02/36] refactor(plugin-sdk): extract shared plugin-build core with fail-closed import containment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New @core/plugin-build: buildPluginPackage (manifest write + per-surface bundling), bundleEntrypoint (one Bun.build choke point: externals layering, sandbox facades, post-bundle scans), facade generation, and a containment resolver plugin. - The CLI build (instatic-plugin build|dev) becomes a thin frontend: config evaluation, dist reset, pack/icon copy, and zipping stay CLI-side; bundling behavior is byte-identical (verified by init+build smoke runs for module and server kinds). - containmentPlugin fails resolution closed to the workspace root: upward-relative escapes, absolute paths, and import-attribute payloads throw; bare specifiers must be explicitly mapped (SDK) or external (import-map-resolved). Symlinked roots (macOS /var → /private/var) are realpath-normalized so containment can't be silently disabled. - Barrel gate covers @core/plugin-build; sandbox-invariant gate re-pinned to the shared core. Co-Authored-By: Claude Fable 5 --- smoke-test/.gitignore | 4 + smoke-test/README.md | 23 + smoke-test/instatic-plugin.config.ts | 14 + smoke-test/modules/hello.ts | 19 + .../no-core-barrel-deep-imports.test.ts | 2 + .../plugin-sandbox-invariants.test.ts | 7 +- .../plugins/pluginBuildContainment.test.ts | 134 ++++++ src/core/plugin-build/buildPackage.ts | 216 +++++++++ src/core/plugin-build/bundle.ts | 220 +++++++++ src/core/plugin-build/containment.ts | 107 +++++ src/core/plugin-build/facades.ts | 82 ++++ src/core/plugin-build/index.ts | 17 + src/core/plugin-sdk/cli/build.ts | 433 ++---------------- 13 files changed, 870 insertions(+), 408 deletions(-) create mode 100644 smoke-test/.gitignore create mode 100644 smoke-test/README.md create mode 100644 smoke-test/instatic-plugin.config.ts create mode 100644 smoke-test/modules/hello.ts create mode 100644 src/__tests__/plugins/pluginBuildContainment.test.ts create mode 100644 src/core/plugin-build/buildPackage.ts create mode 100644 src/core/plugin-build/bundle.ts create mode 100644 src/core/plugin-build/containment.ts create mode 100644 src/core/plugin-build/facades.ts create mode 100644 src/core/plugin-build/index.ts diff --git a/smoke-test/.gitignore b/smoke-test/.gitignore new file mode 100644 index 000000000..df45c0b07 --- /dev/null +++ b/smoke-test/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.plugin.zip +.DS_Store diff --git a/smoke-test/README.md b/smoke-test/README.md new file mode 100644 index 000000000..434a56c71 --- /dev/null +++ b/smoke-test/README.md @@ -0,0 +1,23 @@ +# Smoke Test + +> Plugin id: `local.smoke-test` + +## Develop + +```bash +instatic-plugin dev # watch + sync into the running CMS +instatic-plugin build # produce a .plugin.zip +``` + +The dev command writes built files directly into the host CMS's +`uploads/plugins/local.smoke-test//` directory. On first run it +auto-detects the host's `uploads/` folder by walking up from the plugin +directory; pass `--uploads ` (or set `INSTATIC_UPLOADS_DIR`) when running +outside the instatic monorepo. + +You'll need to install the plugin once via the admin UI (`/admin/plugins` → +Upload Plugin) so the host registers it and approves permissions. After +that, every `instatic-plugin dev` rebuild flows in without another upload. + +See [docs/features/plugin-system.md](../instatic/docs/features/plugin-system.md) +for the full plugin SDK surface. diff --git a/smoke-test/instatic-plugin.config.ts b/smoke-test/instatic-plugin.config.ts new file mode 100644 index 000000000..38fb3fb64 --- /dev/null +++ b/smoke-test/instatic-plugin.config.ts @@ -0,0 +1,14 @@ +import { definePlugin, permissions } from '@core/plugin-sdk' +import hello from './modules/hello' + +export default definePlugin({ + id: 'local.smoke-test', + name: 'Smoke Test', + version: '0.1.0', + description: 'A new Smoke Test plugin.', + permissions: [permissions.modulesRegister], + modules: [hello], + // Add settings, admin pages, hooks, frontend bundles, or a Visual Component + // pack here as your plugin grows. See docs/features/plugin-system.md for the + // full SDK surface. +}) diff --git a/smoke-test/modules/hello.ts b/smoke-test/modules/hello.ts new file mode 100644 index 000000000..b8b4fcb1e --- /dev/null +++ b/smoke-test/modules/hello.ts @@ -0,0 +1,19 @@ +import { control, defineModule, html } from '@core/plugin-sdk' + +export default defineModule({ + id: 'local.smoke-test.hello', + name: 'Hello', + description: 'Sample canvas module emitted by the scaffolded plugin.', + category: 'Smoke Test', + htmlTag: 'div', + defaults: { + message: 'Hello from your new plugin.', + }, + schema: { + message: control.text('Message'), + }, + render: ({ props }) => ({ + html: html`
${props.message}
`, + css: `.hello { padding: 12px; border: 1px dashed currentColor; border-radius: 6px; }`, + }), +}) diff --git a/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts b/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts index 8c97aefda..1731c3b15 100644 --- a/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts +++ b/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts @@ -37,6 +37,8 @@ const BARRELLED_MODULES = [ 'framework-schema', 'fonts', 'collab', + 'plugin-build', + 'site-plugins', ] // Scan production + test sources in both the app and the server. diff --git a/src/__tests__/architecture/plugin-sandbox-invariants.test.ts b/src/__tests__/architecture/plugin-sandbox-invariants.test.ts index 796d0c799..b183d8514 100644 --- a/src/__tests__/architecture/plugin-sandbox-invariants.test.ts +++ b/src/__tests__/architecture/plugin-sandbox-invariants.test.ts @@ -106,8 +106,11 @@ describe('plugin sandbox invariants', () => { expect(scanCount).toBeGreaterThanOrEqual(2) }) - it('the SDK build pipeline applies the same sandbox scan at build time', async () => { - const source = await read('src/core/plugin-sdk/cli/build.ts') + it('the shared build core applies the same sandbox scan at build time', async () => { + // The bundling pipeline lives in @core/plugin-build (shared by the CLI + // and the server-side site plugin build) — both surfaces ride the same + // scan + IIFE contract by construction. + const source = await read('src/core/plugin-build/bundle.ts') expect(source).toContain('assertSandboxSafe') // Sandboxed bundles must be emitted as IIFE (the format QuickJS can // eval). The build pipeline used to ship ESM with `export function …` diff --git a/src/__tests__/plugins/pluginBuildContainment.test.ts b/src/__tests__/plugins/pluginBuildContainment.test.ts new file mode 100644 index 000000000..804edabb7 --- /dev/null +++ b/src/__tests__/plugins/pluginBuildContainment.test.ts @@ -0,0 +1,134 @@ +/** + * Import containment for workspace plugin builds — the resolve plugin must + * fail closed: every import originating inside the workspace resolves inside + * it; bare specifiers are rejected unless mapped; absolute paths and + * upward-relative escapes throw. Without this, draft code could embed host + * files (env files, DB files) into a bundle and exfiltrate them through the + * plugin's own public routes. + */ +import { describe, expect, test } from 'bun:test' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { buildPluginPackage } from '@core/plugin-build' +import { parsePluginManifest } from '@core/plugins/manifest' + +async function workspace(files: Record): Promise { + const root = await mkdtemp(join(tmpdir(), 'containment-')) + for (const [path, content] of Object.entries(files)) { + const absolute = join(root, path) + await mkdir(dirname(absolute), { recursive: true }) + await writeFile(absolute, content, 'utf8') + } + return root +} + +const MANIFEST = parsePluginManifest({ + id: 'site.demo', + name: 'Demo', + version: '1.0.1+aaaa1111', + apiVersion: 1, + permissions: [], + resources: [], + adminPages: [], + entrypoints: { server: 'server/index.js' }, +}) + +const SDK_ENTRY = resolve(import.meta.dir, '../../core/plugin-sdk/index.ts') + +describe('plugin build import containment', () => { + test('relative import inside the workspace bundles fine', async () => { + const root = await workspace({ + 'server/index.ts': "import { x } from '../shared/util'\nexport function activate() { return x }", + 'shared/util.ts': 'export const x = 1', + }) + try { + const out = join(root, '.dist') + const result = await buildPluginPackage({ + sourceDir: root, + outputDir: out, + manifest: MANIFEST, + resolve: { workspaceRoot: root, bareSpecifiers: {} }, + }) + expect(result.files).toContain('server/index.js') + expect(existsSync(join(out, 'server/index.js'))).toBe(true) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test('upward escape outside the workspace fails the build', async () => { + const root = await workspace({ + 'server/index.ts': "import secret from '../../outside'\nexport function activate() { return secret }", + }) + await writeFile(join(root, '..', 'outside.ts'), 'export default 42', 'utf8') + try { + await expect( + buildPluginPackage({ + sourceDir: root, + outputDir: join(root, '.dist'), + manifest: MANIFEST, + resolve: { workspaceRoot: root, bareSpecifiers: {} }, + }), + ).rejects.toThrow(/outside the plugin workspace/) + } finally { + await rm(join(root, '..', 'outside.ts'), { force: true }) + await rm(root, { recursive: true, force: true }) + } + }) + + test('absolute-path import-attribute payload fails the build', async () => { + const root = await workspace({ + 'server/index.ts': "import x from '/etc/hosts' with { type: 'text' }\nexport function activate() { return x }", + }) + try { + await expect( + buildPluginPackage({ + sourceDir: root, + outputDir: join(root, '.dist'), + manifest: MANIFEST, + resolve: { workspaceRoot: root, bareSpecifiers: {} }, + }), + ).rejects.toThrow(/outside the plugin workspace/) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test('unlisted bare specifier fails; mapped one resolves to the host SDK', async () => { + const rejected = await workspace({ + 'server/index.ts': "import { html } from '@instatic/plugin-sdk'\nexport function activate() { return html }", + }) + try { + await expect( + buildPluginPackage({ + sourceDir: rejected, + outputDir: join(rejected, '.dist'), + manifest: MANIFEST, + resolve: { workspaceRoot: rejected, bareSpecifiers: {} }, + }), + ).rejects.toThrow(/not an allowed dependency/) + } finally { + await rm(rejected, { recursive: true, force: true }) + } + + const mapped = await workspace({ + 'server/index.ts': "import { html } from '@instatic/plugin-sdk'\nexport function activate() { return String(html) }", + }) + try { + const result = await buildPluginPackage({ + sourceDir: mapped, + outputDir: join(mapped, '.dist'), + manifest: MANIFEST, + resolve: { + workspaceRoot: mapped, + bareSpecifiers: { '@instatic/plugin-sdk': SDK_ENTRY }, + }, + }) + expect(result.files).toContain('server/index.js') + } finally { + await rm(mapped, { recursive: true, force: true }) + } + }) +}) diff --git a/src/core/plugin-build/buildPackage.ts b/src/core/plugin-build/buildPackage.ts new file mode 100644 index 000000000..c1f72daec --- /dev/null +++ b/src/core/plugin-build/buildPackage.ts @@ -0,0 +1,216 @@ +/** + * `buildPluginPackage` — the shared, pure plugin-package builder. + * + * Takes a fully-derived manifest plus an on-disk source workspace and emits + * the runtime package layout (`plugin.json` + per-surface bundles). Two + * frontends drive it: + * + * - the CLI (`instatic-plugin build|dev`) — evaluates the author's + * `instatic-plugin.config.ts` on the author's own machine, derives the + * manifest, then calls this core; + * - the server-side site plugin build — materializes draft `SiteFile[]` + * source into a temp workspace, derives the manifest from the draft + * `plugin.json`, and calls this core with a fail-closed + * `ImportResolverPolicy` (the host must never let draft code read + * outside its own folder). + * + * The core knows nothing about zips, dist directories, packs, or icons — + * those stay with the callers. + */ +import { existsSync, readdirSync } from 'node:fs' +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { PluginManifest } from '@core/plugin-sdk' +import { bundleEntrypoint } from './bundle' +import { generateModulesFacade } from './facades' + +/** + * Import-containment seam. Absent = default resolution (CLI behavior). + * The site plugin frontend supplies a policy that fails any resolution + * escaping the materialized workspace. See `./containment.ts`. + */ +export interface ImportResolverPolicy { + /** Absolute dir that workspace-originating imports must stay inside. */ + workspaceRoot: string + /** Exact bare-specifier → absolute-path overrides (e.g. '@instatic/plugin-sdk'). */ + bareSpecifiers: Record + /** + * Bare specifiers allowed to stay external (resolved by a runtime import + * map, never bundled). Populated automatically by `bundleEntrypoint` from + * the bundle's own external list when not set explicitly. + */ + allowedExternals?: string[] +} + +export interface BuildPackageInput { + /** Absolute dir containing the plugin source (server/, editor/, frontend/, modules/…). */ + sourceDir: string + /** Absolute dir to write the package into (plugin.json + bundles). */ + outputDir: string + /** Fully-derived, already-validated manifest to serialize as plugin.json. */ + manifest: PluginManifest + resolve?: ImportResolverPolicy + /** + * Bare specifiers frontend bundles leave external (resolved by the + * published page's `