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