diff --git a/PLUGIN.md b/PLUGIN.md index 969f5d9..9296f16 100644 --- a/PLUGIN.md +++ b/PLUGIN.md @@ -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 ``, 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 `` and `/: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. diff --git a/db/schema.ts b/db/schema.ts index fd60a35..a24aee9 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -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. // diff --git a/manifest.ts b/manifest.ts index b80a94f..2e62590 100644 --- a/manifest.ts +++ b/manifest.ts @@ -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', diff --git a/web/Cards.tsx b/web/Cards.tsx index a9f9fe5..bf6b331 100644 --- a/web/Cards.tsx +++ b/web/Cards.tsx @@ -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 }) => ( diff --git a/web/ConsoleView.tsx b/web/ConsoleView.tsx index 333612a..487fcfe 100644 --- a/web/ConsoleView.tsx +++ b/web/ConsoleView.tsx @@ -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 }) => (
diff --git a/web/OffscaleNav.tsx b/web/OffscaleNav.tsx index 9a29d22..de1fb29 100644 --- a/web/OffscaleNav.tsx +++ b/web/OffscaleNav.tsx @@ -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/
, not channel writes — so they cmd-click into a new tab, +// Sections are real links to /offscale/
, 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. diff --git a/web/OffscaleServerPicker.tsx b/web/OffscaleServerPicker.tsx index b66436d..9f430d0 100644 --- a/web/OffscaleServerPicker.tsx +++ b/web/OffscaleServerPicker.tsx @@ -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 = () => {
-
Headscale
+
Offscale
{active ? active.name : 'no server'}
diff --git a/web/OffscaleView.tsx b/web/OffscaleView.tsx index d1a38ee..065d547 100644 --- a/web/OffscaleView.tsx +++ b/web/OffscaleView.tsx @@ -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. diff --git a/web/panels.ts b/web/panels.ts index afcb8fd..d81884a 100644 --- a/web/panels.ts +++ b/web/panels.ts @@ -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, diff --git a/web/shared.ts b/web/shared.ts index e70d47f..00b4c49 100644 --- a/web/shared.ts +++ b/web/shared.ts @@ -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 => diff --git a/web/useOffscaleServers.ts b/web/useOffscaleServers.ts index b9d56a4..2ef7138 100644 --- a/web/useOffscaleServers.ts +++ b/web/useOffscaleServers.ts @@ -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