/** * 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[]; rpId?: string; } export interface StoredChallenge { challenge: string; userId?: string; type: 'registration' | 'authentication' | 'device_registration'; 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, rp_id) VALUES ( ${cred.credentialId}, ${cred.userId}, ${cred.publicKey}, ${cred.counter}, ${cred.transports || null}, ${new Date(cred.createdAt)}, ${cred.rpId || 'rspace.online'} ) `; } 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' | 'email_verification'; 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; } // ============================================================================ // GUARDIAN OPERATIONS // ============================================================================ export interface StoredGuardian { id: string; userId: string; name: string; email: string | null; guardianUserId: string | null; status: 'pending' | 'accepted' | 'revoked'; inviteToken: string | null; inviteExpiresAt: number | null; acceptedAt: number | null; createdAt: number; } function rowToGuardian(row: any): StoredGuardian { return { id: row.id, userId: row.user_id, name: row.name, email: row.email || null, guardianUserId: row.guardian_user_id || null, status: row.status, inviteToken: row.invite_token || null, inviteExpiresAt: row.invite_expires_at ? new Date(row.invite_expires_at).getTime() : null, acceptedAt: row.accepted_at ? new Date(row.accepted_at).getTime() : null, createdAt: new Date(row.created_at).getTime(), }; } export async function addGuardian( id: string, userId: string, name: string, email: string | null, inviteToken: string, inviteExpiresAt: number, ): Promise { const rows = await sql` INSERT INTO guardians (id, user_id, name, email, invite_token, invite_expires_at) VALUES (${id}, ${userId}, ${name}, ${email}, ${inviteToken}, ${new Date(inviteExpiresAt)}) RETURNING * `; return rowToGuardian(rows[0]); } export async function getGuardians(userId: string): Promise { const rows = await sql` SELECT * FROM guardians WHERE user_id = ${userId} AND status != 'revoked' ORDER BY created_at ASC `; return rows.map(rowToGuardian); } export async function getGuardianByInviteToken(token: string): Promise { const rows = await sql`SELECT * FROM guardians WHERE invite_token = ${token}`; if (rows.length === 0) return null; return rowToGuardian(rows[0]); } export async function acceptGuardianInvite(guardianId: string, guardianUserId: string): Promise { await sql` UPDATE guardians SET status = 'accepted', guardian_user_id = ${guardianUserId}, accepted_at = NOW(), invite_token = NULL WHERE id = ${guardianId} `; } export async function removeGuardian(guardianId: string, userId: string): Promise { const result = await sql` UPDATE guardians SET status = 'revoked' WHERE id = ${guardianId} AND user_id = ${userId} `; return result.count > 0; } export async function getGuardianById(guardianId: string): Promise { const rows = await sql`SELECT * FROM guardians WHERE id = ${guardianId}`; if (rows.length === 0) return null; return rowToGuardian(rows[0]); } export async function getGuardianships(guardianUserId: string): Promise { const rows = await sql` SELECT * FROM guardians WHERE guardian_user_id = ${guardianUserId} AND status = 'accepted' ORDER BY created_at ASC `; return rows.map(rowToGuardian); } // ============================================================================ // RECOVERY REQUEST OPERATIONS // ============================================================================ export interface StoredRecoveryRequest { id: string; userId: string; status: string; threshold: number; approvalCount: number; initiatedAt: number; expiresAt: number; completedAt: number | null; } function rowToRecoveryRequest(row: any): StoredRecoveryRequest { return { id: row.id, userId: row.user_id, status: row.status, threshold: row.threshold, approvalCount: row.approval_count, initiatedAt: new Date(row.initiated_at).getTime(), expiresAt: new Date(row.expires_at).getTime(), completedAt: row.completed_at ? new Date(row.completed_at).getTime() : null, }; } export async function createRecoveryRequest( id: string, userId: string, threshold: number, expiresAt: number, ): Promise { const rows = await sql` INSERT INTO recovery_requests (id, user_id, threshold, expires_at) VALUES (${id}, ${userId}, ${threshold}, ${new Date(expiresAt)}) RETURNING * `; return rowToRecoveryRequest(rows[0]); } export async function getRecoveryRequest(requestId: string): Promise { const rows = await sql`SELECT * FROM recovery_requests WHERE id = ${requestId}`; if (rows.length === 0) return null; return rowToRecoveryRequest(rows[0]); } export async function getActiveRecoveryRequest(userId: string): Promise { const rows = await sql` SELECT * FROM recovery_requests WHERE user_id = ${userId} AND status = 'pending' AND expires_at > NOW() ORDER BY initiated_at DESC LIMIT 1 `; if (rows.length === 0) return null; return rowToRecoveryRequest(rows[0]); } export async function createRecoveryApproval( requestId: string, guardianId: string, approvalToken: string, ): Promise { await sql` INSERT INTO recovery_approvals (request_id, guardian_id, approval_token) VALUES (${requestId}, ${guardianId}, ${approvalToken}) ON CONFLICT DO NOTHING `; } export async function approveRecoveryByToken(approvalToken: string): Promise<{ requestId: string; guardianId: string } | null> { // Find the approval const rows = await sql` SELECT * FROM recovery_approvals WHERE approval_token = ${approvalToken} AND approved_at IS NULL `; if (rows.length === 0) return null; const row = rows[0]; // Mark approved await sql` UPDATE recovery_approvals SET approved_at = NOW(), approval_token = NULL WHERE request_id = ${row.request_id} AND guardian_id = ${row.guardian_id} `; // Increment approval count on request await sql` UPDATE recovery_requests SET approval_count = approval_count + 1 WHERE id = ${row.request_id} `; return { requestId: row.request_id, guardianId: row.guardian_id }; } export async function updateRecoveryRequestStatus(requestId: string, status: string): Promise { const completedAt = status === 'completed' ? sql`NOW()` : null; await sql` UPDATE recovery_requests SET status = ${status}, completed_at = ${completedAt} WHERE id = ${requestId} `; } export async function getRecoveryApprovals(requestId: string): Promise> { const rows = await sql` SELECT guardian_id, approved_at FROM recovery_approvals WHERE request_id = ${requestId} `; return rows.map(r => ({ guardianId: r.guardian_id, approvedAt: r.approved_at ? new Date(r.approved_at).getTime() : null, })); } // ============================================================================ // DEVICE LINK OPERATIONS // ============================================================================ export async function createDeviceLink(token: string, userId: string, expiresAt: number): Promise { await sql` INSERT INTO device_links (token, user_id, expires_at) VALUES (${token}, ${userId}, ${new Date(expiresAt)}) `; } export async function getDeviceLink(token: string): Promise<{ userId: string; expiresAt: number; used: boolean } | null> { const rows = await sql`SELECT * FROM device_links WHERE token = ${token}`; if (rows.length === 0) return null; return { userId: rows[0].user_id, expiresAt: new Date(rows[0].expires_at).getTime(), used: rows[0].used, }; } export async function markDeviceLinkUsed(token: string): Promise { await sql`UPDATE device_links SET used = TRUE WHERE token = ${token}`; } // ============================================================================ // USER PROFILE OPERATIONS // ============================================================================ export interface StoredUserProfile { userId: string; username: string; displayName: string | null; bio: string | null; avatarUrl: string | null; profileEmail: string | null; profileEmailIsRecovery: boolean; did: string | null; walletAddress: string | null; emailForwardEnabled: boolean; emailForwardMailcowId: string | null; createdAt: string; updatedAt: string; } function rowToProfile(row: any): StoredUserProfile { return { userId: row.id, username: row.username, displayName: row.display_name || null, bio: row.bio || null, avatarUrl: row.avatar_url || null, profileEmail: row.profile_email || null, profileEmailIsRecovery: row.profile_email_is_recovery || false, did: row.did || null, walletAddress: row.wallet_address || null, emailForwardEnabled: row.email_forward_enabled || false, emailForwardMailcowId: row.email_forward_mailcow_id || null, createdAt: row.created_at?.toISOString?.() || new Date(row.created_at).toISOString(), updatedAt: row.updated_at?.toISOString?.() || row.created_at?.toISOString?.() || new Date().toISOString(), }; } export async function getUserProfile(userId: string): Promise { const [row] = await sql`SELECT * FROM users WHERE id = ${userId}`; if (!row) return null; return rowToProfile(row); } export interface UserProfileUpdates { displayName?: string | null; bio?: string | null; avatarUrl?: string | null; profileEmail?: string | null; profileEmailIsRecovery?: boolean; walletAddress?: string | null; } export async function updateUserProfile(userId: string, updates: UserProfileUpdates): Promise { const sets: string[] = []; const values: any[] = []; if (updates.displayName !== undefined) { sets.push('display_name'); values.push(updates.displayName); } if (updates.bio !== undefined) { sets.push('bio'); values.push(updates.bio); } if (updates.avatarUrl !== undefined) { sets.push('avatar_url'); values.push(updates.avatarUrl); } if (updates.profileEmail !== undefined) { sets.push('profile_email'); values.push(updates.profileEmail); } if (updates.profileEmailIsRecovery !== undefined) { sets.push('profile_email_is_recovery'); values.push(updates.profileEmailIsRecovery); } if (updates.walletAddress !== undefined) { sets.push('wallet_address'); values.push(updates.walletAddress); } if (sets.length === 0) { return getUserProfile(userId); } // Build dynamic update — use tagged template for each field // postgres lib doesn't easily support dynamic column names, so we use unsafe for the SET clause const setClauses = sets.map((col, i) => `${col} = $${i + 2}`).join(', '); const params = [userId, ...values]; await sql.unsafe( `UPDATE users SET ${setClauses}, updated_at = NOW() WHERE id = $1`, params, ); return getUserProfile(userId); } // ============================================================================ // ENCRYPTED ADDRESS OPERATIONS // ============================================================================ export interface StoredEncryptedAddress { id: string; userId: string; ciphertext: string; iv: string; label: string; labelCustom: string | null; isDefault: boolean; createdAt: string; updatedAt: string; } function rowToAddress(row: any): StoredEncryptedAddress { return { id: row.id, userId: row.user_id, ciphertext: row.ciphertext, iv: row.iv, label: row.label, labelCustom: row.label_custom || null, isDefault: row.is_default || false, createdAt: row.created_at?.toISOString?.() || new Date(row.created_at).toISOString(), updatedAt: row.updated_at?.toISOString?.() || new Date(row.updated_at).toISOString(), }; } export async function getUserAddresses(userId: string): Promise { const rows = await sql` SELECT * FROM encrypted_addresses WHERE user_id = ${userId} ORDER BY is_default DESC, created_at ASC `; return rows.map(rowToAddress); } export async function getAddressById(id: string, userId: string): Promise { const [row] = await sql` SELECT * FROM encrypted_addresses WHERE id = ${id} AND user_id = ${userId} `; if (!row) return null; return rowToAddress(row); } export async function saveUserAddress( userId: string, addr: { id: string; ciphertext: string; iv: string; label: string; labelCustom?: string; isDefault: boolean }, ): Promise { // If setting as default, unset all others first if (addr.isDefault) { await sql`UPDATE encrypted_addresses SET is_default = FALSE WHERE user_id = ${userId}`; } const rows = await sql` INSERT INTO encrypted_addresses (id, user_id, ciphertext, iv, label, label_custom, is_default) VALUES (${addr.id}, ${userId}, ${addr.ciphertext}, ${addr.iv}, ${addr.label}, ${addr.labelCustom || null}, ${addr.isDefault}) ON CONFLICT (id, user_id) DO UPDATE SET ciphertext = ${addr.ciphertext}, iv = ${addr.iv}, label = ${addr.label}, label_custom = ${addr.labelCustom || null}, is_default = ${addr.isDefault}, updated_at = NOW() RETURNING * `; return rowToAddress(rows[0]); } export async function deleteUserAddress(id: string, userId: string): Promise { const result = await sql` DELETE FROM encrypted_addresses WHERE id = ${id} AND user_id = ${userId} `; return result.count > 0; } // ============================================================================ // EMAIL FORWARDING OPERATIONS // ============================================================================ export async function getEmailForwardStatus(userId: string): Promise<{ enabled: boolean; mailcowId: string | null; username: string; profileEmail: string | null; } | null> { const [row] = await sql` SELECT username, profile_email, email_forward_enabled, email_forward_mailcow_id FROM users WHERE id = ${userId} `; if (!row) return null; return { enabled: row.email_forward_enabled || false, mailcowId: row.email_forward_mailcow_id || null, username: row.username, profileEmail: row.profile_email || null, }; } export async function setEmailForward(userId: string, enabled: boolean, mailcowId: string | null): Promise { await sql` UPDATE users SET email_forward_enabled = ${enabled}, email_forward_mailcow_id = ${mailcowId}, updated_at = NOW() WHERE id = ${userId} `; } // ============================================================================ // ADMIN OPERATIONS // ============================================================================ export interface AdminUserInfo { userId: string; username: string; displayName: string | null; did: string | null; email: string | null; createdAt: string; credentialCount: number; spaceMembershipCount: number; } export async function listAllUsers(): Promise { const rows = await sql` SELECT u.id, u.username, u.display_name, u.did, u.email, u.created_at, (SELECT COUNT(*)::int FROM credentials c WHERE c.user_id = u.id) as credential_count, (SELECT COUNT(*)::int FROM space_members sm WHERE sm.user_did = u.did) as space_membership_count FROM users u ORDER BY u.created_at DESC `; return rows.map(row => ({ userId: row.id, username: row.username, displayName: row.display_name || null, did: row.did || null, email: row.email || null, createdAt: row.created_at?.toISOString?.() || new Date(row.created_at).toISOString(), credentialCount: Number(row.credential_count), spaceMembershipCount: Number(row.space_membership_count), })); } export async function deleteUser(userId: string): Promise { const user = await getUserById(userId); if (!user) return false; // Remove space memberships for this user's DID if (user.did) { await sql`DELETE FROM space_members WHERE user_did = ${user.did}`; } // Delete user (CASCADE handles credentials, recovery_tokens, guardians, etc.) const result = await sql`DELETE FROM users WHERE id = ${userId}`; return result.count > 0; } export async function deleteSpaceMembers(spaceSlug: string): Promise { const result = await sql`DELETE FROM space_members WHERE space_slug = ${spaceSlug}`; return result.count; } // ============================================================================ // HEALTH CHECK // ============================================================================ export async function checkDatabaseHealth(): Promise { try { await sql`SELECT 1`; return true; } catch { return false; } } export { sql };