From 135cc9a0729ab70485e2ba4c522103b1db973e2d Mon Sep 17 00:00:00 2001 From: kasunben Date: Sat, 8 Nov 2025 23:14:08 +0100 Subject: [PATCH] POC: Per Plugin Database --- .prettierignore | 2 + bin/sv.mjs | 137 ++++++++++++++++ docs/CLI.md | 51 ++++-- docs/plugins/capabilities.md | 18 +-- docs/plugins/per-plugin-databases.md | 112 +++++++++++++ .../rfcs/rfc-0003-plugin-db-role-isolation.md | 97 +++++++++++ ...04-sqlite-plugin-datasource-segregation.md | 96 +++++++++++ .../rfc-0005-sqlite-plugin-readonly-views.md | 96 +++++++++++ .../rfc-0006-plugin-data-broker-service.md | 95 +++++++++++ packages/types/index.d.ts | 24 +++ platform/src/ext-host/build-routes.js | 10 +- platform/src/ext-host/capabilities.js | 29 +++- platform/src/ext-host/index.js | 23 ++- platform/src/server.js | 4 +- .../src/services/plugin-database-manager.js | 150 ++++++++++++++++++ plugins/papertrail/src/App.jsx | 2 +- tests/ext-host-capabilities.test.mjs | 73 +++++++-- tools/build-manifest.mjs | 33 ++++ tools/plugin-templates/custom/plugin.json | 1 + tools/plugin-templates/spa/plugin.json | 1 + 20 files changed, 1006 insertions(+), 48 deletions(-) create mode 100644 docs/plugins/per-plugin-databases.md create mode 100644 docs/rfcs/rfc-0003-plugin-db-role-isolation.md create mode 100644 docs/rfcs/rfc-0004-sqlite-plugin-datasource-segregation.md create mode 100644 docs/rfcs/rfc-0005-sqlite-plugin-readonly-views.md create mode 100644 docs/rfcs/rfc-0006-plugin-data-broker-service.md create mode 100644 platform/src/services/plugin-database-manager.js diff --git a/.prettierignore b/.prettierignore index 699a55a..a38f5ab 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,3 +10,5 @@ platform/src/views/register.html plugins/blog/views/blog/editor.html plugins/papertrail/* tools/plugin-templates/spa/vite.config.js +tools/plugin-templates/spa/plugin.json +tools/plugin-templates/custom/plugin.json diff --git a/bin/sv.mjs b/bin/sv.mjs index 773d8dc..0f0e779 100755 --- a/bin/sv.mjs +++ b/bin/sv.mjs @@ -9,6 +9,8 @@ import { createHash } from "node:crypto"; import { promisify } from "node:util"; import { createRequire } from "module"; +import createPluginDatabaseManager from "../platform/src/services/plugin-database-manager.js"; + const require = createRequire(import.meta.url); const pkg = require("../package.json"); @@ -44,6 +46,7 @@ if (!aliasLoaded) { const BUILD_MANIFEST_SCRIPT = resolve(__dirname, "../tools/build-manifest.mjs"); const MANIFEST_PATH = resolve(__dirname, "../manifest.json"); const PLUGINS_DIR = resolve(__dirname, "../plugins"); +const DATA_DIR = resolve(__dirname, "../data"); const execFileAsync = promisify(execFile); const COPY_SKIP_DIRS = new Set([".git", ".hg", ".svn"]); const COPY_SKIP_FILES = new Set([".DS_Store"]); @@ -105,6 +108,7 @@ function printUsage() { remove show [--json] validate + db migrate deploy [--plugin ] [--dry-run] @@ -518,6 +522,46 @@ function createMigrationStateTemplate() { return { core: [], plugins: {} }; } +function normalizePluginDatabaseMode(input) { + if (!input) return "shared"; + const normalized = String(input).toLowerCase(); + if (normalized === "exclusive") return "exclusive-sqlite"; + if (normalized === "shared") return "shared"; + if (normalized === "exclusive-sqlite") return "exclusive-sqlite"; + throw new Error(`Unknown plugin database mode "${input}". Use "shared" or "exclusive".`); +} + +function formatDatabaseBlockForTemplate(mode) { + if (mode === "exclusive-sqlite") { + return '{\n "mode": "exclusive-sqlite",\n "provider": "sqlite"\n }'; + } + return '{\n "mode": "shared"\n }'; +} + +function createCliPluginDatabaseManager() { + const sqliteDir = resolve(DATA_DIR, "plugins"); + return createPluginDatabaseManager({ + sqlite: { baseDir: sqliteDir }, + sharedDatasourceUrl: process.env.PLUGIN_DATABASE_URL || process.env.DATABASE_URL || null, + }); +} + +async function resolveInstalledPlugin(selector) { + const installed = await indexInstalledPlugins(); + const match = + installed.byNamespace.get(selector) || + installed.byId.get(selector) || + [...installed.byNamespace.values()].find((entry) => entry.id === selector); + if (!match) { + throw new Error( + `Unknown plugin "${selector}". Use a namespace or manifest id (e.g., @org/plugin).` + ); + } + const { manifest } = await readPluginManifest(match.dir); + const namespace = manifest.namespace || match.namespace || selector; + return { manifest, namespace, dir: match.dir }; +} + async function loadMigrationState() { try { const raw = await fs.readFile(MIGRATION_STATE_PATH, "utf8"); @@ -651,6 +695,7 @@ const commands = { Examples: sv plugins add ./src/plugins/blog sv plugins enable @sovereign/blog + sv plugins db ensure blog sv plugins list --json `, @@ -668,6 +713,8 @@ const commands = { const outputJson = Boolean(args.flags.json); const type = String(args.flags.type || "custom").toLowerCase(); const version = args.flags.version || "0.1.0"; + const databaseModeFlag = args.flags.db || args.flags.database || "shared"; + const databaseMode = normalizePluginDatabaseMode(databaseModeFlag); const displayName = args.flags.name || args.flags["display-name"] || toTitleCase(namespace) || namespace; const description = @@ -713,6 +760,7 @@ const commands = { DEV_ORIGIN: devOrigin, PREVIEW_PORT: String(previewPort), LIB_GLOBAL: libraryGlobal, + PLUGIN_DATABASE_BLOCK: formatDatabaseBlockForTemplate(databaseMode), }; let templateDir; @@ -731,6 +779,7 @@ const commands = { namespace, id: pluginId, type, + databaseMode, targetDir, templateDir, dryRun, @@ -746,11 +795,99 @@ const commands = { console.log( `Created ${type} plugin "${pluginId}" in ${targetDir} from template ${templateDir}.` ); + console.log(`Database mode: ${databaseMode}`); if (!skipManifest) { console.log(`Manifest updated via tools/build-manifest.mjs.`); } else { console.log(`--skip-manifest set; run "sv manifest generate" when ready.`); } + if (databaseMode === "exclusive-sqlite") { + console.log( + `Run "sv plugins db ensure ${namespace}" to provision the dedicated SQLite database.` + ); + } + } + }, + }, + + db: { + __help__: ` + Usage: + sv plugins db info [--json] + sv plugins db ensure [--json] + `, + desc: "Inspect or provision a plugin's dedicated database (SQLite-only for now)", + async run(args) { + const action = args._[2]; + if (!action || !["info", "ensure"].includes(action)) { + exitUsage(`Usage: sv plugins db [--json]`); + } + const selector = args._[3]; + if (!selector) { + exitUsage( + `Missing plugin namespace. Usage: sv plugins db ${action} [--json]` + ); + } + + const { manifest, namespace } = await resolveInstalledPlugin(selector); + const dbConfig = manifest?.sovereign?.database || {}; + const mode = dbConfig.mode || "shared"; + const manager = createCliPluginDatabaseManager(); + + if (mode === "shared") { + const note = `Plugin ${namespace} is configured for shared database mode. Declare sovereign.database.mode="exclusive-sqlite" to enable a dedicated file.`; + const payload = { + namespace, + pluginId: manifest.id, + mode, + provider: manager.sharedProvider, + url: manager.sharedDatasourceUrl, + path: null, + message: note, + }; + if (args.flags.json) { + console.log(JSON.stringify(payload, null, 2)); + } else { + console.log(note); + } + return; + } + + if (mode !== "exclusive-sqlite") { + throw new Error( + `Plugin ${namespace} requests unsupported database mode "${mode}". Only "exclusive-sqlite" is available right now.` + ); + } + + const descriptor = await manager.resolveDatasource(manifest, { + namespace, + pluginId: manifest.id, + }); + + const payload = { + namespace, + pluginId: manifest.id, + mode: descriptor.mode, + provider: descriptor.provider, + url: descriptor.url, + path: descriptor.path || null, + }; + + if (args.flags.json) { + console.log(JSON.stringify(payload, null, 2)); + return; + } + + if (action === "ensure") { + console.log( + `[plugins] ${namespace}: ensured SQLite database at ${payload.path || descriptor.url}.` + ); + } else { + console.log(`Plugin: ${namespace}`); + console.log(`Mode: ${payload.mode}`); + console.log(`Provider: ${payload.provider}`); + console.log(`URL: ${payload.url}`); + console.log(`Path: ${payload.path || "(n/a)"}`); } }, }, diff --git a/docs/CLI.md b/docs/CLI.md index d8efe49..331d0be 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -21,7 +21,7 @@ sv [global options] [args] | Command | Purpose | | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | -| `sv plugins create ` | Scaffold a new plugin from the built-in templates. | +| `sv plugins create ` | Scaffold a new plugin from the built-in templates (with optional per-plugin DB config). | | `sv plugins add ` | Install a plugin from a directory or git URL. | | `sv plugins list [--json] [--enabled\|--disabled]` | Inspect installed plugins plus their enablement state. | | `sv plugins enable ` | Turn on a plugin by clearing `draft`/`devOnly` in its manifest and rebuilding the workspace manifest. | @@ -29,6 +29,7 @@ sv [global options] [args] | `sv plugins remove ` | Unregister a disabled plugin after safety checks, optionally archiving its files. | | `sv plugins show [--json]` | Inspect plugin manifest details plus enablement status. | | `sv plugins validate ` | Lint a plugin directory for manifest correctness and required files. | +| `sv plugins db ` | Inspect or provision a plugin’s dedicated SQLite database. | ### `sv plugins create [--type custom|spa]` @@ -36,19 +37,20 @@ Bootstraps a plugin directory under `plugins/` using the curated temp **Flags** -| Flag | Effect | -| -------------------------- | -------------------------------------------------------------------------------------------------- | -| `--type ` | Choose which scaffold to use (`custom` by default). | -| `--name ""` | Human-facing plugin name; defaults to the namespace in Title Case. | -| `--description ""` | Short description embedded into the manifest. | -| `--id <@scope/name>` | Override the generated `plugin.json#id` (`@sovereign/` by default). | -| `--version ` | Initial version string (`0.1.0` by default). | -| `--author ""` | Author metadata stored in the manifest. | -| `--license ""` | License string stored in the manifest. | -| `--dev-port ` | Override the dev server port embedded in SPA manifests (random port between 4100–4299 by default). | -| `--skip-manifest` | Skip running `sv manifest generate` after scaffolding. | -| `--dry-run` | Print what would happen without writing files. | -| `--json` | Emit a JSON summary instead of human-readable text. | +| Flag | Effect | +| -------------------------- | --------------------------------------------------------------------------------------------------- | +| `--type ` | Choose which scaffold to use (`custom` by default). | +| `--name ""` | Human-facing plugin name; defaults to the namespace in Title Case. | +| `--description ""` | Short description embedded into the manifest. | +| `--id <@scope/name>` | Override the generated `plugin.json#id` (`@sovereign/` by default). | +| `--version ` | Initial version string (`0.1.0` by default). | +| `--author ""` | Author metadata stored in the manifest. | +| `--license ""` | License string stored in the manifest. | +| `--dev-port ` | Override the dev server port embedded in SPA manifests (random port between 4100–4299 by default). | +| `--db ` | Embed the desired `sovereign.database.mode` block (`shared` by default, `exclusive` → SQLite file). | +| `--skip-manifest` | Skip running `sv manifest generate` after scaffolding. | +| `--dry-run` | Print what would happen without writing files. | +| `--json` | Emit a JSON summary instead of human-readable text. | **Example** @@ -58,6 +60,9 @@ sv plugins create acme-support --name "Acme Support Desk" # Create an SPA plugin using a specific id + dev port sv plugins create companion --type spa --id @acme/companion --dev-port 4500 + +# Create an exclusive-database plugin (provisions via SQLite) +sv plugins create papertrail --db exclusive ``` ### `sv plugins add ` @@ -199,6 +204,17 @@ sv plugins show @sovereign/blog --json Runs a fast lint over a plugin directory. Validation ensures the directory exists, `plugin.json` passes the same basic schema checks used during `sv plugins add`, and that type-specific files exist (`index.js` for `custom`, `dist/index.js` for `spa`). Failures are printed and the command exits with status `1`. Passing validations print a short success line. +### `sv plugins db ` + +Utility commands for the per-plugin database manager. They only support SQLite-backed exclusives right now. + +| Subcommand | Effect | +| -------------------- | -------------------------------------------------------------------------------------------------------------- | +| `info ` | Prints the current datasource descriptor (mode, provider, DSN/path). Shared-mode plugins emit a friendly note. | +| `ensure ` | Creates or reuses the plugin’s dedicated SQLite file under `data/plugins/.db` and prints the path. | + +Both subcommands accept `--json` for script-friendly output. If the plugin manifest still declares `sovereign.database.mode = "shared"`, the commands exit early with guidance on switching to `exclusive-sqlite`. + **Flags** | Flag | Effect | @@ -208,8 +224,11 @@ Runs a fast lint over a plugin directory. Validation ensures the directory exist **Example** ``` -sv plugins validate ./plugins/blog -sv plugins validate ../my-plugin --json +# Inspect the datasource descriptor +sv plugins db info papertrail + +# Provision the SQLite file and print JSON diagnostics +sv plugins db ensure papertrail --json ``` ## Manifest Commands diff --git a/docs/plugins/capabilities.md b/docs/plugins/capabilities.md index 3aa6b22..2b1c9d2 100644 --- a/docs/plugins/capabilities.md +++ b/docs/plugins/capabilities.md @@ -18,15 +18,15 @@ Each plugin manifest defines host access under `sovereign.platformCapabilities`. The manifest builder resolves these flags into `plugins..sovereign.platformCapabilitiesResolved` inside `manifest.json`, so ops and UI surfaces can display the exact set (e.g., `["database","git"]`). Only the following allow‑listed capabilities are currently accepted: -| Capability | Injected Service | Notes | -| ------------ | ---------------------------- | ------------------------------- | -| `database` | Prisma client (`ctx.prisma`) | Full DB access | -| `git` | Git manager helpers | Used by blog/papertrail | -| `fs` | File-system adapter | Scoped to plugin storage | -| `env` | `refreshEnvCache` | Refreshes env config | -| `uuid` | `uuid()` helper | Deterministic IDs | -| `mailer` | Transactional mailer | Sends email | -| `fileUpload` | Upload helper (experimental) | Disabled in prod until hardened | +| Capability | Injected Service | Notes | +| ------------ | ---------------------------- | ------------------------------------------------------------------------------------- | +| `database` | Prisma client (`ctx.prisma`) | Shared DB by default; `exclusive-sqlite` plugins receive their isolated Prisma client | +| `git` | Git manager helpers | Used by blog/papertrail | +| `fs` | File-system adapter | Scoped to plugin storage | +| `env` | `refreshEnvCache` | Refreshes env config | +| `uuid` | `uuid()` helper | Deterministic IDs | +| `mailer` | Transactional mailer | Sends email | +| `fileUpload` | Upload helper (experimental) | Disabled in prod until hardened | Requests for unknown caps, or caps disabled in production, cause bootstrap errors. During development you may temporarily bypass declarations by exporting `DEV_ALLOW_ALL_CAPS=true`, but this is noisy and should never reach production. diff --git a/docs/plugins/per-plugin-databases.md b/docs/plugins/per-plugin-databases.md new file mode 100644 index 0000000..3840ee3 --- /dev/null +++ b/docs/plugins/per-plugin-databases.md @@ -0,0 +1,112 @@ +# Per-Plugin Database Support + +This document captures the strategy for giving Sovereign plugins the option to run against their own database instead of the shared plugin datasource. It complements RFC-0003..0006 and focuses on the developer/operational workflow we will implement inside the platform. + +## Goals + +- Let a plugin (e.g., Papertrail) request an isolated datasource without forcing every plugin to incur the overhead. +- Keep Prisma ergonomics by generating a client that is pre-wired to the plugin’s database. +- Centralize lifecycle management (provision, migrate, rotate credentials, teardown) in the platform so plugins remain sandboxed. +- Provide a paved path for both SQLite (local/dev) and PostgreSQL/MySQL (production) deployments. + +> **Note:** The initial implementation only supports SQLite (`exclusive-sqlite` mode). Other providers remain on the roadmap and are called out here for context, but they are not yet wired up at runtime. + +## High-Level Architecture + +``` +┌──────────┐ manifest.sovereign.database ┌────────────────────┐ +│ Plugin │ ───────────────────────────────────▶ │ Plugin DB Manager │ +│ manifest │ │ (extension host) │ +└──────────┘ ├────────────────────┤ + │ provider drivers │ + │ • SQLite files │ + │ • Postgres roles │ + │ • MySQL schemas │ + └────────┬───────────┘ + │ + ┌──────────────────┴────────────────┐ + │ Prisma client generation + inject │ + └───────────────────────────────────┘ +``` + +1. Plugins declare their desired database mode in `plugin.json` (see Manifest Contract). +2. During install/enable, the **Plugin Database Manager** provisions or reuses the datasource via the requested provider. +3. The platform generates or loads the plugin-specific Prisma Client and injects it into the sandboxed runtime (`context.db`). +4. Core data access still flows through the broker (RFC-0006); the isolated DB only stores plugin-owned tables. + +## Manifest Contract + +`plugin.json → sovereign.database` becomes the single source of truth: + +```jsonc +"sovereign": { + "schemaVersion": 2, + "...": "...", + "database": { + "mode": "exclusive-postgres", + "provider": "postgresql", + "schema": "papertrail", + "migrations": { + "directory": "migrations", + "entryPoint": "scripts/migrate.js" + }, + "limits": { + "storageMb": 1024, + "connections": 5 + } + } +} +``` + +Modes: + +| Mode | Description | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `shared` | Plugin uses the existing shared datasource (default / backwards-compatible). | +| `exclusive-sqlite` | Manager creates `data/plugins/.db` and wires a dedicated SQLite Prisma datasource. | +| `exclusive-postgres` | Manager provisions a schema/role pair in the primary Postgres cluster and hands the credentials to the plugin runtime. | + +Additional providers (e.g., MySQL) plug in via the same interface when needed. + +## Plugin Database Manager + +- New service under `platform/src/services/plugin-database-manager.js`. +- Responsibilities: + - Parse `sovereign.database`. + - Provision/destroy datasources via provider drivers. + - Run migrations via the plugin-specified entry point (or a default `prisma migrate deploy` helper). + - Produce a `PluginDatasourceDescriptor` (`{ url, provider, manifest, secretsRef }`) that the extension host uses when constructing plugin sandboxes. + - Emit structured audit logs for every lifecycle event. +- Providers planned for v1: + 1. **SQLiteFileProvider** – ensures directory structure, creates file, returns `file:...` URL. + 2. **PostgresRoleProvider** – uses RFC-0003 patterns to create roles/schemas, rotate passwords, and grant least privilege. + +## Prisma Integration + +- Each plugin that opts in ships a Prisma schema template (generated during `sv plugin build`) with placeholders for the datasource URL. +- The extension host injects `PLUGIN_DATABASE_URL` (per plugin) before dynamic importing the plugin bundle to ensure Prisma lazily connects to the right database. +- Shared fallback (`mode = "shared"`) keeps using the existing plugin Prisma client, so legacy plugins remain untouched. + +## CLI & Automation + +**Available today** + +- `sv plugins create --db exclusive` scaffolds the manifest with `exclusive-sqlite` mode enabled. +- `sv plugins db ensure ` provisions (or reuses) the plugin’s SQLite file and prints its path. +- `sv plugins db info ` inspects the current descriptor (mode/provider/url) for a plugin. + +**Planned additions** + +- `sv plugin db migrate ` invokes the manager to run migrations locally or in CI. +- `sv doctor plugins --db` reports the health/size of every plugin datasource and flags drift (e.g., missing migrations, stale schema). + +## Rollout Plan + +1. **Schema + typings:** introduce `sovereign.database` in the manifest schema, TypeScript defs, and validation tooling (this change). +2. **Manager skeleton:** land the service with provider hooks and stub methods so other teams can start integrating. +3. **SQLite provider pilot:** support local dev + CI; point Papertrail to its exclusive DB for end-to-end testing. +4. **Postgres provider:** wire into production stacks, complete credential rotation + auditing. +5. **Plugin migrations + CLI:** ensure plugin authors have a paved path for maintaining their schemas. +6. **Full enforcement:** once a plugin declares exclusive mode, automatically withhold the shared Prisma client from its sandbox. + +This document will evolve as we add provider-specific guides and CLI references. For now it provides the shared vocabulary and entry points for engineers starting the implementation work. diff --git a/docs/rfcs/rfc-0003-plugin-db-role-isolation.md b/docs/rfcs/rfc-0003-plugin-db-role-isolation.md new file mode 100644 index 0000000..28cd4db --- /dev/null +++ b/docs/rfcs/rfc-0003-plugin-db-role-isolation.md @@ -0,0 +1,97 @@ +# RFC-0003: Dedicated Database Roles for Plugins + +**Status:** Draft +**Author:** Sovereign Team, Codex (AI assistant) +**Created:** 2025-11-08 +**Target Version:** Sovereign v0.2 +**Tags:** Security, Database, Plugins, Least-Privilege + +--- + +## 1. Problem Statement + +Plugins currently receive unrestricted database connections, allowing them to read or mutate every table in the Sovereign schema. This violates least-privilege principles and means a buggy or malicious plugin can exfiltrate secrets, corrupt configuration, or drop core tables. We need an immediate control that limits the blast radius without refactoring the entire data layer. + +## 2. Goals + +- Provide coarse but enforceable separation between core data and plugin data. +- Require minimal code changes in the plugin host and Prisma layer. +- Enable per-plugin or per-capability auditing of database operations. +- Remain compatible with PostgreSQL/MySQL deployments where role-based access control is available. + +## 3. Non-Goals + +- Redesigning the plugin SDK or lifecycle. +- Introducing per-row policies or attribute-based access control. +- Supporting SQLite (which lacks native roles); see RFC-0004/0005 for SQLite-specific controls. + +## 4. Proposal + +### 4.1 Role Model + +1. **Core Role (`svc_core`)** – full privileges on the Sovereign schema, used only by the kernel services. +2. **Plugin Service Role (`svc_plugins`)** – read access to a curated subset of tables/views plus optional write access to plugin-owned tables. +3. **Optional Per-Plugin Roles (`svc_plugin_`)** – tailored privileges if a plugin needs unique tables. + +### 4.2 Implementation Steps + +1. **Create Roles** + - Example (PostgreSQL): + ```sql + CREATE ROLE svc_plugins LOGIN PASSWORD '***'; + REVOKE ALL ON SCHEMA public FROM PUBLIC; + GRANT USAGE ON SCHEMA public TO svc_plugins; + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE plugin_a_data TO svc_plugins; + ``` + - Repeat for each plugin-owned table set. + +2. **Restrict Core Tables** + - Explicitly revoke access on sensitive tables (`users`, `tenants`, `config`) from `svc_plugins`. + - Optionally expose sanitized views (read-only) for data plugins may consume. + +3. **Update Prisma Configuration** + - Introduce a second Prisma Client instance configured with the plugin role credentials. + - Ensure the extension host only injects this restricted client into plugin sandboxes. + +4. **Credential Management** + - Store the plugin role secret in the Sovereign secrets store or environment variables. + - Rotate credentials on plugin uninstall or periodically. + +5. **Telemetry** + - Tag plugin Client queries with `SET application_name = 'plugin:'` (Postgres) for audit trails. + +### 4.3 Operational Flow + +- Core services keep using `svc_core`. +- When a plugin registers, the extension host hands it the restricted Prisma Client. +- Any attempt to query unauthorized tables will fail at the database layer, producing a deterministic error surfaced to logs/telemetry. + +## 5. Migration & Rollout + +1. Create roles and grants via migration scripts or manual DBA operation. +2. Deploy updated configuration with dual Prisma clients. +3. Smoke-test critical plugins to ensure required tables are exposed. +4. Roll out gradually (e.g., staging → canary → production). + +## 6. Security Impact + +- **Confidentiality:** Prevents plugins from reading user/auth tables. +- **Integrity:** Blocks writes to protected tables; only plugin-owned data is mutable. +- **Auditability:** Database logs now distinguish plugin-originated queries. + +## 7. Risks & Mitigations + +- **Misconfigured Grants:** Automate grant creation via migrations and add tests that fail CI if unauthorized tables slip in. +- **Operational Overhead:** Document required grants per plugin in its manifest to keep the role map in sync. +- **SQLite Environments:** For local dev on SQLite, fall back to RFC-0004/0005 techniques until a multi-DB stack is available. + +## 8. Alternatives + +- Application-level ACLs without DB enforcement (insufficient because a compromised plugin can skip our SDK). +- Full row-level security (powerful but higher complexity; can be layered later). + +## 9. Open Questions + +1. Do we need per-tenant roles when multi-tenancy ships? +2. Should plugin manifests declare their required tables so the CLI can generate grants automatically? +3. How do we surface authorization errors to plugin authors for debug without leaking schema details? diff --git a/docs/rfcs/rfc-0004-sqlite-plugin-datasource-segregation.md b/docs/rfcs/rfc-0004-sqlite-plugin-datasource-segregation.md new file mode 100644 index 0000000..18632d9 --- /dev/null +++ b/docs/rfcs/rfc-0004-sqlite-plugin-datasource-segregation.md @@ -0,0 +1,96 @@ +# RFC-0004: SQLite Datasource Segregation for Plugins + +**Status:** Draft +**Author:** Sovereign Team, Codex (AI assistant) +**Created:** 2025-11-08 +**Target Version:** Sovereign v0.2 +**Tags:** Security, SQLite, Prisma, Plugins + +--- + +## 1. Problem Statement + +Sovereign currently ships with a single SQLite database file that holds both core platform tables and plugin-owned data. Plugins are given a Prisma Client backed by that file, so they can inspect or mutate every table. SQLite lacks role-based access control, so we must enforce isolation at the file level. + +## 2. Goals + +- Keep the developer-friendly SQLite stack for local and small deployments. +- Prevent plugins from accessing core tables by giving them a different physical database file. +- Minimize code churn by relying on Prisma’s multi-datasource support. +- Provide a migration path to PostgreSQL/MySQL where RFC-0003 applies seamlessly. + +## 3. Non-Goals + +- Implement row-level filtering or per-tenant partitioning. +- Guarantee consistency across cross-database transactions (eventual consistency is acceptable). + +## 4. Proposal + +### 4.1 Dual SQLite Files + +1. **`data/core.db`** – core platform tables managed exclusively by the kernel. +2. **`data/plugins.db`** – shared plugin datastore containing: + - Plugin-owned tables (namespaced per plugin). + - Materialized copies of core data the plugins need (synced via jobs/triggers). + +### 4.2 Prisma Configuration + +- Extend `prisma/schema.prisma` (or introduce `prisma/plugins.prisma`) with a second datasource: + + ```prisma + datasource core { + provider = "sqlite" + url = env("DATABASE_URL") + } + + datasource plugins { + provider = "sqlite" + url = env("PLUGIN_DATABASE_URL") + } + ``` + +- Generate two Prisma Clients: + - `@sovereign/db-core` (existing) – injected into kernel services only. + - `@sovereign/db-plugins` – injected into the extension host and passed to plugins. + +### 4.3 Data Synchronization + +- **Read use cases:** expose core data via lightweight sync jobs (e.g., copy user profile summaries hourly) or use views mounted with the `ATTACH DATABASE` command in read-only mode. +- **Write use cases:** plugins write to `plugins.db` only. The core periodically ingests or reacts to plugin data via events/jobs. + +### 4.4 CLI & Dev Experience + +- Update the `sv dev` script to ensure both files exist; auto-create `plugins.db` if missing. +- Provide migration commands: `sv migrate:deploy --scope=core|plugins`. +- Document backup/restore steps for the dual-file layout. + +## 5. Rollout Plan + +1. Introduce `PLUGIN_DATABASE_URL` env var with default `file:data/plugins.db`. +2. Add migrations to create plugin tables in the new file. +3. Modify the extension host to instantiate the plugin Prisma Client. +4. Update existing plugins to depend on the new client (one PR per plugin). +5. Remove direct access to `core.db` from plugin contexts. + +## 6. Security Impact + +- **Confidentiality:** Plugins can no longer read core tables; only replicated slices exist in `plugins.db`. +- **Integrity:** Core tables are untouched by plugin writes; corruption risk is contained. +- **Availability:** Crash or lock in `plugins.db` does not affect core operations, assuming watchdog restarts the plugin host separately. + +## 7. Risks & Mitigations + +- **Data Drift:** Copies of core data may fall out of sync. Use deterministic sync jobs and reconciliation tests. +- **Cross-DB Transactions:** Not supported; design plugin flows to be eventually consistent and leverage events for coordination. +- **Operational Complexity:** Two files to backup. Mitigate with updated automation scripts. + +## 8. Alternatives + +- Use SQLite `ATTACH` with read-only views instead of separate files (see RFC-0005). +- Migrate to a server-grade RDBMS and apply RFC-0003 directly. + +## 9. Open Questions + +1. Do we need per-plugin SQLite files or is one shared `plugins.db` sufficient? +2. How frequently should the core sync data into `plugins.db` for near-real-time read scenarios? +3. Should the CLI expose tooling to inspect and purge plugin DB bloat? diff --git a/docs/rfcs/rfc-0005-sqlite-plugin-readonly-views.md b/docs/rfcs/rfc-0005-sqlite-plugin-readonly-views.md new file mode 100644 index 0000000..836eae6 --- /dev/null +++ b/docs/rfcs/rfc-0005-sqlite-plugin-readonly-views.md @@ -0,0 +1,96 @@ +# RFC-0005: SQLite View-Only Schema for Plugin Read Access + +**Status:** Draft +**Author:** Sovereign Team, Codex (AI assistant) +**Created:** 2025-11-08 +**Target Version:** Sovereign v0.2 +**Tags:** Security, SQLite, Prisma, Read-Only + +--- + +## 1. Problem Statement + +Some plugins need to read slices of core data (users, boards, configs) but should never mutate them. SQLite lacks GRANT/REVOKE, so a single database file still allows writes to any table. We can expose curated read-only views and restrict the Prisma schema used by plugins so no write queries are generated. + +## 2. Goals + +- Allow plugins to query only approved tables/columns needed for their features. +- Prevent accidental writes to core tables from plugin contexts. +- Remain compatible with the existing single SQLite file for simple deployments. +- Require minimal operational overhead compared to dual-database solutions. + +## 3. Non-Goals + +- Providing write paths for plugins (combine with RFC-0004 or RFC-0006 if writes are required). +- Solving per-tenant isolation; this RFC focuses on table/column scope. + +## 4. Proposal + +### 4.1 Create Read-Only Views + +1. Define SQL views that project only the columns safe for plugins, e.g.: + ```sql + CREATE VIEW v_plugin_users AS + SELECT id, display_name, avatar_url, tenant_id + FROM users; + ``` +2. Prefix all plugin-visible views with `v_plugin_*` for discoverability. + +### 4.2 Restrict Prisma Schema + +- Generate a dedicated Prisma schema for plugins that **only** declares the views and plugin-owned tables: + + ```prisma + model PluginUser { + id String @id @map("id") + displayName String @map("display_name") + tenantId String @map("tenant_id") + + @@map("v_plugin_users") + @@ignoreRead(false) + @@ignoreWrite(true) + } + ``` + +- Because Prisma does not generate write helpers for views, plugins cannot call `create/update/delete` on these models. + +### 4.3 Extension Host Changes + +- Instantiate the plugin Prisma Client from the restricted schema file (e.g., `prisma/plugin-views.prisma`). +- During plugin registration, only pass this limited client. +- Validate at runtime that attempts to access undeclared models throw early (e.g., guard `context.db` properties). + +### 4.4 Operational Tooling + +- Add a migration helper (`sv migrate:views`) that re-creates the views whenever the underlying tables change. +- Document the mapping between core tables and plugin views. + +## 5. Rollout Plan + +1. Author SQL migrations to create required `v_plugin_*` views. +2. Scaffold the plugin Prisma schema referencing those views. +3. Update plugin code to use the new client (mostly type import changes). +4. Remove access to the full-core client from plugin sandboxes. + +## 6. Security Impact + +- **Confidentiality:** Sensitive columns never surface in the views, limiting data exposure. +- **Integrity:** Plugins cannot mutate core tables because they only see views and plugin-owned tables. +- **Auditability:** Any attempt to reach non-whitelisted data fails during client code generation. + +## 7. Risks & Mitigations + +- **Schema Drift:** Views can become invalid after schema changes; add CI checks that `prisma migrate deploy` validates both schemas. +- **Performance:** Complex views might impact read latency. Consider materialized snapshots or caching if needed. +- **Developer Ergonomics:** Plugin authors must understand the subset of data available. Provide documentation and TypeScript types generated from the schema. + +## 8. Alternatives + +- Separate SQLite files with synchronization (RFC-0004). +- API broker pattern (RFC-0006) for read/write mediation. + +## 9. Open Questions + +1. Should plugins be allowed to define their own views, or are views centrally managed by the core team? +2. How do we version view-breaking changes so plugin authors can adapt gracefully? +3. Can we auto-generate views from plugin capability declarations in the future? diff --git a/docs/rfcs/rfc-0006-plugin-data-broker-service.md b/docs/rfcs/rfc-0006-plugin-data-broker-service.md new file mode 100644 index 0000000..e3650d1 --- /dev/null +++ b/docs/rfcs/rfc-0006-plugin-data-broker-service.md @@ -0,0 +1,95 @@ +# RFC-0006: Brokered Data Service for Plugin Read/Write Operations + +**Status:** Draft +**Author:** Sovereign Team, Codex (AI assistant) +**Created:** 2025-11-08 +**Target Version:** Sovereign v0.2 +**Tags:** Security, API, Plugins, Data Broker + +--- + +## 1. Problem Statement + +Some plugins legitimately need to write to core data (e.g., create tasks, update settings) while we cannot trust them with full database access. Direct database isolation (RFC-0003/0004/0005) still leaves gaps when business rules or validation must run inside the core. We need a higher-level broker that mediates plugin reads/writes through vetted APIs and enforces authorization, validation, and audit logging. + +## 2. Goals + +- Hide the database completely from plugins; expose only explicit capabilities. +- Centralize validation, authorization, and side effects inside core services. +- Maintain compatibility with both SQLite (dev) and server-grade databases (prod). +- Provide observability for every plugin operation. + +## 3. Non-Goals + +- Replacing the public Sovereign API for end users; this broker is internal to the plugin host. +- Guaranteeing backwards compatibility for unsupported operations (plugins must opt into stable contracts). + +## 4. Proposal + +### 4.1 Data Broker Service + +- Introduce a `DataBroker` service inside the extension host that exposes a minimal RPC surface (function calls or lightweight REST) for plugins. +- Capabilities are declared in the plugin manifest (e.g., `data:task:create`), and the broker verifies them before executing. +- Broker methods call existing core services or Prisma clients to perform operations. + +### 4.2 Contract Design + +- Contracts follow a pattern: + ```ts + interface TaskBroker { + listTasks(filter: TaskFilter, ctx: PluginContext): Promise; + createTask(input: TaskCreateInput, ctx: PluginContext): Promise; + } + ``` +- Contracts are versioned (e.g., `v1alpha`, `v1`), and plugins specify the version they target. + +### 4.3 Execution Flow + +1. Plugin invokes `ctx.broker.tasks.createTask(...)`. +2. Broker checks capability + manifest version compatibility. +3. Input is validated (zod) and sanitized. +4. Core service executes the action using the full-privilege Prisma Client. +5. Result is returned; broker logs operation metadata (`pluginId`, `tenantId`, `latency`, `status`). + +### 4.4 Transport & Isolation + +- Broker APIs are in-process function calls by default for low latency. +- Optionally expose HTTP endpoints if we later host plugins out-of-process (WASM, workers). +- Each plugin runs in a sandbox (worker thread/process) with only the broker proxy object, never a raw DB client. + +### 4.5 Observability & Governance + +- Emit structured events to the audit log on every broker call. +- Rate-limit and timeout broker calls per plugin to prevent abuse. +- Provide CLI tooling (`sv broker:capabilities`) to list available contracts and plugin registrations. + +## 5. Rollout Plan + +1. Define the broker interface and wire it into the extension host. +2. Implement the highest priority contracts (e.g., read user profile, create board entries). +3. Update one pilot plugin to consume the broker instead of direct DB access. +4. Expand coverage until all plugins rely exclusively on broker APIs. +5. Remove the database client from plugin sandboxes entirely. + +## 6. Security Impact + +- **Confidentiality:** Plugins can only request data via vetted contracts that redact sensitive fields. +- **Integrity:** Business rules and validations execute centrally, reducing tampering risk. +- **Availability:** Broker-level rate limits prevent a single plugin from overloading the DB. + +## 7. Risks & Mitigations + +- **Feature Lag:** Broker must keep up with plugin feature needs. Mitigation: publish a versioned roadmap and accept community proposals. +- **Increased Latency:** Extra hop may add milliseconds; keep broker in-process and cache results when safe. +- **Implementation Effort:** Requires building/maintaining APIs. Mitigation: start with the most sensitive operations and expand iteratively. + +## 8. Alternatives + +- Database-level controls only (RFC-0003/0004/0005) – good defense-in-depth but insufficient for business-rule enforcement. +- Full GraphQL/REST public API for plugins – heavier surface area and less controlled than a curated broker. + +## 9. Open Questions + +1. How do we deprecate broker contracts without breaking plugins? (Proposal: semantic versioning + compatibility shims.) +2. Should broker calls be fully asynchronous (message bus) to isolate failures? +3. Can we auto-generate broker clients from OpenAPI/TS interfaces for plugin authors? diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index ea4e560..7635238 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -6,6 +6,29 @@ export type PluginCapabilityValue = | "scoped" | "anonymized"; +export type PluginDatabaseMode = "shared" | "exclusive-sqlite" | "exclusive-postgres"; + +export type PluginDatabaseProvider = "sqlite" | "postgresql" | "mysql"; + +export interface PluginDatabaseLimits { + storageMb?: number; + connections?: number; +} + +export interface PluginDatabaseMigrations { + directory?: string; + entryPoint?: string; +} + +export interface PluginDatabaseConfig { + mode: PluginDatabaseMode; + provider?: PluginDatabaseProvider; + schema?: string; + dataDir?: string; + limits?: PluginDatabaseLimits; + migrations?: PluginDatabaseMigrations; +} + export type PluginRoleAssignment = | string | { @@ -26,6 +49,7 @@ export interface PluginSovereignMetadata { platform?: string; node?: string; }; + database?: PluginDatabaseConfig; routes?: { web?: string; api?: string; diff --git a/platform/src/ext-host/build-routes.js b/platform/src/ext-host/build-routes.js index f6ccd9b..33376e5 100644 --- a/platform/src/ext-host/build-routes.js +++ b/platform/src/ext-host/build-routes.js @@ -10,13 +10,14 @@ import * as pluginHandler from "$/handlers/plugin.js"; import { resolvePluginCapabilities } from "./capabilities.js"; import { createPluginCapabilityAPI } from "./plugin-auth.js"; -export async function buildPluginRoutes(app, manifest, config) { +export async function buildPluginRoutes(app, manifest, config, services = {}) { const { plugins } = manifest; const { NODE_ENV } = config; + const { pluginDatabaseManager } = services; const pluginContextCache = new Map(); - const ensurePluginContext = (plugin, namespace) => { + const ensurePluginContext = async (plugin, namespace) => { const cacheKey = namespace || plugin?.namespace || plugin?.id; if (cacheKey && pluginContextCache.has(cacheKey)) { return pluginContextCache.get(cacheKey); @@ -28,9 +29,10 @@ export async function buildPluginRoutes(app, manifest, config) { path, }; - const { context: capabilityContext, granted } = resolvePluginCapabilities(plugin, { + const { context: capabilityContext, granted } = await resolvePluginCapabilities(plugin, { config, logger, + services: { pluginDatabaseManager }, }); const capabilityAPI = createPluginCapabilityAPI({ @@ -80,7 +82,7 @@ export async function buildPluginRoutes(app, manifest, config) { let pluginContext; try { - pluginContext = ensurePluginContext(plugin, ns); + pluginContext = await ensurePluginContext(plugin, ns); } catch (err) { logger.error(`[plugins] ${ns}: capability resolution failed`, err); continue; diff --git a/platform/src/ext-host/capabilities.js b/platform/src/ext-host/capabilities.js index 89b3450..35fd97a 100644 --- a/platform/src/ext-host/capabilities.js +++ b/platform/src/ext-host/capabilities.js @@ -13,7 +13,27 @@ const capabilityRegistry = { provides: "prisma", description: "Read/write access to the primary database via Prisma client", risk: "critical", - resolve: () => prisma, + resolve: async ({ plugin, services = {} }) => { + const mode = plugin?.sovereign?.database?.mode || "shared"; + if (mode === "shared") { + return prisma; + } + + if (mode !== "exclusive-sqlite") { + throw new Error( + `Plugin database mode "${mode}" is not supported. Only "exclusive-sqlite" is currently available.` + ); + } + + const manager = services.pluginDatabaseManager; + if (!manager) { + throw new Error( + "Plugin database manager is required to resolve exclusive plugin databases, but none was provided." + ); + } + + return manager.acquirePrismaClient(plugin, { namespace: plugin?.namespace || plugin?.id }); + }, }, git: { key: "git", @@ -65,7 +85,10 @@ export function getCapabilityRegistry() { return capabilityRegistry; } -export function resolvePluginCapabilities(plugin = {}, { config = {}, logger } = {}) { +export async function resolvePluginCapabilities( + plugin = {}, + { config = {}, logger, services = {} } = {} +) { const namespace = plugin.namespace || plugin.id || ""; const requested = plugin?.sovereign?.platformCapabilities || plugin?.platformCapabilities || {}; const allowAll = !config.IS_PROD && DEV_ALLOW_ALL_CAPS; @@ -98,7 +121,7 @@ export function resolvePluginCapabilities(plugin = {}, { config = {}, logger } = const targetProp = capability.provides || key; if (!(targetProp in injected)) { - injected[targetProp] = capability.resolve({ plugin, config }); + injected[targetProp] = await capability.resolve({ plugin, config, services }); granted.push(key); } if (config.IS_PROD && capability.disabledInProd && overrideEnabled) { diff --git a/platform/src/ext-host/index.js b/platform/src/ext-host/index.js index d7b470c..f785165 100644 --- a/platform/src/ext-host/index.js +++ b/platform/src/ext-host/index.js @@ -1,9 +1,28 @@ -export default async function createExtHost(manifest) { - // TODO: Review usefulness of this file. +import path from "node:path"; + +import createPluginDatabaseManager from "$/services/plugin-database-manager.js"; + +export default async function createExtHost(manifest, options = {}) { + const dataDir = + manifest.__datadir || + options.dataDir || + path.resolve(options.cwd || manifest.__rootdir || process.cwd(), "data"); + + const pluginDatabaseManager = createPluginDatabaseManager({ + sqlite: { + baseDir: path.join(dataDir, "plugins"), + }, + sharedDatasourceUrl: + options.sharedPluginDatasourceUrl || + process.env.PLUGIN_DATABASE_URL || + process.env.DATABASE_URL, + }); + return { ...manifest, plugins: manifest.plugins, enabledPlugins: manifest.enabledPlugins, __assets: manifest.__assets, + __pluginDatabaseManager: pluginDatabaseManager, }; } diff --git a/platform/src/server.js b/platform/src/server.js index 76f2875..79e6d47 100644 --- a/platform/src/server.js +++ b/platform/src/server.js @@ -199,7 +199,9 @@ export default async function createServer(manifest) { // Project Routes app.use("/api/projects", apiProjects); - await buildPluginRoutes(app, manifest, config); + await buildPluginRoutes(app, manifest, config, { + pluginDatabaseManager: manifest.__pluginDatabaseManager, + }); app.use((req, res) => { if (req.path.startsWith("/api/")) return res.status(404).json({ error: "Not found" }); diff --git a/platform/src/services/plugin-database-manager.js b/platform/src/services/plugin-database-manager.js new file mode 100644 index 0000000..363c2ef --- /dev/null +++ b/platform/src/services/plugin-database-manager.js @@ -0,0 +1,150 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { PrismaClient } from "@prisma/client"; + +import logger from "./logger.js"; + +const DEFAULT_SHARED_PROVIDER = process.env.PLUGIN_DATABASE_PROVIDER || "sqlite"; +const PRISMA_LOG_LEVEL = process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"]; + +const PLUGIN_ID_SAFE_CHARS = /[^a-zA-Z0-9-_]+/g; + +const sanitizePluginId = (pluginId = "") => + pluginId.replace(PLUGIN_ID_SAFE_CHARS, "-").replace(/^-+/, "").replace(/-+$/, "") || "plugin"; + +const ensureFile = async (filePath) => { + try { + await fs.access(filePath); + } catch { + await fs.writeFile(filePath, ""); + } +}; + +class SQLiteFileProvider { + constructor(options = {}) { + this.baseDir = options.baseDir || path.resolve(process.cwd(), "data/plugins"); + } + + async provision({ manifest, config = {}, context = {} }) { + if (!manifest?.id) { + throw new Error("Cannot provision SQLite database: manifest.id missing."); + } + + const dir = config.dataDir + ? path.isAbsolute(config.dataDir) + ? config.dataDir + : path.join(this.baseDir, config.dataDir) + : this.baseDir; + + const fileName = `${sanitizePluginId(manifest.id)}.db`; + const filePath = path.join(dir, fileName); + + await fs.mkdir(dir, { recursive: true }); + await ensureFile(filePath); + + logger.info( + { pluginId: manifest.id, filePath, ...context }, + "Provisioned SQLite database for plugin." + ); + + return { + mode: "exclusive-sqlite", + provider: "sqlite", + url: `file:${filePath}`, + path: filePath, + }; + } +} + +export class PluginDatabaseManager { + constructor(options = {}) { + this.sharedDatasourceUrl = + options.sharedDatasourceUrl || + process.env.PLUGIN_DATABASE_URL || + process.env.DATABASE_URL || + null; + + this.sharedProvider = options.sharedProvider || DEFAULT_SHARED_PROVIDER; + this.providers = new Map(); + this.datasourceCache = new Map(); + this.prismaCache = new Map(); + + const sqliteBaseDir = + options.sqlite?.baseDir || path.resolve(options.cwd || process.cwd(), "data/plugins"); + this.providers.set("exclusive-sqlite", new SQLiteFileProvider({ baseDir: sqliteBaseDir })); + } + + sharedDescriptor() { + if (!this.sharedDatasourceUrl) { + throw new Error( + "Shared plugin datasource URL is not configured. Set PLUGIN_DATABASE_URL or DATABASE_URL." + ); + } + + return { + mode: "shared", + provider: this.sharedProvider, + url: this.sharedDatasourceUrl, + }; + } + + #cacheKey(manifest, context = {}) { + return manifest?.id || context.namespace || context.pluginId || "plugin"; + } + + async resolveDatasource(manifest, context = {}) { + const config = manifest?.sovereign?.database || { mode: "shared" }; + const mode = config.mode || "shared"; + + if (mode === "shared") { + return this.sharedDescriptor(); + } + + if (mode !== "exclusive-sqlite") { + throw new Error( + `Plugin database mode "${mode}" is not supported yet. Only "exclusive-sqlite" is available.` + ); + } + + const provider = this.providers.get(mode); + if (!provider) { + throw new Error(`No provider available for plugin database mode "${mode}".`); + } + + const cacheKey = `${mode}:${this.#cacheKey(manifest, context)}`; + if (this.datasourceCache.has(cacheKey)) { + return this.datasourceCache.get(cacheKey); + } + + const descriptor = await provider.provision({ manifest, config, context }); + this.datasourceCache.set(cacheKey, descriptor); + return descriptor; + } + + async acquirePrismaClient(manifest, context = {}) { + const cacheKey = this.#cacheKey(manifest, context); + if (this.prismaCache.has(cacheKey)) { + return this.prismaCache.get(cacheKey); + } + + const descriptor = await this.resolveDatasource(manifest, context); + if (descriptor.mode === "shared") { + throw new Error( + "acquirePrismaClient is intended for exclusive plugin databases. Shared mode should use the core Prisma client." + ); + } + + const client = new PrismaClient({ + datasources: { db: { url: descriptor.url } }, + log: PRISMA_LOG_LEVEL, + }); + + this.prismaCache.set(cacheKey, client); + return client; + } +} + +export default function createPluginDatabaseManager(options) { + return new PluginDatabaseManager(options); +} diff --git a/plugins/papertrail/src/App.jsx b/plugins/papertrail/src/App.jsx index 7aae12f..27c501f 100644 --- a/plugins/papertrail/src/App.jsx +++ b/plugins/papertrail/src/App.jsx @@ -20,7 +20,7 @@ function App() {

- Edit src/App.jsx and save to test HMR + Edit src/App.jsx and save to test HMR

Click on the Vite and React logos to learn more

diff --git a/tests/ext-host-capabilities.test.mjs b/tests/ext-host-capabilities.test.mjs index c869676..3bf3291 100644 --- a/tests/ext-host-capabilities.test.mjs +++ b/tests/ext-host-capabilities.test.mjs @@ -27,11 +27,13 @@ test("throws when plugin requests unknown capability", async () => { resetDevFlag("false"); const { resolvePluginCapabilities } = await freshModule(); - assert.throws(() => - resolvePluginCapabilities( - { namespace: "blog", sovereign: { platformCapabilities: { imaginary: true } } }, - { config: { IS_PROD: false }, logger: console } - ) + await assert.rejects( + () => + resolvePluginCapabilities( + { namespace: "blog", sovereign: { platformCapabilities: { imaginary: true } } }, + { config: { IS_PROD: false }, logger: console } + ), + /Unknown platform capability/ ); }); @@ -39,11 +41,13 @@ test("denies prod-disabled capability", async () => { resetDevFlag("false"); const { resolvePluginCapabilities } = await freshModule(); - assert.throws(() => - resolvePluginCapabilities( - { namespace: "blog", sovereign: { platformCapabilities: { fileUpload: true } } }, - { config: { IS_PROD: true }, logger: console } - ) + await assert.rejects( + () => + resolvePluginCapabilities( + { namespace: "blog", sovereign: { platformCapabilities: { fileUpload: true } } }, + { config: { IS_PROD: true }, logger: console } + ), + /disabled in production/ ); }); @@ -51,7 +55,7 @@ test("DEV_ALLOW_ALL_CAPS grants all capabilities in dev", async () => { resetDevFlag("true"); const { resolvePluginCapabilities, getCapabilityRegistry } = await freshModule(); - const { context, granted } = resolvePluginCapabilities( + const { context, granted } = await resolvePluginCapabilities( { namespace: "blog", sovereign: { platformCapabilities: {} } }, { config: { IS_PROD: false }, logger: { warn() {}, info() {} } } ); @@ -72,10 +76,55 @@ test("resolvePluginCapabilities injects declared host services", async () => { }, }, }; - const { context, granted } = resolvePluginCapabilities(plugin, { + const { context, granted } = await resolvePluginCapabilities(plugin, { config: { IS_PROD: false }, logger: console, }); assert.ok(context.uuid, "uuid helper injected"); assert.deepEqual(granted, ["uuid"]); }); + +test("database capability uses plugin database manager for exclusive sqlite mode", async () => { + resetDevFlag("false"); + const { resolvePluginCapabilities } = await freshModule(); + const fakeClient = { marker: "plugin-prisma" }; + const pluginDatabaseManager = { + acquirePrismaClient: async () => fakeClient, + }; + const plugin = { + namespace: "papertrail", + sovereign: { + platformCapabilities: { database: true }, + database: { mode: "exclusive-sqlite" }, + }, + }; + + const { context } = await resolvePluginCapabilities(plugin, { + config: { IS_PROD: false }, + logger: console, + services: { pluginDatabaseManager }, + }); + + assert.equal(context.prisma, fakeClient); +}); + +test("throws if exclusive database requested without manager", async () => { + resetDevFlag("false"); + const { resolvePluginCapabilities } = await freshModule(); + const plugin = { + namespace: "papertrail", + sovereign: { + platformCapabilities: { database: true }, + database: { mode: "exclusive-sqlite" }, + }, + }; + + await assert.rejects( + () => + resolvePluginCapabilities(plugin, { + config: { IS_PROD: false }, + logger: console, + }), + /Plugin database manager is required/ + ); +}); diff --git a/tools/build-manifest.mjs b/tools/build-manifest.mjs index e93f825..ab33bf4 100644 --- a/tools/build-manifest.mjs +++ b/tools/build-manifest.mjs @@ -110,6 +110,39 @@ const pluginManifestSchema = { node: { type: "string" }, }, }, + database: { + type: "object", + required: ["mode"], + additionalProperties: false, + properties: { + mode: { + type: "string", + enum: ["shared", "exclusive-sqlite", "exclusive-postgres"], + }, + provider: { + type: "string", + enum: ["sqlite", "postgresql", "mysql"], + }, + schema: { type: "string", minLength: 1 }, + dataDir: { type: "string", minLength: 1 }, + limits: { + type: "object", + additionalProperties: false, + properties: { + storageMb: { type: "number", minimum: 1 }, + connections: { type: "number", minimum: 1 }, + }, + }, + migrations: { + type: "object", + additionalProperties: false, + properties: { + directory: { type: "string", minLength: 1 }, + entryPoint: { type: "string", minLength: 1 }, + }, + }, + }, + }, routes: { type: "object", additionalProperties: false, diff --git a/tools/plugin-templates/custom/plugin.json b/tools/plugin-templates/custom/plugin.json index dec0e53..cb62964 100644 --- a/tools/plugin-templates/custom/plugin.json +++ b/tools/plugin-templates/custom/plugin.json @@ -17,6 +17,7 @@ "schemaVersion": 1, "allowMultipleInstances": true, "compat": { "platform": "^0.8.0", "node": ">=20" }, + "database": {{PLUGIN_DATABASE_BLOCK}}, "platformCapabilities": { "database": false, "git": false, diff --git a/tools/plugin-templates/spa/plugin.json b/tools/plugin-templates/spa/plugin.json index 592c5f7..5c66c78 100644 --- a/tools/plugin-templates/spa/plugin.json +++ b/tools/plugin-templates/spa/plugin.json @@ -17,6 +17,7 @@ "schemaVersion": 1, "allowMultipleInstances": true, "compat": { "platform": "^0.7.3", "node": ">=20" }, + "database": {{PLUGIN_DATABASE_BLOCK}}, "platformCapabilities": { "database": true },