import { parseUserOutput } from "better-auth/db"; import { eq } from "drizzle-orm"; import { isNonEmptyString, readJsonBody } from "@/worker/api/request"; import { oauthAccessTokens, oauthRefreshTokens, sessions, users } from "@/worker/db/schema"; import type { AppContext } from "@/worker/hono"; import { HttpError } from "@/worker/http"; import { requireSession } from "@/worker/middleware/auth"; interface BanUserBody { userId?: unknown; banReason?: unknown; banExpiresIn?: unknown; } // tessera-owned ban handler mounted at POST /api/auth/admin/ban-user // before the Better Auth catchall. Better Auth's plugin only flips // `banned=true` and deletes session rows; it leaves OAuth refresh and // access tokens behind, so a banned user could keep minting tokens via // the refresh-token grant. The atomic D1 batch here flips the flag, // drops sessions, and drops both OAuth token tables in one transaction // so the four state changes succeed or fail together. export const handleBanUser = async (c: AppContext): Promise => { const session = requireSession(c); const logger = c.var.log.child({ component: "admin.users" }); const body = await readJsonBody(c); if (!isNonEmptyString(body.userId)) { throw new HttpError(400, "invalid_body", "userId is required."); } const targetUserId = body.userId; const banReason = isNonEmptyString(body.banReason) && body.banReason.length <= 500 ? body.banReason : "No reason"; const banExpires = typeof body.banExpiresIn === "number" && body.banExpiresIn > 0 ? new Date(Date.now() + body.banExpiresIn * 1000) : null; if (targetUserId === session.user.id) { // Mirror Better Auth's self-ban guard while keeping the response shape // predictable for the UI. throw new HttpError(400, "YOU_CANNOT_BAN_YOURSELF", "You can't ban yourself."); } const db = c.var.db; const exists = await db.select({ id: users.id }).from(users).where(eq(users.id, targetUserId)).get(); if (!exists) { throw new HttpError(404, "USER_NOT_FOUND", "User not found."); } const now = new Date(); // D1 batch is transactional, giving the all-or-rollback semantics the // ban-kill-switch invariant requires. await db.batch([ db.update(users).set({ banned: true, banReason, banExpires, updatedAt: now }).where(eq(users.id, targetUserId)), db.delete(sessions).where(eq(sessions.userId, targetUserId)), db.delete(oauthAccessTokens).where(eq(oauthAccessTokens.userId, targetUserId)), db.delete(oauthRefreshTokens).where(eq(oauthRefreshTokens.userId, targetUserId)), ]); logger.info("admin_user_banned", { targetUserId, byUserId: session.user.id, banExpiresAt: banExpires?.toISOString() ?? null, }); // Re-select after the batch so the response reflects post-update state // for every column, including any future `additionalField` Better Auth // is configured with. parseUserOutput strips fields the plugin marks // private and applies the same shaping the admin plugin's own // ban-user route uses, so authClient.admin.banUser sees a consistent // user object regardless of which path produced it. const updated = await db.select().from(users).where(eq(users.id, targetUserId)).get(); if (!updated) { // Lost the row between the batch and the re-select. Surface as 500 // rather than fabricating a partial response. throw new HttpError(500, "ban_consistency_error", "Ban applied but user row missing."); } return c.json({ user: parseUserOutput(c.var.auth.options, updated) }); };