Skip to content

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.

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).

When isAdminPath(pathname) is true, the middleware:

  1. Builds a fresh createAuth(env, undefined, origin) instance and resolves the session server-side via auth.api.getSession({ headers }).
  2. If a session user exists, stashes user, session, and organizationId on context.locals.
  3. Wraps the whole resolution in try/catch and fails closed — any session-resolution error is treated as unauthenticated. An error never opens the gate.
  4. 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 pathResponse.json({ success: false, error: 'Operator access required' }, { status: 403 }).
  • Page path302 redirect to /dashboard if the user is signed in but not an operator, or to /login?redirect=… if unauthenticated.

isAdminSession (and its core, isAdminUser) recognise exactly two server-controlled, non-self-grantable paths:

  1. Role column. The user’s Better Auth role is 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’s set-role endpoint, which itself requires an existing admin session — there is no UI or API by which a non-admin self-promotes.
  2. Bootstrap allow-list. The user’s id is in env.ADMIN_USER_IDS (comma-separated), parsed by parseAdminUserIds. The same list is handed to the admin plugin via admin({ adminUserIds }), so Better Auth’s own hasPermission honours it. This is the only way to seed the first operator — no migration ever sets role = '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
}

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.

  • Authentication Overview — session model and the viq_session legacy cookie
  • Scopes — public-API scope gating (distinct from the operator role gate)