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}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue