Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7a51923
docs: add config sync design spec
mrhuangyong Jun 2, 2026
4d450ad
docs: add config sync implementation plan
mrhuangyong Jun 2, 2026
0560e5d
feat(sync): add sync_state migration and LocalDb CRUD methods
mrhuangyong Jun 2, 2026
61611fa
feat(sync): add SyncProvider trait and config types
mrhuangyong Jun 2, 2026
f4b535f
feat(sync): add crypto engine with PBKDF2 + AES-256-GCM
mrhuangyong Jun 2, 2026
c84be60
feat(sync): add S3 provider with AWS Signature V4
mrhuangyong Jun 2, 2026
ff777bf
feat(sync): add WebDAV provider
mrhuangyong Jun 2, 2026
79cbcde
feat(sync): add SyncManager with export/import/merge logic
mrhuangyong Jun 2, 2026
8fbe251
feat(sync): add Tauri commands and register in invoke handler
mrhuangyong Jun 2, 2026
6bd1909
feat(sync): add sync API types and invoke wrappers
mrhuangyong Jun 2, 2026
f813399
feat(sync): add SyncSettings component and Settings tab
mrhuangyong Jun 2, 2026
adde35b
style(sync): format with prettier
mrhuangyong Jun 2, 2026
b5c0f80
fix(sync): remove unused SyncProvider import in manager
mrhuangyong Jun 2, 2026
de7acb1
fix(sync): use explicit serde rename for ProviderType enum
mrhuangyong Jun 2, 2026
62e2eaa
fix(sync): add i18n support and config echo-back for sync settings
mrhuangyong Jun 2, 2026
cc2edcd
fix(sync): persist sync password locally so users don't need to re-en…
mrhuangyong Jun 2, 2026
82d8b34
fix(sync): handle password migration for existing configured sync
mrhuangyong Jun 2, 2026
effd00f
fix(sync): map dbType→driver field when importing connections
mrhuangyong Jun 2, 2026
58e1759
feat(sync): add auto-sync with periodic timer and event-driven push
mrhuangyong Jun 2, 2026
406d663
feat(sync): support custom auto-sync interval
mrhuangyong Jun 2, 2026
5c44df2
fix(sync): use tauri::async_runtime::spawn to avoid missing Tokio rea…
mrhuangyong Jun 2, 2026
813b9fa
Merge pull request #1 from mrhuangyong/feat/config-sync
mrhuangyong Jun 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,029 changes: 2,029 additions & 0 deletions docs/superpowers/plans/2026-06-02-config-sync.md

Large diffs are not rendered by default.

245 changes: 245 additions & 0 deletions docs/superpowers/specs/2026-06-02-config-sync-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
# Config Sync Design — DbPaw

## Summary

Add configuration synchronization across devices via S3 or WebDAV, with end-to-end encryption. Supports manual and automatic sync modes with Last-Write-Wins conflict resolution.

## Scope

### Synced Data
- Database connection configurations (connections table)
- Saved queries (saved_queries table)
- AI provider configurations (ai_providers table, excluding conversations/messages)
- User settings (settings.json via tauri-plugin-store)
- Keyboard shortcuts (stored in settings)

### NOT Synced
- AI conversations and messages — device-local, high volume
- SQL/Redis execution logs — device-local, transient
- AI master key — per-device encryption key
- Connection pool state — runtime-only

## Architecture

```
Frontend (React)
SettingsDialog → Sync Tab (SyncSettings.tsx)
▼ invoke()
Backend (Rust)
SyncManager
├── CryptoEngine (PBKDF2 + AES-256-GCM)
├── Snapshot export/import
├── Change detection (SHA-256 hash)
└── Auto-sync timer (tokio interval)
▼ SyncProvider trait
┌────────┬──────────┐
│ S3 │ WebDAV │
└────────┴──────────┘
```

## SyncProvider Trait

```rust
#[async_trait]
pub trait SyncProvider: Send + Sync {
async fn test_connection(&self) -> Result<(), String>;
async fn put_object(&self, key: &str, data: &[u8]) -> Result<(), String>;
async fn get_object(&self, key: &str) -> Result<Option<Vec<u8>>, String>;
async fn delete_object(&self, key: &str) -> Result<(), String>;
}
```

