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. # http://localhost:8899.
# RECLIP_URL=http://localhost:8899 # RECLIP_URL=http://localhost:8899
# ── Headscale (/api/vpn) ──────────────────────────────────────────────────────────────────────── # ── The tailnet is a plugin now, and takes no env ────────────────────────────────────────────────
# These drive the /api/vpn router, NOT the officer-headscale sidecar. The sidecar deliberately reads # HEADSCALE_URL, HEADSCALE_API_KEY and HEADSCALE_USER were here for the `/api/vpn` router. That router
# neither, keeping its registered servers and their keys in Postgres so host env can never shadow # was deleted on 2026-08-14 and the rest left with offscale on the 15th, so nothing in this repository
# one. Set these only if you use /api/vpn. # has read any of them since. Install offscale from the marketplace and register a server in the app:
# HEADSCALE_URL=https://headscale.example.com # its URL and admin key live encrypted in Postgres, where host env cannot shadow them.
# HEADSCALE_API_KEY="<headscale admin api key>"
# HEADSCALE_USER=officer
# ── Bitcoin wallet (officer-wallet) ───────────────────────────────────────────────────────────── # ── Bitcoin wallet (officer-wallet) ─────────────────────────────────────────────────────────────
# The chain data source is NOT here — it is configured from the app, at Wallet → Settings → Chain # The chain data source is NOT here — it is configured from the app, at Wallet → Settings → Chain
+20 -9
View File
@@ -10,10 +10,15 @@ A small SQLite database holding every encryption and signing key the platform us
its own. its own.
**One change from the design below: keys are per PURPOSE, not one key for everything.** The original **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 `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 plugin's columns rather than all seven. `jwt` is the eighth. A core install bootstraps **one** — `jwt` —
`headscale` — and every other purpose is created when its plugin first asks. 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.** > **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 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 makes `keys.ts:26` true: _"a stolen database dump is useless without .env, a stolen .env is useless
without the passphrase"*. without the passphrase"_.
What is wrong is narrower, and it is about **blast radius across processes**. What is wrong is narrower, and it is about **blast radius across processes**.
@@ -47,7 +52,7 @@ Worth listing, because it is wider than the name suggests. Everything below is A
Postgres, encrypted through `officer_db/src/crypto.ts` with a key derived as `SHA-256(VAULT_STORE_KEY)`: Postgres, encrypted through `officer_db/src/crypto.ts` with a key derived as `SHA-256(VAULT_STORE_KEY)`:
| column | what it is | | column | what it is |
| --- | --- | | ------------------------------- | --------------------------------------------------------------------------------------------- |
| `headscale_servers.api_key` | a Headscale **admin** credential — the schema notes it "can delete every node on a tailnet" | | `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 | | `service_connections.secret` | every upstream credential the app store stores: gitea, memos, slskd, transmission |
| `jellyfin_servers.access_token` | Jellyfin session token | | `jellyfin_servers.access_token` | Jellyfin session token |
@@ -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 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 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 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 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 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`, The core is what `ecosystem.light.config.cjs` runs today — `officer`, `officer-anthropic-proxy`,
`officer-agent`, `officer-opencode`, `officer-pty` — **plus `officer-headscale`**. `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 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 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 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 - **Transactional.** A half-rotated table is worse than either end state, because nothing afterwards can
tell which rows are which. 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 A key that is already wrong should fail loudly on row one rather than produce a second layer of
unreadable data. unreadable data.
+6 -3
View File
@@ -40,8 +40,10 @@ prose, here or elsewhere.
## What is switched off (2026-08-13) ## What is switched off (2026-08-13)
A core install runs **six** pm2 processes: `officer`, `officer-anthropic-proxy`, A core install runs **five** pm2 processes: `officer`, `officer-anthropic-proxy`,
`officer-claude-code`, `officer-opencode`, `officer-pty`, `officer-headscale`. Everything else is a `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 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. 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`, 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-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. `pm2 list` shows them; `pm2 logs officer` follows.
Two of those names are worth knowing apart: **`officer-anthropic-proxy` holds the Anthropic credential 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 ""
echo " bootstrapped now:" echo " bootstrapped now:"
echo " jwt signs every session token" echo " jwt signs every session token"
echo " headscale encrypts the Headscale admin API key in Postgres"
echo "" echo ""
echo " Every other purpose — wallet, photos, jellyfin, invoiceshelf, vault," echo " Every other purpose — wallet, photos, jellyfin, invoiceshelf, vault,"
echo " service-connections — is created when its plugin is installed. A" echo " service-connections — is created when its plugin is installed. A"
@@ -637,7 +636,7 @@ if ! skip; then
if confirm "Create it?"; then if confirm "Create it?"; then
if bootstrap_secret_store; then if bootstrap_secret_store; then
ok "created, 0600, owned by ${USERNAME}" 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 "" echo ""
warn "back up $(secret_store_path) — and keep it OUT of the backup that holds your database dump." 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" 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_dir() { echo "${OFFICER_ROOT}/secrets"; }
secret_store_path() { echo "$(secret_store_dir)/officer-keys.db"; } 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 # 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 # 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 \" sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun --eval \"
const { getKey } = await import('officerdb/secret-store'); const { getKey } = await import('officerdb/secret-store');
getKey('jwt'); getKey('jwt');
getKey('headscale');
\"" >/dev/null 2>&1 || return 1 \"" >/dev/null 2>&1 || return 1
[[ -f "$(secret_store_path)" ]] [[ -f "$(secret_store_path)" ]]
@@ -4,7 +4,7 @@ import { DEFAULT_INVOICES_SECTION, invoicesSectionPath, isInvoicesSection, Works
import { useDashboardState } from 'state/useDashboardState'; import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout'; 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 // 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 // /api/invoiceshelf auth proxy — the InvoiceShelf URL, its Sanctum token and the company header live in the
// sidecar and never reach the browser. // sidecar and never reach the browser.
@@ -9,7 +9,7 @@ import {
import { useDashboardState } from 'state/useDashboardState'; import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout'; 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, // 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. // 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 // Headscale was here until 2026-08-14. It became a core sidecar, and then on 2026-08-15 it left the
// profile, so it is not something you install, and `catalogue.test.ts` had been failing on exactly // repository entirely: it is the `offscale` PLUGIN now, installed from the marketplace, and neither
// that ("does not offer to install the baseline") since it was promoted. // 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`, // 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`, // 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 // 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 // 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 // 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 // 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 // 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 // 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 // "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 // 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 // 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 // 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 // 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. // 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. // 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 // Unlike the offscale plugin's panels — whose sidecar reshapes Headscale's API into Officer's own types — the
// sidecar is a deliberate pass-through, so these types mirror Immich's wire shapes directly. They were // 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: // 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 // 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. // 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 // 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 // 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 // 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. // you were already in.
initialInputSent = true; initialInputSent = true;
term.reset(); term.reset();