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 terminal was xterm with one addon (fit) and a single fit() call at connect, so the
shell kept whatever cols/rows it was born with. Drag a panel wider and the PTY never
heard about it — anything drawing a full screen (an agent TUI, top, vim) rendered into a
box that no longer matched the one you were looking at. A ResizeObserver now refits and
sends pty:resize, coalesced to one fit per frame because dragging an edge fires
continuously and each fit reflows.
Scrollback goes from xterm's default 1000 lines to 10,000 — one long agent turn was
enough to lose the start of it.
Addons, all off the shelf and none previously loaded:
- webgl — the renderer, and the reason fast repainting feels smooth rather than syrupy.
Guarded twice: construction can throw where there is no GL context, and the context can
be lost later, so both paths fall back to the DOM renderer instead of a dead canvas.
- unicode11 (+ allowProposedApi) — correct widths for emoji, CJK and box-drawing. The
default unicode 6 tables are why agent TUI frames sit a column out.
- web-links — URLs in output are clickable.
- search — with a find bar on Ctrl/Cmd-Shift-F. Not plain Ctrl-F: that is forward-one-char
in readline and emacs, and swallowing it would break every shell in the app.
- serialize — installed for exact-state replay, not yet wired.
Also reports the shell's own title (OSC 0/2) and the bell through new optional callbacks,
so a panel can show what is running in it and flag a finished turn you weren't watching.
Nothing consumes them yet.
Unrelated but found by this run: three origin-validation tests were failing. Not from this
change — ALLOW_ANY_ORIGIN=true in .env, added last night, and Bun loads the host .env into
tests, so the kill switch had silently turned the assertions into no-ops. The test now pins
both escape hatches off, since its whole job is asserting the checks reject things.
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>
bodyParser caught parse failures and did nothing — the throw was commented out
and `body` was never set, so handlers destructured undefined and the client got
a 500 for a malformed request. Throw BAD_REQUEST instead.
Also adds the regression tests for the Host suffix match fixed in 2ca850c.
Verified against a running server: malformed JSON now 400, valid credentials
path still 401, spoofed Host still 403.
Co-Authored-By: Claude Opus 5 <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>
Officer is single-user: the server owner is the only account, created once by
/auth/bootstrap. Everything that existed to serve additional users was
unreachable, so it is gone rather than left looking like it does something.
Accounts: drop the invite / resend-invite / delete / list-users routes and the
Users settings screen, the inert /auth/signup handler, and the account
verification chain it fed (verify, resend-verification, VerifyScreen, the
UserInvite + VerifyAdmin + VerifyRegistration templates). /auth/verify-token
survives for password resets only, and now requires a reset-password token
rather than accepting any signed JWT.
Roles: drop the users.role column and the four-value USER_ROLES enum. The
permissions table granted every role identical methods, and every
role === 'Super Admin' check was permanently true. The JWT no longer carries a
role claim.
Sandbox: remove sidecar/sandbox.ts and its five call sites. bwrap was selected
only for non-Super-Admin users, so it never ran. It was also not a usable agent
jail as written — --share-net, the project root (with .env) bound read-only,
and runuser dropping to the server's own uid. Rebuilding it for agent
containment would be a different construction, and git history keeps this one.
getHomeDir keeps its DATA_PATH meaning; the new getOwnerHomeDir resolves the
owner's real login home, which is what terminals, chats and task runs use.
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>
Add a duress password (DISTRESS_PASSWORD env): entering it at login trips an
in-memory full lockdown — all new logins (password + passkey) and every existing
session are refused until the server is restarted, and the login itself returns a
normal "invalid credentials" so it gives nothing away. Also add
POST /api/auth/blacklist-token as a clearly-named alias for revoking the current
JWT (same effect as signout).
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>