signup-allowlist.ts101 lines · main
1import { newId, ValidationError } from '@briven/shared';
2import { and, desc, eq, isNull } from 'drizzle-orm';
3
4import { getDb } from '../db/client.js';
5import { signupAllowlist, type SignupAllowlistEntry } from '../db/schema.js';
6
7/**
8 * Invite-only beta gate. When `BRIVEN_OPEN_SIGNUPS=false`, Better
9 * Auth's `user.create.before` hook (configured in lib/auth.ts) calls
10 * `isEmailAllowed` and rejects any signup whose email isn't on this
11 * list. An admin manages entries via /dashboard/admin/allowlist.
12 *
13 * The check is case-insensitive and trims whitespace — we normalise on
14 * write + read so "Foo@example.com" and "foo@example.com " resolve to
15 * the same allowlist row.
16 */
17
18const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
19
20function normalise(email: string): string {
21 return email.trim().toLowerCase();
22}
23
24function validateEmail(email: string): string {
25 const cleaned = normalise(email);
26 if (!EMAIL_RE.test(cleaned)) {
27 throw new ValidationError('not a valid email address');
28 }
29 return cleaned;
30}
31
32export async function listAllowlist(): Promise<SignupAllowlistEntry[]> {
33 const db = getDb();
34 return db.select().from(signupAllowlist).orderBy(desc(signupAllowlist.invitedAt));
35}
36
37export async function isEmailAllowed(email: string): Promise<boolean> {
38 const cleaned = normalise(email);
39 const db = getDb();
40 const rows = await db
41 .select({ id: signupAllowlist.id })
42 .from(signupAllowlist)
43 .where(eq(signupAllowlist.email, cleaned))
44 .limit(1);
45 return rows.length > 0;
46}
47
48export interface AddEntryInput {
49 email: string;
50 invitedBy: string | null;
51 notes?: string | null;
52}
53
54export async function addToAllowlist(input: AddEntryInput): Promise<SignupAllowlistEntry> {
55 const email = validateEmail(input.email);
56 const db = getDb();
57 try {
58 const inserted = await db
59 .insert(signupAllowlist)
60 .values({
61 id: newId('al'),
62 email,
63 invitedBy: input.invitedBy,
64 notes: input.notes ?? null,
65 })
66 .returning();
67 const row = inserted[0];
68 if (!row) throw new Error('insert returned no row');
69 return row;
70 } catch (err) {
71 if (err instanceof Error && /unique|duplicate/i.test(err.message)) {
72 throw new ValidationError(`${email} is already on the allowlist`);
73 }
74 throw err;
75 }
76}
77
78export async function removeFromAllowlist(email: string): Promise<boolean> {
79 const cleaned = normalise(email);
80 const db = getDb();
81 const result = await db
82 .delete(signupAllowlist)
83 .where(eq(signupAllowlist.email, cleaned))
84 .returning({ id: signupAllowlist.id });
85 return result.length > 0;
86}
87
88/**
89 * Stamp `accepted_at` on the matching row when an allowlisted email
90 * actually signs up. Called from the Better Auth `user.create.after`
91 * hook. Idempotent — repeat signups (e.g. magic-link re-flows) are
92 * fine; the first one wins and the rest no-op via the WHERE clause.
93 */
94export async function markAllowlistAccepted(email: string): Promise<void> {
95 const cleaned = normalise(email);
96 const db = getDb();
97 await db
98 .update(signupAllowlist)
99 .set({ acceptedAt: new Date() })
100 .where(and(eq(signupAllowlist.email, cleaned), isNull(signupAllowlist.acceptedAt)));
101}