# Offscale — the first real plugin

**Status: LIVE DOCUMENT, opened 2026-08-14, offscale extracted 2026-08-15.** Decisions and findings from
the session that built the plugin system. Correct it in place; it is meant to be edited, not archived.

It lives HERE, in the plugin, rather than in the platform's `docs/`. Most of it is about the plugin
system generally rather than about offscale, and that is deliberate: this is the worked example, and the
reasoning is most useful next to the code it produced. The platform's own docs should not carry the
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.

**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.
Calling it Headscale would undersell it and calling it a fork would be wrong: the server underneath is
stock. The distinct name marks a distinct product, not a badge on someone else's.

Related, and older: `sidecar-app-store.md` is the origin design and is largely implemented despite its
"Nothing implemented" header. `sidecar-topology.md` is where the runtime shape was going.

---

## The reframe

**Core is `officer` and nothing else. Everything else is a plugin** — `officer-pty`, `officer-opencode`,
`officer-claude-code`, offscale. `officer-anthropic-proxy` is a known exception to think about later; the
intuition is that it is one plugin requiring two sidecars.

The old baseline was six PM2 processes. Headscale was removed from it on 2026-08-14 (`services.sh`,
the local ecosystem file, `catalogue.test.ts`'s `CORE[]` mirror, and PM2 itself), so the machine this was
written on runs five.

### Two words, because "core" was doing two jobs

- **baseline** — what a fresh install actually runs
- **first-party** — what Officer Dev publishes

They come apart immediately: offscale is first-party and no longer baseline. Saying "core" for both makes
"is X core?" a question with two answers.

---

## What a plugin is made of

Combined per plugin as needed. **Only `meta` and the ID are always required.**

- a **meta** object — id, name, dock item, backend/frontend mount, etc.
- an **ID** (see below)
- a **sidecar**
- a **backend router** and its routes
- a **db schema**
- **default permissions per user group**
- 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**
- **plugin settings page** definitions
- an accompanying **mobile app**

A plugin is completely self-contained. The platform's installed/enabled state decides whether its routers
mount, whether its sidecar is in the ecosystem file, and so on.

### What offscale needs

db schema · backend router + routes · frontend router + routes · sidecar.

**Not** a context menu, **not** officer-items capabilities, and (probably) **not** a settings page.

---

## Identity and routing

**The app-name is the ID.** One identifier, not two — it names the plugin, prefixes its tables, and is its
route. A random ID plus a separate app-name was considered and dropped: splitting the uniqueness guarantee
across two namespaces means whichever is weaker becomes the real attack surface.

**Uniqueness comes from two mechanisms**, because one is not enough:

- **globally** — the marketplace owns the namespace for published names, with human review. A name as
  generic as `notes` gets refused: it is a name Officer Dev may want later.
- **locally** — the platform refuses to install a plugin whose app-name is already taken on this machine.
  Needed because a private plugin never asks the marketplace anything.

The marketplace works like the Chrome extension store. Anyone may write plugins for their own use with no
restrictions; publishing is what invites review.

### Mount prefixes

```
first-party   /api/<app-name>              e.g. /api/offscale
third-party   /api/p/<creator>/<app-name>  e.g. /api/p/alice/notes
```

`p` is a literal segment meaning "plugin". First-party plugins sit at the root because Officer Dev owns
that namespace anyway, and because provenance is then legible at a glance in a log or a route table.

**The prefix must be derived by exactly one function from the manifest.** Nothing about a first-party
plugin's code may know it is first-party. If that difference ever leaks past the one derivation — a
special case in the router, a bypassed check, a different install branch — first-party and third-party
become two systems, and only one of them gets tested.

`/p/` does **not** solve plugin-vs-plugin collisions; the marketplace and the local check do. What it
guarantees is that a plugin can never shadow a **core** route, which also means the platform can keep
adding core routes forever without breaking installs.

---

## The database

**Tables live in `public`, prefixed with the app-name** — `offscale_servers`, exactly as the codebase
already does (`headscale_servers`, `music_favorites`, `vault_tokens`). No new machinery.

### A Postgres schema per plugin was tested and rejected

Not rejected on suspicion — it was built and proven to work, then dropped as more complexity than it
earns. Recorded so nobody re-runs the experiment:

| Property                                                          | Result                    |
| ----------------------------------------------------------------- | ------------------------- |
| `pgSchema('offscale')` + `drizzle-kit push` creates the namespace | works                     |
| Cross-schema FK to `public.users`                                 | works                     |
| Partial unique index preserved                                    | works                     |
| Push is idempotent, no spurious re-creation                       | works                     |
| Cascade delete across the schema boundary                         | works                     |
| `DROP SCHEMA offscale CASCADE` as uninstall                       | works, `public` untouched |

**The finding worth keeping: `schemaFilter` is mandatory, and the docs are wrong.** Drizzle's config
documentation states that push "will by default manage all schemas". On drizzle-kit **0.31.8** that is
false. A push with the table verifiably exported reported `No changes detected` and created nothing;
naming the schema in `schemaFilter` made the identical push work.

If per-plugin schemas are ever revisited, that is the trap: **a plugin install would report success and
silently create no tables.** Same failure shape as several bugs found the same day — a refusal wearing the
costume of a normal result.

---

## Mounting — rebuild and swap, at runtime

**Runtime mounting, no restart.** This went round twice — C, then B on the belief that Hono could not
mount at runtime, then back — so the reasoning is recorded rather than the conclusion alone.

### What was actually tested

| Router                           | `app.route()` after serving has begun                                 |
| -------------------------------- | --------------------------------------------------------------------- |
| `SmartRouter` _(Hono's default)_ | **throws** — `Can not add a route since the matcher is already built` |
| `RegExpRouter`                   | **throws**, same reason                                               |
| `TrieRouter`                     | works                                                                 |
| `PatternRouter`                  | works                                                                 |

So adding at runtime is possible, but only by giving up the fast matcher — and Hono has **no API to
remove a route**, which uninstall needs.

### The approach that solves both

Rebuild the whole app from the current plugin set and **reassign the variable**:

```ts
let app = buildApp(installedPlugins()); // core routes + one .route() per plugin
serve({ fetch: (req, server) => app.fetch(req, server) }); // closure, NOT app.fetch

// install:    app = buildApp([...installed, 'offscale'])
// uninstall:  app = buildApp(installed.filter(p => p !== 'offscale'))
```

The `fetch` closure reads `app` on every request, so reassigning it **is** the swap. Verified end to end:

```
no plugins    /offscale/x -> 404   | /core -> 200
installed     /offscale/x -> 200   | /core -> 200
uninstalled   /offscale/x -> 404   | /core -> 200
```

Better than the TrieRouter route on both counts: the default `SmartRouter` is kept, so the fast
`RegExpRouter` path survives — and **uninstall works**, which an add-only API cannot express.

### The one line that has to change

`server.tsx:322` is `'/api/*': honoServer.fetch` — a **bound method**, evaluated once at `serve()`. It has
to become `(req, server) => honoServer.fetch(req, server)`, or reassigning the app has no effect at all.
This is the whole mechanical cost.

### Websockets are a separate table, and they reload

Six providers are declared in **Bun's route table**, not Hono's: `/api/tasks/run/ws`,
`/api/tasks/pipeline/ws`, `/api/terminal/ws`, `/api/chat/ws`, `/api/cliamp/ws`, `/api/cliamp/audio/ws`.
The Hono swap does not reach them — but `server.reload({ routes })` does, in both directions:

```
before reload  /api/offscale/ws -> refused    | /core -> 200
after  reload  /api/offscale/ws -> CONNECTED  | /core -> 200
after  remove  /api/offscale/ws -> refused    | /core -> 200
```

So **nothing needs a restart, for either table.** A plugin owning a socket is possible from the start.
`reload` wants the whole option set, so `fetch` is passed alongside `routes`.

`[open]` Whether connections already open across a `reload` survive it was not tested. Worth knowing
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`

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
every mounted route has a permission — and to refuse the swap if one does not. Same invariant, asserted
where mounting actually happens instead of once at start-up.

Two things it must survive, both live today:

- The premise in `sidecar-app-store.md` that "every API route stays mounted regardless" is **retired**. An
  uninstalled plugin's routes are not mounted, so nothing can reach them.
- The check is currently **fed the wrong list** — `Object.keys(handlers)` from `server.tsx`, while Bun
  serves the _route table_, and the two diverged when plugins were switched off. Moving the assertion into
  `buildApp()` fixes this by construction for Hono routes, and leaves the websocket table as the part that
  still needs pointing at reality.

---

## Permissions

A plugin declares capabilities. **A plugin may declare `app`, and nothing else.**

`CapabilityKind` 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.

### 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
the surface; `write` permits everything.

Anything beyond that — who may see whose rows, per-user isolation, ownership of individual records,
visibility rules of any kind — is **implemented inside the plugin**, by the plugin's author. It is not the
platform's responsibility and the platform should not grow machinery for it. A plugin knows what its data
means; the platform only knows whether this account got through the door.

### Offscale v1 uses that model exactly, with nothing added

One shared resource, role-gated:

- **read** — sees what the owner sees: the owner's registered servers, nodes, users, keys, policy
- **write** — can change them, including deleting a server the owner registered

The second is genuinely dangerous, and deliberately allowed. The stored credential is a Headscale **admin**
key that can delete every node on a tailnet, and there is no read-only version of it. So `write` on
offscale is close to full control of the tailnet — which is the owner's decision to make, and the expected
use is read for most roles. Say Developers get `read` and nobody gets `write`.

Two implementation consequences, both inside the plugin:

1. **The queries stop scoping by the caller.** Every one takes the caller's `userId` today —
   `listHeadscaleServers(userId)`, `getActiveHeadscaleCredentials(userId)` — and the schema is per-user
   because of it. Under this model a member sees the **owner's** rows, so those resolve to the owner's id
   always. The per-user shape stays in the table, unused, and becomes the seam if isolation is ever wanted.

2. **Two POSTs are really reads, and must be declared `readOnlyWrites`:**
   - `POST /ssh-test` — a reachability probe that mutates nothing
   - `POST /policy/assist` — proposes a document and, emphatically, never saves one

   Without them a read-level account cannot test a connection or draft a policy, which reads as a broken
   feature rather than a withheld permission. Everything else — activate, rename, tags, routes, expire,
   delete, policy `PUT` — is a genuine write.

### Music is where the richer model gets designed

Offscale is deliberately the simple case. **The next plugin extracted is most likely music, and that is
the right place to develop the in-plugin visibility system** — it has genuinely per-user data (favourites,
playlists, now-playing) sitting on top of a genuinely shared one (a single global library index, noted in
`TODO.md` as one household, one library). So "whose is this row" has a real and non-uniform answer there,
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

A manifest needs three names, not one:

1. `capabilities/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`

Offscale needs (1) and (3), and not (2).

---

## Secrets

Two stores, and a plugin author will reach for the wrong one unless told:

- **plugin-global keys** → the secret store (`officer_db/src/secret-store.ts`, real: `getKey(purpose)`,
  `hasKey`, `retiredKeys`). Purpose-keyed encryption and signing keys, not arbitrary values.
- **per-user credentials** → `service_connections`, which already does the hard part: the row is keyed
  `(userId, service)` and **a NULL `url` means "inherit the instance"**, so a member structurally cannot
  see or supply the URL. `service` is free text with no namespacing yet — that needs solving before third
  parties touch it.

Offscale's own coupling is small and instructive. `headscale/queries.ts` imports exactly two things from
the host:

```ts
import { db } from '../db'; // the connection
import { encryptSecret, decryptSecret } from '../crypto'; // at-rest encryption, 10 uses
```

A plugin cannot carry its own `db` (it must share the connection to reference `users.id`) and should not
carry its own crypto (the key lives in the platform's store). **So those two are provided to a plugin
rather than imported by it.** That is the first concrete piece of the plugin↔host API, and it fell out of
the pilot rather than being invented.

---

## `/api/vpn` is being deleted

Officer had two headscale surfaces:

|            | `/api/vpn`                               | `/api/headscale`                       |
| ---------- | ---------------------------------------- | -------------------------------------- |
| capability | `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                    |

`POST /api/vpn/enroll` was one-tap enrollment for a phone already signed into Officer. **It has no caller
anywhere.** Verified against the mobile monorepo:

1. `enrollVpn()` has one call site, `useVpnScreen.ts:617`, inside `enroll()`
2. `enroll()` is reached only via `if (embedded) await enroll()`
3. `embedded` is optional and defaults to `false`
4. `VpnScreen` is rendered in exactly one place — `apps/offscale/src/App.tsx` — which never passes it

`apps/mobile` and `apps/headscale` have zero references to `enrollVpn`, `VpnScreen` or `api/vpn`. Neither
does the Officer web app. The live database holds no `vpn` grants.

**And it will never come back.** Offscale is permanently standalone: no login, no backend calls, no
dependency on Officer or the platform. The reasoning is the app's own and it is sound — _the thing that
gets you to the platform cannot itself need the platform_, or a broken tailnet locks you out of both.

### Everything collapses to one namespace

`/api/offscale/*`. The comment in `vpn/router.ts` claiming "the path is a contract" no longer binds: the
contract has no counterparty.

**The invite flow stays and does not need the mobile app changed.** `claimInvite` calls
`${invite.base}/api/v1/enroll/claim` — the **Companion** on the server, at a base URL carried in the
invite link. `/api/v1/` is Headscale's own namespace. The phone never talks to Officer for invites.

- **phone → Companion** — untouched by anything here
- **web admin → Officer → sidecar** — ours to rename freely

### There are THREE components, not two

Easy to miss, and worth stating because two of them contain the word "enroll":

| Component        | Repo                         | Enrolment surface                                |
| ---------------- | ---------------------------- | ------------------------------------------------ |
| Officer platform | `officerdev/platform`        | `/api/offscale/*` — web admin only               |
| Mobile suite     | `officerdev/monorepo-mobile` | calls the Companion, never Officer               |
| **Companion**    | `officerdev/offscale-server` | `/api/v1/enroll/*` under basePath `/officer-api` |

The Companion ships beside each Headscale server. Confirmed against its source on 2026-08-14: zero
references to `/api/vpn/*`, and its only outbound calls are the docker socket and its sibling headscale's
`/health`. It never calls Officer and does not use `/api/offscale/*` either.

**`/api/v1/enroll/*` is the Companion's and is not ours to collapse.** The phone claims at
`${invite.base}/api/v1/enroll/claim`, where `invite.base` is the `sidecarOrigin` the Companion itself put
in the invite (`https://<domain>/officer-api`).

**Trap when deleting:** do not delete the sidecar's `enroll.ts`. Line 71 dispatches
`/enroll/invites` to `handleInvitesRoute`, so it is the invite flow's entry point. Only the bare
`POST /_officer/enroll` handler below it is dead.

**A public route is possible if ever needed.** `/api/vault` is already exempt from platform auth
(`EXEMPT_API_PREFIXES`) because Bitwarden clients carry a Vaultwarden bearer rather than a platform JWT.
The exemption must be declared with a reason or the boot check refuses. Not needed today.

**Not an open question — decided.** Removing `vpn` leaves no member-grantable headscale surface, and that
is correct. The invite flow supersedes it completely:

1. the Officer headscale app holds an admin API key for the Headscale server
2. from it the owner mints an **invite** — a URL pointing at the Companion
3. the Companion turns that into the redirect the phone app claims
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
one. Do not reintroduce a member-facing enrolment route on the assumption something is missing.

---

## What headscale actually is — the inventory

Read end to end on 2026-08-14. This is what has to move.

### Backend — 2,406 lines

`/api/headscale` is **18 lines**: a pure `createSidecarProxy`, no Headscale knowledge, "must never grow app
logic". Everything is in the sidecar under `/_officer/*`, dispatched by `routes.ts` to eight handlers —
`servers · nodes · users · keys · policy · enroll · ssh-test · companion`.

Three things worth knowing before touching it:

- **Every domain route acts on the _active_ server**, stored in Postgres behind a partial unique index and
  never passed as a parameter — so no client can act on a server the owner is not currently looking at.
- **`client.ts` is a quirk-absorption layer, and that is the good part.** The quirks are Headscale's:
  uint64 ids arrive as JSON _strings_ (never round-trip through `Number` — it breaks above 2^53), 401/403
  bodies are plain text while every other error is JSON, and the gateway uses `DiscardUnknown` so a
  misspelled request field makes the call **succeed and do nothing** — which is why mutations read the
  object back. One file containing all of it is the model for a plugin's client layer, not something to
  undo.
- **The Companion is optional per server** and answers `{available:false, reason}` at HTTP 200. The trick
  is distinguishing nginx's HTML 502 (no companion) from the companion's JSON 502 (docker op failed): it
  branches on whether the body parses.

Host dependencies: `officerdb` (db + crypto), `DATA_PATH`, `officer-url.mjs`, `createSidecarConnector`,
`createSidecarProxy`, the anthropic proxy's state file, and the `ssh` binary.

### Frontend — 29 files, 27 endpoints

Three registered panels (`headscale-servers`, `headscale-nav`, `headscale-view`, all
`availableOnPanel: false`) inside a locked `WorkspaceView`, with `headscale-view` dispatching on
`useHeadscaleSection()` to eight section views: Servers · Nodes · Users · Keys · Invites · Policy ·
Diagnostics · Console.

It **follows the navigation conventions** — no `usePanelChannel` anywhere, no opaque clicks, the section
lives in `:section` and nowhere else. The one exception is documented and correct: choosing the active
server is a DB write that re-scopes every query, so it stays a button rather than a URL.

The whole frontend↔host coupling, which becomes the plugin API:

| Import                                        | Why it matters                                                                                                          |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `hooks/useClient` → `useClient`, `getHeaders` | both, not just the client — `useCompanionLogStream` needs raw headers because `EventSource` cannot send `Authorization` |
| `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                                 |
| `../Terminal/Terminal` → `TerminalView`       | **the awkward one** — a code dependency on another panel app                                                            |

### `assist.ts` travels, but stays unwired

The ACL-drafting assistant was written and never tested. **Carry it into the plugin, do not delete it, and
do not wire it up** — it is there as a marker that the idea exists, to be finished or removed deliberately
later. Do not tidy it away as unused code.

---

## The manifest — proposal

Written against offscale rather than invented in the abstract, on the principle that a field list designed
from nothing includes what nothing needs and misses what is awkward. The field set grows per plugin; this
is the floor, not the ceiling.

```ts
// plugins/offscale/manifest.ts
export const manifest = {
  /** Constant today. The one input to `mountPrefix()`, and the seam third parties hang off later. */
  publisher: 'officerdev',
  /** The plugin's own semver. Updates compare against this. */
  version: '1.0.0',
  /** Which platforms this build is good for. Refused at install when it does not match. */
  platform: '>=1.0.0 <2.0.0',

  label: 'Offscale',
  summary: 'Your tailnet — machines, users, pre-auth keys and access policy',
  icon: 'Network',
  color: '#818cf8',

  // Named `permissions`, NOT `capabilities`. That word already means three 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: [
    {
      key: 'offscale',
      label: 'Offscale',
      description: 'The tailnet: machines, routes and ACLs',
      /** Owner-only, or grantable to members. The whole distinction a plugin needs. */
      ownerOnly: true,
    },
  ],
} as const;
```

### THE RULE: every plugin route renders a Workspace with at least one panel

Exclusionary, and enforced by shape rather than by review. A plugin **does not render a screen.** It
contributes panels and says how they are arranged; the shell renders `WorkspaceView` around them.

```
web/panels.ts   exports appRegistryMetas — at least one panel
web/layout.ts   exports defaultLayout — how they are arranged
```

Both are required the moment `web/` exists. Missing either and the plugin is **refused at discovery**, by
name and with the reason:

```
probeplug: has a web/ directory but is missing web/layout.ts.
Every plugin route renders a Workspace: contribute panels and a layout, not a screen.
```

There is deliberately no way to export a component. A plugin that could would be free to render a bare
div, a full-page form, its own navigation — and the platform would become a shell hosting strangers'
layouts rather than one application. Non-compliance is not refused so much as **unrepresentable**: there
is nowhere to put a screen.

The shell registers the pair `<prefix>` and `<prefix>/:section`, exactly as the core screens do
(`/headscale/:section`), so a plugin's sections stay addressable, linkable and cmd-clickable. Panels read
`useParams` independently — nothing is passed between them, so they cannot disagree. `appTypes.allowed`
is pinned to that plugin's own panel keys, so a persisted layout naming something else falls back rather
than rendering another plugin's panel inside this one.

### Everything the tree can say, the tree says

The manifest holds only what a directory listing genuinely cannot tell you: an identity fact, or something
a human chose. Everything structural is convention, and **presence is the declaration**:

| Path                 | Means                                                                                         |
| -------------------- | --------------------------------------------------------------------------------------------- |
| _the directory name_ | `appName` — `plugins/offscale/` **is** the id, so it cannot disagree with where the code sits |
| `sidecar/index.ts`   | there is a sidecar; PM2 gets an entry. `.mjs` instead means node — see below                  |
| `api/router.ts`      | there is a backend router, mounted at `mountPrefix(manifest)`                                 |
| `db/schema.ts`       | there are tables; pushed on install, every name prefixed `offscale_`                          |
| `web/Router.tsx`     | there is a frontend; its default export mounts at `<prefix>/*`                                |
| `web/panels.ts`      | it contributes panels; exports `appRegistryMetas`                                             |

The dock tile and the page title need no fields either — the tile is `{ label, icon, color, to:
mountPrefix(manifest) }` and the title is `label`, all of which are already above. Writing them again was
duplication that could only ever drift.

**The runtime is the file extension.** `sidecar/index.mjs` runs under node, `sidecar/index.ts` under bun.
Implicit, but it is the rule this repo already follows — `officer-pty` is `pty/index.mjs` under node
because node-pty is a native module built against Node's ABI, and everything else is bun. Better than a
field that can contradict the file it describes.

### Install asks nothing, and that is the default

Offscale needs **none** of the install fields the current app-store catalogue carries — no `modes`, no
`existingFields`, no `configFields`, no `composeTemplate`, no `members`. There is no Docker to provision
and no external service to point at.

Its install is the whole of it: put the code there, push the schema, start the sidecar, swap the routes.
Available immediately. Everything else is configuration the user does **afterwards, inside the app** — a
Headscale server is registered at `/offscale/servers` and lands in `offscale_servers`, which is already
how it works today.

So the rule is **a plugin installs with no questions unless it says otherwise**, and the prompting
machinery (the three install shapes in `sidecar-app-store.md`) gets designed against the first extracted
plugin that actually needs Docker or a remote instance. That was part of why offscale is the right pilot:
it exercises the mounting, the schema and the sidecar without the install flow being a variable too.

### Dropped from the first draft

- **`dependsOn`** — nothing read it and nothing enforced it. Both of offscale's dependencies already
  explain themselves where it matters (`assistant_unavailable`; "no SSH host configured"). A field whose
  only job is to be displayed, that nothing displays, is stale the first time anyone looks at it. Add it
  when something consumes it.
- **`kind`** — see below.
- **`sidecar` / `schema` / `frontend` objects** — all convention now.

`[open]` A plugin with a frontend that should NOT get a dock tile has no way to say so: `web/` present
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 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
nothing is gained by forbidding it.

Corrected rule:

| Kind        | May a plugin declare it? | Why                                                        |
| ----------- | ------------------------ | ---------------------------------------------------------- |
| `app`       | yes                      | the ordinary grantable surface                             |
| `admin`     | yes                      | self-restriction, never an escalation                      |
| `core`      | **no**                   | every account, not deniable — an ungated grant to everyone |
| `execution` | **no**                   | runs as the owner's OS user; the platform's to assign      |
| `confined`  | **no**                   | implies a Linux identity the platform provisions           |

### One function decides the prefix

`publisher` is the only input, so first-party and third-party cannot become two code paths:

```ts
const mountPrefix = (m: Manifest) =>
  m.publisher === 'officerdev' ? `/${m.appName}` : `/p/${m.publisher}/${m.appName}`;
```

Used for both `/api/...` and the frontend route. Nothing else in the codebase may branch on provenance.

### Notes on the fields

- **`sidecar.runtime`** exists because `officer-pty` runs under node for node-pty's native ABI while
  everything else is bun. One plugin already needs it, so it is not speculative generality.
- **`platform`** is the compat range, and it presumes the platform gains a version. It has none today;
  1.0 is expected before anyone outside Officer Dev writes a plugin.
- **`dependsOn`** is deliberately not enforced. Code dependencies need no declaration — a plugin builds
  inside the workspace, so `import { TerminalView }` simply resolves — and service dependencies already
  degrade. This is for the human reading the store.
- **No `health`.** Deferred; process-online is what the store knows and that is enough for now.
- **No `migrations`.** Deferred; a field can be added without redesign.
- **No permission list.** A plugin calls the API with the user's token and the user's permissions.

---

## What is built — complete, as of 2026-08-15

**Offscale is a plugin, and nothing in the system is a stub.** Validated by the owner against the live
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                       |

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
whole thing survives a restart because boot regenerates and mounts before `serve()`.

### Three bugs the extraction found

Worth recording because none were visible from reading:

1. **Install started the sidecar before mounting.** `createSidecarProxy` learns its port from a one-shot
   `<name>:server` event and subscribes when the plugin's router is first imported — at mount. So the
   announcement fired into a void: process online, routes mounted, every request `503 sidecar not
available`. It would have hit every plugin with an HTTP sidecar; `example` never caught it because it
   has no listener to announce. Install and enable now mount first.
2. **The built SPA had no Tailwind.** `bunfig.toml` declares the plugin under `[serve.static]`, which
   applies to Bun's static serving and not to a programmatic `Bun.build()`.
3. **The build could destroy itself.** Clearing `build/` before building meant a failed build left
   nothing, and two overlapping builds could delete each other's shell. It now stages and swaps.

### Still open

- **Websocket providers.** `server.reload({ routes })` is proven but not called; Bun's route table is
  still the hardcoded providers. No plugin owns a socket yet.
- **Totality across plugin routes.** `PROTECTED_API_PREFIXES` is still the core list, and the check reads
  `Object.keys(handlers)` while Bun serves the route table. The assertion wants moving into
  `buildHonoApp`, which is now the single place routes are mounted.
- **Two dock sources.** The app store keeps its own catalogue, so tiles come from there and from the
  plugin system. One when the app store is rebuilt on this.
- **Members.** Offscale is `ownerOnly` — read/write for members needs its queries resolving to the
  OWNER's rows rather than the caller's, which is a change inside the plugin.

---

## The state of the app store, as found

It **is** the plugin system, roughly 90% built, with one structural hole.

`ecosystem.config.cjs` is generated once at setup and **nothing appends to it on install**, so the
installer's final step runs `pm2 start ecosystem.config.cjs --only officer-jellyfin`, matches no app, and
silently does nothing. Acknowledged in `app-store/pm2.ts:23-29`:

> _"Installing a plugin has to append its entry here before starting it — that is the plugin system's job
> and it is not built."_

Net: **nothing in the catalogue installs end-to-end today.** Containers come up, `service_connections` is
written, assets publish, the dock tile appears — and the sidecar never starts.

Also found:

- The `schema` install step is a **logged no-op** (`effects.ts:117-124`). Every table still ships via
  `bun db:push`.
- Of 8 entries declaring a compose template, **only 2 exist on disk** (`transmission`, `vaultwarden`).
  `slskd` has an icon and nothing else. `catalogue.test.ts` asserts a template _name_ is declared but never
  that the directory exists.
- `hono.ts` has **28 routers mounted and 15 commented out**; `officer_db/src/schema.ts` has **11 commented
  schema exports** under "uncomment when the plugin is installed". Today, installing a plugin literally
  means editing two files and rebuilding.
- `catalogue.test.ts` asserts every entry's process has a matching `src/servers/sidecar/<dir>`. A plugin in
  its own repository has no such directory, so that test inverts — as `sidecar-app-store.md` predicted.
- A **dead, unrelated** plugin system still exists: `GET /server-settings/plugins` scans
  `src/workspaces/plugins/`, which does not exist, so it always returns `[]`. `PluginsSection.tsx` still
  renders against it. Not to be confused with any of the above.

---

## Where the code lives

`plugins/offscale` on `gitea.officer.dev` — private, default branch `main`, topic `officer-plugin`.

The `plugins` org exists because Gitea has **no nested organizations** (verified: no `parent` field on the
org object), so `<owner>/<repo>` is the only real namespace it has. Topics work and are searchable, and are
used in addition rather than instead — they span orgs, which matters because browser extensions under
`extensions/` may become plugins later.

---

## Open questions

1. ~~**Frontend code is the hard one.**~~ **Answered** — see "How the frontend ships". Build to `build/`,
   rebuild on install, one generated `Plugins.tsx`, same origin. No federation, no import maps, no iframe:
   everything compiles together and a plugin changes what "everything" is. The developer builds inside a
   platform checkout, so dev-time and build-time are the same mechanism.
2. **Migrations and versioning.** A plugin needs a version and a platform-compatibility range, and
   something has to apply schema changes over time. Cheap now, miserable to retrofit.
3. ~~**Health, distinct from enabled.**~~ **Deferred, deliberately.** A sidecar can be online while the
   thing it exists to talk to is unreachable — offscale's own `/servers/:id/health` is exactly that
   question. But process-online covers the common failure, every plugin that needs more surfaces it in its
   own UI, and this is a manifest field that can be added later without redesign. Revisit in a distant
   future, not before.
4. ~~**No inter-plugin dependencies.**~~ **Overtaken by evidence.** That measurement was of _schemas_ and is
   still true there; at runtime the pilot has two — `assist` → anthropic-proxy (service) and `ConsoleView`
   → `TerminalView` (code). The rule became "may depend, must degrade" — see "Dependencies between
   plugins". What is still open is the **code** kind: either `TerminalView` becomes host API, or the
   Console section does not travel with the plugin.
5. **`service_connections.service` namespacing** before third parties touch it.
6. **`officer-anthropic-proxy`** — one plugin, two sidecars.
7. **Gitea is installed but invisible.** Containers `gitea` and `gitea-postgres` run, `officer-gitea` is
   not in PM2, and there is no `sidecar_installs` row — it predates the store. "Already there, but not by
   us" needs an answer, and the store deliberately refuses to adopt directories it did not create.
