From 27aa72d923ed78c728c5e23089005380b021c6d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 16 Aug 2026 11:24:23 +0000 Subject: [PATCH] finish the offscale sweep: two real remnants and eleven stale names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 12 +++--- docs/secret-store.md | 41 ++++++++++++------- docs/working-on-officer.md | 9 ++-- scripts/setup/officer-setup.sh | 3 +- scripts/setup/officer-setup/lib/secrets.sh | 8 +++- .../Dashboard/Invoices/InvoicesScreen.tsx | 2 +- .../Transmission/TransmissionScreen.tsx | 2 +- src/servers/app-store/catalogue.ts | 7 ++-- src/servers/sidecar/invoiceshelf/config.ts | 2 +- src/servers/sidecar/photos/config.ts | 2 +- src/servers/sidecar/slskd/config.ts | 2 +- .../officerdev/src/apps/Photos/shared.ts | 4 +- .../officerdev/src/apps/Terminal/Terminal.tsx | 2 +- 13 files changed, 56 insertions(+), 40 deletions(-) diff --git a/.env.example b/.env.example index 40557631..461e82a2 100644 --- a/.env.example +++ b/.env.example @@ -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_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 diff --git a/docs/secret-store.md b/docs/secret-store.md index 355b0c23..39ef2b4b 100644 --- a/docs/secret-store.md +++ b/docs/secret-store.md @@ -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. diff --git a/docs/working-on-officer.md b/docs/working-on-officer.md index 9242c80a..3d7a7868 100644 --- a/docs/working-on-officer.md +++ b/docs/working-on-officer.md @@ -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 diff --git a/scripts/setup/officer-setup.sh b/scripts/setup/officer-setup.sh index fd52f59d..abb28277 100755 --- a/scripts/setup/officer-setup.sh +++ b/scripts/setup/officer-setup.sh @@ -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" diff --git a/scripts/setup/officer-setup/lib/secrets.sh b/scripts/setup/officer-setup/lib/secrets.sh index 5097e430..dabe11e2 100644 --- a/scripts/setup/officer-setup/lib/secrets.sh +++ b/scripts/setup/officer-setup/lib/secrets.sh @@ -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)" ]] diff --git a/src/apps/officer-web/Screens/Dashboard/Invoices/InvoicesScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Invoices/InvoicesScreen.tsx index f1f24aa0..820ac7aa 100644 --- a/src/apps/officer-web/Screens/Dashboard/Invoices/InvoicesScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Invoices/InvoicesScreen.tsx @@ -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. diff --git a/src/apps/officer-web/Screens/Dashboard/Transmission/TransmissionScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Transmission/TransmissionScreen.tsx index bf8b9c1d..42959333 100644 --- a/src/apps/officer-web/Screens/Dashboard/Transmission/TransmissionScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Transmission/TransmissionScreen.tsx @@ -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. // diff --git a/src/servers/app-store/catalogue.ts b/src/servers/app-store/catalogue.ts index b60a5efc..e6955506 100644 --- a/src/servers/app-store/catalogue.ts +++ b/src/servers/app-store/catalogue.ts @@ -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`, diff --git a/src/servers/sidecar/invoiceshelf/config.ts b/src/servers/sidecar/invoiceshelf/config.ts index 8f7df921..5313d468 100644 --- a/src/servers/sidecar/invoiceshelf/config.ts +++ b/src/servers/sidecar/invoiceshelf/config.ts @@ -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 diff --git a/src/servers/sidecar/photos/config.ts b/src/servers/sidecar/photos/config.ts index 98ce8eb8..0cb3fa6d 100644 --- a/src/servers/sidecar/photos/config.ts +++ b/src/servers/sidecar/photos/config.ts @@ -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 diff --git a/src/servers/sidecar/slskd/config.ts b/src/servers/sidecar/slskd/config.ts index 87755cbe..ea8640b1 100644 --- a/src/servers/sidecar/slskd/config.ts +++ b/src/servers/sidecar/slskd/config.ts @@ -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. diff --git a/src/workspaces/officerdev/src/apps/Photos/shared.ts b/src/workspaces/officerdev/src/apps/Photos/shared.ts index b2b24b63..21590a64 100644 --- a/src/workspaces/officerdev/src/apps/Photos/shared.ts +++ b/src/workspaces/officerdev/src/apps/Photos/shared.ts @@ -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. diff --git a/src/workspaces/officerdev/src/apps/Terminal/Terminal.tsx b/src/workspaces/officerdev/src/apps/Terminal/Terminal.tsx index 005c1f86..b42b38a6 100644 --- a/src/workspaces/officerdev/src/apps/Terminal/Terminal.tsx +++ b/src/workspaces/officerdev/src/apps/Terminal/Terminal.tsx @@ -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();