diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index 1d64e0c..bd960b0 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -575,6 +575,162 @@ export async function markDeviceLinkUsed(token: string): Promise { await sql`UPDATE device_links SET used = TRUE WHERE token = ${token}`; } +// ============================================================================ +// USER PROFILE OPERATIONS +// ============================================================================ + +export interface StoredUserProfile { + userId: string; + username: string; + displayName: string | null; + bio: string | null; + avatarUrl: string | null; + profileEmail: string | null; + profileEmailIsRecovery: boolean; + did: string | null; + walletAddress: string | null; + createdAt: string; + updatedAt: string; +} + +function rowToProfile(row: any): StoredUserProfile { + return { + userId: row.id, + username: row.username, + displayName: row.display_name || null, + bio: row.bio || null, + avatarUrl: row.avatar_url || null, + profileEmail: row.profile_email || null, + profileEmailIsRecovery: row.profile_email_is_recovery || false, + did: row.did || null, + walletAddress: row.wallet_address || null, + createdAt: row.created_at?.toISOString?.() || new Date(row.created_at).toISOString(), + updatedAt: row.updated_at?.toISOString?.() || row.created_at?.toISOString?.() || new Date().toISOString(), + }; +} + +export async function getUserProfile(userId: string): Promise { + const [row] = await sql`SELECT * FROM users WHERE id = ${userId}`; + if (!row) return null; + return rowToProfile(row); +} + +export interface UserProfileUpdates { + displayName?: string | null; + bio?: string | null; + avatarUrl?: string | null; + profileEmail?: string | null; + profileEmailIsRecovery?: boolean; + walletAddress?: string | null; +} + +export async function updateUserProfile(userId: string, updates: UserProfileUpdates): Promise { + const sets: string[] = []; + const values: any[] = []; + + if (updates.displayName !== undefined) { sets.push('display_name'); values.push(updates.displayName); } + if (updates.bio !== undefined) { sets.push('bio'); values.push(updates.bio); } + if (updates.avatarUrl !== undefined) { sets.push('avatar_url'); values.push(updates.avatarUrl); } + if (updates.profileEmail !== undefined) { sets.push('profile_email'); values.push(updates.profileEmail); } + if (updates.profileEmailIsRecovery !== undefined) { sets.push('profile_email_is_recovery'); values.push(updates.profileEmailIsRecovery); } + if (updates.walletAddress !== undefined) { sets.push('wallet_address'); values.push(updates.walletAddress); } + + if (sets.length === 0) { + return getUserProfile(userId); + } + + // Build dynamic update — use tagged template for each field + // postgres lib doesn't easily support dynamic column names, so we use unsafe for the SET clause + const setClauses = sets.map((col, i) => `${col} = $${i + 2}`).join(', '); + const params = [userId, ...values]; + await sql.unsafe( + `UPDATE users SET ${setClauses}, updated_at = NOW() WHERE id = $1`, + params, + ); + + return getUserProfile(userId); +} + +// ============================================================================ +// ENCRYPTED ADDRESS OPERATIONS +// ============================================================================ + +export interface StoredEncryptedAddress { + id: string; + userId: string; + ciphertext: string; + iv: string; + label: string; + labelCustom: string | null; + isDefault: boolean; + createdAt: string; + updatedAt: string; +} + +function rowToAddress(row: any): StoredEncryptedAddress { + return { + id: row.id, + userId: row.user_id, + ciphertext: row.ciphertext, + iv: row.iv, + label: row.label, + labelCustom: row.label_custom || null, + isDefault: row.is_default || false, + createdAt: row.created_at?.toISOString?.() || new Date(row.created_at).toISOString(), + updatedAt: row.updated_at?.toISOString?.() || new Date(row.updated_at).toISOString(), + }; +} + +export async function getUserAddresses(userId: string): Promise { + const rows = await sql` + SELECT * FROM encrypted_addresses + WHERE user_id = ${userId} + ORDER BY is_default DESC, created_at ASC + `; + return rows.map(rowToAddress); +} + +export async function getAddressById(id: string, userId: string): Promise { + const [row] = await sql` + SELECT * FROM encrypted_addresses + WHERE id = ${id} AND user_id = ${userId} + `; + if (!row) return null; + return rowToAddress(row); +} + +export async function saveUserAddress( + userId: string, + addr: { id: string; ciphertext: string; iv: string; label: string; labelCustom?: string; isDefault: boolean }, +): Promise { + // If setting as default, unset all others first + if (addr.isDefault) { + await sql`UPDATE encrypted_addresses SET is_default = FALSE WHERE user_id = ${userId}`; + } + + const rows = await sql` + INSERT INTO encrypted_addresses (id, user_id, ciphertext, iv, label, label_custom, is_default) + VALUES (${addr.id}, ${userId}, ${addr.ciphertext}, ${addr.iv}, ${addr.label}, ${addr.labelCustom || null}, ${addr.isDefault}) + ON CONFLICT (id, user_id) DO UPDATE SET + ciphertext = ${addr.ciphertext}, + iv = ${addr.iv}, + label = ${addr.label}, + label_custom = ${addr.labelCustom || null}, + is_default = ${addr.isDefault}, + updated_at = NOW() + RETURNING * + `; + return rowToAddress(rows[0]); +} + +export async function deleteUserAddress(id: string, userId: string): Promise { + const result = await sql` + DELETE FROM encrypted_addresses + WHERE id = ${id} AND user_id = ${userId} + `; + return result.count > 0; +} + // ============================================================================ // HEALTH CHECK // ============================================================================ diff --git a/src/encryptid/schema.sql b/src/encryptid/schema.sql index a35ef24..1730b77 100644 --- a/src/encryptid/schema.sql +++ b/src/encryptid/schema.sql @@ -12,6 +12,14 @@ CREATE TABLE IF NOT EXISTS users ( CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +-- Profile columns (added for user profile management) +ALTER TABLE users ADD COLUMN IF NOT EXISTS bio TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_email TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_email_is_recovery BOOLEAN DEFAULT FALSE; +ALTER TABLE users ADD COLUMN IF NOT EXISTS wallet_address TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW(); + CREATE TABLE IF NOT EXISTS credentials ( credential_id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -118,3 +126,24 @@ CREATE TABLE IF NOT EXISTS device_links ( ); CREATE INDEX IF NOT EXISTS idx_device_links_user_id ON device_links(user_id); + +-- ============================================================================ +-- ENCRYPTED POSTAL ADDRESSES (zero-knowledge address storage) +-- ============================================================================ + +-- Addresses are encrypted client-side with passkey-derived AES-256-GCM keys. +-- The server stores opaque ciphertext + cleartext metadata (label, isDefault) for UI listing. +CREATE TABLE IF NOT EXISTS encrypted_addresses ( + id TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + ciphertext TEXT NOT NULL, + iv TEXT NOT NULL, + label TEXT NOT NULL CHECK (label IN ('home', 'work', 'shipping', 'billing', 'other')), + label_custom TEXT, + is_default BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_encrypted_addresses_user_id ON encrypted_addresses(user_id); diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 9170fd8..ccb8015 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -56,6 +56,12 @@ import { listSpaceMembers, upsertSpaceMember, removeSpaceMember, + getUserProfile, + updateUserProfile, + getUserAddresses, + getAddressById, + saveUserAddress, + deleteUserAddress, } from './db.js'; // ============================================================================ @@ -637,6 +643,173 @@ app.get('/api/user/credentials', async (c) => { } }); +// ============================================================================ +// USER PROFILE ENDPOINTS +// ============================================================================ + +// GET /api/user/profile — get the authenticated user's profile +app.get('/api/user/profile', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const profile = await getUserProfile(claims.sub as string); + if (!profile) return c.json({ error: 'User not found' }, 404); + + return c.json({ success: true, profile }); +}); + +// PUT /api/user/profile — update the authenticated user's profile +app.put('/api/user/profile', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const body = await c.req.json(); + const updates: Record = {}; + + if (body.displayName !== undefined) updates.displayName = body.displayName; + if (body.bio !== undefined) { + if (typeof body.bio === 'string' && body.bio.length > 500) { + return c.json({ error: 'Bio must be 500 characters or fewer' }, 400); + } + updates.bio = body.bio; + } + if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl; + if (body.profileEmail !== undefined) { + if (body.profileEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.profileEmail)) { + return c.json({ error: 'Invalid email format' }, 400); + } + updates.profileEmail = body.profileEmail; + } + if (body.profileEmailIsRecovery !== undefined) updates.profileEmailIsRecovery = body.profileEmailIsRecovery; + if (body.walletAddress !== undefined) updates.walletAddress = body.walletAddress; + + const profile = await updateUserProfile(claims.sub as string, updates); + if (!profile) return c.json({ error: 'User not found' }, 404); + + return c.json({ success: true, profile }); +}); + +// ============================================================================ +// ENCRYPTED ADDRESS ENDPOINTS +// ============================================================================ + +const MAX_ADDRESSES = 10; +const VALID_LABELS = ['home', 'work', 'shipping', 'billing', 'other']; + +// GET /api/user/addresses — get all encrypted addresses for the user +app.get('/api/user/addresses', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const addresses = await getUserAddresses(claims.sub as string); + return c.json({ + success: true, + addresses: addresses.map(a => ({ + id: a.id, + ciphertext: a.ciphertext, + iv: a.iv, + label: a.label, + labelCustom: a.labelCustom, + isDefault: a.isDefault, + })), + }); +}); + +// POST /api/user/addresses — save a new encrypted address +app.post('/api/user/addresses', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const userId = claims.sub as string; + const body = await c.req.json(); + + if (!body.id || !body.ciphertext || !body.iv || !body.label) { + return c.json({ error: 'Missing required fields: id, ciphertext, iv, label' }, 400); + } + if (!VALID_LABELS.includes(body.label)) { + return c.json({ error: `Invalid label. Must be one of: ${VALID_LABELS.join(', ')}` }, 400); + } + + // Check address limit + const existing = await getUserAddresses(userId); + if (existing.length >= MAX_ADDRESSES) { + return c.json({ error: `Maximum ${MAX_ADDRESSES} addresses allowed` }, 400); + } + + const address = await saveUserAddress(userId, { + id: body.id, + ciphertext: body.ciphertext, + iv: body.iv, + label: body.label, + labelCustom: body.labelCustom, + isDefault: body.isDefault || false, + }); + + return c.json({ + success: true, + address: { + id: address.id, + ciphertext: address.ciphertext, + iv: address.iv, + label: address.label, + labelCustom: address.labelCustom, + isDefault: address.isDefault, + }, + }, 201); +}); + +// PUT /api/user/addresses/:id — update an existing encrypted address +app.put('/api/user/addresses/:id', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const userId = claims.sub as string; + const id = c.req.param('id'); + const body = await c.req.json(); + + const existing = await getAddressById(id, userId); + if (!existing) return c.json({ error: 'Address not found' }, 404); + + if (body.label && !VALID_LABELS.includes(body.label)) { + return c.json({ error: `Invalid label. Must be one of: ${VALID_LABELS.join(', ')}` }, 400); + } + + const address = await saveUserAddress(userId, { + id, + ciphertext: body.ciphertext || existing.ciphertext, + iv: body.iv || existing.iv, + label: body.label || existing.label, + labelCustom: body.labelCustom !== undefined ? body.labelCustom : existing.labelCustom, + isDefault: body.isDefault !== undefined ? body.isDefault : existing.isDefault, + }); + + return c.json({ + success: true, + address: { + id: address.id, + ciphertext: address.ciphertext, + iv: address.iv, + label: address.label, + labelCustom: address.labelCustom, + isDefault: address.isDefault, + }, + }); +}); + +// DELETE /api/user/addresses/:id — delete an encrypted address +app.delete('/api/user/addresses/:id', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const userId = claims.sub as string; + const id = c.req.param('id'); + + const deleted = await deleteUserAddress(id, userId); + if (!deleted) return c.json({ error: 'Address not found' }, 404); + + return c.json({ success: true }); +}); + // ============================================================================ // ACCOUNT SETTINGS ENDPOINTS // ============================================================================