diff --git a/BACKEND_INTEGRATION_COMPLETE.md b/BACKEND_INTEGRATION_COMPLETE.md deleted file mode 100644 index 48f75108..00000000 --- a/BACKEND_INTEGRATION_COMPLETE.md +++ /dev/null @@ -1,1876 +0,0 @@ -# Backend API Integration - Complete Documentation - -**Date**: 2026-01-08 -**Status**: ✅ Complete (Phases 1-5) -**Branch**: `feature/backend-api` - ---- - -## Table of Contents - -1. [Overview](#overview) -2. [Architecture](#architecture) -3. [Phase 1: Foundation](#phase-1-foundation) -4. [Phase 2: Auth Flow](#phase-2-auth-flow) -5. [Phase 3: Sync Engine](#phase-3-sync-engine) -6. [Phase 4: Polish & Production Ready](#phase-4-polish--production-ready) -7. [Phase 5: Real E2E Encryption](#phase-5-real-e2e-encryption) -8. [Testing Guide](#testing-guide) -9. [Deployment Checklist](#deployment-checklist) -10. [Troubleshooting](#troubleshooting) - ---- - -## Overview - -This document details the complete integration of the Hono.js backend API with the Readied Electron desktop app, enabling: - -- **Authentication**: Passwordless magic link authentication via email -- **Synchronization**: End-to-end encrypted bidirectional sync between devices -- **Conflict Resolution**: Automatic conflict detection with user-driven resolution -- **Subscription Management**: Pro tier features with Stripe integration (UI ready) -- **Security**: AES-256-GCM encryption with OS-level key storage - -### What Was Built - -**New Services (Main Process):** - -- `TokenStorage` - Secure JWT token management using Electron safeStorage -- `DeviceInfo` - Device identification and metadata -- `ApiClient` - HTTP client with auto token refresh and retry logic -- `EncryptionService` - AES-256-GCM encryption for note content -- `SyncService` - Bidirectional sync orchestration with conflict detection - -**New Stores (Renderer Process):** - -- `authStore` - Authentication state management (Zustand) -- `syncStore` - Sync state management (Zustand) -- `settings` - Settings persistence (localStorage) - -**New UI Components:** - -- `AccountSection` - Account management and sync controls -- `MagicLinkFlow` - Magic link authentication dialog -- `SyncStatusIndicator` - Real-time sync status in sidebar -- `ConflictResolver` - Conflict resolution UI -- `BackupSection` - Data backup/restore - -**New IPC Handlers:** - -- `auth:*` - Authentication operations -- `sync:*` - Sync operations -- `subscription:*` - Subscription management -- `encryption:*` - Encryption key management - ---- - -## Architecture - -### System Overview - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Electron Desktop App │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ Renderer Process Main Process │ -│ ┌──────────────┐ ┌──────────────┐ │ -│ │ │ │ │ │ -│ │ authStore │◄────IPC─────────►│ TokenStorage │ │ -│ │ syncStore │ │ ApiClient │ │ -│ │ │ │ SyncService │ │ -│ │ │ │ EncryptionSvc│ │ -│ │ │ │ │ │ -│ └──────────────┘ └──────────────┘ │ -│ │ │ -└────────────────────────────────────────────┼────────────────┘ - │ - HTTPS/JWT - │ - ▼ - ┌────────────────────┐ - │ Backend API │ - │ (Hono.js) │ - ├────────────────────┤ - │ /auth/* │ - │ /sync/* │ - │ /subscription/* │ - └────────────────────┘ - │ - ┌───────────────────┼───────────────────┐ - │ │ │ - ▼ ▼ ▼ - ┌──────────┐ ┌──────────┐ ┌──────────┐ - │ Turso │ │ Resend │ │ Stripe │ - │ (libSQL) │ │ (Email) │ │(Payments)│ - └──────────┘ └──────────┘ └──────────┘ -``` - -### Data Flow - -**Authentication Flow:** - -``` -1. User enters email → authStore.requestMagicLink() -2. Renderer → IPC → Main → ApiClient.requestMagicLink() -3. Backend sends email via Resend -4. User clicks link (readied://auth/verify?token=xxx) -5. Deep link handler → authStore.verifyToken() -6. Main → ApiClient.verifyMagicLink() → Save tokens via TokenStorage -7. Auto-start sync timer (5 minutes) -``` - -**Sync Flow:** - -``` -1. Auto-sync timer triggers OR manual sync button -2. syncStore.syncNow() → IPC → SyncService.syncNow() -3. PULL: ApiClient.pullChanges(cursor) → Backend -4. Decrypt changes → Apply to local DB -5. Detect conflicts (same note, different device, different version) -6. PUSH: Collect local changes → Encrypt → ApiClient.pushChanges() -7. Update cursor, lastSyncAt -8. Show conflicts in UI if any -``` - -**Encryption Flow:** - -``` -1. On first launch: Generate random 256-bit key -2. Encrypt key using Electron safeStorage (OS keychain) -3. Save encrypted key to {userData}/encryption.key -4. For each note sync: - - Encrypt: plaintext → AES-256-GCM → iv:ciphertext:authTag - - Backend stores encrypted blob (server can't read content) - - Decrypt on pull: iv:ciphertext:authTag → AES-256-GCM → plaintext -``` - ---- - -## Phase 1: Foundation - -**Goal**: Core infrastructure for HTTP communication, token storage, and state management. - -### Files Created - -#### Main Process Services - -**`apps/desktop/src/main/services/tokenStorage.ts` (~100 LOC)** - -```typescript -export class TokenStorage { - private readonly tokenPath: string; - - async saveTokens(accessToken: string, refreshToken: string): Promise; - async getTokens(): Promise; - async clearTokens(): Promise; - async hasTokens(): Promise; -} -``` - -- **Purpose**: Secure storage of JWT tokens -- **Security**: Uses Electron `safeStorage` API (OS keychain/DPAPI/libsecret) -- **File**: `{userData}/auth.encrypted` (binary encrypted file) -- **Format**: JSON with `{ accessToken, refreshToken }` encrypted - -**`apps/desktop/src/main/services/deviceInfo.ts` (~80 LOC)** - -```typescript -export interface DeviceInfo { - deviceId: string; - name: string; - platform: string; -} - -export async function getOrCreateDeviceInfo(dataDir: string): Promise; -``` - -- **Purpose**: Generate and persist unique device identifier -- **File**: `{userData}/device.json` -- **Device ID**: UUID v4 -- **Device Name**: OS hostname -- **Platform**: darwin/win32/linux - -**`apps/desktop/src/main/services/apiClient.ts` (~330 LOC)** - -```typescript -export class ApiClient { - constructor( - private readonly baseUrl: string, - private readonly tokenStorage: TokenStorage, - private readonly deviceInfo: DeviceInfo - ) - - // Core - private async request(endpoint: string, options?: RequestInit): Promise - async refreshAccessToken(): Promise - - // Auth endpoints - async requestMagicLink(email: string): Promise - async verifyMagicLink(token: string): Promise - async getCurrentUser(): Promise - - // Sync endpoints - async pullChanges(cursor: number, limit?: number): Promise - async pushChanges(changes: Array<...>): Promise - async getSyncStatus(): Promise - - // Subscription endpoints - async getSubscriptionStatus(): Promise - async createPortalSession(returnUrl: string): Promise<{ url: string }> -} -``` - -- **Purpose**: Centralized HTTP client for all backend communication -- **Features**: - - Automatic token refresh on 401 - - Retry logic (3 attempts with exponential backoff) - - Timeout handling (30s default) - - Device ID in all requests -- **Base URL**: `process.env.READIED_API_URL || 'http://localhost:8787'` - -#### Renderer Process Stores - -**`apps/desktop/src/renderer/stores/settings.ts` (~80 LOC)** - -```typescript -interface SettingsState { - backup: { lastBackupAt: number | null }; - sync: { - enabled: boolean; - autoSyncInterval: number; - lastSyncAt: number | null; - }; - - updateBackup: (backup: Partial) => void; - updateSync: (sync: Partial) => void; -} - -export const useSettingsStore = create()( - persist((set) => ({ ... }), { name: 'readied-settings' }) -) -``` - -- **Purpose**: Persist app settings to localStorage -- **Storage**: `localStorage['readied-settings']` -- **Missing**: This file was referenced but didn't exist - created in Phase 1 - -**`apps/desktop/src/renderer/stores/authStore.ts` (~160 LOC)** - -```typescript -interface AuthState { - user: User | null; - isAuthenticated: boolean; - isLoading: boolean; - error: string | null; - - requestMagicLink: (email: string) => Promise; - verifyToken: (token: string) => Promise; - logout: () => Promise; - loadSession: () => Promise; - clearError: () => void; -} - -export const useAuthStore = create()((set) => ({ ... })) -``` - -- **Purpose**: Manage authentication state and actions -- **Actions**: Request magic link, verify token, logout, load session -- **Auto-sync**: Triggers `startAutoSync()` on successful auth - -**`apps/desktop/src/renderer/stores/syncStore.ts` (~150 LOC)** - -```typescript -export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline'; - -interface Conflict { - noteId: string; - localContent: string; - remoteContent: string; - localVersion: number; - remoteVersion: number; - timestamp: string; -} - -interface SyncState { - status: SyncStatus; - cursor: number; - lastSyncAt: number | null; - conflicts: Conflict[]; - error: string | null; - isEnabled: boolean; - - syncNow: () => Promise; - resolveConflict: (noteId: string, resolution: 'local' | 'remote') => Promise; - clearError: () => void; - setEnabled: (enabled: boolean) => void; - updateLastSyncAt: (timestamp: number) => void; -} -``` - -- **Purpose**: Manage sync state and operations -- **Conflicts**: Stores conflicts for user resolution -- **Status**: Tracks sync status (idle/syncing/error/offline) - -### Files Modified - -**`apps/desktop/src/main/index.ts` (+400 LOC)** - -- Added `initAuthSync()` function to initialize services -- Instantiated `TokenStorage`, `DeviceInfo`, `ApiClient` -- Registered `registerAuthSyncHandlers()` function -- Added IPC handlers for `auth:*` and `sync:*` operations - -**`apps/desktop/src/preload/index.ts` (+150 LOC)** - -- Added type definitions for API responses -- Extended `ReadiedAPI` interface with `auth`, `sync`, `subscription` sections -- Implemented IPC invocations for all new handlers - -**`apps/desktop/package.json`** - -- Added dependency: `"cross-fetch": "^4.1.0"` - -### Key Design Decisions - -1. **Token Storage Security**: Using Electron safeStorage ensures tokens are encrypted at rest using OS-level APIs -2. **Centralized HTTP Client**: Single ApiClient class handles all HTTP logic, avoiding duplication -3. **Automatic Token Refresh**: On 401, automatically refresh token and retry request transparently -4. **Device Identification**: Persistent UUID ensures consistent device tracking across sessions - ---- - -## Phase 2: Auth Flow - -**Goal**: Implement magic link authentication with UI components. - -### Files Created - -#### UI Components - -**`apps/desktop/src/renderer/pages/settings/components/SettingGroup.tsx` (~40 LOC)** - -```typescript -export function SettingGroup({ title, children }: SettingGroupProps); -``` - -- **Purpose**: Reusable collapsible section for settings -- **Styling**: `SettingGroup.module.css` - -**`apps/desktop/src/renderer/pages/settings/components/SettingRow.tsx` (~50 LOC)** - -```typescript -export function SettingRow({ label, description, children }: SettingRowProps); -``` - -- **Purpose**: Individual setting row with label, description, and action -- **Styling**: `SettingRow.module.css` - -**`apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx` (~175 LOC)** - -```typescript -export function AccountSection(); -``` - -- **Features**: - - Sign in button (opens MagicLinkFlow) - - Shows email when authenticated - - Sign out button - - Manual sync button with last sync timestamp - - Sync status indicator (offline warning) - - Conflict resolver integration -- **State**: Uses `useAuthStore()` and `useSyncStore()` - -**`apps/desktop/src/renderer/components/auth/MagicLinkFlow.tsx` (~165 LOC)** - -```typescript -type Step = 'email' | 'sent' | 'verifying' | 'success' | 'error'; - -export function MagicLinkFlow({ onSuccess, onCancel }: MagicLinkFlowProps); -``` - -- **Flow**: - 1. **Email Step**: Input field for email address - 2. **Sent Step**: "Check your email" confirmation - 3. **Verifying Step**: Loading state (shown on deep link) - 4. **Success Step**: "Welcome back!" (auto-closes) - 5. **Error Step**: Error message with retry button -- **Styling**: `MagicLinkFlow.module.css` (modal overlay, animations) - -### Files Modified - -**`apps/desktop/src/renderer/pages/settings/SettingsApp.tsx`** - -- Added `AccountSection` import and render -- Updated `SettingsSection` type to include `'account'` -- Added account section to sidebar navigation - -**`apps/desktop/src/renderer/pages/settings/sections/BackupSection.tsx`** - -- Fixed imports to use new `SettingGroup` and `SettingRow` components -- Fixed property names: `result.path` instead of `result.outputPath` -- Fixed type checks: removed invalid `cancelled` property - -**`apps/desktop/src/renderer/pages/settings/sections/Section.module.css`** - -- Added button styles: `primaryButton`, `dangerButton`, `secondaryButton` -- Added status badge styles -- Added message styles: `successMessage`, `infoMessage`, `errorMessage` -- Added `spinning` animation for loading states - -**`apps/desktop/src/renderer/App.tsx`** - -- Added `useAuthStore` import -- Added `loadSession()` call in `useEffect` on mount -- Ensures session is restored on app launch - -### Authentication Flow Detail - -**1. Request Magic Link:** - -```typescript -// User enters email in MagicLinkFlow -await useAuthStore.getState().requestMagicLink('user@example.com'); -// → IPC → ApiClient.requestMagicLink() -// → POST /auth/magic-link { email, deviceId, deviceName } -// → Backend generates token, sends email via Resend -// → Email contains link: readied://auth/verify?token=xxx -``` - -**2. Verify Token (Deep Link):** - -```typescript -// User clicks link in email -// OS opens app with readied://auth/verify?token=xxx -// Main process receives deep link event -// → Sends IPC event: 'auth:verify-token' with token -// → Renderer calls useAuthStore.getState().verifyToken(token) -// → IPC → ApiClient.verifyMagicLink(token) -// → POST /auth/verify { token, deviceId } -// → Backend validates token, returns user + JWT tokens -// → TokenStorage.saveTokens(accessToken, refreshToken) -// → Auth complete, start auto-sync -``` - -**3. Load Session (App Launch):** - -```typescript -// On app launch, App.tsx calls: -useAuthStore.getState().loadSession(); -// → IPC → Check TokenStorage.hasTokens() -// → If tokens exist: ApiClient.getCurrentUser() -// → GET /auth/me (with JWT in Authorization header) -// → Returns user data -// → Start auto-sync -``` - -**4. Logout:** - -```typescript -useAuthStore.getState().logout(); -// → Stop auto-sync timer -// → IPC → TokenStorage.clearTokens() -// → Clear auth state -``` - ---- - -## Phase 3: Sync Engine - -**Goal**: Bidirectional sync with conflict detection and resolution. - -### Files Created - -**`apps/desktop/src/main/services/encryptionService.ts` (~200 LOC)** - -```typescript -export class EncryptionService { - private key: Buffer | null = null; - private readonly keyPath: string; - - constructor(dataDir: string); - async initialize(): Promise; - - async encrypt(plaintext: string): Promise; - async decrypt(ciphertext: string): Promise; - isEncrypted(content: string): boolean; - - exportKey(): string; - async importKey(keyHex: string): Promise; -} -``` - -- **Algorithm**: AES-256-GCM (implemented in Phase 5) -- **Key Storage**: `{userData}/encryption.key` (encrypted with safeStorage) -- **Format**: `{iv}:{ciphertext}:{authTag}` (base64 encoded) - -**`apps/desktop/src/main/services/syncService.ts` (~400 LOC)** - -```typescript -export class SyncService { - private cursor: number = 0; - private lastSyncAt: number | null = null; - private isSyncing: boolean = false; - private autoSyncTimer: NodeJS.Timeout | null = null; - - async pull(): Promise - async push(changes: Array<...>): Promise - async syncNow(): Promise - async resolveConflict(noteId: string, resolution: 'local' | 'remote'): Promise - - startAutoSync(intervalMs?: number): void - stopAutoSync(): void - getState(): SyncState -} -``` - -- **Purpose**: Orchestrates sync operations -- **Auto-sync**: Timer-based automatic sync (default 5 minutes) -- **Conflict Detection**: Compares local and remote versions -- **Conflict Resolution**: Creates copy with timestamp, applies chosen version - -**Sync Logic Detail:** - -**Pull Changes:** - -```typescript -async pull(): Promise { - // 1. Get changes from server - const response = await apiClient.pullChanges(this.cursor); - - // 2. For each change: - for (const change of response.changes) { - // Decrypt content - const plaintext = await encryptionService.decrypt(change.encryptedData); - - // Check for conflict - const localNote = await noteRepository.getNoteById(change.noteId); - if (localNote && - localNote.version < change.version && - localNote.deviceId !== change.deviceId) { - // CONFLICT: Note changed on both devices - conflicts.push({ - noteId: change.noteId, - localContent: localNote.content, - remoteContent: plaintext, - localVersion: localNote.version, - remoteVersion: change.version, - timestamp: new Date().toISOString() - }); - - // Create conflict copy - const conflictTitle = `${localNote.title} (Conflict ${Date.now()})`; - await noteRepository.createNote({ - content: localNote.content, - title: conflictTitle, - // ... copy metadata - }); - } - - // Apply remote change - await applyChange(change, plaintext); - } - - // 3. Update cursor - this.cursor = response.cursor; - this.lastSyncAt = Date.now(); - - return { success: true, changes, conflicts, cursor, hasMore }; -} -``` - -**Push Changes:** - -```typescript -async push(changes: Array<...>): Promise { - // 1. Collect local changes (notes modified since last sync) - const localChanges = await collectLocalChanges(); - - // 2. Encrypt each change - const encryptedChanges = await Promise.all( - localChanges.map(async (change) => { - const encrypted = await encryptionService.encrypt(change.content); - return { - noteId: change.noteId, - operation: change.operation, - encryptedData: encrypted, - version: change.version, - deviceId: this.deviceInfo.deviceId - }; - }) - ); - - // 3. Send to server - const response = await apiClient.pushChanges(encryptedChanges); - - // 4. Handle conflicts from server - for (const result of response.results) { - if (result.status === 'conflict') { - // Server detected conflict, add to conflicts list - conflicts.push(...); - } - } - - return { success: true, results: response.results }; -} -``` - -**Full Sync Cycle:** - -```typescript -async syncNow(): Promise { - // 1. Pull changes from server - const pullResult = await this.pull(); - - // 2. Push local changes to server - const pushResult = await this.push([]); - - // 3. Return combined result - return { - success: true, - changesApplied: pullResult.changes.length, - changesPushed: pushResult.results.length, - conflicts: [...pullResult.conflicts, ...pushResult.conflicts] - }; -} -``` - -**`apps/desktop/src/renderer/components/sync/ConflictResolver.tsx` (~180 LOC)** - -```typescript -export function ConflictResolver(); -``` - -- **Purpose**: UI for resolving sync conflicts -- **Features**: - - Expandable list of conflicts - - Side-by-side diff view (local vs remote) - - Version numbers displayed - - "Keep Local" / "Keep Remote" buttons - - Auto-removes conflict after resolution -- **Styling**: `ConflictResolver.module.css` (grid layout, diff styles) - -### Files Modified - -**`apps/desktop/src/main/index.ts`** - -- Initialize `EncryptionService` and `SyncService` in `initAuthSync()` -- Added IPC handlers: - - `sync:pull` - Pull changes from server - - `sync:push` - Push changes to server - - `sync:syncNow` - Full sync cycle - - `sync:status` - Get sync status - - `sync:resolveConflict` - Resolve a conflict - - `sync:startAutoSync` - Start auto-sync timer - - `sync:stopAutoSync` - Stop auto-sync timer - -**`apps/desktop/src/preload/index.ts`** - -- Added sync methods to API: - - `pull()`, `push()`, `syncNow()`, `status()` - - `resolveConflict()`, `startAutoSync()`, `stopAutoSync()` - -**`apps/desktop/src/renderer/stores/syncStore.ts`** - -- Updated `syncNow()` to call IPC handler -- Added error handling with user-friendly messages -- Added `resolveConflict()` implementation - -**`apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx`** - -- Added sync button with loading state -- Added last sync timestamp display -- Integrated `` component -- Added offline status warning - -### Conflict Resolution Strategy - -**Detection:** - -- Conflict occurs when: - 1. Note exists locally AND remotely - 2. Both versions modified since last sync - 3. Modifications from different devices - 4. Local version < remote version - -**Automatic Handling:** - -1. Create copy of local version: `{title} (Conflict {timestamp})` -2. Apply remote version to original note -3. Add conflict to `syncStore.conflicts` array -4. Show conflict resolver UI - -**User Resolution:** - -1. User reviews both versions in ConflictResolver -2. User chooses "Keep Local" or "Keep Remote" -3. Chosen version applied to original note -4. Conflict removed from list -5. Other version remains as the conflict copy (user can delete manually) - ---- - -## Phase 4: Polish & Production Ready - -**Goal**: Error handling, deep links, sync status indicator, and final polish. - -### Features Implemented - -#### 1. Auto-sync on Authentication - -**`apps/desktop/src/renderer/stores/authStore.ts`** - -- `verifyToken()`: Start auto-sync after successful authentication -- `loadSession()`: Start auto-sync if session exists -- `logout()`: Stop auto-sync before clearing tokens - -```typescript -// After successful authentication -await window.readied.sync.startAutoSync(5 * 60 * 1000); // 5 minutes - -// Before logout -await window.readied.sync.stopAutoSync(); -``` - -#### 2. Sync Status Indicator - -**Files Created:** - -**`apps/desktop/src/renderer/components/sync/SyncStatusIndicator.tsx` (~90 LOC)** - -```typescript -export function SyncStatusIndicator(); -``` - -- **Purpose**: Real-time sync status in sidebar header -- **States**: - - **Syncing**: Spinning RefreshCw icon (blue) - - **Idle**: CheckCircle icon (green) + "Synced Xm ago" - - **Error**: AlertCircle icon (red) + "Sync failed" - - **Offline**: CloudOff icon (gray) + "Offline" -- **Features**: - - Tooltip on hover with details - - Relative time formatting (just now, 5m ago, 2h ago, 3d ago) - - Only visible when authenticated -- **Styling**: `SyncStatusIndicator.module.css` - -**Files Modified:** - -**`apps/desktop/src/renderer/components/sidebar/SidebarHeader.tsx`** - -- Added `` component -- Positioned next to settings button - -#### 3. Deep Link Handler (readied:// protocol) - -**`apps/desktop/src/main/index.ts`** - -**Protocol Registration:** - -```typescript -protocol.registerSchemesAsPrivileged([ - // ... existing asset protocol - { - scheme: 'readied', - privileges: { - secure: true, - standard: true, - }, - }, -]); -``` - -**Deep Link Handler (macOS):** - -```typescript -app.on('open-url', (event, url) => { - event.preventDefault(); - const log = getLogger(); - log.info({ url }, 'Deep link received'); - - try { - const urlObj = new URL(url); - - // Handle auth verification: readied://auth/verify?token=xxx - if (urlObj.hostname === 'auth' && urlObj.pathname === '/verify') { - const token = urlObj.searchParams.get('token'); - if (token) { - // Send token to renderer process - const mainWin = BrowserWindow.getAllWindows().find(win => !win.isDestroyed()); - if (mainWin) { - mainWin.webContents.send('auth:verify-token', token); - mainWin.show(); - mainWin.focus(); - } - } - } - } catch (error) { - log.error({ error }, 'Failed to parse deep link URL'); - } -}); -``` - -**Protocol Client Registration (Windows/Linux):** - -```typescript -// Register as default protocol client -if (process.defaultApp) { - if (process.argv.length >= 2 && process.argv[1]) { - app.setAsDefaultProtocolClient('readied', process.execPath, [process.argv[1]]); - } -} else { - app.setAsDefaultProtocolClient('readied'); -} -``` - -**IPC Event Listener:** - -**`apps/desktop/src/preload/index.ts`** - -```typescript -ipc: { - on: (channel: string, listener: (...args: unknown[]) => void) => { - ipcRenderer.on(channel, (_event, ...args) => listener(...args)); - return () => { - ipcRenderer.removeAllListeners(channel); - }; - }, -} -``` - -**`apps/desktop/src/renderer/App.tsx`** - -```typescript -// Handle deep link auth verification -useEffect(() => { - const handleAuthVerification = async (...args: unknown[]) => { - const token = args[0] as string; - if (!token) return; - - try { - await useAuthStore.getState().verifyToken(token); - } catch (error) { - console.error('Deep link auth verification failed:', error); - } - }; - - // Listen for deep link auth verification events - const removeListener = window.readied.ipc.on('auth:verify-token', handleAuthVerification); - - return () => { - removeListener(); - }; -}, []); -``` - -#### 4. Enhanced Error Handling - -**User-Friendly Error Messages:** - -**Auth Errors (`authStore.ts`):** - -- Network errors → "No internet connection. Check your network and try again." -- Timeouts → "Connection timeout. Please try again." -- Rate limits → "Too many requests. Please wait a moment and try again." -- Expired tokens → "This link has expired or is invalid. Please request a new one." -- Device limits → "Device limit reached. Remove a device to continue." - -**Sync Errors (`syncStore.ts`):** - -- Network/offline → "No internet connection. Sync will resume when online." -- 401 errors → "Session expired. Please sign in again." -- 403 errors → "Sync requires Pro subscription." -- 429 errors → "Too many requests. Please wait a moment." -- 500 errors → "Server error. Please try again later." -- Note not found → "Note not found. It may have been deleted." (auto-removes conflict) - -**Error Detection Logic:** - -```typescript -async syncNow() { - try { - // ... sync logic - } catch (error) { - let errorMessage = 'Sync failed'; - let status: SyncStatus = 'error'; - - if (error instanceof Error) { - const msg = error.message.toLowerCase(); - if (msg.includes('network') || msg.includes('fetch') || msg.includes('enotfound')) { - errorMessage = 'No internet connection. Sync will resume when online.'; - status = 'offline'; - } else if (msg.includes('unauthorized') || msg.includes('401')) { - errorMessage = 'Session expired. Please sign in again.'; - } else if (msg.includes('forbidden') || msg.includes('403')) { - errorMessage = 'Sync requires Pro subscription.'; - } - // ... more error cases - } - - set({ status, error: errorMessage }); - throw error; - } -} -``` - -**Error Display:** - -**`apps/desktop/src/renderer/components/auth/MagicLinkFlow.tsx`** - -- Error step shows user-friendly message -- Retry button to start over -- Automatically uses error from `authStore.error` - -**`apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx`** - -- Success/error messages displayed below actions -- Sync error shown in red -- Offline warning shown when status is 'offline' - -#### 5. Build and Testing - -**Fixed Lint Errors:** - -- Unused error variables → Prefixed with `_error` -- Unused imports → Removed - -**Build Results:** - -- ✅ All packages build successfully -- ✅ TypeScript compilation passes -- ✅ Main bundle: 2,283.74 kB -- ✅ Renderer bundle: 2,228.26 kB -- ✅ Preload bundle: 6.77 kB - ---- - -## Phase 5: Real E2E Encryption - -**Goal**: Replace placeholder base64 encoding with production-grade AES-256-GCM encryption. - -### Encryption Implementation - -**`apps/desktop/src/main/services/encryptionService.ts` (Complete Rewrite)** - -**Key Features:** - -- **Algorithm**: AES-256-GCM (Galois/Counter Mode) -- **Key Size**: 256 bits (32 bytes) -- **IV Size**: 96 bits (12 bytes) - recommended for GCM -- **Authentication**: GCM auth tag (128 bits) -- **Format**: `{iv}:{ciphertext}:{authTag}` (base64 encoded) - -**Security Properties:** - -- ✅ **Confidentiality**: AES-256 encryption -- ✅ **Integrity**: GCM authentication tag prevents tampering -- ✅ **Uniqueness**: Random IV for each encryption -- ✅ **Non-deterministic**: Same plaintext → different ciphertext - -**Implementation:** - -```typescript -import { randomBytes, createCipheriv, createDecipheriv } from 'crypto'; -import { join } from 'path'; -import { readFile, writeFile } from 'fs/promises'; -import { existsSync } from 'fs'; -import { safeStorage } from 'electron'; - -const ALGORITHM = 'aes-256-gcm'; -const IV_LENGTH = 12; // 96 bits -const KEY_LENGTH = 32; // 256 bits - -export class EncryptionService { - private key: Buffer | null = null; - private readonly keyPath: string; - - constructor(dataDir: string) { - this.keyPath = join(dataDir, 'encryption.key'); - } - - async initialize(): Promise { - if (this.key) return; // Already initialized - - try { - // Try to load existing key - if (existsSync(this.keyPath)) { - const encryptedKey = await readFile(this.keyPath); - const keyBuffer = safeStorage.decryptString(encryptedKey); - this.key = Buffer.from(keyBuffer, 'hex'); - } else { - // Generate new key - await this.generateKey(); - } - } catch (error) { - throw new Error(`Failed to initialize encryption: ${error.message}`); - } - } - - private async generateKey(): Promise { - // Generate random 256-bit key - this.key = randomBytes(KEY_LENGTH); - - // Encrypt key using OS keychain - const keyHex = this.key.toString('hex'); - const encryptedKey = safeStorage.encryptString(keyHex); - - // Save encrypted key to disk - await writeFile(this.keyPath, encryptedKey); - } - - async encrypt(plaintext: string): Promise { - if (!this.key) throw new Error('Encryption service not initialized'); - - // Generate random IV - const iv = randomBytes(IV_LENGTH); - - // Create cipher - const cipher = createCipheriv(ALGORITHM, this.key, iv); - - // Encrypt - const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]); - - // Get authentication tag - const authTag = cipher.getAuthTag(); - - // Format: iv:ciphertext:authTag (all base64) - return [iv.toString('base64'), encrypted.toString('base64'), authTag.toString('base64')].join( - ':' - ); - } - - async decrypt(ciphertext: string): Promise { - if (!this.key) throw new Error('Encryption service not initialized'); - - // Parse format - const parts = ciphertext.split(':'); - if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) { - throw new Error('Invalid encrypted format'); - } - - const iv = Buffer.from(parts[0], 'base64'); - const encrypted = Buffer.from(parts[1], 'base64'); - const authTag = Buffer.from(parts[2], 'base64'); - - // Create decipher - const decipher = createDecipheriv(ALGORITHM, this.key, iv); - decipher.setAuthTag(authTag); - - // Decrypt - const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); - - return decrypted.toString('utf-8'); - } - - isEncrypted(content: string): boolean { - try { - const parts = content.split(':'); - if (parts.length !== 3) return false; - - // Validate all parts are valid base64 - for (const part of parts) { - Buffer.from(part, 'base64'); - } - return true; - } catch { - return false; - } - } - - exportKey(): string { - if (!this.key) throw new Error('Encryption service not initialized'); - return this.key.toString('hex'); - } - - async importKey(keyHex: string): Promise { - this.key = Buffer.from(keyHex, 'hex'); - - // Save imported key - const encryptedKey = safeStorage.encryptString(keyHex); - await writeFile(this.keyPath, encryptedKey); - } -} -``` - -### Key Management - -**IPC Handlers (`apps/desktop/src/main/index.ts`):** - -```typescript -// Export encryption key (for backup) -ipcMain.handle('encryption:exportKey', async () => { - try { - if (!encryptionService) { - throw new Error('Encryption service not initialized'); - } - const keyHex = encryptionService.exportKey(); - return { success: true, key: keyHex }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to export encryption key', - }; - } -}); - -// Import encryption key (for restore) -ipcMain.handle('encryption:importKey', async (_event, keyHex: string) => { - try { - if (!encryptionService) { - throw new Error('Encryption service not initialized'); - } - await encryptionService.importKey(keyHex); - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to import encryption key', - }; - } -}); -``` - -**Preload API (`apps/desktop/src/preload/index.ts`):** - -```typescript -encryption: { - /** Export encryption key for backup */ - exportKey: () => Promise<{ success: boolean; key?: string; error?: string }>; - /** Import encryption key from backup */ - importKey: (keyHex: string) => Promise<{ success: boolean; error?: string }>; -} -``` - -**Initialization (`apps/desktop/src/main/index.ts`):** - -```typescript -// Initialize encryption service -encryptionService = new EncryptionService(dataPaths.root); -await encryptionService.initialize(); - -// Pass to sync service -syncService = new SyncService(apiClient, encryptionService, noteRepository); -``` - -### Security Considerations - -**Key Storage:** - -- Encryption key stored in `{userData}/encryption.key` -- Key encrypted using Electron `safeStorage`: - - **macOS**: Keychain - - **Windows**: DPAPI (Data Protection API) - - **Linux**: libsecret -- Key never exposed in plaintext outside secure storage - -**Encryption Strength:** - -- AES-256: NIST-approved for top secret data -- GCM mode: Provides both confidentiality and integrity -- Random IVs: Prevents pattern analysis -- Authentication tag: Detects tampering - -**Key Rotation:** - -- Future feature: `reEncrypt()` method available -- Can decrypt with old key, re-encrypt with new key -- Requires full note re-encryption - -**Backup/Restore:** - -- User can export key as hex string -- Store securely (password manager, encrypted USB, etc.) -- Import key on new device to restore access - -**Threat Model:** - -- ✅ **Server compromise**: Server cannot read note content (E2E) -- ✅ **Network interception**: Encrypted data in transit (HTTPS + E2E) -- ✅ **Disk theft**: Key encrypted by OS (safeStorage) -- ✅ **Data tampering**: GCM auth tag detects modifications -- ⚠️ **Device compromise**: If attacker has OS-level access, can extract key from memory -- ⚠️ **Key loss**: If key lost and no backup, notes are permanently unrecoverable - ---- - -## Testing Guide - -### Local Testing Setup - -**1. Start Backend API:** - -```bash -cd packages/api -pnpm dev # → http://localhost:8787 -``` - -**2. Verify Backend:** - -```bash -curl http://localhost:8787/health -# Expected: { "status": "ok" } -``` - -**3. Start Desktop App:** - -```bash -pnpm dev -# App connects to http://localhost:8787 (env var) -``` - -### Test Scenarios - -#### Test 1: Authentication Flow - -**Steps:** - -1. Launch app -2. Click Settings → Account → Sign In -3. Enter email -4. Check terminal (wrangler dev) for magic link URL -5. Copy token from URL and verify manually OR open URL to test deep link -6. Verify: User signed in, email displayed -7. Verify: Sync status indicator appears in sidebar - -**Expected Logs:** - -```bash -# Terminal (wrangler dev) -📧 Magic link email (dev mode): - To: test@example.com - Link: readied://auth/verify?token=eyJhbGci... -``` - -#### Test 2: Manual Sync - -**Steps:** - -1. Sign in (Test 1) -2. Create a note -3. Click "Sync Now" button -4. Verify: "Syncing..." state -5. Verify: "Synced X seconds ago" after completion -6. Check backend database (Turso studio) for encrypted note - -**Expected:** - -- Sync status changes: idle → syncing → idle -- Last sync timestamp updates -- Note appears in Turso `sync_changes` table -- `encrypted_data` field contains base64 string (encrypted) - -#### Test 3: Conflict Resolution - -**Requires 2 devices or 2 databases:** - -**Setup:** - -1. Sign in on Device A -2. Create note "Test Conflict" -3. Sync -4. Sign in on Device B -5. Pull note "Test Conflict" -6. Modify note on Device A (don't sync) -7. Modify note on Device B (different content) -8. Sync on Device B -9. Sync on Device A - -**Expected:** - -- Conflict detected -- Conflict resolver UI appears -- "Test Conflict (Conflict {timestamp})" copy created -- User can choose "Keep Local" or "Keep Remote" -- After resolution, conflict removed from list - -#### Test 4: Offline Mode - -**Steps:** - -1. Sign in -2. Disconnect network (turn off WiFi) -3. Try to sync -4. Verify: Status changes to "offline" -5. Verify: Error message: "No internet connection. Sync will resume when online." -6. Reconnect network -7. Try to sync again -8. Verify: Sync succeeds - -#### Test 5: Encryption - -**Steps:** - -1. Sign in -2. Create note with content "Secret message" -3. Sync -4. Check `{userData}/encryption.key` file exists -5. Query Turso database: - -```sql -SELECT encrypted_data FROM sync_changes WHERE note_id = 'xxx'; -``` - -6. Verify: `encrypted_data` is base64 string, not "Secret message" -7. Verify: Format matches `{base64}:{base64}:{base64}` - -**Export/Import Key:** - -```typescript -// Export -const result = await window.readied.encryption.exportKey(); -console.log('Key:', result.key); // Hex string - -// Import (on different device) -await window.readied.encryption.importKey(result.key); -``` - -#### Test 6: Auto-Sync - -**Steps:** - -1. Sign in -2. Wait 5 minutes -3. Verify: Sync automatically triggers -4. Check logs for sync events -5. Sign out -6. Wait 5 minutes -7. Verify: No auto-sync (timer stopped) - -#### Test 7: Deep Link - -**macOS:** - -```bash -open "readied://auth/verify?token=YOUR_TOKEN" -``` - -**Windows (CMD):** - -```cmd -start readied://auth/verify?token=YOUR_TOKEN -``` - -**Expected:** - -- App opens (or focuses if already open) -- Token automatically verified -- User signed in -- No manual token entry required - -### Error Testing - -**Test Network Errors:** - -1. Sign in -2. Block outgoing connections to localhost:8787 (firewall) -3. Try to sync -4. Verify: Error message: "No internet connection. Sync will resume when online." - -**Test Token Expiry:** - -1. Sign in -2. Manually delete tokens: Delete `{userData}/auth.encrypted` -3. Try to sync -4. Verify: Error message: "Session expired. Please sign in again." - -**Test Invalid Token:** - -1. Trigger deep link with invalid token: - -```bash -open "readied://auth/verify?token=invalid" -``` - -2. Verify: Error message: "This link has expired or is invalid. Please request a new one." - -### Performance Testing - -**Large Sync:** - -1. Create 100+ notes -2. Sync all -3. Monitor: - - Sync duration - - Memory usage - - CPU usage -4. Expected: < 30s for 100 notes - -**Encryption Performance:** - -```typescript -// Test encryption speed -const start = Date.now(); -for (let i = 0; i < 1000; i++) { - await encryptionService.encrypt('Test content ' + i); -} -const duration = Date.now() - start; -console.log(`1000 encryptions: ${duration}ms`); // Expected: < 1000ms -``` - ---- - -## Deployment Checklist - -### Phase 6: Production Deployment - -**⚠️ NOT YET IMPLEMENTED - CHECKLIST FOR FUTURE** - -#### 1. Backend API Deployment - -**Deploy to Cloudflare Workers:** - -```bash -cd packages/api - -# Set production secrets -pnpm wrangler secret put TURSO_DATABASE_URL -# Paste production Turso URL - -pnpm wrangler secret put TURSO_AUTH_TOKEN -# Paste production Turso token - -pnpm wrangler secret put JWT_SECRET -# Generate: openssl rand -hex 32 - -pnpm wrangler secret put RESEND_API_KEY -# Get from Resend dashboard - -pnpm wrangler secret put STRIPE_WEBHOOK_SECRET -# Get from Stripe dashboard - -pnpm wrangler secret put ENVIRONMENT -# Enter: production - -# Deploy -pnpm deploy -``` - -**Verify Deployment:** - -```bash -curl https://api.readied.app/health -# Expected: { "status": "ok" } -``` - -#### 2. Configure Resend (Email Service) - -1. Create account: https://resend.com -2. Add domain: `readied.app` -3. Verify DNS records: - - SPF: `v=spf1 include:_spf.resend.com ~all` - - DKIM: (provided by Resend) - - DMARC: `v=DMARC1; p=none;` -4. Create production API key -5. Update secret: `pnpm wrangler secret put RESEND_API_KEY` - -**Test Email:** - -```bash -curl -X POST https://api.readied.app/auth/magic-link \ - -H "Content-Type: application/json" \ - -d '{"email":"your-email@example.com"}' -``` - -Check inbox for magic link email. - -#### 3. Configure Stripe (Payments) - -**Create Products:** - -1. Go to Stripe Dashboard → Products -2. Create "Readied Pro - Monthly" - - Price: $2.99/month - - Recurring: Monthly -3. Create "Readied Pro - Yearly" - - Price: $29/year - - Recurring: Yearly - -**Create Webhook:** - -1. Go to Developers → Webhooks -2. Add endpoint: `https://api.readied.app/subscription/webhook` -3. Select events: - - `checkout.session.completed` - - `customer.subscription.updated` - - `customer.subscription.deleted` - - `invoice.payment_failed` -4. Copy webhook signing secret -5. Update secret: `pnpm wrangler secret put STRIPE_WEBHOOK_SECRET` - -**Test Webhook:** - -- Send test webhook from Stripe dashboard -- Verify logs in Cloudflare Workers - -#### 4. Update Desktop App - -**Environment Configuration:** - -**For Development (keep existing):** - -```bash -# apps/desktop/.env.development -READIED_API_URL=http://localhost:8787 -``` - -**For Production (built app):** - -```typescript -// apps/desktop/src/main/index.ts -const apiBaseUrl = process.env.READIED_API_URL || 'https://api.readied.app'; -``` - -**Build for Production:** - -```bash -# Build all packages -pnpm build - -# Build macOS app -pnpm --filter @readied/desktop dist:mac - -# Build Windows app -pnpm --filter @readied/desktop dist:win - -# Output: apps/desktop/dist/ -``` - -#### 5. Distribution - -**macOS:** - -- Sign app with Apple Developer certificate -- Notarize with Apple -- Create DMG installer -- Upload to GitHub Releases - -**Windows:** - -- Sign app with code signing certificate -- Create installer (NSIS) -- Upload to GitHub Releases - -**Auto-Update:** - -- Already configured with `electron-updater` -- Update `electron-builder.json5` with publish config: - -```json5 -{ - publish: { - provider: 'github', - owner: 'yourusername', - repo: 'readied', - }, -} -``` - -#### 6. Monitoring - -**Backend:** - -- Cloudflare Workers analytics (automatic) -- Optional: Add Sentry for error tracking -- Monitor logs in Cloudflare dashboard - -**Desktop App:** - -- Electron crash reporter (optional) -- Analytics via backend API (session tracking) - -**Metrics to Monitor:** - -- Auth success rate (>95% expected) -- Sync success rate (>90% expected) -- Error rate by type -- Active devices per user -- Subscription conversion rate - -#### 7. DNS Configuration - -**Required DNS Records:** - -``` -api.readied.app → CNAME → your-worker.workers.dev -readied.app → SPF → v=spf1 include:_spf.resend.com ~all -_domainkey.* → DKIM → (Resend provides) -_dmarc → TXT → v=DMARC1; p=none; rua=mailto:dmarc@readied.app -``` - ---- - -## Troubleshooting - -### Common Issues - -#### Issue: "Encryption service not initialized" - -**Symptom:** Error when trying to sync - -**Cause:** EncryptionService not initialized on app start - -**Fix:** Check main process logs: - -```typescript -// apps/desktop/src/main/index.ts -encryptionService = new EncryptionService(dataPaths.root); -await encryptionService.initialize(); // Must be called! -``` - -#### Issue: "Session expired" after app restart - -**Symptom:** User must sign in again every time app restarts - -**Cause:** Tokens not persisting or failing to decrypt - -**Fix:** - -1. Check `{userData}/auth.encrypted` exists -2. Verify `safeStorage.isEncryptionAvailable()` returns `true` -3. Check logs for decryption errors - -#### Issue: Sync conflicts not appearing - -**Symptom:** No conflicts detected when expected - -**Cause:** Conflict detection logic issue - -**Debug:** - -```typescript -// In syncService.ts pull() method -console.log('Local version:', localNote.version); -console.log('Remote version:', change.version); -console.log('Device IDs:', localNote.deviceId, '!==', change.deviceId); -``` - -**Expected:** Conflict when: - -- `localNote.version < change.version` -- `localNote.deviceId !== change.deviceId` - -#### Issue: Deep links not working - -**macOS:** - -1. Check protocol registered: - -```bash -defaults read com.readied.app -# Look for CFBundleURLTypes -``` - -2. Re-install app (protocol registration happens on install) - -**Windows:** - -1. Check registry: - -```cmd -reg query HKEY_CLASSES_ROOT\readied -``` - -2. Re-install app - -#### Issue: "Network error" in local development - -**Cause:** Backend API not running - -**Fix:** - -```bash -cd packages/api -pnpm dev # Must be running! -``` - -**Verify:** - -```bash -curl http://localhost:8787/health -``` - -#### Issue: Encryption key lost - -**Symptom:** Cannot decrypt notes after reinstall - -**Cause:** Encryption key file deleted or corrupted - -**Fix:** - -1. If backup exists: Use `encryption:importKey` IPC handler -2. If no backup: Notes are permanently unrecoverable (E2E security trade-off) - -**Prevention:** - -- Prompt user to export key after first sync -- Store key in password manager -- Regular backups - -### Debugging Tools - -**Main Process Logs:** - -```typescript -// apps/desktop/src/main/index.ts -const log = getLogger(); -log.info('Message', { data }); -log.error('Error', { error: error.message }); -``` - -**Logs location:** `{userData}/logs/main.log` - -**Renderer Process Logs:** - -```typescript -console.log('Debug info'); -console.error('Error:', error); -``` - -**View logs:** DevTools Console (Cmd+Option+I) - -**IPC Debugging:** - -```typescript -// In main process -ipcMain.handle('test:handler', async (_event, data) => { - console.log('Received:', data); - return { success: true }; -}); - -// In renderer -const result = await window.readied.ipc.invoke('test:handler', { foo: 'bar' }); -console.log('Result:', result); -``` - -**Network Debugging:** - -```typescript -// In apiClient.ts -private async request(endpoint: string, options?: RequestInit): Promise { - console.log('→ Request:', endpoint, options); - const response = await fetch(this.baseUrl + endpoint, options); - console.log('← Response:', response.status, response.statusText); - // ... -} -``` - -### Database Inspection - -**Turso (libSQL):** - -```bash -# Connect to database -turso db shell readied - -# List tables -.tables - -# Check sync changes -SELECT * FROM sync_changes ORDER BY created_at DESC LIMIT 10; - -# Check users -SELECT * FROM users; - -# Check subscriptions -SELECT * FROM subscriptions; -``` - -**Local SQLite:** - -```bash -# Open database -sqlite3 ~/Library/Application\ Support/Readied/notes.db - -# List tables -.tables - -# Check notes -SELECT id, title, length(content) as content_length FROM notes LIMIT 10; - -# Check metadata -SELECT * FROM metadata; -``` - ---- - -## Summary of Changes - -### Files Created (29 files) - -**Main Process Services (5 files):** - -- `apps/desktop/src/main/services/tokenStorage.ts` (~100 LOC) -- `apps/desktop/src/main/services/deviceInfo.ts` (~80 LOC) -- `apps/desktop/src/main/services/apiClient.ts` (~330 LOC) -- `apps/desktop/src/main/services/encryptionService.ts` (~200 LOC) -- `apps/desktop/src/main/services/syncService.ts` (~400 LOC) - -**Renderer Stores (3 files):** - -- `apps/desktop/src/renderer/stores/settings.ts` (~80 LOC) -- `apps/desktop/src/renderer/stores/authStore.ts` (~160 LOC) -- `apps/desktop/src/renderer/stores/syncStore.ts` (~150 LOC) - -**UI Components (9 files + 9 CSS files):** - -- `apps/desktop/src/renderer/pages/settings/components/SettingGroup.tsx` + `.module.css` -- `apps/desktop/src/renderer/pages/settings/components/SettingRow.tsx` + `.module.css` -- `apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx` -- `apps/desktop/src/renderer/components/auth/MagicLinkFlow.tsx` + `.module.css` -- `apps/desktop/src/renderer/components/sync/SyncStatusIndicator.tsx` + `.module.css` -- `apps/desktop/src/renderer/components/sync/ConflictResolver.tsx` + `.module.css` - -### Files Modified (8 files) - -- `apps/desktop/src/main/index.ts` (+~600 LOC) -- `apps/desktop/src/preload/index.ts` (+~200 LOC) -- `apps/desktop/src/renderer/App.tsx` (+~30 LOC) -- `apps/desktop/src/renderer/pages/settings/SettingsApp.tsx` (+~20 LOC) -- `apps/desktop/src/renderer/pages/settings/sections/BackupSection.tsx` (~30 LOC changed) -- `apps/desktop/src/renderer/pages/settings/sections/Section.module.css` (+~100 LOC) -- `apps/desktop/src/renderer/components/sidebar/SidebarHeader.tsx` (+~5 LOC) -- `apps/desktop/package.json` (added cross-fetch dependency) - -### Total Lines of Code - -**Added:** ~2,700 LOC -**Modified:** ~1,000 LOC -**Total Impact:** ~3,700 LOC - -### Dependencies Added - -```json -{ - "dependencies": { - "cross-fetch": "^4.1.0" - } -} -``` - ---- - -## Next Steps - -1. **Local Testing**: Test all features with backend running locally -2. **Production Deployment** (Phase 6): - - Deploy backend API to Cloudflare Workers - - Configure Resend production email - - Configure Stripe production webhooks - - Build and distribute desktop app -3. **User Testing**: Beta test with real users -4. **Monitoring**: Set up error tracking and analytics -5. **Documentation**: Update user-facing docs with sync instructions - ---- - -## Credits - -**Implementation**: Claude (Sonnet 4.5) -**Date**: January 8, 2026 -**Phases Completed**: 1, 2, 3, 4, 5 -**Status**: ✅ Ready for Local Testing -**Next**: Phase 6 - Production Deployment - ---- - -**End of Documentation** diff --git a/SECURITY.md b/SECURITY.md index 329a0416..915224a8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ | Version | Supported | | ------- | --------- | -| 0.2.x | ✅ Yes | -| < 0.2 | ❌ No | +| 0.9.x | ✅ Yes | +| < 0.9 | ❌ No | ## Reporting a Vulnerability diff --git a/SEMANA_2_COMPLETE.md b/SEMANA_2_COMPLETE.md deleted file mode 100644 index 37921605..00000000 --- a/SEMANA_2_COMPLETE.md +++ /dev/null @@ -1,745 +0,0 @@ -# ✅ Semana 2: Bidirectional Sync - COMPLETE - -**Date:** 2026-01-09 -**Phase:** Phase 1, Sprint 1 -**Status:** **✅ COMPLETE** (Ready for Multi-Device Testing) -**Branch:** `develop` - ---- - -## 🎯 Objective - -Transform the sync system from **read-only** (pull-only) to **bidirectional** (pull + push), enabling true multi-device synchronization with conflict detection and resolution. - ---- - -## 📦 What Was Implemented - -### 1. Database Layer - Local Change Tracking - -**Migration 008: `sync_tracking`** - -- **File:** `packages/storage-sqlite/src/migrations/008_sync_tracking.ts` -- **Version:** `20260109000008` - -**Added Columns:** - -```sql -ALTER TABLE notes ADD COLUMN local_version INTEGER DEFAULT 1; -ALTER TABLE notes ADD COLUMN needs_sync INTEGER DEFAULT 0; -ALTER TABLE notes ADD COLUMN last_synced_at TEXT DEFAULT NULL; -``` - -- `local_version` - Increments on each local change (for conflict detection) -- `needs_sync` - Boolean flag (1 = needs push to server, 0 = in sync) -- `last_synced_at` - ISO 8601 timestamp of last successful sync - -**Triggers (Auto-Tracking):** - -```sql --- Trigger on UPDATE (content/title/metadata changes) -CREATE TRIGGER notes_update_sync_tracking -AFTER UPDATE ON notes -FOR EACH ROW -WHEN NEW.content != OLD.content - OR NEW.title != OLD.title - OR NEW.is_pinned != OLD.is_pinned - OR NEW.status != OLD.status - OR NEW.notebook_id != OLD.notebook_id -BEGIN - UPDATE notes - SET needs_sync = 1, local_version = local_version + 1 - WHERE id = NEW.id; -END; - --- Trigger on INSERT (new notes) -CREATE TRIGGER notes_insert_sync_tracking -AFTER INSERT ON notes -FOR EACH ROW -BEGIN - UPDATE notes SET needs_sync = 1 WHERE id = NEW.id; -END; -``` - -**Index for Performance:** - -```sql -CREATE INDEX idx_notes_needs_sync ON notes(needs_sync) WHERE needs_sync = 1; -``` - -**Why It Matters:** - -- Automatic tracking eliminates manual bookkeeping -- Efficient queries (index on WHERE needs_sync = 1) -- Version tracking enables conflict detection - ---- - -### 2. Repository Layer - Sync Operations - -**File:** `packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts` - -**New Methods:** - -#### `getPendingChanges(limit = 50)` - -```typescript -getPendingChanges(limit = 50): Array<{ - note: Note; - localVersion: number; - lastSyncedAt: string | null; -}> -``` - -- Queries notes where `needs_sync = 1` -- Orders by `local_version` ASC (oldest first) -- Returns notes with their sync metadata -- Used by sync service to batch push - -#### `markAsSynced(noteId: NoteId)` - -```typescript -markAsSynced(noteId: NoteId): void -``` - -- Sets `needs_sync = 0` -- Updates `last_synced_at` to current timestamp -- Called after successful push to server - -#### `markMultipleAsSynced(noteIds: NoteId[])` - -```typescript -markMultipleAsSynced(noteIds: NoteId[]): void -``` - -- Batch version of `markAsSynced` -- Wrapped in transaction for atomicity -- More efficient than individual calls - -#### `getSyncStats()` - -```typescript -getSyncStats(): { - pendingCount: number; - lastSyncedAt: string | null; -} -``` - -- Returns count of notes needing sync -- Returns most recent sync timestamp -- Used for monitoring/UI display - -#### `resetSyncTracking(noteId: NoteId)` - -```typescript -resetSyncTracking(noteId: NoteId): void -``` - -- Sets `needs_sync = 1` -- Increments `local_version` -- Used for conflict resolution (force re-sync) - ---- - -### 3. Sync Service - Bidirectional Sync - -**File:** `apps/desktop/src/main/services/syncService.ts` - -#### **Before (Read-Only):** - -```typescript -async syncNow(): Promise { - // Step 1: Pull changes from server - const pullResult = await this.pull(); - - // Step 2: TODO - Push local changes (not implemented) - - return { - success: true, - changesApplied: pullResult.changes.length, - changesPushed: 0, // Always 0 - conflicts: pullResult.conflicts, - }; -} -``` - -#### **After (Bidirectional):** - -```typescript -async syncNow(): Promise { - // Step 1: Pull changes from server - const pullResult = await this.pull(); - - // Step 2: Push local changes ✅ NOW IMPLEMENTED - let changesPushed = 0; - const pendingChanges = this.noteRepository.getPendingChanges(50); - - if (pendingChanges.length > 0) { - const changesToPush = pendingChanges.map(({ note, localVersion }) => ({ - noteId: note.id, - operation: (note.isDeleted ? 'delete' : 'update') as 'create' | 'update' | 'delete', - content: !note.isDeleted ? note.content : undefined, - localVersion, - })); - - const pushResult = await this.push(changesToPush); - - if (pushResult.success) { - const successfulNoteIds = pushResult.results - .filter(r => r.status === 'applied') - .map(r => createNoteId(r.noteId)); - - this.noteRepository.markMultipleAsSynced(successfulNoteIds); - changesPushed = successfulNoteIds.length; - } - } - - return { - success: true, - changesApplied: pullResult.changes.length, - changesPushed, // Now returns actual count - conflicts: pullResult.conflicts, - }; -} -``` - -**What Changed:** - -1. Gets pending changes from repository -2. Encrypts and pushes to server -3. Marks successfully pushed notes as synced -4. Handles push conflicts -5. Returns actual `changesPushed` count - ---- - -#### `resolveConflict()` - Real Implementation - -**Before (Stub):** - -```typescript -async resolveConflict(noteId: string, resolution: 'local' | 'remote'): Promise { - if (resolution === 'local') { - // TODO: Mark note for push in next sync - console.log(`Conflict resolved: keeping local version for ${noteId}`); - } else { - console.log(`Conflict resolved: keeping remote version for ${noteId}`); - } -} -``` - -**After (Functional):** - -```typescript -async resolveConflict(noteId: string, resolution: 'local' | 'remote'): Promise { - const note = await this.noteRepository.get(createNoteId(noteId)); - if (!note) { - throw new Error(`Note ${noteId} not found`); - } - - if (resolution === 'local') { - // Keep local version, mark for push to server - this.noteRepository.resetSyncTracking(createNoteId(noteId)); - console.log(`Conflict resolved: keeping local version for ${noteId}, marked for sync`); - } else { - // Keep remote version (already applied during pull) - // Just mark as synced to clear the conflict state - this.noteRepository.markAsSynced(createNoteId(noteId)); - console.log(`Conflict resolved: keeping remote version for ${noteId}`); - } -} -``` - -**What It Does:** - -- **"local" resolution:** Calls `resetSyncTracking()` to force re-push -- **"remote" resolution:** Calls `markAsSynced()` to accept server version -- Removes conflict from UI after resolution - ---- - -#### `applyRemoteChange()` - Prevent Ping-Pong - -**Enhancement:** - -```typescript -private async applyRemoteChange(change: SyncChange): Promise { - // ... existing code to apply change ... - - // NEW: Mark as synced to avoid re-pushing - this.noteRepository.markAsSynced(noteId); -} -``` - -**Why:** - -- Without this, notes pulled from server would be marked `needs_sync=1` by the UPDATE trigger -- Would cause infinite sync loop (ping-pong effect) -- Now explicitly marks pulled notes as synced - ---- - -### 4. Conflict Resolution UI - Visual Diff - -**File:** `apps/desktop/src/renderer/components/sync/ConflictResolver.tsx` - -**Features:** - -#### Dual View Modes - -1. **Side-by-Side View** (Default) - - Local version on left - - Remote version on right - - Divider in center with VS icon - - Individual "Keep Local" / "Keep Remote" buttons - -2. **Unified Diff View** (New) - - Combined view showing changes - - Green background for additions - - Red background + strikethrough for deletions - - Gray text for unchanged content - - Centered resolution buttons - -#### Visual Diff Highlighting - -```typescript -// Using 'diff' library for line-based diffing -const diff = diffLines(localContent, remoteContent); - -// Render with color-coded changes -+ added text -- removed text -unchanged text -``` - -#### Components - -- `DiffChange` - Renders individual diff change with styling -- `UnifiedDiff` - Line-by-line diff view with header -- `ConflictResolver` - Main component with view toggle - -#### CSS Styling - -- `.diffAdded` - `background: rgba(34, 197, 94, 0.2); color: #22c55e;` -- `.diffRemoved` - `background: rgba(239, 68, 68, 0.2); color: #ef4444; text-decoration: line-through;` -- `.diffUnchanged` - `color: var(--text-secondary);` -- Responsive layout (mobile-friendly) - -**Integration:** - -- Already integrated in `AccountSection.tsx` (line 159) -- Shows when `conflicts.length > 0` -- Auto-hidden when no conflicts - ---- - -## 🔄 How It Works (End-to-End Flow) - -### Scenario: Edit Note on Device A, Sync to Device B - -``` -┌─────────────────┐ -│ Device A │ -└─────────────────┘ - ↓ -1. User edits note "Meeting Notes" - - Content changes: "Old content" → "New content" - ↓ -2. SQLite Trigger Fires - UPDATE notes SET content='New content' WHERE id='note-123'; - ↓ - Trigger: notes_update_sync_tracking - UPDATE notes SET needs_sync=1, local_version=local_version+1 WHERE id='note-123'; - ↓ -3. Auto-Sync Timer (5 min) OR Manual Sync - syncService.syncNow() - ↓ -4. Pull from Server - - Gets remote changes (if any) - - Applies to local DB - ↓ -5. Push to Server ✅ NEW - - noteRepository.getPendingChanges(50) - - Returns [{note: "Meeting Notes", localVersion: 5}] - - Encrypts content with AES-256-GCM - - apiClient.pushChanges([{noteId: 'note-123', operation: 'update', encryptedData: '...'}]) - ↓ -6. Server Processes Push - - Checks for conflicts (version mismatch) - - Inserts into sync_log table with version=100 - - Returns {results: [{noteId: 'note-123', status: 'applied', version: 100}]} - ↓ -7. Mark as Synced - noteRepository.markAsSynced('note-123') - UPDATE notes SET needs_sync=0, last_synced_at='2026-01-09T10:30:00Z' WHERE id='note-123'; - ↓ -✅ Device A: Note synced successfully - -┌─────────────────┐ -│ Device B │ -└─────────────────┘ - ↓ -8. Device B: Auto-Sync Triggers - syncService.syncNow() - ↓ -9. Pull from Server - - apiClient.pullChanges(cursor=50, limit=50) - - Server returns: [{noteId: 'note-123', version: 100, operation: 'update', encryptedData: '...'}] - ↓ -10. Decrypt & Apply - - encryptionService.decrypt(encryptedData) - - Returns: "New content" - - noteRepository.save({id: 'note-123', content: 'New content', ...}) - - noteRepository.markAsSynced('note-123') ← Prevents re-push - ↓ -✅ Device B: Note updated with "New content" -``` - ---- - -### Conflict Scenario: Same Note Edited Offline on Both Devices - -``` -┌─────────────────┐ ┌─────────────────┐ -│ Device A │ │ Device B │ -│ (Offline) │ │ (Offline) │ -└─────────────────┘ └─────────────────┘ - ↓ ↓ -Edit: "Content A" Edit: "Content B" -needs_sync=1 needs_sync=1 -local_version=5 local_version=5 - ↓ ↓ -Goes Online Waits... - ↓ -Push to Server ✅ -- Server accepts (no conflict yet) -- Server version=100 - ↓ -Mark as synced -needs_sync=0 - ↓ - Goes Online - ↓ - Push to Server ❌ - - Server detects conflict: - - local_version=5 - - server_version=100 - - 5 < 100 → CONFLICT! - - Returns: {status: 'conflict', serverVersion: 100} - ↓ - Device B: Conflict Detected - - Note remains needs_sync=1 - - syncStore.conflicts = [{ - noteId: 'note-123', - localContent: 'Content B', - remoteContent: 'Content A', - localVersion: 5, - remoteVersion: 100, - }] - ↓ - UI Shows ConflictResolver - - User sees side-by-side OR unified diff - - Clicks "Keep Local" OR "Keep Remote" - ↓ - IF "Keep Local": - - resetSyncTracking('note-123') - - needs_sync=1, local_version++ - - Next sync pushes "Content B" - ↓ - IF "Keep Remote": - - markAsSynced('note-123') - - Accepts "Content A" - - needs_sync=0 - ↓ - ✅ Conflict Resolved -``` - ---- - -## 📊 What Works Now - -### ✅ Basic Sync - -- [x] Create note on Device A → Marked `needs_sync=1` -- [x] Auto-sync OR manual sync triggers -- [x] Note pushed to server (encrypted) -- [x] Device B pulls → Decrypts → Applies → Marks as synced -- [x] No ping-pong effect (pulled notes not re-pushed) - -### ✅ Multi-Device Editing - -- [x] Edit same note on Device A → Pushes successfully -- [x] Edit same note on Device B (offline) → Conflict detected on push -- [x] Conflict displayed in UI with visual diff -- [x] User resolves conflict (local or remote) -- [x] Sync continues after resolution - -### ✅ Rapid Edits - -- [x] Trigger increments `local_version` on each edit -- [x] Batch push up to 50 notes per sync -- [x] All edits eventually synced - -### ✅ Delete Sync - -- [x] Soft delete (is_deleted=1) → Marked `needs_sync=1` -- [x] Pushed as `operation='delete'` -- [x] Device B receives delete → Marks note as deleted - -### ✅ UI/UX - -- [x] Conflict resolver shows in AccountSection -- [x] Side-by-side and unified diff views -- [x] Visual diff highlighting (green=added, red=removed) -- [x] Resolution buttons (Keep Local / Keep Remote) -- [x] Auto-hides when no conflicts - ---- - -## 📈 Performance Characteristics - -### Query Performance - -- **Pending changes query:** O(log n) with index on `needs_sync` -- **Batch mark as synced:** O(m) where m = batch size (max 50) -- **Conflict detection:** O(1) per note (version comparison) - -### Sync Throughput - -- **Pull:** 50 notes per request (configurable) -- **Push:** 50 notes per request (configurable) -- **Auto-sync interval:** 5 minutes (configurable) - -### Storage Overhead - -- **3 new columns per note:** ~12 bytes (INTEGER + INTEGER + TEXT) -- **1 new index:** ~4-8 bytes per row -- **Negligible impact:** <1% storage increase - ---- - -## 🧪 Testing Status - -### ✅ Code Complete - -- [x] Migration 008 created -- [x] Repository methods implemented -- [x] Sync service bidirectional -- [x] Conflict resolution functional -- [x] UI with visual diff - -### ⏳ Testing Required - -- [ ] **Multi-device testing** (see TESTING_SYNC.md) - - Scenario 1: Basic push/pull - - Scenario 2: Edit conflict - - Scenario 3: Rapid edits - - Scenario 4: Delete sync -- [ ] **Migration testing** (verify triggers work) -- [ ] **Performance testing** (50+ notes batch push) -- [ ] **Edge cases** (network timeout, server error recovery) - -**Testing Guide:** `TESTING_SYNC.md` (374 lines) - ---- - -## 📦 Commits - -**Semana 2 Commits (3 total):** - -1. **`ebe39e5`** - feat: implement bidirectional sync with local change tracking - - Migration 008: sync_tracking columns + triggers - - Repository methods: getPendingChanges, markAsSynced, etc. - - Sync service: syncNow() with push, resolveConflict() functional - - 273 insertions (+) - -2. **`c65ef3d`** - docs: add multi-device sync testing guide - - TESTING_SYNC.md (374 lines) - - 4 test scenarios, migration verification, debug queries - -3. **`17e1cd4`** - feat: enhance conflict resolution UI with visual diff - - Dual view modes (side-by-side + unified diff) - - Visual diff highlighting (diff library) - - 251 insertions (+) - -**Total:** 4 files changed, 898 insertions(+) - ---- - -## 🔑 Critical Blocker Resolved - -### Before Semana 2 (Audit Finding): - -> ❌ **CRÍTICO** - Sync Bidireccional No Implementado -> -> **Problema:** Solo read-only, push no existe -> -> **Impacto:** Feature Pro inútil, pérdida de datos -> -> **Código literal del problema:** -> -> ```typescript -> // apps/desktop/src/main/services/syncService.ts:74 -> async syncNow() { -> // Step 1: Pull changes from server -> const pullResult = await this.pull(); -> -> // Step 2: TODO - Push local changes (Phase 3 - implement local change tracking) -> // This is where we would push local changes to the server -> -> return pullResult; -> } -> ``` -> -> **Traducción:** Tenés un sistema de sync que solo puede **descargar** cambios del servidor, pero **nunca sube** cambios locales. Es un sistema de backup read-only, no un sync real. - -### After Semana 2: - -> ✅ **RESUELTO** - Sync Bidireccional Funcional -> -> **Implementado:** -> -> - Push de cambios locales al servidor -> - Tracking automático con triggers -> - Detección de conflictos -> - Resolución manual con UI visual -> -> **Traducción:** Ahora tenés un sistema de sync real que sube y baja cambios, con conflictos manejados correctamente. - ---- - -## 🎯 Next Steps - -### Immediate (Esta Semana) - -1. **Multi-device testing** - User must test with 2 devices/instances -2. **Bug fixes** - Address issues found in testing -3. **Deploy to staging** - Test with real server - -### Upcoming (Semanas 5-7 per Plan) - -4. **Git-backed notes** - Differentiator #1 -5. **Knowledge graph** - Differentiator #2 -6. **CLI & API** - Differentiator #3 - ---- - -## 📚 Files Modified - -### Database - -- `packages/storage-sqlite/src/migrations/008_sync_tracking.ts` (NEW) -- `packages/storage-sqlite/src/migrations/index.ts` (MODIFIED) - -### Repository - -- `packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts` (MODIFIED) - - +5 methods (getPendingChanges, markAsSynced, markMultipleAsSynced, getSyncStats, resetSyncTracking) - -### Services - -- `apps/desktop/src/main/services/syncService.ts` (MODIFIED) - - syncNow(): push implementation - - resolveConflict(): functional implementation - - applyRemoteChange(): mark as synced - -### UI - -- `apps/desktop/src/renderer/components/sync/ConflictResolver.tsx` (MODIFIED) - - Dual view modes - - Visual diff with highlighting -- `apps/desktop/src/renderer/components/sync/ConflictResolver.module.css` (MODIFIED) - - Diff styling (.diffAdded, .diffRemoved, etc.) -- `apps/desktop/package.json` (MODIFIED) - - Added `diff` dependency - -### Documentation - -- `TESTING_SYNC.md` (NEW) -- `SEMANA_2_COMPLETE.md` (NEW - this file) - ---- - -## 🏆 Success Criteria (From Plan) - -**Phase 1, Sprint 1 Criteria:** - -- [x] **Editar nota en Device A → sincroniza a Device B** ✅ Code Complete -- [x] **Editar misma nota en A y B offline → conflicto detectado → resuelto** ✅ Code Complete -- [x] **Sync bidireccional funcional end-to-end** ✅ Code Complete - -**Pending:** Multi-device testing by user - ---- - -## 💡 Key Insights - -### What Went Well - -1. **Triggers work perfectly** - Auto-tracking eliminates manual bookkeeping -2. **Batch operations** - markMultipleAsSynced() is efficient -3. **Conflict detection** - Version comparison is simple and reliable -4. **UI polish** - Visual diff makes conflicts understandable - -### What Could Be Better - -1. **Real-time sync** - 5-min polling is slow (future: WebSockets) -2. **Large batches** - 50 notes limit requires multiple syncs (acceptable for MVP) -3. **Merge conflicts** - No automatic merge (user must choose) - -### Lessons Learned - -1. **Triggers are powerful** - Automatic tracking is better than manual -2. **Ping-pong prevention is critical** - Must mark pulled notes as synced -3. **Visual diff is essential** - Users need to see what changed - ---- - -## 🚀 Deployment Checklist - -**Before deploying to staging:** - -- [x] Migration 008 created -- [x] TypeScript compiles with no errors -- [x] IPC handlers exist (sync:resolveConflict) -- [ ] Migration tested locally -- [ ] Multi-device testing passed -- [ ] No critical bugs - -**After deploying to staging:** - -- [ ] Verify migration applies on fresh DB -- [ ] Verify triggers fire correctly -- [ ] Test push/pull with staging API -- [ ] Test conflict resolution flow - ---- - -## 📞 Support Info - -**If sync breaks:** - -1. Check migration applied: `SELECT * FROM migrations WHERE version=20260109000008;` -2. Check triggers exist: `SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE '%sync%';` -3. Check pending notes: `SELECT id, title, needs_sync, local_version FROM notes WHERE needs_sync=1;` -4. Force re-sync: `UPDATE notes SET needs_sync=1, local_version=local_version+1 WHERE id='note-id';` - -**Debug Logs:** - -- Main process: `~/.config/Readied/logs/main.log` -- Renderer process: DevTools Console -- Sync errors: Check Network tab for failed requests - ---- - -## 🎉 Conclusion - -**Semana 2 is COMPLETE.** The sync system is now **fully bidirectional** with **conflict detection** and **visual resolution UI**. This resolves the **critical blocker** from the audit and enables true multi-device sync. - -**Next:** User testing to validate functionality, then proceed to Semanas 5-7 (Git-backed notes). - ---- - -**Status:** ✅ **READY FOR TESTING** -**Branch:** `develop` -**Last Updated:** 2026-01-09 diff --git a/TESTING_SYNC.md b/TESTING_SYNC.md deleted file mode 100644 index 773524d5..00000000 --- a/TESTING_SYNC.md +++ /dev/null @@ -1,389 +0,0 @@ -# Multi-Device Sync Testing Guide - -**Status:** Semana 2, Sprint 1 - Ready for Testing -**Date:** 2026-01-09 -**Feature:** Bidirectional sync with local change tracking - ---- - -## Prerequisites - -1. ✅ Backend API deployed to staging (`api-staging.readied.app`) -2. ✅ Migration 008 (sync tracking) ready -3. ✅ Desktop app with sync service changes -4. ⚠️ Two test accounts or two devices to simulate multi-device - ---- - -## Test Scenarios - -### Scenario 1: Basic Push/Pull (Happy Path) - -**Goal:** Verify note created on Device A syncs to Device B - -**Steps:** - -1. **Device A:** - - Launch app, sign in with test account - - Create new note: "Test Sync Note" - - Edit content: "This is a test note created on Device A" - - Wait 5 minutes for auto-sync OR trigger manual sync - -2. **Verify Server:** - - Check sync_log table in Turso: - ```sql - SELECT * FROM sync_log WHERE user_id = 'test-user-id' ORDER BY version DESC LIMIT 5; - ``` - - Should see encrypted_data for the new note - -3. **Device B:** - - Launch app, sign in with same test account - - Trigger manual sync - - Verify "Test Sync Note" appears in note list - - Open note, verify content matches - -**Expected Result:** ✅ Note syncs correctly, content decrypts properly - -**Failure Modes:** - -- ❌ Note marked needs_sync=1 but not pushed → Check push logic -- ❌ Note pushed but not appearing on B → Check pull logic -- ❌ Content garbled → Check encryption/decryption - ---- - -### Scenario 2: Edit Conflict (Different Devices, Offline) - -**Goal:** Detect and resolve conflicts when same note edited offline on both devices - -**Steps:** - -1. **Device A (online):** - - Create note: "Conflict Test" - - Content: "Original content" - - Wait for sync - -2. **Device B (online):** - - Pull changes, verify note exists - - **Go offline** (disable network) - -3. **Device A (online):** - - Edit note: "Content edited on Device A" - - Wait for sync (should push successfully) - -4. **Device B (offline):** - - Edit same note: "Content edited on Device B" - - Note marked needs_sync=1 locally - - **Go online** - -5. **Device B (online):** - - Trigger sync - - **CONFLICT DETECTED:** - - Push attempt returns status='conflict' - - Note remains needs_sync=1 - - User sees conflict in UI - -6. **Device B - Resolution:** - - Choose "Keep Local" → resetSyncTracking() → push again - - OR choose "Keep Remote" → markAsSynced() → accept server version - -**Expected Result:** - -- ✅ Conflict detected during push -- ✅ User can resolve via UI -- ✅ After resolution, note syncs correctly - -**Failure Modes:** - -- ❌ Conflict not detected → Check version comparison in backend -- ❌ Resolution doesn't work → Check resolveConflict() implementation -- ❌ Note stuck in conflict state → Check markAsSynced() logic - ---- - -### Scenario 3: Rapid Edits (Stress Test) - -**Goal:** Verify sync handles rapid sequential edits without data loss - -**Steps:** - -1. **Device A:** - - Create note: "Rapid Edit Test" - - Edit 10 times rapidly (every 2 seconds) - - Each edit increments local_version - - All marked needs_sync=1 - -2. **Trigger Sync:** - - syncNow() should batch push up to 50 changes - - Server processes each change sequentially - - Mark all as synced after successful push - -3. **Device B:** - - Pull changes - - Verify final content matches Device A's latest edit - - Verify local_version reflects all edits - -**Expected Result:** ✅ All edits synced, no data loss - -**Failure Modes:** - -- ❌ Edits lost → Check trigger doesn't skip updates -- ❌ Version mismatch → Check local_version increment -- ❌ Duplicate pushes → Check markAsSynced() called correctly - ---- - -### Scenario 4: Delete Sync - -**Goal:** Verify deleted note syncs and removes from other devices - -**Steps:** - -1. **Device A:** - - Create note: "Delete Test" - - Sync (ensure on server) - -2. **Device B:** - - Pull, verify note exists - -3. **Device A:** - - Delete note (soft delete: is_deleted=1) - - Sync (push delete operation) - -4. **Device B:** - - Pull changes - - Verify note moved to trash (is_deleted=1) - - OR hard deleted (removed from DB) - -**Expected Result:** ✅ Delete syncs correctly - -**Failure Modes:** - -- ❌ Note not deleted on B → Check delete operation handling -- ❌ Note re-appears after sync → Check trigger doesn't mark deleted notes - ---- - -## Migration Testing - -Before running app, verify migration 008 applies correctly: - -```bash -# Check current migrations -sqlite3 ~/Library/Application\ Support/Readied/readied.db "SELECT * FROM migrations ORDER BY version;" - -# Apply migration (happens automatically on app launch) -pnpm dev - -# Verify new columns exist -sqlite3 ~/Library/Application\ Support/Readied/readied.db \ - "PRAGMA table_info(notes);" | grep -E "(local_version|needs_sync|last_synced_at)" - -# Expected output: -# 12|local_version|INTEGER|0|1|0 -# 13|needs_sync|INTEGER|0|0|0 -# 14|last_synced_at|TEXT|0|NULL|0 - -# Verify triggers created -sqlite3 ~/Library/Application\ Support/Readied/readied.db \ - "SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE '%sync%';" - -# Expected output: -# notes_update_sync_tracking -# notes_insert_sync_tracking - -# Verify index created -sqlite3 ~/Library/Application\ Support/Readied/readied.db \ - "SELECT name FROM sqlite_master WHERE type='index' AND name LIKE '%sync%';" - -# Expected output: -# idx_notes_needs_sync -``` - ---- - -## Manual Sync Trigger (For Testing) - -If auto-sync is too slow (5 min interval), trigger manually: - -**Option 1: DevTools Console (Renderer)** - -```javascript -// Trigger sync -window.api.sync.syncNow(); - -// Check sync status -window.api.sync.getStatus(); -``` - -**Option 2: Main Process (IPC Handler)** - -```typescript -// In main/index.ts -ipcMain.handle('test-sync', async () => { - const result = await syncService.syncNow(); - console.log('Sync result:', result); - return result; -}); - -// Then from renderer: -window.api.invoke('test-sync'); -``` - -**Option 3: Auto-Sync Interval Override** - -```typescript -// In main/index.ts, after creating syncService: -syncService.startAutoSync(30 * 1000); // 30 seconds instead of 5 minutes -``` - ---- - -## Debug Queries - -**Check pending changes locally:** - -```sql -SELECT id, title, local_version, needs_sync, last_synced_at -FROM notes -WHERE needs_sync = 1 -ORDER BY local_version ASC; -``` - -**Check sync stats:** - -```sql -SELECT - COUNT(CASE WHEN needs_sync = 1 THEN 1 END) as pending_count, - MAX(last_synced_at) as last_sync_time -FROM notes; -``` - -**Check server sync log:** - -```sql --- In Turso staging database -SELECT - id, note_id, version, operation, device_id, created_at -FROM sync_log -WHERE user_id = 'test-user-id' -ORDER BY version DESC -LIMIT 20; -``` - -**Check sync cursors:** - -```sql --- In Turso staging database -SELECT - device_id, last_synced_version, updated_at -FROM sync_cursors -WHERE user_id = 'test-user-id'; -``` - ---- - -## Expected Behavior - -### Triggers - -**INSERT:** New note immediately marked needs_sync=1 - -```sql -INSERT INTO notes (...) VALUES (...); --- Trigger: notes_insert_sync_tracking fires --- Result: needs_sync=1 -``` - -**UPDATE (content/title/metadata):** - -```sql -UPDATE notes SET content='new content' WHERE id='note-id'; --- Trigger: notes_update_sync_tracking fires --- Result: needs_sync=1, local_version++ -``` - -**UPDATE (sync-only fields):** Should NOT trigger - -```sql -UPDATE notes SET needs_sync=0, last_synced_at='...' WHERE id='note-id'; --- Trigger: Does NOT fire (WHEN clause prevents it) --- Result: No change to needs_sync or local_version -``` - -### Sync Flow - -1. **User edits note** → Trigger marks needs_sync=1, local_version++ -2. **Auto-sync (5 min)** OR manual sync: - - Pull from server first (get remote changes) - - Push pending changes (needs_sync=1) - - Server responds with status='applied' or 'conflict' - - Mark successful pushes as synced -3. **Other device pulls** → Gets encrypted change, decrypts, applies - ---- - -## Success Criteria - -**Scenario 1 (Basic):** ✅ PASS if note created on A appears on B with correct content - -**Scenario 2 (Conflict):** ✅ PASS if conflict detected AND user can resolve - -**Scenario 3 (Rapid):** ✅ PASS if all 10 edits synced without loss - -**Scenario 4 (Delete):** ✅ PASS if deleted note removed/trashed on B - -**Performance:** ✅ PASS if sync completes in <5s for 50 notes - ---- - -## Known Issues / Limitations - -1. **Conflict Resolution UI:** Not yet implemented - - Currently logs to console - - Next task: Build visual diff UI - -2. **Large Batches:** Push limited to 50 notes per sync - - If >50 pending, requires multiple syncs - - Acceptable for MVP, optimize later - -3. **Real-Time Sync:** Auto-sync is 5-min polling - - Not instant like Inkdrop - - Next phase: WebSockets for real-time - -4. **Migration Rollback:** No automatic rollback - - If migration 008 fails, manual DB repair needed - - Pre-migration backups saved automatically - ---- - -## Next Steps After Testing - -1. ✅ If tests pass → Commit, move to UI for conflict resolution -2. ⚠️ If tests fail → Debug specific failure mode, fix, re-test -3. 📝 Document actual behavior vs expected in this file -4. 🚀 Deploy to staging for broader testing - ---- - -## Test Log Template - -``` -Date: ___________ -Tester: ___________ -Environment: [Staging / Local] - -Scenario 1 (Basic): [PASS / FAIL] - Notes: ___________ -Scenario 2 (Conflict): [PASS / FAIL] - Notes: ___________ -Scenario 3 (Rapid): [PASS / FAIL] - Notes: ___________ -Scenario 4 (Delete): [PASS / FAIL] - Notes: ___________ - -Performance: -- Sync time for 10 notes: _____ ms -- Sync time for 50 notes: _____ ms - -Issues Encountered: -- ___________ - -Overall: [READY FOR PRODUCTION / NEEDS FIXES] -``` diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 49575cec..a78fd113 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1426,6 +1426,7 @@ function registerAuthSyncHandlers(): void { const client = apiClient; const storage = tokenStorage; const sync = syncService; + const encryption = encryptionService; // Broadcast sync status events to all renderer windows sync.onStatusChange(event => { @@ -1685,6 +1686,168 @@ function registerAuthSyncHandlers(): void { } }); + // ═══════════════════════════════════════════════════════════════════════════ + // E2EE Key Management + // ═══════════════════════════════════════════════════════════════════════════ + + // Check if encryption is ready (CEK cached locally) + ipcMain.handle('encryption:isReady', async () => { + return { ready: encryption?.isReady() ?? false }; + }); + + // Check if this is a first-time setup or existing user + ipcMain.handle('encryption:getKeyStatus', async () => { + try { + const serverKeys = await client.getEncryptionKeys(); + const hasLocalKey = encryption?.isReady() ?? false; + const hasLegacyKey = encryption?.hasLegacyKey() ?? false; + + return { + success: true, + hasServerKeys: serverKeys.exists, + hasLocalKey, + hasLegacyKey, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get key status', + }; + } + }); + + // First device: set up encryption keys with passphrase + ipcMain.handle('encryption:setupKeys', async (_event, passphrase: string) => { + try { + if (!encryption) throw new Error('Encryption service not available'); + + const result = await encryption.setupKeys(passphrase); + + // Upload to server + await client.setEncryptionKeys({ + salt: result.salt, + wrappedCek: result.wrappedCek, + wrappedCekRecovery: result.wrappedCekRecovery, + kdfParams: result.kdfParams, + }); + + return { + success: true, + recoveryKey: result.recoveryKey, // Show once to user! + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to setup encryption keys', + }; + } + }); + + // New device: unlock with passphrase + ipcMain.handle('encryption:unlockWithPassphrase', async (_event, passphrase: string) => { + try { + if (!encryption) throw new Error('Encryption service not available'); + + const serverKeys = await client.getEncryptionKeys(); + if ( + !serverKeys.exists || + !serverKeys.salt || + !serverKeys.wrappedCek || + !serverKeys.kdfParams + ) { + return { success: false, error: 'No encryption keys found on server' }; + } + + await encryption.unlockWithPassphrase( + passphrase, + serverKeys.salt, + serverKeys.wrappedCek, + serverKeys.kdfParams + ); + + return { success: true }; + } catch (error) { + const msg = error instanceof Error ? error.message : 'Failed to unlock'; + const isWrongPassphrase = msg.includes('incorrect passphrase') || msg.includes('unwrap'); + return { + success: false, + wrongPassphrase: isWrongPassphrase, + error: isWrongPassphrase ? 'Incorrect passphrase' : msg, + }; + } + }); + + // Unlock with recovery key + ipcMain.handle('encryption:unlockWithRecoveryKey', async (_event, recoveryKey: string) => { + try { + if (!encryption) throw new Error('Encryption service not available'); + + const serverKeys = await client.getEncryptionKeys(); + if (!serverKeys.exists || !serverKeys.wrappedCekRecovery) { + return { success: false, error: 'No recovery key found on server' }; + } + + await encryption.unlockWithRecoveryKey(recoveryKey, serverKeys.wrappedCekRecovery); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to unlock with recovery key', + }; + } + }); + + // Migrate legacy per-device key to key hierarchy + ipcMain.handle('encryption:migrateLegacyKey', async (_event, passphrase: string) => { + try { + if (!encryption) throw new Error('Encryption service not available'); + + const result = await encryption.migrateLegacyKey(passphrase); + + // Upload to server + await client.setEncryptionKeys({ + salt: result.salt, + wrappedCek: result.wrappedCek, + wrappedCekRecovery: result.wrappedCekRecovery, + kdfParams: result.kdfParams, + }); + + return { + success: true, + recoveryKey: result.recoveryKey, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to migrate legacy key', + }; + } + }); + + // Change passphrase (re-wrap CEK) + ipcMain.handle('encryption:changePassphrase', async (_event, newPassphrase: string) => { + try { + if (!encryption) throw new Error('Encryption service not available'); + + const result = await encryption.changePassphrase(newPassphrase); + + // Upload new wrapped key to server + await client.setEncryptionKeys({ + salt: result.salt, + wrappedCek: result.wrappedCek, + kdfParams: result.kdfParams, + }); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to change passphrase', + }; + } + }); + // ═══════════════════════════════════════════════════════════════════════════ // Subscription // ═══════════════════════════════════════════════════════════════════════════ @@ -2589,3 +2752,58 @@ if (process.defaultApp) { } else { app.setAsDefaultProtocolClient('readied'); } + +// Single instance lock + deep link handler for Windows/Linux +// On Windows/Linux, the OS launches a new process with the deep link URL as an argument. +// We use requestSingleInstanceLock to prevent multiple instances and forward the URL. +const gotTheLock = app.requestSingleInstanceLock(); + +if (!gotTheLock) { + // Another instance already has the lock — quit this one. + // The deep link URL was passed to the existing instance via second-instance event. + app.quit(); +} else { + app.on('second-instance', (_event, commandLine) => { + const log = getLogger(); + // On Windows, the deep link URL is the last argument + const deepLinkUrl = commandLine.find(arg => arg.startsWith('readied://')); + + if (deepLinkUrl) { + log.info({ url: deepLinkUrl }, 'Deep link received via second-instance (Windows/Linux)'); + + try { + const urlObj = new URL(deepLinkUrl); + + if (urlObj.hostname === 'auth' && urlObj.pathname === '/verify') { + const token = urlObj.searchParams.get('token'); + if (token) { + log.info('Auth verification token received via second-instance'); + + const mainWin = BrowserWindow.getAllWindows().find( + win => !win.isDestroyed() && win.webContents.isLoading() === false + ); + if (mainWin) { + mainWin.webContents.send('auth:verify-token', token); + mainWin.show(); + mainWin.focus(); + } else { + pendingAuthToken = token; + } + } + } + } catch (error) { + log.error( + { error: error instanceof Error ? error.message : String(error) }, + 'Failed to parse deep link from second-instance' + ); + } + } + + // Focus the existing window + const mainWin = BrowserWindow.getAllWindows().find(win => !win.isDestroyed()); + if (mainWin) { + if (mainWin.isMinimized()) mainWin.restore(); + mainWin.focus(); + } + }); +} diff --git a/apps/desktop/src/main/services/apiClient.ts b/apps/desktop/src/main/services/apiClient.ts index 70251063..882c6c40 100644 --- a/apps/desktop/src/main/services/apiClient.ts +++ b/apps/desktop/src/main/services/apiClient.ts @@ -373,7 +373,7 @@ export class ApiClient { async requestMagicLink(email: string): Promise { await this.request<{ success: boolean; message: string }>('/auth/magic-link', { method: 'POST', - body: JSON.stringify({ email }), + body: JSON.stringify({ email, client: 'desktop' }), }); } @@ -502,6 +502,39 @@ export class ApiClient { return this.request('/sync/status'); } + // ========================================================================== + // E2EE Key Management + // ========================================================================== + + /** + * Get encryption keys from server (salt, wrappedCEK, kdfParams). + * Returns { exists: false } if no keys have been set up yet. + */ + async getEncryptionKeys(): Promise<{ + exists: boolean; + salt?: string; + wrappedCek?: string; + wrappedCekRecovery?: string | null; + kdfParams?: { algorithm: string; iterations: number; hash: string }; + }> { + return this.request('/sync/keys'); + } + + /** + * Store encryption keys on server (first device setup or passphrase change). + */ + async setEncryptionKeys(data: { + salt: string; + wrappedCek: string; + wrappedCekRecovery?: string | null; + kdfParams: { algorithm: string; iterations: number; hash: string }; + }): Promise<{ success: boolean }> { + return this.request('/sync/keys', { + method: 'POST', + body: JSON.stringify(data), + }); + } + // ========================================================================== // Subscription Endpoints // ========================================================================== diff --git a/apps/desktop/src/main/services/encryptionService.ts b/apps/desktop/src/main/services/encryptionService.ts index e4e6ebba..02c806c0 100644 --- a/apps/desktop/src/main/services/encryptionService.ts +++ b/apps/desktop/src/main/services/encryptionService.ts @@ -1,15 +1,24 @@ /** * Encryption Service * - * Provides E2E encryption for note content using AES-256-GCM. - * Encryption key is stored securely using Electron's safeStorage. + * Provides E2E encryption for note content using AES-256-GCM with a + * key hierarchy: Passphrase → Master Key (MK) → Content Encryption Key (CEK). + * + * Key hierarchy: + * - Passphrase: chosen by user, never stored + * - Master Key (MK): derived via PBKDF2(passphrase, salt, 600k iterations) + * - Content Encryption Key (CEK): random AES-256 key, wrapped with MK + * - CEK is cached locally via Electron safeStorage + * + * The server stores only: salt, wrappedCEK, wrappedCEK_recovery, kdfParams. + * It never sees MK, CEK, or plaintext content. * * @module EncryptionService */ -import { randomBytes, createCipheriv, createDecipheriv } from 'crypto'; +import { randomBytes, createCipheriv, createDecipheriv, pbkdf2Sync } from 'crypto'; import { join } from 'path'; -import { readFile, writeFile } from 'fs/promises'; +import { readFile, writeFile, unlink } from 'fs/promises'; import { existsSync } from 'fs'; import { safeStorage } from 'electron'; @@ -20,38 +29,87 @@ import { safeStorage } from 'electron'; const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 12; // 96 bits recommended for GCM const KEY_LENGTH = 32; // 256 bits +const SALT_LENGTH = 32; // 256 bits +const KDF_ITERATIONS = 600_000; +const KDF_HASH = 'sha256'; + +// AES Key Wrap (RFC 3394) default IV +const AES_KW_DEFAULT_IV = Buffer.from('A6A6A6A6A6A6A6A6', 'hex'); + +// ============================================================================ +// KDF Parameters +// ============================================================================ + +export interface KdfParams { + algorithm: string; + iterations: number; + hash: string; +} + +export const DEFAULT_KDF_PARAMS: KdfParams = { + algorithm: 'pbkdf2', + iterations: KDF_ITERATIONS, + hash: KDF_HASH, +}; + +// ============================================================================ +// Key Setup Result +// ============================================================================ + +export interface KeySetupResult { + salt: string; // Base64 + wrappedCek: string; // Base64 + wrappedCekRecovery: string | null; // Base64 + recoveryKey: string | null; // Hex string shown once to user + kdfParams: KdfParams; +} // ============================================================================ // EncryptionService Class // ============================================================================ export class EncryptionService { - private key: Buffer | null = null; - private readonly keyPath: string; + private key: Buffer | null = null; // The active CEK + private readonly cekCachePath: string; + private readonly legacyKeyPath: string; constructor(dataDir: string) { - this.keyPath = join(dataDir, 'encryption.key'); + this.cekCachePath = join(dataDir, 'cek.cache'); + this.legacyKeyPath = join(dataDir, 'encryption.key'); } + // ========================================================================== + // Initialization + // ========================================================================== + /** - * Initialize encryption service - * Loads or generates encryption key + * Initialize encryption service. + * Tries to load cached CEK from safeStorage. + * Returns true if CEK is available, false if passphrase setup is needed. */ - async initialize(): Promise { + async initialize(): Promise { if (this.key) { - return; // Already initialized + return true; } try { - // Try to load existing key - if (existsSync(this.keyPath)) { - const encryptedKey = await readFile(this.keyPath); - const keyBuffer = safeStorage.decryptString(encryptedKey); - this.key = Buffer.from(keyBuffer, 'hex'); - } else { - // Generate new key - await this.generateKey(); + // Try cached CEK first (from previous passphrase setup) + if (existsSync(this.cekCachePath)) { + const encryptedCek = await readFile(this.cekCachePath); + const cekHex = safeStorage.decryptString(encryptedCek); + this.key = Buffer.from(cekHex, 'hex'); + return true; + } + + // Fallback: try legacy per-device key (pre-key-hierarchy) + if (existsSync(this.legacyKeyPath)) { + const encryptedKey = await readFile(this.legacyKeyPath); + const keyHex = safeStorage.decryptString(encryptedKey); + this.key = Buffer.from(keyHex, 'hex'); + return true; } + + return false; // No key available — passphrase setup required } catch (error) { throw new Error( `Failed to initialize encryption: ${error instanceof Error ? error.message : 'Unknown error'}` @@ -60,22 +118,125 @@ export class EncryptionService { } /** - * Generate and store a new encryption key + * Check if a CEK is loaded and ready for encrypt/decrypt operations. */ - private async generateKey(): Promise { - // Generate random key - this.key = randomBytes(KEY_LENGTH); + isReady(): boolean { + return this.key !== null; + } - // Encrypt key using OS keychain - const keyHex = this.key.toString('hex'); - const encryptedKey = safeStorage.encryptString(keyHex); + // ========================================================================== + // Key Hierarchy — First Device Setup + // ========================================================================== - // Save encrypted key to disk - await writeFile(this.keyPath, encryptedKey); + /** + * Set up encryption keys for the first time (first device). + * Generates CEK, wraps it with passphrase-derived MK, returns data to upload to server. + */ + async setupKeys(passphrase: string): Promise { + // Generate salt and CEK + const salt = randomBytes(SALT_LENGTH); + const cek = randomBytes(KEY_LENGTH); + + // Derive Master Key from passphrase + const mk = this.deriveKey(passphrase, salt, DEFAULT_KDF_PARAMS); + + // Wrap CEK with MK + const wrappedCek = this.wrapKey(mk, cek); + + // Generate recovery key and wrap CEK with it + const recoveryKeyBuf = randomBytes(KEY_LENGTH); + const recoveryKey = recoveryKeyBuf.toString('hex'); + const wrappedCekRecovery = this.wrapKey(recoveryKeyBuf, cek); + + // Cache CEK locally + await this.cacheCek(cek); + this.key = cek; + + return { + salt: salt.toString('base64'), + wrappedCek: wrappedCek.toString('base64'), + wrappedCekRecovery: wrappedCekRecovery.toString('base64'), + recoveryKey, + kdfParams: DEFAULT_KDF_PARAMS, + }; } + // ========================================================================== + // Key Hierarchy — New Device Setup + // ========================================================================== + /** - * Encrypt plaintext content using AES-256-GCM + * Unlock encryption on a new device using passphrase + server data. + * Derives MK from passphrase, unwraps CEK, caches it locally. + * Throws if passphrase is wrong (unwrap fails). + */ + async unlockWithPassphrase( + passphrase: string, + salt: string, + wrappedCek: string, + kdfParams: KdfParams + ): Promise { + const saltBuf = Buffer.from(salt, 'base64'); + const wrappedCekBuf = Buffer.from(wrappedCek, 'base64'); + + // Derive MK from passphrase + const mk = this.deriveKey(passphrase, saltBuf, kdfParams); + + // Unwrap CEK — throws if passphrase is wrong + const cek = this.unwrapKey(mk, wrappedCekBuf); + + // Cache CEK locally + await this.cacheCek(cek); + this.key = cek; + } + + /** + * Unlock encryption using recovery key + server data. + */ + async unlockWithRecoveryKey(recoveryKeyHex: string, wrappedCekRecovery: string): Promise { + const recoveryKeyBuf = Buffer.from(recoveryKeyHex, 'hex'); + const wrappedBuf = Buffer.from(wrappedCekRecovery, 'base64'); + + const cek = this.unwrapKey(recoveryKeyBuf, wrappedBuf); + + await this.cacheCek(cek); + this.key = cek; + } + + // ========================================================================== + // Passphrase Change + // ========================================================================== + + /** + * Change passphrase — re-wraps CEK with new MK. + * Returns new server data to upload. + */ + async changePassphrase(newPassphrase: string): Promise<{ + salt: string; + wrappedCek: string; + kdfParams: KdfParams; + }> { + if (!this.key) { + throw new Error('CEK not loaded — cannot change passphrase'); + } + + const salt = randomBytes(SALT_LENGTH); + const mk = this.deriveKey(newPassphrase, salt, DEFAULT_KDF_PARAMS); + const wrappedCek = this.wrapKey(mk, this.key); + + return { + salt: salt.toString('base64'), + wrappedCek: wrappedCek.toString('base64'), + kdfParams: DEFAULT_KDF_PARAMS, + }; + } + + // ========================================================================== + // Encrypt / Decrypt (unchanged interface) + // ========================================================================== + + /** + * Encrypt plaintext content using AES-256-GCM. * Format: {iv}:{ciphertext}:{authTag} (all base64 encoded) */ async encrypt(plaintext: string): Promise { @@ -84,26 +245,14 @@ export class EncryptionService { } try { - // Generate random IV (initialization vector) const iv = randomBytes(IV_LENGTH); - - // Create cipher const cipher = createCipheriv(ALGORITHM, this.key, iv); - - // Encrypt const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]); - - // Get authentication tag const authTag = cipher.getAuthTag(); - // Format: iv:ciphertext:authTag (all base64) - const result = [ - iv.toString('base64'), - encrypted.toString('base64'), - authTag.toString('base64'), - ].join(':'); - - return result; + return [iv.toString('base64'), encrypted.toString('base64'), authTag.toString('base64')].join( + ':' + ); } catch (error) { throw new Error( `Failed to encrypt content: ${error instanceof Error ? error.message : 'Unknown error'}` @@ -112,7 +261,7 @@ export class EncryptionService { } /** - * Decrypt encrypted content using AES-256-GCM + * Decrypt encrypted content using AES-256-GCM. * Expects format: {iv}:{ciphertext}:{authTag} (all base64 encoded) */ async decrypt(ciphertext: string): Promise { @@ -121,7 +270,6 @@ export class EncryptionService { } try { - // Parse encrypted format const parts = ciphertext.split(':'); if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) { throw new Error('Invalid encrypted format'); @@ -131,11 +279,8 @@ export class EncryptionService { const encrypted = Buffer.from(parts[1], 'base64'); const authTag = Buffer.from(parts[2], 'base64'); - // Create decipher const decipher = createDecipheriv(ALGORITHM, this.key, iv); decipher.setAuthTag(authTag); - - // Decrypt const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); return decrypted.toString('utf-8'); @@ -147,8 +292,7 @@ export class EncryptionService { } /** - * Check if content is encrypted (for migration purposes) - * Checks for proper encryption format: {base64}:{base64}:{base64} + * Check if content is encrypted (for migration purposes). */ isEncrypted(content: string): boolean { try { @@ -156,12 +300,9 @@ export class EncryptionService { if (parts.length !== 3) { return false; } - - // Check if all parts are valid base64 for (const part of parts) { Buffer.from(part, 'base64'); } - return true; } catch { return false; @@ -169,29 +310,21 @@ export class EncryptionService { } /** - * Re-encrypt content with a new key (for key rotation) + * Re-encrypt content with a new key (for key rotation / migration). */ async reEncrypt(oldCiphertext: string, newKey: Buffer): Promise { - // Decrypt with current key const plaintext = await this.decrypt(oldCiphertext); - - // Temporarily swap keys const oldKey = this.key; this.key = newKey; - try { - // Encrypt with new key - const newCiphertext = await this.encrypt(plaintext); - return newCiphertext; + return await this.encrypt(plaintext); } finally { - // Restore old key this.key = oldKey; } } /** - * Export encryption key (for backup purposes) - * Returns hex-encoded key + * Export the current CEK as hex (for backup/migration). */ exportKey(): string { if (!this.key) { @@ -201,19 +334,109 @@ export class EncryptionService { } /** - * Import encryption key from hex string (for restore purposes) + * Import a CEK from hex and cache it. */ async importKey(keyHex: string): Promise { try { this.key = Buffer.from(keyHex, 'hex'); - - // Save imported key - const encryptedKey = safeStorage.encryptString(keyHex); - await writeFile(this.keyPath, encryptedKey); + await this.cacheCek(this.key); } catch (error) { throw new Error( `Failed to import key: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } + + // ========================================================================== + // Legacy Key Migration + // ========================================================================== + + /** + * Check if a legacy per-device key exists (pre-key-hierarchy). + * Used to detect existing installations that need migration. + */ + hasLegacyKey(): boolean { + return existsSync(this.legacyKeyPath) && !existsSync(this.cekCachePath); + } + + /** + * Migrate from legacy per-device key to key hierarchy. + * The legacy key becomes the CEK — it gets wrapped and uploaded to server. + * Returns setup result (same as setupKeys) to upload to server. + */ + async migrateLegacyKey(passphrase: string): Promise { + if (!this.key) { + throw new Error('Legacy key not loaded'); + } + + const cek = this.key; // Current legacy key becomes the CEK + const salt = randomBytes(SALT_LENGTH); + const mk = this.deriveKey(passphrase, salt, DEFAULT_KDF_PARAMS); + const wrappedCek = this.wrapKey(mk, cek); + + // Generate recovery key + const recoveryKeyBuf = randomBytes(KEY_LENGTH); + const recoveryKey = recoveryKeyBuf.toString('hex'); + const wrappedCekRecovery = this.wrapKey(recoveryKeyBuf, cek); + + // Cache as new-format CEK + await this.cacheCek(cek); + + // Remove legacy key file + try { + await unlink(this.legacyKeyPath); + } catch { + // Ignore — may not exist + } + + return { + salt: salt.toString('base64'), + wrappedCek: wrappedCek.toString('base64'), + wrappedCekRecovery: wrappedCekRecovery.toString('base64'), + recoveryKey, + kdfParams: DEFAULT_KDF_PARAMS, + }; + } + + // ========================================================================== + // Internal — Key Derivation & Wrapping + // ========================================================================== + + /** + * Derive a key from passphrase using PBKDF2. + */ + private deriveKey(passphrase: string, salt: Buffer, params: KdfParams): Buffer { + return pbkdf2Sync(passphrase, salt, params.iterations, KEY_LENGTH, params.hash); + } + + /** + * Wrap a key using AES-256-KW (RFC 3394). + * Uses Node.js crypto aes-256-wrap with the standard IV. + */ + private wrapKey(wrappingKey: Buffer, keyToWrap: Buffer): Buffer { + const cipher = createCipheriv('aes-256-wrap' as string, wrappingKey, AES_KW_DEFAULT_IV); + return Buffer.concat([cipher.update(keyToWrap), cipher.final()]); + } + + /** + * Unwrap a key using AES-256-KW (RFC 3394). + * Throws if the wrapping key is incorrect. + */ + private unwrapKey(wrappingKey: Buffer, wrappedKey: Buffer): Buffer { + try { + const decipher = createDecipheriv('aes-256-wrap' as string, wrappingKey, AES_KW_DEFAULT_IV); + return Buffer.concat([decipher.update(wrappedKey), decipher.final()]); + } catch { + throw new Error('Failed to unwrap key — incorrect passphrase or corrupted data'); + } + } + + /** + * Cache CEK locally using Electron safeStorage. + */ + private async cacheCek(cek: Buffer): Promise { + const cekHex = cek.toString('hex'); + const encryptedCek = safeStorage.encryptString(cekHex); + await writeFile(this.cekCachePath, encryptedCek); + } } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index c645c374..208ae80e 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -644,6 +644,44 @@ export interface ReadiedAPI { exportKey: () => Promise<{ success: boolean; key?: string; error?: string }>; /** Import encryption key from backup */ importKey: (keyHex: string) => Promise<{ success: boolean; error?: string }>; + /** Check if encryption is ready (CEK cached locally) */ + isReady: () => Promise<{ ready: boolean }>; + /** Get key status — server keys, local cache, legacy key */ + getKeyStatus: () => Promise<{ + success: boolean; + hasServerKeys?: boolean; + hasLocalKey?: boolean; + hasLegacyKey?: boolean; + error?: string; + }>; + /** First device: set up encryption keys with passphrase */ + setupKeys: (passphrase: string) => Promise<{ + success: boolean; + recoveryKey?: string | null; + error?: string; + }>; + /** New device: unlock with passphrase */ + unlockWithPassphrase: (passphrase: string) => Promise<{ + success: boolean; + wrongPassphrase?: boolean; + error?: string; + }>; + /** Unlock with recovery key */ + unlockWithRecoveryKey: (recoveryKey: string) => Promise<{ + success: boolean; + error?: string; + }>; + /** Migrate legacy per-device key to key hierarchy */ + migrateLegacyKey: (passphrase: string) => Promise<{ + success: boolean; + recoveryKey?: string | null; + error?: string; + }>; + /** Change passphrase (re-wrap CEK) */ + changePassphrase: (newPassphrase: string) => Promise<{ + success: boolean; + error?: string; + }>; }; git: { /** Initialize git repository for a notebook */ @@ -992,6 +1030,17 @@ const api: ReadiedAPI = { encryption: { exportKey: () => ipcRenderer.invoke('encryption:exportKey'), importKey: (keyHex: string) => ipcRenderer.invoke('encryption:importKey', keyHex), + isReady: () => ipcRenderer.invoke('encryption:isReady'), + getKeyStatus: () => ipcRenderer.invoke('encryption:getKeyStatus'), + setupKeys: (passphrase: string) => ipcRenderer.invoke('encryption:setupKeys', passphrase), + unlockWithPassphrase: (passphrase: string) => + ipcRenderer.invoke('encryption:unlockWithPassphrase', passphrase), + unlockWithRecoveryKey: (recoveryKey: string) => + ipcRenderer.invoke('encryption:unlockWithRecoveryKey', recoveryKey), + migrateLegacyKey: (passphrase: string) => + ipcRenderer.invoke('encryption:migrateLegacyKey', passphrase), + changePassphrase: (newPassphrase: string) => + ipcRenderer.invoke('encryption:changePassphrase', newPassphrase), }, git: { init: (notebookId: string) => ipcRenderer.invoke('git:init', notebookId), diff --git a/PLUGIN_SYSTEM.md b/docs/PLUGIN_SYSTEM.md similarity index 100% rename from PLUGIN_SYSTEM.md rename to docs/PLUGIN_SYSTEM.md diff --git a/docs/plans/2026-02-18-marketing-site-redesign-design.md b/docs/archived/plans-2026/2026-02-18-marketing-site-redesign-design.md similarity index 100% rename from docs/plans/2026-02-18-marketing-site-redesign-design.md rename to docs/archived/plans-2026/2026-02-18-marketing-site-redesign-design.md diff --git a/docs/plans/2026-02-18-marketing-site-redesign.md b/docs/archived/plans-2026/2026-02-18-marketing-site-redesign.md similarity index 100% rename from docs/plans/2026-02-18-marketing-site-redesign.md rename to docs/archived/plans-2026/2026-02-18-marketing-site-redesign.md diff --git a/docs/plans/2026-02-19-phase1-fix-and-polish.md b/docs/archived/plans-2026/2026-02-19-phase1-fix-and-polish.md similarity index 100% rename from docs/plans/2026-02-19-phase1-fix-and-polish.md rename to docs/archived/plans-2026/2026-02-19-phase1-fix-and-polish.md diff --git a/docs/plans/2026-02-19-phase2-plugin-marketplace.md b/docs/archived/plans-2026/2026-02-19-phase2-plugin-marketplace.md similarity index 100% rename from docs/plans/2026-02-19-phase2-plugin-marketplace.md rename to docs/archived/plans-2026/2026-02-19-phase2-plugin-marketplace.md diff --git a/docs/plans/2026-02-19-plugin-ecosystem-design.md b/docs/archived/plans-2026/2026-02-19-plugin-ecosystem-design.md similarity index 100% rename from docs/plans/2026-02-19-plugin-ecosystem-design.md rename to docs/archived/plans-2026/2026-02-19-plugin-ecosystem-design.md diff --git a/docs/plans/2026-03-11-data-access-api-design.md b/docs/archived/plans-2026/2026-03-11-data-access-api-design.md similarity index 100% rename from docs/plans/2026-03-11-data-access-api-design.md rename to docs/archived/plans-2026/2026-03-11-data-access-api-design.md diff --git a/docs/plans/2026-03-11-data-access-api-implementation.md b/docs/archived/plans-2026/2026-03-11-data-access-api-implementation.md similarity index 100% rename from docs/plans/2026-03-11-data-access-api-implementation.md rename to docs/archived/plans-2026/2026-03-11-data-access-api-implementation.md diff --git a/docs/plans/2026-03-11-notebook-sync-design.md b/docs/archived/plans-2026/2026-03-11-notebook-sync-design.md similarity index 100% rename from docs/plans/2026-03-11-notebook-sync-design.md rename to docs/archived/plans-2026/2026-03-11-notebook-sync-design.md diff --git a/docs/plans/2026-03-11-notebook-sync-implementation.md b/docs/archived/plans-2026/2026-03-11-notebook-sync-implementation.md similarity index 100% rename from docs/plans/2026-03-11-notebook-sync-implementation.md rename to docs/archived/plans-2026/2026-03-11-notebook-sync-implementation.md diff --git a/docs/plans/2026-03-11-phase2-completion-design.md b/docs/archived/plans-2026/2026-03-11-phase2-completion-design.md similarity index 100% rename from docs/plans/2026-03-11-phase2-completion-design.md rename to docs/archived/plans-2026/2026-03-11-phase2-completion-design.md diff --git a/docs/plans/2026-03-11-phase2-completion-implementation.md b/docs/archived/plans-2026/2026-03-11-phase2-completion-implementation.md similarity index 100% rename from docs/plans/2026-03-11-phase2-completion-implementation.md rename to docs/archived/plans-2026/2026-03-11-phase2-completion-implementation.md diff --git a/docs/plans/2026-03-11-remark-rehype-hooks-enhancement.md b/docs/archived/plans-2026/2026-03-11-remark-rehype-hooks-enhancement.md similarity index 100% rename from docs/plans/2026-03-11-remark-rehype-hooks-enhancement.md rename to docs/archived/plans-2026/2026-03-11-remark-rehype-hooks-enhancement.md diff --git a/docs/plans/2026-03-11-sync-hardening-design.md b/docs/archived/plans-2026/2026-03-11-sync-hardening-design.md similarity index 100% rename from docs/plans/2026-03-11-sync-hardening-design.md rename to docs/archived/plans-2026/2026-03-11-sync-hardening-design.md diff --git a/docs/plans/2026-03-11-sync-hardening-implementation.md b/docs/archived/plans-2026/2026-03-11-sync-hardening-implementation.md similarity index 100% rename from docs/plans/2026-03-11-sync-hardening-implementation.md rename to docs/archived/plans-2026/2026-03-11-sync-hardening-implementation.md diff --git a/docs/plans/2026-03-11-theme-system-design.md b/docs/archived/plans-2026/2026-03-11-theme-system-design.md similarity index 100% rename from docs/plans/2026-03-11-theme-system-design.md rename to docs/archived/plans-2026/2026-03-11-theme-system-design.md diff --git a/docs/plans/2026-03-11-theme-system-implementation.md b/docs/archived/plans-2026/2026-03-11-theme-system-implementation.md similarity index 100% rename from docs/plans/2026-03-11-theme-system-implementation.md rename to docs/archived/plans-2026/2026-03-11-theme-system-implementation.md diff --git a/docs/plans/2026-03-12-roadmap-auth-sync-ai.md b/docs/archived/plans-2026/2026-03-12-roadmap-auth-sync-ai.md similarity index 100% rename from docs/plans/2026-03-12-roadmap-auth-sync-ai.md rename to docs/archived/plans-2026/2026-03-12-roadmap-auth-sync-ai.md diff --git a/docs/plans/2026-03-12-website-redesign-design.md b/docs/archived/plans-2026/2026-03-12-website-redesign-design.md similarity index 100% rename from docs/plans/2026-03-12-website-redesign-design.md rename to docs/archived/plans-2026/2026-03-12-website-redesign-design.md diff --git a/docs/plans/2026-03-12-website-redesign-implementation.md b/docs/archived/plans-2026/2026-03-12-website-redesign-implementation.md similarity index 100% rename from docs/plans/2026-03-12-website-redesign-implementation.md rename to docs/archived/plans-2026/2026-03-12-website-redesign-implementation.md diff --git a/docs/plans/api-reference.md b/docs/archived/plans-2026/api-reference.md similarity index 100% rename from docs/plans/api-reference.md rename to docs/archived/plans-2026/api-reference.md diff --git a/docs/superpowers/specs/2026-03-14-ai-core-provider-abstraction-design.md b/docs/archived/superpowers-2026/2026-03-14-ai-core-provider-abstraction-design.md similarity index 100% rename from docs/superpowers/specs/2026-03-14-ai-core-provider-abstraction-design.md rename to docs/archived/superpowers-2026/2026-03-14-ai-core-provider-abstraction-design.md diff --git a/docs/superpowers/plans/2026-03-14-ai-core-provider-abstraction.md b/docs/archived/superpowers-2026/2026-03-14-ai-core-provider-abstraction.md similarity index 100% rename from docs/superpowers/plans/2026-03-14-ai-core-provider-abstraction.md rename to docs/archived/superpowers-2026/2026-03-14-ai-core-provider-abstraction.md diff --git a/docs/superpowers/specs/2026-03-14-automated-release-pipeline-design.md b/docs/archived/superpowers-2026/2026-03-14-automated-release-pipeline-design.md similarity index 100% rename from docs/superpowers/specs/2026-03-14-automated-release-pipeline-design.md rename to docs/archived/superpowers-2026/2026-03-14-automated-release-pipeline-design.md diff --git a/docs/superpowers/plans/2026-03-14-automated-release-pipeline.md b/docs/archived/superpowers-2026/2026-03-14-automated-release-pipeline.md similarity index 100% rename from docs/superpowers/plans/2026-03-14-automated-release-pipeline.md rename to docs/archived/superpowers-2026/2026-03-14-automated-release-pipeline.md diff --git a/docs/superpowers/specs/2026-03-18-ai-tool-use-design.md b/docs/archived/superpowers-2026/2026-03-18-ai-tool-use-design.md similarity index 100% rename from docs/superpowers/specs/2026-03-18-ai-tool-use-design.md rename to docs/archived/superpowers-2026/2026-03-18-ai-tool-use-design.md diff --git a/docs/superpowers/plans/2026-03-18-ai-tool-use.md b/docs/archived/superpowers-2026/2026-03-18-ai-tool-use.md similarity index 100% rename from docs/superpowers/plans/2026-03-18-ai-tool-use.md rename to docs/archived/superpowers-2026/2026-03-18-ai-tool-use.md diff --git a/packages/api/docs/TODO_MONITORING.md b/packages/api/docs/OBSERVABILITY.md similarity index 95% rename from packages/api/docs/TODO_MONITORING.md rename to packages/api/docs/OBSERVABILITY.md index d8fc5a37..5aa4bcd0 100644 --- a/packages/api/docs/TODO_MONITORING.md +++ b/packages/api/docs/OBSERVABILITY.md @@ -1,6 +1,8 @@ -# TODO: Monitoring & Observability +# Monitoring & Observability -## Sentry Setup (Pending) +> **Status: Not yet implemented.** This document outlines the planned observability setup for the API. + +## Sentry Setup (Planned) ### Why Sentry? diff --git a/packages/api/drizzle/0005_sync_tables.sql b/packages/api/drizzle/0005_sync_tables.sql new file mode 100644 index 00000000..15f87ca5 --- /dev/null +++ b/packages/api/drizzle/0005_sync_tables.sql @@ -0,0 +1,31 @@ +CREATE TABLE `tag_sync_log` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `tag_id` text NOT NULL, + `version` integer NOT NULL, + `operation` text NOT NULL, + `data` text, + `device_id` text NOT NULL, + `created_at` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `idx_tag_sync_log_user_version` ON `tag_sync_log` (`user_id`,`version`); +--> statement-breakpoint +CREATE INDEX `idx_tag_sync_log_user_tag` ON `tag_sync_log` (`user_id`,`tag_id`); +--> statement-breakpoint +CREATE TABLE `notebook_sync_log` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `notebook_id` text NOT NULL, + `version` integer NOT NULL, + `operation` text NOT NULL, + `data` text, + `device_id` text NOT NULL, + `created_at` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `idx_nb_sync_log_user_version` ON `notebook_sync_log` (`user_id`,`version`); +--> statement-breakpoint +CREATE INDEX `idx_nb_sync_log_user_notebook` ON `notebook_sync_log` (`user_id`,`notebook_id`); diff --git a/packages/api/drizzle/0006_shared_notes_columns.sql b/packages/api/drizzle/0006_shared_notes_columns.sql new file mode 100644 index 00000000..e5f0444b --- /dev/null +++ b/packages/api/drizzle/0006_shared_notes_columns.sql @@ -0,0 +1,7 @@ +ALTER TABLE `shared_notes` ADD `tags` text NOT NULL DEFAULT '[]'; +--> statement-breakpoint +ALTER TABLE `shared_notes` ADD `backlinks` text NOT NULL DEFAULT '[]'; +--> statement-breakpoint +ALTER TABLE `shared_notes` ADD `word_count` integer NOT NULL DEFAULT 0; +--> statement-breakpoint +ALTER TABLE `shared_notes` ADD `notebook_name` text NOT NULL DEFAULT ''; diff --git a/packages/api/drizzle/0007_user_keys.sql b/packages/api/drizzle/0007_user_keys.sql new file mode 100644 index 00000000..2215466c --- /dev/null +++ b/packages/api/drizzle/0007_user_keys.sql @@ -0,0 +1,13 @@ +CREATE TABLE `user_keys` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `salt` text NOT NULL, + `wrapped_cek` text NOT NULL, + `wrapped_cek_recovery` text, + `kdf_params` text NOT NULL, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `user_keys_user_id_unique` ON `user_keys` (`user_id`); diff --git a/packages/api/drizzle/meta/_journal.json b/packages/api/drizzle/meta/_journal.json index ddb58d62..b3c57477 100644 --- a/packages/api/drizzle/meta/_journal.json +++ b/packages/api/drizzle/meta/_journal.json @@ -36,6 +36,27 @@ "when": 1740096000000, "tag": "0004_plugin_catalog", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1743264000000, + "tag": "0005_sync_tables", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1743264000001, + "tag": "0006_shared_notes_columns", + "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1743264000002, + "tag": "0007_user_keys", + "breakpoints": true } ] } diff --git a/packages/api/src/db/schema.ts b/packages/api/src/db/schema.ts index 2f3c6436..0b10b4ab 100644 --- a/packages/api/src/db/schema.ts +++ b/packages/api/src/db/schema.ts @@ -300,6 +300,31 @@ export const pluginCatalog = sqliteTable( ] ); +/** + * User encryption keys — E2EE key hierarchy + * Stores wrapped Content Encryption Key (CEK) and KDF parameters. + * Server never sees the Master Key or plaintext CEK. + */ +export const userKeys = sqliteTable('user_keys', { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: text('user_id') + .notNull() + .unique() + .references(() => users.id, { onDelete: 'cascade' }), + salt: text('salt').notNull(), // Base64-encoded PBKDF2 salt + wrappedCek: text('wrapped_cek').notNull(), // CEK wrapped with Master Key (base64) + wrappedCekRecovery: text('wrapped_cek_recovery'), // CEK wrapped with recovery key (base64, optional) + kdfParams: text('kdf_params').notNull(), // JSON: { algorithm, iterations, hash } + createdAt: text('created_at') + .notNull() + .$defaultFn(() => new Date().toISOString()), + updatedAt: text('updated_at') + .notNull() + .$defaultFn(() => new Date().toISOString()), +}); + // Type exports for use in routes export type User = typeof users.$inferSelect; export type NewUser = typeof users.$inferInsert; @@ -312,3 +337,4 @@ export type SharedNote = typeof sharedNotes.$inferSelect; export type PluginCatalogEntry = typeof pluginCatalog.$inferSelect; export type NewPluginCatalogEntry = typeof pluginCatalog.$inferInsert; export type NotebookSyncLogEntry = typeof notebookSyncLog.$inferSelect; +export type UserKeys = typeof userKeys.$inferSelect; diff --git a/packages/api/src/routes/auth.ts b/packages/api/src/routes/auth.ts index 8e67b668..d2c0c49c 100644 --- a/packages/api/src/routes/auth.ts +++ b/packages/api/src/routes/auth.ts @@ -12,7 +12,7 @@ import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { eq, and, gt, isNull } from 'drizzle-orm'; import { createDb, type Env } from '../db/client.js'; -import { users, magicLinks, devices } from '../db/schema.js'; +import { users, magicLinks, devices, subscriptions } from '../db/schema.js'; import { createTokens, verifyRefreshToken, authMiddleware } from '../middleware/auth.js'; import { authRateLimit } from '../middleware/rateLimit.js'; import { createEmailService } from '../services/email.js'; @@ -25,10 +25,11 @@ auth.use('*', authRateLimit); // Request magic link const magicLinkSchema = z.object({ email: z.string().email(), + client: z.enum(['web', 'desktop']).optional().default('web'), }); auth.post('/magic-link', zValidator('json', magicLinkSchema), async c => { - const { email } = c.req.valid('json'); + const { email, client } = c.req.valid('json'); const db = createDb(c.env); // Find or create user @@ -48,9 +49,12 @@ auth.post('/magic-link', zValidator('json', magicLinkSchema), async c => { expiresAt, }); - // Send email + // Send email — desktop clients get a readied:// deep link URL const emailService = createEmailService(c.env.RESEND_API_KEY); - const magicLinkUrl = `https://readied.app/auth/verify?token=${token}`; + const magicLinkUrl = + client === 'desktop' + ? `readied://auth/verify?token=${token}` + : `https://readied.app/auth/verify?token=${token}`; const emailSent = await emailService.sendMagicLink(email, magicLinkUrl); if (!emailSent) { @@ -123,6 +127,24 @@ auth.post('/verify', zValidator('json', verifySchema), async c => { }); } + // Auto-create trial subscription for new users + const [existingSub] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.userId, user.id)) + .limit(1); + + if (!existingSub) { + const trialDays = 14; + const trialEndsAt = new Date(Date.now() + trialDays * 24 * 60 * 60 * 1000).toISOString(); + await db.insert(subscriptions).values({ + userId: user.id, + status: 'trialing', + plan: 'pro', + trialEndsAt, + }); + } + // Generate tokens const tokens = await createTokens(c.env.JWT_SECRET, user, deviceId); diff --git a/packages/api/src/routes/sync.ts b/packages/api/src/routes/sync.ts index d9f79e58..2804bfa2 100644 --- a/packages/api/src/routes/sync.ts +++ b/packages/api/src/routes/sync.ts @@ -14,7 +14,14 @@ import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { eq, and, gt, desc, sql } from 'drizzle-orm'; import { createDb, type Env } from '../db/client.js'; -import { syncLog, syncCursors, subscriptions, tagSyncLog, notebookSyncLog } from '../db/schema.js'; +import { + syncLog, + syncCursors, + subscriptions, + tagSyncLog, + notebookSyncLog, + userKeys, +} from '../db/schema.js'; import { authMiddleware, type AuthUser } from '../middleware/auth.js'; import { syncRateLimit } from '../middleware/rateLimit.js'; @@ -616,4 +623,68 @@ sync.post('/tags', zValidator('json', tagPushSchema), async c => { return c.json({ results, cursor: finalCursor }); }); +// ============================================================================ +// E2EE Key Management +// ============================================================================ + +const postKeysSchema = z.object({ + salt: z.string().min(1), // Base64-encoded salt + wrappedCek: z.string().min(1), // Base64-encoded wrapped CEK + wrappedCekRecovery: z.string().nullable().optional(), // Base64-encoded wrapped CEK (recovery) + kdfParams: z.object({ + algorithm: z.string(), + iterations: z.number().int().min(1), + hash: z.string(), + }), +}); + +// Get encryption keys for the current user +sync.get('/keys', async c => { + const { userId } = c.get('user'); + const db = createDb(c.env); + + const [keys] = await db.select().from(userKeys).where(eq(userKeys.userId, userId)).limit(1); + + if (!keys) { + return c.json({ exists: false }, 200); + } + + return c.json({ + exists: true, + salt: keys.salt, + wrappedCek: keys.wrappedCek, + wrappedCekRecovery: keys.wrappedCekRecovery, + kdfParams: JSON.parse(keys.kdfParams), + }); +}); + +// Store encryption keys (first device setup or passphrase change) +sync.post('/keys', zValidator('json', postKeysSchema), async c => { + const { salt, wrappedCek, wrappedCekRecovery, kdfParams } = c.req.valid('json'); + const { userId } = c.get('user'); + const db = createDb(c.env); + + await db + .insert(userKeys) + .values({ + userId, + salt, + wrappedCek, + wrappedCekRecovery: wrappedCekRecovery ?? null, + kdfParams: JSON.stringify(kdfParams), + }) + .onConflictDoUpdate({ + target: [userKeys.userId], + set: { + salt, + wrappedCek, + wrappedCekRecovery: wrappedCekRecovery ?? null, + kdfParams: JSON.stringify(kdfParams), + updatedAt: new Date().toISOString(), + }, + }); + + return c.json({ success: true }); +}); + export { sync };