# 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
```
