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>
This commit is contained in:
@@ -109,7 +109,7 @@ adding core routes forever without breaking installs.
|
||||
## The database
|
||||
|
||||
**Tables live in `public`, prefixed with the app-name** — `offscale_servers`, exactly as the codebase
|
||||
already does (`headscale_servers`, `music_favorites`, `vault_tokens`). No new machinery.
|
||||
already does (`music_favorites`, `vault_tokens`). No new machinery.
|
||||
|
||||
### A Postgres schema per plugin was tested and rejected
|
||||
|
||||
@@ -255,7 +255,7 @@ use is read for most roles. Say Developers get `read` and nobody gets `write`.
|
||||
Two implementation consequences, both inside the plugin:
|
||||
|
||||
1. **The queries stop scoping by the caller.** Every one takes the caller's `userId` today —
|
||||
`listHeadscaleServers(userId)`, `getActiveHeadscaleCredentials(userId)` — and the schema is per-user
|
||||
`listOffscaleServers(userId)`, `getActiveOffscaleCredentials(userId)` — and the schema is per-user
|
||||
because of it. Under this model a member sees the **owner's** rows, so those resolve to the owner's id
|
||||
always. The per-user shape stays in the table, unused, and becomes the seam if isolation is ever wanted.
|
||||
|
||||
@@ -399,7 +399,7 @@ Read end to end on 2026-08-14. This is what has to move.
|
||||
|
||||
### Backend — 2,406 lines
|
||||
|
||||
`/api/headscale` is **18 lines**: a pure `createSidecarProxy`, no Headscale knowledge, "must never grow app
|
||||
`/api/offscale` is **18 lines**: a pure `createSidecarProxy`, no Headscale knowledge, "must never grow app
|
||||
logic". Everything is in the sidecar under `/_officer/*`, dispatched by `routes.ts` to eight handlers —
|
||||
`servers · nodes · users · keys · policy · enroll · ssh-test · companion`.
|
||||
|
||||
@@ -422,9 +422,9 @@ Host dependencies: `officerdb` (db + crypto), `DATA_PATH`, `officer-url.mjs`, `c
|
||||
|
||||
### Frontend — 29 files, 27 endpoints
|
||||
|
||||
Three registered panels (`headscale-servers`, `headscale-nav`, `headscale-view`, all
|
||||
`availableOnPanel: false`) inside a locked `WorkspaceView`, with `headscale-view` dispatching on
|
||||
`useHeadscaleSection()` to eight section views: Servers · Nodes · Users · Keys · Invites · Policy ·
|
||||
Three registered panels (`offscale-servers`, `offscale-nav`, `offscale-view`, all
|
||||
`availableOnPanel: false`) inside a locked `WorkspaceView`, with `offscale-view` dispatching on
|
||||
`useOffscaleSection()` to eight section views: Servers · Nodes · Users · Keys · Invites · Policy ·
|
||||
Diagnostics · Console.
|
||||
|
||||
It **follows the navigation conventions** — no `usePanelChannel` anywhere, no opaque clicks, the section
|
||||
@@ -442,11 +442,25 @@ The whole frontend↔host coupling, which becomes the plugin API:
|
||||
| `state/useDashboardState` | per-user layout, backed by `/api/dashboards`, a `core` permission — stays host-provided |
|
||||
| `../Terminal/Terminal` → `TerminalView` | **the awkward one** — a code dependency on another panel app |
|
||||
|
||||
### `assist.ts` travels, but stays unwired
|
||||
> **On names.** This document was written before the rebrand and describes the extraction as it was
|
||||
> planned. Present-tense claims about identifiers were corrected to the shipped names on 2026-08-16;
|
||||
> the "before" columns of the migration tables still say `headscale` on purpose, because that is what
|
||||
> they are recording. Where this file and the code disagree, the code is right.
|
||||
|
||||
The ACL-drafting assistant was written and never tested. **Carry it into the plugin, do not delete it, and
|
||||
do not wire it up** — it is there as a marker that the idea exists, to be finished or removed deliberately
|
||||
later. Do not tidy it away as unused code.
|
||||
### `assist.ts` — WIRED, and this section used to say the opposite
|
||||
|
||||
This said "carry it into the plugin, do not wire it up" and told the reader to leave it as a marker. That
|
||||
has not been true for some time: `sidecar/policy.ts:5` imports `handlePolicyAssistRoute` and routes
|
||||
`POST /_officer/policy/assist`, `web/PolicyView.tsx:158` renders `<PolicyAssistant />`, and the manifest
|
||||
lists `/policy/assist` in `readOnlyWrites` — which is a declaration that the route exists and is a read.
|
||||
|
||||
Corrected 2026-08-16 rather than deleted, because the instruction that was here is exactly the kind a
|
||||
future reader would have followed: someone tidying "unused" code would have found it wired and assumed the
|
||||
document knew something they did not. A doc that confidently describes the opposite of the code is worse
|
||||
than no doc.
|
||||
|
||||
What is still true: it drafts, it never writes. The route returns a proposed policy and the owner saves it
|
||||
through the normal `PUT /_officer/policy` path or not at all.
|
||||
|
||||
---
|
||||
|
||||
@@ -510,7 +524,7 @@ layouts rather than one application. Non-compliance is not refused so much as **
|
||||
is nowhere to put a screen.
|
||||
|
||||
The shell registers the pair `<prefix>` and `<prefix>/:section`, exactly as the core screens do
|
||||
(`/headscale/:section`), so a plugin's sections stay addressable, linkable and cmd-clickable. Panels read
|
||||
(`/offscale/:section`), so a plugin's sections stay addressable, linkable and cmd-clickable. Panels read
|
||||
`useParams` independently — nothing is passed between them, so they cannot disagree. `appTypes.allowed`
|
||||
is pinned to that plugin's own panel keys, so a persisted layout naming something else falls back rather
|
||||
than rendering another plugin's panel inside this one.
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import { users } from 'officerdb/auth/schema';
|
||||
//
|
||||
// `api_key` is a Headscale *admin* credential — it can delete every node on a tailnet — so it is encrypted
|
||||
// at rest via ../crypto.ts, exactly like the vault token set. Encryption/decryption is confined to
|
||||
// queries/headscale.ts; nothing outside that file ever sees ciphertext, and list callers never see the key
|
||||
// db/queries.ts; nothing outside that file ever sees ciphertext, and list callers never see the key
|
||||
// at all. SECURITY_AUDIT.md L2 records plaintext credential storage as an open finding, so the plaintext
|
||||
// email/integrations tables are debt to avoid copying, not a precedent to follow.
|
||||
//
|
||||
|
||||
+16
@@ -33,6 +33,22 @@ export const manifest: PluginManifest = {
|
||||
// Worth knowing while it is unfinished: the stored credential is a Headscale ADMIN api key that can
|
||||
// delete every node on a tailnet, and there is no read-only version of it — so `write` here is close to
|
||||
// full control of the tailnet, which is the owner's decision to make deliberately.
|
||||
// The Console shells into a machine by spawning `ssh` (sidecar/ssh.ts), and so does the reachability
|
||||
// probe behind /ssh-test. This manifest said "Offscale needed nothing" until 2026-08-16, which meant a
|
||||
// host without an ssh client passed the install checks and then failed at runtime with a spawn error —
|
||||
// the one failure mode `osDependencies` exists to convert into a refusal at install time. PLUGIN.md had
|
||||
// listed it as a host dependency all along; the manifest was the file that was wrong.
|
||||
//
|
||||
// Present on effectively every Linux install, which is exactly why it was missed: the declaration costs
|
||||
// nothing on the machines that have it and is the whole difference on the machines that do not.
|
||||
osDependencies: [
|
||||
{
|
||||
binary: 'ssh',
|
||||
reason: 'Opens the Console shell to a machine, and probes reachability before you try.',
|
||||
packages: { apt: 'openssh-client', pacman: 'openssh', dnf: 'openssh-clients', brew: 'openssh' },
|
||||
},
|
||||
],
|
||||
|
||||
permissions: [
|
||||
{
|
||||
key: 'offscale',
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// Shared visual language for the /headscale panels, matching the /soulseek grouped views: almost-black
|
||||
// Shared visual language for the /offscale panels, matching the /soulseek grouped views: almost-black
|
||||
// cards on hairline white borders. Kept local to the app so the look changes in one place.
|
||||
|
||||
export const Card = ({ children }: { children: ReactNode }) => (
|
||||
|
||||
+5
-1
@@ -19,7 +19,11 @@ import { Button } from './Cards';
|
||||
// than the same one re-purposed. TerminalView suppresses its initial input when the sidecar replays a buffer,
|
||||
// which is what stops a re-attach from typing a second `ssh` inside the first.
|
||||
|
||||
const consoleSessionId = (serverId: number) => `headscale-console-${serverId}`;
|
||||
// Renamed from `headscale-console-…` on 2026-08-16. This is a pty session KEY, not a label, so the rename
|
||||
// has a real one-time 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 indefinitely.
|
||||
const consoleSessionId = (serverId: number) => `offscale-console-${serverId}`;
|
||||
|
||||
const Centred = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
|
||||
+2
-2
@@ -4,10 +4,10 @@ import { Server, Laptop, Users, KeyRound, Smartphone, ShieldCheck, Activity, Ter
|
||||
import { OFFSCALE_SECTIONS, offscaleSectionPath, type OffscaleSectionId } from './shared';
|
||||
import { useOffscaleServers } from './useOffscaleServers';
|
||||
|
||||
// Lower-left panel of the /headscale workspace: the section list. Which server it all acts on is the panel
|
||||
// Lower-left panel of the /offscale workspace: the section list. Which server it all acts on is the panel
|
||||
// above (OffscaleServerPicker) — that one mutates, this one navigates, which is why they are separate.
|
||||
//
|
||||
// Sections are real links to /headscale/<section>, not channel writes — so they cmd-click into a new tab,
|
||||
// Sections are real links to /offscale/<section>, not channel writes — so they cmd-click into a new tab,
|
||||
// survive a reload, and answer the back button. Active state comes from react-router's NavLink rather than
|
||||
// being derived in JS, per the navigation audit's Phase 4.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link } from 'react-router';
|
||||
import { offscaleSectionPath } from './shared';
|
||||
import { useOffscaleServers } from './useOffscaleServers';
|
||||
|
||||
// Top-left panel of the /headscale workspace: which server everything else acts on.
|
||||
// Top-left panel of the /offscale workspace: which server everything else acts on.
|
||||
//
|
||||
// It is its own panel rather than a block inside OffscaleNav because the two answer different questions —
|
||||
// "which server" and "which section" — and only one of them is navigation. Activating a server is a mutation
|
||||
@@ -23,7 +23,7 @@ export const OffscaleServerPicker = () => {
|
||||
<Network className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold leading-tight">Headscale</div>
|
||||
<div className="truncate text-sm font-semibold leading-tight">Offscale</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{active ? active.name : 'no server'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { PolicyView } from './PolicyView';
|
||||
import { DiagnosticsView } from './DiagnosticsView';
|
||||
import { ConsoleView } from './ConsoleView';
|
||||
|
||||
// Right panel of the /headscale workspace — renders the section named by the URL.
|
||||
// Right panel of the /offscale workspace — renders the section named by the URL.
|
||||
//
|
||||
// Every section except `servers` acts on whichever server is active; each handles the "none selected" case
|
||||
// itself through ViewShell, so there is no gating to do here.
|
||||
|
||||
+3
-3
@@ -12,15 +12,15 @@ import { OffscaleViewHeader } from './OffscaleViewHeader';
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'offscale-servers',
|
||||
name: 'Headscale servers',
|
||||
name: 'Offscale servers',
|
||||
icon: Network,
|
||||
component: OffscaleServerPicker,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{ key: 'offscale-nav', name: 'Headscale', icon: PanelLeft, component: OffscaleNav, availableOnPanel: false },
|
||||
{ key: 'offscale-nav', name: 'Offscale', icon: PanelLeft, component: OffscaleNav, availableOnPanel: false },
|
||||
{
|
||||
key: 'offscale-view',
|
||||
name: 'Headscale',
|
||||
name: 'Offscale',
|
||||
icon: LayoutGrid,
|
||||
component: OffscaleView,
|
||||
header: OffscaleViewHeader,
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
// Shared types/constants for the /headscale workspace panels. Everything here mirrors the wire shapes the
|
||||
// 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
|
||||
@@ -17,7 +17,7 @@ export const OFFSCALE_SECTIONS = [
|
||||
|
||||
export type OffscaleSectionId = (typeof OFFSCALE_SECTIONS)[number]['id'];
|
||||
|
||||
/** Where /headscale lands, and where an unrecognised section redirects to. */
|
||||
/** 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 =>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { OffscaleServer, OffscaleHealth, OffscaleSshTest } from './shared';
|
||||
|
||||
// The registered-servers cache. Every panel in the /headscale workspace reads this one query, so switching
|
||||
// The registered-servers cache. Every panel in the /offscale workspace reads this one query, so switching
|
||||
// the active server anywhere updates the whole screen at once.
|
||||
//
|
||||
// Registration is validated server-side before anything is saved (reachable, >=0.29, key accepted), which
|
||||
|
||||
Reference in New Issue
Block a user