From 80f457f61522a9675d25d119dfb1af9d44bbc5ad Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 2 Jan 2026 19:29:42 +0100 Subject: [PATCH] feat: add Web3 wallet linking to CryptID accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add WalletLinkPanel component for connecting and linking wallets - Add useWallet hooks (useWalletConnection, useWalletLink, useLinkedWallets) - Add wallet API endpoints in worker (link, list, update, unlink, verify) - Add proper signature verification with @noble/hashes and @noble/secp256k1 - Add D1 migration for linked_wallets table - Integrate wallet section into Settings > Integrations tab - Support for MetaMask, WalletConnect, Coinbase Wallet - Multi-chain support: Ethereum, Optimism, Arbitrum, Base, Polygon 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .env.example | 4 + src/components/WalletLinkPanel.tsx | 521 +++++++++++++++ src/hooks/useWallet.ts | 346 ++++++++++ src/ui/UserSettingsModal.tsx | 20 + worker/migrations/002_linked_wallets.sql | 94 +++ worker/types.ts | 91 +++ worker/walletAuth.ts | 769 +++++++++++++++++++++++ worker/worker.ts | 205 ++++++ 8 files changed, 2050 insertions(+) create mode 100644 src/components/WalletLinkPanel.tsx create mode 100644 src/hooks/useWallet.ts create mode 100644 worker/migrations/002_linked_wallets.sql create mode 100644 worker/walletAuth.ts diff --git a/.env.example b/.env.example index b17bb28..1629262 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,10 @@ VITE_RUNPOD_IMAGE_ENDPOINT_ID='your_image_endpoint_id' # Automatic1111/SD VITE_RUNPOD_VIDEO_ENDPOINT_ID='your_video_endpoint_id' # Wan2.2 VITE_RUNPOD_WHISPER_ENDPOINT_ID='your_whisper_endpoint_id' # WhisperX +# WalletConnect (Web3 wallet integration) +# Get your project ID at https://cloud.walletconnect.com/ +VITE_WALLETCONNECT_PROJECT_ID='your_walletconnect_project_id' + # Worker-only Variables (Do not prefix with VITE_) CLOUDFLARE_API_TOKEN='your_cloudflare_token' CLOUDFLARE_ACCOUNT_ID='your_account_id' diff --git a/src/components/WalletLinkPanel.tsx b/src/components/WalletLinkPanel.tsx new file mode 100644 index 0000000..33df28b --- /dev/null +++ b/src/components/WalletLinkPanel.tsx @@ -0,0 +1,521 @@ +/** + * WalletLinkPanel - UI for connecting and linking Web3 wallets to CryptID + * + * Features: + * - Connect wallet (MetaMask, WalletConnect, etc.) + * - Link wallet to CryptID account via signature + * - View and manage linked wallets + * - Set primary wallet + * - Unlink wallets + */ + +import React, { useState } from 'react'; +import { + useWalletConnection, + useWalletLink, + useLinkedWallets, + formatAddress, + LinkedWallet, +} from '../hooks/useWallet'; +import { useAuth } from '../context/AuthContext'; + +// ============================================================================= +// Styles (inline for simplicity - can be moved to CSS/Tailwind) +// ============================================================================= + +const styles = { + container: { + padding: '16px', + fontFamily: 'system-ui, -apple-system, sans-serif', + } as React.CSSProperties, + section: { + marginBottom: '24px', + } as React.CSSProperties, + sectionTitle: { + fontSize: '14px', + fontWeight: 600, + marginBottom: '12px', + color: '#374151', + } as React.CSSProperties, + card: { + background: '#f9fafb', + borderRadius: '8px', + padding: '12px', + marginBottom: '8px', + } as React.CSSProperties, + button: { + background: '#4f46e5', + color: 'white', + border: 'none', + borderRadius: '6px', + padding: '8px 16px', + cursor: 'pointer', + fontSize: '14px', + fontWeight: 500, + } as React.CSSProperties, + buttonSecondary: { + background: '#e5e7eb', + color: '#374151', + border: 'none', + borderRadius: '6px', + padding: '8px 16px', + cursor: 'pointer', + fontSize: '14px', + fontWeight: 500, + } as React.CSSProperties, + buttonDanger: { + background: '#ef4444', + color: 'white', + border: 'none', + borderRadius: '6px', + padding: '6px 12px', + cursor: 'pointer', + fontSize: '12px', + fontWeight: 500, + } as React.CSSProperties, + buttonSmall: { + padding: '4px 8px', + fontSize: '12px', + } as React.CSSProperties, + flexRow: { + display: 'flex', + alignItems: 'center', + gap: '8px', + } as React.CSSProperties, + flexBetween: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + } as React.CSSProperties, + address: { + fontFamily: 'monospace', + fontSize: '13px', + color: '#6b7280', + } as React.CSSProperties, + badge: { + background: '#dbeafe', + color: '#1d4ed8', + padding: '2px 6px', + borderRadius: '4px', + fontSize: '11px', + fontWeight: 500, + } as React.CSSProperties, + badgePrimary: { + background: '#dcfce7', + color: '#166534', + } as React.CSSProperties, + error: { + color: '#ef4444', + fontSize: '13px', + marginTop: '8px', + } as React.CSSProperties, + success: { + color: '#22c55e', + fontSize: '13px', + marginTop: '8px', + } as React.CSSProperties, + input: { + width: '100%', + padding: '8px 12px', + border: '1px solid #d1d5db', + borderRadius: '6px', + fontSize: '14px', + marginBottom: '8px', + } as React.CSSProperties, + connectorButton: { + display: 'flex', + alignItems: 'center', + gap: '8px', + width: '100%', + padding: '12px', + background: 'white', + border: '1px solid #e5e7eb', + borderRadius: '8px', + cursor: 'pointer', + marginBottom: '8px', + transition: 'border-color 0.2s', + } as React.CSSProperties, + walletIcon: { + width: '24px', + height: '24px', + borderRadius: '6px', + background: '#f3f4f6', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '14px', + } as React.CSSProperties, +}; + +// ============================================================================= +// Sub-components +// ============================================================================= + +interface ConnectWalletSectionProps { + onConnect: (connectorId?: string) => void; + connectors: Array<{ id: string; name: string; type: string }>; + isConnecting: boolean; +} + +function ConnectWalletSection({ onConnect, connectors, isConnecting }: ConnectWalletSectionProps) { + return ( +
+
Connect Wallet
+ {connectors.map((connector) => ( + + ))} +
+ ); +} + +interface ConnectedWalletSectionProps { + address: string; + ensName: string | null; + chainId: number; + connectorName: string | undefined; + onDisconnect: () => void; + isDisconnecting: boolean; +} + +function ConnectedWalletSection({ + address, + ensName, + chainId, + connectorName, + onDisconnect, + isDisconnecting, +}: ConnectedWalletSectionProps) { + const chainNames: Record = { + 1: 'Ethereum', + 10: 'Optimism', + 137: 'Polygon', + 42161: 'Arbitrum', + 8453: 'Base', + }; + + return ( +
+
Connected Wallet
+
+
+
+
+ {ensName || formatAddress(address)} +
+
{formatAddress(address, 6)}
+
+
+
{chainNames[chainId] || `Chain ${chainId}`}
+ {connectorName && ( +
+ via {connectorName} +
+ )} +
+
+
+ +
+
+
+ ); +} + +interface LinkWalletSectionProps { + address: string; + isLinking: boolean; + linkError: string | null; + onLink: (label?: string) => Promise<{ success: boolean; error?: string }>; + isAuthenticated: boolean; +} + +function LinkWalletSection({ + address: _address, + isLinking, + linkError, + onLink, + isAuthenticated, +}: LinkWalletSectionProps) { + const [label, setLabel] = useState(''); + const [success, setSuccess] = useState(false); + + const handleLink = async () => { + setSuccess(false); + const result = await onLink(label || undefined); + if (result.success) { + setSuccess(true); + setLabel(''); + } + }; + + if (!isAuthenticated) { + return ( +
+
Link to CryptID
+
+ Please sign in with CryptID to link your wallet. +
+
+ ); + } + + return ( +
+
Link to CryptID
+
+

+ Link this wallet to your CryptID account. You'll be asked to sign a message + to prove ownership. +

+ setLabel(e.target.value)} + style={styles.input} + /> + + {linkError &&
{linkError}
} + {success &&
Wallet linked successfully!
} +
+
+ ); +} + +interface LinkedWalletItemProps { + wallet: LinkedWallet; + onSetPrimary: () => Promise; + onUnlink: () => Promise; +} + +function LinkedWalletItem({ wallet, onSetPrimary, onUnlink }: LinkedWalletItemProps) { + const [isUpdating, setIsUpdating] = useState(false); + + const handleSetPrimary = async () => { + setIsUpdating(true); + await onSetPrimary(); + setIsUpdating(false); + }; + + const handleUnlink = async () => { + if (!confirm('Are you sure you want to unlink this wallet?')) return; + setIsUpdating(true); + await onUnlink(); + setIsUpdating(false); + }; + + return ( +
+
+
+
+ + {wallet.ensName || wallet.label || formatAddress(wallet.address)} + + {wallet.isPrimary && ( + Primary + )} + {wallet.type.toUpperCase()} +
+
+ {formatAddress(wallet.address, 8)} +
+
+
+ {!wallet.isPrimary && ( + + )} + +
+
+
+ ); +} + +interface LinkedWalletsSectionProps { + wallets: LinkedWallet[]; + isLoading: boolean; + error: string | null; + onUpdateWallet: (address: string, updates: { isPrimary?: boolean }) => Promise; + onUnlinkWallet: (address: string) => Promise; +} + +function LinkedWalletsSection({ + wallets, + isLoading, + error, + onUpdateWallet, + onUnlinkWallet, +}: LinkedWalletsSectionProps) { + if (isLoading) { + return ( +
+
Linked Wallets
+
Loading...
+
+ ); + } + + if (error) { + return ( +
+
Linked Wallets
+
{error}
+
+ ); + } + + if (wallets.length === 0) { + return ( +
+
Linked Wallets
+
+ No wallets linked yet. Connect a wallet and link it above. +
+
+ ); + } + + return ( +
+
Linked Wallets ({wallets.length})
+ {wallets.map((wallet) => ( + onUpdateWallet(wallet.address, { isPrimary: true })} + onUnlink={() => onUnlinkWallet(wallet.address)} + /> + ))} +
+ ); +} + +// ============================================================================= +// Main Component +// ============================================================================= + +export function WalletLinkPanel() { + const { session } = useAuth(); + const { + address, + isConnected, + isConnecting, + chainId, + connectorName, + ensName, + connect, + disconnect, + isDisconnecting, + connectors, + } = useWalletConnection(); + + const { isLinking, linkError, linkWallet, clearError } = useWalletLink(); + + const { + wallets, + isLoading: isLoadingWallets, + error: walletsError, + updateWallet, + unlinkWallet, + refetch: refetchWallets, + } = useLinkedWallets(); + + // Check if the connected wallet is already linked + const isCurrentWalletLinked = address + ? wallets.some(w => w.address.toLowerCase() === address.toLowerCase()) + : false; + + const handleLink = async (label?: string) => { + clearError(); + const result = await linkWallet(label); + if (result.success) { + await refetchWallets(); + } + return result; + }; + + return ( +
+

