← back to Grant

lib/auth.ts

102 lines

import { createAuth, type AuthSession } from '@dw/nextjs-admin-login';
import { query } from './db';
import { getCapabilities, type Role } from './accounts';

export const auth = createAuth({ cookieName: 'grant-auth' });
export const {
  COOKIE_NAME: AUTH_COOKIE_NAME,
  createSession,
  verifyAuth,
  buildAuthCookie,
  buildLogoutCookie,
  hashPassword,
  // 2026-05-05 (tick 19): v0.2.0 — org-scoped session helpers.
  // verifyAuthWithOrg accepts BOTH legacy 3-part tokens (returns orgId:null)
  // and new 4-part tokens (returns orgId:string), so existing logged-in
  // browsers don't get bumped.
  createSessionWithOrg,
  verifyAuthWithOrg,
} = auth;
export type { AuthSession };

/**
 * Resolve org_id for the current request. Prefers the session cookie's
 * embedded orgId (v0.2.0 fast path); falls back to `SELECT id FROM
 * organizations LIMIT 1` for legacy v1 sessions or pre-onboarding state.
 * Returns null if neither path produces an org row.
 */
export async function resolveOrgId(session: AuthSession): Promise<string | null> {
  if (session.orgId) return session.orgId;
  // 2026-05-30 (audit P2-1): deterministic fallback — without ORDER BY,
  // LIMIT 1 picks an arbitrary org row, which would mis-route legacy v1
  // sessions in a multi-org deployment. Pin to the oldest org.
  const orgRes = await query('SELECT id FROM organizations ORDER BY created_at ASC LIMIT 1');
  return orgRes.rows.length > 0 ? orgRes.rows[0].id : null;
}

/**
 * Identity returned to the client for the current session: the app-gate
 * username plus its capability set (see lib/accounts).
 */
export interface SessionIdentity {
  user: string;
  role: Role;
  isAdmin: boolean;
  canWrite: boolean;
  canUseAI: boolean;
  email: string | null;
  displayName: string | null;
}

/**
 * Resolve the full identity + capabilities for a session.
 *
 * Capabilities are looked up from lib/accounts by the session username
 * (admin / guest / viewer / demo). For the admin account we additionally
 * enrich email/displayName from the org's owner/admin users-table row so the
 * header shows the real account; guests are entirely registry-defined and are
 * never in the users table. The registry is the single source of truth for
 * isAdmin/canWrite/canUseAI — the same values middleware enforces.
 */
export async function resolveSessionIdentity(
  session: AuthSession,
): Promise<SessionIdentity> {
  const caps = getCapabilities(session.username);

  let email: string | null = null;
  let displayName: string | null = caps.displayName;

  // Enrich the admin identity from the users table (best-effort).
  if (caps.isAdmin) {
    try {
      const orgId = await resolveOrgId(session);
      if (orgId) {
        const res = await query<{ email: string; display_name: string | null }>(
          `SELECT email, display_name
             FROM users
            WHERE org_id = $1 AND role IN ('owner', 'admin')
            ORDER BY (role = 'owner') DESC, created_at ASC
            LIMIT 1`,
          [orgId],
        );
        if (res.rows.length > 0) {
          email = res.rows[0].email;
          displayName = res.rows[0].display_name ?? displayName;
        }
      }
    } catch {
      // DB unreachable — fall back to registry display name, stay admin.
    }
  }

  return {
    user: session.username,
    role: caps.role,
    isAdmin: caps.isAdmin,
    canWrite: caps.canWrite,
    canUseAI: caps.canUseAI,
    email,
    displayName,
  };
}