837 lines
26 KiB
TypeScript
837 lines
26 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
// 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<StoredCredential | null> {
|
|
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<void> {
|
|
await sql`
|
|
UPDATE credentials
|
|
SET counter = ${newCounter}, last_used = NOW()
|
|
WHERE credential_id = ${credentialId}
|
|
`;
|
|
}
|
|
|
|
export async function getUserCredentials(userId: string): Promise<StoredCredential[]> {
|
|
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<void> {
|
|
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<StoredChallenge | null> {
|
|
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<void> {
|
|
await sql`DELETE FROM challenges WHERE challenge = ${challenge}`;
|
|
}
|
|
|
|
export async function cleanExpiredChallenges(): Promise<number> {
|
|
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<void> {
|
|
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<void> {
|
|
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<StoredRecoveryToken | null> {
|
|
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<void> {
|
|
await sql`UPDATE recovery_tokens SET used = TRUE WHERE token = ${token}`;
|
|
}
|
|
|
|
export async function cleanExpiredRecoveryTokens(): Promise<number> {
|
|
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<StoredSpaceMember | null> {
|
|
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<StoredSpaceMember[]> {
|
|
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<StoredSpaceMember> {
|
|
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<boolean> {
|
|
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<StoredGuardian> {
|
|
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<StoredGuardian[]> {
|
|
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<StoredGuardian | null> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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<StoredGuardian | null> {
|
|
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<StoredGuardian[]> {
|
|
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<StoredRecoveryRequest> {
|
|
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<StoredRecoveryRequest | null> {
|
|
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<StoredRecoveryRequest | null> {
|
|
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<void> {
|
|
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<void> {
|
|
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<Array<{ guardianId: string; approvedAt: number | null }>> {
|
|
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<void> {
|
|
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<void> {
|
|
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<StoredUserProfile | null> {
|
|
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<StoredUserProfile | null> {
|
|
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<StoredEncryptedAddress[]> {
|
|
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<StoredEncryptedAddress | null> {
|
|
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<StoredEncryptedAddress> {
|
|
// 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<boolean> {
|
|
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<void> {
|
|
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<AdminUserInfo[]> {
|
|
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<boolean> {
|
|
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<number> {
|
|
const result = await sql`DELETE FROM space_members WHERE space_slug = ${spaceSlug}`;
|
|
return result.count;
|
|
}
|
|
|
|
// ============================================================================
|
|
// HEALTH CHECK
|
|
// ============================================================================
|
|
|
|
export async function checkDatabaseHealth(): Promise<boolean> {
|
|
try {
|
|
await sql`SELECT 1`;
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export { sql };
|