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:
+177
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user