the design doc moves into the plugin it produced
docs/offscale-plugin.md becomes plugins/offscale/PLUGIN.md. most of it is about the plugin system generally rather than about offscale, which is exactly why it belongs with the worked example — the reasoning is most useful beside the code it produced, and the platform's docs should not carry the history of something it no longer knows exists. every reference updated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,732 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user