finish the offscale sweep: two real remnants and eleven stale names

Asked for a thorough check that nothing of offscale is left in the platform. The
extraction was sound — no router, no permission entry, no app-store entry, no
screen, no panel app, no env var anything reads. Two things were genuinely wrong,
and both were in setup rather than in the server.

── The secret store minted a key for a plugin ──

`officer-setup` bootstrapped two purposes: `jwt` and `headscale`. The second was a
key for a PLUGIN, under a name the rename had already replaced, on a machine where
the plugin is not installed and nothing would ever ask for it. offscale encrypts
under `offscale`, and `getKey` creates on demand — which is precisely what lets
core know nothing about plugins. Now core bootstraps `jwt` alone.

── `.env.example` documented variables for a deleted router ──

HEADSCALE_URL, HEADSCALE_API_KEY and HEADSCALE_USER were described as driving
`/api/vpn`. That router was deleted on 2026-08-14 and nothing has read any of them
since. Left as a note saying where the tailnet's credentials live now, because
somebody with an older `.env` will otherwise wonder why setting them does nothing.

── Names that outlived the thing ──

Eleven comments referred to the plugin's route, panels, console or server registry
as "headscale": three sidecar config files describing their own shape by pointing
at it, two screens citing `/headscale` as the example of a Workspace/Panel route,
the terminal's note about the Console, and the photos types. Also `catalogue.ts`,
which still claimed headscale "is CORE now", and `working-on-officer.md`, which
told the reader a core install runs six processes including `officer-headscale`.

Upstream references are deliberately untouched: "Headscale's API", "the Headscale
admin credential", "is this a Headscale server?" all name the thing the plugin
drives, and renaming those would make the sentences false.

`docs/secret-store.md` argued headscale must be core because the tailnet is the
perimeter. Marked superseded rather than deleted, with the distinction that
settles it: the perimeter is the tailnet EXISTING, which machine-setup still
arranges on a core install — not the platform shipping the screen that
administers it.

Verified by counting: live code, setup provisioning, env references, permission
entries, app-store entries, hono mounts, dock defaults, screens and panel apps all
zero. What is left is generated files (Plugins.gen, plugin-schemas.gen), the
bundled catalogue entry, test fixtures using "offscale" as an example app name,
and history.

