written against the real plugin rather than invented as a field list, on the theory that an abstract one includes what nothing needs and misses what is awkward. that paid off on the first field that mattered. the rule here said a plugin may declare `app` and nothing else. offscale's capability is `admin` — owner only — and should stay that way, so the rule was wrong. the distinction is direction, not privilege: `core` means every account and not deniable, so claiming it grants yourself to everyone; `admin` means owner only, which is a plugin restricting itself. corrected table in the doc. core, execution and confined stay the platform's to assign. `publisher` is the only input to the mount prefix, through one function, so first-party and third-party cannot drift into two code paths. sidecar.runtime is a field because officer-pty needs node for node-pty's abi while everything else is bun — one plugin already needs it, so not speculative. dependsOn is informational and unenforced. code dependencies need no declaration now that a plugin builds inside the workspace, and service dependencies already degrade; this exists so the store can say the console section wants the terminal plugin, rather than the section silently doing nothing. health is marked deferred rather than open, with the reasoning, so it does not get re-raised. migrations likewise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
640 lines
34 KiB
Markdown
640 lines
34 KiB
Markdown
# Offscale — the first real plugin
|
|
|
|
**Status: LIVE DOCUMENT, opened 2026-08-14.** Decisions and findings from the session that started the
|
|
plugin system. Correct it in place; it is meant to be edited, not archived.
|
|
|
|
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 is dynamic
|
|
|
|
Three options were considered:
|
|
|
|
- **A** — mounted always, refuses when not installed _(what ships today)_
|
|
- **B** — mount set decided at boot from the install table, restart `officer` on install
|
|
- **C** — genuinely dynamic, mounted and unmounted at runtime
|
|
|
|
**C is the decision.** Explicitly not an intermediate step: the platform must not need to know a plugin
|
|
exists in advance, and B still bakes the answer at boot.
|
|
|
|
This contradicts `sidecar-app-store.md`, which leans on "every API route stays mounted regardless" as a
|
|
simplification. That premise is retired.
|
|
|
|
### What has to change with it
|
|
|
|
`assertCapabilityTotality` runs before `serve()` and throws if a mounted router has no registry entry. It
|
|
exists for a real reason: a Member 403'd on `GET /api/tasks` while opening `/api/tasks/pipeline/ws` with a
|
|
101 in the same minute, because Bun's route table matches the socket before the `/api/*` catch-all.
|
|
|
|
**It does not die, it relocates.** Today it asks "does every mounted route have a permission?" once, at
|
|
boot. Under C the same question is asked **per mount**: registering a router and registering its
|
|
capability become one transaction, and an incomplete one is refused.
|
|
|
|
### Foundations that already exist
|
|
|
|
- Sidecars register at **runtime** over `/api/sidecar/register` and are found **by capability, never by
|
|
name** — which is how `officer-agent` was renamed to `officer-claude-code` without touching a caller.
|
|
- `sidecar/proxied-prefixes.ts` is already a runtime-mutable `Set`, self-registered by
|
|
`createSidecarProxy` "so a new sidecar cannot forget to add itself".
|
|
|
|
What is compile-time is only the routes and permissions, not the sidecar's existence.
|
|
|
|
---
|
|
|
|
## 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.**
|
|
|
|
`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.
|
|
|
|
### 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
|
|
export const manifest = {
|
|
// ── Identity ─────────────────────────────────────────────────────────────
|
|
/** THE id. Route segment, table prefix, sidecar suffix, install key. One name, everywhere. */
|
|
appName: 'offscale',
|
|
/** Who published it. The ONLY input that decides the mount prefix — see `mountPrefix()`. */
|
|
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',
|
|
|
|
// ── Presentation ─────────────────────────────────────────────────────────
|
|
label: 'Offscale',
|
|
summary: 'Your tailnet — machines, users, pre-auth keys and access policy',
|
|
icon: 'Network',
|
|
color: '#818cf8',
|
|
|
|
// ── Backend ──────────────────────────────────────────────────────────────
|
|
sidecar: {
|
|
/** PM2 process name. Always `officer-<appName>`, but written out because PM2 is told it verbatim. */
|
|
process: 'officer-offscale',
|
|
/** `bun` or `node`. A field rather than an assumption: officer-pty needs node for node-pty's ABI. */
|
|
runtime: 'bun',
|
|
script: 'sidecar/index.ts',
|
|
},
|
|
|
|
/** Permissions this plugin defines. `app` or `admin` only — see below. */
|
|
capabilities: [
|
|
{
|
|
key: 'offscale',
|
|
label: 'Offscale',
|
|
description: 'The tailnet: machines, routes and ACLs',
|
|
kind: 'admin',
|
|
},
|
|
],
|
|
|
|
// ── Data ─────────────────────────────────────────────────────────────────
|
|
/** Drizzle schema. Every table must be prefixed `offscale_`; the installer enforces it. */
|
|
schema: 'db/schema.ts',
|
|
|
|
// ── Frontend ─────────────────────────────────────────────────────────────
|
|
frontend: {
|
|
/** Default export is mounted at `<prefix>/*` by the generated Plugins.tsx. */
|
|
router: 'web/Router.tsx',
|
|
/** `appRegistryMetas` — the panel apps this plugin contributes. */
|
|
panels: 'web/panels.ts',
|
|
dock: { label: 'Offscale', to: '/offscale' },
|
|
title: 'Offscale',
|
|
},
|
|
|
|
// ── Optional dependencies ────────────────────────────────────────────────
|
|
// Informational, not enforced: both are service dependencies that already degrade. They exist so the
|
|
// store can say "the Console section needs the terminal plugin" instead of the section silently not
|
|
// working, which is the difference between a missing feature and a broken one.
|
|
dependsOn: [
|
|
{ appName: 'anthropic-proxy', optional: true, reason: 'the ACL drafting assistant' },
|
|
{ appName: 'pty', optional: true, reason: 'the SSH console section' },
|
|
],
|
|
} as const;
|
|
```
|
|
|
|
### `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.
|
|
|
|
---
|
|
|
|
## 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.
|