Files
offscale/OFFSCALE_API.md
T
pastilhas 95b84ea748 rebrand to OffScale, and fix what the first extraction missed
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.
2026-08-15 18:41:52 +00:00

9.3 KiB
Raw Blame History

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):

  1. 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.
  2. Unset timestamps are the protobuf zero value, serialised as 0001-01-01T00:00:00Z rather than omitted. Rendered naively that reads as the year 1; it means "never", so it becomes null.
  3. EmitUnpopulated means absent repeated fields arrive as [] and absent messages as null. 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.230.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.