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.
This commit is contained in:
2026-08-15 18:41:52 +00:00
parent 8a446bb4b5
commit 95b84ea748
42 changed files with 658 additions and 346 deletions
+24 -24
View File
@@ -1,10 +1,10 @@
// Shared types/constants for the /headscale workspace panels. Everything here mirrors the wire shapes the
// officer-headscale sidecar returns under /api/headscale/_officer/* — deliberately NOT Headscale's own API
// officer-offscale sidecar returns under /api/offscale/_officer/* — deliberately NOT Headscale's own API
// shapes. The sidecar absorbs Headscale's quirks (uint64-as-string ids, zero-date sentinels, the version
// floor), so these types are stable across Headscale releases and the browser never learns the upstream
// version. See src/servers/sidecar/headscale/routes.ts.
// version. See ../sidecar/routes.ts, and ../OFFSCALE_API.md for the published contract.
export const HEADSCALE_SECTIONS = [
export const OFFSCALE_SECTIONS = [
{ id: 'servers', label: 'Servers' },
{ id: 'nodes', label: 'Nodes' },
{ id: 'users', label: 'Users' },
@@ -15,19 +15,19 @@ export const HEADSCALE_SECTIONS = [
{ id: 'console', label: 'Console' },
] as const;
export type HeadscaleSectionId = (typeof HEADSCALE_SECTIONS)[number]['id'];
export type OffscaleSectionId = (typeof OFFSCALE_SECTIONS)[number]['id'];
/** Where /headscale lands, and where an unrecognised section redirects to. */
export const DEFAULT_HEADSCALE_SECTION: HeadscaleSectionId = 'servers';
export const DEFAULT_OFFSCALE_SECTION: OffscaleSectionId = 'servers';
export const isHeadscaleSection = (value: string | undefined): value is HeadscaleSectionId =>
HEADSCALE_SECTIONS.some((s) => s.id === value);
export const isOffscaleSection = (value: string | undefined): value is OffscaleSectionId =>
OFFSCALE_SECTIONS.some((s) => s.id === value);
/** The one place the section URL is spelled, so the nav, the guard and any deep link cannot drift apart. */
export const headscaleSectionPath = (id: HeadscaleSectionId) => `/headscale/${id}`;
export const offscaleSectionPath = (id: OffscaleSectionId) => `/offscale/${id}`;
/** A registered Headscale server. The API key is never included — it stays encrypted in Postgres. */
export type HeadscaleServer = {
export type OffscaleServer = {
id: number;
name: string;
url: string;
@@ -44,7 +44,7 @@ export type HeadscaleServer = {
};
/** Result of GET /_officer/servers/:id/health — reachable AND the stored key still works. */
export type HeadscaleHealth = {
export type OffscaleHealth = {
ok: boolean;
version?: string;
/** `'unknown'` for self-built servers reporting the literal 'dev'. */
@@ -54,10 +54,10 @@ export type HeadscaleHealth = {
};
/** Result of POST /_officer/ssh-test — can we open a shell there with the keys already on this box. */
export type HeadscaleSshTest = { ok: boolean; error?: string; ms: number };
export type OffscaleSshTest = { ok: boolean; error?: string; ms: number };
/** Officer's supported floor, restated for UI copy. The sidecar is the enforcer; this is only a label. */
export const MIN_HEADSCALE_VERSION = '0.29';
export const MIN_OFFSCALE_VERSION = '0.29';
// ── Access policy ─────────────────────────────────────────────────────────────────────────────────
@@ -66,7 +66,7 @@ export const MIN_HEADSCALE_VERSION = '0.29';
* whether it is stored in the database or read from a file — so this carries no "is it editable" flag,
* because there is nothing on the server that reports one. Only an attempted save finds out.
*/
export type HeadscalePolicy = {
export type OffscalePolicy = {
policy: string;
/** Null when Headscale has never recorded one, which includes every file-backed policy. */
updatedAt: string | null;
@@ -81,7 +81,7 @@ export const POLICY_REJECTED = 'policy_rejected';
// The Officer Companion is a service deployed next to a Headscale server that can see the container the
// admin API is served from: whether it is running, what it logged, and start/stop/restart. It is optional
// and per-server, so `available: false` is a first-class state rather than an error — the admin API on the
// same domain is independent and may still work. Contract: COMMS/HEADSCALE_COMPANION_API.md.
// same domain is independent and may still work. Contract: COMMS/OFFSCALE_COMPANION_API.md.
/** Never available for a companion that is missing — the reason says which flavour of missing. */
type Unavailable = { available: false; reason: string };
@@ -128,7 +128,7 @@ export type CompanionActionResult =
// ── Domain objects ────────────────────────────────────────────────────────────────────────────────
// Ids are strings because Headscale's are uint64 — never parse them to numbers.
export type HeadscaleUser = {
export type OffscaleUser = {
id: string;
name: string;
displayName: string | null;
@@ -138,13 +138,13 @@ export type HeadscaleUser = {
createdAt: string | null;
};
export type HeadscaleUserWithCounts = HeadscaleUser & { nodeCount: number; onlineCount: number };
export type OffscaleUserWithCounts = OffscaleUser & { nodeCount: number; onlineCount: number };
export type HeadscaleNode = {
export type OffscaleNode = {
id: string;
name: string;
hostname: string;
user: HeadscaleUser | null;
user: OffscaleUser | null;
ipAddresses: string[];
online: boolean;
lastSeen: string | null;
@@ -162,13 +162,13 @@ export type HeadscaleNode = {
isExitNode: boolean;
};
export type HeadscalePreAuthKey = {
export type OffscalePreAuthKey = {
id: string;
/** Non-null ONLY on the creation response — the sidecar strips the secret from every list. */
key: string | null;
/** A never-usable label for telling keys apart in a list, e.g. `hskey-auth-a1b2c3-***`. */
keyDisplay: string;
user: HeadscaleUser | null;
user: OffscaleUser | null;
reusable: boolean;
ephemeral: boolean;
used: boolean;
@@ -189,7 +189,7 @@ export const NO_ACTIVE_SERVER = 'no_active_server';
export type InviteStatus = 'pending' | 'claimed' | 'expired' | 'revoked';
/** What the admin list returns. It carries no claim token and no key — by design, at every status. */
export type HeadscaleInvite = {
export type OffscaleInvite = {
id: string;
user: string;
note?: string | null;
@@ -206,7 +206,7 @@ export type HeadscaleInvite = {
* The create response — the ONLY time the link exists. Its fragment holds the claim token, so it is held in
* component state, shown once, and never written to a cache, a query key or a log.
*/
export type HeadscaleInviteCreated = HeadscaleInvite & { url: string };
export type OffscaleInviteCreated = OffscaleInvite & { url: string };
export type InviteCreateInput = {
user: string;
@@ -216,8 +216,8 @@ export type InviteCreateInput = {
note: string;
};
export type InvitesListResult = { available: true; invites: HeadscaleInvite[] } | Unavailable;
export type InviteCreateResult = { available: true; invite: HeadscaleInviteCreated } | Unavailable;
export type InvitesListResult = { available: true; invites: OffscaleInvite[] } | Unavailable;
export type InviteCreateResult = { available: true; invite: OffscaleInviteCreated } | Unavailable;
/** Spec §4.1. The floor is Officer's: a sub-minute invite cannot be sent to anybody in time. */
export const INVITE_TTL_MIN_SECONDS = 60;