From 5d979d6519462a01963fccdd90449d61b6676994 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 00:49:41 +0000 Subject: [PATCH 1/3] docs(adr): add ADR-0025 plugin package distribution (code + deps) Design the publish and install flow for code plugins (vs. the existing metadata-only JSON package flow): - .osplugin signed artifact format + compiled objectstack.plugin.json manifest (adds engines/runtime/permissions/integrity blocks) - build -> sign -> publish pipeline extending os package publish, storing both browsable JSON metadata and the binary artifact blob - permissioned install flow: resolve -> compat -> consent -> verify -> materialize -> register -> load - trust/isolation tiers (in-process / QuickJS sandbox / out-of-process) reusing the existing loader, permission enforcer, and sandbox - bundled-default packaging (no npm at install time); manifest-deps opt-in - unifies packages and plugins: a pure-element package is the degenerate plugin (empty dist, no permissions) https://claude.ai/code/session_01DRww7tXjCVdqXsHtv5gDq6 --- docs/adr/0025-plugin-package-distribution.md | 358 +++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 docs/adr/0025-plugin-package-distribution.md diff --git a/docs/adr/0025-plugin-package-distribution.md b/docs/adr/0025-plugin-package-distribution.md new file mode 100644 index 000000000..92060dba3 --- /dev/null +++ b/docs/adr/0025-plugin-package-distribution.md @@ -0,0 +1,358 @@ +# ADR-0025: Plugin Package Distribution (Code + Dependencies) + +**Status**: Proposed +**Deciders**: ObjectStack Protocol Architects +**Builds on**: [ADR-0003](./0003-package-as-first-class-citizen.md) (package + versioned releases), [ADR-0004](./0004-cloud-multi-kernel.md) (cloud multi-kernel), [ADR-0010](./0010-metadata-protection-model.md) (L1/L2/L3 protection), [ADR-0016](./0016-studio-package-authoring-and-publish.md) (package authoring & publish, local export/import) +**Consumers**: `@objectstack/core` (kernel, plugin-loader, security), `@objectstack/runtime` (sandbox, marketplace install), `@objectstack/cli`, `@objectstack/spec/system` (ObjectStackManifest), `@objectstack/spec/cloud`, `../objectui` (Studio) + +--- + +## 0. Context + +ObjectStack already ships a clean, low-risk distribution path for +**pure-element packages** (metadata-only): a package is authored visually in +Studio, bound to a `package_id`, compiled/exported to a single self-contained +JSON manifest, and installed by **hot-registering** that JSON into the running +engine (`engine.registerApp`). The publish/install loop is trivial precisely +because the artifact is *declarative data* — no code runs, so there is nothing +to build, no dependencies to resolve, and no trust boundary to cross. + +- Publish: `os package publish` → `POST /cloud/packages` → `POST + /cloud/packages/:id/versions` (snapshots `dist/objectstack.json` into + `sys_package_version.manifest_json`). See + `packages/cli/src/commands/package/publish.ts`. +- Install (cloud): browse via `MarketplaceProxyPlugin`, install on cloud. +- Install (local): `POST /api/v1/marketplace/install-local` with an inline + manifest, hot-registered into the kernel and cached on disk. See + `packages/runtime/src/cloud/marketplace-install-local-plugin.ts` and + ADR-0016 §9. + +The next step is **plugins**: distributable units that contain **executable +code and npm dependencies**, not just metadata. The repository already has the +*authoring* half of this story: + +- `packages/plugins/*` are real npm packages — each has a `package.json` (deps, + `tsup` build → `dist/`), an `objectstack.config.ts` exporting an + `ObjectStackManifest` (`id`, `type`, `capabilities` = + implements/provides/requires/extensionPoints/contributes, `configuration` + schema), and a `src/index.ts` with a lifecycle entry point. +- The microkernel can already *load* code plugins: `packages/core/src/ + plugin-loader.ts` (dependency ordering, health checks, `signature` field, + `startupTimeout`, `hotReloadable`), `packages/core/src/types.ts` (`Plugin` + with `init/start/destroy` + `PluginContext`), + `packages/core/src/security/plugin-permission-enforcer.ts` + (capability-based service/hook/file/network enforcement), + `PluginConfigValidator`, and `packages/runtime/src/sandbox/quickjs-runner.ts` + (a QuickJS-WASM sandbox that wires only capability-gated `ctx.api/crypto/log` + into untrusted code). + +**What is missing is the distribution layer**: there is no artifact format for +code plugins, no build→sign→publish pipeline, and no permissioned install flow. +A code plugin cannot be a JSON blob — it must be built, its dependencies must +be resolved, and because it executes inside (or alongside) the host process it +introduces a **trust boundary** the JSON flow never had. + +## 1. Goals & Non-goals + +### Goals + +- A distributable **plugin artifact** (`.osplugin`) for code + dependencies, + built deterministically from a plugin source package. +- A **build → sign → publish** pipeline that extends `os package publish` + rather than forking a parallel system. +- A **permissioned install flow**: compatibility check → explicit permission + consent → verified download → materialize → register → load. +- A **trust/isolation tiering** that decides *how* plugin code is loaded + (in-process, sandboxed, out-of-process), reusing the existing loader, + permission enforcer, and QuickJS sandbox. +- **Local-first parity** (ADR-0016 §9): install a local `.osplugin` with no + cloud account, mirroring `marketplace/install-local`. +- **Unification**: a pure-element package is the degenerate case of a plugin + (empty `dist`, no permissions); both go through one install superset. + +### Non-goals + +- Marketplace monetization / billing (separate `service-marketplace`). +- A from-scratch package registry: we reuse the `sys_*` schema family and add + blob storage for the artifact. +- Full out-of-process isolation (Tier 2) in v1 — the design reserves the seam + but the first slice targets Tier 0/Tier 1. +- Replacing the metadata-only JSON flow — it remains the fast path and becomes + a special case. + +## 2. Decision + +Introduce a **signed `.osplugin` artifact**, a **build/sign/publish pipeline** +that stores both browsable JSON metadata *and* the binary artifact, and a +**permissioned install flow** governed by trust tiers. Default packaging is +**bundled** (dependencies pre-bundled into `dist`) so that, like the JSON flow, +install is "unpack + verify" with **no npm at install time**. + +## 3. Detailed design + +### 3.1 Artifact format — `.osplugin` + +A `.osplugin` is a `tar.gz`: + +``` +-.osplugin +├── objectstack.plugin.json # compiled manifest (see §3.2) +├── dist/ # pre-built ESM bundle (tsup output) +├── package.json # runtime deps — only for the manifest-deps strategy +├── pnpm-lock.yaml # locked deps — only for the manifest-deps strategy +├── assets/ README LICENSE icon +└── SIGNATURE # detached signature + publisher cert chain +``` + +### 3.2 Compiled manifest (`objectstack.plugin.json`) + +Extends the existing `ObjectStackManifest` with three new blocks +(`engines`, `runtime`, `permissions`, `integrity`): + +```jsonc +{ + "id": "com.acme.crm-enrich", + "version": "1.2.0", + "type": "plugin", + "engines": { "platform": ">=4.0 <5", "protocol": ">=1.0" }, + "runtime": "node", // node | sandbox | worker (see §3.6) + "packaging": "bundled", // bundled | manifest-deps (see §3.3) + "permissions": { // explicitly requested capabilities + "services": ["object", "http"], + "hooks": ["record.beforeInsert"], + "network": ["api.acme.com"], + "fs": [] + }, + "integrity": { "dist/index.mjs": "sha256-..." }, // per-file hashes + "configuration": { /* existing config schema (PluginConfigValidator) */ }, + "capabilities": { /* existing implements/provides/requires/contributes */ }, + "contributes": { /* OPTIONAL declarative metadata: objects/views/flows/... */ } +} +``` + +`permissions` is the one new protocol surface the runtime must understand at +install/load time; everything else is additive. `permissions` maps 1:1 onto the +checks the existing `PluginPermissionEnforcer` already implements +(`canAccessService`, `canTriggerHook`, `canReadFile/Write`, `canNetworkRequest`). + +### 3.3 Packaging strategies (default: bundled) + +- **Bundled (default, recommended).** `tsup` with `noExternal` bundles all JS + dependencies into `dist`. Native (`.node`) addons are disallowed. Install = + unpack + verify; **no npm/toolchain at install time**. This is the closest + analog to the JSON flow and keeps the security surface minimal. +- **manifest-deps (opt-in).** Ships `package.json` + `pnpm-lock.yaml`; install + runs `pnpm install --frozen-lockfile --ignore-scripts` in an isolated dir. + Only for plugins that genuinely need native addons or very large deps; slower + and requires a toolchain/network at install. Disallowed for unverified + publishers (§3.7). + +### 3.4 Build → sign → publish pipeline + +``` +os plugin build → os plugin sign → os plugin publish +``` + +1. **build** — run `tsup`; validate the manifest (Zod `ObjectStackManifest` + + new `permissions` schema); check `engines.platform/protocol`; compute + per-file `integrity` hashes; emit `-.osplugin`. +2. **sign** — sign the artifact with the publisher key (reuses the + `PluginMetadata.signature` field). May be done server-side at publish. +3. **publish** — extends `os package publish`: + - `POST /cloud/plugins` → ensure a `sys_plugin` row (reverse-domain `id`, + `owner_org_id`). + - `POST /cloud/plugins/:id/versions` → upload the `.osplugin` blob to object + storage; store metadata (manifest, `permissions`, `integrity`, signature, + `engines`, size, SBOM) in `sys_plugin_version` with + `status: pending_review → published`. + - **Server-side gates**: manifest schema validation; secret/malware/known-vuln + dependency scan; **permission audit** (sensitive permissions trigger human + review); marketplace **counter-signs** with the platform key on approval. + - `marketplace_listed` flag reuses ADR-0016's catalog model so plugins appear + in the same marketplace as packages, with a "contains code" badge and a + permission-disclosure screen. + +The registry therefore stores **both** the browsable JSON metadata (unified +marketplace UX with packages) **and** the binary artifact blob. + +### 3.5 Install flow + +Install is a deliberate, **permissioned** action — the key difference from the +JSON flow: + +``` +resolve → compat check → permission consent → download+verify → materialize → register → load +``` + +1. **Resolve.** Ask the registry for `id@version` (or latest matching the engine + range); receive manifest + download URL + signature + declared permissions + + dependency closure. +2. **Compatibility.** Verify `engines.platform/protocol` against the host; + resolve plugin→plugin `requires` deps and topologically order them (reuse the + loader's dependency ordering); fetch deps too. +3. **Permission consent (new).** Present the declared `permissions` (services / + hooks / fs / network / extension points) to the admin. The **granted set** is + persisted and later enforced by `PluginPermissionEnforcer`. +4. **Download + verify.** Download the `.osplugin`; verify the signature against + trusted publisher + marketplace keys; verify each file against `integrity`. + Reject on any mismatch. +5. **Materialize.** Unpack to a per-environment, per-plugin dir + `/plugins////`. For manifest-deps, run + `pnpm install --frozen-lockfile --ignore-scripts` here. +6. **Register.** Write `sys_plugin_installation` (`env_id`, `plugin_id`, + `version_id`, `granted_permissions`, `status`, `is_preview`) — parallel to + `sys_package_installation`. Persist to a local store so installs survive + restarts (reuse the ADR-0016 §9.7 `package-state-store.ts` pattern). +7. **Load + activate.** On next boot (or hot, when `hotReloadable`), the loader + scans installed plugins, dynamic-imports the entry, wraps `PluginContext` + with the enforcer scoped to the granted set, and runs `init → start` in + dependency order. If the plugin also has `contributes` metadata, that JSON is + hot-registered exactly like a pure-element package. + +**Local-first parity.** `os plugin install ./foo.osplugin` and a Studio "Install +plugin" upload both hit a `marketplace/install-local`-style endpoint (inline +artifact, register-before-persist per ADR-0016 §9.3). Local artifacts need no +cloud account; signature verification is still performed (publisher key or an +explicit `--trust-unsigned` dev override). + +### 3.6 Trust / isolation tiers + +Because plugins execute code, the manifest's `runtime` field selects *how* the +code is loaded: + +| Tier | `runtime` | How loaded | For | Status | +|---|---|---|---|---| +| **T0 Trusted** | `node` | In-process dynamic `import`; full `PluginContext` gated by declared capabilities | First-party / org-signed / verified plugins | loader exists | +| **T1 Sandboxed** | `sandbox` | QuickJS-WASM (`quickjs-runner.ts`); no Node API; only capability-gated `ctx.api/crypto/log` | Pure-logic plugins (hooks, formulas, transforms) | sandbox exists; extend to "script plugins" | +| **T2 Out-of-process** | `worker` | Worker thread / child process / WASM component + RPC bridge; crash/timeout/memory isolation | Untrusted 3rd-party needing richer APIs | reserved (future) | + +The marketplace may **force a lower-privilege tier** for unverified publishers +(e.g. only `sandbox`/`worker`). + +### 3.7 Security model (reuses existing components) + +- **Signing.** `PluginMetadata.signature` exists. Publisher signs; marketplace + counter-signs on approval. Host ships trusted root keys; verify the chain at + install (§3.5 step 4) **and** at load (§3.5 step 7). +- **Permissions.** New manifest `permissions` block → install-time consent → + granted set → `PluginPermissionEnforcer` (service/hook/file/network already + enforced). Principle of least privilege; all denials logged (existing + behavior). +- **Config.** `PluginConfigValidator` validates plugin config against the + `configuration` schema. +- **Supply chain.** Lockfile + per-file `integrity`; server-side scan for + secrets and known-vuln deps; SBOM stored on the version row; **always** + `--ignore-scripts` (no `postinstall`). +- **Reversible disable.** Reuse the package enable/disable + disable-state-store + (ADR-0016 §9.5/§9.7): disabling a plugin stops loading its code and unregisters + its contributions on next boot. + +### 3.8 Versioning & lifecycle + +Reuse the `sys_*` shape: + +| Concern | Artifact | +|---|---| +| Plugin identity | `sys_plugin` (reverse-domain id, owner org, `marketplace_listed`) | +| Immutable version | `sys_plugin_version` (semver, checksum, signature, `permissions`, `engines`, SBOM, blob pointer, `status`) | +| Install state | `sys_plugin_installation` (`env_id`, `version_id`, `granted_permissions`, `is_preview`, `status`) | + +`engines` ranges gate compatibility; support deprecate/yank. **Update** = install +the new version side-by-side, swap the installation pointer; if the new version +**widens permissions**, require re-consent before activation. + +### 3.9 Composition with metadata packages (unification) + +A plugin may also ship declarative metadata via `contributes`. Install is then a +**superset**: hot-register the JSON *and* load the code + grant permissions. A +pure-element package is the degenerate plugin — `dist` empty, `permissions` +empty — so it falls straight back onto today's simple JSON path. One install +pipeline, two ends of a spectrum. + +## 4. Phasing + +- **Phase 1 — Artifact & build.** Define `.osplugin` + `objectstack.plugin.json` + (`permissions`/`engines`/`runtime` Zod schema in `@objectstack/spec/system`); + `os plugin build` (bundled strategy only); per-file integrity. +- **Phase 2 — Local install.** `os plugin install ./x.osplugin` + Studio upload + → `marketplace/install-local`-style endpoint; materialize; `sys_plugin_*` + rows + disable-state persistence; T0 in-process load with permission consent. +- **Phase 3 — Signing & registry.** Publisher signing + verification; `POST + /cloud/plugins` + `/versions` (blob storage); marketplace listing + permission + disclosure UI; server-side scans + counter-signing. +- **Phase 4 — Sandbox tier.** T1 `sandbox` runtime for script plugins via + QuickJS; marketplace tier enforcement for unverified publishers. +- **Phase 5 — manifest-deps & T2.** Opt-in `manifest-deps` packaging + (`pnpm install --ignore-scripts`); reserve and prototype out-of-process T2. + +## 5. Consequences + +### Positive + +- Closes the distribution gap for code plugins while **reusing** the kernel + loader, permission enforcer, QuickJS sandbox, and `sys_*` schema family. +- Bundled-default keeps install nearly as simple as the JSON flow (unpack + + verify, no npm). +- Explicit permission consent makes the new trust boundary visible and + auditable rather than implicit. +- Unifies packages and plugins under one marketplace and one install superset. + +### Negative + +- Introduces a binary artifact + blob storage + signing/key management the + JSON-only flow never needed. +- Permission consent + tiering add UX and lifecycle surface (re-consent on + permission widening, tier enforcement). +- Bundled packaging excludes native addons by default (mitigated by the opt-in + manifest-deps strategy in Phase 5). + +### Neutral + +- The metadata-only JSON flow is unchanged and becomes the degenerate case. +- Local-first install mirrors ADR-0016 §9; cloud publish layers on top. + +## 6. Alternatives considered + +1. **Distribute plugins as plain npm packages installed via `npm i`.** Rejected: + no signing, no permission consent, no `engines`/protocol gating, arbitrary + `postinstall` scripts — the trust boundary is wide open and install depends on + a registry + toolchain on every host. +2. **Ship raw source and build on the host at install.** Rejected: slow, + non-deterministic, requires a full toolchain per host, and complicates + integrity/signing. +3. **A second, parallel plugin registry separate from packages.** Rejected: + forks the marketplace UX and the `sys_*` schema; the superset/degenerate-case + model keeps one pipeline. +4. **Always sandbox (no T0 in-process tier).** Rejected for v1: first-party and + verified plugins need full host APIs and performance; tiering lets policy, not + architecture, decide the privilege level. + +## 7. Open questions + +- **Key management & rotation.** Where do publisher keys live, and how are root + keys rotated/revoked on the host? +- **Permission widening UX.** Auto-disable on update until re-consent, or run + with the old grant until an admin approves? +- **Hot vs boot activation.** Which plugin types are safe to hot-load + (`hotReloadable`) vs require a restart to fully (un)register contributions? +- **T2 RPC surface.** What is the minimal host-service RPC contract an + out-of-process plugin gets, and how does the enforcer mediate it? +- **Cross-plugin dependency conflicts.** How are diamond dependencies / version + conflicts in plugin `requires` resolved at install time? + +## 8. References + +- [ADR-0003](./0003-package-as-first-class-citizen.md) — Package + versioned releases +- [ADR-0004](./0004-cloud-multi-kernel.md) — Cloud multi-kernel +- [ADR-0010](./0010-metadata-protection-model.md) — L1/L2/L3 protection +- [ADR-0016](./0016-studio-package-authoring-and-publish.md) — Package authoring & publish; local export/import (§9) +- `packages/core/src/plugin-loader.ts` — plugin loading, lifecycle, health, signature +- `packages/core/src/types.ts` — `Plugin` (`init/start/destroy`) + `PluginContext` +- `packages/core/src/security/plugin-permission-enforcer.ts` — capability-based enforcement +- `packages/core/src/security/plugin-config-validator.ts` — config validation +- `packages/runtime/src/sandbox/quickjs-runner.ts` — QuickJS-WASM sandbox (T1) +- `packages/runtime/src/cloud/marketplace-install-local-plugin.ts` — local inline install (ADR-0016 §9) +- `packages/runtime/src/cloud/marketplace-proxy-plugin.ts` — marketplace browse proxy +- `packages/runtime/src/package-state-store.ts` — per-environment disable-state persistence +- `packages/cli/src/commands/package/publish.ts` — existing package publish pipeline +- `packages/plugins/driver-memory/objectstack.config.ts` — example plugin manifest (`ObjectStackManifest`) From 16361672508b6ba50c2ae868b99f6ecd7ebf33f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 01:05:50 +0000 Subject: [PATCH 2/3] docs(adr): refine ADR-0025 trust/deps/perms; add ADR-0026 UI plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0025 §3.10 scenario-review refinements: - default-deny trust: T0 in-process reserved for verified publishers, third-party defaults to sandbox/worker, enforced at publish - externalize @objectstack/* as host-provided peer-dep singletons (bundling forks the engine) - protocol-first compatibility gating over platform semver - plugin permissions compose with object/field-level RLS - secrets via settings/KV store (ADR-0007), never in the artifact - connectors span declarative (OpenAPI/MCP) + code-plugin sub-paths - explicit three-layer model (L0 JSON / L1 sandboxed-script / L2 code) ADR-0026 (new): client-side UI plugin distribution - runtime:'ui' variant of .osplugin reusing the ADR-0025 backbone - two-tier loading: U0 in-app module (verified) / U1 iframe sandbox (third-party default) with postMessage RPC + per-plugin CSP - server-authoritative data (RLS-gated) so client grants can't bypass server permissions https://claude.ai/code/session_01DRww7tXjCVdqXsHtv5gDq6 --- docs/adr/0025-plugin-package-distribution.md | 104 +++++++- .../adr/0026-client-ui-plugin-distribution.md | 235 ++++++++++++++++++ 2 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 docs/adr/0026-client-ui-plugin-distribution.md diff --git a/docs/adr/0025-plugin-package-distribution.md b/docs/adr/0025-plugin-package-distribution.md index 92060dba3..14de18b26 100644 --- a/docs/adr/0025-plugin-package-distribution.md +++ b/docs/adr/0025-plugin-package-distribution.md @@ -4,6 +4,22 @@ **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0003](./0003-package-as-first-class-citizen.md) (package + versioned releases), [ADR-0004](./0004-cloud-multi-kernel.md) (cloud multi-kernel), [ADR-0010](./0010-metadata-protection-model.md) (L1/L2/L3 protection), [ADR-0016](./0016-studio-package-authoring-and-publish.md) (package authoring & publish, local export/import) **Consumers**: `@objectstack/core` (kernel, plugin-loader, security), `@objectstack/runtime` (sandbox, marketplace install), `@objectstack/cli`, `@objectstack/spec/system` (ObjectStackManifest), `@objectstack/spec/cloud`, `../objectui` (Studio) +**Related**: [ADR-0026](./0026-client-ui-plugin-distribution.md) (client-side UI plugins — out of scope here), [ADR-0007](./0007-settings-manifest-and-kv-store.md) (settings + secret store), [ADR-0022](./0022-connectors-vs-messaging-channels.md)/[ADR-0023](./0023-openapi-to-connector-generator.md)/[ADR-0024](./0024-mcp-connectors.md) (connectors) + +--- + +> **Revision note (2026-06-01) — read §3.10 first.** §§1–3.9 capture the original +> two-channel framing (declarative JSON package vs. `.osplugin` code plugin). +> A scenario review (what the marketplace actually needs to carry) surfaced six +> refinements, consolidated in **§3.10**. The headline corrections: (1) trust is +> **default-deny** — third-party plugins default to an isolated tier, in-process +> `node` is opt-in via verification; (2) `@objectstack/*` deps are **externalized +> as peer deps** (host-provided singletons), never bundled; (3) compatibility is +> gated **protocol-first**, not on platform semver; (4) plugin permissions compose +> with the **object/field-level RLS** layer; (5) secrets live in the **settings/KV +> store** (ADR-0007), never in the artifact; (6) connectors span a declarative +> (OpenAPI/MCP-generated) sub-path, not only hand-written code plugins. Client-side +> **UI** plugins are split out to ADR-0026. --- @@ -79,6 +95,12 @@ introduces a **trust boundary** the JSON flow never had. but the first slice targets Tier 0/Tier 1. - Replacing the metadata-only JSON flow — it remains the fast path and becomes a special case. +- **Client-side UI plugins** (custom field renderers, view types, widgets) — + these load in the browser and need a different module/sandbox model; see + ADR-0026. +- **Sandboxed-script packages** (validations, formulas, small automations) — these + ride the existing JSON flow + QuickJS bodies (the "L1" layer in §3.10) and need + neither the `.osplugin` pipeline nor an install-time permission prompt. ## 2. Decision @@ -147,6 +169,14 @@ checks the existing `PluginPermissionEnforcer` already implements and requires a toolchain/network at install. Disallowed for unverified publishers (§3.7). +**Externalize the host runtime (both strategies).** `@objectstack/*` packages +(`core`, `spec`, …) are declared as **peerDependencies** and marked `external` in +the bundle — the plugin binds to the **host's** copy at load time. Bundling them +would create a second engine/Zod instance inside the plugin (duplicate registries, +broken `instanceof`, double validation). Rule: **bundle third-party libs, +externalize `@objectstack/*` (host-provided singletons)**. Version compatibility +of these peers is gated by §3.8. + ### 3.4 Build → sign → publish pipeline ``` @@ -226,8 +256,13 @@ code is loaded: | **T1 Sandboxed** | `sandbox` | QuickJS-WASM (`quickjs-runner.ts`); no Node API; only capability-gated `ctx.api/crypto/log` | Pure-logic plugins (hooks, formulas, transforms) | sandbox exists; extend to "script plugins" | | **T2 Out-of-process** | `worker` | Worker thread / child process / WASM component + RPC bridge; crash/timeout/memory isolation | Untrusted 3rd-party needing richer APIs | reserved (future) | -The marketplace may **force a lower-privilege tier** for unverified publishers -(e.g. only `sandbox`/`worker`). +**Trust is default-deny.** Tier 0 (`node`, in-process) means *arbitrary code in +the host process* — the platform's single biggest risk surface. It is **reserved +for first-party and verified/enterprise-signed publishers**. Community / unverified +third-party plugins **default to T1 (`sandbox`) or T2 (`worker`)**; `node` is an +opt-in privilege unlocked only after publisher verification. The marketplace +enforces this at publish time (an unverified publisher cannot ship `runtime: +'node'`), not merely "may force" it at install. ### 3.7 Security model (reuses existing components) @@ -257,9 +292,14 @@ Reuse the `sys_*` shape: | Immutable version | `sys_plugin_version` (semver, checksum, signature, `permissions`, `engines`, SBOM, blob pointer, `status`) | | Install state | `sys_plugin_installation` (`env_id`, `version_id`, `granted_permissions`, `is_preview`, `status`) | -`engines` ranges gate compatibility; support deprecate/yank. **Update** = install -the new version side-by-side, swap the installation pointer; if the new version -**widens permissions**, require re-consent before activation. +Compatibility is gated **protocol-first** (per §3.10 #3): the host checks that it +provides every `capabilities.implements[].protocol` at a compatible version, then +falls back to `engines.platform`/`engines.protocol` ranges as a secondary guard. +This is more stable than platform semver and reuses the existing capability +declaration. Support deprecate/yank. **Update** = install the new version +side-by-side, swap the installation pointer; if the new version **widens +permissions** (services, hooks, network hosts, or RLS scopes — §3.10 #4), require +re-consent before activation. ### 3.9 Composition with metadata packages (unification) @@ -269,6 +309,56 @@ pure-element package is the degenerate plugin — `dist` empty, `permissions` empty — so it falls straight back onto today's simple JSON path. One install pipeline, two ends of a spectrum. +### 3.10 Refinements from scenario review (2026-06-01) + +A pass over *what the marketplace must actually carry* (vertical apps, templates, +drivers, connectors, AI extensions, auth, automation, governance, UI widgets) +yielded a sharper model and six corrections. + +**Three artifact layers, not two.** The metadata/code split is really a spectrum: + +| Layer | Form | Install trust decision | Channel | +|---|---|---|---| +| **L0 declarative** | objects/views/flows/dashboards/permissions/themes/translations JSON | none (it is data) | existing JSON hot-register | +| **L1 declarative + sandboxed script** | JSON carrying L1 expression / L2 `ScriptBody` (validations, formulas, small automations) | "scripts allowed" only; runs in the existing QuickJS sandbox | JSON flow — **no `.osplugin`** | +| **L2 code plugin** | real npm deps + host APIs (drivers, connectors, AI, auth, …) | explicit permission consent + signature | `.osplugin` (this ADR) | + +Recognizing **L1** explicitly means most "custom logic" (the bulk of what users +think needs code) rides the simple JSON flow + sandbox and never touches the +heavy code-plugin pipeline. This *strengthens* the §3.9 superset/spectrum claim. + +**The six corrections:** + +1. **Default-deny trust (see §3.6).** T0 in-process is reserved for first-party / + verified publishers; third-party defaults to T1/T2; enforced at publish. +2. **Externalize `@objectstack/*` (see §3.3).** Host runtime is a peer-dep + singleton; bundling it forks the engine. +3. **Protocol-first compatibility (see §3.8).** Gate primarily on the + `capabilities.implements[].protocol` version the host provides (e.g. + `storage.protocol.v1`), with `engines.platform` as a secondary guard — + protocols are stable across platform major versions, semver is not. +4. **Permissions compose with RLS.** The coarse service/hook/file/network gate is + necessary but not sufficient for connectors. Data-affecting permissions + (which objects/fields a plugin may read/write, which OAuth scopes, which + external hosts) must compose with the platform's object/field-level security + (`plugin-security` / RLS), not be a parallel coarse gate. The granted set in + `sys_plugin_installation` therefore references RLS scopes, not just service + names. +5. **Secrets never ship in the artifact.** Connectors need credentials + (API keys, OAuth tokens). The `.osplugin` must not carry secrets; the + `configuration` schema marks secret fields, and install wires them to the + platform settings / KV secret store (ADR-0007) via secret-refs. The plugin + reads them through `PluginContext`, gated by the network/service permission. +6. **Connectors span declarative + code.** With ADR-0023 (OpenAPI→connector) and + ADR-0024 (MCP connectors), any integration describable by OpenAPI/MCP is a + **declarative connector config** (closer to L0/L1); only integrations needing + custom auth, pagination, or streaming fall to an L2 code plugin. The connector + catalog is therefore not monolithically "code plugins." + +**Client-side UI plugins** (field renderers, view types, widgets) are a real +marketplace need this ADR does **not** cover — they load in the browser and need +a module-federation / iframe-sandbox model. Split to **ADR-0026**. + ## 4. Phasing - **Phase 1 — Artifact & build.** Define `.osplugin` + `objectstack.plugin.json` @@ -346,6 +436,10 @@ pipeline, two ends of a spectrum. - [ADR-0004](./0004-cloud-multi-kernel.md) — Cloud multi-kernel - [ADR-0010](./0010-metadata-protection-model.md) — L1/L2/L3 protection - [ADR-0016](./0016-studio-package-authoring-and-publish.md) — Package authoring & publish; local export/import (§9) +- [ADR-0007](./0007-settings-manifest-and-kv-store.md) — Settings manifest + KV/secret store (§3.10 #5) +- [ADR-0022](./0022-connectors-vs-messaging-channels.md) / [ADR-0023](./0023-openapi-to-connector-generator.md) / [ADR-0024](./0024-mcp-connectors.md) — Connectors: declarative generation vs. code plugins (§3.10 #6) +- [ADR-0026](./0026-client-ui-plugin-distribution.md) — Client-side UI plugin distribution (browser module/sandbox) +- `packages/plugins/plugin-security/` — RBAC/RLS; composes with plugin permissions (§3.10 #4) - `packages/core/src/plugin-loader.ts` — plugin loading, lifecycle, health, signature - `packages/core/src/types.ts` — `Plugin` (`init/start/destroy`) + `PluginContext` - `packages/core/src/security/plugin-permission-enforcer.ts` — capability-based enforcement diff --git a/docs/adr/0026-client-ui-plugin-distribution.md b/docs/adr/0026-client-ui-plugin-distribution.md new file mode 100644 index 000000000..81659f108 --- /dev/null +++ b/docs/adr/0026-client-ui-plugin-distribution.md @@ -0,0 +1,235 @@ +# ADR-0026: Client-Side UI Plugin Distribution + +**Status**: Proposed +**Deciders**: ObjectStack Protocol Architects +**Builds on**: [ADR-0025](./0025-plugin-package-distribution.md) (plugin package distribution — server/runtime plugins), [ADR-0003](./0003-package-as-first-class-citizen.md) (package + versioned releases), [ADR-0010](./0010-metadata-protection-model.md) (protection model) +**Consumers**: `@objectstack/client-react`, `@objectstack/console`, `../objectui` (Studio), `@objectstack/spec/system` (ObjectStackManifest), `@objectstack/spec/cloud` + +--- + +## 0. Context + +ADR-0025 designs distribution for **server/runtime** plugins (`.osplugin`, +`runtime: node|sandbox|worker`). A scenario review (ADR-0025 §3.10) surfaced a +category it explicitly does **not** cover: **client-side UI plugins** that run in +the browser inside the ObjectUI console SPA — + +- **Custom field renderers** (signature pad, rich geo/map, barcode/QR, color + picker, masked input, dependent-picklist UI). +- **Custom view types** (beyond the built-in grid/kanban/gantt/calendar — e.g. + org chart, timeline, map view, image gallery). +- **Custom widgets** (KPI/chart variants, embedded BI, custom list cards). +- **Page sections / layout blocks** and behavior-carrying themes. + +These cannot ride the server plugin pipeline as-is: they load in the browser, +render React against the host's component model, and must be sandboxed against a +*client* threat model (DOM access, token exfiltration, CSP), not a Node one. The +existing `ObjectStackManifest` already has the declarative seam — +`capabilities.extensionPoints` / `extensions` and `contributes` — and ObjectUI +already has a fixed catalog of view/widget types; what is missing is **how a +third party ships browser code that registers a new field/view/widget at +runtime, and how that code is isolated**. + +## 1. Goals & Non-goals + +### Goals + +- A **client UI plugin artifact** carrying a browser bundle that registers + field renderers / view types / widgets / page sections into the running + console via a stable contract. +- **Reuse ADR-0025's distribution backbone** — same `sys_plugin*` registry, + publish, signing, and install flow; UI plugins are a `runtime: 'ui'` variant of + the `.osplugin`, not a parallel system. +- A **two-tier loading model** mirroring ADR-0025's trust split: verified → + in-app module (shared singletons), third-party → **iframe-sandboxed**. +- **Server-authoritative data**: UI plugins read/write only through the client + SDK under the user's session, so all access stays **RLS-gated server-side** — + the client grant never bypasses server permissions. +- **Compatibility gating** on the ObjectUI protocol version + shared-singleton + (React/design-system) version ranges. + +### Non-goals + +- Server/runtime plugins — covered by ADR-0025. +- A new component framework — UI plugins target React + the host design system. +- Declarative-only UI (themes, translations, view *config*) — that stays an L0 + JSON package (ADR-0025 §3.10); this ADR is for UI that ships *code*. +- Native mobile widgets (separate track). + +## 2. Decision + +Ship client UI plugins as a **`runtime: 'ui'` variant of the `.osplugin`** +artifact (ADR-0025 §3.1) whose `dist` is a **browser ESM bundle**. The console +loads it at runtime into an **ObjectUI extension registry**, isolating it by trust +tier: **verified** plugins load as in-app modules sharing host singletons; +**third-party** plugins render inside a **sandboxed iframe** that talks to the host +over a constrained `postMessage` RPC bridge. Distribution (publish/sign/install) +is the ADR-0025 pipeline unchanged. + +## 3. Detailed design + +### 3.1 Artifact & manifest + +A UI plugin is an `.osplugin` with: + +```jsonc +{ + "id": "com.acme.signature-field", + "version": "1.0.0", + "type": "ui-plugin", + "runtime": "ui", // new tier (ADR-0025 §3.6) + "engines": { "objectui": ">=1.0 <2", "react": "^19" }, // protocol + shared singletons + "ui": { + "entry": "dist/plugin.mjs", // browser ESM, default-exports a register fn + "shared": ["react", "react-dom", "@objectstack/client-react"], // host-provided + "extends": [ // maps to ObjectStackManifest extensions + { "point": "fieldType", "name": "signature" }, + { "point": "viewType", "name": "map" }, + { "point": "widget", "name": "gauge" } + ] + }, + "permissions": { // client-scoped (ADR-0025 §3.10 #4) + "data": { "read": ["account"], "write": [] }, // via client SDK, RLS-gated server-side + "network": ["tiles.acme.com"], // becomes per-plugin CSP connect-src + "navigation": true + }, + "integrity": { "dist/plugin.mjs": "sha256-..." } +} +``` + +`extends` reuses the existing `capabilities.extensionPoints`/`extensions` seam; +`ui.shared` lists singletons the host injects (never bundled — same externalize +rule as ADR-0025 §3.3, applied to browser deps). + +### 3.2 Extension contract + +The entry default-exports a register function called with a typed host API: + +```ts +export default function register(host: UiPluginHost) { + host.registerFieldType('signature', { Display, Edit }); // React components + host.registerViewType('map', { View }); + host.registerWidget('gauge', { Widget, configSchema }); +} +``` + +Components receive **props only** (value, record context, field config, a scoped +`sdk` for data, a `theme` token set) — no direct access to host stores, router +internals, or other plugins. The contract versions with `engines.objectui`. + +### 3.3 Loading & isolation tiers + +Mirrors ADR-0025's trust split (default-deny): + +| Tier | For | Mechanism | Isolation | +|---|---|---|---| +| **U0 In-app module** | First-party / verified | Native ESM `import()` + import-map (or module federation) sharing `react`/`client-react`/design-system singletons; components mount directly in the host tree | Trust-based; runs with host privileges, gated by declared permissions | +| **U1 Iframe sandbox** | Third-party / unverified (**default**) | Plugin renders inside a `sandbox`ed `