Files
offscale/web/shared.ts
T
pastilhasandClaude Opus 5 373d67e0a0 finish the rebrand: the parts a user reads, and the manifest that was wrong
The extraction landed months ago and the platform side is clean. What was left was
inside the plugin, and it was the half that shows.

── Names a user actually reads ──

The three registered panels were still called "Headscale servers", "Headscale" and
"Headscale" in the panel picker, and the server picker rendered "Headscale" as its
own heading. Those name OUR panels, not the upstream server, so they were simply
missed.

Deliberately NOT a find-and-replace. Most of the ~175 "headscale" strings in here
are correct — "no active Headscale server", "headscale timed out", "is this a
Headscale server?" all refer to the thing this plugin drives, and renaming them
would make the errors lie. What was fixed is the plugin's own identity, plus nine
stale `/headscale` route paths that are wrong rather than merely old (the route is
`/offscale`) and one reference to `queries/headscale.ts`, a file the extraction
deleted.

The console's pty session key moves from `headscale-console-<id>` to
`offscale-console-<id>`. That one has a real cost: a console open at the moment of
the change keeps running in the sidecar under the old key with nothing pointing at
it, until that sidecar restarts. Paid once, deliberately, rather than carrying the
pre-extraction name in persisted state forever.

── The manifest was missing a dependency it has always had ──

`sidecar/ssh.ts` spawns `ssh`, and so does the reachability probe. The manifest
said "Offscale needed nothing", so install checked for nothing and a host without
an ssh client failed at RUNTIME with a spawn error — precisely the failure
`osDependencies` exists to turn into a refusal at install time. PLUGIN.md listed
ssh as a host dependency all along; the manifest was the file that was wrong.

Missed because ssh is on effectively every Linux box, which is also why declaring
it costs nothing there and is the whole difference where it is absent.

── Documentation that said the opposite of the code ──

PLUGIN.md instructed the reader to carry `assist.ts` in "but do not wire it up",
as a marker. It has been wired for some time: `policy.ts` routes
`POST /_officer/policy/assist`, `PolicyView.tsx` renders `<PolicyAssistant />`, and
the manifest declares the route in `readOnlyWrites`. Corrected rather than deleted,
because that instruction is exactly the kind someone would follow — a person
tidying "unused" code would have found it wired and assumed the document knew
something they did not.

Present-tense identifiers corrected to the shipped names; the "before" columns of
the migration tables still say headscale, because that is what they record. A note
at the top says which is which.

tsgo clean, 21 plugin tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 04:23:28 +00:00

226 lines
9.6 KiB
TypeScript