### S3 Provider
- Config: endpoint, region, bucket, access_key_id, secret_access_key, path_prefix
- Uses `reqwest` + manual AWS Signature V4 (no heavy AWS SDK dependency)
- Remote path: `s3://{bucket}/{path_prefix}sync_snapshot.enc`

### WebDAV Provider
- Config: server_url, username, password
- Uses `reqwest` with standard HTTP PUT/GET/DELETE
- Remote path: `{server_url}/sync_snapshot.enc`

## Encryption

```
User password → PBKDF2-SHA256 (600k iterations, random 16-byte salt) → AES-256-GCM key
Plaintext snapshot JSON → AES-256-GCM (random 12-byte nonce) → ciphertext

File format: [16 bytes salt][12 bytes nonce][ciphertext + GCM tag]
```

- Snapshot includes a `snapshot_hash` (SHA-256 of plaintext) for integrity verification after decryption
- Wrong password → GCM tag verification fails → user-friendly error

## Snapshot Format

```json
{
"version": 1,
"device_id": "uuid",
"timestamp": "2026-06-02T10:30:00Z",
"snapshot_hash": "sha256-of-plaintext",
"data": {
"connections": [...],
"saved_queries": [...],
"ai_providers": [...],
"settings": { "key": "value" }
}
}
```

### Sensitive Field Handling
- Connection passwords: plaintext inside encrypted snapshot (E2E encryption protects in transit/at rest)
- AI API Keys: plaintext inside snapshot; on import, re-encrypted with local `ai_master.key`
- SSH key paths: synced as-is (users may need to verify paths on different OS)
- Provider credentials (S3 secret key / WebDAV password): stored locally encrypted with `ai_master.key`

## Sync Mode: Hybrid

### Auto Sync (default)
- App startup → 30s delay (wait for LocalDb init) → first pull
- Every 5 minutes → hash comparison → push if changed
- Configurable interval
- Silent failure on network errors, recorded in `last_sync_result`

### Manual Sync
- "Sync Now" → pull + push
- "Force Push" → local overwrites remote
- "Force Pull" → remote overwrites local

## Conflict Resolution: Last-Write-Wins

```
Pull remote → compare timestamps:
- remote.timestamp > local.timestamp AND remote.device_id != local.device_id → apply remote
- otherwise → skip (local is newer or same device)
```

Applying remote data:
1. Clear local tables (connections, saved_queries, ai_providers)
2. Insert remote data
3. Update settings.json keys
4. Re-encrypt AI API keys with local `ai_master.key`
5. Update `last_synced_hash`

## Change Detection

```
local data → export to JSON → SHA-256 hash → compare with last_synced_hash
- different → local has changes → push
- same → no changes → skip
```

`last_synced_hash` stored in `sync_state` table in SQLite.

## Database: sync_state Table

```sql
CREATE TABLE IF NOT EXISTS sync_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```

Keys: `device_id`, `sync_config` (JSON, provider params without passwords), `sync_enabled`, `last_synced_hash`, `last_sync_at`, `last_sync_result`, `sync_password_hash` (for verification without storing plaintext)

Comment on lines +137 to +148

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid storing a fast password verifier in sync_state.

