Admin / Operator Guard
The operator console is deny-by-default. Everything under /admin (the page shell) and the entire /api/admin/* surface is admitted only for a server-validated session whose user holds an admin role. There is no 200-with-content-hidden-client-side path: non-admins are redirected (pages) or get 403 JSON (API). The decision is made in src/middleware.ts and runs before the legacy KV-session shortcut, so a legacy shared-password session — which carries no role — can never reach /admin.
Which paths are guarded
Section titled “Which paths are guarded”Route matching lives in src/lib/auth/admin-guard.ts as two pure, unit-tested predicates over the two prefixes /admin and /api/admin:
export const ADMIN_PATH_PREFIXES = ["/admin", "/api/admin"] as const;
export function isAdminPath(pathname: string): boolean { return ADMIN_PATH_PREFIXES.some((p) => pathname === p || pathname.startsWith(`${p}/`));}
export function isAdminApiPath(pathname: string): boolean { return pathname === "/api/admin" || pathname.startsWith("/api/admin/");}The exact-or-slash boundary is deliberate: /administrator and /api/admins merely share a string prefix and are not gated as admin. isAdminApiPath distinguishes the API sub-surface (which gets 403 JSON on denial) from the rest of the console (which redirects).
The guard sequence
Section titled “The guard sequence”When isAdminPath(pathname) is true, the middleware:
- Builds a fresh
createAuth(env, undefined, origin)instance and resolves the session server-side viaauth.api.getSession({ headers }). - If a session user exists, stashes
user,session, andorganizationIdoncontext.locals. - Wraps the whole resolution in
try/catchand fails closed — any session-resolution error is treated as unauthenticated. An error never opens the gate. - Calls
isAdminSession(user, env.ADMIN_USER_IDS).
If the guard passes, it sets context.locals.isOperator = true (the /api/admin/* route-level guard in src/lib/admin/auth.ts re-checks this server-set flag as defense in depth) and proceeds. If it fails:
- API path →
Response.json({ success: false, error: 'Operator access required' }, { status: 403 }). - Page path →
302redirect to/dashboardif the user is signed in but not an operator, or to/login?redirect=…if unauthenticated.
What makes a user an operator
Section titled “What makes a user an operator”isAdminSession (and its core, isAdminUser) recognise exactly two server-controlled, non-self-grantable paths:
- Role column. The user’s Better Auth
roleis one of the admin role names —"admin"or"superAdmin"(ADMIN_ROLE_NAMES). Matching is case-insensitive but exact per comma-separated token, so"administrator"and"user-admin"are not admin. The role is set only by Better Auth’sset-roleendpoint, which itself requires an existing admin session — there is no UI or API by which a non-admin self-promotes. - Bootstrap allow-list. The user’s id is in
env.ADMIN_USER_IDS(comma-separated), parsed byparseAdminUserIds. The same list is handed to the admin plugin viaadmin({ adminUserIds }), so Better Auth’s ownhasPermissionhonours it. This is the only way to seed the first operator — no migration ever setsrole = 'admin'.
Everything is fail-closed: no user, a blank or unknown role, and an id absent from the allow-list all resolve to false.
export function isAdminUser(user, adminUserIds) { if (!user) return false; if (user.id && adminUserIds.has(user.id)) return true; // bootstrap path return roleIsAdmin(user.role); // role path}Operator landing behaviour
Section titled “Operator landing behaviour”Outside the admin paths, the middleware adds one operator convenience: a request to the exact path /dashboard from an admin session is 302-redirected to /admin, unless a viq_view=user cookie is present (the “Switch to User View” toggle). Only /dashboard is rerouted — project pages and the rest of the app stay reachable so an operator can still use the user-facing product.
Related
Section titled “Related”- Authentication Overview — session model and the
viq_sessionlegacy cookie - Scopes — public-API scope gating (distinct from the operator role gate)