the plugin system says permissions, not the other word
i named the field permissions and then narrated in the overloaded one anyway, which is worse than either — the whole reason for the rename was that the word already means three things here. renamed what was mine: setPluginCapabilities -> setPluginPermissions, pluginCapabilities -> pluginPermissions, and the prose throughout. what remains is the platform's own vocabulary, not the plugin system's: the type it imports, and the field on a dock manifest, which is the shape the shell already renders. renaming those is a separate change to a separate system and is the owner's to make. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,7 @@ export const manifest: PluginManifest = {
|
|||||||
color: '#94a3b8',
|
color: '#94a3b8',
|
||||||
|
|
||||||
// One permission gating the whole surface. `ownerOnly: false` means a role can be granted it — which is
|
// 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.
|
// the interesting case, because it is the one the permission gate actually has to resolve.
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
key: 'example',
|
key: 'example',
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export type Capability = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The PLATFORM's own capabilities. A plugin's are added on top at runtime — see `setPluginCapabilities`.
|
* The PLATFORM's own capabilities. A plugin's are added on top at runtime — see `setPluginPermissions`.
|
||||||
*
|
*
|
||||||
* Kept separate from the live `CAPABILITIES` below so two invariants hold by construction rather than by
|
* 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
|
* anyone remembering them: a plugin can never become `core` (granted to everyone, undeniable), and a
|
||||||
@@ -428,7 +428,7 @@ const CORE_REGISTRY: Capability[] = [
|
|||||||
// exports are live bindings, so an importer holding `CAPABILITY_BY_KEY` sees the reassignment — every
|
// 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.
|
// 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
|
// Nothing mutates the arrays in place. `setPluginPermissions` replaces them, for the same reason
|
||||||
// `buildHonoApp` replaces the app rather than adding routes to it: a half-updated lookup is worse than a
|
// `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.
|
// stale one, and replacement has no half.
|
||||||
|
|
||||||
@@ -480,7 +480,7 @@ export const CORE_CAPABILITIES = CORE_REGISTRY.filter((c) => c.kind === 'core');
|
|||||||
* question. A key colliding with a core one is REFUSED here rather than silently overriding — a plugin
|
* 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.
|
* that could redefine `chat` or `terminal` could widen it.
|
||||||
*/
|
*/
|
||||||
export function setPluginCapabilities(pluginCapabilities: Capability[]): { rejected: string[] } {
|
export function setPluginPermissions(pluginCapabilities: Capability[]): { rejected: string[] } {
|
||||||
const coreKeys = new Set(CORE_REGISTRY.map((c) => c.key));
|
const coreKeys = new Set(CORE_REGISTRY.map((c) => c.key));
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const accepted: Capability[] = [];
|
const accepted: Capability[] = [];
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import type { PluginState } from './mount';
|
|||||||
// ── What is NOT here ──
|
// ── What is NOT here ──
|
||||||
//
|
//
|
||||||
// The dock tile. It is permission-filtered, and permission is a runtime question: a grant takes effect on
|
// The dock tile. It is permission-filtered, and permission is a runtime question: a grant takes effect on
|
||||||
// the next request, not on the next build. So the tile comes from `/api/user/capabilities` like every
|
// the next request, not on the next build. So the tile is served by the self endpoint at request time,
|
||||||
// other plugin manifest, and this file carries only what the BUNDLER needs.
|
// and this file carries only what the BUNDLER needs.
|
||||||
|
|
||||||
const GENERATED_PATH = join(PLATFORM_DIR, 'src/apps/officer-web/Plugins.gen.tsx');
|
const GENERATED_PATH = join(PLATFORM_DIR, 'src/apps/officer-web/Plugins.gen.tsx');
|
||||||
const BUILD_DIR = join(PLATFORM_DIR, 'build');
|
const BUILD_DIR = join(PLATFORM_DIR, 'build');
|
||||||
|
|||||||
@@ -15,9 +15,10 @@
|
|||||||
/**
|
/**
|
||||||
* A permission the plugin adds to the platform's permission system.
|
* A permission the plugin adds to the platform's permission system.
|
||||||
*
|
*
|
||||||
* Called `permissions` and NOT `capabilities`: that word already means three different things in this
|
* Called `permissions`, and that word is used throughout the plugin system deliberately. The other one
|
||||||
* codebase — the permission registry, the file-based item store under `$OFFICER_ROOT/capabilities`, and
|
* already means three different things here — the permission registry, the file-based item store under
|
||||||
* the routing keys a sidecar registers with. A fourth would be one too many.
|
* `$OFFICER_ROOT/capabilities`, and the routing keys a sidecar registers with — and a fourth meaning
|
||||||
|
* would be one too many. Nothing in this system uses it.
|
||||||
*/
|
*/
|
||||||
export type PluginPermission = {
|
export type PluginPermission = {
|
||||||
/** Stable identifier, stored as the grant's subject. Renaming one is a data change. */
|
/** Stable identifier, stored as the grant's subject. Renaming one is a data change. */
|
||||||
@@ -26,10 +27,10 @@ export type PluginPermission = {
|
|||||||
description: string;
|
description: string;
|
||||||
// There is deliberately NO `ownerOnly`, and no kind of any sort.
|
// There is deliberately NO `ownerOnly`, and no kind of any sort.
|
||||||
//
|
//
|
||||||
// Every plugin permission is grantable, per role, at read or write — the same configuration as every
|
// Every plugin permission is grantable, per role, at read or write — the same configuration the
|
||||||
// core capability, on the same page, with no special cases to learn. A plugin that wanted to be
|
// platform's own permissions get, on the same page, with no special cases to learn. A plugin that
|
||||||
// owner-only would be a plugin the owner cannot delegate, and "which members may reach this" is a
|
// wanted to be owner-only would be a plugin the owner cannot delegate, and "which members may reach
|
||||||
// decision that belongs to the person running the server rather than to the person who wrote the code.
|
// this" is a decision belonging to whoever runs the server rather than whoever wrote the code.
|
||||||
//
|
//
|
||||||
// Finer visibility — whose rows a member sees, what a read means for this plugin's data — is the
|
// Finer visibility — whose rows a member sees, what a read means for this plugin's data — is the
|
||||||
// PLUGIN's business and lives in its own queries. The platform's answer is uniform: read, write, or
|
// PLUGIN's business and lives in its own queries. The platform's answer is uniform: read, write, or
|
||||||
@@ -151,9 +152,9 @@ export function manifestProblems(appName: string, manifest: Partial<PluginManife
|
|||||||
problems.push(`permissions[${i}].key is required`);
|
problems.push(`permissions[${i}].key is required`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// The permission key shares the capability registry's namespace, so a plugin colliding with a core
|
// The key shares the platform's permission namespace, so a plugin colliding with a built-in one
|
||||||
// capability would silently widen or narrow it. Prefixing is not enforced here — the installer
|
// would silently widen or narrow it. Prefixing is not enforced here — the installer checks against
|
||||||
// checks against the live registry, which is the only thing that knows what is taken.
|
// the live registry, which is the only thing that knows what is taken.
|
||||||
if (typeof permission.label !== 'string' || !permission.label) {
|
if (typeof permission.label !== 'string' || !permission.label) {
|
||||||
problems.push(`permissions[${i}].label is required`);
|
problems.push(`permissions[${i}].label is required`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { listPluginInstalls, type PluginInstall } from 'officerdb';
|
import { listPluginInstalls, type PluginInstall } from 'officerdb';
|
||||||
import type { MountedPlugin } from '../hono';
|
import type { MountedPlugin } from '../hono';
|
||||||
import { rebuildHonoApp } from '../hono';
|
import { rebuildHonoApp } from '../hono';
|
||||||
import { setPluginCapabilities, type Capability } from '../capabilities/registry';
|
import { setPluginPermissions, type Capability } from '../capabilities/registry';
|
||||||
import { discoverPlugins } from './discover';
|
import { discoverPlugins } from './discover';
|
||||||
import { mountPrefix, type DiscoveredPlugin } from './manifest';
|
import { mountPrefix, type DiscoveredPlugin } from './manifest';
|
||||||
import { generatePluginsModule, rebuildFrontend } from './generate';
|
import { generatePluginsModule, rebuildFrontend } from './generate';
|
||||||
@@ -87,18 +87,18 @@ export async function mountablePlugins(snapshot: PluginsSnapshot): Promise<Mount
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turn a plugin's declared permissions into capabilities the platform's own machinery understands.
|
* Turn a plugin's declared permissions into the entries the platform's own registry understands.
|
||||||
*
|
*
|
||||||
* ALWAYS `app`, because a plugin permission is always grantable: the same read/write/none per role that
|
* ALWAYS `app`, because a plugin permission is always grantable: the same read/write/none per role the
|
||||||
* every core capability gets, on the same page. `core`, `execution`, `confined` and `admin` stay the
|
* platform's own permissions get, on the same page. The restricted levels stay the platform's to assign
|
||||||
* platform's to assign, and a plugin cannot name any of them — its manifest has no field for a kind, so
|
* and a plugin cannot name any of them — its manifest has no field for one, so the escalation question is
|
||||||
* the escalation question is removed rather than answered.
|
* removed rather than answered.
|
||||||
*
|
*
|
||||||
* The FIRST permission gates the plugin's whole surface. Later ones are registered as grantable keys that
|
* 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
|
* 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.
|
* that can be added when something needs it rather than guessed at now.
|
||||||
*/
|
*/
|
||||||
function pluginCapabilities(state: PluginState): Capability[] {
|
function pluginPermissions(state: PluginState): Capability[] {
|
||||||
const { plugin } = state;
|
const { plugin } = state;
|
||||||
const prefix = mountPrefix(plugin);
|
const prefix = mountPrefix(plugin);
|
||||||
|
|
||||||
@@ -133,11 +133,11 @@ export type RefreshResult = {
|
|||||||
export async function refreshPluginMounts(options: { skipFrontend?: boolean } = {}): Promise<RefreshResult> {
|
export async function refreshPluginMounts(options: { skipFrontend?: boolean } = {}): Promise<RefreshResult> {
|
||||||
const snapshot = await snapshotPlugins();
|
const snapshot = await snapshotPlugins();
|
||||||
|
|
||||||
// Capabilities BEFORE routes. The capability gate runs ahead of every router, so a route mounted without
|
// Permissions BEFORE routes. The permission 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,
|
// 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.
|
// which would make a freshly installed plugin 403 until something else happened to refresh.
|
||||||
const enabled = snapshot.states.filter((s) => s.install?.enabled);
|
const enabled = snapshot.states.filter((s) => s.install?.enabled);
|
||||||
const { rejected } = setPluginCapabilities(enabled.flatMap(pluginCapabilities));
|
const { rejected } = setPluginPermissions(enabled.flatMap(pluginPermissions));
|
||||||
if (rejected.length) {
|
if (rejected.length) {
|
||||||
console.error(`[plugins] permission keys refused (already taken by the platform): ${rejected.join(', ')}`);
|
console.error(`[plugins] permission keys refused (already taken by the platform): ${rejected.join(', ')}`);
|
||||||
}
|
}
|
||||||
@@ -170,7 +170,7 @@ export async function refreshPluginMounts(options: { skipFrontend?: boolean } =
|
|||||||
*
|
*
|
||||||
* Presentation comes from the manifest and the route from `mountPrefix`, so there is one source for both
|
* Presentation comes from the manifest and the route from `mountPrefix`, so there is one source for both
|
||||||
* and nothing to keep in step. `capability` is the plugin's first permission when it has one, which is
|
* and nothing to keep in step. `capability` is the plugin's first permission when it has one, which is
|
||||||
* what lets `/api/user/capabilities` filter the tile out for an account that cannot reach the screen —
|
* what lets the self endpoint filter the tile out for an account that cannot reach the screen —
|
||||||
* a member must not be handed the manifest of a feature they may not use, even to hide it.
|
* a member must not be handed the manifest of a feature they may not use, even to hide it.
|
||||||
*
|
*
|
||||||
* Built here rather than baked into the generated frontend module on purpose: WHO sees a tile is a
|
* Built here rather than baked into the generated frontend module on purpose: WHO sees a tile is a
|
||||||
|
|||||||
Reference in New Issue
Block a user