/** * 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'; import { encryptField, decryptField, hashForLookup } from './server-crypto'; // ============================================================================ // 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; label?: string; } export interface StoredChallenge { challenge: string; userId?: string; type: 'registration' | 'authentication' | 'device_registration' | 'wallet_link' | 'legacy_migration'; 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, did?: string): Promise { // Ensure user exists first (with display name + DID so they're never NULL) // If a proper DID is provided (e.g. from PRF key derivation), use it; otherwise omit await createUser(cred.userId, cred.username, cred.username, did || undefined); 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, c.label, 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, label: row.label || undefined, })); } export async function updateCredentialLabel(credentialId: string, userId: string, label: string): Promise { const result = await sql` UPDATE credentials SET label = ${label} WHERE credential_id = ${credentialId} AND user_id = ${userId} RETURNING credential_id `; return result.length > 0; } export async function deleteCredential(credentialId: string, userId: string): Promise { const result = await sql` DELETE FROM credentials WHERE credential_id = ${credentialId} AND user_id = ${userId} RETURNING credential_id `; return result.length > 0; } // ============================================================================ // 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 StoredChallenge['type'], 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 consumeChallenge( challenge: string, userId: string, expectedType: StoredChallenge['type'], ): Promise { const rows = await sql` DELETE FROM challenges WHERE challenge = ${challenge} AND user_id = ${userId} AND type = ${expectedType} AND expires_at > NOW() RETURNING * `; if (rows.length === 0) return null; const row = rows[0]; return { challenge: row.challenge, userId: row.user_id || undefined, type: row.type as StoredChallenge['type'], createdAt: new Date(row.created_at).getTime(), expiresAt: new Date(row.expires_at).getTime(), }; } 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 { const [emailEnc, profileEmailEnc, emailHash] = await Promise.all([ encryptField(email), encryptField(email), hashForLookup(email), ]); await sql`UPDATE users SET email = ${email}, profile_email = ${email}, email_enc = ${emailEnc}, profile_email_enc = ${profileEmailEnc}, email_hash = ${emailHash}, updated_at = NOW() WHERE id = ${userId}`; } export async function getUserByEmail(email: string) { const hash = await hashForLookup(email); // Try hash lookup first, fall back to plaintext for pre-migration rows const [user] = await sql`SELECT * FROM users WHERE email_hash = ${hash}`; if (user) return user; const [legacy] = await sql`SELECT * FROM users WHERE email = ${email} AND email_hash IS NULL`; return legacy || null; } export async function getUserById(userId: string) { const [user] = await sql`SELECT * FROM users WHERE id = ${userId}`; return user || null; } /** Record a global logout — all JWTs issued before this timestamp are revoked */ export async function setUserLoggedOutAt(userId: string): Promise { await sql`UPDATE users SET logged_out_at = NOW() WHERE id = ${userId}`; } /** Get the timestamp of the user's last global logout (null if never) */ export async function getUserLoggedOutAt(userId: string): Promise { const [row] = await sql`SELECT logged_out_at FROM users WHERE id = ${userId}`; return row?.logged_out_at ? new Date(row.logged_out_at) : null; } /** Update a user's DID (e.g. upgrading from truncated to proper did:key:z6Mk...) */ export async function updateUserDid(userId: string, newDid: string): Promise { await sql`UPDATE users SET did = ${newDid}, updated_at = NOW() WHERE id = ${userId}`; } /** Update all space memberships from one DID to another */ export async function migrateSpaceMemberDid(oldDid: string, newDid: string): Promise { const result = await sql`UPDATE space_members SET user_did = ${newDid} WHERE user_did = ${oldDid}`; return result.count; } // ============================================================================ // RECOVERY TOKEN OPERATIONS // ============================================================================ export interface StoredRecoveryToken { token: string; userId: string; type: 'email_verify' | 'account_recovery' | 'magic_login'; 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' | 'magic_login', 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 listSpacesForUser(userDID: string): Promise { const rows = await sql` SELECT * FROM space_members WHERE user_did = ${userDID} ORDER BY joined_at DESC `; 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 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; } async function rowToGuardian(row: any): Promise { const [nameDecrypted, emailDecrypted] = await Promise.all([ decryptField(row.name_enc), decryptField(row.email_enc), ]); return { id: row.id, userId: row.user_id, name: nameDecrypted ?? row.name, email: emailDecrypted ?? 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 [nameEnc, emailEnc] = await Promise.all([encryptField(name), encryptField(email)]); const rows = await sql` INSERT INTO guardians (id, user_id, name, email, name_enc, email_enc, invite_token, invite_expires_at) VALUES (${id}, ${userId}, ${name}, ${email}, ${nameEnc}, ${emailEnc}, ${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 Promise.all(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 await 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 await 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 Promise.all(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 IN ('pending', 'approved') 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; } async function rowToProfile(row: any): Promise { const [bioDecrypted, avatarUrlDecrypted, profileEmailDecrypted, walletDecrypted] = await Promise.all([ decryptField(row.bio_enc), decryptField(row.avatar_url_enc), decryptField(row.profile_email_enc), decryptField(row.wallet_address_enc), ]); return { userId: row.id, username: row.username, displayName: row.display_name || null, bio: bioDecrypted ?? row.bio ?? null, avatarUrl: avatarUrlDecrypted ?? row.avatar_url ?? null, profileEmail: profileEmailDecrypted ?? row.profile_email ?? null, profileEmailIsRecovery: row.profile_email_is_recovery || false, did: row.did || null, walletAddress: walletDecrypted ?? 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 await 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); const enc = await encryptField(updates.bio); sets.push('bio_enc'); values.push(enc); } if (updates.avatarUrl !== undefined) { sets.push('avatar_url'); values.push(updates.avatarUrl); const enc = await encryptField(updates.avatarUrl); sets.push('avatar_url_enc'); values.push(enc); } if (updates.profileEmail !== undefined) { sets.push('profile_email'); values.push(updates.profileEmail); const enc = await encryptField(updates.profileEmail); sets.push('profile_email_enc'); values.push(enc); } 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); const enc = await encryptField(updates.walletAddress); sets.push('wallet_address_enc'); values.push(enc); } 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; } // ============================================================================ // LINKED WALLETS (SIWE-verified external wallet associations) // ============================================================================ export interface StoredLinkedWallet { id: string; userId: string; ciphertext: string; iv: string; addressHash: string; source: 'external-eoa' | 'external-safe'; verified: boolean; linkedAt: string; } function rowToLinkedWallet(row: any): StoredLinkedWallet { return { id: row.id, userId: row.user_id, ciphertext: row.ciphertext, iv: row.iv, addressHash: row.address_hash, source: row.source, verified: row.verified || false, linkedAt: row.linked_at?.toISOString?.() || new Date(row.linked_at).toISOString(), }; } export async function createLinkedWallet( userId: string, wallet: { id: string; ciphertext: string; iv: string; addressHash: string; source: 'external-eoa' | 'external-safe' }, ): Promise { const rows = await sql` INSERT INTO linked_wallets (id, user_id, ciphertext, iv, address_hash, source, verified) VALUES (${wallet.id}, ${userId}, ${wallet.ciphertext}, ${wallet.iv}, ${wallet.addressHash}, ${wallet.source}, TRUE) ON CONFLICT (id, user_id) DO UPDATE SET ciphertext = ${wallet.ciphertext}, iv = ${wallet.iv}, address_hash = ${wallet.addressHash}, source = ${wallet.source}, verified = TRUE, linked_at = NOW() RETURNING * `; return rowToLinkedWallet(rows[0]); } export async function getLinkedWallets(userId: string): Promise { const rows = await sql` SELECT * FROM linked_wallets WHERE user_id = ${userId} ORDER BY linked_at ASC `; return rows.map(rowToLinkedWallet); } export async function deleteLinkedWallet(userId: string, id: string): Promise { const result = await sql` DELETE FROM linked_wallets WHERE id = ${id} AND user_id = ${userId} `; return result.count > 0; } export async function linkedWalletExists(userId: string, addressHash: string): Promise { const [row] = await sql` SELECT 1 FROM linked_wallets WHERE user_id = ${userId} AND address_hash = ${addressHash} LIMIT 1 `; return !!row; } // ============================================================================ // 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, profile_email_enc, email_forward_enabled, email_forward_mailcow_id FROM users WHERE id = ${userId} `; if (!row) return null; const profileEmailDecrypted = await decryptField(row.profile_email_enc); return { enabled: row.email_forward_enabled || false, mailcowId: row.email_forward_mailcow_id || null, username: row.username, profileEmail: profileEmailDecrypted ?? 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.email_enc, 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 Promise.all(rows.map(async row => { const emailDecrypted = await decryptField(row.email_enc); return { userId: row.id, username: row.username, displayName: row.display_name || null, did: row.did || null, email: emailDecrypted ?? 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; } } // ============================================================================ // SPACE INVITE OPERATIONS // ============================================================================ export interface StoredSpaceInvite { id: string; spaceSlug: string; email: string | null; role: string; token: string; invitedBy: string; status: 'pending' | 'accepted' | 'expired' | 'revoked'; createdAt: number; expiresAt: number; acceptedAt: number | null; acceptedByDid: string | null; } async function rowToInvite(row: any): Promise { const emailDecrypted = await decryptField(row.email_enc); return { id: row.id, spaceSlug: row.space_slug, email: emailDecrypted ?? row.email ?? null, role: row.role, token: row.token, invitedBy: row.invited_by, status: row.status, createdAt: new Date(row.created_at).getTime(), expiresAt: new Date(row.expires_at).getTime(), acceptedAt: row.accepted_at ? new Date(row.accepted_at).getTime() : null, acceptedByDid: row.accepted_by_did || null, }; } export async function createSpaceInvite( id: string, spaceSlug: string, token: string, invitedBy: string, role: string, expiresAt: number, email?: string, ): Promise { const emailEnc = await encryptField(email || null); const rows = await sql` INSERT INTO space_invites (id, space_slug, email, email_enc, role, token, invited_by, expires_at) VALUES (${id}, ${spaceSlug}, ${email || null}, ${emailEnc}, ${role}, ${token}, ${invitedBy}, ${new Date(expiresAt)}) RETURNING * `; return await rowToInvite(rows[0]); } export async function getSpaceInviteByToken(token: string): Promise { const rows = await sql`SELECT * FROM space_invites WHERE token = ${token}`; if (rows.length === 0) return null; return await rowToInvite(rows[0]); } export async function listSpaceInvites(spaceSlug: string): Promise { const [spaceRows, identityRows] = await Promise.all([ sql`SELECT * FROM space_invites WHERE space_slug = ${spaceSlug} ORDER BY created_at DESC`, sql`SELECT * FROM identity_invites WHERE space_slug = ${spaceSlug} ORDER BY created_at DESC`, ]); const spaceInvites = await Promise.all(spaceRows.map(rowToInvite)); const identityInvites = await Promise.all(identityRows.map(async (row: any) => { const emailDecrypted = await decryptField(row.email_enc); return { id: row.id, spaceSlug: row.space_slug, email: emailDecrypted ?? row.email ?? null, role: row.space_role || 'member', token: row.token, invitedBy: row.invited_by_user_id, status: row.status === 'claimed' ? 'accepted' : row.status, createdAt: new Date(row.created_at).getTime(), expiresAt: new Date(row.expires_at).getTime(), acceptedAt: row.claimed_at ? new Date(row.claimed_at).getTime() : null, acceptedByDid: row.claimed_by_user_id || null, } as StoredSpaceInvite; })); return [...spaceInvites, ...identityInvites].sort((a, b) => b.createdAt - a.createdAt); } export async function acceptSpaceInvite(token: string, acceptedByDid: string): Promise { const rows = await sql` UPDATE space_invites SET status = 'accepted', accepted_at = NOW(), accepted_by_did = ${acceptedByDid} WHERE token = ${token} AND status = 'pending' AND expires_at > NOW() RETURNING * `; if (rows.length === 0) return null; return await rowToInvite(rows[0]); } export async function revokeSpaceInvite(id: string, spaceSlug: string): Promise { const result = await sql` UPDATE space_invites SET status = 'revoked' WHERE id = ${id} AND space_slug = ${spaceSlug} AND status = 'pending' `; if (result.count > 0) return true; // Also check identity_invites (email invites with space_slug) const result2 = await sql` UPDATE identity_invites SET status = 'revoked' WHERE id = ${id} AND space_slug = ${spaceSlug} AND status = 'pending' `; return result2.count > 0; } // ============================================================================ // NOTIFICATION OPERATIONS // ============================================================================ export interface StoredNotification { id: string; userDid: string; category: 'space' | 'module' | 'system' | 'social'; eventType: string; title: string; body: string | null; spaceSlug: string | null; moduleId: string | null; actionUrl: string | null; actorDid: string | null; actorUsername: string | null; metadata: Record; read: boolean; dismissed: boolean; deliveredWs: boolean; deliveredEmail: boolean; deliveredPush: boolean; createdAt: string; readAt: string | null; expiresAt: string | null; } async function rowToNotification(row: any): Promise { const [titleDecrypted, bodyDecrypted, actorUsernameDecrypted] = await Promise.all([ decryptField(row.title_enc), decryptField(row.body_enc), decryptField(row.actor_username_enc), ]); return { id: row.id, userDid: row.user_did, category: row.category, eventType: row.event_type, title: titleDecrypted ?? row.title, body: bodyDecrypted ?? row.body ?? null, spaceSlug: row.space_slug || null, moduleId: row.module_id || null, actionUrl: row.action_url || null, actorDid: row.actor_did || null, actorUsername: actorUsernameDecrypted ?? row.actor_username ?? null, metadata: row.metadata || {}, read: row.read, dismissed: row.dismissed, deliveredWs: row.delivered_ws, deliveredEmail: row.delivered_email, deliveredPush: row.delivered_push, createdAt: row.created_at?.toISOString?.() || new Date(row.created_at).toISOString(), readAt: row.read_at ? (row.read_at?.toISOString?.() || new Date(row.read_at).toISOString()) : null, expiresAt: row.expires_at ? (row.expires_at?.toISOString?.() || new Date(row.expires_at).toISOString()) : null, }; } export async function createNotification(notif: { id: string; userDid: string; category: 'space' | 'module' | 'system' | 'social'; eventType: string; title: string; body?: string; spaceSlug?: string; moduleId?: string; actionUrl?: string; actorDid?: string; actorUsername?: string; metadata?: Record; expiresAt?: Date; }): Promise { const [titleEnc, bodyEnc, actorUsernameEnc] = await Promise.all([ encryptField(notif.title), encryptField(notif.body || null), encryptField(notif.actorUsername || null), ]); const rows = await sql` INSERT INTO notifications (id, user_did, category, event_type, title, body, title_enc, body_enc, actor_username_enc, space_slug, module_id, action_url, actor_did, actor_username, metadata, expires_at) VALUES ( ${notif.id}, ${notif.userDid}, ${notif.category}, ${notif.eventType}, ${notif.title}, ${notif.body || null}, ${titleEnc}, ${bodyEnc}, ${actorUsernameEnc}, ${notif.spaceSlug || null}, ${notif.moduleId || null}, ${notif.actionUrl || null}, ${notif.actorDid || null}, ${notif.actorUsername || null}, ${JSON.stringify(notif.metadata || {})}, ${notif.expiresAt || null} ) RETURNING * `; return await rowToNotification(rows[0]); } export async function getUserNotifications( userDid: string, opts: { unreadOnly?: boolean; limit?: number; offset?: number; category?: string } = {}, ): Promise { const limit = opts.limit || 50; const offset = opts.offset || 0; let rows; if (opts.unreadOnly && opts.category) { rows = await sql` SELECT * FROM notifications WHERE user_did = ${userDid} AND NOT dismissed AND NOT read AND category = ${opts.category} ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset} `; } else if (opts.unreadOnly) { rows = await sql` SELECT * FROM notifications WHERE user_did = ${userDid} AND NOT dismissed AND NOT read ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset} `; } else if (opts.category) { rows = await sql` SELECT * FROM notifications WHERE user_did = ${userDid} AND NOT dismissed AND category = ${opts.category} ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset} `; } else { rows = await sql` SELECT * FROM notifications WHERE user_did = ${userDid} AND NOT dismissed ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset} `; } return Promise.all(rows.map(rowToNotification)); } export async function getUnreadCount(userDid: string): Promise { try { const [row] = await sql` SELECT COUNT(*)::int as count FROM notifications WHERE user_did = ${userDid} AND NOT read AND NOT dismissed `; return row?.count ?? 0; } catch (err) { console.error("[notifications] getUnreadCount failed:", err instanceof Error ? err.message : err); return 0; } } export async function markNotificationRead(id: string, userDid: string): Promise { const result = await sql` UPDATE notifications SET read = TRUE, read_at = NOW() WHERE id = ${id} AND user_did = ${userDid} `; return result.count > 0; } export async function markAllNotificationsRead( userDid: string, opts: { spaceSlug?: string; category?: string } = {}, ): Promise { let result; if (opts.spaceSlug && opts.category) { result = await sql` UPDATE notifications SET read = TRUE, read_at = NOW() WHERE user_did = ${userDid} AND NOT read AND space_slug = ${opts.spaceSlug} AND category = ${opts.category} `; } else if (opts.spaceSlug) { result = await sql` UPDATE notifications SET read = TRUE, read_at = NOW() WHERE user_did = ${userDid} AND NOT read AND space_slug = ${opts.spaceSlug} `; } else if (opts.category) { result = await sql` UPDATE notifications SET read = TRUE, read_at = NOW() WHERE user_did = ${userDid} AND NOT read AND category = ${opts.category} `; } else { result = await sql` UPDATE notifications SET read = TRUE, read_at = NOW() WHERE user_did = ${userDid} AND NOT read `; } return result.count; } export async function dismissNotification(id: string, userDid: string): Promise { const result = await sql` UPDATE notifications SET dismissed = TRUE WHERE id = ${id} AND user_did = ${userDid} `; return result.count > 0; } export async function markNotificationDelivered(id: string, channel: 'ws' | 'email' | 'push'): Promise { const col = channel === 'ws' ? 'delivered_ws' : channel === 'email' ? 'delivered_email' : 'delivered_push'; await sql.unsafe(`UPDATE notifications SET ${col} = TRUE WHERE id = $1`, [id]); } export async function cleanExpiredNotifications(): Promise { const result = await sql`DELETE FROM notifications WHERE expires_at IS NOT NULL AND expires_at < NOW()`; return result.count; } // ── Notification Preferences ── export interface StoredNotificationPreferences { userDid: string; emailEnabled: boolean; pushEnabled: boolean; quietHoursStart: string | null; quietHoursEnd: string | null; mutedSpaces: string[]; mutedCategories: string[]; digestFrequency: 'none' | 'daily' | 'weekly'; updatedAt: string; } function rowToPreferences(row: any): StoredNotificationPreferences { return { userDid: row.user_did, emailEnabled: row.email_enabled, pushEnabled: row.push_enabled, quietHoursStart: row.quiet_hours_start || null, quietHoursEnd: row.quiet_hours_end || null, mutedSpaces: row.muted_spaces || [], mutedCategories: row.muted_categories || [], digestFrequency: row.digest_frequency || 'none', updatedAt: row.updated_at?.toISOString?.() || new Date(row.updated_at).toISOString(), }; } export async function getNotificationPreferences(userDid: string): Promise { const [row] = await sql`SELECT * FROM notification_preferences WHERE user_did = ${userDid}`; if (!row) return null; return rowToPreferences(row); } export async function upsertNotificationPreferences( userDid: string, prefs: Partial>, ): Promise { const rows = await sql` INSERT INTO notification_preferences (user_did, email_enabled, push_enabled, quiet_hours_start, quiet_hours_end, muted_spaces, muted_categories, digest_frequency) VALUES ( ${userDid}, ${prefs.emailEnabled ?? true}, ${prefs.pushEnabled ?? true}, ${prefs.quietHoursStart || null}, ${prefs.quietHoursEnd || null}, ${prefs.mutedSpaces || []}, ${prefs.mutedCategories || []}, ${prefs.digestFrequency || 'none'} ) ON CONFLICT (user_did) DO UPDATE SET email_enabled = COALESCE(${prefs.emailEnabled ?? null}::boolean, notification_preferences.email_enabled), push_enabled = COALESCE(${prefs.pushEnabled ?? null}::boolean, notification_preferences.push_enabled), quiet_hours_start = COALESCE(${prefs.quietHoursStart ?? null}, notification_preferences.quiet_hours_start), quiet_hours_end = COALESCE(${prefs.quietHoursEnd ?? null}, notification_preferences.quiet_hours_end), muted_spaces = COALESCE(${prefs.mutedSpaces ?? null}, notification_preferences.muted_spaces), muted_categories = COALESCE(${prefs.mutedCategories ?? null}, notification_preferences.muted_categories), digest_frequency = COALESCE(${prefs.digestFrequency ?? null}, notification_preferences.digest_frequency), updated_at = NOW() RETURNING * `; return rowToPreferences(rows[0]); } // ============================================================================ // FUND CLAIM OPERATIONS // ============================================================================ export interface StoredFundClaim { id: string; token: string; emailHash: string; email: string | null; walletAddress: string; openfortPlayerId: string | null; fiatAmount: string | null; fiatCurrency: string; sessionId: string | null; provider: string | null; status: 'pending' | 'claimed' | 'expired' | 'resent'; claimedByUserId: string | null; createdAt: number; expiresAt: number; claimedAt: number | null; } async function rowToFundClaim(row: any): Promise { const [emailDecrypted, walletDecrypted] = await Promise.all([ decryptField(row.email_enc), decryptField(row.wallet_address_enc), ]); return { id: row.id, token: row.token, emailHash: row.email_hmac || row.email_hash, email: emailDecrypted ?? row.email ?? null, walletAddress: walletDecrypted ?? row.wallet_address, openfortPlayerId: row.openfort_player_id || null, fiatAmount: row.fiat_amount || null, fiatCurrency: row.fiat_currency || 'USD', sessionId: row.session_id || null, provider: row.provider || null, status: row.status, claimedByUserId: row.claimed_by_user_id || null, createdAt: new Date(row.created_at).getTime(), expiresAt: new Date(row.expires_at).getTime(), claimedAt: row.claimed_at ? new Date(row.claimed_at).getTime() : null, }; } export async function createFundClaim(claim: { id: string; token: string; emailHash: string; email: string; walletAddress: string; openfortPlayerId?: string; fiatAmount?: string; fiatCurrency?: string; sessionId?: string; provider?: string; expiresAt: number; }): Promise { const [emailEnc, walletEnc, emailHmac] = await Promise.all([ encryptField(claim.email), encryptField(claim.walletAddress), hashForLookup(claim.email), ]); const rows = await sql` INSERT INTO fund_claims (id, token, email_hash, email, email_enc, email_hmac, wallet_address, wallet_address_enc, openfort_player_id, fiat_amount, fiat_currency, session_id, provider, expires_at) VALUES ( ${claim.id}, ${claim.token}, ${claim.emailHash}, ${claim.email}, ${emailEnc}, ${emailHmac}, ${claim.walletAddress}, ${walletEnc}, ${claim.openfortPlayerId || null}, ${claim.fiatAmount || null}, ${claim.fiatCurrency || 'USD'}, ${claim.sessionId || null}, ${claim.provider || null}, ${new Date(claim.expiresAt)} ) RETURNING * `; return await rowToFundClaim(rows[0]); } export async function getFundClaimByToken(token: string): Promise { const rows = await sql`SELECT * FROM fund_claims WHERE token = ${token}`; if (rows.length === 0) return null; return await rowToFundClaim(rows[0]); } export async function getFundClaimsByEmailHash(emailHash: string): Promise { // Try HMAC hash first, fall back to legacy SHA-256 hash let rows = await sql` SELECT * FROM fund_claims WHERE email_hmac = ${emailHash} AND status IN ('pending', 'resent') AND expires_at > NOW() ORDER BY created_at DESC `; if (rows.length === 0) { rows = await sql` SELECT * FROM fund_claims WHERE email_hash = ${emailHash} AND email_hmac IS NULL AND status IN ('pending', 'resent') AND expires_at > NOW() ORDER BY created_at DESC `; } return Promise.all(rows.map(rowToFundClaim)); } export async function acceptFundClaim(token: string, userId: string): Promise { const rows = await sql` UPDATE fund_claims SET status = 'claimed', claimed_by_user_id = ${userId}, claimed_at = NOW(), email = NULL, email_enc = NULL WHERE token = ${token} AND status IN ('pending', 'resent') AND expires_at > NOW() RETURNING * `; if (rows.length === 0) return null; return await rowToFundClaim(rows[0]); } export async function accumulateFundClaim(claimId: string, additionalAmount: string, expiresAt: number): Promise { const rows = await sql` UPDATE fund_claims SET fiat_amount = (COALESCE(fiat_amount::numeric, 0) + ${additionalAmount}::numeric)::text, expires_at = ${new Date(expiresAt)} WHERE id = ${claimId} AND status IN ('pending', 'resent') RETURNING * `; if (rows.length === 0) return null; return await rowToFundClaim(rows[0]); } export async function expireFundClaim(claimId: string): Promise { await sql`UPDATE fund_claims SET status = 'expired', email = NULL, email_enc = NULL WHERE id = ${claimId} AND status IN ('pending', 'resent')`; } export async function cleanExpiredFundClaims(): Promise { // Null out email on expired claims, then mark them expired await sql`UPDATE fund_claims SET email = NULL, email_enc = NULL, status = 'expired' WHERE status IN ('pending', 'resent') AND expires_at < NOW()`; const result = await sql`DELETE FROM fund_claims WHERE status = 'expired' AND expires_at < NOW() - INTERVAL '30 days'`; return result.count; } // ============================================================================ // OIDC PROVIDER // ============================================================================ export interface StoredOidcClient { clientId: string; clientSecret: string; name: string; redirectUris: string[]; allowedEmails: string[]; createdAt: number; } export async function getOidcClient(clientId: string): Promise { const rows = await sql`SELECT * FROM oidc_clients WHERE client_id = ${clientId}`; return rows.length ? mapOidcClientRow(rows[0]) : null; } export async function createOidcAuthCode( code: string, clientId: string, userId: string, redirectUri: string, scope: string, ): Promise { const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes await sql` INSERT INTO oidc_auth_codes (code, client_id, user_id, redirect_uri, scope, expires_at) VALUES (${code}, ${clientId}, ${userId}, ${redirectUri}, ${scope}, ${expiresAt}) `; } export async function consumeOidcAuthCode(code: string): Promise<{ clientId: string; userId: string; redirectUri: string; scope: string; } | null> { const rows = await sql` UPDATE oidc_auth_codes SET used = TRUE WHERE code = ${code} AND used = FALSE AND expires_at > NOW() RETURNING client_id, user_id, redirect_uri, scope `; if (!rows.length) return null; const r = rows[0]; return { clientId: r.client_id, userId: r.user_id, redirectUri: r.redirect_uri, scope: r.scope, }; } export async function cleanExpiredOidcCodes(): Promise { const result = await sql`DELETE FROM oidc_auth_codes WHERE expires_at < NOW() OR used = TRUE`; return result.count; } export async function seedOidcClients(clients: Array<{ clientId: string; clientSecret: string; name: string; redirectUris: string[]; allowedEmails?: string[]; }>): Promise { for (const c of clients) { await sql` INSERT INTO oidc_clients (client_id, client_secret, name, redirect_uris, allowed_emails) VALUES (${c.clientId}, ${c.clientSecret}, ${c.name}, ${c.redirectUris}, ${c.allowedEmails || []}) ON CONFLICT (client_id) DO UPDATE SET client_secret = EXCLUDED.client_secret, name = EXCLUDED.name, redirect_uris = EXCLUDED.redirect_uris, allowed_emails = EXCLUDED.allowed_emails `; } } function mapOidcClientRow(r: any): StoredOidcClient { return { clientId: r.client_id, clientSecret: r.client_secret, name: r.name, redirectUris: r.redirect_uris, allowedEmails: r.allowed_emails || [], createdAt: new Date(r.created_at).getTime(), }; } export async function listOidcClients(): Promise { const rows = await sql`SELECT * FROM oidc_clients ORDER BY created_at`; return rows.map(mapOidcClientRow); } export async function updateOidcClient(clientId: string, updates: { name?: string; clientSecret?: string; redirectUris?: string[]; allowedEmails?: string[]; }): Promise { const client = await getOidcClient(clientId); if (!client) return null; const rows = await sql` UPDATE oidc_clients SET name = ${updates.name ?? client.name}, client_secret = ${updates.clientSecret ?? client.clientSecret}, redirect_uris = ${updates.redirectUris ?? client.redirectUris}, allowed_emails = ${updates.allowedEmails ?? client.allowedEmails} WHERE client_id = ${clientId} RETURNING * `; return rows.length ? mapOidcClientRow(rows[0]) : null; } export async function createOidcClient(client: { clientId: string; clientSecret: string; name: string; redirectUris: string[]; allowedEmails?: string[]; }): Promise { const rows = await sql` INSERT INTO oidc_clients (client_id, client_secret, name, redirect_uris, allowed_emails) VALUES (${client.clientId}, ${client.clientSecret}, ${client.name}, ${client.redirectUris}, ${client.allowedEmails || []}) RETURNING * `; return mapOidcClientRow(rows[0]); } export async function deleteOidcClient(clientId: string): Promise { const result = await sql`DELETE FROM oidc_clients WHERE client_id = ${clientId}`; return result.count > 0; } // ============================================================================ // IDENTITY INVITES // ============================================================================ export interface StoredIdentityInvite { id: string; token: string; email: string; invitedByUserId: string; invitedByUsername: string; message: string | null; spaceSlug: string | null; spaceRole: string; clientId: string | null; status: string; claimedByUserId: string | null; createdAt: number; expiresAt: number; claimedAt: number | null; } async function mapInviteRow(r: any): Promise { const [emailDecrypted, messageDecrypted] = await Promise.all([ decryptField(r.email_enc), decryptField(r.message_enc), ]); return { id: r.id, token: r.token, email: emailDecrypted ?? r.email, invitedByUserId: r.invited_by_user_id, invitedByUsername: r.invited_by_username, message: messageDecrypted ?? r.message ?? null, spaceSlug: r.space_slug, spaceRole: r.space_role, clientId: r.client_id || null, status: r.status, claimedByUserId: r.claimed_by_user_id, createdAt: new Date(r.created_at).getTime(), expiresAt: new Date(r.expires_at).getTime(), claimedAt: r.claimed_at ? new Date(r.claimed_at).getTime() : null, }; } export async function createIdentityInvite(invite: { id: string; token: string; email: string; invitedByUserId: string; invitedByUsername: string; message?: string; spaceSlug?: string; spaceRole?: string; clientId?: string; expiresAt: number; }): Promise { const [emailEnc, emailHash, messageEnc] = await Promise.all([ encryptField(invite.email), hashForLookup(invite.email), encryptField(invite.message || null), ]); const rows = await sql` INSERT INTO identity_invites (id, token, email, email_enc, email_hash, invited_by_user_id, invited_by_username, message, message_enc, space_slug, space_role, client_id, expires_at) VALUES (${invite.id}, ${invite.token}, ${invite.email}, ${emailEnc}, ${emailHash}, ${invite.invitedByUserId}, ${invite.invitedByUsername}, ${invite.message || null}, ${messageEnc}, ${invite.spaceSlug || null}, ${invite.spaceRole || 'member'}, ${invite.clientId || null}, ${new Date(invite.expiresAt).toISOString()}) RETURNING * `; return await mapInviteRow(rows[0]); } export async function getIdentityInviteByToken(token: string): Promise { const rows = await sql`SELECT * FROM identity_invites WHERE token = ${token}`; return rows.length ? await mapInviteRow(rows[0]) : null; } export async function getIdentityInvitesByEmail(email: string): Promise { const hash = await hashForLookup(email); // Try hash lookup first, fall back to plaintext for pre-migration rows let rows = await sql`SELECT * FROM identity_invites WHERE email_hash = ${hash} ORDER BY created_at DESC`; if (rows.length === 0) { rows = await sql`SELECT * FROM identity_invites WHERE email = ${email} AND email_hash IS NULL ORDER BY created_at DESC`; } return Promise.all(rows.map(mapInviteRow)); } export async function getIdentityInvitesByInviter(userId: string): Promise { const rows = await sql`SELECT * FROM identity_invites WHERE invited_by_user_id = ${userId} ORDER BY created_at DESC`; return Promise.all(rows.map(mapInviteRow)); } export async function getIdentityInvitesByClient(clientId: string): Promise { const rows = await sql`SELECT * FROM identity_invites WHERE client_id = ${clientId} ORDER BY created_at DESC`; return Promise.all(rows.map(mapInviteRow)); } export async function claimIdentityInvite(token: string, claimedByUserId: string): Promise { const rows = await sql` UPDATE identity_invites SET status = 'claimed', claimed_by_user_id = ${claimedByUserId}, claimed_at = NOW() WHERE token = ${token} AND status = 'pending' AND expires_at > NOW() RETURNING * `; return rows.length ? await mapInviteRow(rows[0]) : null; } export async function revokeIdentityInvite(id: string, userId: string): Promise { const result = await sql` UPDATE identity_invites SET status = 'revoked' WHERE id = ${id} AND invited_by_user_id = ${userId} AND status = 'pending' `; return result.count > 0; } export async function cleanExpiredIdentityInvites(): Promise { const result = await sql` UPDATE identity_invites SET status = 'expired' WHERE status = 'pending' AND expires_at < NOW() `; return result.count; } // ============================================================================ // PUSH SUBSCRIPTIONS // ============================================================================ export interface StoredPushSubscription { id: string; userDid: string; endpoint: string; keyP256dh: string; keyAuth: string; userAgent: string | null; createdAt: string; lastUsed: string | null; } export async function savePushSubscription(sub: { id: string; userDid: string; endpoint: string; keyP256dh: string; keyAuth: string; userAgent?: string; }): Promise { await sql` INSERT INTO push_subscriptions (id, user_did, endpoint, key_p256dh, key_auth, user_agent) VALUES (${sub.id}, ${sub.userDid}, ${sub.endpoint}, ${sub.keyP256dh}, ${sub.keyAuth}, ${sub.userAgent ?? null}) ON CONFLICT (user_did, endpoint) DO UPDATE SET key_p256dh = EXCLUDED.key_p256dh, key_auth = EXCLUDED.key_auth, user_agent = EXCLUDED.user_agent `; } export async function getUserPushSubscriptions(userDid: string): Promise { const rows = await sql` SELECT id, user_did, endpoint, key_p256dh, key_auth, user_agent, created_at, last_used FROM push_subscriptions WHERE user_did = ${userDid} `; return rows.map(r => ({ id: r.id, userDid: r.user_did, endpoint: r.endpoint, keyP256dh: r.key_p256dh, keyAuth: r.key_auth, userAgent: r.user_agent, createdAt: r.created_at, lastUsed: r.last_used, })); } export async function deletePushSubscription(id: string, userDid: string): Promise { const result = await sql` DELETE FROM push_subscriptions WHERE id = ${id} AND user_did = ${userDid} `; return result.count > 0; } export async function deletePushSubscriptionByEndpoint(endpoint: string): Promise { await sql`DELETE FROM push_subscriptions WHERE endpoint = ${endpoint}`; } export async function updatePushSubscriptionLastUsed(id: string): Promise { await sql`UPDATE push_subscriptions SET last_used = NOW() WHERE id = ${id}`; } // ============================================================================ // DELEGATIONS (person-to-person liquid democracy) // ============================================================================ export type DelegationAuthority = 'gov-ops' | 'fin-ops' | 'dev-ops' | 'custom'; export type DelegationState = 'active' | 'paused' | 'revoked'; export interface StoredDelegation { id: string; delegatorDid: string; delegateDid: string; authority: DelegationAuthority; weight: number; maxDepth: number; retainAuthority: boolean; spaceSlug: string; state: DelegationState; customScope: string | null; expiresAt: number | null; createdAt: number; updatedAt: number; } function mapDelegationRow(r: any): StoredDelegation { return { id: r.id, delegatorDid: r.delegator_did, delegateDid: r.delegate_did, authority: r.authority, weight: parseFloat(r.weight), maxDepth: r.max_depth, retainAuthority: r.retain_authority, spaceSlug: r.space_slug, state: r.state, customScope: r.custom_scope || null, expiresAt: r.expires_at ? new Date(r.expires_at).getTime() : null, createdAt: new Date(r.created_at).getTime(), updatedAt: new Date(r.updated_at).getTime(), }; } export async function createDelegation(d: { id: string; delegatorDid: string; delegateDid: string; authority: DelegationAuthority; weight: number; maxDepth?: number; retainAuthority?: boolean; spaceSlug: string; customScope?: string; expiresAt?: number; }): Promise { const rows = await sql` INSERT INTO delegations (id, delegator_did, delegate_did, authority, weight, max_depth, retain_authority, space_slug, custom_scope, expires_at) VALUES (${d.id}, ${d.delegatorDid}, ${d.delegateDid}, ${d.authority}, ${d.weight}, ${d.maxDepth ?? 3}, ${d.retainAuthority ?? true}, ${d.spaceSlug}, ${d.customScope || null}, ${d.expiresAt ? new Date(d.expiresAt).toISOString() : null}) RETURNING * `; return mapDelegationRow(rows[0]); } export async function getDelegation(id: string): Promise { const rows = await sql`SELECT * FROM delegations WHERE id = ${id}`; return rows.length ? mapDelegationRow(rows[0]) : null; } export async function listDelegationsFrom(delegatorDid: string, spaceSlug: string): Promise { const rows = await sql` SELECT * FROM delegations WHERE delegator_did = ${delegatorDid} AND space_slug = ${spaceSlug} AND state != 'revoked' ORDER BY authority, created_at `; return rows.map(mapDelegationRow); } export async function listDelegationsTo(delegateDid: string, spaceSlug: string): Promise { const rows = await sql` SELECT * FROM delegations WHERE delegate_did = ${delegateDid} AND space_slug = ${spaceSlug} AND state != 'revoked' ORDER BY authority, created_at `; return rows.map(mapDelegationRow); } export async function updateDelegation(id: string, updates: { weight?: number; state?: DelegationState; maxDepth?: number; retainAuthority?: boolean; expiresAt?: number | null; }): Promise { const sets: string[] = []; const vals: any[] = []; if (updates.weight !== undefined) { sets.push('weight'); vals.push(updates.weight); } if (updates.state !== undefined) { sets.push('state'); vals.push(updates.state); } if (updates.maxDepth !== undefined) { sets.push('max_depth'); vals.push(updates.maxDepth); } if (updates.retainAuthority !== undefined) { sets.push('retain_authority'); vals.push(updates.retainAuthority); } if (updates.expiresAt !== undefined) { sets.push('expires_at'); vals.push(updates.expiresAt ? new Date(updates.expiresAt).toISOString() : null); } if (sets.length === 0) return getDelegation(id); const rows = await sql` UPDATE delegations SET ${sql(Object.fromEntries(sets.map((s, i) => [s, vals[i]])))} , updated_at = NOW() WHERE id = ${id} AND state != 'revoked' RETURNING * `; return rows.length ? mapDelegationRow(rows[0]) : null; } export async function revokeDelegation(id: string): Promise { const result = await sql` UPDATE delegations SET state = 'revoked', updated_at = NOW() WHERE id = ${id} AND state != 'revoked' `; return result.count > 0; } /** Get total weight delegated by a user for a given authority in a space */ export async function getTotalDelegatedWeight(delegatorDid: string, authority: string, spaceSlug: string): Promise { const rows = await sql` SELECT COALESCE(SUM(weight), 0) as total FROM delegations WHERE delegator_did = ${delegatorDid} AND authority = ${authority} AND space_slug = ${spaceSlug} AND state = 'active' `; return parseFloat(rows[0].total); } /** Get all active delegations in a space for a given authority (for trust computation) */ export async function listActiveDelegations(spaceSlug: string, authority?: string): Promise { const rows = authority ? await sql` SELECT * FROM delegations WHERE space_slug = ${spaceSlug} AND authority = ${authority} AND state = 'active' AND (expires_at IS NULL OR expires_at > NOW()) ` : await sql` SELECT * FROM delegations WHERE space_slug = ${spaceSlug} AND state = 'active' AND (expires_at IS NULL OR expires_at > NOW()) `; return rows.map(mapDelegationRow); } /** Clean expired delegations (mark as revoked) */ export async function cleanExpiredDelegations(): Promise { const result = await sql` UPDATE delegations SET state = 'revoked', updated_at = NOW() WHERE state = 'active' AND expires_at IS NOT NULL AND expires_at < NOW() `; return result.count; } // ============================================================================ // TRUST EVENTS // ============================================================================ export type TrustEventType = | 'delegation_created' | 'delegation_increased' | 'delegation_decreased' | 'delegation_revoked' | 'delegation_paused' | 'delegation_resumed' | 'endorsement' | 'flag' | 'collaboration' | 'guardian_link'; export interface StoredTrustEvent { id: string; sourceDid: string; targetDid: string; eventType: TrustEventType; authority: string | null; weightDelta: number | null; spaceSlug: string; metadata: Record; createdAt: number; } function mapTrustEventRow(r: any): StoredTrustEvent { return { id: r.id, sourceDid: r.source_did, targetDid: r.target_did, eventType: r.event_type, authority: r.authority || null, weightDelta: r.weight_delta != null ? parseFloat(r.weight_delta) : null, spaceSlug: r.space_slug, metadata: r.metadata || {}, createdAt: new Date(r.created_at).getTime(), }; } export async function logTrustEvent(event: { id: string; sourceDid: string; targetDid: string; eventType: TrustEventType; authority?: string; weightDelta?: number; spaceSlug: string; metadata?: Record; }): Promise { const rows = await sql` INSERT INTO trust_events (id, source_did, target_did, event_type, authority, weight_delta, space_slug, metadata) VALUES (${event.id}, ${event.sourceDid}, ${event.targetDid}, ${event.eventType}, ${event.authority || null}, ${event.weightDelta ?? null}, ${event.spaceSlug}, ${JSON.stringify(event.metadata || {})}) RETURNING * `; return mapTrustEventRow(rows[0]); } export async function getTrustEvents(did: string, spaceSlug: string, limit = 50): Promise { const rows = await sql` SELECT * FROM trust_events WHERE (source_did = ${did} OR target_did = ${did}) AND space_slug = ${spaceSlug} ORDER BY created_at DESC LIMIT ${limit} `; return rows.map(mapTrustEventRow); } export async function getTrustEventsSince(spaceSlug: string, since: number): Promise { const rows = await sql` SELECT * FROM trust_events WHERE space_slug = ${spaceSlug} AND created_at >= ${new Date(since).toISOString()} ORDER BY created_at ASC `; return rows.map(mapTrustEventRow); } // ============================================================================ // TRUST SCORES (materialized) // ============================================================================ export interface StoredTrustScore { sourceDid: string; targetDid: string; authority: string; spaceSlug: string; score: number; directWeight: number; transitiveWeight: number; lastComputed: number; } function mapTrustScoreRow(r: any): StoredTrustScore { return { sourceDid: r.source_did, targetDid: r.target_did, authority: r.authority, spaceSlug: r.space_slug, score: parseFloat(r.score), directWeight: parseFloat(r.direct_weight), transitiveWeight: parseFloat(r.transitive_weight), lastComputed: new Date(r.last_computed).getTime(), }; } export async function upsertTrustScore(score: { sourceDid: string; targetDid: string; authority: string; spaceSlug: string; score: number; directWeight: number; transitiveWeight: number; }): Promise { await sql` INSERT INTO trust_scores (source_did, target_did, authority, space_slug, score, direct_weight, transitive_weight, last_computed) VALUES (${score.sourceDid}, ${score.targetDid}, ${score.authority}, ${score.spaceSlug}, ${score.score}, ${score.directWeight}, ${score.transitiveWeight}, NOW()) ON CONFLICT (source_did, target_did, authority, space_slug) DO UPDATE SET score = EXCLUDED.score, direct_weight = EXCLUDED.direct_weight, transitive_weight = EXCLUDED.transitive_weight, last_computed = NOW() `; } /** Get aggregated trust scores — total trust received by each user for an authority in a space */ export async function getAggregatedTrustScores(spaceSlug: string, authority: string): Promise> { const rows = await sql` SELECT target_did, SUM(score) as total_score, SUM(direct_weight) as direct_score, SUM(transitive_weight) as transitive_score FROM trust_scores WHERE space_slug = ${spaceSlug} AND authority = ${authority} GROUP BY target_did ORDER BY total_score DESC `; return rows.map(r => ({ did: r.target_did, totalScore: parseFloat(r.total_score), directScore: parseFloat(r.direct_score), transitiveScore: parseFloat(r.transitive_score), })); } /** Get trust scores for a specific user across all authorities */ export async function getTrustScoresByAuthority(did: string, spaceSlug: string): Promise { const rows = await sql` SELECT * FROM trust_scores WHERE target_did = ${did} AND space_slug = ${spaceSlug} ORDER BY authority `; return rows.map(mapTrustScoreRow); } /** List all users with trust metadata for a space (user directory) */ export async function listAllUsersWithTrust(spaceSlug: string): Promise; }>> { const rows = await sql` SELECT u.did, u.username, u.display_name, u.avatar_url, sm.role, COALESCE( json_object_agg(ts.authority, ts.total) FILTER (WHERE ts.authority IS NOT NULL), '{}' ) as trust_scores FROM space_members sm JOIN users u ON u.did = sm.user_did LEFT JOIN ( SELECT target_did, authority, SUM(score) as total FROM trust_scores WHERE space_slug = ${spaceSlug} GROUP BY target_did, authority ) ts ON ts.target_did = sm.user_did WHERE sm.space_slug = ${spaceSlug} GROUP BY u.did, u.username, u.display_name, u.avatar_url, sm.role ORDER BY u.username `; return rows.map(r => ({ did: r.did, username: r.username, displayName: r.display_name || null, avatarUrl: r.avatar_url || null, role: r.role, trustScores: typeof r.trust_scores === 'string' ? JSON.parse(r.trust_scores) : (r.trust_scores || {}), })); } // ============================================================================ // LEGACY IDENTITY OPERATIONS (CryptID → EncryptID migration) // ============================================================================ export interface StoredLegacyIdentity { id: string; userId: string; provider: 'cryptid'; legacyUsername: string; legacyPublicKey: string; legacyPublicKeyHash: string; verified: boolean; migratedData: boolean; linkedAt: string; verifiedAt: string | null; } function rowToLegacyIdentity(row: any): StoredLegacyIdentity { return { id: row.id, userId: row.user_id, provider: row.provider, legacyUsername: row.legacy_username, legacyPublicKey: row.legacy_public_key, legacyPublicKeyHash: row.legacy_public_key_hash, verified: row.verified || false, migratedData: row.migrated_data || false, linkedAt: row.linked_at?.toISOString?.() || new Date(row.linked_at).toISOString(), verifiedAt: row.verified_at ? (row.verified_at?.toISOString?.() || new Date(row.verified_at).toISOString()) : null, }; } export async function createLegacyIdentity( userId: string, identity: { id: string; provider: 'cryptid'; legacyUsername: string; legacyPublicKey: string; legacyPublicKeyHash: string }, ): Promise { const rows = await sql` INSERT INTO legacy_identities (id, user_id, provider, legacy_username, legacy_public_key, legacy_public_key_hash, verified) VALUES (${identity.id}, ${userId}, ${identity.provider}, ${identity.legacyUsername}, ${identity.legacyPublicKey}, ${identity.legacyPublicKeyHash}, TRUE) ON CONFLICT (provider, legacy_public_key_hash) DO UPDATE SET user_id = ${userId}, legacy_username = ${identity.legacyUsername}, legacy_public_key = ${identity.legacyPublicKey}, verified = TRUE, verified_at = NOW() RETURNING * `; return rowToLegacyIdentity(rows[0]); } export async function getLegacyIdentityByPublicKeyHash(provider: string, hash: string): Promise { const [row] = await sql` SELECT * FROM legacy_identities WHERE provider = ${provider} AND legacy_public_key_hash = ${hash} `; return row ? rowToLegacyIdentity(row) : null; } export async function getLegacyIdentitiesByUser(userId: string): Promise { const rows = await sql` SELECT * FROM legacy_identities WHERE user_id = ${userId} ORDER BY linked_at ASC `; return rows.map(rowToLegacyIdentity); } export async function verifyLegacyIdentity(id: string): Promise { const result = await sql` UPDATE legacy_identities SET verified = TRUE, verified_at = NOW() WHERE id = ${id} `; return result.count > 0; } export async function getUserByLegacyPublicKeyHash(provider: string, hash: string): Promise<{ userId: string; username: string; did: string | null; legacyIdentityId: string; } | null> { const [row] = await sql` SELECT u.id as user_id, u.username, u.did, li.id as legacy_identity_id FROM users u JOIN legacy_identities li ON li.user_id = u.id WHERE li.provider = ${provider} AND li.legacy_public_key_hash = ${hash} AND li.verified = TRUE `; if (!row) return null; return { userId: row.user_id, username: row.username, did: row.did || null, legacyIdentityId: row.legacy_identity_id, }; } // ============================================================================ // UNIVERSAL PROFILE // ============================================================================ export interface StoredUniversalProfile { upAddress: string; keyManagerAddress: string; chainId: number; deployedAt: Date; } export async function getUserUPAddress(userId: string): Promise { const [row] = await sql` SELECT up_address, up_address_enc, up_key_manager_address, up_chain_id, up_deployed_at FROM users WHERE id = ${userId} AND up_address IS NOT NULL `; if (!row) return null; const upDecrypted = await decryptField(row.up_address_enc); return { upAddress: upDecrypted ?? row.up_address, keyManagerAddress: row.up_key_manager_address, chainId: row.up_chain_id, deployedAt: new Date(row.up_deployed_at), }; } export async function setUserUPAddress( userId: string, upAddress: string, keyManagerAddress: string, chainId: number, ): Promise { const [upEnc, upHash] = await Promise.all([encryptField(upAddress), hashForLookup(upAddress)]); await sql` UPDATE users SET up_address = ${upAddress}, up_address_enc = ${upEnc}, up_address_hash = ${upHash}, up_key_manager_address = ${keyManagerAddress}, up_chain_id = ${chainId}, up_deployed_at = NOW() WHERE id = ${userId} `; } export async function getUserByUPAddress(upAddress: string): Promise<{ userId: string; username: string } | null> { const hash = await hashForLookup(upAddress); // Try hash lookup first, fall back to plaintext for pre-migration rows const [row] = await sql`SELECT id, username FROM users WHERE up_address_hash = ${hash}`; if (row) return { userId: row.id, username: row.username }; const [legacy] = await sql`SELECT id, username FROM users WHERE up_address = ${upAddress} AND up_address_hash IS NULL`; if (!legacy) return null; return { userId: legacy.id, username: legacy.username }; } // ============================================================================ // SPACE EMAIL ALIAS OPERATIONS // ============================================================================ export async function getSpaceEmailAlias(spaceSlug: string): Promise<{ spaceSlug: string; mailcowId: string } | null> { const [row] = await sql`SELECT * FROM space_email_aliases WHERE space_slug = ${spaceSlug}`; if (!row) return null; return { spaceSlug: row.space_slug, mailcowId: row.mailcow_id }; } export async function setSpaceEmailAlias(spaceSlug: string, mailcowId: string): Promise { await sql` INSERT INTO space_email_aliases (space_slug, mailcow_id) VALUES (${spaceSlug}, ${mailcowId}) ON CONFLICT (space_slug) DO UPDATE SET mailcow_id = ${mailcowId} `; } export async function deleteSpaceEmailAlias(spaceSlug: string): Promise { const result = await sql`DELETE FROM space_email_aliases WHERE space_slug = ${spaceSlug}`; return result.count > 0; } export async function upsertSpaceEmailForwarding(spaceSlug: string, userDid: string, optIn: boolean): Promise { await sql` INSERT INTO space_email_forwarding (space_slug, user_did, opt_in, updated_at) VALUES (${spaceSlug}, ${userDid}, ${optIn}, NOW()) ON CONFLICT (space_slug, user_did) DO UPDATE SET opt_in = ${optIn}, updated_at = NOW() `; } export async function removeSpaceEmailForwarding(spaceSlug: string, userDid: string): Promise { const result = await sql`DELETE FROM space_email_forwarding WHERE space_slug = ${spaceSlug} AND user_did = ${userDid}`; return result.count > 0; } export async function getOptedInDids(spaceSlug: string): Promise { const rows = await sql` SELECT user_did FROM space_email_forwarding WHERE space_slug = ${spaceSlug} AND opt_in = TRUE `; return rows.map((r) => r.user_did); } export async function getSpaceEmailForwarding(spaceSlug: string, userDid: string): Promise<{ optIn: boolean } | null> { const [row] = await sql` SELECT opt_in FROM space_email_forwarding WHERE space_slug = ${spaceSlug} AND user_did = ${userDid} `; if (!row) return null; return { optIn: row.opt_in }; } /** * Batch DID→profileEmail lookup. Joins users table on did column, * decrypts profile_email_enc for each match. */ export async function getProfileEmailsByDids(dids: string[]): Promise> { if (dids.length === 0) return new Map(); const rows = await sql` SELECT did, profile_email, profile_email_enc FROM users WHERE did = ANY(${dids}) `; const result = new Map(); for (const row of rows) { const email = row.profile_email_enc ? await decryptField(row.profile_email_enc) : row.profile_email; if (email) result.set(row.did, email); } return result; } // ============================================================================ // AGENT MAILBOX OPERATIONS // ============================================================================ export async function getAgentMailbox(spaceSlug: string): Promise<{ email: string; password: string } | null> { const [row] = await sql`SELECT email, password FROM agent_mailboxes WHERE space_slug = ${spaceSlug}`; if (!row) return null; return { email: row.email, password: row.password }; } export async function setAgentMailbox(spaceSlug: string, email: string, password: string): Promise { await sql` INSERT INTO agent_mailboxes (space_slug, email, password) VALUES (${spaceSlug}, ${email}, ${password}) ON CONFLICT (space_slug) DO UPDATE SET email = ${email}, password = ${password} `; } export async function deleteAgentMailbox(spaceSlug: string): Promise { const result = await sql`DELETE FROM agent_mailboxes WHERE space_slug = ${spaceSlug}`; return result.count > 0; } export async function listAllAgentMailboxes(): Promise> { const rows = await sql`SELECT space_slug, email, password FROM agent_mailboxes`; return rows.map((r) => ({ spaceSlug: r.space_slug, email: r.email, password: r.password })); } export { sql };