replaces the account backstop. it was two hand-written lists — NON_OWNER_PATHS
confining every non-owner to /api/auth + /api/music, and NON_OWNER_WS_PROVIDERS
doing the same for sockets. they were not wrong, they were unscalable in one
specific way: an allow-list answers "which paths" but never "why", so onboarding
anyone who needed anything other than music meant editing an array in a
middleware file and hoping the socket half got edited too.
now both doors resolve against the registry, so they cannot disagree about what
a role holds. terminal, chat, task-runner, pipeline and desktop are refused by
being `execution` capabilities rather than by being absent from a list somebody
maintains.
fail-closed everywhere: an unknown capability key, a missing row, a database
error or a deleted user all deny. the grant cache is keyed on role and has an
explicit invalidation contract — unlike the one super-admin.ts refuses to have,
this one has exactly one writer and it lives beside the reader.
seeded Member → music at WRITE, which is precisely what the old path-based
backstop allowed. granting `read` would have been a silent downgrade that broke
playlists for the three live member accounts overnight.
verified against the live database and real accounts: 27 http/socket cases, the
read/write split (personal sub-paths writable at read, /music/scan not), cache
invalidation after a revoke, and the borrowed test account's role restored.
20 new unit tests; full suite 362 pass 0 fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Member account could open a terminal. Demonstrated, not inferred: on 2026-08-06
a token for dingoshf@gmail.com (role Member) was refused GET /api/tasks with 403
and, in the same minute, opened /api/tasks/pipeline/ws with 101 Switching
Protocols.
The account backstop lives in originScopeMiddleware, which is a Hono middleware.
Websocket upgrades never reach Hono: Bun's route table in server.tsx matches
'/api/terminal/ws' and its siblings before the '/api/*' entry that hands off, so
NON_OWNER_PATHS was enforced on HTTP and nowhere else. upgradeWs verified the
token and the blacklist, then upgraded — it proved WHO was calling and never
asked what they could reach. None of the handlers behind it checked either;
terminal, chat and desktop have no authorisation code at all.
What was reachable with any valid token: a shell as the owner in the owner's
home, the agent with --dangerously-skip-permissions, arbitrary script execution
through task-runner and pipeline, and the owner's physical screen and keyboard
over the VNC mirror.
upgradeWs now refuses any provider a non-owner has no business on. cliamp and
cliamp-audio stay open to them — those are the socket half of /api/music, which
is what a music account exists for. An unrecognised provider denies.
NON_OWNER_WS_PROVIDERS is declared beside NON_OWNER_PATHS on purpose. They are
one rule at two doors, and the whole failure was that only one door had it;
splitting them across files is how that happens again.
The vault socket was already gated — it verifies isSuperAdmin in `open` and
closes 4001 — so upgradeWs was the only gap.
Not yet verified against the running server: platform TS does not hot-reload, so
the 101 above still reproduces until `pm2 restart officer`. The provider table
was checked in isolation: the five execution providers and an unknown name all
deny, cliamp and cliamp-audio allow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signing in from a server-to-server client returned 500. originMiddleware leaves `origin`
undefined when a request has neither Origin nor Referer — exactly what such a client sends —
and signin.ts took it as a string, passed it into getPasskeysByUserIdAndOrigin, and Postgres
rejected the undefined parameter. It would have thrown on origin.startsWith() a few lines
later too.
It used to be unreachable: originValidationMiddleware rejected origin-less requests before
the handler ran, so the value was always a string by the time anything touched it. Turning
the origin checks off removed that gate without the code behind it ever having needed to
cope. This is the second thing that flag has surfaced rather than caused.
The two call sites want different answers, so they get different ones:
- signin normalises to ''. No passkey is registered against the empty origin, so an
origin-less caller gets an empty list and falls through to password auth, which is what it
is asking for.
- the four passkey routes now refuse with 400 "Passkey operations require an Origin header".
WebAuthn is defined in terms of an origin — a passkey is registered against one and is only
verifiable against the same one — so substituting '' there would be quietly wrong.
Also flips ALLOW_ANY_ORIGIN to default ON: the checks are off unless it is explicitly
'false'. A deliberate inversion of fail-closed, safe because of where this runs — the
perimeter is the tailnet, devices are admitted by hand, and every protected route still
requires a valid token. Verified both directions: unset lets a foreign origin through,
ALLOW_ANY_ORIGIN=false rejects it. The origin test suite pins the flag off, so it still
asserts the checks reject things rather than passing vacuously.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The /api/music exemption was not enough on its own: an app has to reach /api/auth to sign
in before it ever calls its own feature, so the login was still 403ing on Invalid origin.
ALLOW_ANY_ORIGIN=true now accepts every Origin everywhere and skips the per-origin path
scoping. Set on this host; ALLOW_ANY_ORIGIN_MUSIC stays as the narrower option.
The account backstop is deliberately NOT disabled — a valid non-owner token is still
confined to /api/auth + /api/music whatever Origin it claims, because that rule is
account-based rather than origin-based, and it is the airtight half of the pair. Every
protected route still requires a valid token.
Reverting is an env edit and a restart. Both flags and their call sites come out when the
tailnet becomes the perimeter.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Off unless ALLOW_ANY_ORIGIN_MUSIC=true, so it is opt-in per host and reverting is an env
edit rather than a deploy. Set on this host now; the flag and the two call sites come out
when the tailnet becomes the perimeter and the music app stops needing to be public.
It drops one layer, not the lock: /api/music still requires a valid token through
userMiddleware, and the non-owner account backstop in originScopeMiddleware still confines
music accounts to /api/auth + /api/music whatever Origin they claim. Worth being plain
about what is lost — Origin was never authentication here. `officer://<hex>` is chosen by
the client, trivially forged outside a browser, and extractable from any shipped app
binary. It is defence in depth, and this removes it for one path prefix.
Two gates had to know: userMiddleware, which is what actually 403s, and the CORS origin
callback, which would otherwise echo an empty origin and block a browser client that
userMiddleware had already allowed through.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each app origin was declared three times — destructured from process.env, listed in
APP_ORIGINS, then given a rule — so adding an app meant editing the same file in three
places and remembering all three. Two apps in and the list was already wrong: OffTail had
been added to .env as OFF_SCALE_ORIGIN, which nothing read, so it did nothing.
Any `OFFICER_<APP>_ORIGIN` in the environment is now an app origin, and its rule comes
from its own name: an app may reach /api/auth (it has to sign in) plus the one feature it
is named for, so OFFICER_VAULT_ORIGIN gets /api/vault without being told. Adding an app is
adding an env var.
Two things stay hand-written because they are not that convention:
- OWNER_ONLY_APPS ('APP') — the main app is the whole platform rather than one feature of
it, so it keeps full access but owner-only. A different kind of rule, not a path scope.
- APP_SCOPE_OVERRIDES ('TAIL' → /api/vpn) — the one app whose name and API surface differ.
NON_OWNER_PATHS also stays, and is worth not mistaking for the music app's rule even
though the value matches: it is the ACCOUNT backstop, applied whatever Origin a caller
claims or omits, and it is the airtight half of that pair.
Also renames MUSIC_APP_ORIGIN to OFFICER_MUSIC_ORIGIN (the other three already had the
shape) and drops the dead OFF_SCALE_ORIGIN. Env and code changed together; .env on this
host is updated, so this needs a restart, not a migration.
Verified the derived rules against the live .env: APP → superAdminOnly, MUSIC →
auth+music, VAULT → auth+vault, TAIL → auth+vpn — identical to what was hardcoded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The in-app Tailscale needs one thing from the platform: a way to turn an authenticated
Officer session into a Headscale pre-auth key, so the phone registers itself instead of
someone pasting a key by hand. The VPN's control and data planes talk directly to
Headscale — never through /api — so this is not a proxy and should not become one.
Headscale itself runs on a separate host, managed manually. The platform consumes
HEADSCALE_URL (also returned as controlUrl) and HEADSCALE_API_KEY, both from the host
env. Neither is set yet, which is why an unconfigured instance answers 503 rather than
crashing — Headscale is being stood up in parallel.
The response is `{ controlUrl, authKey }` exactly, because enrollVpn() in
@officer/core/officer-net.ts reads those two fields; changing the shape means changing the
app.
Two things the spec's sketch does not do:
- The `user` field changed meaning across Headscale versions — a name on <=v0.22, a
numeric id on v0.23+ — and we cannot see which one is running from here. So it resolves
the id via /api/v1/user and tries that first, falling back to the name. Whichever the
live server accepts wins, and neither version needs a config flag.
- Upstream calls carry a 10s timeout, and upstream error bodies are logged but never
returned to the client: that is an admin API and its errors are descriptive.
The standalone app's origin follows the platform's own convention rather than the spec's
literal: every other app origin is an env var with a scope rule, so this one is
OFFICER_TAIL_ORIGIN (set in .env on this host), restricted to /api/auth + /api/vpn the way
OffVault is restricted to /api/auth + /api/vault. The OffTail tile embedded in the main
Officer app needs nothing — it reuses OFFICER_APP_ORIGIN.
Not implemented: the optional GET/DELETE /api/vpn/devices. They are not needed for a first
connection and are better written against a running Headscale.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scope OFFICER_VAULT_ORIGIN to ['/api/auth', '/api/vault'] instead of
'/api/vault' only. The OffVault app authenticates to the platform first
(/api/auth/signin) and only then reaches the vault proxy — same shape as the
music app's ['/api/auth', '/api/music']. Without /api/auth the signin was
rejected in originScopeMiddleware with "Origin not permitted".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Transparent pass-through fronting a self-hosted Vaultwarden so the OffVault
(Bitwarden-SDK) app reaches it through the platform's per-app origin gate. True
out-of-process sidecar (officer-vault): it owns all Vaultwarden knowledge (URL,
paths, notifications WebSocket) on a random loopback port and registers via the
sidecar connector; the platform is a thin origin-gated forwarder that knows only
the sidecar's port. Never decrypts/parses/rewrites/logs bodies.
- sidecar/vault: HTTP + notifications-WS proxy to VAULTWARDEN_URL, /_health
- api/vault: sidecar-port discovery + thin forwarder + WS pipe + origin gate
- origin: OFFICER_VAULT_ORIGIN allow-listed, scoped to /api/vault
- mounted top-level (not protected) so the Bitwarden bearer token isn't 401'd
- protocol: vault:server event; ecosystem: officer-vault app
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a Super Admin ("owner") identity — SUPER_ADMIN_EMAIL, else the
bootstrap/first user (super-admin.ts) — and closes the hole where a music
account could sign into the full platform:
- Rename EXPO_PUBLIC_CLIENT_ORIGIN -> OFFICER_APP_ORIGIN.
- PUBLIC_URL + OFFICER_APP_ORIGIN are owner-only origins; MUSIC_APP_ORIGIN
stays path-scoped to /api/auth + /api/music.
- Account backstop (origin-independent): a valid non-owner token may reach
only /api/auth + /api/music regardless of Origin — airtight even if the
header is omitted/forged.
- signin rejects a non-owner logging in from an owner-only origin.
Owner keeps full access (verified); music users are confined to the music app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add MUSIC_APP_ORIGIN to the origin allowlist and a global
originScopeMiddleware that restricts scoped app origins (the standalone
officer-music client) to their permitted path prefixes — /api/auth and
/api/music — and 403s everything else. The main web origin is unaffected,
and the gate no-ops while MUSIC_APP_ORIGIN is unset.
Lets extra sign-in-only users authenticate through the music app and reach
only music + auth, without reintroducing any per-user permission scheme.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The no-Origin branch asked whether the configured origin ends with the
client-supplied Host, so `Host: dev` matched https://rezio.pastilhas.dev — as
did `pastilhas.dev` and `o.pastilhas.dev`. Match the URL authority exactly
instead. Officer always runs behind an HTTPS reverse proxy, so the forwarded
Host is expected to equal PUBLIC_URL's authority.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Origin validation, every rate limiter and the password-strength check each
treated an unset PUBLIC_BUILD_ENV as "relaxed", so a deployment that forgot the
variable silently ran with CORS reflecting any origin, no brute-force limit on
the sole account, and no password rules. setup.sh writes it, but .env.example
never mentioned it.
The three now share IS_DEV_BUILD, which is true only when PUBLIC_BUILD_ENV is
explicitly "dev" or "development". Anything else, including unset, is hardened.
Documented in .env.example.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
origin-validation: the production web origin now reads from PUBLIC_URL (.env), e.g.
https://officer.pastilhas.dev, instead of a hardcoded domain; drop alpha.officer.dev.
officer-web/index.html: point og:image/favicon/manifest/etc. at the new domain (served
locally from public/).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
APP_ORIGINS now includes EXPO_PUBLIC_CLIENT_ORIGIN when set, so the mobile app's
officer:// custom-scheme origin passes origin validation without weakening it for
anyone else. The origin (with its embedded token) lives in .env, not the repo.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>