Skip to content
File

Blob: src/worker/api/admin/users.ts

typescript79 lines
1import { parseUserOutput } from "better-auth/db";
2import { eq } from "drizzle-orm";
3 
4import { isNonEmptyString, readJsonBody } from "@/worker/api/request";
5import { oauthAccessTokens, oauthRefreshTokens, sessions, users } from "@/worker/db/schema";
6import type { AppContext } from "@/worker/hono";
7import { HttpError } from "@/worker/http";
8import { requireSession } from "@/worker/middleware/auth";
9 
10interface 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.
23export 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};