+ Web3 Wallet +

+ + {!isConnected ? ( + + ) : ( + <> + + + {!isCurrentWalletLinked && ( + + )} + + )} + + +
+ ); +} + +export default WalletLinkPanel; diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts new file mode 100644 index 0000000..4b7b3fb --- /dev/null +++ b/src/hooks/useWallet.ts @@ -0,0 +1,346 @@ +/** + * useWallet - Hooks for Web3 wallet integration + * + * Provides functionality for: + * - Connecting/disconnecting wallets + * - Linking wallets to CryptID accounts + * - Managing linked wallets + */ + +import { useState, useCallback, useEffect } from 'react'; +import { + useAccount, + useConnect, + useDisconnect, + useSignMessage, + useEnsName, + useEnsAvatar, + useChainId, +} from 'wagmi'; +import { useAuth } from '../context/AuthContext'; +import { WORKER_URL } from '../constants/workerUrl'; +import * as crypto from '../lib/auth/crypto'; + +// ============================================================================= +// Types +// ============================================================================= + +export type WalletType = 'eoa' | 'safe' | 'hardware' | 'contract'; + +export interface LinkedWallet { + id: string; + address: string; + type: WalletType; + chainId: number; + label: string | null; + ensName: string | null; + ensAvatar: string | null; + isPrimary: boolean; + linkedAt: string; + lastUsedAt: string | null; +} + +interface LinkWalletResult { + success: boolean; + wallet?: LinkedWallet; + error?: string; +} + +// ============================================================================= +// Message Generation +// ============================================================================= + +/** + * Generate the message that must be signed to link a wallet + */ +function generateLinkMessage( + username: string, + address: string, + timestamp: string, + nonce: string +): string { + return `Link wallet to CryptID + +Account: ${username} +Wallet: ${address} +Timestamp: ${timestamp} +Nonce: ${nonce} + +This signature proves you own this wallet.`; +} + +// ============================================================================= +// useWalletConnection - Basic wallet connection +// ============================================================================= + +export function useWalletConnection() { + const { address, isConnected, isConnecting, connector } = useAccount(); + const { connect, connectors, isPending: isConnectPending } = useConnect(); + const { disconnect, isPending: isDisconnectPending } = useDisconnect(); + const chainId = useChainId(); + + // ENS data for connected wallet + const { data: ensName } = useEnsName({ address }); + const { data: ensAvatar } = useEnsAvatar({ name: ensName || undefined }); + + const connectWallet = useCallback((connectorId?: string) => { + const targetConnector = connectorId + ? connectors.find(c => c.id === connectorId) + : connectors[0]; // Default to first connector (usually injected) + + if (targetConnector) { + connect({ connector: targetConnector }); + } + }, [connect, connectors]); + + return { + // Connection state + address, + isConnected, + isConnecting: isConnecting || isConnectPending, + chainId, + connectorName: connector?.name, + + // ENS data + ensName: ensName || null, + ensAvatar: ensAvatar || null, + + // Actions + connect: connectWallet, + disconnect, + isDisconnecting: isDisconnectPending, + + // Available connectors + connectors: connectors.map(c => ({ + id: c.id, + name: c.name, + type: c.type, + })), + }; +} + +// ============================================================================= +// useWalletLink - Link wallet to CryptID +// ============================================================================= + +export function useWalletLink() { + const { address, isConnected } = useAccount(); + const { signMessageAsync } = useSignMessage(); + const { session } = useAuth(); + const [isLinking, setIsLinking] = useState(false); + const [linkError, setLinkError] = useState(null); + + const linkWallet = useCallback(async (label?: string): Promise => { + if (!address) { + return { success: false, error: 'No wallet connected' }; + } + + if (!session.authed || !session.username) { + return { success: false, error: 'Not authenticated with CryptID' }; + } + + setIsLinking(true); + setLinkError(null); + + try { + // Generate the message to sign + const timestamp = new Date().toISOString(); + const nonce = globalThis.crypto.randomUUID(); + const message = generateLinkMessage( + session.username, + address, + timestamp, + nonce + ); + + // Request signature from wallet + const signature = await signMessageAsync({ message }); + + // Get public key for auth header + const publicKey = crypto.getPublicKey(session.username); + if (!publicKey) { + throw new Error('Could not get CryptID public key'); + } + + // Send to backend for verification + const response = await fetch(`${WORKER_URL}/api/wallet/link`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CryptID-PublicKey': publicKey, + }, + body: JSON.stringify({ + walletAddress: address, + signature, + message, + label, + walletType: 'eoa', + chainId: 1, + }), + }); + + const data = await response.json() as { error?: string; wallet?: LinkedWallet }; + + if (!response.ok) { + throw new Error(data.error || 'Failed to link wallet'); + } + + return { + success: true, + wallet: data.wallet, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + setLinkError(errorMessage); + return { success: false, error: errorMessage }; + } finally { + setIsLinking(false); + } + }, [address, session.authed, session.username, signMessageAsync]); + + return { + address, + isConnected, + isLinking, + linkError, + linkWallet, + clearError: () => setLinkError(null), + }; +} + +// ============================================================================= +// useLinkedWallets - Manage linked wallets +// ============================================================================= + +export function useLinkedWallets() { + const { session } = useAuth(); + const [wallets, setWallets] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Fetch linked wallets + const fetchWallets = useCallback(async () => { + if (!session.authed || !session.username) { + setWallets([]); + return; + } + + const publicKey = crypto.getPublicKey(session.username); + if (!publicKey) { + setError('Could not get CryptID public key'); + return; + } + + setIsLoading(true); + setError(null); + + try { + const response = await fetch(`${WORKER_URL}/api/wallet/list`, { + headers: { + 'X-CryptID-PublicKey': publicKey, + }, + }); + + const data = await response.json() as { error?: string; wallets?: LinkedWallet[] }; + + if (!response.ok) { + throw new Error(data.error || 'Failed to fetch wallets'); + } + + setWallets(data.wallets || []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setIsLoading(false); + } + }, [session.authed, session.username]); + + // Fetch on mount and when session changes + useEffect(() => { + fetchWallets(); + }, [fetchWallets]); + + // Update a wallet + const updateWallet = useCallback(async ( + address: string, + updates: { label?: string; isPrimary?: boolean } + ): Promise => { + if (!session.username) return false; + + const publicKey = crypto.getPublicKey(session.username); + if (!publicKey) return false; + + try { + const response = await fetch(`${WORKER_URL}/api/wallet/${address}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-CryptID-PublicKey': publicKey, + }, + body: JSON.stringify(updates), + }); + + if (response.ok) { + await fetchWallets(); // Refresh list + return true; + } + return false; + } catch { + return false; + } + }, [session.username, fetchWallets]); + + // Unlink a wallet + const unlinkWallet = useCallback(async (address: string): Promise => { + if (!session.username) return false; + + const publicKey = crypto.getPublicKey(session.username); + if (!publicKey) return false; + + try { + const response = await fetch(`${WORKER_URL}/api/wallet/${address}`, { + method: 'DELETE', + headers: { + 'X-CryptID-PublicKey': publicKey, + }, + }); + + if (response.ok) { + await fetchWallets(); // Refresh list + return true; + } + return false; + } catch { + return false; + } + }, [session.username, fetchWallets]); + + return { + wallets, + isLoading, + error, + refetch: fetchWallets, + updateWallet, + unlinkWallet, + primaryWallet: wallets.find(w => w.isPrimary) || wallets[0] || null, + }; +} + +// ============================================================================= +// Utility functions +// ============================================================================= + +/** + * Format an address for display (0x1234...5678) + */ +export function formatAddress(address: string, chars = 4): string { + if (!address) return ''; + return `${address.slice(0, chars + 2)}...${address.slice(-chars)}`; +} + +/** + * Check if an address is valid + */ +export function isValidAddress(address: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(address); +} diff --git a/src/ui/UserSettingsModal.tsx b/src/ui/UserSettingsModal.tsx index fc66670..5f53123 100644 --- a/src/ui/UserSettingsModal.tsx +++ b/src/ui/UserSettingsModal.tsx @@ -6,6 +6,7 @@ import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyCo import { linkEmailToAccount, checkEmailStatus, type LookupResult } from "../lib/auth/cryptidEmailService" import { GoogleDataService, type GoogleService, type ShareableItem } from "../lib/google" import { GoogleExportBrowser } from "../components/GoogleExportBrowser" +import { WalletLinkPanel } from "../components/WalletLinkPanel" // AI tool model configurations const AI_TOOLS = [ @@ -977,6 +978,25 @@ export function UserSettingsModal({ onClose, isDarkMode, onToggleDarkMode }: Use )} +
+ + {/* Web3 Wallet Section */} +

