Ctrl K
ComunidadGuías…

lib/auth/better-auth/invitations.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<InvitationRecord[]> {
  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<boolean> {
  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<InvitationRecord | null> {
  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<boolean> {
  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<boolean> {
  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<void> {
  await db
    .update(invitations)
    .set({ usedAt: null })
    .where(and(eq(invitations.id, id), isNull(invitations.revokedAt)))
}