359 lines
10 KiB
TypeScript
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 };
|