Files
platform/plugins/EXTRACTING-A-PLUGIN.md
T
pastilhas 027b10bd6e 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.
2026-08-15 16:31:11 +00:00

12 KiB

Extracting a feature into a plugin

The runbook, written the day offscale became the first one. Follow it for music, then for the rest.

Read first, in this order:

  1. plugins/offscale/PLUGIN.md — every decision and why, including the three that reversed
  2. plugins/example/ — the reference implementation, deliberately the smallest real plugin
  3. plugins/offscale/ — the worked example, all four parts
  4. plugins/music/PLUGIN.md — the MESSY worked example: three pieces that stayed behind, and why each is a seam rather than a loose end. Read it if your feature has anything the platform also uses.
  5. src/servers/plugins/ — the system itself: manifest, discover, mount, install, ecosystem, schema, generate

The rules. These are not preferences

Every plugin route renders a Workspace with at least one panel. A plugin contributes web/panels.ts (appRegistryMetas, at least one) and web/layout.ts (defaultLayout); the shell renders WorkspaceView around them. There is no way to export a component — a web/ directory missing either file is refused at discovery, by name. Non-compliance is unrepresentable, not forbidden.

Every plugin permission is grantable, per role, at read or write. No kind, no ownerOnly, no field of any sort. The platform's answer is uniform; what a grant means — whose rows a member sees, whether a resource is shared or per-user — is the plugin's own job, in its own queries.

Say permissions, never the other word. It already means three things in this codebase.

The manifest holds only what a directory listing cannot say. Identity facts and human choices: publisher, version, platform, label, summary, icon, color, permissions. Everything structural is convention — presence is the declaration:

manifest.ts        required
api/router.ts      a backend router, mounted at mountPrefix()
db/schema.ts       tables, prefixed <app-name>_
sidecar/index.ts   a process (.mjs instead means node)
web/panels.ts      panels — REQUIRED with web/
web/layout.ts      layout — REQUIRED with web/

A host binary is the one exception, and it goes in the manifest — the tree cannot say it. Declare osDependencies when your plugin shells out to something: the binary to probe on PATH, why it is needed, and a package name per package manager. Absent means self-sufficient, which offscale and example are. Music added the field; see its PLUGIN.md for what it is guarding against.

appName is the directory name. The sidecar runtime is the file extension.

Nothing may branch on provenance except mountPrefix(). First-party and third-party differing anywhere else means two systems, and only one gets tested.

Uninstall never destroys data. The generated schema barrel follows plugin directories, not the install table — db:push drops what it cannot see, so following installs would delete a plugin's tables on uninstall. Only deleting a plugin's source can lose its data.


