From a00116b2c0b77b35c9a6693fb89dc02635ae9046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 14 Aug 2026 22:43:49 +0000 Subject: [PATCH] a plugin's permissions become real capabilities, and survive a restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit two gaps between "a plugin can mount routes" and "a plugin is part of the platform". both closed. FIRST: nothing registered a plugin's declared permissions, so the capability gate could not resolve a plugin path at all. it resolved to null, and null is denied — the owner never noticed because isSuperAdmin short-circuits every check, which is exactly the shape of bug that reaches a member first. the registry is now rebuildable the same way the hono app is: CORE_REGISTRY holds the platform's own, CAPABILITIES is core plus whatever the installed plugins declare, and setPluginCapabilities replaces the plugin half wholesale rather than diffing it. two invariants hold by construction — DEFAULT_ROLE_- CAPABILITIES and CORE_CAPABILITIES derive from CORE_REGISTRY, so a plugin can never put itself in the fresh-install baseline and can never become `core` (every account, undeniable). a key colliding with a core one is refused and logged, because a plugin able to redefine `chat` could widen it. ownerOnly maps to admin, everything else to app. those are the only kinds a manifest can express, and it has no field for a kind at all. capabilities are registered BEFORE routes are mounted: the gate runs ahead of every router, so mounting a route whose permission is not yet registered would 403 the freshly installed plugin until something else happened to refresh. SECOND: nothing mounted plugins at boot. honoServer is built with none at import, because discovery reads disk and database and neither can be awaited at module scope, and every install verb rebuilt — so it tested perfectly and would have silently unmounted everything on the first restart. server.tsx now refreshes before serve(), so there is no window where an installed plugin 404s, and a plugin that will not load is logged rather than fatal. verified: after a restart, [plugins] mounted /example, the row survived, officer-example came back online from its ecosystem entry, capabilityForApiPath resolves /api/example/ping to the example capability at kind=app, and it appears in the owner's grantable list. 756 pass, same 10 pre-existing failures. Co-Authored-By: Claude Opus 5 --- plugins/example/manifest.ts | 13 ++++-- src/server.tsx | 20 +++++++++ src/servers/capabilities/registry.ts | 66 +++++++++++++++++++++++++--- src/servers/plugins/install.ts | 6 ++- src/servers/plugins/mount.ts | 42 +++++++++++++++++- 5 files changed, 135 insertions(+), 12 deletions(-) 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 }; }