Files
offscale/OFFSCALE_API.md
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

134 lines
9.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.