Files
offscale/sidecar/client.ts
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

114 lines
4.9 KiB
TypeScript

import type { OffscaleServerCredentials } from '../db/queries';
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
// wire-level quirks are handled once:
//
// • Auth is `Authorization: Bearer <apiKey>`. Headscale's swagger declares no securityDefinitions at all,
// so a generated client would omit it entirely.
// • 401/403 bodies are PLAIN TEXT ("Unauthorized"), with no content-type — every other error is
// grpc-gateway `{code,message,details}` JSON. Blindly .json()-ing an error body throws on exactly the
// auth failure you most want to report clearly.
// • Every uint64 is serialized as a JSON STRING, not a number: `node.id` arrives as "7". We keep ids as
// strings end to end and never round-trip them through Number, which would silently break above 2^53.
// • The gateway marshals with EmitUnpopulated, so absent values come back as [] / null / "" / false rather
// than being omitted. You cannot distinguish "unset" from "empty" — don't try.
// • It also marshals with DiscardUnknown, so a misspelled request field is IGNORED rather than rejected.
// Silent no-ops are the failure mode; mutations here read the object back where the API returns it.
const DEFAULT_TIMEOUT_MS = 15_000;
/** An upstream failure carrying the HTTP status to surface, mapped to a response at the route boundary. */
export class OffscaleError extends Error {
constructor(
readonly status: number,
message: string,
/**
* Headscale's own words, kept even when `message` generalizes them.
*
* A 5xx is normally not safe to relay — it leaks internals and rarely helps. The policy endpoints are
* the exception: Headscale answers "policy is read from a file" and reports a HuJSON syntax error's
* line and column with the same 500, and there the message IS the feature. Callers that know their
* endpoint's 5xx is a real answer read this; everyone else keeps getting "headscale error".
*/
readonly detail?: string,
) {
super(message);
this.name = 'OffscaleError';
}
}
type CallOptions = { method?: string; body?: unknown; timeoutMs?: number };
/**
* Extract a human-usable message from a Headscale error response, tolerating both of its formats.
* Never returned verbatim to the browser for auth failures — see callers.
*/
async function errorMessage(res: Response): Promise<string> {
const text = await res.text().catch(() => '');
if (!text) return `upstream returned ${res.status}`;
try {
const parsed = JSON.parse(text) as { message?: unknown };
if (typeof parsed.message === 'string' && parsed.message) return parsed.message;
} catch {
/* plain text — the 401 case */
}
return text.slice(0, 300);
}
export type OffscaleClient = {
readonly serverId: number;
/** Call an admin API path (e.g. `/api/v1/node`). Throws OffscaleError on any non-2xx. */
call: <T>(path: string, opts?: CallOptions) => Promise<T>;
};
/** Build a client bound to one registered server's credentials. */
export function createClient(creds: OffscaleServerCredentials): OffscaleClient {
async function call<T>(path: string, opts: CallOptions = {}): Promise<T> {
const { method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
const headers: Record<string, string> = {
authorization: `Bearer ${creds.apiKey}`,
accept: 'application/json',
};
if (body !== undefined) headers['content-type'] = 'application/json';
let res: Response;
try {
res = await fetch(`${creds.url}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
const timedOut = err instanceof Error && err.name === 'TimeoutError';
throw new OffscaleError(504, timedOut ? 'headscale timed out' : 'headscale unreachable');
}
if (res.status === 401 || res.status === 403) {
// The stored key is wrong, expired, or was revoked on the server. Actionable, and distinct from an
// Officer-side auth problem — the UI should point the owner at re-entering the key.
throw new OffscaleError(502, 'headscale rejected the stored API key');
}
if (!res.ok) {
const message = await errorMessage(res);
console.error(`[headscale] ${method} ${path} -> ${res.status}: ${message}`);
// 4xx from the admin API is usually a bad argument and safe to relay; 5xx is not, so it's generalized.
const serverSide = res.status >= 500;
throw new OffscaleError(serverSide ? 502 : res.status, serverSide ? 'headscale error' : message, message);
}
// 204 and empty 200s are legitimate: several mutations return `{}` or nothing at all.
const text = await res.text();
if (!text) return {} as T;
try {
return JSON.parse(text) as T;
} catch {
throw new OffscaleError(502, 'headscale returned a non-JSON body');
}
}
return { serverId: creds.id, call };
}