File
Blob: src/worker/api/admin/users.ts
| 1 | import { parseUserOutput } from "better-auth/db"; |
| 2 | import { eq } from "drizzle-orm"; |
| 3 | |
| 4 | import { isNonEmptyString, readJsonBody } from "@/worker/api/request"; |
| 5 | import { oauthAccessTokens, oauthRefreshTokens, sessions, users } from "@/worker/db/schema"; |
| 6 | import type { AppContext } from "@/worker/hono"; |
| 7 | import { HttpError } from "@/worker/http"; |
| 8 | import { requireSession } from "@/worker/middleware/auth"; |
| 9 | |
| 10 | interface BanUserBody { |
| 11 | userId?: unknown; |
| 12 | banReason?: unknown; |
| 13 | banExpiresIn?: unknown; |
| 14 | } |
| 15 | |
| 16 | // tessera-owned ban handler mounted at POST /api/auth/admin/ban-user |
| 17 | // before the Better Auth catchall. Better Auth's plugin only flips |
| 18 | // `banned=true` and deletes session rows; it leaves OAuth refresh and |
| 19 | // access tokens behind, so a banned user could keep minting tokens via |
| 20 | // the refresh-token grant. The atomic D1 batch here flips the flag, |
| 21 | // drops sessions, and drops both OAuth token tables in one transaction |
| 22 | // so the four state changes succeed or fail together. |
| 23 | export const handleBanUser = async (c: AppContext): Promise<Response> => { |
| 24 | const session = requireSession(c); |
| 25 | const logger = c.var.log.child({ component: "admin.users" }); |
| 26 | const body = await readJsonBody<BanUserBody>(c); |
| 27 | if (!isNonEmptyString(body.userId)) { |
| 28 | throw new HttpError(400, "invalid_body", "userId is required."); |
| 29 | } |
| 30 | const targetUserId = body.userId; |
| 31 | const banReason = isNonEmptyString(body.banReason) && body.banReason.length <= 500 ? body.banReason : "No reason"; |
| 32 | const banExpires = |
| 33 | typeof body.banExpiresIn === "number" && body.banExpiresIn > 0 |
| 34 | ? new Date(Date.now() + body.banExpiresIn * 1000) |
| 35 | : null; |
| 36 | |
| 37 | if (targetUserId === session.user.id) { |
| 38 | // Mirror Better Auth's self-ban guard while keeping the response shape |
| 39 | // predictable for the UI. |
| 40 | throw new HttpError(400, "YOU_CANNOT_BAN_YOURSELF", "You can't ban yourself."); |
| 41 | } |
| 42 | |
| 43 | const db = c.var.db; |
| 44 | const exists = await db.select({ id: users.id }).from(users).where(eq(users.id, targetUserId)).get(); |
| 45 | if (!exists) { |
| 46 | throw new HttpError(404, "USER_NOT_FOUND", "User not found."); |
| 47 | } |
| 48 | |
| 49 | const now = new Date(); |
| 50 | // D1 batch is transactional, giving the all-or-rollback semantics the |
| 51 | // ban-kill-switch invariant requires. |
| 52 | await db.batch([ |
| 53 | db.update(users).set({ banned: true, banReason, banExpires, updatedAt: now }).where(eq(users.id, targetUserId)), |
| 54 | db.delete(sessions).where(eq(sessions.userId, targetUserId)), |
| 55 | db.delete(oauthAccessTokens).where(eq(oauthAccessTokens.userId, targetUserId)), |
| 56 | db.delete(oauthRefreshTokens).where(eq(oauthRefreshTokens.userId, targetUserId)), |
| 57 | ]); |
| 58 | |
| 59 | logger.info("admin_user_banned", { |
| 60 | targetUserId, |
| 61 | byUserId: session.user.id, |
| 62 | banExpiresAt: banExpires?.toISOString() ?? null, |
| 63 | }); |
| 64 | |
| 65 | // Re-select after the batch so the response reflects post-update state |
| 66 | // for every column, including any future `additionalField` Better Auth |
| 67 | // is configured with. parseUserOutput strips fields the plugin marks |
| 68 | // private and applies the same shaping the admin plugin's own |
| 69 | // ban-user route uses, so authClient.admin.banUser sees a consistent |
| 70 | // user object regardless of which path produced it. |
| 71 | const updated = await db.select().from(users).where(eq(users.id, targetUserId)).get(); |
| 72 | if (!updated) { |
| 73 | // Lost the row between the batch and the re-select. Surface as 500 |
| 74 | // rather than fabricating a partial response. |
| 75 | throw new HttpError(500, "ban_consistency_error", "Ban applied but user row missing."); |
| 76 | } |
| 77 | return c.json({ user: parseUserOutput(c.var.auth.options, updated) }); |
| 78 | }; |