A sync_password_hash in local SQLite creates an offline brute-force target if the DB is exposed, and it does not buy you much because decrypting the snapshot already verifies the password. Prefer dropping the verifier entirely, or store a salted slow KDF output instead of a raw hash.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/specs/2026-06-02-config-sync-design.md` around lines 137 -
148, Remove the fast password verifier stored as the key "sync_password_hash" in
the sync_state table and any code paths that read/write that key (references to
sync_state and "sync_password_hash"); either drop the key from the schema and
associated logic, or if a verifier must be kept, replace it with a salted, slow
KDF output (e.g., PBKDF2/Argon2) and update all read/write/verification code to
use the KDF output and salt rather than a raw hash so offline brute-force is not
trivial.

## Tauri Commands

| Command | Purpose |
|---------|---------|
| `sync_test_connection(config)` | Validate provider connectivity |
| `sync_configure(config, sync_password)` | Save config + first upload |
| `sync_get_status()` | Return current sync state |
| `sync_now()` | Manual pull + push |
| `sync_force_push()` | Local overwrites remote |
| `sync_force_pull()` | Remote overwrites local |
| `sync_disable()` | Turn off sync, keep config |
| `sync_update_password(old, new)` | Re-encrypt with new password |

## Frontend

### New Files
- `src/components/settings/SyncSettings.tsx` — Sync tab in Settings dialog
- Type definitions for SyncConfig, SyncStatus, SyncResult

### Modified Files
- `src/services/api.ts` — Add `syncApi` namespace
- `src/components/settings/SettingsDialog.tsx` — Add Sync tab

### UI Layout
- Provider selector dropdown (S3 / WebDAV)
- Dynamic form fields based on provider
- Sync password + confirmation inputs
- Test Connection button with status indicator
- Auto-sync toggle + interval selector
- Sync status display (device ID, last sync time, result)
- Action buttons: Sync Now, Force Push, Force Pull, Disable

## Backend Files

### New Files
| File | Purpose |
|------|---------|
| `src-tauri/src/sync/mod.rs` | Module entry |
| `src-tauri/src/sync/provider.rs` | SyncProvider trait |
| `src-tauri/src/sync/crypto.rs` | PBKDF2 + AES-256-GCM |
| `src-tauri/src/sync/manager.rs` | SyncManager (export/import/timer/hash) |
| `src-tauri/src/sync/s3.rs` | S3 implementation (reqwest + Sig V4) |
| `src-tauri/src/sync/webdav.rs` | WebDAV implementation (reqwest) |
| `src-tauri/src/commands/sync.rs` | Tauri command handlers |
| `src-tauri/migrations/017_sync_state.sql` | sync_state table migration |

### Modified Files
| File | Change |
|------|--------|
| `src-tauri/src/lib.rs` | Register sync commands, start/stop auto-sync on app lifecycle |
| `src-tauri/src/state.rs` | Add `sync_manager` to AppState |
| `src-tauri/src/db/local.rs` | Add sync_state CRUD methods |
| `src-tauri/Cargo.toml` | Add `sha2`, `hmac`, `pbkdf2` dependencies |

## New Rust Dependencies

```toml
sha2 = "0.10"
hmac = "0.12"
pbkdf2 = "0.12"
# aes-gcm, reqwest, serde_json, chrono — already present
```

## Error Prefixes

- `[SYNC_CONFIG_ERROR]` — Invalid configuration
- `[SYNC_CONNECTION_ERROR]` — Remote connection failure
- `[SYNC_CRYPTO_ERROR]` — Encryption/decryption failure
- `[SYNC_MERGE_ERROR]` — Data merge failure
- `[SYNC_PASSWORD_ERROR]` — Wrong sync password

## Edge Cases

| Scenario | Handling |
|----------|----------|
| Remote has no snapshot | First sync, push local data |
| Fresh install (no local data) | Pull remote data |
| Wrong sync password | GCM tag verification fails, show error |
| Remote unreachable | Auto-sync silent fail, record error, no impact on normal usage |
| App exit during sync | Cancel in-progress sync, don't block exit |
| Concurrent sync (two windows) | Mutex ensures single operation at a time |
| Schema version mismatch | Snapshot `version` field check on import |
| Rapid multi-device edits | Last-Write-Wins may lose intermediate changes (user-accepted tradeoff) |

## Auto-Sync Lifecycle

```
App startup → LocalDb init complete
→ SyncManager::new(state)
→ Read sync_state: enabled?
→ Yes: delay 30s → pull → start interval timer (5min, configurable)
→ No: idle

