2482 lines
84 KiB
TypeScript
2482 lines
84 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';
|
|
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<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, did?: string): Promise<void> {
|
|
// 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<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, 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<boolean> {
|
|
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<boolean> {
|
|
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<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 StoredChallenge['type'],
|
|
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 consumeChallenge(
|
|
challenge: string,
|
|
userId: string,
|
|
expectedType: StoredChallenge['type'],
|
|
): Promise<StoredChallenge | null> {
|
|
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<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> {
|
|
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<void> {
|
|
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<Date | null> {
|
|
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<void> {
|
|
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<number> {
|
|
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<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' | '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<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 listSpacesForUser(userDID: string): Promise<StoredSpaceMember[]> {
|
|
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<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;
|
|
}
|
|
|
|
async function rowToGuardian(row: any): Promise<StoredGuardian> {
|
|
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<StoredGuardian> {
|
|
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<StoredGuardian[]> {
|
|
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<StoredGuardian | null> {
|
|
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<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 await 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 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<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 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<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;
|
|
}
|
|
|
|
async function rowToProfile(row: any): Promise<StoredUserProfile> {
|
|
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<StoredUserProfile | null> {
|
|
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<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);
|
|
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<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;
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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<StoredLinkedWallet> {
|
|
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<StoredLinkedWallet[]> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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<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.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<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;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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<StoredSpaceInvite> {
|
|
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<StoredSpaceInvite> {
|
|
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<StoredSpaceInvite | null> {
|
|
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<StoredSpaceInvite[]> {
|
|
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<StoredSpaceInvite | null> {
|
|
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<boolean> {
|
|
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<string, any>;
|
|
read: boolean;
|
|
dismissed: boolean;
|
|
deliveredWs: boolean;
|
|
deliveredEmail: boolean;
|
|
deliveredPush: boolean;
|
|
createdAt: string;
|
|
readAt: string | null;
|
|
expiresAt: string | null;
|
|
}
|
|
|
|
async function rowToNotification(row: any): Promise<StoredNotification> {
|
|
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<string, any>;
|
|
expiresAt?: Date;
|
|
}): Promise<StoredNotification> {
|
|
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<StoredNotification[]> {
|
|
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<number> {
|
|
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<boolean> {
|
|
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<number> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<number> {
|
|
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<StoredNotificationPreferences | null> {
|
|
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<Omit<StoredNotificationPreferences, 'userDid' | 'updatedAt'>>,
|
|
): Promise<StoredNotificationPreferences> {
|
|
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<StoredFundClaim> {
|
|
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<StoredFundClaim> {
|
|
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<StoredFundClaim | null> {
|
|
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<StoredFundClaim[]> {
|
|
// 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<StoredFundClaim | null> {
|
|
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<StoredFundClaim | null> {
|
|
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<void> {
|
|
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<number> {
|
|
// 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<StoredOidcClient | null> {
|
|
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<void> {
|
|
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<number> {
|
|
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<void> {
|
|
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<StoredOidcClient[]> {
|
|
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<StoredOidcClient | null> {
|
|
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<StoredOidcClient> {
|
|
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<boolean> {
|
|
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<StoredIdentityInvite> {
|
|
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<StoredIdentityInvite> {
|
|
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<StoredIdentityInvite | null> {
|
|
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<StoredIdentityInvite[]> {
|
|
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<StoredIdentityInvite[]> {
|
|
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<StoredIdentityInvite[]> {
|
|
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<StoredIdentityInvite | null> {
|
|
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<boolean> {
|
|
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<number> {
|
|
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<void> {
|
|
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<StoredPushSubscription[]> {
|
|
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<boolean> {
|
|
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<void> {
|
|
await sql`DELETE FROM push_subscriptions WHERE endpoint = ${endpoint}`;
|
|
}
|
|
|
|
export async function updatePushSubscriptionLastUsed(id: string): Promise<void> {
|
|
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<StoredDelegation> {
|
|
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<StoredDelegation | null> {
|
|
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<StoredDelegation[]> {
|
|
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<StoredDelegation[]> {
|
|
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<StoredDelegation | null> {
|
|
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<boolean> {
|
|
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<number> {
|
|
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<StoredDelegation[]> {
|
|
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<number> {
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
}): Promise<StoredTrustEvent> {
|
|
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<StoredTrustEvent[]> {
|
|
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<StoredTrustEvent[]> {
|
|
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<void> {
|
|
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<Array<{ did: string; totalScore: number; directScore: number; transitiveScore: number }>> {
|
|
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<StoredTrustScore[]> {
|
|
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<Array<{
|
|
did: string;
|
|
username: string;
|
|
displayName: string | null;
|
|
avatarUrl: string | null;
|
|
role: string;
|
|
trustScores: Record<string, number>;
|
|
}>> {
|
|
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<StoredLegacyIdentity> {
|
|
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<StoredLegacyIdentity | null> {
|
|
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<StoredLegacyIdentity[]> {
|
|
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<boolean> {
|
|
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<StoredUniversalProfile | null> {
|
|
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<void> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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<string[]> {
|
|
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<Map<string, string>> {
|
|
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<string, string>();
|
|
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<void> {
|
|
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<boolean> {
|
|
const result = await sql`DELETE FROM agent_mailboxes WHERE space_slug = ${spaceSlug}`;
|
|
return result.count > 0;
|
|
}
|
|
|
|
export async function listAllAgentMailboxes(): Promise<Array<{ spaceSlug: string; email: string; password: string }>> {
|
|
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 };
|