rspace-online/src/encryptid/db.ts

359 lines
10 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[];
}
export interface StoredChallenge {
challenge: string;
userId?: string;
type: 'registration' | 'authentication';
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)
VALUES (
${cred.credentialId},
${cred.userId},
${cred.publicKey},
${cred.counter},
${cred.transports || null},
${new Date(cred.createdAt)}
)
`;
}
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';
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;
}
// ============================================================================
// HEALTH CHECK
// ============================================================================
export async function checkDatabaseHealth(): Promise<boolean> {
try {
await sql`SELECT 1`;
return true;
} catch {
return false;
}
}
export { sql };