diff --git a/internal/api/app.go b/internal/api/app.go index 888fc9f..4e5599f 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -108,6 +108,9 @@ func NewApp( debug: debugEnabled, activityEvents: make(map[string]bool), } + if app.syncSvc == nil { + app.rebindSyncService() + } app.ensureActivityProviderSubscriptions() app.startFileWatcherForOpenVault() return app @@ -714,6 +717,7 @@ func (a *App) CreateVault(path string) error { if err := a.vault.CreateVault(path); err != nil { return err } + a.rebindSyncService() a.secretsSession = nil a.startFileWatcherForOpenVault() return nil @@ -727,6 +731,7 @@ func (a *App) OpenVault(path string) error { if err := a.vault.OpenVault(path); err != nil { return err } + a.rebindSyncService() a.secretsSession = nil a.startFileWatcherForOpenVault() return nil @@ -741,6 +746,7 @@ func (a *App) CloseVault() error { a.fileWatcher.Stop() } a.vault.CloseVault() + a.syncSvc = nil a.secretsSession = nil return nil } @@ -1656,6 +1662,7 @@ func (a *App) SetCurrentVault(path string) string { a.secretsSession = nil // Save the actual vault path (normalized by OpenVault, includes VerstakVault/) vaultPath := a.vault.GetVaultPath() + a.rebindSyncService() if err := a.appSettings.SetCurrentVault(vaultPath); err != nil { return fmt.Sprintf("failed to save app settings: %v", err) } @@ -1685,6 +1692,16 @@ func (a *App) SetCurrentVault(path string) string { return "" } +func (a *App) rebindSyncService() { + if a == nil || a.vault == nil || a.vault.GetVaultStatus() != vault.StatusOpen { + if a != nil { + a.syncSvc = nil + } + return + } + a.syncSvc = syncsvc.NewService(a.vault.GetVaultPath(), "") +} + func (a *App) startFileWatcherForOpenVault() { if a == nil || a.vault == nil || a.eventBus == nil { return @@ -2160,7 +2177,9 @@ func (a *App) syncStatus() (*SyncStatusDTO, error) { LastError: cfg.Sync.LastError, } - if cfg.Sync.DeviceID != "" { + if deviceID := a.syncSvc.GetDeviceID(); deviceID != "" { + dto.DeviceID = deviceID + } else if cfg.Sync.DeviceID != "" { dto.DeviceID = cfg.Sync.DeviceID } @@ -2170,10 +2189,13 @@ func (a *App) syncStatus() (*SyncStatusDTO, error) { if deviceToken != "" { client := newSyncClient(serverURL, "", "", vaultPath) client.DeviceToken = deviceToken - if cfg.Sync.DeviceID != "" { - client.DeviceID = cfg.Sync.DeviceID + if dto.DeviceID != "" { + client.DeviceID = dto.DeviceID } if info, err := client.GetMe(); err == nil { + if info.DeviceID != "" { + _ = a.syncSvc.SetDeviceID(info.DeviceID) + } dto.DeviceName = info.DeviceName dto.DeviceID = info.DeviceID dto.Connected = true @@ -2224,19 +2246,24 @@ func (a *App) syncConfigure(serverURL, username, password string) error { if err := a.requireVault(); err != nil { return err } + meta := a.vault.GetVaultMeta() + if meta == nil || strings.TrimSpace(meta.VaultID) == "" { + return fmt.Errorf("vault ID is unavailable") + } vaultPath := a.vaultPath() hostname, _ := os.Hostname() if hostname == "" { hostname = "unknown" } client := newSyncClient(serverURL, "", "", vaultPath) - deviceID, deviceToken, err := client.PairDevice(serverURL, username, password, hostname, "verstak-desktop/v2") + deviceID, deviceToken, err := client.PairDevice(serverURL, username, password, hostname, "verstak-desktop/v2", meta.VaultID) if err != nil { return fmt.Errorf("pair: %w", err) } if err := syncsvc.SaveDeviceToken(vaultPath, deviceToken); err != nil { return fmt.Errorf("save token: %w", err) } + a.syncSvc = syncsvc.NewService(vaultPath, deviceID) if err := a.syncSvc.SetState(serverURL, ""); err != nil { return err } @@ -2271,9 +2298,15 @@ func (a *App) syncDisconnect() error { vaultPath := a.vaultPath() deviceToken := syncsvc.LoadDeviceToken(vaultPath) cfg := a.appSettings.Get() + serverURL := cfg.Sync.ServerURL + if a.syncSvc != nil { + if currentServerURL, _, _, _, err := a.syncSvc.GetState(); err == nil && currentServerURL != "" { + serverURL = currentServerURL + } + } - if deviceToken != "" { - client := newSyncClient(cfg.Sync.ServerURL, "", "", vaultPath) + if deviceToken != "" && serverURL != "" { + client := newSyncClient(serverURL, "", "", vaultPath) client.DeviceToken = deviceToken _ = client.RevokeCurrent() } @@ -2288,6 +2321,9 @@ func (a *App) syncDisconnect() error { if err := a.appSettings.UpdateSync(cfg.Sync); err != nil { return err } + if a.syncSvc == nil { + return nil + } return a.syncSvc.SetState("", "") } @@ -2350,6 +2386,7 @@ func (a *App) syncResetKey() error { return err } vaultPath := a.vaultPath() + deviceToken := syncsvc.LoadDeviceToken(vaultPath) if err := syncsvc.RemoveDeviceToken(vaultPath); err != nil && !os.IsNotExist(err) { return err } @@ -2361,7 +2398,14 @@ func (a *App) syncResetKey() error { cfg.Sync.LastStatus = "disconnected" cfg.Sync.LastError = "" if a.syncSvc != nil { - if err := a.syncSvc.SetState(cfg.Sync.ServerURL, ""); err != nil { + serverURL, _, _, _, err := a.syncSvc.GetState() + if err != nil { + return err + } + if serverURL == "" && deviceToken != "" { + serverURL = cfg.Sync.ServerURL + } + if err := a.syncSvc.SetState(serverURL, ""); err != nil { return err } } @@ -2384,6 +2428,12 @@ func (a *App) syncNow() (map[string]interface{}, error) { return nil, err } vaultPath := a.vaultPath() + if a.syncSvc == nil { + a.rebindSyncService() + } + if a.syncSvc == nil { + return nil, fmt.Errorf("sync service not initialized") + } serverURL, apiKey, lastPullSeq, _, err := a.syncSvc.GetState() deviceToken := syncsvc.LoadDeviceToken(vaultPath) @@ -2391,14 +2441,28 @@ func (a *App) syncNow() (map[string]interface{}, error) { return nil, fmt.Errorf("sync not configured") } - deviceID := "" + deviceID := a.syncSvc.GetDeviceID() cfg := a.appSettings.Get() - if cfg.Sync.DeviceID != "" { + if deviceID == "" && deviceToken == "" && cfg.Sync.DeviceID != "" { deviceID = cfg.Sync.DeviceID } client := newSyncClient(serverURL, apiKey, deviceID, vaultPath) client.DeviceToken = deviceToken + if deviceID == "" && deviceToken != "" { + info, err := client.GetMe() + if err != nil { + return nil, fmt.Errorf("sync identity: %w", err) + } + if info.DeviceID == "" { + return nil, fmt.Errorf("sync identity: server returned an empty device ID") + } + if err := a.syncSvc.SetDeviceID(info.DeviceID); err != nil { + return nil, fmt.Errorf("save sync identity: %w", err) + } + deviceID = info.DeviceID + client.DeviceID = deviceID + } unpushed, err := a.syncSvc.GetUnpushedOps() if err != nil { @@ -2637,14 +2701,19 @@ func syncPayloadPath(op syncsvc.Op, payload syncFilePayload) string { } func (a *App) localSyncDeviceID() string { + if a.syncSvc != nil { + if deviceID := a.syncSvc.GetDeviceID(); deviceID != "" { + return deviceID + } + } + if a.vault != nil && a.vault.GetVaultStatus() == vault.StatusOpen && syncsvc.LoadDeviceToken(a.vaultPath()) != "" { + return "" + } if a.appSettings != nil { if deviceID := a.appSettings.Get().Sync.DeviceID; deviceID != "" { return deviceID } } - if a.syncSvc != nil { - return a.syncSvc.GetDeviceID() - } return "" } diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 25a6810..5274bf8 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -1104,6 +1104,174 @@ func TestSyncNowPushesLocalOpsAndAppliesPulledFileOps(t *testing.T) { } } +func TestSyncConfigurePairsCurrentVaultID(t *testing.T) { + app, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device") + meta := app.vault.GetVaultMeta() + if meta == nil || meta.VaultID == "" { + t.Fatal("test vault must have an ID") + } + + var pairedVaultID string + server := newLocalHTTPTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/client/pair" { + http.NotFound(w, r) + return + } + var request struct { + VaultID string `json:"vault_id"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + pairedVaultID = request.VaultID + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "device_id": "paired-device", + "device_token": "paired-token", + }) + })) + defer server.Close() + + if err := app.syncConfigure(server.URL, "alice", "secret"); err != nil { + t.Fatalf("syncConfigure: %v", err) + } + if pairedVaultID != meta.VaultID { + t.Fatalf("paired vault ID = %q, want %q", pairedVaultID, meta.VaultID) + } +} + +func TestSyncNowHydratesLegacyVaultDeviceID(t *testing.T) { + app, root := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "wrong-global-device") + var pushedDeviceID string + server := newLocalHTTPTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer vault-token" { + http.Error(w, "missing auth", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/client/me": + _ = json.NewEncoder(w).Encode(map[string]string{"device_id": "vault-device"}) + case "/api/v1/sync/push": + var request struct { + DeviceID string `json:"device_id"` + Ops []struct { + OpID string `json:"op_id"` + } `json:"ops"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + pushedDeviceID = request.DeviceID + accepted := make([]string, 0, len(request.Ops)) + for _, op := range request.Ops { + accepted = append(accepted, op.OpID) + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "accepted": accepted, + "count": len(accepted), + "conflicts": []map[string]interface{}{}, + }) + case "/api/v1/sync/pull": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "server_sequence": 0, + "ops": []map[string]interface{}{}, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + app.syncSvc = syncsvc.NewService(root, "") + if err := app.syncSvc.SetState(server.URL, ""); err != nil { + t.Fatalf("SetState: %v", err) + } + if err := syncsvc.SaveDeviceToken(root, "vault-token"); err != nil { + t.Fatalf("SaveDeviceToken: %v", err) + } + if err := app.syncSvc.RecordOp(syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpCreate, map[string]string{"path": "Docs/one.txt"}); err != nil { + t.Fatalf("RecordOp: %v", err) + } + + if _, err := app.syncNow(); err != nil { + t.Fatalf("syncNow: %v", err) + } + if pushedDeviceID != "vault-device" { + t.Fatalf("pushed device ID = %q, want vault-device", pushedDeviceID) + } + if app.syncSvc.GetDeviceID() != "vault-device" { + t.Fatalf("persisted device ID = %q, want vault-device", app.syncSvc.GetDeviceID()) + } +} + +func TestVaultTransitionsRebindSyncService(t *testing.T) { + settings := appsettings.NewManager(filepath.Join(t.TempDir(), "config.json")) + if err := settings.Load(); err != nil { + t.Fatalf("settings Load: %v", err) + } + app := &App{ + capRegistry: capability.NewRegistry(), + vault: vault.NewVault(nil), + appSettings: settings, + } + + if err := app.CreateVault(t.TempDir()); err != nil { + t.Fatalf("CreateVault: %v", err) + } + if app.syncSvc == nil { + t.Fatal("CreateVault must initialize the sync service") + } + + vaultB := vault.NewVault(nil) + if err := vaultB.CreateVault(t.TempDir()); err != nil { + t.Fatalf("Create vault B: %v", err) + } + primeSyncState(t, vaultB.GetVaultPath(), "device-b", "https://sync-b.example.test", 22) + if err := app.OpenVault(vaultB.GetVaultPath()); err != nil { + t.Fatalf("OpenVault B: %v", err) + } + assertSyncState(t, app.syncSvc, "device-b", "https://sync-b.example.test", 22) + + vaultC := vault.NewVault(nil) + if err := vaultC.CreateVault(t.TempDir()); err != nil { + t.Fatalf("Create vault C: %v", err) + } + primeSyncState(t, vaultC.GetVaultPath(), "device-c", "https://sync-c.example.test", 33) + if errStr := app.SetCurrentVault(vaultC.GetVaultPath()); errStr != "" { + t.Fatalf("SetCurrentVault C: %s", errStr) + } + assertSyncState(t, app.syncSvc, "device-c", "https://sync-c.example.test", 33) +} + +func primeSyncState(t *testing.T, vaultRoot, deviceID, serverURL string, lastPullSeq int) { + t.Helper() + service := syncsvc.NewService(vaultRoot, deviceID) + if err := service.SetState(serverURL, ""); err != nil { + t.Fatalf("SetState: %v", err) + } + if err := service.SetLastPullSeq(lastPullSeq); err != nil { + t.Fatalf("SetLastPullSeq: %v", err) + } +} + +func assertSyncState(t *testing.T, service *syncsvc.Service, wantDeviceID, wantServerURL string, wantLastPullSeq int) { + t.Helper() + if service == nil { + t.Fatal("sync service is nil") + } + serverURL, _, lastPullSeq, _, err := service.GetState() + if err != nil { + t.Fatalf("GetState: %v", err) + } + if service.GetDeviceID() != wantDeviceID || serverURL != wantServerURL || lastPullSeq != wantLastPullSeq { + t.Fatalf("sync state = device=%q server=%q cursor=%d, want device=%q server=%q cursor=%d", + service.GetDeviceID(), serverURL, lastPullSeq, wantDeviceID, wantServerURL, wantLastPullSeq) + } +} + func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) { tmpDir := t.TempDir() vaultParent := filepath.Join(tmpDir, "vault-parent") diff --git a/internal/core/sync/client.go b/internal/core/sync/client.go index 1b7ccf5..9d10086 100644 --- a/internal/core/sync/client.go +++ b/internal/core/sync/client.go @@ -77,12 +77,13 @@ func NewClient(serverURL, apiKey, deviceID, vaultRoot string) *Client { } // PairDevice calls POST /api/client/pair and returns device_id and device_token. -func (c *Client) PairDevice(serverURL, username, password, deviceName, clientVersion string) (deviceID, deviceToken string, err error) { +func (c *Client) PairDevice(serverURL, username, password, deviceName, clientVersion, vaultID string) (deviceID, deviceToken string, err error) { body := map[string]string{ "login": username, "password": password, "device_name": deviceName, "client_version": clientVersion, + "vault_id": vaultID, } var resp struct { DeviceID string `json:"device_id"` diff --git a/internal/core/sync/client_test.go b/internal/core/sync/client_test.go index 95b9da5..f29cca2 100644 --- a/internal/core/sync/client_test.go +++ b/internal/core/sync/client_test.go @@ -86,3 +86,36 @@ func TestPushDoesNotRetryClientErrors(t *testing.T) { t.Fatalf("attempts = %d, want 1", attempts) } } + +func TestPairDeviceSendsVaultID(t *testing.T) { + var pairedVaultID string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/client/pair" { + http.NotFound(w, r) + return + } + var request struct { + VaultID string `json:"vault_id"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + pairedVaultID = request.VaultID + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "device_id": "device-1", + "device_token": "token-1", + }) + })) + defer server.Close() + + client := NewClient(server.URL, "", "", t.TempDir()) + _, _, err := client.PairDevice(server.URL, "alice", "secret", "Desktop", "verstak-desktop/v2", "vault-123") + if err != nil { + t.Fatalf("PairDevice: %v", err) + } + if pairedVaultID != "vault-123" { + t.Fatalf("paired vault ID = %q, want vault-123", pairedVaultID) + } +} diff --git a/internal/core/sync/service.go b/internal/core/sync/service.go index 3fc87b0..18212d8 100644 --- a/internal/core/sync/service.go +++ b/internal/core/sync/service.go @@ -60,7 +60,13 @@ type Service struct { // NewService creates a sync service. func NewService(vaultRoot, deviceID string) *Service { - return &Service{vaultRoot: vaultRoot, deviceID: deviceID} + service := &Service{vaultRoot: vaultRoot, deviceID: deviceID} + if deviceID == "" { + if state, err := service.loadState(); err == nil { + service.deviceID = state.DeviceID + } + } + return service } func (s *Service) syncDir() string { @@ -212,6 +218,9 @@ func (s *Service) SetState(serverURL, apiKey string) error { } st.ServerURL = serverURL st.APIKey = apiKey + if s.deviceID != "" { + st.DeviceID = s.deviceID + } return s.saveState(st) } @@ -240,6 +249,23 @@ func (s *Service) GetDeviceID() string { return s.deviceID } +// SetDeviceID persists the device ID for this vault's sync state. +func (s *Service) SetDeviceID(deviceID string) error { + if err := s.ensureDir(); err != nil { + return err + } + st, err := s.loadState() + if err != nil { + st = &syncState{} + } + st.DeviceID = deviceID + if err := s.saveState(st); err != nil { + return err + } + s.deviceID = deviceID + return nil +} + // --- file helpers --- func (s *Service) loadOps() ([]Op, error) {