# 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.