derive app origins from the environment

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>
This commit is contained in:
2026-07-30 08:55:17 +00:00
co-authored by Claude Opus 5
parent 44a9124ef5
commit 4598e53c89
2 changed files with 42 additions and 28 deletions
+41 -27
View File
@@ -4,8 +4,7 @@ import { IS_DEV_BUILD } from '../build-env';
import { verify } from '../jwt';
import { isSuperAdmin } from '../super-admin';
const { PUBLIC_URL, OFFICER_APP_ORIGIN, MUSIC_APP_ORIGIN, OFFICER_VAULT_ORIGIN, OFFICER_TAIL_ORIGIN } =
process.env;
const { PUBLIC_URL } = process.env;
// The allowed production web origin comes from PUBLIC_URL in .env (e.g. https://officer.pastilhas.dev),
// not a hardcoded domain.
@@ -28,23 +27,34 @@ const CHROME_EXTENSIONS: string[] = [
// 'chrome-extension://<id>'
];
const APP_ORIGINS: string[] = [
// Officer mobile app — an officer:// custom-scheme origin (with an embedded token), set via env.
OFFICER_APP_ORIGIN,
// Standalone officer-music app — its own custom-scheme origin. Allowlisted so it can authenticate
// and stream; SCOPED_ORIGINS below restricts it to /api/auth + /api/music only.
MUSIC_APP_ORIGIN,
// OffVault app (Bitwarden-SDK client) — its own custom-scheme origin. Allowlisted so it can sign in to
// the platform AND reach the Vaultwarden reverse-proxy; ORIGIN_RULES below restricts it to
// /api/auth + /api/vault. Vault access uses its own Bitwarden bearer token (not a platform account).
OFFICER_VAULT_ORIGIN,
// Standalone OffTail app (the in-app Tailscale) — its own custom-scheme origin. It signs in and enrolls
// with Headscale; ORIGIN_RULES below restricts it to /api/auth + /api/vpn. The OffTail tile embedded in
// the main Officer app needs nothing here — it reuses OFFICER_APP_ORIGIN.
OFFICER_TAIL_ORIGIN,
].filter((o): o is string => Boolean(o));
// Every `OFFICER_<APP>_ORIGIN` in the environment is an allowed app origin — each app ships a
// custom-scheme origin with an embedded token (`officer://<hex>`), set on the host, never in the repo.
// Adding an app is adding an env var; no code change, which is the point. The slug is what sits between
// the two fixed words: OFFICER_MUSIC_ORIGIN → MUSIC.
const APP_ORIGIN_VAR = /^OFFICER_([A-Z0-9_]+)_ORIGIN$/;
// The only path prefixes a non-owner account (and the music app) may reach.
// Apps that are the whole platform rather than one feature of it. The main Officer app keeps full access
// but is owner-only, so a non-owner can't drive the platform even with a token minted elsewhere.
const OWNER_ONLY_APPS = new Set(['APP']);
// Where an app's API surface isn't `/api/<slug>`. Only exceptions belong here.
const APP_SCOPE_OVERRIDES: Record<string, string[]> = {
// OffTail signs in and mints a Headscale pre-auth key. The VPN's own traffic goes straight to
// Headscale and never through /api, so this is its entire platform surface.
TAIL: ['/api/vpn'],
};
type AppOrigin = { slug: string; origin: string };
const APP_ORIGIN_LIST: AppOrigin[] = Object.entries(process.env).flatMap(([key, value]) => {
const slug = key.match(APP_ORIGIN_VAR)?.[1];
return slug && value ? [{ slug, origin: value }] : [];
});
const APP_ORIGINS: string[] = APP_ORIGIN_LIST.map((a) => a.origin);
// The only path prefixes a non-owner account (and the music app) may reach. Still hand-written because
// this is the ACCOUNT backstop below, not an origin rule — it holds whatever Origin a caller claims.
const NON_OWNER_PATHS = ['/api/auth', '/api/music'];
function pathAllowed(path: string, prefixes: string[]): boolean {
@@ -58,18 +68,22 @@ function pathAllowed(path: string, prefixes: string[]): boolean {
// replaying a token minted through the music app.
// - paths: this Origin may reach ONLY these path prefixes; anything else is 403.
// Origins with no rule keep full access. Rules no-op for env values that are unset.
//
// App rules are derived, not written: an app may reach /api/auth (it has to sign in) plus the one
// feature it is named for — OFFICER_VAULT_ORIGIN gets /api/auth + /api/vault. No superAdminOnly on
// those: the owner signs in through them, and e.g. vault calls carry Bitwarden tokens rather than
// platform accounts, so the account backstop never applies (verify() → null → passes).
type OriginRule = { superAdminOnly?: boolean; paths?: string[] };
const ORIGIN_RULES: Record<string, OriginRule> = {};
for (const { slug, origin } of APP_ORIGIN_LIST) {
ORIGIN_RULES[origin] = OWNER_ONLY_APPS.has(slug)
? { superAdminOnly: true }
: { paths: ['/api/auth', ...(APP_SCOPE_OVERRIDES[slug] ?? [`/api/${slug.toLowerCase()}`])] };
}
// Last, so the web origin wins if an app ever declares the same one.
if (PUBLIC_ORIGIN) ORIGIN_RULES[PUBLIC_ORIGIN] = { superAdminOnly: true };
if (OFFICER_APP_ORIGIN) ORIGIN_RULES[OFFICER_APP_ORIGIN] = { superAdminOnly: true };
if (MUSIC_APP_ORIGIN) ORIGIN_RULES[MUSIC_APP_ORIGIN] = { paths: NON_OWNER_PATHS };
// OffVault signs in to the platform (/api/auth) and reaches the Vaultwarden proxy (/api/vault) — nothing
// else. No superAdminOnly: the owner signs in here, and vault calls carry Bitwarden tokens (not platform
// accounts), so the account backstop never applies to them (verify() → null → passes).
if (OFFICER_VAULT_ORIGIN) ORIGIN_RULES[OFFICER_VAULT_ORIGIN] = { paths: ['/api/auth', '/api/vault'] };
// OffTail signs in (/api/auth) and mints its Headscale pre-auth key (/api/vpn) — nothing else. The VPN
// itself never comes through /api, so this pair is the app's entire platform surface.
if (OFFICER_TAIL_ORIGIN) ORIGIN_RULES[OFFICER_TAIL_ORIGIN] = { paths: ['/api/auth', '/api/vpn'] };
// True when an Origin is reserved for the platform owner (used at signin to reject a non-owner login).
export function isSuperAdminOnlyOrigin(origin: string | undefined): boolean {
+1 -1
View File
@@ -62,7 +62,7 @@ honoServer.use(
);
// Scoped-origin gate: restrict app origins (e.g. the music app) to their allowed path prefixes
// (/api/auth + /api/music). No-ops for the main web origin and while MUSIC_APP_ORIGIN is unset.
// (/api/auth + /api/music). No-ops for the main web origin and while OFFICER_MUSIC_ORIGIN is unset.
honoServer.use(originScopeMiddleware);
honoServer.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));