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:
@@ -0,0 +1,38 @@
|
||||
import { getUsers, getUserByEmail } from 'officerdb';
|
||||
|
||||
// Identifies the platform owner ("Super Admin"). Two mechanisms, both supported:
|
||||
// (b) explicit — SUPER_ADMIN_EMAIL in .env designates the owner by email.
|
||||
// (a) automatic — if that's unset, the bootstrap/first account (lowest user id) is the owner.
|
||||
// Resolved once and cached: the owner never changes at runtime (bootstrap is closed after user #1).
|
||||
const { SUPER_ADMIN_EMAIL } = process.env;
|
||||
|
||||
let cachedId: number | null = null;
|
||||
let resolved = false;
|
||||
|
||||
async function resolveSuperAdminId(): Promise<number | null> {
|
||||
if (resolved) return cachedId;
|
||||
if (SUPER_ADMIN_EMAIL) {
|
||||
const user = await getUserByEmail(SUPER_ADMIN_EMAIL);
|
||||
cachedId = user?.id ?? null;
|
||||
} else {
|
||||
const users = await getUsers();
|
||||
cachedId = users.length ? users.reduce((min, u) => (u.id < min ? u.id : min), users[0]!.id) : null;
|
||||
}
|
||||
resolved = true;
|
||||
return cachedId;
|
||||
}
|
||||
|
||||
export async function getSuperAdminId(): Promise<number | null> {
|
||||
try {
|
||||
return await resolveSuperAdminId();
|
||||
} catch {
|
||||
// Transient DB error before the id is cached: stay unresolved (deny) and retry next call.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isSuperAdmin(payload: { id?: number } | null | undefined): Promise<boolean> {
|
||||
if (!payload?.id) return false;
|
||||
const adminId = await getSuperAdminId();
|
||||
return adminId !== null && payload.id === adminId;
|
||||
}
|
||||
Reference in New Issue
Block a user