diff --git a/docs/offscale-plugin.md b/docs/offscale-plugin.md index 75eb9075..10b78b91 100644 --- a/docs/offscale-plugin.md +++ b/docs/offscale-plugin.md @@ -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) => } />); +``` + +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//` 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