Offscale was the first plugin extracted and it was done before we knew what "extracted" meant. Music, done last, is the standard. This brings offscale to it. ── The rebrand ── The plugin was `offscale` to the platform and `headscale` to itself: sidecar name and handles, the port announcement, the API proxy name, the React components, every hook, the react-query keys, the panel ids and appTypes, and the Postgres table. Now all of those say offscale. The line drawn, and it is deliberate: OffScale is Officer's tooling layer, and Headscale is the server it manages. So every IDENTIFIER is offscale, while a message like `headscale unreachable`, the `headscale apikeys create` hint and the ACL assistant's prompt still say Headscale — because they are talking about the remote server, and renaming them would make the code lie about what it reached. 495 occurrences became 180, and the 180 are all of that second kind. ── The live bug this uncovered ── `headscaleSectionPath` built links to `/headscale/<section>`. The shell has no such route — plugin routes come from `plugin.route`, which is `/offscale` — and it redirects unknown paths to the home page. So every section link in the nav, the console and the server picker silently went home. The extraction moved the route and left the link builder behind. Also live: ServersView told the user to run `pm2 start ecosystem.config.cjs --only officer-headscale`, a process that has not existed since the sidecar was renamed. ── The correctness fix music already had ── api/router.ts hardcoded `prefix: '/api/offscale'`. The proxy strips `prefix.length` characters, so a literal is correct only for a first-party publisher; published by anyone else this mounts at `/api/p/<publisher>/offscale` and forwards the wrong subpath. Derived from `mountPrefix()` now, as music does. ── The rest ── - assets/icon.png — the OffScale artwork, 256px to match music's. The tile stops being a glyph badge. - First tests: 21 of them, over the version floor and the protobuf normalisers. Those are the two places a Headscale release actually breaks this, and they had no coverage at all. `meetsFloor` has a real trap pinned now — comparing minor first would refuse 1.0 as older than 0.29. - OFFSCALE_API.md — the contract was a 45-line comment inside sidecar/index.ts, which is not linkable and not published. Now a document, as MUSIC_API.md is. - web/panels.ts re-exported three components. A plugin cannot export components; that was residue of the platform importing them before extraction. - Comments pointed at src/servers/api/headscale/ and src/servers/sidecar/headscale/, neither of which has existed since the extraction. The crypto purpose moved headscale → offscale too, and the secret-store row was renamed rather than left to create a fresh key — the material is preserved, so this is reversible. Free to do only because offscale_servers had 0 rows; with one stored API key it would have been a migration.
9.3 KiB
The OffScale HTTP contract
What officer-offscale serves, and what the platform forwards to it.
OffScale is Officer's tooling layer over Headscale. It adds nothing destructive and deviates from
nothing Headscale does — it composes, normalises and gates. The naming follows that split exactly:
everything Officer owns is offscale; the server being managed is a Headscale server and is still
called one. So the sidecar registers as offscale, mounts at /api/offscale, and stores its rows in
offscale_servers — while an error saying headscale unreachable means precisely what it says.
How a request gets here
browser ──▶ /api/offscale/_officer/nodes the platform: auth + permission gate only
──▶ createSidecarProxy strips the prefix ../api/router.ts, derived from mountPrefix()
──▶ 127.0.0.1:<ephemeral>/_officer/nodes this sidecar
──▶ https://<registered>/api/v1/node the Headscale admin API, with the stored key
The platform holds no Headscale credential and does not know any Headscale URL. It knows a permission key and a port. That is the whole of its involvement.
The prefix is derived from mountPrefix(), never written as a literal — the proxy strips
prefix.length characters, so a hardcoded /api/offscale would forward the wrong subpath the moment
this plugin were published by anyone but officerdev (it would mount at /api/p/<publisher>/offscale).
Many servers, one active
Officer manages many Headscale servers, not one. Each is registered with a URL and an API key generated on that server; one is active at a time.
Configuration therefore lives in Postgres (offscale_servers, keys encrypted at rest) and not in
environment variables. This sidecar deliberately reads neither OFFSCALE_URL nor OFFSCALE_API_KEY, so
a registered server can never be silently shadowed by host env.
Every route below /_officer/servers acts on the active server and answers 409 when none is
selected.
Routes
Liveness
| Method | Path | Notes |
|---|---|---|
GET |
/_health |
This sidecar only. Per-server reachability is a different question — see /servers/:id/health. |
Server registry
| Method | Path | Notes |
|---|---|---|
GET |
/_officer/servers |
Registered servers. Never includes API keys. |
POST |
/_officer/servers |
{name?, url, apiKey} — validated against the server before it is saved. |
PATCH |
/_officer/servers/:id |
Re-validated when url or apiKey changes. |
DELETE |
/_officer/servers/:id |
Promotes the newest survivor if the deleted one was active. |
POST |
/_officer/servers/:id/activate |
Switch the active server. |
GET |
/_officer/servers/:id/health |
Reachable? Version? Is the key still accepted? |
Nodes
| Method | Path | Notes |
|---|---|---|
GET |
/_officer/nodes |
Normalised. ?user=<username> filters. |
GET |
/_officer/nodes/:id |
|
DELETE |
/_officer/nodes/:id |
Removes it from the tailnet. |
POST |
/_officer/nodes/:id/rename |
{name} |
POST |
/_officer/nodes/:id/tags |
{tags} — a tag: prefix is added when missing. |
POST |
/_officer/nodes/:id/routes |
{routes} for the whole set, or {route, approved} for a single toggle (read-modify-write happens here). |
POST |
/_officer/nodes/:id/expire |
Expires its key, forcing re-auth. Not a delete. |
Users
| Method | Path | Notes |
|---|---|---|
GET |
/_officer/users |
Each with a node count the admin API does not provide. |
POST |
/_officer/users |
{name, displayName?, email?} |
POST |
/_officer/users/:id/rename |
{name} |
DELETE |
/_officer/users/:id |
Refused upstream while the user still owns nodes. |
Pre-auth keys
| Method | Path | Notes |
|---|---|---|
GET |
/_officer/keys |
Secrets masked, with a derived status. |
POST |
/_officer/keys |
{userId, reusable?, ephemeral?, expirationDays?, aclTags?} — the only response carrying the real secret. |
POST |
/_officer/keys/:id/expire |
Expire without deleting. |
DELETE |
/_officer/keys/:id |
Delete outright. |
Enrollment
| Method | Path | Notes |
|---|---|---|
POST |
/_officer/enroll |
{userId?} → {controlUrl, authKey}, a single-use 10-minute key for a joining device. userId is required only when the server has more than one user. |
/_officer/enroll has had no direct caller since 2026-08-14, when /api/vpn/enroll was deleted. It
is kept because it is the handler a route under /api/offscale would reuse, and because
/enroll/invites — which is live — dispatches through the same function.
Anything else: 404.
There is deliberately no /api/v1/* passthrough
Headscale's REST shape changed repeatedly below 0.29 and its ids are uint64-as-JSON-string. Proxying raw would push all of that into the browser — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here instead.
The three protobuf leaks, and where they are contained
Headscale's REST layer is a gRPC gateway marshalling protobuf. It leaks in exactly three ways, all
handled in sidecar/normalize.ts (and pinned by sidecar/normalize.test.ts):
- Every uint64 is a JSON string. Ids stay strings end to end. Never
Number()them — it breaks silently above 2^53, and Headscale's ids are database-assigned, not small by contract. - Unset timestamps are the protobuf zero value, serialised as
0001-01-01T00:00:00Zrather than omitted. Rendered naively that reads as the year 1; it means "never", so it becomesnull. EmitUnpopulatedmeans absent repeated fields arrive as[]and absent messages asnull. There is no way to distinguish "unset" from "empty", so every accessor tolerates both.
The version floor
Officer targets Headscale >= 0.29 and nothing older, checked once at registration by an
unauthenticated GET /version on the server itself. Below that floor the admin API changed shape
repeatedly — identifiers went name→numeric at 0.26, /api/v1/routes was removed at 0.26 in favour of
node-owned route sets, forcedTags/validTags collapsed into tags at 0.28, pre-auth key expiry became
id-based at 0.28, and MoveNode was removed at 0.28. Supporting 0.23–0.28 would mean carrying several
incompatible data models; refusing them at registration costs one probe.
A version that will not parse — a self-built image reporting the literal dev — is reported as
supported: 'unknown' rather than refused. Locking out legitimately self-built deployments would be the
worse failure. sidecar/version.test.ts pins that case specifically.
Do not confuse GET /version with the two similarly-named endpoints: GET /health (root,
unauthenticated, {status:'pass'}) and GET /api/v1/health (authenticated,
{databaseConnectivity:true}) carry no version at all.