App exit (RunEvent::Exit):
→ Cancel timer
→ Don't wait for in-progress sync
```
6 changes: 6 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ axum = "0.8"
tower = "0.5"
tower-http = "0.6"
async-stream = "0.3"
sha2 = "0.10"
hmac = "0.12"
pbkdf2 = "0.12"
hex = "0.4"
url = "2"

[target.'cfg(windows)'.dependencies]
tiberius = { version = "0.12", features = ["winauth"] }
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/migrations/017_sync_state.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS sync_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
14 changes: 11 additions & 3 deletions src-tauri/src/commands/ai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ pub async fn ai_create_provider(
normalize_provider_form(&mut config, Some("openai"))?;
let db = get_db(&state).await?;
let created = db.create_ai_provider(config).await?;
state.sync_scheduler.notify_data_changed();
db.get_ai_provider_public_by_id(created.id).await
}

Expand All @@ -192,6 +193,7 @@ pub async fn ai_update_provider(
normalize_provider_form(&mut config, None)?;
let db = get_db(&state).await?;
let updated = db.update_ai_provider(id, config).await?;
state.sync_scheduler.notify_data_changed();
db.get_ai_provider_public_by_id(updated.id).await
}

Expand All @@ -209,7 +211,9 @@ pub async fn ai_update_provider_direct(
#[tauri::command]
pub async fn ai_delete_provider(state: State<'_, AppState>, id: i64) -> Result<(), String> {
let db = get_db(&state).await?;
db.delete_ai_provider(id).await
let result = db.delete_ai_provider(id).await;
state.sync_scheduler.notify_data_changed();
result
}

pub async fn ai_delete_provider_direct(state: &AppState, id: i64) -> Result<(), String> {
Expand All @@ -220,7 +224,9 @@ pub async fn ai_delete_provider_direct(state: &AppState, id: i64) -> Result<(),
#[tauri::command]
pub async fn ai_set_default_provider(state: State<'_, AppState>, id: i64) -> Result<(), String> {
let db = get_db(&state).await?;
db.set_default_ai_provider(id).await
let result = db.set_default_ai_provider(id).await;
state.sync_scheduler.notify_data_changed();
result
}

pub async fn ai_set_default_provider_direct(state: &AppState, id: i64) -> Result<(), String> {
Expand All @@ -235,7 +241,9 @@ pub async fn ai_clear_provider_api_key(
) -> Result<(), String> {
let provider_type = normalize_provider_type(&provider_type)?;
let db = get_db(&state).await?;
db.clear_ai_provider_api_key(&provider_type).await
let result = db.clear_ai_provider_api_key(&provider_type).await;
state.sync_scheduler.notify_data_changed();
result
}

pub async fn ai_clear_provider_api_key_direct(
Expand Down
16 changes: 12 additions & 4 deletions src-tauri/src/commands/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -647,7 +647,9 @@ pub async fn create_connection(
lock.clone()
};
if let Some(db) = local_db {
db.create_connection(form).await
let result = db.create_connection(form).await;
state.sync_scheduler.notify_data_changed();
result
} else {
Err("Local DB not initialized".to_string())
}
Expand Down Expand Up @@ -684,7 +686,9 @@ pub async fn update_connection(
// If connection is updated, we should remove it from pool so next usage reconnects with new config
state.pool_manager.remove_by_prefix(&id.to_string()).await;

db.update_connection(id, form).await
let result = db.update_connection(id, form).await;
state.sync_scheduler.notify_data_changed();
result
} else {
Err("Local DB not initialized".to_string())
}
Expand All @@ -710,7 +714,9 @@ pub async fn update_connection_direct(

#[tauri::command]
pub async fn delete_connection(state: State<'_, AppState>, id: i64) -> Result<(), String> {
delete_connection_direct(&state, id).await
let result = delete_connection_direct(&state, id).await;
state.sync_scheduler.notify_data_changed();
result
}

pub async fn delete_connection_direct(state: &AppState, id: i64) -> Result<(), String> {
Expand Down Expand Up @@ -1036,7 +1042,9 @@ pub async fn import_connections(
lock.clone()
};
if let Some(db) = local_db {
crate::import::import_from_file(&file_path, &db).await
let result = crate::import::import_from_file(&file_path, &db).await;
state.sync_scheduler.notify_data_changed();
result
} else {
Err("Local DB not initialized".to_string())
}
Expand Down
Loading