how the frontend ships, and what plugins may depend on

everything moves to the plugin, frontend included, so federation stopped being
a later problem and had to be answered. it is answered by not needing it: bun
builds the spa into build/ at start and rebuilds it on install, serving from
that directory instead of compiling through the html import. Bun.build is a
runtime call, so an install needs no restart — just a refresh. same origin
throughout, which is why there is no cors work and no rewrite of useClient.

App.tsx keeps core routes and gains one map over `plugins`, each mounted at a
wildcard delegating to the plugin's own router. that list comes from a generated
Plugins.tsx, because a bundler cannot follow import(runtimeString) — the
specifier has to be concrete before the build. the six places the shell
currently hardcodes headscale collapse into that one file, dock included; the
runtime dockItemsFromPlugins path follows rather than competing with it.
presentation moves to build time, permission stays runtime.

dependencies turned out to be two different problems wearing one word. a service
dependency (assist → anthropic-proxy) is a wire call and already degrades. a
code dependency (ConsoleView → TerminalView) is in the bundle and cannot. rule:
may depend, must degrade. service calls go through the api carrying the user's
token, with the user's own permissions, which also deletes the state-file read
claude-proxy uses today to lift the proxy's secret.

no per-plugin permission list: a plugin is part of the app and bounded by the
account calling it. that makes marketplace review a security boundary rather
than a naming one, which is worth knowing rather than discovering.

and the developer environment is a platform checkout — clone it, run dev, build
the plugin inside. the 13 workspace packages resolve by name because bun links
them, so `import { useClient } from 'hooks/useClient'` just works with no
registry and no versioning. dev-time and build-time become the same mechanism.

