diff --git a/plugins/example/manifest.ts b/plugins/example/manifest.ts index 06318de9..63aef46b 100644 --- a/plugins/example/manifest.ts +++ b/plugins/example/manifest.ts @@ -23,7 +23,14 @@ export const manifest: PluginManifest = { icon: 'Puzzle', color: '#94a3b8', - // Empty is meaningful: this plugin gates nothing of its own and is reachable by anyone who can reach - // the platform. A plugin with a surface worth protecting declares a permission here instead. - permissions: [], + // One permission gating the whole surface. `ownerOnly: false` means a role can be granted it — which is + // the interesting case, because it is the one the capability gate actually has to resolve. + permissions: [ + { + key: 'example', + label: 'Example', + description: 'The reference plugin', + ownerOnly: false, + }, + ], }; diff --git a/src/server.tsx b/src/server.tsx index 9c2ed204..1f37b649 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -3,6 +3,7 @@ import type { ServerWebSocket } from 'bun'; import { serve } from 'bun'; import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono'; import { assertCapabilityTotality } from './servers/capabilities/totality'; +import { refreshPluginMounts } from './servers/plugins/mount'; import { assertInstallLayout } from './servers/data-path'; import { PORT } from './servers/officer-url.mjs'; import { assertSecretsClosed } from './servers/os-user'; @@ -160,6 +161,25 @@ assertInstallLayout(); // JWT_SECRET is worse than no isolation, because the model looks intact. await assertSecretsClosed(process.cwd()); +// Mount whatever is installed, BEFORE serve(). +// +// Without this a restart silently unmounts every plugin: `honoServer` is built with none at import, +// because discovery reads the disk and the database and neither can be awaited at module scope. The +// install verbs each rebuild, so it looked correct in testing — right up until the first restart, which +// is exactly the kind of bug that ships. +// +// Before `serve()` rather than after, so there is no window in which an installed plugin's route 404s. +// Failure is logged and survived: a plugin that will not load must not stop the platform from starting, +// and `refreshPluginMounts` already isolates one bad plugin from the rest. +try { + const { mounted, broken, rejected } = await refreshPluginMounts(); + if (mounted.length) console.log(`[plugins] mounted ${mounted.join(', ')}`); + if (broken.length) console.error(`[plugins] unreadable: ${broken.join(', ')}`); + if (rejected.length) console.error(`[plugins] permission keys refused: ${rejected.join(', ')}`); +} catch (err) { + console.error('[plugins] none mounted —', err instanceof Error ? err.message : err); +} + async function upgradeWs( req: Request, server: any, diff --git a/src/servers/capabilities/registry.ts b/src/servers/capabilities/registry.ts index ec6eef47..24ac0269 100644 --- a/src/servers/capabilities/registry.ts +++ b/src/servers/capabilities/registry.ts @@ -98,7 +98,14 @@ export type Capability = { selfService?: string[]; }; -export const CAPABILITIES: Capability[] = [ +/** + * The PLATFORM's own capabilities. A plugin's are added on top at runtime — see `setPluginCapabilities`. + * + * Kept separate from the live `CAPABILITIES` below so two invariants hold by construction rather than by + * anyone remembering them: a plugin can never become `core` (granted to everyone, undeniable), and a + * plugin is never in the fresh-install baseline. + */ +const CORE_REGISTRY: Capability[] = [ // ── core ──────────────────────────────────────────────────────────────────────────────────────── { key: 'account', @@ -421,8 +428,19 @@ export const CAPABILITIES: Capability[] = [ ]; // ── Derived lookups ─────────────────────────────────────────────────────────────────────────────── +// +// `let`, not `const`, because installing a plugin adds capabilities and uninstalling removes them. ESM +// exports are live bindings, so an importer holding `CAPABILITY_BY_KEY` sees the reassignment — every +// consumer reads these inside a function, never at module scope, which is what makes that safe. +// +// Nothing mutates the arrays in place. `setPluginCapabilities` replaces them, for the same reason +// `buildHonoApp` replaces the app rather than adding routes to it: a half-updated lookup is worse than a +// stale one, and replacement has no half. -export const CAPABILITY_BY_KEY = new Map(CAPABILITIES.map((c) => [c.key, c])); +/** Core plus every installed plugin's. The list `capabilityForApiPath` and the totality check read. */ +export let CAPABILITIES: Capability[] = CORE_REGISTRY; + +export let CAPABILITY_BY_KEY = new Map(CAPABILITIES.map((c) => [c.key, c])); /** * The keys an owner may actually hand to a role. `core` is automatic; `execution` and `admin` are owner-only. @@ -431,7 +449,7 @@ export const CAPABILITY_BY_KEY = new Map(CAPABILITIES.map((c) => [c.key, c])); * enforced in `authorize.ts`, not by withholding it from this list. Withholding it would mean the owner * could not pre-grant a role before provisioning the people in it, which is the normal order of operations. */ -export const GRANTABLE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'app' || c.kind === 'confined'); +export let GRANTABLE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'app' || c.kind === 'confined'); /** * What every role starts with on a fresh install: the three confined capabilities, at `write`. @@ -447,10 +465,46 @@ export const GRANTABLE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'app * `app` capabilities are deliberately NOT here. Those reach data the owner may not intend to share, and each * needs a sidecar installed before it means anything anyway. */ -export const DEFAULT_ROLE_CAPABILITIES: string[] = CAPABILITIES.filter((c) => c.kind === 'confined').map((c) => c.key); +// From CORE_REGISTRY, never from CAPABILITIES: a plugin must not be able to put itself in the baseline +// every new role starts with. +export const DEFAULT_ROLE_CAPABILITIES: string[] = CORE_REGISTRY.filter((c) => c.kind === 'confined').map((c) => c.key); -/** Available to every signed-in account without a grant. */ -export const CORE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'core'); +/** + * Available to every signed-in account without a grant. + * + * From CORE_REGISTRY, so a plugin declaring `kind: 'core'` — which its manifest cannot express, but which + * a future bug could smuggle in — still could not grant itself to everyone undeniably. + */ +export const CORE_CAPABILITIES = CORE_REGISTRY.filter((c) => c.kind === 'core'); + +/** + * Replace the plugin half of the registry. Called after every install, uninstall, enable and disable. + * + * Plugin capabilities are dropped and re-added wholesale rather than diffed: the source of truth is the + * set of installed plugins, and computing a delta against it would be a second answer to the same + * question. A key colliding with a core one is REFUSED here rather than silently overriding — a plugin + * that could redefine `chat` or `terminal` could widen it. + */ +export function setPluginCapabilities(pluginCapabilities: Capability[]): { rejected: string[] } { + const coreKeys = new Set(CORE_REGISTRY.map((c) => c.key)); + const seen = new Set(); + const accepted: Capability[] = []; + const rejected: string[] = []; + + for (const capability of pluginCapabilities) { + if (coreKeys.has(capability.key) || seen.has(capability.key)) { + rejected.push(capability.key); + continue; + } + seen.add(capability.key); + accepted.push(capability); + } + + CAPABILITIES = [...CORE_REGISTRY, ...accepted]; + CAPABILITY_BY_KEY = new Map(CAPABILITIES.map((c) => [c.key, c])); + GRANTABLE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'app' || c.kind === 'confined'); + return { rejected }; +} export type CapabilityLevel = 'read' | 'write'; diff --git a/src/servers/plugins/install.ts b/src/servers/plugins/install.ts index 281e3f03..75492198 100644 --- a/src/servers/plugins/install.ts +++ b/src/servers/plugins/install.ts @@ -112,7 +112,11 @@ export async function installPlugin(appName: string, onStep?: OnStep): Promise

({ + key: permission.key, + label: permission.label, + description: permission.description, + kind: permission.ownerOnly ? ('admin' as const) : ('app' as const), + // Only the first claims the routes; see above. + api: index === 0 ? [prefix] : [], + routes: index === 0 ? [prefix] : [], + ...(permission.readOnlyWrites ? { readOnlyWrites: permission.readOnlyWrites } : {}), + })); +} + /** * Rebuild the application from the current state of disk and database. * @@ -92,9 +120,19 @@ export async function mountablePlugins(snapshot: PluginsSnapshot): Promise { +export async function refreshPluginMounts(): Promise<{ mounted: string[]; broken: string[]; rejected: string[] }> { const snapshot = await snapshotPlugins(); + + // Capabilities BEFORE routes. The capability gate runs ahead of every router, so a route mounted without + // its permission registered is a route the gate cannot resolve — and an unresolvable path is denied, + // which would make a freshly installed plugin 403 until something else happened to refresh. + const enabled = snapshot.states.filter((s) => s.install?.enabled); + const { rejected } = setPluginCapabilities(enabled.flatMap(pluginCapabilities)); + if (rejected.length) { + console.error(`[plugins] permission keys refused (already taken by the platform): ${rejected.join(', ')}`); + } + const mounted = await mountablePlugins(snapshot); rebuildHonoApp(mounted); - return { mounted: mounted.map((m) => m.prefix), broken: snapshot.broken.map((b) => b.appName) }; + return { mounted: mounted.map((m) => m.prefix), broken: snapshot.broken.map((b) => b.appName), rejected }; }