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:
Jeff Emmett 2026-02-27 13:13:32 -08:00
parent 92037610db
commit a2f0752fed
3 changed files with 358 additions and 0 deletions

View File

@ -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
// ============================================================================

View File

@ -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);

View File

@ -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
// ============================================================================