import { and, desc, eq, lt, or } from "drizzle-orm"; import { isNonEmptyString, readOptionalJsonBody, requiredParam } from "@/worker/api/request"; import type { AppContext } from "@/worker/hono"; import { invites, users } from "@/worker/db/schema"; import { HttpError, secretJsonResponse } from "@/worker/http"; import { errorContext } from "@/worker/logger"; import { requireSession } from "@/worker/middleware/auth"; import { encodeBase64Url, sha256 } from "@/worker/services/crypto"; const INVITE_TOKEN_BYTES = 32; const DEFAULT_EXPIRY_DAYS = 7; const MAX_PAGE = 100; const DEFAULT_PAGE = 50; // Pragmatic email shape check — not RFC 5322 perfect, but rejects typos // and obviously bad input before signUpEmail does. Better Auth's signup // validates again at acceptance time. const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; interface CreateInviteBody { email?: unknown; expiresInDays?: unknown; } interface CursorParts { createdAt: string; id: string; } // Cursor encoding is `|`. ISO timestamps contain // colons (`2026-04-27T10:25:00.000Z`), so a `:` delimiter would split // the timestamp itself. The pipe never appears in either half: ISO // dates do not use it, and invite IDs are produced by `inv_` // which is alphanumeric plus `-` and `_`. const CURSOR_DELIMITER = "|"; const parseCursor = (raw: string | undefined): CursorParts | null => { if (!raw) return null; const splitAt = raw.indexOf(CURSOR_DELIMITER); if (splitAt <= 0 || splitAt === raw.length - 1) return null; return { createdAt: raw.slice(0, splitAt), id: raw.slice(splitAt + 1) }; }; export const handleListInvites = async (c: AppContext): Promise => { const db = c.var.db; const cursor = parseCursor(c.req.query("cursor")); const requestedLimit = Number.parseInt(c.req.query("limit") ?? "", 10); const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, MAX_PAGE) : DEFAULT_PAGE; // Stable ordering by (createdAt desc, id desc). Cursor selects rows // strictly older than the previous page's last row; the id tiebreak // matters when two invites share a millisecond timestamp. const where = cursor ? or( lt(invites.createdAt, cursor.createdAt), and(eq(invites.createdAt, cursor.createdAt), lt(invites.id, cursor.id)), ) : undefined; const rows = await db .select() .from(invites) .where(where) .orderBy(desc(invites.createdAt), desc(invites.id)) .limit(limit + 1); const hasMore = rows.length > limit; const page = hasMore ? rows.slice(0, limit) : rows; const last = page[page.length - 1]; const nextCursor = hasMore && last ? `${last.createdAt}${CURSOR_DELIMITER}${last.id}` : null; return c.json({ invites: page.map((r) => ({ id: r.id, email: r.email, createdAt: r.createdAt, expiresAt: r.expiresAt, consumedAt: r.consumedAt, })), nextCursor, }); }; export const handleCreateInvite = async (c: AppContext): Promise => { const session = requireSession(c); const logger = c.var.log.child({ component: "admin.invites" }); const body = await readOptionalJsonBody(c, {}); if (!isNonEmptyString(body.email)) { throw new HttpError(400, "invalid_body", "email is required."); } const email = body.email.trim().toLowerCase(); if (!EMAIL_PATTERN.test(email)) { throw new HttpError(400, "invalid_email", "email is not a valid address."); } const expiresInDays = typeof body.expiresInDays === "number" && body.expiresInDays > 0 && body.expiresInDays <= 90 ? body.expiresInDays : DEFAULT_EXPIRY_DAYS; // Refuse minting when an account already exists for this email. tessera // is invite-only so the only signup path is invite-accept; an existing // user would mean the invitee signed in via another channel (social) or // a prior invite was already accepted. Surfacing the conflict here lets // the admin act (delete the user, or just sign in) instead of generating // a doomed invite URL that would burn at accept time. Both columns are // stored lowercase (Better Auth signup + the trim/lower above) so plain // `eq` is correct. const existingUser = await c.var.db.select({ id: users.id }).from(users).where(eq(users.email, email)).get(); if (existingUser) { throw new HttpError(409, "user_exists", "A user with this email already exists."); } // Refuse minting when an invite for this email already exists, consumed // or not. The DB-level UNIQUE(email) on the invites table is the // authoritative guard against race conditions; this pre-check exists so // the admin sees a clear 409 instead of a generic 500 from the SQLite // constraint violation in the happy sequential case. const existingInvite = await c.var.db.select({ id: invites.id }).from(invites).where(eq(invites.email, email)).get(); if (existingInvite) { throw new HttpError(409, "invite_exists", "An invite for this email already exists. Delete it first."); } const tokenBytes = crypto.getRandomValues(new Uint8Array(INVITE_TOKEN_BYTES)); const token = encodeBase64Url(tokenBytes); const tokenHash = await sha256(token); const now = new Date(); const expiresAt = new Date(now.getTime() + expiresInDays * 86400_000); const id = `inv_${encodeBase64Url(crypto.getRandomValues(new Uint8Array(12)))}`; try { await c.var.db.insert(invites).values({ id, tokenHash, email, createdBy: session.user.id, createdAt: now.toISOString(), expiresAt: expiresAt.toISOString(), }); } catch (err) { // Only the UNIQUE(email) race maps to 409 — every other failure // (D1 transient, schema mismatch, network) must surface as a 500 // through the global handler so the operator sees the real cause. // SQLite's wording for this constraint is // `UNIQUE constraint failed: invites.email` (D1 propagates the // message verbatim). Match against the table.column form rather // than the index name so the check survives a future rename of // `idx_invites_email`. const message = err instanceof Error ? err.message : String(err); const isEmailUniqueViolation = message.includes("UNIQUE constraint failed") && message.includes("invites.email"); if (!isEmailUniqueViolation) { throw err; } // TOCTOU: a concurrent admin mint won the UNIQUE(email) race after // the pre-check above. Same fail-closed shape as the pre-check. logger.warn("admin_invite_create_unique_violation", { email, ...errorContext(err, c.var.logLevel), }); throw new HttpError(409, "invite_exists", "An invite for this email already exists. Delete it first."); } logger.info("admin_invite_created", { inviteId: id, createdBy: session.user.id, expiresInDays, }); const inviteUrl = `${c.var.baseURL}/invite/${token}`; return secretJsonResponse({ id, email, inviteUrl, expiresAt: expiresAt.toISOString(), }); }; export const handleRevokeInvite = async (c: AppContext): Promise => { const logger = c.var.log.child({ component: "admin.invites" }); const id = requiredParam(c, "id", { status: 400, code: "invalid_id", message: "Invite id required." }); await c.var.db.delete(invites).where(eq(invites.id, id)); logger.info("admin_invite_revoked", { inviteId: id, byUserId: c.var.session?.user.id }); return c.body(null, 204); };