/** * EncryptID Database Layer — PostgreSQL * * Replaces in-memory Maps with persistent PostgreSQL storage. * Uses the 'postgres' npm package (lightweight, no native deps with Bun). */ import postgres from 'postgres'; import { readFileSync } from 'fs'; import { join } from 'path'; // ============================================================================ // CONNECTION // ============================================================================ const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { throw new Error('DATABASE_URL environment variable is required'); } const sql = postgres(DATABASE_URL, { max: 10, idle_timeout: 20, connect_timeout: 10, }); // ============================================================================ // TYPES // ============================================================================ export interface StoredCredential { credentialId: string; publicKey: string; userId: string; username: string; counter: number; createdAt: number; lastUsed?: number; transports?: string[]; } export interface StoredChallenge { challenge: string; userId?: string; type: 'registration' | 'authentication'; createdAt: number; expiresAt: number; } // ============================================================================ // INITIALIZATION // ============================================================================ export async function initDatabase(): Promise { const schema = readFileSync(join(import.meta.dir, 'schema.sql'), 'utf-8'); await sql.unsafe(schema); console.log('EncryptID: Database initialized'); // Clean expired challenges on startup await cleanExpiredChallenges(); } // ============================================================================ // USER OPERATIONS // ============================================================================ export async function createUser(id: string, username: string, displayName?: string, did?: string): Promise { await sql` INSERT INTO users (id, username, display_name, did) VALUES (${id}, ${username}, ${displayName || username}, ${did || null}) ON CONFLICT (id) DO NOTHING `; } export async function getUserByUsername(username: string) { const [user] = await sql`SELECT * FROM users WHERE username = ${username}`; return user || null; } // ============================================================================ // CREDENTIAL OPERATIONS // ============================================================================ export async function storeCredential(cred: StoredCredential): Promise { // Ensure user exists first await createUser(cred.userId, cred.username); await sql` INSERT INTO credentials (credential_id, user_id, public_key, counter, transports, created_at) VALUES ( ${cred.credentialId}, ${cred.userId}, ${cred.publicKey}, ${cred.counter}, ${cred.transports || null}, ${new Date(cred.createdAt)} ) `; } export async function getCredential(credentialId: string): Promise { const rows = await sql` SELECT c.credential_id, c.public_key, c.user_id, c.counter, c.transports, c.created_at, c.last_used, u.username FROM credentials c JOIN users u ON c.user_id = u.id WHERE c.credential_id = ${credentialId} `; if (rows.length === 0) return null; const row = rows[0]; return { credentialId: row.credential_id, publicKey: row.public_key, userId: row.user_id, username: row.username, counter: row.counter, createdAt: new Date(row.created_at).getTime(), lastUsed: row.last_used ? new Date(row.last_used).getTime() : undefined, transports: row.transports, }; } export async function updateCredentialUsage(credentialId: string, newCounter: number): Promise { await sql` UPDATE credentials SET counter = ${newCounter}, last_used = NOW() WHERE credential_id = ${credentialId} `; } export async function getUserCredentials(userId: string): Promise { const rows = await sql` SELECT c.credential_id, c.public_key, c.user_id, c.counter, c.transports, c.created_at, c.last_used, u.username FROM credentials c JOIN users u ON c.user_id = u.id WHERE c.user_id = ${userId} `; return rows.map(row => ({ credentialId: row.credential_id, publicKey: row.public_key, userId: row.user_id, username: row.username, counter: row.counter, createdAt: new Date(row.created_at).getTime(), lastUsed: row.last_used ? new Date(row.last_used).getTime() : undefined, transports: row.transports, })); } // ============================================================================ // CHALLENGE OPERATIONS // ============================================================================ export async function storeChallenge(ch: StoredChallenge): Promise { await sql` INSERT INTO challenges (challenge, user_id, type, created_at, expires_at) VALUES ( ${ch.challenge}, ${ch.userId || null}, ${ch.type}, ${new Date(ch.createdAt)}, ${new Date(ch.expiresAt)} ) `; } export async function getChallenge(challenge: string): Promise { const rows = await sql` SELECT * FROM challenges WHERE challenge = ${challenge} `; if (rows.length === 0) return null; const row = rows[0]; return { challenge: row.challenge, userId: row.user_id || undefined, type: row.type as 'registration' | 'authentication', createdAt: new Date(row.created_at).getTime(), expiresAt: new Date(row.expires_at).getTime(), }; } export async function deleteChallenge(challenge: string): Promise { await sql`DELETE FROM challenges WHERE challenge = ${challenge}`; } export async function cleanExpiredChallenges(): Promise { const result = await sql`DELETE FROM challenges WHERE expires_at < NOW()`; return result.count; } // ============================================================================ // USER EMAIL OPERATIONS // ============================================================================ export async function setUserEmail(userId: string, email: string): Promise { await sql`UPDATE users SET email = ${email} WHERE id = ${userId}`; } export async function getUserByEmail(email: string) { const [user] = await sql`SELECT * FROM users WHERE email = ${email}`; return user || null; } export async function getUserById(userId: string) { const [user] = await sql`SELECT * FROM users WHERE id = ${userId}`; return user || null; } // ============================================================================ // RECOVERY TOKEN OPERATIONS // ============================================================================ export interface StoredRecoveryToken { token: string; userId: string; type: 'email_verify' | 'account_recovery'; createdAt: number; expiresAt: number; used: boolean; } export async function storeRecoveryToken(rt: StoredRecoveryToken): Promise { await sql` INSERT INTO recovery_tokens (token, user_id, type, created_at, expires_at, used) VALUES ( ${rt.token}, ${rt.userId}, ${rt.type}, ${new Date(rt.createdAt)}, ${new Date(rt.expiresAt)}, ${rt.used} ) `; } export async function getRecoveryToken(token: string): Promise { const rows = await sql`SELECT * FROM recovery_tokens WHERE token = ${token}`; if (rows.length === 0) return null; const row = rows[0]; return { token: row.token, userId: row.user_id, type: row.type as 'email_verify' | 'account_recovery', createdAt: new Date(row.created_at).getTime(), expiresAt: new Date(row.expires_at).getTime(), used: row.used, }; } export async function markRecoveryTokenUsed(token: string): Promise { await sql`UPDATE recovery_tokens SET used = TRUE WHERE token = ${token}`; } export async function cleanExpiredRecoveryTokens(): Promise { const result = await sql`DELETE FROM recovery_tokens WHERE expires_at < NOW()`; return result.count; } // ============================================================================ // SPACE MEMBERSHIP // ============================================================================ export interface StoredSpaceMember { spaceSlug: string; userDID: string; role: string; joinedAt: number; grantedBy?: string; } export async function getSpaceMember( spaceSlug: string, userDID: string, ): Promise { const rows = await sql` SELECT * FROM space_members WHERE space_slug = ${spaceSlug} AND user_did = ${userDID} `; if (rows.length === 0) return null; const row = rows[0]; return { spaceSlug: row.space_slug, userDID: row.user_did, role: row.role, joinedAt: new Date(row.joined_at).getTime(), grantedBy: row.granted_by || undefined, }; } export async function listSpaceMembers( spaceSlug: string, ): Promise { const rows = await sql` SELECT * FROM space_members WHERE space_slug = ${spaceSlug} ORDER BY joined_at ASC `; return rows.map((row) => ({ spaceSlug: row.space_slug, userDID: row.user_did, role: row.role, joinedAt: new Date(row.joined_at).getTime(), grantedBy: row.granted_by || undefined, })); } export async function upsertSpaceMember( spaceSlug: string, userDID: string, role: string, grantedBy?: string, ): Promise { const rows = await sql` INSERT INTO space_members (space_slug, user_did, role, granted_by) VALUES (${spaceSlug}, ${userDID}, ${role}, ${grantedBy ?? null}) ON CONFLICT (space_slug, user_did) DO UPDATE SET role = ${role}, granted_by = ${grantedBy ?? null} RETURNING * `; const row = rows[0]; return { spaceSlug: row.space_slug, userDID: row.user_did, role: row.role, joinedAt: new Date(row.joined_at).getTime(), grantedBy: row.granted_by || undefined, }; } export async function removeSpaceMember( spaceSlug: string, userDID: string, ): Promise { const result = await sql` DELETE FROM space_members WHERE space_slug = ${spaceSlug} AND user_did = ${userDID} `; return result.count > 0; } // ============================================================================ // HEALTH CHECK // ============================================================================ export async function checkDatabaseHealth(): Promise { try { await sql`SELECT 1`; return true; } catch { return false; } } export { sql };