Files
platform/docs/offscale-plugin.md
T
pastilhasandClaude Opus 5 bbc60b34ac 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>
2026-08-14 17:37:55 +00:00

16 KiB

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 pluginofficer-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-nameoffscale_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.tspermissions (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 credentialsservice_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:

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.