The order that worked

  1. Map it first. Sidecar, api router, db, frontend, and every line of platform wiring that names it.
  2. Move the backend: sidecar/plugins/<name>/sidecar/, api/<name>/router.tsplugins/<name>/api/router.ts (export router, not <name>Router), officer_db/src/<name>/*plugins/<name>/db/.
  3. Rewrite imports. Platform code becomes @@/… (resolves from plugins/ — verified). Queries take officerdb/db and officerdb/crypto. Schema takes officerdb/auth/schemausers.id is the one reference a plugin may make.
  4. Write manifest.ts.
  5. Move the frontend to web/, as panels.ts + layout.ts. Imports of platform UI become 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 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.
  8. Verify on the live server — see below.
  9. Commit and push. Message says what moved, what it found, and what is still open.

Verification — run all of it

bun test                     # 757 pass, 10 pre-existing failures. Any 11th is yours
pm2 restart officer

Then through /plugins, watching PM2 and the browser at each step:

Step Expect
install streamed log; schema applied; sidecar online; route mounted
the plugin's API answers
the plugin's screen renders as a Workspace
dock tile appears
permissions page its permission is listed, read/write/none
disable route 404s, sidecar stops, tables and rows survive
enable comes back
uninstall route gone, pm2 list loses it, data still there
bun db:push while uninstalled No changes detected — data survives
install again identical to the first install

A normal refresh is enough; the shell is no-store. When the log's last line appears, the bundle exists.


Traps, all of which cost real time once

  • Mount before starting the sidecar. createSidecarProxy learns its port from a one-shot <name>:server event and subscribes when the router is first imported — at mount. Start first and the announcement fires into a void: online process, mounted routes, every request 503. Already fixed in install.ts; do not reorder it.
  • src/servers/sidecar/protocol.ts still declares <name>:server per sidecar. Music will need its line kept, or the union generalised to `${string}:server` — which is the better fix and is pending for the whole protocol.
  • bunfig.toml plugins do not reach Bun.build(). Tailwind is passed explicitly in generate.ts.
  • 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. 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 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.
  • Moving a *.test.ts into plugins/ used to stop it running, silently. [test] root was ./src until music; it is now .. If that ever goes back, every extraction quietly shrinks the suite. Compare the FILE COUNT across a run, not just pass/fail — that is the only thing that shows it.
  • A manifest is read once per server process. Discovery does await import(manifest.ts), and the module cache holds it for the lifetime of the process — so editing a manifest while developing changes nothing until pm2 restart officer. Costs ten minutes the first time, because the plugins page keeps cheerfully showing the old values. outdated cannot notice a version bump without a restart either.
  • A plugin importing platform code is fine (@@/…); the reverse is not. If something in src/ imports from your feature and cannot move — a widget, a relay — that piece stays, and the boundary goes around it. Find those before you plan the split; they decide it for you.

Music is done. What it changed about this runbook

Extracted 2026-08-15 and verified live through the whole table above. plugins/music/PLUGIN.md is the record; the parts worth carrying forward are already folded into the rules and traps above.

The one thing that generalises: map what the PLATFORM still needs from your feature before you plan the split. Music's boundary was not chosen — it was dictated by two imports pointing the wrong way (a dashboard widget reaching for useMusicPlayer, a cliamp relay reaching for getMusicServerWsUrl), and both were found by reading the import graph rather than by reasoning about what music "is". Offscale had none, so it came out whole and made the job look cleaner than it is.

The three pieces music left behind are officerdev/src/MusicPlayer/, src/servers/api/music/router.ts and everything cliamp. Each is documented where it sits. None of them is work waiting for you — do not tidy them into a plugin as a warm-up.

The global-overlay question is answered, and the answer is no

Music was the first feature wanting to render on every route. It does not get to, and neither will the next one: a shell slot for a plugin-provided component reopens "there is no way to export a component", which is the rule the whole frontend contract rests on. MusicPlayerHost stays in DashboardLayout, gated on its plugin's permission so it switches itself off with the plugin.

Reopen this only for a feature where the overlay is the whole product, and expect to argue for it.


Which one next

No decision has been made. What the tree says, for whoever picks it:

  • schema.ts still lists eight commented plugin schemas — email, notify, dav, photos, jellyfin, invoiceshelf, soulseek, vault, wallet. Each line names its tables and the file that defines them, which is exactly what its extraction needs.
  • hono.ts still has fifteen commented mounts. Same list, roughly.
  • Soulseek is the interesting one, and not because it is easy: docs/navigation-audit.md records its panels making 37 raw upstream calls, which is the mistake the offscale sidecar exists to avoid. Its extraction is a rewrite wearing a move's clothes. Say so up front rather than discovering it at 2am.
  • Email and wallet both hold credentials, so they meet secret-store and service_connections in a way neither of the first two did. Read docs/secret-store.md first.

Still open, platform-wide. Do not rediscover these

  • Websocket providersserver.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.
  • assertPermissionTotality reads the wrong listObject.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 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 owner's. Its own job, not the platform's.
  • protocol.ts declares <name>:server per sidecar. music:server and headscale:server are both still there for plugins that have left. Generalising the union to `${string}:server` is the fix.
  • hasPersonalWrites reads c.personal only, so a plugin declaring the same thing through readOnlyWrites reports false. Nothing renders it, so it is dead on the wire.