// Shared types/constants for the /offscale workspace panels. Everything here mirrors the wire shapes the
// 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 ../sidecar/routes.ts, and ../OFFSCALE_API.md for the published contract.
export const OFFSCALE_SECTIONS = [
{ id: 'servers', label: 'Servers' },
{ id: 'nodes', label: 'Nodes' },
{ id: 'users', label: 'Users' },
{ id: 'keys', label: 'Pre-auth keys' },
{ id: 'invites', label: 'Device invites' },
{ id: 'policy', label: 'Access policy' },
{ id: 'diagnostics', label: 'Diagnostics' },
{ id: 'console', label: 'Console' },
] as const;
export type OffscaleSectionId = (typeof OFFSCALE_SECTIONS)[number]['id'];
/** Where /offscale lands, and where an unrecognised section redirects to. */
export const DEFAULT_OFFSCALE_SECTION: OffscaleSectionId = 'servers';
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 offscaleSectionPath = (id: OffscaleSectionId) => `/offscale/${id}`;
/** A registered Headscale server. The API key is never included — it stays encrypted in Postgres. */
export type OffscaleServer = {
id: number;
name: string;
url: string;
version: string | null;
/**
* Where the Console section SSHes. Null when unset. Not derived from `url` on purpose — it exists to reach
* the machine when the control plane's own hostname has stopped answering.
*/
sshHost: string | null;
isActive: boolean;
/** ISO string, or null when we have never successfully probed it. */
lastSeenAt: string | null;
createdAt: string;
};
/** Result of GET /_officer/servers/:id/health — reachable AND the stored key still works. */
export type OffscaleHealth = {
ok: boolean;
version?: string;
/** `'unknown'` for self-built servers reporting the literal 'dev'. */
supported?: boolean | 'unknown';
error?: string;
ms: number;
};
/** Result of POST /_officer/ssh-test — can we open a shell there with the keys already on this box. */
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_OFFSCALE_VERSION = '0.29';
// ── Access policy ─────────────────────────────────────────────────────────────────────────────────
/**
* The tailnet's ACL document, in HuJSON (JSON with comments and trailing commas). Headscale serves it
* 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 OffscalePolicy = {
policy: string;
/** Null when Headscale has never recorded one, which includes every file-backed policy. */
updatedAt: string | null;
};
/** Headscale refused the write outright — this server's policy is read-only over the API. */
export const POLICY_READ_ONLY = 'policy_read_only';
/** Headscale parsed the document and rejected it. The message is a syntax position or a bad reference. */
export const POLICY_REJECTED = 'policy_rejected';
// ── Companion API ─────────────────────────────────────────────────────────────────────────────────
// 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/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 };
export type CompanionVerdict = 'ok' | 'degraded' | 'down' | 'unknown';
export type CompanionContainer = {
status: string;
running: boolean;
exitCode: number;
restartCount: number;
startedAt: string;
/** The RFC3339 zero date (`0001-…`) while the container is running. */
finishedAt: string;
/** Null when the image defines no healthcheck. */
healthcheck: string | null;
};
export type CompanionHealthBody = {
verdict: CompanionVerdict;
/** Whether Headscale's own HTTP is answering — the "is the control plane serving?" signal. */
connected: boolean;
container?: CompanionContainer;
/** Human string for the probe outcome, e.g. `GET /health -> 200`, `unreachable`, `container not running`. */
probe?: string;
/** Only on `unknown`: docker has no container by that name. */
reason?: string;
/** Only when not ok — best-effort guesses read out of the logs. May be empty. */
likelyCauses?: string[];
healthcheckOutput?: string | null;
recentLogs?: string[];
};
export type CompanionHealthResult = ({ available: true } & { health: CompanionHealthBody }) | Unavailable;
export type CompanionLogsResult = { available: true; lines: string[] } | Unavailable;
/** The three lifecycle verbs. `stop`/`start` are what the companion calls `disconnect`/`reconnect`. */
export type CompanionAction = 'restart' | 'stop' | 'start';
export type CompanionActionResult =
| { available: true; ok: boolean; action?: string; result?: string; error?: string }
| Unavailable;
// ── Domain objects ────────────────────────────────────────────────────────────────────────────────
// Ids are strings because Headscale's are uint64 — never parse them to numbers.
export type OffscaleUser = {
id: string;
name: string;
displayName: string | null;
email: string | null;
provider: string | null;
profilePicUrl: string | null;
createdAt: string | null;
};
export type OffscaleUserWithCounts = OffscaleUser & { nodeCount: number; onlineCount: number };
export type OffscaleNode = {
id: string;
name: string;
hostname: string;
user: OffscaleUser | null;
ipAddresses: string[];
online: boolean;
lastSeen: string | null;
/** Null means the node's key never expires. */
expiry: string | null;
createdAt: string | null;
registerMethod: string;
tags: string[];
/** What the node advertises. */
availableRoutes: string[];
/** What the admin has approved — the writable set. */
approvedRoutes: string[];
/** What is actually in effect. */
subnetRoutes: string[];
isExitNode: boolean;
};
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: OffscaleUser | null;
reusable: boolean;
ephemeral: boolean;
used: boolean;
expiration: string | null;
createdAt: string | null;
aclTags: string[];
status: 'active' | 'used' | 'expired';
};
/** The sidecar's 409 when no server is selected, distinguished from a genuine 404. */
export const NO_ACTIVE_SERVER = 'no_active_server';
// ── Device invites ────────────────────────────────────────────────────────────────────────────────
// An invite is a link the admin sends to whoever needs to join. The pre-auth key is minted when the link is
// claimed, not when it is created, so an unused invite never has a credential attached to it. Contract:
// COMMS/OFFSCALE_INVITE_ENROLLMENT.md; the records live on the server's companion, never in Officer.
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 OffscaleInvite = {
id: string;
user: string;
note?: string | null;
status: InviteStatus;
ephemeral?: boolean;
tags?: string[];
createdAt?: string | null;
expiresAt?: string | null;
claimedAt?: string | null;
claimedFromIp?: string | null;
};
/**
* 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 OffscaleInviteCreated = OffscaleInvite & { url: string };
export type InviteCreateInput = {
user: string;
ttlSeconds: number;
ephemeral: boolean;
tags: string[];
note: string;
};
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;
export const INVITE_TTL_DEFAULT_SECONDS = 900;
export const INVITE_TTL_MAX_SECONDS = 86_400;