22 KiB
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:
- They want it on every site they visit
- They recommend it to site owners
- Site owners embed the widget to retain users
- More users discover the widget
- 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
-- 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
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
/**
* 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
/**
* 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
-
Password recovery is impossible - If user forgets password, their synced vault is permanently inaccessible. This is a feature, not a bug.
-
No password reset - Account password reset would require vault re-encryption, which requires the old password.
-
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:
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:
- Opens share link
- Page decrypts settings from URL fragment (client-side only)
- Shows preview of shared settings
- 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:
- Delete and restart - User can delete their synced vault and create new one. Loses all settings.
- Local fallback - If local vault still works (different password), offer to re-sync from local.
- 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
-
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)
-
How to handle vault password change?
- Need to re-encrypt and sync new ciphertext
- What if user is on multiple devices during password change?
-
Should we support multiple vaults per account?
- Use case: Different settings for work vs personal
- Complexity: Significant, defer to Phase 2
-
Real-time sync vs polling?
- MVP: Polling (simpler)
- Phase 2: WebSocket for instant sync
-
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