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:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user