a plugin's permissions become real capabilities, and survive a restart

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 22:43:49 +00:00
co-authored by Claude Opus 5
parent 62ee0d1e60
commit a00116b2c0
5 changed files with 135 additions and 12 deletions
+20
View File
@@ -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,
+60 -6
View File
@@ -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<string>();
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';
+5 -1
View File
@@ -112,7 +112,11 @@ export async function installPlugin(appName: string, onStep?: OnStep): Promise<P
await step(steps, onStep, `recorded at ${plugin.manifest.version}`);
const { mounted } = await refreshPluginMounts();
await step(steps, onStep, mounted.length ? `mounted: ${mounted.join(', ')}` : 'mounted: nothing (no api/router.ts)');
await step(
steps,
onStep,
mounted.length ? `mounted: ${mounted.join(', ')}` : 'mounted: nothing (no api/router.ts)',
);
return { ok: true, appName, steps };
} catch (err) {
+40 -2
View File
@@ -1,6 +1,7 @@
import { listPluginInstalls, type PluginInstall } from 'officerdb';
import type { MountedPlugin } from '../hono';
import { rebuildHonoApp } from '../hono';
import { setPluginCapabilities, type Capability } from '../capabilities/registry';
import { discoverPlugins } from './discover';
import { mountPrefix, type DiscoveredPlugin } from './manifest';
@@ -84,6 +85,33 @@ export async function mountablePlugins(snapshot: PluginsSnapshot): Promise<Mount
return mounted;
}
/**
* Turn a plugin's declared permissions into capabilities the platform's own machinery understands.
*
* `ownerOnly` becomes `admin`, everything else becomes `app` — those are the only two kinds a plugin can
* express, and the mapping is the whole of it. `core`, `execution` and `confined` stay the platform's to
* assign, and a plugin cannot name them because its manifest has no field for a kind.
*
* The FIRST permission gates the plugin's whole surface. Later ones are registered as grantable keys that
* claim no path — a plugin wanting genuinely separate surfaces needs sub-path claims in the manifest, and
* that can be added when something needs it rather than guessed at now.
*/
function pluginCapabilities(state: PluginState): Capability[] {
const { plugin } = state;
const prefix = mountPrefix(plugin);
return plugin.manifest.permissions.map((permission, index) => ({
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<Mount
* all, so nothing is mutated: a fresh app is built and `honoServer` is reassigned. `server.tsx` serves it
* through a closure, which is what makes the reassignment take effect.
*/
export async function refreshPluginMounts(): Promise<{ mounted: string[]; broken: string[] }> {
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 };
}