← back to Grant
lib/accounts.ts
104 lines
/**
* Account capability registry — the single source of truth for what each
* login can do. Keyed by the app-gate username carried in the session token.
*
* The app-gate (lib/auth) only knows a username; this maps that username to a
* capability set. Enforced server-side in middleware.ts (authoritative) and
* mirrored client-side via AuthProvider for UI affordances.
*
* Pure module — NO database or node-only imports — so it is safe to import
* from middleware.
*
* Accounts:
* admin — full operator (existing AUTH_USERNAME login).
* guest — member tier: clean Live view, can add/edit data, no admin controls.
* viewer — fully read-only: can view everything, cannot mutate anything.
* demo — read-only data, but AI Discover/Generate routes are usable (demo).
*
* A guest tier is only a usable login when its password env var is set; see
* GUEST_PASSWORD_ENV below and the login route. Absent password ⇒ login
* disabled, but the capability mapping still applies if a session ever exists.
*/
export type Role = 'admin' | 'member' | 'viewer';
export interface Capabilities {
username: string;
role: Role;
displayName: string;
/** Sees admin controls + the Live/Admin toggle. */
isAdmin: boolean;
/** May mutate data (non-AI POST/PUT/PATCH/DELETE). */
canWrite: boolean;
/** May call the AI discover/generate routes. */
canUseAI: boolean;
}
/** The admin username is configurable via env; everything else maps to admin. */
export const ADMIN_USERNAME = process.env.AUTH_USERNAME || 'admin';
/** Fixed guest usernames. The matching password env gates whether the login exists. */
export const GUEST_USERNAMES = ['guest', 'viewer', 'demo'] as const;
export type GuestUsername = (typeof GUEST_USERNAMES)[number];
/** Env var that holds each guest tier's password. */
export const GUEST_PASSWORD_ENV: Record<GuestUsername, string> = {
guest: 'GRANT_GUEST_PASSWORD',
viewer: 'GRANT_VIEWER_PASSWORD',
demo: 'GRANT_DEMO_PASSWORD',
};
const GUEST_CAPS: Record<GuestUsername, Omit<Capabilities, 'username'>> = {
guest: {
role: 'member',
displayName: 'Guest',
isAdmin: false,
canWrite: true,
canUseAI: false,
},
viewer: {
role: 'viewer',
displayName: 'Viewer (read-only)',
isAdmin: false,
canWrite: false,
canUseAI: false,
},
demo: {
role: 'viewer',
displayName: 'Demo',
isAdmin: false,
canWrite: false,
canUseAI: true,
},
};
const ADMIN_CAPS: Omit<Capabilities, 'username'> = {
role: 'admin',
displayName: 'Site Admin',
isAdmin: true,
canWrite: true,
canUseAI: true,
};
/**
* Resolve capabilities for a session username. Unknown usernames fail safe to
* the most-restrictive (read-only, no AI) set — the login route only ever
* mints known usernames, so this is purely defensive.
*/
export function getCapabilities(username: string): Capabilities {
if (username === ADMIN_USERNAME) {
return { username, ...ADMIN_CAPS };
}
if ((GUEST_USERNAMES as readonly string[]).includes(username)) {
return { username, ...GUEST_CAPS[username as GuestUsername] };
}
return {
username,
role: 'viewer',
displayName: username,
isAdmin: false,
canWrite: false,
canUseAI: false,
};
}