+ Web3 Wallets +

+ +
+ +
+ {/* Future Integrations Placeholder */}

diff --git a/worker/migrations/002_linked_wallets.sql b/worker/migrations/002_linked_wallets.sql new file mode 100644 index 0000000..bbcb773 --- /dev/null +++ b/worker/migrations/002_linked_wallets.sql @@ -0,0 +1,94 @@ +-- Migration: Add Linked Wallets for Web3 Integration +-- Date: 2026-01-02 +-- Description: Enables CryptID users to link Ethereum wallets for +-- on-chain transactions, voting, and token-gated features. +-- Related: task-007, doc-001 + +-- ============================================================================= +-- LINKED WALLETS TABLE +-- ============================================================================= +-- Each CryptID user can link multiple Ethereum wallets (EOA, Safe, hardware) +-- Linking requires signature verification to prove wallet ownership + +CREATE TABLE IF NOT EXISTS linked_wallets ( + id TEXT PRIMARY KEY, -- UUID for the link record + user_id TEXT NOT NULL, -- References users.id (CryptID account) + wallet_address TEXT NOT NULL, -- Ethereum address (checksummed, 0x-prefixed) + + -- Wallet metadata + wallet_type TEXT DEFAULT 'eoa' CHECK (wallet_type IN ('eoa', 'safe', 'hardware', 'contract')), + chain_id INTEGER DEFAULT 1, -- Primary chain (1 = Ethereum mainnet) + label TEXT, -- User-provided label (e.g., "Main Wallet") + + -- Verification proof + signature_message TEXT NOT NULL, -- The message that was signed + signature TEXT NOT NULL, -- EIP-191 personal_sign signature + verified_at TEXT NOT NULL, -- When signature was verified + + -- ENS integration + ens_name TEXT, -- Resolved ENS name (if any) + ens_avatar TEXT, -- ENS avatar URL (if any) + ens_resolved_at TEXT, -- When ENS was last resolved + + -- Flags + is_primary INTEGER DEFAULT 0, -- 1 = primary wallet for this user + is_active INTEGER DEFAULT 1, -- 0 = soft-deleted + + -- Timestamps + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + last_used_at TEXT, -- Last time wallet was used for action + + -- Constraints + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id, wallet_address) -- Can't link same wallet twice +); + +-- Indexes for efficient lookups +CREATE INDEX IF NOT EXISTS idx_linked_wallets_user ON linked_wallets(user_id); +CREATE INDEX IF NOT EXISTS idx_linked_wallets_address ON linked_wallets(wallet_address); +CREATE INDEX IF NOT EXISTS idx_linked_wallets_active ON linked_wallets(is_active); +CREATE INDEX IF NOT EXISTS idx_linked_wallets_primary ON linked_wallets(user_id, is_primary); + +-- ============================================================================= +-- WALLET LINKING TOKENS TABLE (for Safe/multisig delayed verification) +-- ============================================================================= +-- For contract wallets that require on-chain signature verification + +CREATE TABLE IF NOT EXISTS wallet_link_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + wallet_address TEXT NOT NULL, + nonce TEXT NOT NULL, -- Random nonce for signature message + token TEXT NOT NULL UNIQUE, -- Secret token for verification callback + expires_at TEXT NOT NULL, + used INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_wallet_link_tokens_token ON wallet_link_tokens(token); +CREATE INDEX IF NOT EXISTS idx_wallet_link_tokens_user ON wallet_link_tokens(user_id); + +-- ============================================================================= +-- TOKEN BALANCES CACHE (optional, for token-gating) +-- ============================================================================= +-- Cache of token balances for faster permission checks +-- Updated periodically or on-demand + +CREATE TABLE IF NOT EXISTS wallet_token_balances ( + id TEXT PRIMARY KEY, + wallet_address TEXT NOT NULL, + token_address TEXT NOT NULL, -- ERC-20/721/1155 contract address + token_type TEXT CHECK (token_type IN ('erc20', 'erc721', 'erc1155')), + chain_id INTEGER NOT NULL, + balance TEXT NOT NULL, -- String to handle big numbers + last_updated TEXT DEFAULT (datetime('now')), + + UNIQUE(wallet_address, token_address, chain_id) +); + +CREATE INDEX IF NOT EXISTS idx_token_balances_wallet ON wallet_token_balances(wallet_address); +CREATE INDEX IF NOT EXISTS idx_token_balances_token ON wallet_token_balances(token_address); +CREATE INDEX IF NOT EXISTS idx_token_balances_chain ON wallet_token_balances(chain_id); diff --git a/worker/types.ts b/worker/types.ts index 1fe028a..3737b49 100644 --- a/worker/types.ts +++ b/worker/types.ts @@ -232,4 +232,95 @@ export interface NetworkGraph { edges: GraphEdge[]; // Current user's connections (for filtering) myConnections: string[]; // User IDs I'm connected to +} + +// ============================================================================= +// Linked Wallet Types (Web3 Integration) +// ============================================================================= + +/** + * Wallet types supported for linking + * - 'eoa': Externally Owned Account (standard wallet) + * - 'safe': Safe (Gnosis Safe) multisig + * - 'hardware': Hardware wallet (via MetaMask bridge) + * - 'contract': Other smart contract wallet + */ +export type WalletType = 'eoa' | 'safe' | 'hardware' | 'contract'; + +/** + * Linked wallet record in the database + */ +export interface LinkedWallet { + id: string; + user_id: string; + wallet_address: string; + wallet_type: WalletType; + chain_id: number; + label: string | null; + signature_message: string; + signature: string; + verified_at: string; + ens_name: string | null; + ens_avatar: string | null; + ens_resolved_at: string | null; + is_primary: number; // SQLite boolean (0 or 1) + is_active: number; // SQLite boolean (0 or 1) + created_at: string; + updated_at: string; + last_used_at: string | null; +} + +/** + * Wallet link token for delayed verification (Safe/contract wallets) + */ +export interface WalletLinkToken { + id: string; + user_id: string; + wallet_address: string; + nonce: string; + token: string; + expires_at: string; + used: number; + created_at: string; +} + +/** + * Cached token balance for a wallet + */ +export interface WalletTokenBalance { + id: string; + wallet_address: string; + token_address: string; + token_type: 'erc20' | 'erc721' | 'erc1155'; + chain_id: number; + balance: string; // String to handle big numbers + last_updated: string; +} + +/** + * API response format for linked wallets + */ +export interface LinkedWalletResponse { + id: string; + address: string; + type: WalletType; + chainId: number; + label: string | null; + ensName: string | null; + ensAvatar: string | null; + isPrimary: boolean; + linkedAt: string; + lastUsedAt: string | null; +} + +/** + * Request body for linking a wallet + */ +export interface WalletLinkRequest { + walletAddress: string; + signature: string; + message: string; + walletType?: WalletType; + chainId?: number; + label?: string; } \ No newline at end of file diff --git a/worker/walletAuth.ts b/worker/walletAuth.ts new file mode 100644 index 0000000..3b7114d --- /dev/null +++ b/worker/walletAuth.ts @@ -0,0 +1,769 @@ +/** + * Wallet Authentication Module + * + * Handles signature verification for linking Ethereum wallets to CryptID accounts. + * Uses EIP-191 personal_sign for EOA wallets. + */ + +import { + Environment, + User, + LinkedWallet, + LinkedWalletResponse, + WalletLinkRequest, + WalletType +} from './types'; +// @ts-ignore - noble packages have proper ESM exports +import { keccak_256 } from '@noble/hashes/sha3.js'; +// @ts-ignore - noble packages have proper ESM exports +import { recoverPublicKey, Signature } from '@noble/secp256k1'; + +// ============================================================================= +// Constants +// ============================================================================= + +// Maximum age for signature timestamps (5 minutes) +const MAX_SIGNATURE_AGE_MS = 5 * 60 * 1000; + +// Maximum wallets per user +const MAX_WALLETS_PER_USER = 10; + +// ============================================================================= +// Message Generation +// ============================================================================= + +/** + * Generate the message that must be signed to link a wallet + */ +export function generateLinkMessage( + username: string, + address: string, + timestamp: string, + nonce: string +): string { + return `Link wallet to CryptID + +Account: ${username} +Wallet: ${address} +Timestamp: ${timestamp} +Nonce: ${nonce} + +This signature proves you own this wallet.`; +} + +/** + * Parse and validate a link message + */ +export function parseLinkMessage(message: string): { + username: string; + address: string; + timestamp: string; + nonce: string; +} | null { + try { + const lines = message.split('\n'); + + // Find the relevant lines + const accountLine = lines.find(l => l.startsWith('Account: ')); + const walletLine = lines.find(l => l.startsWith('Wallet: ')); + const timestampLine = lines.find(l => l.startsWith('Timestamp: ')); + const nonceLine = lines.find(l => l.startsWith('Nonce: ')); + + if (!accountLine || !walletLine || !timestampLine || !nonceLine) { + return null; + } + + return { + username: accountLine.replace('Account: ', '').trim(), + address: walletLine.replace('Wallet: ', '').trim(), + timestamp: timestampLine.replace('Timestamp: ', '').trim(), + nonce: nonceLine.replace('Nonce: ', '').trim(), + }; + } catch { + return null; + } +} + +// ============================================================================= +// Address Utilities +// ============================================================================= + +/** + * Validate an Ethereum address format + */ +export function isValidAddress(address: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(address); +} + +/** + * Checksum an Ethereum address (EIP-55) + * This is a simplified version - in production use viem's getAddress + */ +export function checksumAddress(address: string): string { + // For now, just return lowercase - viem will handle proper checksumming + return address.toLowerCase(); +} + +// ============================================================================= +// Signature Verification +// ============================================================================= + +/** + * Verify an EIP-191 personal_sign signature + * + * This uses the ecrecover approach to recover the signer address from the signature. + * For Cloudflare Workers, we need to implement this without ethers/viem dependencies + * in the worker bundle (they're too large). + * + * Instead, we use a lightweight approach with the Web Crypto API. + */ +export function verifyPersonalSignature( + address: string, + message: string, + signature: string +): boolean { + try { + // The signature should be 65 bytes (130 hex chars + 0x prefix) + if (!signature.startsWith('0x') || signature.length !== 132) { + console.error('Invalid signature format'); + return false; + } + + // For EIP-191 personal_sign, the message is prefixed with: + // "\x19Ethereum Signed Message:\n" + message.length + message + const prefix = `\x19Ethereum Signed Message:\n${message.length}`; + const prefixedMessage = prefix + message; + + // Hash the prefixed message with keccak256 + const messageHash = keccak256(prefixedMessage); + + // Parse signature components + const r = signature.slice(2, 66); + const s = signature.slice(66, 130); + let v = parseInt(signature.slice(130, 132), 16); + + // Normalize v value (some wallets use 0/1, others use 27/28) + if (v < 27) { + v += 27; + } + + // Recover the public key and derive the address + const recoveredAddress = ecrecover(messageHash, v, r, s); + + if (!recoveredAddress) { + return false; + } + + // Compare addresses (case-insensitive) + return recoveredAddress.toLowerCase() === address.toLowerCase(); + } catch (error) { + console.error('Signature verification error:', error); + return false; + } +} + +/** + * Keccak256 hash implementation using @noble/hashes + */ +function keccak256(message: string | Uint8Array): Uint8Array { + if (typeof message === 'string') { + const encoder = new TextEncoder(); + return keccak_256(encoder.encode(message)); + } + return keccak_256(message); +} + +/** + * Convert hex string to Uint8Array + */ +function hexToBytes(hex: string): Uint8Array { + const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex; + const bytes = new Uint8Array(cleanHex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(cleanHex.substr(i * 2, 2), 16); + } + return bytes; +} + +/** + * Convert Uint8Array to hex string + */ +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} + +/** + * Recover address from signature using @noble/secp256k1 + */ +function ecrecover( + messageHash: Uint8Array, + v: number, + r: string, + s: string +): string | null { + try { + // Convert r and s to bytes + const rBytes = hexToBytes(r); + const sBytes = hexToBytes(s); + + // Combine r and s into signature (64 bytes compact format) + const signature = new Uint8Array(64); + signature.set(rBytes, 0); + signature.set(sBytes, 32); + + // Recovery id is v - 27 (for non-EIP-155 signatures) + const recoveryId = v - 27; + + if (recoveryId !== 0 && recoveryId !== 1) { + console.error('Invalid recovery id:', recoveryId); + return null; + } + + // Recover the public key using @noble/secp256k1 v3 API + // The signature needs to include the recovery bit (65 bytes: r || s || v) + const recoveredSig = new Uint8Array(65); + recoveredSig.set(signature, 0); + recoveredSig[64] = recoveryId; + + // prehash: false because we already hashed the message with keccak256 + const pubKeyBytes = recoverPublicKey(recoveredSig, messageHash, { + prehash: false, + }); + + if (!pubKeyBytes) { + return null; + } + + // Address is last 20 bytes of keccak256(pubkey without 0x04 prefix) + // For uncompressed key (65 bytes), skip the first byte (0x04 prefix) + const pubKeyHash = keccak256(pubKeyBytes.slice(1)); + const addressBytes = pubKeyHash.slice(-20); + + return '0x' + bytesToHex(addressBytes); + } catch (error) { + console.error('ecrecover error:', error); + return null; + } +} + +// ============================================================================= +// Database Operations +// ============================================================================= + +/** + * Convert a LinkedWallet database record to API response format + */ +export function walletToResponse(wallet: LinkedWallet): LinkedWalletResponse { + return { + id: wallet.id, + address: wallet.wallet_address, + type: wallet.wallet_type, + chainId: wallet.chain_id, + label: wallet.label, + ensName: wallet.ens_name, + ensAvatar: wallet.ens_avatar, + isPrimary: wallet.is_primary === 1, + linkedAt: wallet.verified_at, + lastUsedAt: wallet.last_used_at, + }; +} + +/** + * Get user by public key (from CryptID auth header) + */ +export async function getUserByPublicKey( + db: D1Database, + publicKey: string +): Promise { + const result = await db.prepare(` + SELECT u.* FROM users u + JOIN device_keys dk ON u.id = dk.user_id + WHERE dk.public_key = ? + `).bind(publicKey).first(); + + return result || null; +} + +/** + * Get all linked wallets for a user + */ +export async function getLinkedWallets( + db: D1Database, + userId: string +): Promise { + const result = await db.prepare(` + SELECT * FROM linked_wallets + WHERE user_id = ? AND is_active = 1 + ORDER BY is_primary DESC, created_at ASC + `).bind(userId).all(); + + return result.results || []; +} + +/** + * Get a specific linked wallet + */ +export async function getLinkedWallet( + db: D1Database, + userId: string, + walletAddress: string +): Promise { + const result = await db.prepare(` + SELECT * FROM linked_wallets + WHERE user_id = ? AND wallet_address = ? AND is_active = 1 + `).bind(userId, walletAddress.toLowerCase()).first(); + + return result || null; +} + +/** + * Check if a wallet is already linked to any account + */ +export async function isWalletLinked( + db: D1Database, + walletAddress: string +): Promise<{ linked: boolean; userId?: string; username?: string }> { + const result = await db.prepare(` + SELECT lw.user_id, u.cryptid_username + FROM linked_wallets lw + JOIN users u ON lw.user_id = u.id + WHERE lw.wallet_address = ? AND lw.is_active = 1 + `).bind(walletAddress.toLowerCase()).first<{ user_id: string; cryptid_username: string }>(); + + if (result) { + return { + linked: true, + userId: result.user_id, + username: result.cryptid_username, + }; + } + + return { linked: false }; +} + +/** + * Count wallets linked to a user + */ +export async function countUserWallets( + db: D1Database, + userId: string +): Promise { + const result = await db.prepare(` + SELECT COUNT(*) as count FROM linked_wallets + WHERE user_id = ? AND is_active = 1 + `).bind(userId).first<{ count: number }>(); + + return result?.count || 0; +} + +/** + * Link a new wallet to a user + */ +export async function linkWallet( + db: D1Database, + userId: string, + request: WalletLinkRequest +): Promise { + const id = crypto.randomUUID(); + const now = new Date().toISOString(); + const walletAddress = request.walletAddress.toLowerCase(); + + // Check if this is the first wallet (make it primary) + const existingCount = await countUserWallets(db, userId); + const isPrimary = existingCount === 0 ? 1 : 0; + + await db.prepare(` + INSERT INTO linked_wallets ( + id, user_id, wallet_address, wallet_type, chain_id, label, + signature_message, signature, verified_at, is_primary, is_active, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?) + `).bind( + id, + userId, + walletAddress, + request.walletType || 'eoa', + request.chainId || 1, + request.label || null, + request.message, + request.signature, + now, + isPrimary, + now, + now + ).run(); + + // Fetch and return the created wallet + const wallet = await db.prepare(` + SELECT * FROM linked_wallets WHERE id = ? + `).bind(id).first(); + + return wallet!; +} + +/** + * Update a linked wallet + */ +export async function updateWallet( + db: D1Database, + userId: string, + walletAddress: string, + updates: { label?: string; isPrimary?: boolean } +): Promise { + const now = new Date().toISOString(); + const address = walletAddress.toLowerCase(); + + // If setting as primary, unset other primaries first + if (updates.isPrimary) { + await db.prepare(` + UPDATE linked_wallets SET is_primary = 0, updated_at = ? + WHERE user_id = ? AND is_active = 1 + `).bind(now, userId).run(); + } + + // Build update query + const setClauses: string[] = ['updated_at = ?']; + const values: (string | number)[] = [now]; + + if (updates.label !== undefined) { + setClauses.push('label = ?'); + values.push(updates.label); + } + + if (updates.isPrimary !== undefined) { + setClauses.push('is_primary = ?'); + values.push(updates.isPrimary ? 1 : 0); + } + + values.push(userId, address); + + await db.prepare(` + UPDATE linked_wallets SET ${setClauses.join(', ')} + WHERE user_id = ? AND wallet_address = ? AND is_active = 1 + `).bind(...values).run(); + + return getLinkedWallet(db, userId, address); +} + +/** + * Unlink (soft-delete) a wallet + */ +export async function unlinkWallet( + db: D1Database, + userId: string, + walletAddress: string +): Promise { + const now = new Date().toISOString(); + const address = walletAddress.toLowerCase(); + + const result = await db.prepare(` + UPDATE linked_wallets SET is_active = 0, updated_at = ? + WHERE user_id = ? AND wallet_address = ? AND is_active = 1 + `).bind(now, userId, address).run(); + + return (result.meta?.changes || 0) > 0; +} + +// ============================================================================= +// Request Handlers +// ============================================================================= + +/** + * Handle POST /api/wallet/link + */ +export async function handleLinkWallet( + request: Request, + env: Environment +): Promise { + try { + const db = env.CRYPTID_DB; + if (!db) { + return jsonResponse({ error: 'Database not configured' }, 503); + } + + // Get authenticated user from public key header + const publicKey = request.headers.get('X-CryptID-PublicKey'); + if (!publicKey) { + return jsonResponse({ error: 'Authentication required' }, 401); + } + + const user = await getUserByPublicKey(db, publicKey); + if (!user) { + return jsonResponse({ error: 'User not found' }, 401); + } + + // Parse request body + const body = await request.json() as WalletLinkRequest; + + // Validate required fields + if (!body.walletAddress || !body.signature || !body.message) { + return jsonResponse({ error: 'Missing required fields: walletAddress, signature, message' }, 400); + } + + // Validate address format + if (!isValidAddress(body.walletAddress)) { + return jsonResponse({ error: 'Invalid wallet address format' }, 400); + } + + // Check wallet count limit + const walletCount = await countUserWallets(db, user.id); + if (walletCount >= MAX_WALLETS_PER_USER) { + return jsonResponse({ error: `Maximum ${MAX_WALLETS_PER_USER} wallets allowed per account` }, 400); + } + + // Check if wallet is already linked + const existingLink = await isWalletLinked(db, body.walletAddress); + if (existingLink.linked) { + if (existingLink.userId === user.id) { + return jsonResponse({ error: 'Wallet already linked to your account' }, 409); + } + return jsonResponse({ error: 'Wallet is linked to another account' }, 409); + } + + // Parse and validate the message + const parsedMessage = parseLinkMessage(body.message); + if (!parsedMessage) { + return jsonResponse({ error: 'Invalid message format' }, 400); + } + + // Validate message matches request + if (parsedMessage.address.toLowerCase() !== body.walletAddress.toLowerCase()) { + return jsonResponse({ error: 'Message wallet address does not match request' }, 400); + } + + if (parsedMessage.username !== user.cryptid_username) { + return jsonResponse({ error: 'Message username does not match authenticated user' }, 400); + } + + // Validate timestamp is recent + const messageTime = new Date(parsedMessage.timestamp).getTime(); + const now = Date.now(); + if (isNaN(messageTime) || now - messageTime > MAX_SIGNATURE_AGE_MS) { + return jsonResponse({ error: 'Signature expired. Please sign a new message.' }, 400); + } + + // Verify signature using proper ecrecover + const signatureValid = verifyPersonalSignature( + body.walletAddress, + body.message, + body.signature + ); + + if (!signatureValid) { + return jsonResponse({ error: 'Signature verification failed' }, 422); + } + + // Link the wallet + const wallet = await linkWallet(db, user.id, body); + + return jsonResponse({ + success: true, + wallet: walletToResponse(wallet), + }, 201); + + } catch (error) { + console.error('Link wallet error:', error); + return jsonResponse({ error: 'Internal server error' }, 500); + } +} + +/** + * Handle GET /api/wallet/list + */ +export async function handleListWallets( + request: Request, + env: Environment +): Promise { + try { + const db = env.CRYPTID_DB; + if (!db) { + return jsonResponse({ error: 'Database not configured' }, 503); + } + + const publicKey = request.headers.get('X-CryptID-PublicKey'); + if (!publicKey) { + return jsonResponse({ error: 'Authentication required' }, 401); + } + + const user = await getUserByPublicKey(db, publicKey); + if (!user) { + return jsonResponse({ error: 'User not found' }, 401); + } + + const wallets = await getLinkedWallets(db, user.id); + + return jsonResponse({ + wallets: wallets.map(walletToResponse), + count: wallets.length, + }); + + } catch (error) { + console.error('List wallets error:', error); + return jsonResponse({ error: 'Internal server error' }, 500); + } +} + +/** + * Handle GET /api/wallet/:address + */ +export async function handleGetWallet( + request: Request, + env: Environment, + address: string +): Promise { + try { + const db = env.CRYPTID_DB; + if (!db) { + return jsonResponse({ error: 'Database not configured' }, 503); + } + + const publicKey = request.headers.get('X-CryptID-PublicKey'); + if (!publicKey) { + return jsonResponse({ error: 'Authentication required' }, 401); + } + + const user = await getUserByPublicKey(db, publicKey); + if (!user) { + return jsonResponse({ error: 'User not found' }, 401); + } + + const wallet = await getLinkedWallet(db, user.id, address); + if (!wallet) { + return jsonResponse({ error: 'Wallet not found' }, 404); + } + + return jsonResponse({ + wallet: walletToResponse(wallet), + }); + + } catch (error) { + console.error('Get wallet error:', error); + return jsonResponse({ error: 'Internal server error' }, 500); + } +} + +/** + * Handle PATCH /api/wallet/:address + */ +export async function handleUpdateWallet( + request: Request, + env: Environment, + address: string +): Promise { + try { + const db = env.CRYPTID_DB; + if (!db) { + return jsonResponse({ error: 'Database not configured' }, 503); + } + + const publicKey = request.headers.get('X-CryptID-PublicKey'); + if (!publicKey) { + return jsonResponse({ error: 'Authentication required' }, 401); + } + + const user = await getUserByPublicKey(db, publicKey); + if (!user) { + return jsonResponse({ error: 'User not found' }, 401); + } + + const body = await request.json() as { label?: string; isPrimary?: boolean }; + + const wallet = await updateWallet(db, user.id, address, body); + if (!wallet) { + return jsonResponse({ error: 'Wallet not found' }, 404); + } + + return jsonResponse({ + success: true, + wallet: walletToResponse(wallet), + }); + + } catch (error) { + console.error('Update wallet error:', error); + return jsonResponse({ error: 'Internal server error' }, 500); + } +} + +/** + * Handle DELETE /api/wallet/:address + */ +export async function handleUnlinkWallet( + request: Request, + env: Environment, + address: string +): Promise { + try { + const db = env.CRYPTID_DB; + if (!db) { + return jsonResponse({ error: 'Database not configured' }, 503); + } + + const publicKey = request.headers.get('X-CryptID-PublicKey'); + if (!publicKey) { + return jsonResponse({ error: 'Authentication required' }, 401); + } + + const user = await getUserByPublicKey(db, publicKey); + if (!user) { + return jsonResponse({ error: 'User not found' }, 401); + } + + const success = await unlinkWallet(db, user.id, address); + if (!success) { + return jsonResponse({ error: 'Wallet not found' }, 404); + } + + return jsonResponse({ + success: true, + message: 'Wallet unlinked', + }); + + } catch (error) { + console.error('Unlink wallet error:', error); + return jsonResponse({ error: 'Internal server error' }, 500); + } +} + +/** + * Handle GET /api/wallet/verify/:address (public endpoint) + */ +export async function handleVerifyWallet( + _request: Request, + env: Environment, + address: string +): Promise { + try { + const db = env.CRYPTID_DB; + if (!db) { + return jsonResponse({ error: 'Database not configured' }, 503); + } + + if (!isValidAddress(address)) { + return jsonResponse({ error: 'Invalid wallet address format' }, 400); + } + + const linkStatus = await isWalletLinked(db, address); + + return jsonResponse({ + linked: linkStatus.linked, + cryptidUsername: linkStatus.username, + }); + + } catch (error) { + console.error('Verify wallet error:', error); + return jsonResponse({ error: 'Internal server error' }, 500); + } +} + +// ============================================================================= +// Utilities +// ============================================================================= + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/worker/worker.ts b/worker/worker.ts index 36aed88..23045c2 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -37,6 +37,14 @@ import { handleListAllUsers, handleCheckUsername, } from "./cryptidAuth" +import { + handleLinkWallet, + handleListWallets, + handleGetWallet, + handleUpdateWallet, + handleUnlinkWallet, + handleVerifyWallet, +} from "./walletAuth" // make sure our sync durable objects are made available to cloudflare export { AutomergeDurableObject } from "./AutomergeDurableObject" @@ -995,6 +1003,32 @@ const router = AutoRouter({ // List all CryptID users (admin only, requires X-Admin-Secret header) .get("/admin/users", handleListAllUsers) + // ============================================================================= + // Wallet Linking API (Web3 Integration) + // ============================================================================= + + // Link a new wallet to the authenticated user's CryptID account + .post("/api/wallet/link", handleLinkWallet) + + // List all wallets linked to the authenticated user + .get("/api/wallet/list", handleListWallets) + + // Check if a wallet address is linked to any CryptID account (public) + .get("/api/wallet/verify/:address", (req, env) => + handleVerifyWallet(req, env, req.params.address)) + + // Get details for a specific linked wallet + .get("/api/wallet/:address", (req, env) => + handleGetWallet(req, env, req.params.address)) + + // Update a linked wallet (label, primary status) + .patch("/api/wallet/:address", (req, env) => + handleUpdateWallet(req, env, req.params.address)) + + // Unlink a wallet from the account + .delete("/api/wallet/:address", (req, env) => + handleUnlinkWallet(req, env, req.params.address)) + // ============================================================================= // User Networking / Social Graph API // ============================================================================= @@ -1714,6 +1748,177 @@ router } }) + // ============================================================================ + // Migration endpoints - for batch converting legacy JSON to Automerge format + // ============================================================================ + + // List all rooms with their size and migration status + .get("/migrate/list", async (_, env) => { + try { + const rooms: Array<{ + roomId: string + hasJson: boolean + hasAutomerge: boolean + shapeCount: number + recordCount: number + needsMigration: boolean + }> = [] + + // List all rooms in R2 + let cursor: string | undefined = undefined + do { + const result = await env.TLDRAW_BUCKET.list({ + prefix: 'rooms/', + cursor, + delimiter: '/' + }) + + // Process each room prefix + for (const prefix of result.delimitedPrefixes || []) { + const roomId = prefix.replace('rooms/', '').replace('/', '') + if (!roomId) continue + + // Check if JSON exists + const jsonObject = await env.TLDRAW_BUCKET.head(`rooms/${roomId}`) + const hasJson = !!jsonObject + + // Check if Automerge binary exists + const automergeObject = await env.TLDRAW_BUCKET.head(`rooms/${roomId}/automerge.bin`) + const hasAutomerge = !!automergeObject + + // Get shape count from JSON if it exists + let shapeCount = 0 + let recordCount = 0 + if (hasJson) { + try { + const jsonData = await env.TLDRAW_BUCKET.get(`rooms/${roomId}`) + if (jsonData) { + const doc = await jsonData.json() as { store?: Record } + if (doc.store) { + recordCount = Object.keys(doc.store).length + shapeCount = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length + } + } + } catch (e) { + console.error(`Error reading room ${roomId}:`, e) + } + } + + rooms.push({ + roomId, + hasJson, + hasAutomerge, + shapeCount, + recordCount, + needsMigration: hasJson && !hasAutomerge + }) + } + + cursor = result.truncated ? result.cursor : undefined + } while (cursor) + + // Sort by shape count descending (largest first) + rooms.sort((a, b) => b.shapeCount - a.shapeCount) + + const summary = { + total: rooms.length, + needsMigration: rooms.filter(r => r.needsMigration).length, + alreadyMigrated: rooms.filter(r => r.hasAutomerge).length, + largeRooms: rooms.filter(r => r.shapeCount > 5000).length + } + + return new Response(JSON.stringify({ summary, rooms }, null, 2), { + headers: { 'Content-Type': 'application/json' } + }) + } catch (error) { + console.error('Migration list failed:', error) + return new Response(JSON.stringify({ + error: 'Failed to list rooms', + message: (error as Error).message + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }) + } + }) + + // Migrate a single room to Automerge format + .post("/migrate/:roomId", async (request, env) => { + const roomId = request.params.roomId + if (!roomId) { + return new Response(JSON.stringify({ error: 'Room ID is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }) + } + + try { + // Import the storage module dynamically + const { AutomergeR2Storage } = await import('./automerge-r2-storage') + const storage = new AutomergeR2Storage(env.TLDRAW_BUCKET) + + // Check if already migrated + const isAutomerge = await storage.isAutomergeFormat(roomId) + if (isAutomerge) { + return new Response(JSON.stringify({ + success: true, + message: 'Room already migrated to Automerge format', + roomId + }), { + headers: { 'Content-Type': 'application/json' } + }) + } + + // Load legacy JSON + const jsonObject = await env.TLDRAW_BUCKET.get(`rooms/${roomId}`) + if (!jsonObject) { + return new Response(JSON.stringify({ + error: 'Room not found', + roomId + }), { + status: 404, + headers: { 'Content-Type': 'application/json' } + }) + } + + const jsonDoc = await jsonObject.json() as { store?: Record; schema?: any } + const recordCount = jsonDoc.store ? Object.keys(jsonDoc.store).length : 0 + const shapeCount = jsonDoc.store ? Object.values(jsonDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0 + + console.log(`🔄 Starting migration for room ${roomId}: ${shapeCount} shapes, ${recordCount} records`) + + // Perform migration + const startTime = Date.now() + const doc = await storage.migrateFromJson(roomId, jsonDoc as any) + const duration = Date.now() - startTime + + if (!doc) { + throw new Error('Migration returned null') + } + + return new Response(JSON.stringify({ + success: true, + message: 'Room migrated successfully', + roomId, + shapeCount, + recordCount, + durationMs: duration + }), { + headers: { 'Content-Type': 'application/json' } + }) + } catch (error) { + console.error(`Migration failed for room ${roomId}:`, error) + return new Response(JSON.stringify({ + error: 'Migration failed', + roomId, + message: (error as Error).message + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }) + } + }) + // Handle scheduled events (cron jobs) export async function scheduled(_event: ScheduledEvent, env: Environment, _ctx: ExecutionContext) { // Cron job triggered