diff --git a/docs/compose/plans/2026-06-20-sync-server-and-plugin.md b/docs/compose/plans/2026-06-20-sync-server-and-plugin.md new file mode 100644 index 0000000..ee49a06 --- /dev/null +++ b/docs/compose/plans/2026-06-20-sync-server-and-plugin.md @@ -0,0 +1,1934 @@ +# Sync Server & Plugin Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement sync server (separate repo) and sync plugin for Verstak2 platform. + +**Architecture:** Sync server is a standalone Go HTTP server based on old Verstak server. Sync plugin is a dynamic plugin with settings UI registered via contributes.settingsPanels. + +**Tech Stack:** Go (server), Svelte (plugin UI), SQLite (server DB), Wails (desktop integration) + +--- + +## Phase 1: Sync Server + +### Task 1: Initialize Sync Server Repository + +**Covers:** [S1, S2] + +**Files:** +- Create: `verstak-sync-server/go.mod` +- Create: `verstak-sync-server/cmd/server/main.go` +- Create: `verstak-sync-server/README.md` + +- [ ] **Step 1: Create directory structure** + +```bash +mkdir -p verstak-sync-server/cmd/server +mkdir -p verstak-sync-server/internal/server +``` + +- [ ] **Step 2: Initialize Go module** + +```bash +cd verstak-sync-server +go mod init github.com/verstak/verstak-sync-server +``` + +- [ ] **Step 3: Create main.go entry point** + +```go +package main + +import ( + "flag" + "fmt" + "log" + "os" + "path/filepath" +) + +func main() { + port := flag.Int("port", 47732, "HTTP port") + dataDir := flag.String("data", "./server-data", "Data directory (db, blobs, config)") + adminUser := flag.String("admin-user", "", "Create admin user (first run)") + adminPass := flag.String("admin-pass", "", "Admin password (first run)") + flag.Parse() + + absData, err := filepath.Abs(*dataDir) + if err != nil { + log.Fatalf("data dir: %v", err) + } + + if err := os.MkdirAll(absData, 0750); err != nil { + log.Fatalf("create data dir: %v", err) + } + + cfg, err := LoadConfig(absData) + if err != nil { + log.Fatalf("config: %v", err) + } + + if *adminUser != "" && *adminPass != "" { + if err := cfg.SetAdmin(*adminUser, *adminPass); err != nil { + log.Fatalf("set admin: %v", err) + } + fmt.Printf("Admin user %q created.\n", *adminUser) + } + + dbPath := filepath.Join(absData, "server.db") + srv, err := NewServer(dbPath, absData, cfg) + if err != nil { + log.Fatalf("server: %v", err) + } + defer srv.Close() + + addr := fmt.Sprintf(":%d", *port) + log.Printf("Verstak Sync Server starting on %s (data: %s)", addr, absData) + if err := srv.ListenAndServe(addr); err != nil { + log.Fatalf("serve: %v", err) + } +} +``` + +- [ ] **Step 4: Create README.md** + +```markdown +# Verstak Sync Server + +Standalone sync server for Verstak vaults. + +## Quick Start + +```bash +# First run with admin user +go run ./cmd/server --admin-user admin --admin-pass secret + +# Subsequent runs +go run ./cmd/server +``` + +## API Endpoints + +- `POST /api/v1/sync/push` — push operations +- `POST /api/v1/sync/pull` — pull operations +- `POST /api/v1/blobs/` — upload blob +- `GET /api/v1/blobs/:sha256` — download blob +- `POST /api/client/pair` — device pairing +- `GET /api/v1/health` — health check + +## Configuration + +Server config at `server-data/config.yml`: +```yaml +port: 47732 +admin: + - username: admin + password_hash: "$2a$10$..." +``` +``` + +- [ ] **Step 5: Commit** + +```bash +cd verstak-sync-server +git init +git add . +git commit -m "feat: initialize sync server repository" +``` + +--- + +### Task 2: Implement Server Core + +**Covers:** [S2] + +**Files:** +- Create: `verstak-sync-server/internal/server/server.go` +- Create: `verstak-sync-server/internal/server/config.go` +- Create: `verstak-sync-server/internal/server/schema.go` + +- [ ] **Step 1: Create server.go** + +```go +package server + +import ( + "database/sql" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +type pairRateLimit struct { + mu sync.Mutex + attempts map[string]int +} + +func (p *pairRateLimit) allow(ip string) bool { + p.mu.Lock() + defer p.mu.Unlock() + if p.attempts == nil { + p.attempts = make(map[string]int) + } + p.attempts[ip]++ + return p.attempts[ip] <= 5 +} + +func (p *pairRateLimit) reset(ip string) { + p.mu.Lock() + defer p.mu.Unlock() + delete(p.attempts, ip) +} + +type Server struct { + db *sql.DB + cfg *Config + tokens *tokenStore + userTokens *userTokenStore + blobsDir string + mux *http.ServeMux + pairLimit *pairRateLimit +} + +func (s *Server) auditLog(eventType, userID, deviceID, ip, msg string) { + s.db.Exec("INSERT INTO server_audit_log (event_type, user_id, device_id, ip, message, created_at) VALUES (?, ?, ?, ?, ?, ?)", + eventType, userID, deviceID, ip, msg, time.Now().UTC().Format(time.RFC3339)) +} + +func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) { + db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?mode=rwc", dbPath)) + if err != nil { + return nil, fmt.Errorf("open db: %w", err) + } + db.SetMaxOpenConns(1) + + for _, stmt := range strings.Split(serverSchema, ";") { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + if _, err := db.Exec(stmt); err != nil { + db.Close() + return nil, fmt.Errorf("schema: %w", err) + } + } + + db.Exec("ALTER TABLE server_users ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0") + db.Exec("ALTER TABLE server_users ADD COLUMN last_seen TEXT") + db.Exec("ALTER TABLE server_devices ADD COLUMN token_hash TEXT") + db.Exec("ALTER TABLE server_devices ADD COLUMN token_prefix TEXT") + db.Exec("ALTER TABLE server_devices ADD COLUMN token_suffix TEXT") + db.Exec("ALTER TABLE server_devices ADD COLUMN user_id TEXT") + db.Exec("ALTER TABLE server_devices ADD COLUMN client_version TEXT") + db.Exec("ALTER TABLE server_devices ADD COLUMN last_ip TEXT") + db.Exec("ALTER TABLE server_devices ADD COLUMN revoked_at TEXT") + db.Exec("ALTER TABLE server_ops ADD COLUMN server_sequence INTEGER") + db.Exec("CREATE INDEX IF NOT EXISTS idx_server_ops_sequence ON server_ops(server_sequence)") + db.Exec("CREATE INDEX IF NOT EXISTS idx_server_ops_entity ON server_ops(entity_type, entity_id)") + db.Exec(`CREATE TABLE IF NOT EXISTS server_tombstones ( + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + op_id TEXT NOT NULL, + deleted_at TEXT NOT NULL, + PRIMARY KEY (entity_type, entity_id) + )`) + db.Exec(`CREATE TABLE IF NOT EXISTS server_idempotency_keys ( + idempotency_key TEXT PRIMARY KEY, + response_json TEXT NOT NULL, + created_at TEXT NOT NULL + )`) + db.Exec(`ALTER TABLE server_ops ADD COLUMN idempotency_key TEXT`) + db.Exec(`ALTER TABLE server_ops ADD COLUMN client_sequence INTEGER DEFAULT 0`) + db.Exec(`ALTER TABLE server_ops ADD COLUMN last_seen_server_seq INTEGER DEFAULT 0`) + + blobsDir := filepath.Join(dataDir, "blobs") + if err := os.MkdirAll(blobsDir, 0750); err != nil { + db.Close() + return nil, err + } + + s := &Server{ + db: db, + cfg: cfg, + tokens: newTokenStore(), + userTokens: newUserTokenStore(), + blobsDir: blobsDir, + pairLimit: &pairRateLimit{}, + } + s.mux = s.routes() + return s, nil +} + +func (s *Server) Close() error { + return s.db.Close() +} + +func (s *Server) ListenAndServe(addr string) error { + return http.ListenAndServe(addr, s.mux) +} +``` + +- [ ] **Step 2: Create config.go** + +```go +package server + +import ( + "fmt" + "os" + "path/filepath" + "sync" + + "golang.org/x/crypto/bcrypt" + "gopkg.in/yaml.v3" +) + +type AdminUser struct { + Username string `yaml:"username"` + PasswordHash string `yaml:"password_hash"` +} + +type Config struct { + Port int `yaml:"port"` + Admin []AdminUser `yaml:"admin"` + mu sync.Mutex + path string +} + +func LoadConfig(dataDir string) (*Config, error) { + path := filepath.Join(dataDir, "config.yml") + cfg := &Config{ + Port: 47732, + Admin: nil, + path: path, + } + data, err := os.ReadFile(path) + if err == nil { + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + } + return cfg, nil +} + +func (c *Config) Save() error { + c.mu.Lock() + defer c.mu.Unlock() + return c.saveLocked() +} + +func (c *Config) saveLocked() error { + data, err := yaml.Marshal(c) + if err != nil { + return err + } + return os.WriteFile(c.path, data, 0640) +} + +func (c *Config) SetAdmin(username, password string) error { + c.mu.Lock() + defer c.mu.Unlock() + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + user := AdminUser{Username: username, PasswordHash: string(hash)} + for i, u := range c.Admin { + if u.Username == username { + c.Admin[i] = user + return c.saveLocked() + } + } + c.Admin = append(c.Admin, user) + return c.saveLocked() +} + +func (c *Config) CheckAdmin(username, password string) bool { + c.mu.Lock() + defer c.mu.Unlock() + for _, u := range c.Admin { + if u.Username == username { + if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil { + return true + } + } + } + return false +} +``` + +- [ ] **Step 3: Create schema.go** + +```go +package server + +const serverSchema = ` +CREATE TABLE IF NOT EXISTS server_users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE, + password_hash TEXT NOT NULL, + confirmed INTEGER NOT NULL DEFAULT 0, + confirm_token TEXT, + reset_token TEXT, + reset_expires TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS server_devices ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + api_key TEXT, + token_hash TEXT, + token_prefix TEXT, + token_suffix TEXT, + user_id TEXT, + client_version TEXT, + last_ip TEXT, + last_seen TEXT, + revoked_at TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES server_users(id) +); + +CREATE TABLE IF NOT EXISTS server_user_devices ( + user_id TEXT NOT NULL, + device_id TEXT NOT NULL, + PRIMARY KEY (user_id, device_id), + FOREIGN KEY (user_id) REFERENCES server_users(id), + FOREIGN KEY (device_id) REFERENCES server_devices(id) +); + +CREATE TABLE IF NOT EXISTS server_ops ( + id TEXT PRIMARY KEY, + op_id TEXT NOT NULL, + device_id TEXT, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + op_type TEXT NOT NULL, + payload_json TEXT, + created_at TEXT NOT NULL, + pushed_at TEXT, + applied_at TEXT, + server_sequence INTEGER, + idempotency_key TEXT, + client_sequence INTEGER DEFAULT 0, + last_seen_server_seq INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS server_tombstones ( + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + op_id TEXT NOT NULL, + deleted_at TEXT NOT NULL, + PRIMARY KEY (entity_type, entity_id) +); + +CREATE TABLE IF NOT EXISTS server_idempotency_keys ( + idempotency_key TEXT PRIMARY KEY, + response_json TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS server_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + user_id TEXT, + device_id TEXT, + ip TEXT, + message TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_server_ops_sequence ON server_ops(server_sequence); +CREATE INDEX IF NOT EXISTS idx_server_ops_entity ON server_ops(entity_type, entity_id); +CREATE INDEX IF NOT EXISTS idx_server_devices_token ON server_devices(token_hash); +CREATE INDEX IF NOT EXISTS idx_server_devices_user ON server_devices(user_id); +` +``` + +- [ ] **Step 4: Add dependencies** + +```bash +cd verstak-sync-server +go get github.com/mattn/go-sqlite3 +go get golang.org/x/crypto/bcrypt +go get gopkg.in/yaml.v3 +``` + +- [ ] **Step 5: Commit** + +```bash +git add internal/server/ +git commit -m "feat: add server core (server, config, schema)" +``` + +--- + +### Task 3: Implement Routes and Handlers + +**Covers:** [S2] + +**Files:** +- Create: `verstak-sync-server/internal/server/routes.go` +- Create: `verstak-sync-server/internal/server/handlers_api.go` +- Create: `verstak-sync-server/internal/server/handlers_auth.go` +- Create: `verstak-sync-server/internal/server/middleware.go` +- Create: `verstak-sync-server/internal/server/tokens.go` + +- [ ] **Step 1: Create routes.go** + +```go +package server + +import "net/http" + +func (s *Server) routes() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/health", s.handleHealth) + mux.HandleFunc("/api/v1/device/register", s.handleDeviceRegister) + mux.HandleFunc("/api/v1/sync/push", s.handleSyncPush) + mux.HandleFunc("/api/v1/sync/pull", s.handleSyncPull) + mux.HandleFunc("/api/v1/blobs/", s.handleBlobs) + mux.HandleFunc("/api/client/pair", s.handleClientPair) + mux.HandleFunc("/api/auth/test", s.handleAuthTest) + mux.HandleFunc("/api/client/revoke-current", s.handleClientRevoke) + mux.HandleFunc("/api/client/me", s.handleClientMe) + mux.HandleFunc("/api/client/revoke-device", s.handleClientRevokeDevice) + mux.HandleFunc("/", s.handleNotFound) + return mux +} +``` + +- [ ] **Step 2: Create handlers_api.go** + +Copy from `~/git/verstak/cmd/verstak-server/handlers_api.go` and update package name to `server`. + +- [ ] **Step 3: Create handlers_auth.go** + +Copy from `~/git/verstak/cmd/verstak-server/handlers_user.go` (auth endpoints only) and update package name. + +- [ ] **Step 4: Create middleware.go** + +```go +package server + +import ( + "net/http" + "strings" +) + +func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if tok == "" { + jsonErr(w, 401, "token required") + return + } + next(w, r) + } +} + +func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || !s.cfg.CheckAdmin(username, password) { + jsonErr(w, 401, "admin auth required") + return + } + next(w, r) + } +} +``` + +- [ ] **Step 5: Create tokens.go** + +Copy from `~/git/verstak/cmd/verstak-server/tokens.go` and update package name. + +- [ ] **Step 6: Create helpers.go** + +```go +package server + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" +) + +func jsonOK(w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(data) +} + +func jsonErr(w http.ResponseWriter, code int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +func sha256Hex(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:]) +} +``` + +- [ ] **Step 7: Commit** + +```bash +git add internal/server/ +git commit -m "feat: add routes and API handlers" +``` + +--- + +### Task 4: Test Sync Server + +**Covers:** [S2] + +**Files:** +- Create: `verstak-sync-server/internal/server/server_test.go` + +- [ ] **Step 1: Create server_test.go** + +```go +package server + +import ( + "testing" + "os" + "path/filepath" +) + +func TestNewServer(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + cfg := &Config{Port: 0} + + srv, err := NewServer(dbPath, dir, cfg) + if err != nil { + t.Fatalf("NewServer failed: %v", err) + } + defer srv.Close() + + // Verify DB exists + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + t.Error("database file not created") + } +} + +func TestConfigSetAdmin(t *testing.T) { + dir := t.TempDir() + cfg := &Config{Port: 0, path: filepath.Join(dir, "config.yml")} + + if err := cfg.SetAdmin("admin", "password123"); err != nil { + t.Fatalf("SetAdmin failed: %v", err) + } + + if !cfg.CheckAdmin("admin", "password123") { + t.Error("CheckAdmin should return true for correct password") + } + + if cfg.CheckAdmin("admin", "wrong") { + t.Error("CheckAdmin should return false for wrong password") + } +} +``` + +- [ ] **Step 2: Run tests** + +```bash +cd verstak-sync-server +go test ./internal/server/ -v +``` + +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add internal/server/server_test.go +git commit -m "test: add server unit tests" +``` + +--- + +## Phase 2: Desktop Backend API + +### Task 5: Add Sync Backend Methods + +**Covers:** [S3, S4] + +**Files:** +- Create: `verstak-desktop/internal/core/sync/client.go` +- Create: `verstak-desktop/internal/core/sync/service.go` +- Modify: `verstak-desktop/internal/api/app.go` + +- [ ] **Step 1: Create sync/client.go** + +```go +package sync + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "time" +) + +type Client struct { + ServerURL string + APIKey string + DeviceToken string + DeviceID string + VaultRoot string + HTTP *http.Client +} + +func NewClient(serverURL, apiKey, deviceID, vaultRoot string) *Client { + return &Client{ + ServerURL: serverURL, + APIKey: apiKey, + DeviceID: deviceID, + VaultRoot: vaultRoot, + HTTP: &http.Client{Timeout: 30 * time.Second}, + } +} + +type DeviceInfo struct { + DeviceID string `json:"device_id"` + UserID string `json:"user_id"` + Username string `json:"username"` + DeviceName string `json:"device_name"` + ClientVersion string `json:"client_version"` + LastSeen string `json:"last_seen"` + RevokedAt string `json:"revoked_at"` + CreatedAt string `json:"created_at"` +} + +func (c *Client) PairDevice(serverURL, username, password, deviceName, clientVersion string) (deviceID, deviceToken string, err error) { + body := map[string]string{ + "login": username, + "password": password, + "device_name": deviceName, + "client_version": clientVersion, + } + var resp struct { + DeviceID string `json:"device_id"` + DeviceToken string `json:"device_token"` + } + savedURL := c.ServerURL + c.ServerURL = serverURL + err = c.post("/api/client/pair", body, &resp) + c.ServerURL = savedURL + if err != nil { + return "", "", err + } + return resp.DeviceID, resp.DeviceToken, nil +} + +func (c *Client) GetMe() (*DeviceInfo, error) { + var resp DeviceInfo + if err := c.get("/api/client/me", &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (c *Client) RevokeCurrent() error { + var resp struct { + Status string `json:"status"` + } + return c.post("/api/client/revoke-current", nil, &resp) +} + +func (c *Client) TestAuth(serverURL, username, password string) error { + body := map[string]string{"username": username, "password": password} + savedURL := c.ServerURL + savedKey := c.APIKey + c.ServerURL = serverURL + c.APIKey = "" + err := c.post("/api/auth/test", body, nil) + c.ServerURL = savedURL + c.APIKey = savedKey + return err +} + +type PushOp struct { + OpID string `json:"op_id"` + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` + OpType string `json:"op_type"` + PayloadJSON string `json:"payload_json"` + ClientSequence int `json:"client_sequence"` + LastSeenServerSeq int `json:"last_seen_server_seq"` + CreatedAt string `json:"created_at"` +} + +type PushResponse struct { + Accepted []string `json:"accepted"` + Count int `json:"count"` + Conflicts []map[string]interface{} `json:"conflicts"` +} + +func (c *Client) Push(ops []PushOp) (*PushResponse, error) { + req := struct { + DeviceID string `json:"device_id"` + Ops []PushOp `json:"ops"` + }{ + DeviceID: c.DeviceID, + Ops: ops, + } + var resp PushResponse + if err := c.post("/api/v1/sync/push", req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +type PullResponse struct { + ServerSequence int `json:"server_sequence"` + Ops []Op `json:"ops"` +} + +type Op struct { + OpID string `json:"op_id"` + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` + OpType string `json:"op_type"` + PayloadJSON string `json:"payload_json"` + CreatedAt string `json:"created_at"` +} + +func (c *Client) Pull(sinceSequence int) (*PullResponse, error) { + req := struct { + SinceSequence int `json:"since_sequence"` + }{ + SinceSequence: sinceSequence, + } + var resp PullResponse + if err := c.post("/api/v1/sync/pull", req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (c *Client) UploadBlob(localPath string) (sha256 string, err error) { + var b bytes.Buffer + w := multipart.NewWriter(&b) + fw, err := w.CreateFormFile("file", filepath.Base(localPath)) + if err != nil { + return "", err + } + f, err := os.Open(localPath) + if err != nil { + return "", err + } + defer f.Close() + if _, err := io.Copy(fw, f); err != nil { + return "", err + } + w.Close() + + req, err := http.NewRequest("POST", c.ServerURL+"/api/v1/blobs/", &b) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+c.bearerToken()) + + resp, err := c.HTTP.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var result struct { + SHA256 string `json:"sha256"` + Size int `json:"size"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + return result.SHA256, nil +} + +func (c *Client) DownloadBlob(sha256, destPath string) error { + req, err := http.NewRequest("GET", c.ServerURL+"/api/v1/blobs/"+sha256, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.bearerToken()) + + resp, err := c.HTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("download blob: HTTP %d", resp.StatusCode) + } + + out, err := os.Create(destPath) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, resp.Body) + return err +} + +func (c *Client) bearerToken() string { + if c.DeviceToken != "" { + return c.DeviceToken + } + return c.APIKey +} + +func (c *Client) post(path string, body, result interface{}) error { + var b bytes.Buffer + if body != nil { + if err := json.NewEncoder(&b).Encode(body); err != nil { + return err + } + } + req, err := http.NewRequest("POST", c.ServerURL+path, &b) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.bearerToken()) + + resp, err := c.HTTP.Do(req) + if err != nil { + return fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(resp.Body) + return fmt.Errorf("server %d: %s", resp.StatusCode, string(data)) + } + + if result != nil { + return json.NewDecoder(resp.Body).Decode(result) + } + return nil +} + +func (c *Client) get(path string, result interface{}) error { + req, err := http.NewRequest("GET", c.ServerURL+path, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.bearerToken()) + + resp, err := c.HTTP.Do(req) + if err != nil { + return fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(resp.Body) + return fmt.Errorf("server %d: %s", resp.StatusCode, string(data)) + } + + if result != nil { + return json.NewDecoder(resp.Body).Decode(result) + } + return nil +} +``` + +- [ ] **Step 2: Create sync/service.go** + +```go +package sync + +import ( + "database/sql" + "encoding/json" + "fmt" + "time" +) + +type Service struct { + db *sql.DB + deviceID string +} + +func NewService(db *sql.DB, deviceID string) *Service { + return &Service{db: db, deviceID: deviceID} +} + +type SyncOp struct { + ID string `json:"id"` + OpID string `json:"op_id"` + DeviceID string `json:"device_id"` + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` + OpType string `json:"op_type"` + PayloadJSON string `json:"payload_json"` + CreatedAt string `json:"created_at"` + PushedAt *string `json:"pushed_at,omitempty"` + ClientSequence int `json:"client_sequence,omitempty"` + LastSeenServerSeq int `json:"last_seen_server_seq,omitempty"` +} + +func (s *Service) RecordOp(entityType, entityID, opType string, payload interface{}) error { + id := fmt.Sprintf("%d", time.Now().UnixNano()) + now := time.Now().UTC().Format(time.RFC3339) + + var payloadStr string + if payload != nil { + b, err := json.Marshal(payload) + if err != nil { + return err + } + payloadStr = string(b) + } + + _, err := s.db.Exec( + `INSERT INTO sync_ops (id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + id, id, s.deviceID, entityType, entityID, opType, payloadStr, now, + ) + return err +} + +func (s *Service) GetUnpushedOps() ([]SyncOp, error) { + rows, err := s.db.Query( + `SELECT id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at + FROM sync_ops WHERE pushed_at IS NULL ORDER BY created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + + var ops []SyncOp + for rows.Next() { + var o SyncOp + if err := rows.Scan(&o.ID, &o.OpID, &o.DeviceID, &o.EntityType, &o.EntityID, + &o.OpType, &o.PayloadJSON, &o.CreatedAt); err != nil { + return nil, err + } + ops = append(ops, o) + } + return ops, rows.Err() +} + +func (s *Service) MarkPushed(opIDs []string) error { + now := time.Now().UTC().Format(time.RFC3339) + for _, id := range opIDs { + _, err := s.db.Exec("UPDATE sync_ops SET pushed_at=? WHERE op_id=?", now, id) + if err != nil { + return err + } + } + return nil +} + +func (s *Service) GetState() (serverURL, apiKey string, lastPullSeq int, lastSyncAt string, err error) { + err = s.db.QueryRow( + `SELECT server_url, api_key, last_pull_seq, COALESCE(last_sync_at,'') FROM sync_state WHERE device_id=?`, + s.deviceID).Scan(&serverURL, &apiKey, &lastPullSeq, &lastSyncAt) + if err == sql.ErrNoRows { + return "", "", 0, "", nil + } + return +} + +func (s *Service) SetState(serverURL, apiKey string) error { + _, err := s.db.Exec( + `INSERT INTO sync_state (device_id, server_url, api_key, last_pull_seq, last_sync_at) + VALUES (?, ?, ?, 0, '') + ON CONFLICT(device_id) DO UPDATE SET + server_url=excluded.server_url, + api_key=excluded.api_key`, + s.deviceID, serverURL, apiKey, + ) + return err +} + +func (s *Service) SetLastPullSeq(seq int) error { + _, err := s.db.Exec("UPDATE sync_state SET last_pull_seq=? WHERE device_id=?", seq, s.deviceID) + return err +} + +func (s *Service) SetLastSyncAt(t string) error { + _, err := s.db.Exec("UPDATE sync_state SET last_sync_at=? WHERE device_id=?", t, s.deviceID) + return err +} + +func (s *Service) GetDeviceID() string { + return s.deviceID +} +``` + +- [ ] **Step 3: Add sync methods to app.go** + +Add these methods to `verstak-desktop/internal/api/app.go`: + +```go +func (a *App) SyncStatus() (map[string]interface{}, error) { + // Return current sync status +} + +func (a *App) SyncConfigure(serverURL, username, password string) error { + // Pair device with server +} + +func (a *App) SyncDisconnect() error { + // Disconnect and revoke device +} + +func (a *App) SyncTestConnection(serverURL, username, password string) error { + // Test server credentials +} + +func (a *App) SyncSetInterval(minutes int) error { + // Set auto-sync interval +} + +func (a *App) SyncNow() (map[string]interface{}, error) { + // Trigger immediate sync +} + +func (a *App) ResetSyncKey() error { + // Clear device token +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add internal/core/sync/ internal/api/app.go +git commit -m "feat: add sync backend methods" +``` + +--- + +## Phase 3: Sync Plugin + +### Task 6: Create Plugin Structure + +**Covers:** [S3] + +**Files:** +- Create: `verstak-official-plugins/plugins/sync/plugin.json` +- Create: `verstak-official-plugins/plugins/sync/frontend/src/index.js` +- Create: `verstak-official-plugins/plugins/sync/frontend/src/SyncSettings.svelte` +- Create: `verstak-official-plugins/plugins/sync/frontend/src/SyncStatusBar.svelte` +- Create: `verstak-official-plugins/plugins/sync/frontend/package.json` + +- [ ] **Step 1: Create plugin.json** + +```json +{ + "schemaVersion": 1, + "id": "verstak.sync", + "name": "Sync", + "version": "0.1.0", + "apiVersion": "0.1.0", + "description": "Vault synchronization across devices via Verstak Sync Server.", + "source": "official", + "icon": "sync", + "provides": [ + "verstak/sync/v1", + "verstak/sync.status/v1" + ], + "requires": [ + "verstak/core/files/v1" + ], + "permissions": [ + "files.read", + "files.write", + "network.remote", + "settings.read", + "settings.write", + "ui.register" + ], + "frontend": { + "entry": "frontend/dist/index.js" + }, + "contributes": { + "settingsPanels": [ + { + "id": "verstak.sync.settings", + "title": "Sync", + "component": "SyncSettings" + } + ], + "statusBarItems": [ + { + "id": "verstak.sync.status", + "label": "Sync", + "position": "right" + } + ] + } +} +``` + +- [ ] **Step 2: Create frontend package.json** + +```json +{ + "name": "verstak-sync-plugin", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "vite build", + "dev": "vite build --watch" + }, + "devDependencies": { + "vite": "^5.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "svelte": "^4.0.0" + } +} +``` + +- [ ] **Step 3: Create frontend/src/index.js** + +```javascript +import SyncSettings from './SyncSettings.svelte'; +import SyncStatusBar from './SyncStatusBar.svelte'; + +window.VerstakPluginRegister('verstak.sync', { + components: { + SyncSettings: { + mount(container, props, api) { + return new SyncSettings({ + target: container, + props: { ...props, api } + }); + }, + unmount(container) { + // Svelte handles cleanup + } + }, + SyncStatusBar: { + mount(container, props, api) { + return new SyncStatusBar({ + target: container, + props: { ...props, api } + }); + }, + unmount(container) { + // Svelte handles cleanup + } + } + } +}); +``` + +- [ ] **Step 4: Commit** + +```bash +git add plugins/sync/ +git commit -m "feat: create sync plugin structure" +``` + +--- + +### Task 7: Implement SyncSettings Component + +**Covers:** [S3, S4] + +**Files:** +- Create: `verstak-official-plugins/plugins/sync/frontend/src/SyncSettings.svelte` + +- [ ] **Step 1: Create SyncSettings.svelte** + +```svelte + + +
Synchronize your vault across devices via Verstak Sync Server.
+ + {#if errorMsg} +This will clear the device token. You'll need to reconnect.
+