also writes down the headscale inventory now that it has been read end to end,
including that assist.ts travels unwired as a marker and must not be tidied away
as dead code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 18:58:22 +00:00
co-authored by Claude Opus 5
parent 7f26f0b4b8
commit f6b2905cc7
+177 -6
View File
@@ -166,6 +166,117 @@ What is compile-time is only the routes and permissions, not the sidecar's exist
---
## How the frontend ships
**Everything moves to the plugin — the frontend does not stay in this repo.** That rules out treating
federation as a later problem, and it is what the build model below exists to answer.
### Build to a directory, rebuild on install
Today Bun compiles the SPA at server start, through the HTML import. That changes:
1. `pm2` starts `officer`
2. it builds the frontend immediately into `build/` (untracked)
3. it serves `index.html` from that build
4. installing a plugin triggers a rebuild, and the page auto-refreshes or the user is told to
5. **no server restart**`Bun.build()` is a runtime call, not a process lifecycle event
**Same origin throughout.** A separate origin for the API was considered and dropped: it would mean CORS
and rewriting every endpoint in `useClient` for no gain, since Bun can serve the build itself.
This is why there is no module federation, no import map and no iframe anywhere in this design. Everything
is compiled together; a plugin simply changes what "everything" is.
### `Plugins.tsx`, generated at build time
`App.tsx` keeps the core routes and gains one map:
```tsx
plugins.map((plugin) => <Route path={`${plugin.route}/*`} element={<plugin.Router />} />);
```
The wildcard delegates to the plugin's own router, which React Router nests natively. `plugins` comes from
a **generated `Plugins.tsx`**, written at build time from what is installed — because a bundler cannot
follow `import(someRuntimeString)`, the specifier has to be concrete before the build runs.
Everything the shell currently hardcodes per plugin collapses into that one file. Today headscale is named
in six places, and every one of them is a place to forget:
| Place | What it holds |
| ----------------------------- | --------------------------------------------------- |
| `App.tsx` | the `/headscale` + `/headscale/:section` route pair |
| `Screens/Dashboard/index.tsx` | the screen barrel export |
| `AppRegistry.tsx` | the panel-meta import and spread |
| `Dock.tsx` | the tile, in `CORE_DOCK_ITEMS` |
| `usePageTitle.ts` | the title rule |
| `officerdev/src/index.ts` | the section-helper re-exports |
**Build time becomes the single source**, including the dock — the runtime `dockItemsFromPlugins` path is
to be corrected to follow this rather than left as a second source that can disagree.
### Presentation is build-time; permission stays runtime
The one line not to blur. `Plugins.tsx` says a tile exists at `/headscale`. Whether _this_ account sees it
is still asked per request, because grants change without a rebuild and the entire auth model rests on
re-reading the role rather than trusting a claim.
---
## Dependencies between plugins
The pilot has two, and they are **different kinds** — the same word covering two problems:
- **service** — `assist.ts` needs `officer-anthropic-proxy`. A runtime call over a wire. Loosely coupled,
and it already degrades: `ProxyUnavailable` → 503 `assistant_unavailable`.
- **code** — `ConsoleView` imports `TerminalView` from the Terminal panel app, and relies on
`/api/terminal/ws`. That is in its own bundle at build time. It cannot degrade; it resolves or the panel
does not build.
**The rule: a plugin may depend on another, and must degrade when it is absent.** Both of the above are
optional sections, which is why both are survivable.
### Service dependencies go through the API
**A plugin may call any API endpoint, carrying the user's token, with exactly the permissions that user has
anywhere else in the app.** A plugin is part of the application, not a guest in it.
Which means a plugin offering a service to other plugins **exposes it as routes**, like everything else. No
private side channels — and that deletes a real piece of debt: `claude-proxy.ts` currently reaches the
anthropic proxy by reading its private state file (`DATA_PATH/sidecar/claude-state.json`) to lift a secret.
Invisible, unversioned, and silently broken the day the proxy moves. An authenticated API call is better
regardless of plugins.
There is deliberately **no per-plugin permission list**. The bound is the user: a plugin can never exceed
the account calling it. What carries the weight instead is marketplace review — which makes review a
**security** boundary, not just a naming one. Worth knowing about the thing you are relying on.
---
## Developing a plugin
The platform is open source, so the development environment is a **platform checkout**. A developer clones
the platform, runs it in dev, and builds the plugin inside it — the WordPress model.
That dissolves what looked like the hardest problem. There are 13 workspace packages (`components`,
`hooks`, `state`, `helpers`, `types`, `officerdev`, `widgets`, …) and they resolve purely because
`"workspaces": ["src/workspaces/*"]` links them by name. So a plugin author writes
```ts
import { useClient } from 'hooks/useClient';
import { Card } from 'components/Card';
```
and it works, with no publishing, no package registry and no version negotiation — because the plugin sits
inside the workspace like any first-party code.
**And it makes dev-time and build-time the same mechanism.** A plugin builds on the server exactly as it
built on the laptop, so it cannot work in one and fail in the other.
`[open]` The plugin directory must be gitignored in the platform repo, so work in progress is not swept
into someone's commit. A plugin's own repository is cloned _into_ a platform checkout, never forked from it.
---
## Permissions
A plugin declares capabilities. **A plugin may declare `app`, and nothing else.**
@@ -290,6 +401,63 @@ one. Do not reintroduce a member-facing enrolment route on the assumption someth
---
## 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 state of the app store, as found
It **is** the plugin system, roughly 90% built, with one structural hole.
@@ -335,16 +503,19 @@ used in addition rather than instead — they span orgs, which matters because b
## Open questions
1. **Frontend code is the hard one.** Everything else on the list is data or a process; the SPA is
compiled. `public/plugins/<id>/` exists but carries only icons. Shipping a third party's React that
shares the shell's React, router and query client is a different problem class — federation, an iframe,
or a manifest-driven generic UI. Do not design the package format as though this is solved.
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.** The store already renders amber for "installed and enabled but the
process is not online". That state needs a definition a plugin can satisfy.
4. **No inter-plugin dependencies.** Measured: zero sidecar-to-sidecar dependencies, and every non-core
schema references only `auth.ts`. Worth promoting from accident to rule while it is still free.
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