auth: confine non-owner accounts to the music app (account + origin gates)

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>
This commit is contained in:
2026-07-26 18:38:29 +00:00
co-authored by Claude Opus 4.8
parent b40464632c
commit 1d441e3aa1
3 changed files with 116 additions and 20 deletions
+8
View File
@@ -3,6 +3,8 @@ import { getUserByEmail, getPasskeysByUserIdAndOrigin } from 'officerdb';
import { sign } from '@@/jwt';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
import { isSuperAdminOnlyOrigin } from '@@/_middlewares';
import { isSuperAdmin } from '@@/super-admin';
import { isLockdown, noteBlocked } from './panic';
const TEST_USERS: number[] = [];
@@ -28,6 +30,12 @@ export const signinHandler: Handler = async function (ctx) {
const isValidPassword = TEST_USERS.includes(dbUser.id) || (await argon2.verify(dbUser.password, password));
if (!isValidPassword) throw errors.UNAUTHORIZED();
// A non-owner account may authenticate only through an app origin (e.g. the music app), never the
// Super-Admin-only web/mobile origins — so its credentials can't open the full platform in a browser.
if (isSuperAdminOnlyOrigin(origin) && !(await isSuperAdmin({ id: dbUser.id }))) {
throw errors.FORBIDDEN('This account can only sign in through its app.');
}
const { id, name, username } = dbUser;
const tokenUser = { id, email, name, username, passkeys: passkeys.length };