diff --git a/docs/VAULT_SYNC_DESIGN.md b/docs/VAULT_SYNC_DESIGN.md new file mode 100644 index 0000000..a9bfde3 --- /dev/null +++ b/docs/VAULT_SYNC_DESIGN.md @@ -0,0 +1,868 @@ +# Vault Sync Design Document + +Cross-site settings synchronization via optional uncloseai.com account linking. + +**Status:** Proposed +**Author:** Hermes Staff +**Date:** 2026-01-23 + +--- + +## Overview + +### Current State + +The UncloseAI widget uses a per-site encrypted vault stored in localStorage (`vault.js`). Each site embedding the widget has its own isolated vault: + +- User sets up vault on site-a.com with password +- Settings encrypted with AES-256, stored in localStorage +- Session persistence (7-day TTL) for convenience +- No data leaves the user's browser + +### Proposed Enhancement + +Add optional cross-site sync via uncloseai.com accounts. Users who want their settings everywhere can link their vault to an account. Users who prefer local-only storage continue as before. + +### Strategic Goal: Viral Spreading + +Users who enjoy the widget on one site become advocates: +1. They want it on every site they visit +2. They recommend it to site owners +3. Site owners embed the widget to retain users +4. More users discover the widget +5. Cycle repeats + +Cross-site sync is the catalyst that transforms one-site users into multi-site evangelists. + +--- + +## User Flow + +### Flow 1: First-Time User (Local Only) + +``` +1. User visits site-a.com (first time with widget) +2. Widget prompts: "Create a secure vault?" +3. User creates vault with password +4. Settings stored locally in localStorage +5. Widget works normally (local vault only) +``` + +### Flow 2: Local User Sees Sync Prompt + +``` +1. User has been using widget on site-a.com for a while +2. After N sessions OR manual settings access, show prompt: + "Sync your settings across all sites?" + [Link Account] [Not Now] [Don't Ask Again] +3. If "Not Now": dismiss, ask again after more sessions +4. If "Don't Ask Again": store preference, never ask again +5. If "Link Account": proceed to account linking +``` + +**Trigger conditions for sync prompt:** +- User has opened settings modal at least 3 times +- User has been using widget for at least 7 days +- User has not dismissed "Don't Ask Again" +- User does not already have a linked account + +### Flow 3: Account Linking + +``` +1. User clicks "Link Account" +2. Modal shows options: + - "Sign in with email (magic link)" + - "Sign in with GitHub" (optional OAuth) + - "Sign in with Google" (optional OAuth) +3. User chooses email magic link +4. User enters email address +5. Email sent with one-time login link +6. User clicks link, opens uncloseai.com/auth/verify?token=xxx +7. Token validated, session cookie set +8. Redirect back to original site with auth token in URL fragment +9. Widget detects auth token, stores sync credentials +10. Widget uploads encrypted vault to server +11. Success message: "Settings will sync across all sites!" +``` + +### Flow 4: Returning User with Synced Account + +``` +1. User visits site-b.com (new site, same widget) +2. Widget detects no local vault +3. Widget checks for sync credentials (stored in localStorage) +4. If credentials exist: + a. Widget fetches encrypted vault from server + b. User prompted for vault password (still needed for decryption) + c. Settings decrypted locally, vault initialized +5. User has all their settings without manual setup +``` + +### Flow 5: Conflict Resolution + +``` +1. User has synced account, uses widget on site-a.com +2. User changes setting (e.g., switches model) +3. Widget uploads new encrypted vault to server +4. User simultaneously on site-b.com (second tab/device) +5. site-b.com widget has stale local copy +6. On next interaction, widget pulls from server +7. Conflict detected: local timestamp != server timestamp +8. Resolution: Last-write-wins (server version) +9. User sees toast: "Settings updated from another session" +``` + +--- + +## Technical Design + +### Server-Side (uncloseai.com API) + +#### Database Schema + +```sql +-- Users table (for accounts) +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + email_verified BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- OAuth connections (optional) +CREATE TABLE oauth_connections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + provider VARCHAR(50) NOT NULL, -- 'github', 'google' + provider_user_id VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(provider, provider_user_id) +); + +-- Magic link tokens +CREATE TABLE magic_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL, + token_hash VARCHAR(64) NOT NULL, -- SHA-256 of token + expires_at TIMESTAMP NOT NULL, + used BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Encrypted vaults (stores ciphertext only) +CREATE TABLE vaults ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE, + encrypted_data TEXT NOT NULL, -- AES-encrypted JSON blob + version INTEGER DEFAULT 1, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- API keys for widget authentication +CREATE TABLE api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + key_hash VARCHAR(64) NOT NULL, -- SHA-256 of API key + name VARCHAR(100), + last_used_at TIMESTAMP, + expires_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +#### API Endpoints + +``` +POST /api/auth/magic-link + Request: { "email": "user@example.com" } + Response: { "success": true, "message": "Check your email" } + Action: Generate token, send email with link to /auth/verify?token=xxx + +GET /api/auth/verify?token=xxx + Response: { "success": true, "api_key": "vk_xxx...", "user_id": "uuid" } + Action: Validate token, create/get user, generate API key + Notes: API key returned only once, user must save it + +POST /api/auth/oauth/github (optional) + OAuth callback handler for GitHub + +POST /api/auth/oauth/google (optional) + OAuth callback handler for Google + +POST /api/vault/sync + Headers: Authorization: Bearer vk_xxx... + Request: { "encrypted_data": "...", "version": 2 } + Response: { "success": true, "version": 2, "updated_at": "..." } + Action: Store encrypted blob, increment version if changed + Notes: Server NEVER decrypts - stores ciphertext only + +GET /api/vault/sync + Headers: Authorization: Bearer vk_xxx... + Response: { "encrypted_data": "...", "version": 2, "updated_at": "..." } + Action: Return encrypted blob + Notes: Returns 404 if no vault exists for this user + +DELETE /api/vault/sync + Headers: Authorization: Bearer vk_xxx... + Response: { "success": true } + Action: Delete vault (account remains) + +GET /api/vault/version + Headers: Authorization: Bearer vk_xxx... + Response: { "version": 2, "updated_at": "..." } + Action: Quick check for sync (lightweight polling) + +DELETE /api/account + Headers: Authorization: Bearer vk_xxx... + Response: { "success": true } + Action: Delete account and all associated data +``` + +#### Rate Limiting + +``` +/api/auth/magic-link: 5 requests per email per hour +/api/vault/sync POST: 60 requests per minute per user +/api/vault/sync GET: 120 requests per minute per user +/api/vault/version: 300 requests per minute per user +``` + +### Client-Side (vault.js Extensions) + +#### New Constants + +```javascript +const UncloseVault = { + // ... existing constants ... + + // Sync-related localStorage keys + SYNC_KEY: 'uncloseai_sync_credentials', + SYNC_PROMPT_KEY: 'uncloseai_sync_prompt_state', + + // API base URL + SYNC_API_BASE: 'https://uncloseai.com/api', + + // Sync check interval (5 minutes) + SYNC_POLL_INTERVAL_MS: 5 * 60 * 1000, + + // ... rest of existing code ... +}; +``` + +#### New Methods + +```javascript +/** + * Check if sync is enabled and credentials exist + */ +isSyncEnabled() { + const creds = this.getSyncCredentials(); + return creds !== null && creds.apiKey !== null; +} + +/** + * Get stored sync credentials + */ +getSyncCredentials() { + try { + const json = localStorage.getItem(this.SYNC_KEY); + return json ? JSON.parse(json) : null; + } catch (e) { + return null; + } +} + +/** + * Store sync credentials after successful auth + */ +setSyncCredentials(apiKey, userId) { + localStorage.setItem(this.SYNC_KEY, JSON.stringify({ + apiKey, + userId, + linkedAt: new Date().toISOString() + })); +} + +/** + * Clear sync credentials (unlink account) + */ +clearSyncCredentials() { + localStorage.removeItem(this.SYNC_KEY); +} + +/** + * Push local vault to server + * Call after any local save() if sync is enabled + */ +async sync() { + if (!this.isSyncEnabled() || !this.isUnlocked()) { + return { success: false, error: 'Sync not available' }; + } + + const creds = this.getSyncCredentials(); + const vault = this.getVault(); + + try { + const response = await fetch(`${this.SYNC_API_BASE}/vault/sync`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${creds.apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + encrypted_data: vault.encrypted_settings, + version: vault.version || 1 + }) + }); + + if (!response.ok) { + if (response.status === 401) { + // API key invalid, clear credentials + this.clearSyncCredentials(); + return { success: false, error: 'Session expired' }; + } + return { success: false, error: 'Sync failed' }; + } + + const data = await response.json(); + + // Update local version + vault.version = data.version; + vault.synced_at = data.updated_at; + this.saveVault(vault); + + return { success: true, version: data.version }; + } catch (e) { + return { success: false, error: e.message }; + } +} + +/** + * Pull vault from server + * Returns encrypted data that still needs password to decrypt + */ +async pull() { + if (!this.isSyncEnabled()) { + return { success: false, error: 'Sync not enabled' }; + } + + const creds = this.getSyncCredentials(); + + try { + const response = await fetch(`${this.SYNC_API_BASE}/vault/sync`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${creds.apiKey}` + } + }); + + if (!response.ok) { + if (response.status === 404) { + return { success: true, data: null }; // No vault on server yet + } + if (response.status === 401) { + this.clearSyncCredentials(); + return { success: false, error: 'Session expired' }; + } + return { success: false, error: 'Pull failed' }; + } + + const data = await response.json(); + + return { + success: true, + data: { + encrypted_settings: data.encrypted_data, + version: data.version, + updated_at: data.updated_at + } + }; + } catch (e) { + return { success: false, error: e.message }; + } +} + +/** + * Check if server has newer version + */ +async checkForUpdates() { + if (!this.isSyncEnabled()) return false; + + const creds = this.getSyncCredentials(); + const vault = this.getVault(); + + try { + const response = await fetch(`${this.SYNC_API_BASE}/vault/version`, { + headers: { 'Authorization': `Bearer ${creds.apiKey}` } + }); + + if (!response.ok) return false; + + const data = await response.json(); + return data.version > (vault?.version || 0); + } catch (e) { + return false; + } +} + +/** + * Merge server vault with local (last-write-wins) + */ +async syncFromServer(password) { + const result = await this.pull(); + + if (!result.success || !result.data) { + return result; + } + + // Try to decrypt with provided password + const decrypted = this.decrypt(result.data.encrypted_settings, password); + if (!decrypted) { + return { success: false, error: 'Wrong password' }; + } + + // Replace local vault with server version + const vault = { + encrypted_settings: result.data.encrypted_settings, + version: result.data.version, + synced_at: result.data.updated_at, + updated_at: new Date().toISOString() + }; + + this.saveVault(vault); + this._settings = decrypted; + this._password = password; + + window.dispatchEvent(new CustomEvent('uncloseai-vault-synced')); + + return { success: true }; +} +``` + +#### Modified save() Method + +```javascript +/** + * Save current settings to vault (modified for sync) + */ +async save() { + if (!this.isUnlocked()) { + console.error('Cannot save: vault not unlocked'); + return false; + } + + const vault = this.getVault() || {}; + const encrypted = this.encrypt(this._settings, this._password); + + if (!encrypted) { + console.error('Failed to encrypt settings'); + return false; + } + + vault.encrypted_settings = encrypted; + vault.updated_at = new Date().toISOString(); + vault.version = (vault.version || 0) + 1; + this.saveVault(vault); + + // Auto-sync if enabled (non-blocking) + if (this.isSyncEnabled()) { + this.sync().catch(e => console.warn('Sync failed:', e)); + } + + return true; +} +``` + +--- + +## Privacy and Zero-Knowledge Architecture + +### Core Principle + +**The server NEVER has the ability to decrypt user data.** + +``` +User's Browser uncloseai.com Server + | | + | 1. User enters password | + | (never leaves browser) | + | | + | 2. Settings encrypted with | + | password using AES-256 | + | | + | 3. Encrypted blob sent to server | + | POST /vault/sync | + |----------------------------------->| + | | 4. Server stores + | | encrypted blob + | | (ciphertext only) + | | + | 5. On new device, encrypted | + | blob retrieved | + |<-----------------------------------| + | | + | 6. User enters password | + | (decryption happens locally) | + | | +``` + +### What the Server Stores + +``` +{ + "user_id": "uuid", + "encrypted_data": "U2FsdGVkX1+...(opaque ciphertext)...", + "version": 3, + "updated_at": "2026-01-23T10:00:00Z" +} +``` + +### What the Server CANNOT See + +- User's vault password +- Decrypted settings (API keys, model preferences, etc.) +- Any plaintext user data + +### Implications + +1. **Password recovery is impossible** - If user forgets password, their synced vault is permanently inaccessible. This is a feature, not a bug. + +2. **No password reset** - Account password reset would require vault re-encryption, which requires the old password. + +3. **User responsibility** - Users must remember their vault password. We provide clear warnings during setup. + +--- + +## Viral Mechanics + +### "Powered by UncloseAI" Badge + +Optional badge shown in widget corner: + +``` +[ Settings ] [Voice] [Model] Powered by UncloseAI +``` + +Badge behavior: +- Shown only if site owner enables it (opt-in for site owners) +- Or shown if user has free account (non-paying users get badge) +- Clicking opens uncloseai.com in new tab +- Badge can be hidden by site owners who pay/self-host + +### "Get This Widget" Link + +In settings modal footer: + +``` +------------------------------------------ +| Want this AI on your website? | +| [Add to Your Site] - Free, 2 min setup | +------------------------------------------ +``` + +Link goes to: `uncloseai.com/embed?ref={site_domain}` + +Referral tracking: +- Store referring domain in analytics +- Site owner gets credit when their users convert +- Future: referral rewards (free premium features) + +### Share Settings Link + +Users can share their vault (encrypted) via URL: + +```javascript +async generateShareLink() { + if (!this.isUnlocked()) return null; + + // Create one-time export + const exportData = { + settings: this._settings, + exported_at: new Date().toISOString(), + // Exclude sensitive fields + exclude: ['customAPIKey', 'unsandboxSecretKey'] + }; + + const filtered = { ...exportData.settings }; + exportData.exclude.forEach(k => delete filtered[k]); + + // Encrypt with random key + const shareKey = CryptoJS.lib.WordArray.random(16).toString(); + const encrypted = this.encrypt(filtered, shareKey); + + // Encode in URL + const payload = btoa(JSON.stringify({ e: encrypted })); + return `https://uncloseai.com/share#${shareKey}:${payload}`; +} +``` + +Recipient flow: +1. Opens share link +2. Page decrypts settings from URL fragment (client-side only) +3. Shows preview of shared settings +4. User can import into their own vault + +### Referral Program (Future) + +Site owner rewards: +- Each site owner gets referral code +- When new site embeds widget via referral, both get benefits +- Tiered rewards: 5 referrals = X, 10 referrals = Y + +User rewards: +- Users who share widget and drive signups get: + - Extended session duration + - Priority model access + - Early access to new features + +--- + +## MVP vs Full Version + +### MVP (Phase 1) + +**Goal:** Prove the sync concept works and users want it. + +**Scope:** +- Email magic link authentication only +- Basic vault sync (push/pull) +- Last-write-wins conflict resolution +- "Powered by UncloseAI" badge +- "Get This Widget" link + +**Not in MVP:** +- OAuth (GitHub, Google) +- Share settings link +- Referral program +- Analytics for site owners +- Paid tiers + +**Timeline:** 2-3 weeks + +**Success metrics:** +- 100+ accounts created in first month +- 20+ cross-site sync users (used on 2+ domains) +- 10+ new site embeds from "Get This Widget" + +### Full Version (Phase 2) + +**Goal:** Scale viral mechanics and add monetization. + +**Scope:** +- OAuth providers (GitHub, Google) +- Share settings link with preview +- Referral program with tracking +- Site owner analytics dashboard +- Paid tiers (remove badge, priority support) +- Real-time sync (WebSocket) +- Multi-device session management + +**Timeline:** 2-3 months after MVP validation + +--- + +## Security Considerations + +### End-to-End Encryption + +- All encryption/decryption happens in browser using CryptoJS +- Server stores only ciphertext +- No server-side key escrow +- No ability to decrypt without user's password + +### Password Requirements + +- Minimum 8 characters (enforced client-side) +- Encourage but don't require complexity +- Show strength meter in UI +- Clear warning: "This password cannot be recovered" + +### Account Recovery + +**Scenario: User forgets vault password** + +Options: +1. **Delete and restart** - User can delete their synced vault and create new one. Loses all settings. +2. **Local fallback** - If local vault still works (different password), offer to re-sync from local. +3. **No recovery** - By design. Zero-knowledge means no backdoor. + +UI messaging: +``` +Forgot your vault password? + +Your vault is encrypted and cannot be recovered without your password. +This is a security feature, not a bug. + +Options: +[ Delete Vault & Start Fresh ] - Lose all synced settings +[ Cancel ] - Try to remember your password +``` + +### Rate Limiting + +Prevent brute force and abuse: + +``` +Endpoint Limit +------------------------------------------ +POST /auth/magic-link 5/hour/email +POST /vault/sync 60/minute/user +GET /vault/sync 120/minute/user +GET /vault/version 300/minute/user +POST /auth/verify 10/minute/IP +``` + +### API Key Security + +- API keys prefixed with `vk_` for easy identification +- Keys are 32 bytes of random data (256 bits) +- Stored hashed (SHA-256) in database +- Keys can be revoked from account settings +- Keys have optional expiration + +### CORS Policy + +``` +Access-Control-Allow-Origin: * +``` + +Widget runs on any domain, so CORS must be permissive. Security comes from: +- API key authentication +- Rate limiting +- E2E encryption (server can't read data anyway) + +### Magic Link Security + +- Tokens are 32 bytes of random data +- Tokens expire after 15 minutes +- Tokens are single-use +- Token hash stored in database (not plaintext) +- Email delivery via trusted provider (SendGrid, Postmark) + +--- + +## Implementation Checklist + +### Server-Side + +``` +[ ] Database schema (PostgreSQL) +[ ] User model and migrations +[ ] Magic link generation and verification +[ ] Email sending integration (SendGrid) +[ ] Vault CRUD endpoints +[ ] API key generation and validation +[ ] Rate limiting middleware +[ ] CORS configuration +[ ] Error handling and logging +[ ] Health check endpoint +``` + +### Client-Side + +``` +[ ] Sync credentials storage +[ ] sync() method +[ ] pull() method +[ ] checkForUpdates() method +[ ] Modified save() with auto-sync +[ ] Auth flow UI (modal) +[ ] "Link Account" prompt logic +[ ] "Powered by UncloseAI" badge +[ ] "Get This Widget" link +[ ] Sync status indicator +[ ] Error handling and retry logic +[ ] Offline queue for sync operations +``` + +### Testing + +``` +[ ] Unit tests for sync methods +[ ] Integration tests with mock server +[ ] E2E test: full account linking flow +[ ] E2E test: cross-site sync +[ ] E2E test: conflict resolution +[ ] Security audit: encryption verification +[ ] Load testing: rate limits +``` + +### Documentation + +``` +[ ] User guide: setting up sync +[ ] User guide: what happens if I forget my password +[ ] Site owner guide: embedding options +[ ] API documentation (for advanced users) +[ ] Privacy policy updates +``` + +--- + +## Open Questions + +1. **Should vault password be separate from account password?** + - Current design: Yes, vault has its own password + - Alternative: Use account password for vault (simpler UX, less secure) + +2. **How to handle vault password change?** + - Need to re-encrypt and sync new ciphertext + - What if user is on multiple devices during password change? + +3. **Should we support multiple vaults per account?** + - Use case: Different settings for work vs personal + - Complexity: Significant, defer to Phase 2 + +4. **Real-time sync vs polling?** + - MVP: Polling (simpler) + - Phase 2: WebSocket for instant sync + +5. **What settings should be excluded from sync?** + - Definitely exclude: Nothing by default + - User choice: Let users select what syncs + +--- + +## Appendix: Email Templates + +### Magic Link Email + +``` +Subject: Sign in to UncloseAI + +Hi there, + +Click the link below to sign in to your UncloseAI account: + +[Sign In] - https://uncloseai.com/auth/verify?token=xxx + +This link expires in 15 minutes and can only be used once. + +If you didn't request this, you can safely ignore this email. + +- The UncloseAI Team +``` + +### Welcome Email (After First Sync) + +``` +Subject: Your settings are now synced! + +Welcome to UncloseAI! + +Your vault is now synced across all sites. Here's what you can do: + +1. Visit any website with the UncloseAI widget +2. Click "Sync Settings" in the settings menu +3. Enter your vault password +4. Your preferences load automatically! + +Remember: Your vault password is never sent to our servers. +If you forget it, your synced data cannot be recovered. + +Need help? Reply to this email. + +- The UncloseAI Team +```