import crypto from 'node:crypto'; import { and, createDb, eq, gt, type Database, magicLinks, memberships, organizations, sessions, users } from '@bmm/db'; const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; // 15 min const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days function sha256(input: string): string { return crypto.createHash('sha256').update(input).digest('hex'); } function randomToken(bytes = 32): string { return crypto.randomBytes(bytes).toString('base64url'); } function slugify(input: string): string { return input .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, '') .slice(0, 48) || 'org'; } export interface MagicLinkIssued { token: string; expiresAt: Date; } export async function issueMagicLink(email: string, db: Database = createDb()): Promise { const lower = email.trim().toLowerCase(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(lower)) { throw new Error('invalid_email'); } const token = randomToken(32); const tokenHash = sha256(token); const expiresAt = new Date(Date.now() + MAGIC_LINK_TTL_MS); await db.insert(magicLinks).values({ email: lower, tokenHash, expiresAt }); return { token, expiresAt }; } export interface ConsumedSession { sessionToken: string; userId: string; orgId: string; email: string; } export async function consumeMagicLink( token: string, meta: { ipAddress?: string; userAgent?: string } = {}, db: Database = createDb(), ): Promise { const tokenHash = sha256(token); const [row] = await db .select() .from(magicLinks) .where(and(eq(magicLinks.tokenHash, tokenHash), gt(magicLinks.expiresAt, new Date()))) .limit(1); if (!row || row.consumedAt) { throw new Error('invalid_or_expired_token'); } await db.update(magicLinks).set({ consumedAt: new Date() }).where(eq(magicLinks.id, row.id)); // Get or create user + default org let user = (await db.select().from(users).where(eq(users.email, row.email)).limit(1))[0]; if (!user) { [user] = await db .insert(users) .values({ email: row.email, emailVerified: true }) .returning(); const orgSlug = `${slugify(row.email.split('@')[0] ?? 'me')}-${randomToken(3).toLowerCase()}`; const [org] = await db .insert(organizations) .values({ slug: orgSlug, name: `${row.email.split('@')[0]}'s workspace` }) .returning(); if (!org) throw new Error('org_create_failed'); await db.insert(memberships).values({ orgId: org.id, userId: user!.id, role: 'owner' }); } else if (!user.emailVerified) { await db.update(users).set({ emailVerified: true }).where(eq(users.id, user.id)); } if (!user) throw new Error('user_resolve_failed'); const [membership] = await db .select() .from(memberships) .where(eq(memberships.userId, user.id)) .limit(1); if (!membership) throw new Error('no_org_membership'); const sessionToken = randomToken(32); const sessionHash = sha256(sessionToken); await db.insert(sessions).values({ userId: user.id, tokenHash: sessionHash, expiresAt: new Date(Date.now() + SESSION_TTL_MS), ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); return { sessionToken, userId: user.id, orgId: membership.orgId, email: user.email }; } export interface AuthedUser { userId: string; orgId: string; email: string; role: string; } export async function getSession( sessionToken: string | null | undefined, db: Database = createDb(), ): Promise { if (!sessionToken) return null; const hash = sha256(sessionToken); const [row] = await db .select({ userId: sessions.userId, expiresAt: sessions.expiresAt, email: users.email, }) .from(sessions) .innerJoin(users, eq(users.id, sessions.userId)) .where(eq(sessions.tokenHash, hash)) .limit(1); if (!row || row.expiresAt < new Date()) return null; const [membership] = await db .select({ orgId: memberships.orgId, role: memberships.role }) .from(memberships) .where(eq(memberships.userId, row.userId)) .limit(1); if (!membership) return null; return { userId: row.userId, orgId: membership.orgId, email: row.email, role: membership.role }; } export async function destroySession(sessionToken: string, db: Database = createDb()): Promise { const hash = sha256(sessionToken); await db.delete(sessions).where(eq(sessions.tokenHash, hash)); } export const __test = { sha256, randomToken, slugify };