diff --git a/docs/explorations/0194_[_]_EXTENSIBILITY_FABRIC_PLUGINS_LABS_AI_EDITOR.md b/docs/explorations/0194_[_]_EXTENSIBILITY_FABRIC_PLUGINS_LABS_AI_EDITOR.md index 85e6ac299..e1411850a 100644 --- a/docs/explorations/0194_[_]_EXTENSIBILITY_FABRIC_PLUGINS_LABS_AI_EDITOR.md +++ b/docs/explorations/0194_[_]_EXTENSIBILITY_FABRIC_PLUGINS_LABS_AI_EDITOR.md @@ -507,16 +507,22 @@ classDiagram ### Phase 1 — Unify the substrate -- [ ] Create `@xnetjs/trust` (zero-dep): `InstallProvenance`, `TrustTier`, - `SandboxKind`, `deriveTrustTier`, `requiresCapabilityReprompt`, - `sandboxForTier`, with tests. -- [ ] Re-export from `packages/labs/src/trust.ts` and +- [x] Create `@xnetjs/trust` (zero-dep MIT leaf): `InstallProvenance`, + `TrustTier`, `SandboxKind`, `deriveTrustTier`, `requiresCapabilityReprompt`, + `sandboxForTier`, with tests (`packages/trust/`). +- [x] Re-export from `packages/labs/src/trust.ts` and `packages/plugins/src/ecosystem/provenance-trust.ts`; delete the duplicated - bodies; keep the public APIs stable. + bodies; keep the public APIs stable. _As-built: `LabInstallSource`/ + `PluginTrustTier`/`InstallProvenance`/`SandboxKind` preserved as aliases of + the shared types; `LabTrustTier` (in `labs/runtime/types.ts`) aliased to the + shared `TrustTier`. labs (46) + plugins (452) suites unchanged & green._ - [ ] Add `packages/plugins/src/ecosystem/runtime.ts`: run user/marketplace-tier plugin code on the labs `RuntimeLadder`; first-party stays host-realm. + _(deferred — needs the benchmark below + a port to avoid the `plugins→labs` + cycle, since labs already depends on plugins.)_ - [ ] Benchmark plugin activation + a representative editor interaction against - 0184 budgets; gate the runtime switch on no regression. + 0184 budgets; gate the runtime switch on no regression. _(deferred with the + runtime switch above.)_ ### Phase 2 — AI drives the ecosystem @@ -552,9 +558,10 @@ classDiagram ## Validation Checklist -- [ ] `@xnetjs/trust` is the single source of trust logic; `labs` and `plugins` +- [x] `@xnetjs/trust` is the single source of trust logic; `labs` and `plugins` both consume it; the byte-identical mirror is gone; all existing trust tests - pass against the shared package. + pass against the shared package (trust 10, plugins 452, labs 46 — green; + typecheck/eslint/prettier/fallow clean). - [ ] A user-tier plugin and a user-tier Lab run on the **same** ladder rung (SES Worker); a marketplace one on the iframe rung; first-party stays host-realm; no plugin activation/editor-latency regression vs 0184 budgets. diff --git a/packages/labs/package.json b/packages/labs/package.json index 9b044dcf6..d29892606 100644 --- a/packages/labs/package.json +++ b/packages/labs/package.json @@ -25,6 +25,7 @@ "dependencies": { "@xnetjs/data": "workspace:*", "@xnetjs/plugins": "workspace:*", + "@xnetjs/trust": "workspace:*", "quickjs-emscripten": "^0.31.0", "ses": "^1.10.0" }, diff --git a/packages/labs/src/runtime/types.ts b/packages/labs/src/runtime/types.ts index 10b220378..050af4beb 100644 --- a/packages/labs/src/runtime/types.ts +++ b/packages/labs/src/runtime/types.ts @@ -19,9 +19,10 @@ export type LabRuntimeTier = 'sandbox' | 'app' | 'server' /** * Capability follows provenance, never self-declaration. Assigned by the host - * from where the Lab/extension came from — mirrors `WidgetTrustTier`. + * from where the Lab/extension came from — mirrors `WidgetTrustTier`. Aliased to + * the shared `@xnetjs/trust` `TrustTier` (0194) so labs and plugins agree. */ -export type LabTrustTier = 'first-party' | 'user' | 'marketplace' +export type { TrustTier as LabTrustTier } from '@xnetjs/trust' export type LabLogLevel = 'log' | 'info' | 'warn' | 'error' diff --git a/packages/labs/src/trust.ts b/packages/labs/src/trust.ts index 737fefe4e..66332f936 100644 --- a/packages/labs/src/trust.ts +++ b/packages/labs/src/trust.ts @@ -1,58 +1,19 @@ /** - * Trust derivation (exploration 0180). + * Trust derivation (explorations 0180, 0194). * * The load-bearing invariant of the whole extensibility story: a Lab/extension's * trust tier follows its PROVENANCE — where it came from — never anything the - * code declares about itself. A Lab you (or an AI you approved) authored locally - * installs at the `user` tier; something pulled from a marketplace installs at - * the `marketplace` tier (and gets the iframe). + * code declares about itself. When a Lab/extension node SYNCS to another device, + * the receiver must RE-DERIVE the tier from its own local install action — never + * trust a tier carried in the synced payload. {@link deriveTrustTier} is that + * single choke point. * - * Critically, when a Lab/extension node SYNCS to another device, the receiver - * must RE-DERIVE the tier from its own local install action — never trust a tier - * carried in the synced payload. {@link deriveTrustTier} is that single choke - * point. + * 0194 extracted this logic into the zero-dep `@xnetjs/trust` leaf (it was + * byte-for-byte duplicated in `@xnetjs/plugins`); this module re-exports it under + * the labs-local `LabInstallSource` name to preserve the labs public API. */ -import type { LabTrustTier } from './runtime/types' +export { deriveTrustTier, requiresCapabilityReprompt } from '@xnetjs/trust' -export type LabInstallSource = - /** Bundled with the app. */ - | 'builtin' - /** Authored in this workspace by the user. */ - | 'authored' - /** Generated by an AI agent and explicitly approved by the user. */ - | 'ai-generated' - /** Imported from a file/manifest the user pasted or opened. */ - | 'imported' - /** Installed from a public marketplace. */ - | 'marketplace' - /** Arrived via P2P sync from another device/peer. */ - | 'synced' - -/** - * Map an install source to a trust tier. Note that `synced` does NOT inherit - * any tier — it always lands at `user` and the host must re-confirm - * capabilities locally before activating (sync is not consent). - */ -export function deriveTrustTier(source: LabInstallSource): LabTrustTier { - switch (source) { - case 'builtin': - return 'first-party' - case 'authored': - case 'ai-generated': - case 'imported': - case 'synced': - return 'user' - case 'marketplace': - return 'marketplace' - } -} - -/** - * Whether installing from `source` must re-prompt the user for the extension's - * capabilities before activation. True for anything not authored on this device - * in this session — especially synced nodes. - */ -export function requiresCapabilityReprompt(source: LabInstallSource): boolean { - return source !== 'builtin' && source !== 'authored' -} +/** Where a Lab/extension came from — the only input to its trust tier. */ +export type { InstallProvenance as LabInstallSource } from '@xnetjs/trust' diff --git a/packages/plugins/package.json b/packages/plugins/package.json index 9131f7149..072b3e343 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -38,6 +38,7 @@ "dependencies": { "@xnetjs/abuse": "workspace:*", "@xnetjs/core": "workspace:*", + "@xnetjs/trust": "workspace:*", "@xnetjs/data": "workspace:*", "acorn": "^8.15.0" }, diff --git a/packages/plugins/src/ecosystem/provenance-trust.ts b/packages/plugins/src/ecosystem/provenance-trust.ts index 54e4e9d11..6d00d3ee0 100644 --- a/packages/plugins/src/ecosystem/provenance-trust.ts +++ b/packages/plugins/src/ecosystem/provenance-trust.ts @@ -1,70 +1,16 @@ /** - * @xnetjs/plugins — install provenance → trust tier (exploration 0192). + * @xnetjs/plugins — install provenance → trust tier (explorations 0192, 0194). * - * This mirrors `@xnetjs/labs`'s `deriveTrustTier`/`requiresCapabilityReprompt` - * (the canonical version for Labs/runtime code) but lives in `@xnetjs/plugins` - * so the plugin *install path* can consult it without `@xnetjs/plugins` taking a - * dependency on `@xnetjs/labs`. The two must agree: tier follows **provenance** - * (where the plugin came from), never anything the manifest declares about - * itself, and a `synced` plugin always re-derives locally — sync is not consent. + * This used to carry a byte-for-byte copy of `@xnetjs/labs`'s trust logic + * (mirrored in 0192 to avoid a `plugins → labs` dependency edge). 0194 extracted + * the shared logic into the zero-dep `@xnetjs/trust` leaf, which both packages + * now consume — so this module is a thin re-export that preserves the plugins + * public API (`InstallProvenance`, `PluginTrustTier`, `SandboxKind`, and the + * three functions). */ -/** Where a plugin came from — the only input to its trust tier. */ -export type InstallProvenance = - /** Bundled with the app. */ - | 'builtin' - /** Authored in this workspace by the user. */ - | 'authored' - /** Generated by an AI agent and explicitly approved by the user. */ - | 'ai-generated' - /** Imported from a file/manifest the user pasted or opened. */ - | 'imported' - /** Installed from a public marketplace. */ - | 'marketplace' - /** Arrived via P2P sync from another device/peer. */ - | 'synced' +export { deriveTrustTier, requiresCapabilityReprompt, sandboxForTier } from '@xnetjs/trust' +export type { InstallProvenance, SandboxKind } from '@xnetjs/trust' -/** The execution trust tier a plugin runs at. */ -export type PluginTrustTier = 'first-party' | 'user' | 'marketplace' - -/** - * Map install provenance to a trust tier. `synced` does NOT inherit any tier — - * it lands at `user` and must re-confirm capabilities locally before activating. - */ -export function deriveTrustTier(provenance: InstallProvenance): PluginTrustTier { - switch (provenance) { - case 'builtin': - return 'first-party' - case 'authored': - case 'ai-generated': - case 'imported': - case 'synced': - return 'user' - case 'marketplace': - return 'marketplace' - } -} - -/** - * Whether installing from `provenance` must re-prompt for capability consent - * before activation. True for anything not authored on this device in this - * session — especially synced and marketplace plugins. - */ -export function requiresCapabilityReprompt(provenance: InstallProvenance): boolean { - return provenance !== 'builtin' && provenance !== 'authored' -} - -/** The sandbox a tier maps to (mirrors the dashboard widget tiers). */ -export type SandboxKind = 'host' | 'ses-worker' | 'iframe' - -/** Map a trust tier to the sandbox its code should run in. */ -export function sandboxForTier(tier: PluginTrustTier): SandboxKind { - switch (tier) { - case 'first-party': - return 'host' - case 'user': - return 'ses-worker' - case 'marketplace': - return 'iframe' - } -} +/** The execution trust tier a plugin runs at (alias of the shared `TrustTier`). */ +export type { TrustTier as PluginTrustTier } from '@xnetjs/trust' diff --git a/packages/trust/README.md b/packages/trust/README.md new file mode 100644 index 000000000..c7f7eb5ef --- /dev/null +++ b/packages/trust/README.md @@ -0,0 +1,22 @@ +# @xnetjs/trust + +The shared provenance→trust-tier primitives for xNet's extensibility systems +(exploration 0194). + +Trust follows **provenance** — where an extension came from — never anything the +code declares about itself. A `synced` extension always re-derives its tier +locally (sync is not consent). + +```ts +import { deriveTrustTier, requiresCapabilityReprompt, sandboxForTier } from '@xnetjs/trust' + +deriveTrustTier('marketplace') // 'marketplace' → sandboxForTier → 'iframe' +deriveTrustTier('authored') // 'user' → 'ses-worker' +deriveTrustTier('builtin') // 'first-party' → 'host' +requiresCapabilityReprompt('synced') // true +``` + +This package is the single source of truth consumed by `@xnetjs/plugins` +(ecosystem layer, 0192) and `@xnetjs/labs` (0180), which previously carried +byte-for-byte identical copies of this logic. It is intentionally zero-dependency +and policy-only (no runtime) so it never becomes a coupling magnet. diff --git a/packages/trust/package.json b/packages/trust/package.json new file mode 100644 index 000000000..1f3060a9a --- /dev/null +++ b/packages/trust/package.json @@ -0,0 +1,29 @@ +{ + "name": "@xnetjs/trust", + "version": "0.0.1", + "description": "Shared provenance→trust-tier primitives for xNet extensibility — the single source of truth consumed by @xnetjs/plugins and @xnetjs/labs.", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsup src/index.ts --format esm --dts", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsup": "^8.0.0", + "typescript": "^5.4.0", + "vitest": "^4.0.0" + }, + "private": true +} diff --git a/packages/trust/src/index.test.ts b/packages/trust/src/index.test.ts new file mode 100644 index 000000000..446fd295b --- /dev/null +++ b/packages/trust/src/index.test.ts @@ -0,0 +1,57 @@ +/** + * Tests for @xnetjs/trust — the shared provenance→trust primitives (0194). + */ + +import { describe, it, expect } from 'vitest' +import { + deriveTrustTier, + requiresCapabilityReprompt, + sandboxForTier, + type InstallProvenance +} from './index' + +const ALL: InstallProvenance[] = [ + 'builtin', + 'authored', + 'ai-generated', + 'imported', + 'marketplace', + 'synced' +] + +describe('deriveTrustTier', () => { + it('maps provenance to tier; only builtin is first-party, only marketplace is marketplace', () => { + expect(deriveTrustTier('builtin')).toBe('first-party') + expect(deriveTrustTier('marketplace')).toBe('marketplace') + for (const p of ['authored', 'ai-generated', 'imported', 'synced'] as InstallProvenance[]) { + expect(deriveTrustTier(p)).toBe('user') + } + }) + + it('never throws and always returns a valid tier for any provenance', () => { + for (const p of ALL) { + expect(['first-party', 'user', 'marketplace']).toContain(deriveTrustTier(p)) + } + }) +}) + +describe('requiresCapabilityReprompt', () => { + it.each(ALL)('is false only for builtin/authored (%s)', (provenance) => { + const localToThisDevice = provenance === 'builtin' || provenance === 'authored' + expect(requiresCapabilityReprompt(provenance)).toBe(!localToThisDevice) + }) +}) + +describe('sandboxForTier', () => { + it('maps tiers to sandboxes', () => { + expect(sandboxForTier('first-party')).toBe('host') + expect(sandboxForTier('user')).toBe('ses-worker') + expect(sandboxForTier('marketplace')).toBe('iframe') + }) + + it('round-trips: a marketplace install ends up in the iframe', () => { + expect(sandboxForTier(deriveTrustTier('marketplace'))).toBe('iframe') + expect(sandboxForTier(deriveTrustTier('synced'))).toBe('ses-worker') + expect(sandboxForTier(deriveTrustTier('builtin'))).toBe('host') + }) +}) diff --git a/packages/trust/src/index.ts b/packages/trust/src/index.ts new file mode 100644 index 000000000..2d89f876f --- /dev/null +++ b/packages/trust/src/index.ts @@ -0,0 +1,71 @@ +/** + * @xnetjs/trust — the shared provenance→trust primitives for xNet extensibility + * (exploration 0194). + * + * The load-bearing invariant of the whole extensibility story: an extension's + * trust tier follows its PROVENANCE — where it came from — never anything the + * code declares about itself. Something you (or an AI you approved) authored + * locally installs at the `user` tier; something pulled from a marketplace + * installs at the `marketplace` tier (and runs in the iframe). + * + * Critically, when an extension node SYNCS to another device, the receiver must + * RE-DERIVE the tier from its own local install action — never trust a tier + * carried in the synced payload. {@link deriveTrustTier} is that single choke + * point. + * + * This package exists because `@xnetjs/labs` (0180) and `@xnetjs/plugins`'s + * ecosystem layer (0192) had *byte-for-byte identical* copies of this logic — + * 0192 mirrored it to avoid a `plugins → labs` dependency edge (labs already + * depends on plugins). A tiny zero-dep leaf both can depend on is the clean fix. + * Keep it policy-only (no runtime) so it never becomes a coupling magnet. + */ + +/** Where an extension came from — the only input to its trust tier. */ +export type InstallProvenance = + /** Bundled with the app. */ + | 'builtin' + /** Authored in this workspace by the user. */ + | 'authored' + /** Generated by an AI agent and explicitly approved by the user. */ + | 'ai-generated' + /** Imported from a file/manifest the user pasted or opened. */ + | 'imported' + /** Installed from a public marketplace. */ + | 'marketplace' + /** Arrived via P2P sync from another device/peer. */ + | 'synced' + +/** The execution trust tier an extension runs at. */ +export type TrustTier = 'first-party' | 'user' | 'marketplace' + +/** The sandbox a tier maps to (mirrors the dashboard widget tiers). */ +export type SandboxKind = 'host' | 'ses-worker' | 'iframe' + +/** + * Map install provenance to a trust tier. `synced` does NOT inherit any tier — + * it lands at `user` and the host must re-confirm capabilities locally before + * activating (sync is not consent). `authored`/`ai-generated`/`imported` also + * land at `user`; only `builtin` is first-party and only `marketplace` is + * marketplace. + */ +export function deriveTrustTier(provenance: InstallProvenance): TrustTier { + if (provenance === 'builtin') return 'first-party' + if (provenance === 'marketplace') return 'marketplace' + return 'user' +} + +/** + * Whether installing from `provenance` must re-prompt the user for the + * extension's capabilities before activation. True for anything not authored on + * this device in this session — especially synced and marketplace nodes. + */ +export function requiresCapabilityReprompt(provenance: InstallProvenance): boolean { + return provenance !== 'builtin' && provenance !== 'authored' +} + +/** Map a trust tier to the sandbox its code should run in. */ +export function sandboxForTier(tier: TrustTier): SandboxKind { + if (tier === 'first-party') return 'host' + if (tier === 'marketplace') return 'iframe' + return 'ses-worker' +} diff --git a/packages/trust/tsconfig.json b/packages/trust/tsconfig.json new file mode 100644 index 000000000..90d76d7e8 --- /dev/null +++ b/packages/trust/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00f82d938..98facdf5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1310,6 +1310,9 @@ importers: '@xnetjs/plugins': specifier: workspace:* version: link:../plugins + '@xnetjs/trust': + specifier: workspace:* + version: link:../trust quickjs-emscripten: specifier: ^0.31.0 version: 0.31.0 @@ -1460,6 +1463,9 @@ importers: '@xnetjs/data': specifier: workspace:* version: link:../data + '@xnetjs/trust': + specifier: workspace:* + version: link:../trust acorn: specifier: ^8.15.0 version: 8.15.0 @@ -1792,6 +1798,21 @@ importers: specifier: ^4.0.0 version: 4.0.18(@types/node@20.19.30)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.27.0)(msw@2.12.7(@types/node@20.19.30)(typescript@5.9.3))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2) + packages/trust: + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.30 + tsup: + specifier: ^8.0.0 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.4.0 + version: 5.9.3 + vitest: + specifier: ^4.0.0 + version: 4.0.18(@types/node@20.19.30)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.27.0)(msw@2.12.7(@types/node@20.19.30)(typescript@5.9.3))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2) + packages/ui: dependencies: '@base-ui/react': diff --git a/vitest.config.ts b/vitest.config.ts index dc9bfc56b..ed31e25b1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -62,6 +62,7 @@ const workspaceAliases = { '@xnetjs/storage': new URL('./packages/storage/src/index.ts', import.meta.url).pathname, '@xnetjs/sync': new URL('./packages/sync/src/index.ts', import.meta.url).pathname, '@xnetjs/telemetry': new URL('./packages/telemetry/src/index.ts', import.meta.url).pathname, + '@xnetjs/trust': new URL('./packages/trust/src/index.ts', import.meta.url).pathname, '@xnetjs/ui': new URL('./packages/ui/src/index.ts', import.meta.url).pathname, '@xnetjs/vectors': new URL('./packages/vectors/src/index.ts', import.meta.url).pathname, '@xnetjs/views': new URL('./packages/views/src/index.ts', import.meta.url).pathname @@ -99,8 +100,8 @@ export default defineConfig({ pool: 'threads', isolate: false, include: [ - 'packages/{abuse,billing,canvas-core,cli,cloud,crm,dictation,entitlements,comms,crypto,core,data,experiments,formula,history,identity,ledger,network,query,sqlite,storage,sync,telemetry,vectors}/src/**/*.test.ts', - 'packages/{abuse,billing,canvas-core,cli,cloud,crm,dictation,entitlements,comms,crypto,core,data,experiments,formula,history,identity,ledger,network,query,sqlite,storage,sync,telemetry,vectors}/test/**/*.test.ts', + 'packages/{abuse,billing,canvas-core,cli,cloud,crm,dictation,entitlements,comms,crypto,core,data,experiments,formula,history,identity,ledger,network,query,sqlite,storage,sync,telemetry,trust,vectors}/src/**/*.test.ts', + 'packages/{abuse,billing,canvas-core,cli,cloud,crm,dictation,entitlements,comms,crypto,core,data,experiments,formula,history,identity,ledger,network,query,sqlite,storage,sync,telemetry,trust,vectors}/test/**/*.test.ts', // Control-plane app logic (xNet Cloud — managed-hosting explorations 0174/0175) 'apps/cloud/src/**/*.test.ts', // Social matching layer — pure connect modules only; the