step 4/4: the docs say permissions too, and capability means one thing again

44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.

Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:

  permissions          renamed (steps 1–2)
  $OFFICER_ROOT/capabilities/  KEPT — the item store, and now the only thing
                               the word means that is ours
  sidecar routing keys renamed to `handles` (step 3)
  Lightning wallet     KEPT — a domain term, and on the wire to the mobile apps
  terminfo queries     KEPT — XTGETTCAP, in the pty sidecar
  InvoiceShelf         KEPT — per-resource { write, bulkDelete } flags

The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:

CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.

And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.

Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.

Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.

The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
This commit is contained in:
2026-08-15 16:31:11 +00:00
parent f9fd002ff4
commit 027b10bd6e
45 changed files with 721 additions and 694 deletions
+5 -5
View File
@@ -69,7 +69,7 @@ on uninstall. Only deleting a plugin's source can lose its data.
`officerdev` (the barrel exports `WorkspaceView`, `TerminalView`, `AppRegistryMeta`); `hooks/useClient`
and `helpers/clipboard` stay as they are.
6. **Remove every trace from the platform**, and delete rather than comment out: `hono.ts` mount and
import, the `capabilities/registry.ts` entry, `App.tsx` routes, `Screens/Dashboard/index.tsx`,
import, the `permissions/registry.ts` entry, `App.tsx` routes, `Screens/Dashboard/index.tsx`,
`AppRegistry.tsx`, `officerdev/src/index.ts` re-exports, `Dock.tsx` tile, `usePageTitle.ts` rule, and
**both** database barrels (`index.ts` and `schema.ts`).
7. **`bunx tsgo`** until clean. It finds the wiring you missed.
@@ -117,9 +117,9 @@ A normal refresh is enough; the shell is `no-store`. When the log's last line ap
- **The shell output is named for the entrypoint** (`index.gen.html`), and `naming` does not change it.
- **A stale generated file** (`Plugins.gen.tsx`, `plugin-schemas.gen.ts`) will fail the typecheck after a
contract change. Regenerate rather than hand-edit.
- **Delete the feature's `app-store/catalogue.ts` entry, or its screen goes blank.** `capabilityAvailability`
- **Delete the feature's `app-store/catalogue.ts` entry, or its screen goes blank.** `permissionAvailability`
derives from `sidecar_installs`, and a plugin never gets a row there — its install state is
`plugin_installs`. A leftover catalogue entry therefore makes the capability permanently `unavailable`,
`plugin_installs`. A leftover catalogue entry therefore makes the permission permanently `unavailable`,
which puts its route into `deniedRoutes` and withholds the dock tile, on a server where the plugin is
installed and healthy. This has now bitten twice: headscale (2026-08-14) and nearly music. The note in
`catalogue.ts` is the one to read.
@@ -180,10 +180,10 @@ No decision has been made. What the tree says, for whoever picks it:
- **Websocket providers** — `server.reload({ routes })` proven, never called. No plugin owns a socket yet;
music would have been the first and cliamp being out of scope is what let it pass.
- **`assertCapabilityTotality` reads the wrong list** — `Object.keys(handlers)` while Bun serves the route
- **`assertPermissionTotality` reads the wrong list** — `Object.keys(handlers)` while Bun serves the route
table, and plugin routes are not in `PROTECTED_API_PREFIXES` at all. It belongs in `buildHonoApp()`,
now the single place routes are mounted. Security-adjacent; close it before members reach plugin routes.
The live example is the two cliamp sockets: served in the route table, claimed by no capability, and
The live example is the two cliamp sockets: served in the route table, claimed by no permission, and
invisible to the check. Pinned by a test in `registry.test.ts` so it stays a known fact.
- **Two dock sources** — the app store keeps its own catalogue; one when it is rebuilt on this
- **Offscale's queries scope by caller**, so a granted member sees their own empty list rather than the
+1 -1
View File
@@ -4,7 +4,7 @@
// it, and it is the property that makes install-without-restart possible on the platform side too.
//
// A real one binds a loopback port and registers over `/api/sidecar/register` so the platform can reach
// it by capability (see `servers/sidecar/connect.ts`). This one does neither, on purpose: it exists to
// it by permission (see `servers/sidecar/connect.ts`). This one does neither, on purpose: it exists to
// prove that a plugin's process is written into the ecosystem file, started, stopped and deleted by the
// installer, and adding a socket here would test Bun rather than that.
+6 -6
View File
@@ -130,7 +130,7 @@ to `/music` remounts the host and reloads it.
## Two bugs, neither visible from reading
**The app-store catalogue still listed music, and that would have blanked the screen.**
`capabilityAvailability()` derives from `sidecar_installs`, and a _plugin_ never gets a row there — its
`permissionAvailability()` derives from `sidecar_installs`, and a _plugin_ never gets a row there — its
install state is `plugin_installs`. So `music` would have been permanently `unavailable`, which puts
`/music` into `deniedRoutes`: dock tile withheld, screen blank, on a server where the plugin was
installed, enabled and healthy.
@@ -150,7 +150,7 @@ so it finds `src/servers/plugins/` and not `plugins/`.
## Permissions
One permission, `music`, and the key is deliberately unchanged from the registry entry it replaces — so
every existing `role_capabilities` grant keeps meaning what it meant, and `can('music')` keeps resolving
every existing `role_permissions` grant keeps meaning what it meant, and `can('music')` keeps resolving
for the overlay. Renaming it would have been a silent data change.
The old entry carried `personal: ['/favorites', '/now-playing', '/playlists', '/queue']`. A manifest has
@@ -224,7 +224,7 @@ The runbook's table, run against `platform.officer.dev` rather than reasoned abo
| the API | `/api/music/manifest` 200, `/api/music/favorites` returns per-user JSON |
| range requests | full 200 + `Accept-Ranges`; `bytes=100-199`**206**, correct `Content-Range`, exactly 100 bytes; unsatisfiable → **416**; `../../etc/passwd`**400** |
| the screen | route generated in `Plugins.gen.tsx`, panels in the built bundle, `PluginScreen` wraps `WorkspaceView`. Structural — not eyeballed in a browser |
| dock | tile present in `/api/user/capabilities`; `/music` in `routes`, not in `deniedRoutes` |
| dock | tile present in `/api/user/permissions`; `/music` in `routes`, not in `deniedRoutes` |
| permissions page | `music` listed among the grantable |
| disable | route 404s, sidecar `stopped`, **rows survive** |
| enable | 200 again, sidecar online, favourites still there |
@@ -247,7 +247,7 @@ exist before.
Found 2026-08-15, after the extraction landed, by reading the code rather than by anything failing.
`MusicBrowser.tsx` lists folders with `GET /file-browser/ls`, not through the music sidecar
(`MusicBrowser.tsx:63,81`). `/file-browser` belongs to the **`files`** capability, and `files` is
(`MusicBrowser.tsx:63,81`). `/file-browser` belongs to the **`files`** permission, and `files` is
**`confined`** — so:
- a member granted `music` but not `files` gets a working player, working favourites, and an **empty
@@ -266,7 +266,7 @@ Three possible shapes, none chosen:
listing behind the `music` permission where it belongs, and the plugin stops needing `files` at all.
Most self-contained, and the most work.
2. **The manifest declares a permission dependency**, and the platform refuses the grant or warns. Honest,
but it makes one plugin's grant conditional on another capability, which is new machinery.
but it makes one plugin's grant conditional on another permission, which is new machinery.
3. **Leave it and document it** — a member needs `files` too. Cheapest, and it quietly ties a music grant
to a Linux account, which is a much bigger commitment than the owner is agreeing to on that page.
@@ -280,5 +280,5 @@ Not tonight's call.
manifests, and the self endpoint concatenates both. One when the store is rebuilt on the plugin system.
- **`src/servers/sidecar/protocol.ts` still declares `music:server`** per sidecar. Generalising the union
to `` `${string}:server` `` is the better fix and is pending for the whole protocol.
- **The cliamp sockets are claimed by no capability**, and are served. Now pinned by a test in
- **The cliamp sockets are claimed by no permission**, and are served. Now pinned by a test in
`registry.test.ts` rather than left to be rediscovered — closing it is the totality work.
+1 -1
View File
@@ -53,7 +53,7 @@ export const manifest: PluginManifest = {
// One permission gating the whole surface, grantable per role at read or write like every other.
//
// The key is `music` and that is not incidental: it is the key the platform's own registry used until
// this extraction, so every existing `role_capabilities` grant keeps meaning what it meant, and the
// this extraction, so every existing `role_permissions` grant keeps meaning what it meant, and the
// overlay's `can('music')` keeps resolving. Renaming it would have been a silent data change.
//
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. Favourites,
+1 -1
View File
@@ -123,7 +123,7 @@ export const MusicPlayerHost = () => {
// Restore the saved "currently playing" on first load — paused, at its position — so a reload/return
// lands back on the track. Skipped when a queue already exists (an in-app nav kept player state).
//
// Also skipped without the `music` capability. This host is mounted by the shell for every account, so it
// Also skipped without the `music` permission. This host is mounted by the shell for every account, so it
// used to reach for `/music/now-playing` on a member's very first paint and 403.
useEffect(() => {
if (restoredRef.current) return;
+32 -31
View File
@@ -9,7 +9,7 @@ reasoning is most useful next to the code it produced. The platform's own docs s
history of something it no longer knows exists.
Offscale is Headscale extracted into a plugin. It is the pilot: chosen because it is a genuine vertical
slice (schema + backend router + sidecar + frontend screen + capabilities) without being pathological.
slice (schema + backend router + sidecar + frontend screen + permissions) without being pathological.
**The name is not a rename.** Offscale is Headscale _plus the Companion_ — an API and UI that ship beside
the Headscale server and add what Headscale itself does not do, the invite flow being the first of them.
@@ -54,7 +54,7 @@ Combined per plugin as needed. **Only `meta` and the ID are always required.**
- what it stores in the **secret store**, and whether that is per-user or plugin-global
- a **frontend router**, its routes, and the frontend code
- how it **mounts into the file browser context menu**
- a set of **capabilities added to officer-items**
- a set of **permissions added to officer-items**
- **plugin settings page** definitions
- an accompanying **mobile app**
@@ -65,7 +65,7 @@ mount, whether its sidecar is in the ecosystem file, and so on.
db schema · backend router + routes · frontend router + routes · sidecar.
**Not** a context menu, **not** officer-items capabilities, and (probably) **not** a settings page.
**Not** a context menu, **not** officer-items permissions, and (probably) **not** a settings page.
---
@@ -202,7 +202,7 @@ before a plugin install can interrupt somebody's terminal.
The two tables remain two lists, which is the same seam as the totality bug below.
### What this means for `assertCapabilityTotality`
### What this means for `assertPermissionTotality`
It can no longer be only a boot check, because the mount set changes after boot. The question moves to
**per rebuild**: `buildApp()` is the one place routes are mounted, so it is the one place to assert that
@@ -222,9 +222,9 @@ Two things it must survive, both live today:
## Permissions
A plugin declares capabilities. **A plugin may declare `app`, and nothing else.**
A plugin declares permissions. **A plugin may declare `app`, and nothing else.**
`CapabilityKind` is `core | app | confined | execution | admin`. `core` means _every account, not
`PermissionKind` is `core | app | confined | execution | admin`. `core` means _every account, not
deniable_, so a third-party manifest naming its own kind is a privilege-escalation surface: "malicious
plugin declares itself core" is an ungated grant to every user. `core`, `execution` and `admin` stay the
platform's to assign.
@@ -232,7 +232,7 @@ platform's to assign.
### The platform grants read or write. Everything richer is the plugin's own job
The platform's contract is exactly what it already has and no more: **a role holds `read` or `write` on a
capability**, stored in `role_capabilities`, enforced by the gate. `read` permits safe methods anywhere in
permission**, stored in `role_permissions`, enforced by the gate. `read` permits safe methods anywhere in
the surface; `write` permits everything.
Anything beyond that — who may see whose rows, per-user isolation, ownership of individual records,
@@ -277,13 +277,14 @@ where offscale's is just "the owner's".
Not designed yet, and deliberately not designed here. Recorded so the intent survives.
### Three different things are called "capability" here
### Several different things were called "capability" here
A manifest needs three names, not one:
1. `capabilities/registry.ts`**permissions** (`headscale`, `vpn`)
1. `permissions/registry.ts`**permissions** (`headscale`, `vpn`)
2. `$OFFICER_ROOT/capabilities/` — the **file-based item store** (skills, tools, tasks)
3. `sidecar-registry` `capabilities: ['music']`**routing keys** for `sendCommand`
3. `sidecar-registry` `handles: ['music']`**routing keys** for `sendCommand`, renamed from
`capabilities` on 2026-08-15
Offscale needs (1) and (3), and not (2).
@@ -321,7 +322,7 @@ Officer had two headscale surfaces:
| | `/api/vpn` | `/api/headscale` |
| ---------- | ---------------------------------------- | -------------------------------------- |
| capability | `vpn`, kind `app` — grantable to members | `headscale`, kind `admin` — owner only |
| permission | `vpn`, kind `app` — grantable to members | `headscale`, kind `admin` — owner only |
| purpose | enrol your own device | the tailnet: machines, routes, ACLs |
| surface | one route, `POST /enroll` | the whole admin API |
@@ -387,7 +388,7 @@ is correct. The invite flow supersedes it completely:
4. the device joins
That path needs no per-member permission on Officer at all, and it is the one that exists and works.
`/api/vpn/enroll` was the design it replaced, not a capability still waiting for a UI — there never was
`/api/vpn/enroll` was the design it replaced, not a permission still waiting for a UI — there never was
one. Do not reintroduce a member-facing enrolment route on the assumption something is missing.
---
@@ -438,7 +439,7 @@ The whole frontend↔host coupling, which becomes the plugin API:
| `helpers/clipboard``copyToClipboard` | carries the non-secure-context fallback; re-implementing it would silently regress |
| `AppRegistryMeta` | the panel-contribution contract |
| `officerdev``WorkspaceView`, `LayoutNode` | needs `appTypes: {allowed, fallback}` and `locked` |
| `state/useDashboardState` | per-user layout, backed by `/api/dashboards`, a `core` capability — stays host-provided |
| `state/useDashboardState` | per-user layout, backed by `/api/dashboards`, a `core` permission — stays host-provided |
| `../Terminal/Terminal``TerminalView` | **the awkward one** — a code dependency on another panel app |
### `assist.ts` travels, but stays unwired
@@ -470,7 +471,7 @@ export const manifest = {
icon: 'Network',
color: '#818cf8',
// Named `permissions`, NOT `capabilities`. That word already means three different things here — the
// Named `permissions`, NOT `capabilities`. That word meant several different things here — the
// permission registry, the officer-items store, and the sidecar's routing keys — and a fourth would be
// one too many. `permissions` is accurate and free: the old table of that name went in 044aacf4.
permissions: [
@@ -568,7 +569,7 @@ means a tile. Fine for offscale; add a flag the first time something needs it.
### `admin` has to be allowed, and the pilot proved it immediately
The earlier rule here was "a plugin may declare `app`, and nothing else". **That is wrong, and offscale is
the counterexample**: its capability is `kind: 'admin'` — owner-only — and it should stay that way.
the counterexample**: its permission is `kind: 'admin'` — owner-only — and it should stay that way.
The distinction is direction. `core` means _every account, undeniable_, so a plugin claiming it grants
itself to everyone: escalation. `admin` means _owner only_, which is a plugin **restricting** itself, and
@@ -616,24 +617,24 @@ Used for both `/api/...` and the frontend route. Nothing else in the codebase ma
server across repeated install / enable / disable / uninstall cycles, checking PM2 and the frontend each
time.
| Piece | Where |
| --------------------------------------- | ---------------------------------------------------- |
| Manifest, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
| Discovery by convention | `servers/plugins/discover.ts` |
| Disk ⋈ database, mounts, dock manifests | `servers/plugins/mount.ts` |
| Install runner, four verbs, streamed | `servers/plugins/install.ts` |
| PM2 ecosystem entry | `servers/plugins/ecosystem.ts` |
| Schema barrel + `db:push` | `servers/plugins/schema.ts` |
| `Plugins.gen.tsx` + `Bun.build` | `servers/plugins/generate.ts` |
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
| Capability registration | `capabilities/registry.ts``setPluginCapabilities` |
| Install state | `plugin_installs` |
| The screen | `/plugins`, two panels, SSE log |
| The reference plugin | `plugins/example/` |
| **The first real plugin** | `plugins/offscale/` — 45 files |
| Piece | Where |
| --------------------------------------- | -------------------------------------------------- |
| Manifest, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
| Discovery by convention | `servers/plugins/discover.ts` |
| Disk ⋈ database, mounts, dock manifests | `servers/plugins/mount.ts` |
| Install runner, four verbs, streamed | `servers/plugins/install.ts` |
| PM2 ecosystem entry | `servers/plugins/ecosystem.ts` |
| Schema barrel + `db:push` | `servers/plugins/schema.ts` |
| `Plugins.gen.tsx` + `Bun.build` | `servers/plugins/generate.ts` |
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
| Permission registration | `permissions/registry.ts``setPluginPermissions` |
| Install state | `plugin_installs` |
| The screen | `/plugins`, two panels, SSE log |
| The reference plugin | `plugins/example/` |
| **The first real plugin** | `plugins/offscale/` — 45 files |
Nothing needs a restart. Routes swap by rebuilding the Hono app, the sidecar gets a PM2 entry, the
frontend is regenerated and rebuilt in ~3s, capabilities are registered before routes mount, and the
frontend is regenerated and rebuilt in ~3s, permissions are registered before routes mount, and the
whole thing survives a restart because boot regenerates and mounts before `serve()`.
### Three bugs the extraction found