← 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,
  };
}