# 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:/_officer/nodes this sidecar ──▶ https:///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//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=` 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.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.