import { createHash, randomBytes } from 'crypto' import { and, desc, eq, gt, isNull } from 'drizzle-orm' import { db } from '@/lib/db' import { invitations } from './schema' /** Invitation tokens are valid for one week by default. */ export const INVITATION_TTL_MS = 7 * 24 * 60 * 60 * 1000 export interface InvitationRecord { id: string email: string | null token: string invitedBy: string revokedAt: Date | null usedAt: Date | null expiresAt: Date createdAt: Date } function toDate(value: Date | string | null): Date | null { if (value === null || value === undefined) return null return value instanceof Date ? value : new Date(value) } function toRecord(row: typeof invitations.$inferSelect): InvitationRecord { return { ...row, revokedAt: toDate(row.revokedAt), usedAt: toDate(row.usedAt), expiresAt: toDate(row.expiresAt)!, createdAt: toDate(row.createdAt)! } } export function generateInvitationToken(): string { return randomBytes(24).toString('hex') } /** * Invitation tokens are stored as SHA-256 hashes. The plaintext token only * exists in the link handed to the admin at creation time, so a database * leak cannot be replayed as valid invitations. */ export function hashInvitationToken(token: string): string { return createHash('sha256').update(token).digest('hex') } export async function createInvitation(params: { invitedBy: string email: string ttlMs?: number }): Promise<{ invitation: InvitationRecord; token: string }> { const token = generateInvitationToken() const [row] = await db .insert(invitations) .values({ token: hashInvitationToken(token), email: params.email, invitedBy: params.invitedBy, expiresAt: new Date(Date.now() + (params.ttlMs ?? INVITATION_TTL_MS)) }) .returning() return { invitation: toRecord(row), token } } export async function listInvitations(): Promise { const rows = await db .select() .from(invitations) .orderBy(desc(invitations.createdAt)) return rows.map(toRecord) } /** * True when a live bootstrap invitation for the address was created within * the cooldown window. The bootstrap branch runs before better-auth's rate * limiter, so without this a caller who knows the gated address could loop * the sign-up action to flood the mailbox and the table with admin links. */ export async function hasRecentBootstrapInvitation( email: string, cooldownMs: number ): Promise { const [existing] = await db .select({ id: invitations.id }) .from(invitations) .where( and( eq(invitations.email, email), eq(invitations.invitedBy, 'bootstrap'), isNull(invitations.usedAt), isNull(invitations.revokedAt), gt(invitations.expiresAt, new Date()), gt(invitations.createdAt, new Date(Date.now() - cooldownMs)) ) ) return Boolean(existing) } /** * Resolve an invitation token. Returns the invitation only when it exists, * is not revoked, not already used, and not expired. */ export async function validateInvitation( token: string | null | undefined ): Promise { if (!token) { return null } const [row] = await db .select() .from(invitations) .where( and( eq(invitations.token, hashInvitationToken(token)), isNull(invitations.revokedAt) ) ) if (!row) { return null } const invitation = toRecord(row) if (invitation.usedAt || invitation.expiresAt.getTime() <= Date.now()) { return null } return invitation } export async function revokeInvitation(id: string): Promise { const [row] = await db .update(invitations) .set({ revokedAt: new Date() }) .where(and(eq(invitations.id, id), isNull(invitations.revokedAt))) .returning({ id: invitations.id }) return Boolean(row) } /** Marks an invitation as consumed. Returns false if already consumed. */ export async function consumeInvitation(id: string): Promise { const [row] = await db .update(invitations) .set({ usedAt: new Date() }) .where( and( eq(invitations.id, id), isNull(invitations.usedAt), isNull(invitations.revokedAt) ) ) .returning({ id: invitations.id }) return Boolean(row) } /** * Undoes a consumption so the same link can be redeemed again. Used when the * sign-up the claim enabled failed before creating the account (e.g. the * password was rejected), so a corrected retry does not need a fresh link. * The address binding and expiry still apply. */ export async function releaseInvitation(id: string): Promise { await db .update(invitations) .set({ usedAt: null }) .where(and(eq(invitations.id, id), isNull(invitations.revokedAt))) }