auth-security.ts522 lines · main
1import { runInProjectDatabase } from '../db/data-plane.js';
2
3/**
4 * User security management — suspensions, bans, allowlist/blocklist,
5 * waitlist, and email validation.
6 *
7 * All user-moderation state lives in `_briven_auth_user_security` (an
8 * auxiliary table — we never ALTER the users table per DoltGres constraints).
9 * Waitlist entries live in `_briven_auth_waitlist`.
10 */
11
12// ─── disposable email domains ─────────────────────────────────────────────
13
14/** A small built-in set of known disposable domains. Expanded at runtime
15 * from a downloaded blocklist if available. */
16const BUILT_IN_DISPOSABLE = new Set([
17 'mailinator.com',
18 'tempmail.com',
19 'throwaway.com',
20 'guerrillamail.com',
21 'yopmail.com',
22 'sharklasers.com',
23 'getairmail.com',
24 '10minutemail.com',
25 'burnermail.io',
26 'temp-mail.org',
27 'fakeinbox.com',
28 'mailnesia.com',
29 'trashmail.com',
30 'getnada.com',
31 ' Mohmal.com',
32 'maildrop.cc',
33 'harakirimail.com',
34 'spamgourmet.com',
35 'mailcatch.com',
36 'mytrashmail.com',
37 'emailondeck.com',
38 'dispostable.com',
39]);
40
41// ─── email validation helpers ─────────────────────────────────────────────
42
43/**
44 * Normalize email for gates + linking.
45 * Gmail/googlemail: ignore dots and strip +tags so `a.b+x@gmail.com` ≡ `ab@gmail.com`.
46 */
47export function normalizeEmail(email: string): string {
48 const trimmed = email.trim().toLowerCase();
49 const at = trimmed.lastIndexOf('@');
50 if (at < 0) return trimmed;
51 let local = trimmed.slice(0, at);
52 const domain = trimmed.slice(at + 1);
53 // Gmail normalization: dots ignored, +tags stripped, googlemail → gmail.
54 if (domain === 'gmail.com' || domain === 'googlemail.com') {
55 local = local.replace(/\./g, '').split('+')[0]!;
56 return `${local}@gmail.com`;
57 }
58 return `${local}@${domain}`;
59}
60
61function domainFromEmail(email: string): string {
62 const at = email.lastIndexOf('@');
63 return at >= 0 ? email.slice(at + 1) : '';
64}
65
66function hasSubaddress(email: string): boolean {
67 const local = email.split('@')[0] ?? '';
68 return /[+#=]/.test(local);
69}
70
71function isDisposableDomain(domain: string): boolean {
72 return BUILT_IN_DISPOSABLE.has(domain.toLowerCase());
73}
74
75// ─── allowlist / blocklist checks ─────────────────────────────────────────
76
77export interface EmailGateResult {
78 allowed: boolean;
79 reason?: string;
80}
81
82/**
83 * Check whether an email address passes the project's allowlist/blocklist
84 * rules. Called BEFORE sign-up and BEFORE org invite acceptance.
85 */
86export function checkEmailGate(
87 email: string,
88 opts: {
89 allowedDomains: string[];
90 blockedDomains: string[];
91 blockDisposable: boolean;
92 blockSubaddresses: boolean;
93 },
94): EmailGateResult {
95 const normalized = normalizeEmail(email);
96 const domain = domainFromEmail(normalized);
97
98 if (!domain) {
99 return { allowed: false, reason: 'invalid email address' };
100 }
101
102 // Blocklist is checked first — explicit blocks override allows.
103 if (opts.blockedDomains.length > 0) {
104 const blocked = opts.blockedDomains.some(
105 (d) => domain === d.toLowerCase() || normalized.endsWith(`@${d.toLowerCase()}`),
106 );
107 if (blocked) {
108 return { allowed: false, reason: 'this email domain is not allowed' };
109 }
110 }
111
112 if (opts.blockDisposable && isDisposableDomain(domain)) {
113 return { allowed: false, reason: 'disposable email addresses are not allowed' };
114 }
115
116 if (opts.blockSubaddresses && hasSubaddress(normalized)) {
117 return { allowed: false, reason: 'email subaddresses are not allowed' };
118 }
119
120 if (opts.allowedDomains.length > 0) {
121 const allowed = opts.allowedDomains.some(
122 (d) => domain === d.toLowerCase() || normalized.endsWith(`@${d.toLowerCase()}`),
123 );
124 if (!allowed) {
125 return { allowed: false, reason: 'your email domain is not on the allowed list' };
126 }
127 }
128
129 return { allowed: true };
130}
131
132// ─── user security state (bans / suspensions) ─────────────────────────────
133
134export interface UserSecurityState {
135 suspendedAt: Date | null;
136 suspendedReason: string | null;
137 bannedAt: Date | null;
138 bannedReason: string | null;
139 banExpiresAt: Date | null;
140}
141
142/**
143 * Fetch the security state for a user. Returns null when no moderation
144 * row exists (the common case — most users are unmoderated).
145 */
146export async function getUserSecurityState(
147 projectId: string,
148 userId: string,
149): Promise<UserSecurityState | null> {
150 const rows = await runInProjectDatabase<
151 Array<{
152 suspended_at: Date | null;
153 suspended_reason: string | null;
154 banned_at: Date | null;
155 banned_reason: string | null;
156 ban_expires_at: Date | null;
157 }>
158 >(projectId, async (tx) =>
159 tx.unsafe(
160 `SELECT suspended_at, suspended_reason, banned_at, banned_reason, ban_expires_at
161 FROM "_briven_auth_user_security"
162 WHERE user_id = $1
163 LIMIT 1`,
164 [userId] as never[],
165 ) as never,
166 );
167 const row = rows[0];
168 if (!row) return null;
169 return {
170 suspendedAt: row.suspended_at,
171 suspendedReason: row.suspended_reason,
172 bannedAt: row.banned_at,
173 bannedReason: row.banned_reason,
174 banExpiresAt: row.ban_expires_at,
175 };
176}
177
178/**
179 * Is the user currently blocked from authentication? Checks both suspension
180 * and ban (including expired bans).
181 */
182export function isUserBlocked(state: UserSecurityState | null): { blocked: boolean; reason?: string } {
183 if (!state) return { blocked: false };
184
185 // Permanent ban
186 if (state.bannedAt && !state.banExpiresAt) {
187 return { blocked: true, reason: state.bannedReason ?? 'account banned' };
188 }
189
190 // Temporary ban (check expiry)
191 if (state.bannedAt && state.banExpiresAt) {
192 if (new Date() < state.banExpiresAt) {
193 return { blocked: true, reason: state.bannedReason ?? 'account temporarily banned' };
194 }
195 }
196
197 // Suspension
198 if (state.suspendedAt) {
199 return { blocked: true, reason: state.suspendedReason ?? 'account suspended' };
200 }
201
202 return { blocked: false };
203}
204
205/**
206 * Ban a user. Idempotent — re-banning updates the reason and timestamp.
207 */
208export async function banUser(
209 projectId: string,
210 userId: string,
211 opts: { reason?: string; expiresAt?: Date },
212): Promise<void> {
213 await runInProjectDatabase(projectId, async (tx) => {
214 // Upsert: insert if absent, update if present.
215 const existing = await tx.unsafe(
216 `SELECT 1 FROM "_briven_auth_user_security" WHERE user_id = $1 LIMIT 1`,
217 [userId] as never[],
218 );
219 const now = new Date().toISOString();
220 const expires = opts.expiresAt ? opts.expiresAt.toISOString() : null;
221 if ((existing as unknown[]).length > 0) {
222 await tx.unsafe(
223 `UPDATE "_briven_auth_user_security"
224 SET banned_at = $2, banned_reason = $3, ban_expires_at = $4, updated_at = $2
225 WHERE user_id = $1`,
226 [userId, now, opts.reason ?? null, expires] as never,
227 );
228 } else {
229 await tx.unsafe(
230 `INSERT INTO "_briven_auth_user_security"
231 (id, user_id, banned_at, banned_reason, ban_expires_at, created_at, updated_at)
232 VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $2, $2)`,
233 [userId, now, opts.reason ?? null, expires] as never,
234 );
235 }
236 });
237}
238
239/**
240 * Unban a user. Idempotent — no-op if the user was never banned.
241 */
242export async function unbanUser(projectId: string, userId: string): Promise<void> {
243 await runInProjectDatabase(projectId, async (tx) => {
244 await tx.unsafe(
245 `UPDATE "_briven_auth_user_security"
246 SET banned_at = NULL, banned_reason = NULL, ban_expires_at = NULL, updated_at = $2
247 WHERE user_id = $1`,
248 [userId, new Date().toISOString()] as never,
249 );
250 });
251}
252
253/**
254 * Suspend a user.
255 */
256export async function suspendUser(
257 projectId: string,
258 userId: string,
259 opts: { reason?: string } = {},
260): Promise<void> {
261 await runInProjectDatabase(projectId, async (tx) => {
262 const existing = await tx.unsafe(
263 `SELECT 1 FROM "_briven_auth_user_security" WHERE user_id = $1 LIMIT 1`,
264 [userId] as never[],
265 );
266 const now = new Date().toISOString();
267 if ((existing as unknown[]).length > 0) {
268 await tx.unsafe(
269 `UPDATE "_briven_auth_user_security"
270 SET suspended_at = $2, suspended_reason = $3, updated_at = $2
271 WHERE user_id = $1`,
272 [userId, now, opts.reason ?? null] as never,
273 );
274 } else {
275 await tx.unsafe(
276 `INSERT INTO "_briven_auth_user_security"
277 (id, user_id, suspended_at, suspended_reason, created_at, updated_at)
278 VALUES (gen_random_uuid()::text, $1, $2, $3, $2, $2)`,
279 [userId, now, opts.reason ?? null] as never,
280 );
281 }
282 });
283}
284
285/**
286 * Unsuspend a user.
287 */
288export async function unsuspendUser(projectId: string, userId: string): Promise<void> {
289 await runInProjectDatabase(projectId, async (tx) => {
290 await tx.unsafe(
291 `UPDATE "_briven_auth_user_security"
292 SET suspended_at = NULL, suspended_reason = NULL, updated_at = $2
293 WHERE user_id = $1`,
294 [userId, new Date().toISOString()] as never,
295 );
296 });
297}
298
299// ─── waitlist ─────────────────────────────────────────────────────────────
300
301export interface WaitlistEntry {
302 id: string;
303 email: string;
304 name: string | null;
305 status: 'pending' | 'approved' | 'rejected';
306 createdAt: Date;
307}
308
309/**
310 * Add an email to the waitlist. Idempotent — if the email already exists,
311 * returns the existing row without overwriting.
312 */
313export async function addToWaitlist(
314 projectId: string,
315 input: { email: string; name?: string },
316): Promise<WaitlistEntry> {
317 const normalized = normalizeEmail(input.email);
318 const rows = await runInProjectDatabase<
319 Array<{ id: string; email: string; name: string | null; status: string; created_at: Date }>
320 >(projectId, async (tx) => {
321 // Try insert; on conflict (unique email) return existing.
322 const inserted = await tx.unsafe(
323 `INSERT INTO "_briven_auth_waitlist" (id, email, name, status, created_at, updated_at)
324 VALUES (gen_random_uuid()::text, $1, $2, 'pending', now(), now())
325 ON CONFLICT (email) DO UPDATE SET updated_at = now()
326 RETURNING id, email, name, status, created_at`,
327 [normalized, input.name ?? null] as never,
328 );
329 return inserted as never;
330 });
331 const row = rows[0];
332 if (!row) throw new Error('waitlist insert returned no row');
333 return {
334 id: row.id,
335 email: row.email,
336 name: row.name,
337 status: row.status as WaitlistEntry['status'],
338 createdAt: row.created_at,
339 };
340}
341
342/**
343 * Check whether an email has been approved on the waitlist.
344 */
345export async function isWaitlistApproved(
346 projectId: string,
347 email: string,
348): Promise<boolean> {
349 const rows = await runInProjectDatabase<Array<{ status: string }>>(
350 projectId,
351 async (tx) =>
352 tx.unsafe(
353 `SELECT status FROM "_briven_auth_waitlist" WHERE email = $1 LIMIT 1`,
354 [normalizeEmail(email)] as never,
355 ) as never,
356 );
357 const row = rows[0];
358 return row?.status === 'approved';
359}
360
361/**
362 * Approve a waitlist entry by id.
363 */
364export async function approveWaitlistEntry(
365 projectId: string,
366 entryId: string,
367 approvedBy: string,
368): Promise<void> {
369 await runInProjectDatabase(projectId, async (tx) => {
370 await tx.unsafe(
371 `UPDATE "_briven_auth_waitlist"
372 SET status = 'approved', approved_at = now(), approved_by = $2, updated_at = now()
373 WHERE id = $1`,
374 [entryId, approvedBy] as never,
375 );
376 });
377}
378
379/**
380 * Reject a waitlist entry by id.
381 */
382export async function rejectWaitlistEntry(
383 projectId: string,
384 entryId: string,
385 opts: { reason?: string } = {},
386): Promise<void> {
387 await runInProjectDatabase(projectId, async (tx) => {
388 await tx.unsafe(
389 `UPDATE "_briven_auth_waitlist"
390 SET status = 'rejected', rejected_at = now(), rejected_reason = $2, updated_at = now()
391 WHERE id = $1`,
392 [entryId, opts.reason ?? null] as never,
393 );
394 });
395}
396
397/**
398 * List waitlist entries for the admin dashboard.
399 */
400export async function listWaitlist(
401 projectId: string,
402 opts: { status?: string; limit?: number; cursor?: string | null } = {},
403): Promise<{ items: WaitlistEntry[]; nextCursor: string | null }> {
404 const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
405
406 // Build WHERE clauses with parameterized queries to avoid injection.
407 const conditions: string[] = [];
408 const params: unknown[] = [];
409
410 if (opts.status) {
411 conditions.push(`status = $${params.length + 1}`);
412 params.push(opts.status);
413 }
414
415 if (opts.cursor) {
416 const [createdAt, id] = opts.cursor.split('__');
417 if (createdAt && id) {
418 conditions.push(
419 `(created_at < $${params.length + 1} OR (created_at = $${params.length + 1} AND id < $${params.length + 2}))`,
420 );
421 params.push(createdAt, id);
422 }
423 }
424
425 const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
426 params.push(limit + 1);
427 const limitPlaceholder = `$${params.length}`;
428
429 const rows = await runInProjectDatabase<
430 Array<{
431 id: string;
432 email: string;
433 name: string | null;
434 status: string;
435 created_at: Date;
436 }>
437 >(
438 projectId,
439 async (tx) =>
440 tx.unsafe(
441 `SELECT id, email, name, status, created_at
442 FROM "_briven_auth_waitlist"
443 ${whereClause}
444 ORDER BY created_at DESC, id DESC
445 LIMIT ${limitPlaceholder}`,
446 params as never,
447 ) as never,
448 );
449
450 const hasMore = rows.length > limit;
451 const page = hasMore ? rows.slice(0, limit) : rows;
452
453 const items = page.map((r) => ({
454 id: r.id,
455 email: r.email,
456 name: r.name,
457 status: r.status as WaitlistEntry['status'],
458 createdAt: r.created_at,
459 }));
460
461 const nextCursor = hasMore
462 ? `${page[page.length - 1]!.created_at.toISOString()}__${page[page.length - 1]!.id}`
463 : null;
464
465 return { items, nextCursor };
466}
467
468// ─── sign-up mode gate ────────────────────────────────────────────────────
469
470export interface SignUpGateResult {
471 allowed: boolean;
472 reason?: string;
473 waitlistEntry?: WaitlistEntry;
474}
475
476/**
477 * The master sign-up gate. Combines signUpMode + email allowlist/blocklist
478 * + waitlist status into a single decision.
479 *
480 * Called by the auth-tenant bridge BEFORE Better Auth processes sign-up.
481 */
482export async function checkSignUpGate(
483 projectId: string,
484 email: string,
485 opts: {
486 signUpMode: 'public' | 'restricted' | 'waitlist';
487 allowedDomains: string[];
488 blockedDomains: string[];
489 blockDisposable: boolean;
490 blockSubaddresses: boolean;
491 },
492): Promise<SignUpGateResult> {
493 // 1. Email format / blocklist / allowlist.
494 const emailGate = checkEmailGate(email, {
495 allowedDomains: opts.allowedDomains,
496 blockedDomains: opts.blockedDomains,
497 blockDisposable: opts.blockDisposable,
498 blockSubaddresses: opts.blockSubaddresses,
499 });
500 if (!emailGate.allowed) {
501 return { allowed: false, reason: emailGate.reason };
502 }
503
504 // 2. Sign-up mode.
505 if (opts.signUpMode === 'restricted') {
506 return { allowed: false, reason: 'sign-ups are restricted to invited users only' };
507 }
508
509 if (opts.signUpMode === 'waitlist') {
510 const approved = await isWaitlistApproved(projectId, email);
511 if (!approved) {
512 const entry = await addToWaitlist(projectId, { email });
513 return {
514 allowed: false,
515 reason: 'you have been added to the waitlist. you will receive an email when approved.',
516 waitlistEntry: entry,
517 };
518 }
519 }
520
521 return { allowed: true };
522}