headscale leaves the baseline, and offscale gets a design doc
first step of extracting headscale into a plugin. CORE_PROCESSES is five now, and the catalogue.test CORE[] mirror follows it — not optional, since that list asserts "the catalogue must not offer a core process" and would have blocked adding offscale to the catalogue later. the local generated ecosystem file lost its entry too, and officer-headscale was stopped and deleted from pm2 by hand. the platform still mounts /api/headscale and still declares the headscale and vpn capabilities, so the feature is present-but-unavailable rather than gone. docs/offscale-plugin.md is a live document for the rest of it. what it records that nothing else does: core is now `officer` alone and everything else is a plugin; routes are /api/<app-name> for ours and /api/p/<creator>/<app-name> for third parties, derived by one function so the two can never become two systems; tables stay in public with an app-name prefix; mounting becomes genuinely dynamic, which retires the "every route stays mounted" premise and relocates assertCapabilityTotality from a boot check to a per-mount transaction. it also records a rejected experiment with evidence — a postgres schema per plugin works completely, including cross-schema FK, idempotent push and DROP SCHEMA CASCADE as uninstall — and the reason not to: drizzle-kit 0.31.8 needs schemaFilter naming every schema, contradicting its own docs, and without it push reports "No changes detected" and creates nothing. a plugin install that reports success and makes no tables is the exact failure shape we have hit three times this week. and /api/vpn is dead: no caller in the mobile monorepo, none in the web app, no grants in the database. offscale is permanently standalone, so it never comes back. the invite flow is unaffected — the phone claims from the Companion, not from officer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
# 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.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
**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.
|
||||
|
||||
**Open:** the `vpn` capability is the only member-grantable piece of headscale — everything else is
|
||||
`admin`. If it disappears with the route, members lose the ability to enrol their own devices. Decide
|
||||
deliberately rather than losing it in the move.
|
||||
|
||||
---
|
||||
|
||||
## 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.** 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.
|
||||
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.
|
||||
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