tsgo clean, frontend builds, 873 pass / 7 fail unchanged, setup scripts parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 11:24:23 +00:00
co-authored by Claude Opus 5
parent c0e61a4cba
commit 27aa72d923
13 changed files with 56 additions and 40 deletions
+5 -7
View File
@@ -63,13 +63,11 @@ PUBLIC_URL=http://localhost:9000
# http://localhost:8899.
# RECLIP_URL=http://localhost:8899
# ── Headscale (/api/vpn) ────────────────────────────────────────────────────────────────────────
# These drive the /api/vpn router, NOT the officer-headscale sidecar. The sidecar deliberately reads
# neither, keeping its registered servers and their keys in Postgres so host env can never shadow
# one. Set these only if you use /api/vpn.
# HEADSCALE_URL=https://headscale.example.com
# HEADSCALE_API_KEY="<headscale admin api key>"
# HEADSCALE_USER=officer
# ── The tailnet is a plugin now, and takes no env ────────────────────────────────────────────────
# HEADSCALE_URL, HEADSCALE_API_KEY and HEADSCALE_USER were here for the `/api/vpn` router. That router
# was deleted on 2026-08-14 and the rest left with offscale on the 15th, so nothing in this repository
# has read any of them since. Install offscale from the marketplace and register a server in the app:
# its URL and admin key live encrypted in Postgres, where host env cannot shadow them.
# ── Bitcoin wallet (officer-wallet) ─────────────────────────────────────────────────────────────
# The chain data source is NOT here — it is configured from the app, at Wallet → Settings → Chain
+26 -15
View File
@@ -10,10 +10,15 @@ A small SQLite database holding every encryption and signing key the platform us
its own.
**One change from the design below: keys are per PURPOSE, not one key for everything.** The original
plan moved a single at-rest key into the store. What shipped gives `headscale`, `wallet`, `photos`,
plan moved a single at-rest key into the store. What shipped gives `offscale`, `wallet`, `photos`,
`jellyfin`, `invoiceshelf`, `vault` and `service-connections` a key each, so one leaked key opens one
plugin's columns rather than all seven. `jwt` is the eighth. A core install bootstraps two — `jwt` and
`headscale` — and every other purpose is created when its plugin first asks.
plugin's columns rather than all seven. `jwt` is the eighth. A core install bootstraps **one** — `jwt` —
and every other purpose is created when its owner first asks.
(It bootstrapped `headscale` as well until 2026-08-16. That was a key for a plugin, minted under a name
the rename had already replaced, on machines where the plugin was not installed and nothing would ever
ask for it. `getKey` creates on demand, which is what makes core needing to know nothing about plugins
work at all.)
---
@@ -25,8 +30,8 @@ not lost in a refactor:
> **Secrets live in Postgres. The key that opens them does not.**
That is why `officer_db/src/crypto.ts` reads `VAULT_STORE_KEY` from the environment, and it is what
makes `keys.ts:26` true: *"a stolen database dump is useless without .env, a stolen .env is useless
without the passphrase"*.
makes `keys.ts:26` true: _"a stolen database dump is useless without .env, a stolen .env is useless
without the passphrase"_.
What is wrong is narrower, and it is about **blast radius across processes**.
@@ -46,13 +51,13 @@ The second problem is that changing the key is currently unrecoverable rather th
Worth listing, because it is wider than the name suggests. Everything below is AES-256-GCM ciphertext in
Postgres, encrypted through `officer_db/src/crypto.ts` with a key derived as `SHA-256(VAULT_STORE_KEY)`:
| column | what it is |
| --- | --- |
| `headscale_servers.api_key` | a Headscale **admin** credential — the schema notes it "can delete every node on a tailnet" |
| `service_connections.secret` | every upstream credential the app store stores: gitea, memos, slskd, transmission |
| `jellyfin_servers.access_token` | Jellyfin session token |
| `wallets.config` | node credentials — macaroon, rune, LNDHub password, NWC URI. Spending authority |
| `wallets.seed_envelope` | a BIP39 mnemonic, already sealed under an owner passphrase, encrypted **again** with this key |
| column | what it is |
| ------------------------------- | --------------------------------------------------------------------------------------------- |
| `headscale_servers.api_key` | a Headscale **admin** credential — the schema notes it "can delete every node on a tailnet" |
| `service_connections.secret` | every upstream credential the app store stores: gitea, memos, slskd, transmission |
| `jellyfin_servers.access_token` | Jellyfin session token |
| `wallets.config` | node credentials — macaroon, rune, LNDHub password, NWC URI. Spending authority |
| `wallets.seed_envelope` | a BIP39 mnemonic, already sealed under an owner passphrase, encrypted **again** with this key |
`decryptSecret` throws when the key does not verify, so a wrong key is not a degraded mode — it is every
one of those becoming unreadable at once.
@@ -69,7 +74,7 @@ persisted). Everything else in that table has exactly one lock.
Keys cannot live in the database they unlock. A dump would then contain both the ciphertext and the
thing that opens it, and the property quoted at the top stops being true. Encrypting the key with a
second key only moves the question — eventually exactly one secret has to be readable without any other
secret, and the only real decision is *where it lives*.
secret, and the only real decision is _where it lives_.
SQLite rather than a flat file, for one reason that is not secrecy: **rotation needs key versions.** A
rotation has to decrypt with the old key and re-encrypt with the new, and an interrupted rotation needs
@@ -170,7 +175,13 @@ finds it already there.
The core is what `ecosystem.light.config.cjs` runs today — `officer`, `officer-anthropic-proxy`,
`officer-agent`, `officer-opencode`, `officer-pty` — **plus `officer-headscale`**.
Headscale is core for a stated reason rather than by preference: `CLAUDE.md` says the tailnet *is* the
**Superseded 2026-08-15.** The tailnet left the repository and is the `offscale` plugin; core is the five
above. The argument below was not refuted — it was overtaken. What settled it: the perimeter is the
tailnet EXISTING, which is a machine-setup concern, and that is not the same as the platform shipping the
screen that administers it. `machine-setup.sh` still walks you onto a tailnet on a core install; only the
admin UI became optional. Kept because the reasoning is what a future "should X be core?" argument needs.
Headscale is core for a stated reason rather than by preference: `CLAUDE.md` says the tailnet _is_ the
perimeter — origin checking was removed on 2026-08-13 precisely because the tailnet is what stands in
its place, so the tailnet is now load-bearing rather than one layer of two. A security model
that rests on the tailnet cannot treat administering the tailnet as an optional extra. Vaultwarden and
@@ -220,7 +231,7 @@ Two properties it must have, both learned from the failure it replaces:
- **Transactional.** A half-rotated table is worse than either end state, because nothing afterwards can
tell which rows are which.
- **Verify before writing.** Every row must decrypt with the retired key *before* anything is written.
- **Verify before writing.** Every row must decrypt with the retired key _before_ anything is written.
A key that is already wrong should fail loudly on row one rather than produce a second layer of
unreadable data.
+6 -3
View File
@@ -40,8 +40,10 @@ prose, here or elsewhere.
## What is switched off (2026-08-13)
A core install runs **six** pm2 processes: `officer`, `officer-anthropic-proxy`,
`officer-claude-code`, `officer-opencode`, `officer-pty`, `officer-headscale`. Everything else is a
A core install runs **five** pm2 processes: `officer`, `officer-anthropic-proxy`,
`officer-claude-code`, `officer-opencode`, `officer-pty` — the list in `officer-setup`'s
`CORE_PROCESSES`. `officer-headscale` was the sixth until 2026-08-15; the tailnet is the `offscale`
PLUGIN now, so it runs as `officer-offscale` only where it has been installed. Everything else is a
plugin, and every plugin router is commented out in `hono.ts` with its permission's `api` claim
commented beside it — they must move together or `assertPermissionTotality` refuses to boot.
@@ -95,7 +97,8 @@ Commit messages: simple lowercase, no prefixes, explaining _why_.
The server runs under pm2 as `officer`, plus sidecars (`officer-anthropic-proxy`, `officer-claude-code`,
`officer-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`).
`officer-slskd`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`, and a process per
installed plugin — `officer-offscale`, `officer-music`).
`pm2 list` shows them; `pm2 logs officer` follows.
Two of those names are worth knowing apart: **`officer-anthropic-proxy` holds the Anthropic credential
+1 -2
View File
@@ -627,7 +627,6 @@ if ! skip; then
echo ""
echo " bootstrapped now:"
echo " jwt signs every session token"
echo " headscale encrypts the Headscale admin API key in Postgres"
echo ""
echo " Every other purpose — wallet, photos, jellyfin, invoiceshelf, vault,"
echo " service-connections — is created when its plugin is installed. A"
@@ -637,7 +636,7 @@ if ! skip; then
if confirm "Create it?"; then
if bootstrap_secret_store; then
ok "created, 0600, owned by ${USERNAME}"
report_changed "created $(secret_store_path) (0600, dir 0700, owner ${USERNAME}) with keys for: jwt, headscale. Generated locally, never transmitted."
report_changed "created $(secret_store_path) (0600, dir 0700, owner ${USERNAME}) with keys for: jwt. Generated locally, never transmitted."
echo ""
warn "back up $(secret_store_path) — and keep it OUT of the backup that holds your database dump."
echo " Losing it signs everyone out and makes every encrypted column in"
+6 -2
View File
@@ -20,7 +20,12 @@ OFFICER_SETUP_SECRETS_LOADED=1
secret_store_dir() { echo "${OFFICER_ROOT}/secrets"; }
secret_store_path() { echo "$(secret_store_dir)/officer-keys.db"; }
# Create the store and the two purposes a core install needs.
# Create the store and the one purpose a core install needs.
#
# `jwt` only. It used to also mint `headscale`, which was a key for a PLUGIN — and a plugin is not on a
# fresh machine, so the key was created, named after a rename that had already happened, and never used
# by anything. `getKey` creates on demand, so offscale mints its own `offscale` key the first time it
# encrypts a server's API key. Nothing here has to know a plugin exists.
#
# Run AS the owner, not as root: the platform runs as them, and a store root
# created would be a store they cannot write. `install -d -o` sets the owner in
@@ -33,7 +38,6 @@ bootstrap_secret_store() {
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun --eval \"
const { getKey } = await import('officerdb/secret-store');
getKey('jwt');
getKey('headscale');
\"" >/dev/null 2>&1 || return 1
[[ -f "$(secret_store_path)" ]]
@@ -4,7 +4,7 @@ import { DEFAULT_INVOICES_SECTION, invoicesSectionPath, isInvoicesSection, Works
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /invoices uses the Workspace/Panel system (like /transmission and /headscale): the section nav on the
// /invoices uses the Workspace/Panel system (like /transmission and /offscale): the section nav on the
// left, the section itself on the right. Both panels talk to the officer-invoiceshelf sidecar through the
// /api/invoiceshelf auth proxy — the InvoiceShelf URL, its Sanctum token and the company header live in the
// sidecar and never reach the browser.
@@ -9,7 +9,7 @@ import {
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /transmission uses the Workspace/Panel system (like /headscale): a filter nav on the left and the section
// /transmission uses the Workspace/Panel system (like /offscale): a filter nav on the left and the section
// view on the right. Both talk to the officer-transmission sidecar through the /api/transmission auth proxy,
// which holds no Transmission credentials of its own — the RPC URL and any Basic auth live in the sidecar.
//
+4 -3
View File
@@ -359,9 +359,10 @@ export const CATALOGUE: CatalogueEntry[] = [
},
],
},
// Headscale was here until 2026-08-14 and is CORE now — `officer-headscale` runs in the baseline
// profile, so it is not something you install, and `catalogue.test.ts` had been failing on exactly
// that ("does not offer to install the baseline") since it was promoted.
// Headscale was here until 2026-08-14. It became a core sidecar, and then on 2026-08-15 it left the
// repository entirely: it is the `offscale` PLUGIN now, installed from the marketplace, and neither
// this catalogue nor `CORE_PROCESSES` mentions it. The note below is kept because the FAILURE it
// describes is about this file's model and would happen again to anything promoted out of it.
//
// Leaving it here did more than fail a test. Availability is derived ONLY from `sidecar_installs`,
// and a core sidecar never gets a row there — so `headscale` and `vpn` were permanently `unavailable`,
+1 -1
View File
@@ -14,7 +14,7 @@ import { UpstreamError, callUpstream, invalidateConfig, listCompanies, mintToken
//
// The Sanctum token is WRITE-ONLY across this boundary. The list reports each account's label, URL, pinned
// company and whether it is selected; it has no field that could carry a token, masked or otherwise. The
// only way to change one is to send a new one, which is the same shape photos and headscale use.
// only way to change one is to send a new one, which is the same shape photos and offscale use.
//
// A save is validated before it is stored, and the validation does something the photos one does not: it
// lists the companies the token can act for and PINS one. The `company` header is the sharpest edge in this
+1 -1
View File
@@ -14,7 +14,7 @@ import { UpstreamError, callUpstream, invalidateConfig, mintApiKey, normalizeBas
//
// The API key is WRITE-ONLY across this boundary. The list reports each account's label, URL and whether it
// is selected; it has no field that could carry a key, masked or otherwise. The only way to change one is to
// send a new one, which is the same shape headscale's server registry uses.
// send a new one, which is the same shape the offscale plugin's server registry uses.
//
// "A new one" has two forms: a key the owner pasted from Immich's own settings screen, or an email and
// password the sidecar trades for one (see mintApiKey). The password is used for that exchange and never
+1 -1
View File
@@ -7,7 +7,7 @@ import { authHeaders, getSlskdConfig, invalidateSlskdConfig, normalizeBase } fro
//
// The API key is WRITE-ONLY across this boundary. The GET reports the URL and whether a key is stored; it
// has no field that could carry the key itself, masked or otherwise. The only way to change one is to send
// a new one, which is the same shape the photos and headscale registries use.
// a new one, which is the same shape the photos and offscale registries use.
//
// ONE connection, not a registry: nobody runs two Soulseek daemons. So there is no label, no id in the
// path and no activate route — a save is an upsert over the single row, and the UI is one form.
@@ -1,7 +1,7 @@
// Shared types, constants and pure helpers for the /photos workspace panels.
//
// Unlike the Headscale panels — whose sidecar reshapes Headscale's API into Officer's own types — the photos
// sidecar is a deliberate pass-through, so these types mirror Immich's wire shapes directly. They were
// Unlike the offscale plugin's panels — whose sidecar reshapes Headscale's API into Officer's own types — the
// photos sidecar is a deliberate pass-through, so these types mirror Immich's wire shapes directly. They were
// generated from the LIVE instance's OpenAPI document (`GET /api/spec.json`, Immich 3.0.3), not from memory:
// several of them changed shape in 3.0 (`isArchived` became `visibility`, and the timeline bucket response
// became columnar), so anything written from an older mental model would be subtly wrong.
@@ -336,7 +336,7 @@ export const TerminalView = ({
// It does however PROVE the session already exists and has already run whatever we would have
// typed into it — `initialInputSent` is scoped to this mount, so without this a remount (a layout
// change, a route change, reopening the panel) would type the command a second time into a live
// shell. Harmless for `ls`; for the Headscale console it means an `ssh` nested inside the `ssh`
// shell. Harmless for `ls`; for offscale's Console it means an `ssh` nested inside the `ssh`
// you were already in.
initialInputSent = true;
term.reset();