Skip to content
File

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

typescript184 lines
1import { and, desc, eq, lt, or } from "drizzle-orm";
2 
3import { isNonEmptyString, readOptionalJsonBody, requiredParam } from "@/worker/api/request";
4import type { AppContext } from "@/worker/hono";
5import { invites, users } from "@/worker/db/schema";
6import { HttpError, secretJsonResponse } from "@/worker/http";
7import { errorContext } from "@/worker/logger";
8import { requireSession } from "@/worker/middleware/auth";
9import { encodeBase64Url, sha256 } from "@/worker/services/crypto";
10 
11const INVITE_TOKEN_BYTES = 32;
12const DEFAULT_EXPIRY_DAYS = 7;
13const MAX_PAGE = 100;
14const DEFAULT_PAGE = 50;
15 
16// Pragmatic email shape check — not RFC 5322 perfect, but rejects typos
17// and obviously bad input before signUpEmail does. Better Auth's signup
18// validates again at acceptance time.
19const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
20 
21interface CreateInviteBody {
22 email?: unknown;
23 expiresInDays?: unknown;
24}
25 
26interface CursorParts {
27 createdAt: string;
28 id: string;
29}
30 
31// Cursor encoding is `<isoCreatedAt>|<id>`. ISO timestamps contain
32// colons (`2026-04-27T10:25:00.000Z`), so a `:` delimiter would split
33// the timestamp itself. The pipe never appears in either half: ISO
34// dates do not use it, and invite IDs are produced by `inv_<base64url>`
35// which is alphanumeric plus `-` and `_`.
36const CURSOR_DELIMITER = "|";
37 
38const parseCursor = (raw: string | undefined): CursorParts | null => {
39 if (!raw) return null;
40 const splitAt = raw.indexOf(CURSOR_DELIMITER);
41 if (splitAt <= 0 || splitAt === raw.length - 1) return null;
42 return { createdAt: raw.slice(0, splitAt), id: raw.slice(splitAt + 1) };
43};
44 
45export const handleListInvites = async (c: AppContext): Promise<Response> => {
46 const db = c.var.db;
47 const cursor = parseCursor(c.req.query("cursor"));
48 const requestedLimit = Number.parseInt(c.req.query("limit") ?? "", 10);
49 const limit =
50 Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, MAX_PAGE) : DEFAULT_PAGE;
51 
52 // Stable ordering by (createdAt desc, id desc). Cursor selects rows
53 // strictly older than the previous page's last row; the id tiebreak
54 // matters when two invites share a millisecond timestamp.
55 const where = cursor
56 ? or(
57 lt(invites.createdAt, cursor.createdAt),
58 and(eq(invites.createdAt, cursor.createdAt), lt(invites.id, cursor.id)),
59 )
60 : undefined;
61 const rows = await db
62 .select()
63 .from(invites)
64 .where(where)
65 .orderBy(desc(invites.createdAt), desc(invites.id))
66 .limit(limit + 1);
67 
68 const hasMore = rows.length > limit;
69 const page = hasMore ? rows.slice(0, limit) : rows;
70 const last = page[page.length - 1];
71 const nextCursor = hasMore && last ? `${last.createdAt}${CURSOR_DELIMITER}${last.id}` : null;
72 
73 return c.json({
74 invites: page.map((r) => ({
75 id: r.id,
76 email: r.email,
77 createdAt: r.createdAt,
78 expiresAt: r.expiresAt,
79 consumedAt: r.consumedAt,
80 })),
81 nextCursor,
82 });
83};
84 
85export const handleCreateInvite = async (c: AppContext): Promise<Response> => {
86 const session = requireSession(c);
87 const logger = c.var.log.child({ component: "admin.invites" });
88 const body = await readOptionalJsonBody<CreateInviteBody>(c, {});
89 if (!isNonEmptyString(body.email)) {
90 throw new HttpError(400, "invalid_body", "email is required.");
91 }
92 const email = body.email.trim().toLowerCase();
93 if (!EMAIL_PATTERN.test(email)) {
94 throw new HttpError(400, "invalid_email", "email is not a valid address.");
95 }
96 const expiresInDays =
97 typeof body.expiresInDays === "number" && body.expiresInDays > 0 && body.expiresInDays <= 90
98 ? body.expiresInDays
99 : DEFAULT_EXPIRY_DAYS;
100 
101 // Refuse minting when an account already exists for this email. tessera
102 // is invite-only so the only signup path is invite-accept; an existing
103 // user would mean the invitee signed in via another channel (social) or
104 // a prior invite was already accepted. Surfacing the conflict here lets
105 // the admin act (delete the user, or just sign in) instead of generating
106 // a doomed invite URL that would burn at accept time. Both columns are
107 // stored lowercase (Better Auth signup + the trim/lower above) so plain
108 // `eq` is correct.
109 const existingUser = await c.var.db.select({ id: users.id }).from(users).where(eq(users.email, email)).get();
110 if (existingUser) {
111 throw new HttpError(409, "user_exists", "A user with this email already exists.");
112 }
113 
114 // Refuse minting when an invite for this email already exists, consumed
115 // or not. The DB-level UNIQUE(email) on the invites table is the
116 // authoritative guard against race conditions; this pre-check exists so
117 // the admin sees a clear 409 instead of a generic 500 from the SQLite
118 // constraint violation in the happy sequential case.
119 const existingInvite = await c.var.db.select({ id: invites.id }).from(invites).where(eq(invites.email, email)).get();
120 if (existingInvite) {
121 throw new HttpError(409, "invite_exists", "An invite for this email already exists. Delete it first.");
122 }
123 
124 const tokenBytes = crypto.getRandomValues(new Uint8Array(INVITE_TOKEN_BYTES));
125 const token = encodeBase64Url(tokenBytes);
126 const tokenHash = await sha256(token);
127 const now = new Date();
128 const expiresAt = new Date(now.getTime() + expiresInDays * 86400_000);
129 
130 const id = `inv_${encodeBase64Url(crypto.getRandomValues(new Uint8Array(12)))}`;
131 try {
132 await c.var.db.insert(invites).values({
133 id,
134 tokenHash,
135 email,
136 createdBy: session.user.id,
137 createdAt: now.toISOString(),
138 expiresAt: expiresAt.toISOString(),
139 });
140 } catch (err) {
141 // Only the UNIQUE(email) race maps to 409 — every other failure
142 // (D1 transient, schema mismatch, network) must surface as a 500
143 // through the global handler so the operator sees the real cause.
144 // SQLite's wording for this constraint is
145 // `UNIQUE constraint failed: invites.email` (D1 propagates the
146 // message verbatim). Match against the table.column form rather
147 // than the index name so the check survives a future rename of
148 // `idx_invites_email`.
149 const message = err instanceof Error ? err.message : String(err);
150 const isEmailUniqueViolation = message.includes("UNIQUE constraint failed") && message.includes("invites.email");
151 if (!isEmailUniqueViolation) {
152 throw err;
153 }
154 // TOCTOU: a concurrent admin mint won the UNIQUE(email) race after
155 // the pre-check above. Same fail-closed shape as the pre-check.
156 logger.warn("admin_invite_create_unique_violation", {
157 email,
158 ...errorContext(err, c.var.logLevel),
159 });
160 throw new HttpError(409, "invite_exists", "An invite for this email already exists. Delete it first.");
161 }
162 logger.info("admin_invite_created", {
163 inviteId: id,
164 createdBy: session.user.id,
165 expiresInDays,
166 });
167 
168 const inviteUrl = `${c.var.baseURL}/invite/${token}`;
169 return secretJsonResponse({
170 id,
171 email,
172 inviteUrl,
173 expiresAt: expiresAt.toISOString(),
174 });
175};
176 
177export const handleRevokeInvite = async (c: AppContext): Promise<Response> => {
178 const logger = c.var.log.child({ component: "admin.invites" });
179 const id = requiredParam(c, "id", { status: 400, code: "invalid_id", message: "Invite id required." });
180 await c.var.db.delete(invites).where(eq(invites.id, id));
181 logger.info("admin_invite_revoked", { inviteId: id, byUserId: c.var.session?.user.id });
182 return c.body(null, 204);
183};