feat: add user profile and encrypted address API endpoints
Server-side support for user profile management and zero-knowledge postal address storage: Schema: - ALTER users table: add bio, avatar_url, profile_email, profile_email_is_recovery, wallet_address, updated_at columns - CREATE encrypted_addresses table with composite PK (id, user_id), label CHECK constraint, and cleartext metadata for UI listing DB layer: - getUserProfile, updateUserProfile (dynamic column updates) - getUserAddresses, getAddressById, saveUserAddress (upsert), deleteUserAddress - Default-address logic: unsets all others when isDefault=true API routes: - GET/PUT /api/user/profile — bio validation (500 chars), email format - GET/POST /api/user/addresses — max 10 addresses, label validation - PUT/DELETE /api/user/addresses/:id — 404 if not found Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
92037610db
commit
a2f0752fed
|
|
@ -575,6 +575,162 @@ export async function markDeviceLinkUsed(token: string): Promise<void> {
|
||||||
await sql`UPDATE device_links SET used = TRUE WHERE token = ${token}`;
|
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<StoredUserProfile | null> {
|
||||||
|
const [row] = await sql`SELECT * FROM users WHERE id = ${userId}`;
|
||||||
|
if (!row) return null;
|
||||||
|
return rowToProfile(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfileUpdates {
|
||||||
|
displayName?: string | null;
|
||||||
|
bio?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
profileEmail?: string | null;
|
||||||
|
profileEmailIsRecovery?: boolean;
|
||||||
|
walletAddress?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserProfile(userId: string, updates: UserProfileUpdates): Promise<StoredUserProfile | null> {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
|
||||||
|
if (updates.displayName !== undefined) { sets.push('display_name'); values.push(updates.displayName); }
|
||||||
|
if (updates.bio !== undefined) { sets.push('bio'); values.push(updates.bio); }
|
||||||
|
if (updates.avatarUrl !== undefined) { sets.push('avatar_url'); values.push(updates.avatarUrl); }
|
||||||
|
if (updates.profileEmail !== undefined) { sets.push('profile_email'); values.push(updates.profileEmail); }
|
||||||
|
if (updates.profileEmailIsRecovery !== undefined) { sets.push('profile_email_is_recovery'); values.push(updates.profileEmailIsRecovery); }
|
||||||
|
if (updates.walletAddress !== undefined) { sets.push('wallet_address'); values.push(updates.walletAddress); }
|
||||||
|
|
||||||
|
if (sets.length === 0) {
|
||||||
|
return getUserProfile(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dynamic update — use tagged template for each field
|
||||||
|
// postgres lib doesn't easily support dynamic column names, so we use unsafe for the SET clause
|
||||||
|
const setClauses = sets.map((col, i) => `${col} = $${i + 2}`).join(', ');
|
||||||
|
const params = [userId, ...values];
|
||||||
|
await sql.unsafe(
|
||||||
|
`UPDATE users SET ${setClauses}, updated_at = NOW() WHERE id = $1`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
return getUserProfile(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ENCRYPTED ADDRESS OPERATIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface StoredEncryptedAddress {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
ciphertext: string;
|
||||||
|
iv: string;
|
||||||
|
label: string;
|
||||||
|
labelCustom: string | null;
|
||||||
|
isDefault: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToAddress(row: any): StoredEncryptedAddress {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
ciphertext: row.ciphertext,
|
||||||
|
iv: row.iv,
|
||||||
|
label: row.label,
|
||||||
|
labelCustom: row.label_custom || null,
|
||||||
|
isDefault: row.is_default || false,
|
||||||
|
createdAt: row.created_at?.toISOString?.() || new Date(row.created_at).toISOString(),
|
||||||
|
updatedAt: row.updated_at?.toISOString?.() || new Date(row.updated_at).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserAddresses(userId: string): Promise<StoredEncryptedAddress[]> {
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT * FROM encrypted_addresses
|
||||||
|
WHERE user_id = ${userId}
|
||||||
|
ORDER BY is_default DESC, created_at ASC
|
||||||
|
`;
|
||||||
|
return rows.map(rowToAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAddressById(id: string, userId: string): Promise<StoredEncryptedAddress | null> {
|
||||||
|
const [row] = await sql`
|
||||||
|
SELECT * FROM encrypted_addresses
|
||||||
|
WHERE id = ${id} AND user_id = ${userId}
|
||||||
|
`;
|
||||||
|
if (!row) return null;
|
||||||
|
return rowToAddress(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveUserAddress(
|
||||||
|
userId: string,
|
||||||
|
addr: { id: string; ciphertext: string; iv: string; label: string; labelCustom?: string; isDefault: boolean },
|
||||||
|
): Promise<StoredEncryptedAddress> {
|
||||||
|
// If setting as default, unset all others first
|
||||||
|
if (addr.isDefault) {
|
||||||
|
await sql`UPDATE encrypted_addresses SET is_default = FALSE WHERE user_id = ${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await sql`
|
||||||
|
INSERT INTO encrypted_addresses (id, user_id, ciphertext, iv, label, label_custom, is_default)
|
||||||
|
VALUES (${addr.id}, ${userId}, ${addr.ciphertext}, ${addr.iv}, ${addr.label}, ${addr.labelCustom || null}, ${addr.isDefault})
|
||||||
|
ON CONFLICT (id, user_id) DO UPDATE SET
|
||||||
|
ciphertext = ${addr.ciphertext},
|
||||||
|
iv = ${addr.iv},
|
||||||
|
label = ${addr.label},
|
||||||
|
label_custom = ${addr.labelCustom || null},
|
||||||
|
is_default = ${addr.isDefault},
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
return rowToAddress(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserAddress(id: string, userId: string): Promise<boolean> {
|
||||||
|
const result = await sql`
|
||||||
|
DELETE FROM encrypted_addresses
|
||||||
|
WHERE id = ${id} AND user_id = ${userId}
|
||||||
|
`;
|
||||||
|
return result.count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// HEALTH CHECK
|
// HEALTH CHECK
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,14 @@ CREATE TABLE IF NOT EXISTS users (
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
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 (
|
CREATE TABLE IF NOT EXISTS credentials (
|
||||||
credential_id TEXT PRIMARY KEY,
|
credential_id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
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);
|
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);
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,12 @@ import {
|
||||||
listSpaceMembers,
|
listSpaceMembers,
|
||||||
upsertSpaceMember,
|
upsertSpaceMember,
|
||||||
removeSpaceMember,
|
removeSpaceMember,
|
||||||
|
getUserProfile,
|
||||||
|
updateUserProfile,
|
||||||
|
getUserAddresses,
|
||||||
|
getAddressById,
|
||||||
|
saveUserAddress,
|
||||||
|
deleteUserAddress,
|
||||||
} from './db.js';
|
} 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<string, any> = {};
|
||||||
|
|
||||||
|
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
|
// ACCOUNT SETTINGS ENDPOINTS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue