every plugin permission is grantable, and there is no field to say otherwise

offscale was missing from the permissions page because it declared ownerOnly,
which mapped to kind admin, and admin capabilities are never offered for
granting. correct by the old rule and wrong by the standard: every plugin
follows the same platform-level permission model, appearing on the same page
with the same read/write/none per role.

so the field is gone rather than flipped. a plugin has no way to say owner-only,
which makes the standard structural instead of remembered — the same move as the
workspace rule. core, execution, confined and admin stay the platform's to
assign and a plugin cannot name any of them, so the escalation question is
removed rather than answered.

this is the second draft of this decision to be deleted: first a full
CapabilityKind with three of five values forbidden, then an ownerOnly boolean,
now nothing. the doc records all three so the reasoning is visible rather than
just the conclusion.

finer visibility stays the plugin's job. offscale is the worked example of the
gap that leaves and its manifest says so: it is grantable now, and its queries
still scope by the caller, so a granted member would see their own empty server
list rather than the owner's. closing that is a change inside the plugin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 00:42:35 +00:00
co-authored by Claude Opus 5
parent 8b6cb34ae0
commit 8cc51cfb40
5 changed files with 25 additions and 22 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ const valid = (over: Partial<PluginManifest> = {}): PluginManifest => ({
summary: 'Your tailnet',
icon: 'Network',
color: '#818cf8',
permissions: [{ key: 'offscale', label: 'Offscale', description: 'The tailnet', ownerOnly: true }],
permissions: [{ key: 'offscale', label: 'Offscale', description: 'The tailnet' }],
...over,
});
+10 -8
View File
@@ -24,14 +24,16 @@ export type PluginPermission = {
key: string;
label: string;
description: string;
/**
* Owner-only, or grantable to members. The whole distinction a plugin needs.
*
* The platform's own `CapabilityKind` has five values because the PLATFORM has five sorts of surface.
* A plugin has two states, so this is a boolean — which also removes the escalation question rather
* than answering it: a plugin cannot claim `core` if `core` is not a word it can say.
*/
ownerOnly?: boolean;
// 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
// core capability, on the same page, with no special cases to learn. A plugin that wanted to be
// owner-only would be a plugin the owner cannot delegate, and "which members may reach this" is a
// decision that belongs to the person running the server rather than to the person who wrote the code.
//
// 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
// nothing. See docs/offscale-plugin.md.
/**
* Requests that look like writes and are not — `POST /ssh-test` probes, `POST /policy/assist` proposes
* a document and never saves one. Without declaring them, a read-level account meets what reads as a
+5 -4
View File
@@ -89,9 +89,10 @@ export async function mountablePlugins(snapshot: PluginsSnapshot): Promise<Mount
/**
* 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.
* ALWAYS `app`, because a plugin permission is always grantable: the same read/write/none per role that
* every core capability gets, on the same page. `core`, `execution`, `confined` and `admin` stay the
* platform's to assign, and a plugin cannot name any of them — its manifest has no field for a kind, so
* the escalation question is removed rather than answered.
*
* 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
@@ -105,7 +106,7 @@ function pluginCapabilities(state: PluginState): Capability[] {
key: permission.key,
label: permission.label,
description: permission.description,
kind: permission.ownerOnly ? ('admin' as const) : ('app' as const),
kind: 'app' as const,
// Only the first claims the routes; see above.
api: index === 0 ? [prefix] : [],
routes: index === 0 ? [prefix] : [],