verstak-docs/docs/compose/plans/2026-06-20-sync-server-and-...

45 KiB

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

mkdir -p verstak-sync-server/cmd/server
mkdir -p verstak-sync-server/internal/server
  • Step 2: Initialize Go module
cd verstak-sync-server
go mod init github.com/verstak/verstak-sync-server
  • Step 3: Create main.go entry point
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
# 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:

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

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

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

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
cd verstak-sync-server
go test ./internal/server/ -v

Expected: PASS

  • Step 3: Commit
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

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
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:

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

{
  "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
{
  "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
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
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

<script>
  export let api = null;

  let settings = null;
  let loading = false;
  let errorMsg = '';
  let resultMsg = '';
  let resultKind = '';

  let serverUrl = '';
  let username = '';
  let password = '';
  let syncInterval = 0;
  let autoSync = false;
  let showDisconnectConfirm = false;
  let showResetKeyConfirm = false;
  let connectionOk = null;

  async function load() {
    try {
      settings = await api.settings.read();
      if (settings) {
        serverUrl = settings.serverUrl || '';
        syncInterval = settings.syncInterval || 0;
        autoSync = settings.autoSync || false;
      }
    } catch (e) {
      settings = null;
    }
  }

  load();

  async function testConnection() {
    loading = true;
    errorMsg = '';
    resultKind = '';
    connectionOk = null;
    try {
      await api.commands.execute('verstak.sync.testConnection', {
        serverUrl,
        username,
        password
      });
      connectionOk = true;
      resultMsg = 'connection ok';
    } catch (e) {
      connectionOk = false;
      resultMsg = 'connection failed: ' + String(e);
    }
    loading = false;
  }

  async function configureSync() {
    loading = true;
    errorMsg = '';
    resultKind = '';
    try {
      await api.commands.execute('verstak.sync.configure', {
        serverUrl,
        username,
        password
      });
      resultMsg = 'configured';
      username = '';
      password = '';
      await load();
    } catch (e) {
      errorMsg = String(e);
    }
    loading = false;
  }

  async function runSyncNow() {
    loading = true;
    errorMsg = '';
    resultKind = '';
    try {
      const r = await api.commands.execute('verstak.sync.syncNow');
      const summary = `Pushed: ${r?.pushed || 0}, Pulled: ${r?.pulled || 0}`;
      resultMsg = summary;
      await load();
    } catch (e) {
      errorMsg = String(e);
    }
    loading = false;
  }

  async function setInterval() {
    try {
      await api.settings.write('syncInterval', syncInterval);
      resultMsg = 'settings saved';
      resultKind = '';
    } catch (e) {
      errorMsg = String(e);
    }
  }

  async function setAutoSync() {
    try {
      await api.settings.write('autoSync', autoSync);
      resultMsg = 'settings saved';
      resultKind = '';
    } catch (e) {
      errorMsg = String(e);
    }
  }

  function confirmDisconnect() {
    showDisconnectConfirm = true;
  }

  async function doDisconnect() {
    showDisconnectConfirm = false;
    loading = true;
    resultKind = '';
    try {
      await api.commands.execute('verstak.sync.disconnect');
      resultMsg = 'disconnected';
      await load();
    } catch (e) {
      errorMsg = String(e);
    }
    loading = false;
  }

  function confirmResetKey() {
    showResetKeyConfirm = true;
  }

  async function doResetKey() {
    showResetKeyConfirm = false;
    loading = true;
    resultKind = '';
    try {
      await api.commands.execute('verstak.sync.resetKey');
      resultMsg = 'key reset';
      await load();
    } catch (e) {
      errorMsg = String(e);
    }
    loading = false;
  }

  function statusLabel(s) {
    if (!s) return 'Not configured';
    const labels = {
      'connected': 'Connected',
      'disconnected': 'Disconnected',
      'disabled': 'Not configured',
      'error': 'Error',
      'revoked': 'Revoked',
    };
    return labels[s] || s;
  }
</script>

<div class="settings-section">
  <h2>Sync</h2>
  <p class="section-desc">Synchronize your vault across devices via Verstak Sync Server.</p>

  {#if errorMsg}
    <div class="error-msg">{errorMsg}</div>
  {/if}
  {#if resultMsg && !errorMsg}
    <div class="result-msg" class:warning={resultKind === 'warning'}>{resultMsg}</div>
  {/if}

  {#if settings && settings.serverUrl}
    <div class="settings-card">
      <div class="sync-info">
        <div class="info-row">
          <span class="info-label">Status</span>
          <span class="info-value" class:status-ok={settings.lastStatus === 'connected'} class:status-err={settings.lastStatus === 'error' || settings.lastStatus === 'revoked'}>
            {statusLabel(settings.lastStatus)}
          </span>
        </div>
        {#if settings.serverUrl}
          <div class="info-row">
            <span class="info-label">Server</span>
            <span class="info-value mono">{settings.serverUrl}</span>
          </div>
        {/if}
        {#if settings.deviceName}
          <div class="info-row">
            <span class="info-label">Device</span>
            <span class="info-value">{settings.deviceName}</span>
          </div>
        {/if}
        {#if settings.deviceId}
          <div class="info-row">
            <span class="info-label">Device ID</span>
            <span class="info-value mono">{settings.deviceId}</span>
          </div>
        {/if}
        {#if settings.lastSyncAt}
          <div class="info-row">
            <span class="info-label">Last Sync</span>
            <span class="info-value">{settings.lastSyncAt}</span>
          </div>
        {/if}
        {#if settings.lastError}
          <div class="info-row">
            <span class="info-label">Last Error</span>
            <span class="info-value error">{settings.lastError}</span>
          </div>
        {/if}
      </div>
    </div>

    <div class="sync-actions">
      <button class="btn btn-primary" on:click={runSyncNow} disabled={loading}>
        Sync Now
      </button>
      <button class="btn" on:click={confirmDisconnect} disabled={loading}>
        Disconnect
      </button>
      <button class="btn" on:click={confirmResetKey} disabled={loading}>
        Reset Key
      </button>
    </div>

    <div class="sync-interval">
      <label>
        <span class="label-text">Sync Interval (minutes)</span>
        <div class="interval-row">
          <input type="number" bind:value={syncInterval} min="0" placeholder="0" />
          <button class="btn btn-sm" on:click={setInterval}>Save</button>
        </div>
      </label>
    </div>

    <div class="sync-autosync">
      <label>
        <input type="checkbox" bind:checked={autoSync} on:change={setAutoSync} />
        <span class="label-text">Auto-sync</span>
      </label>
    </div>
  {:else}
    <div class="settings-card">
      <div class="sync-setup">
        <div class="form-group">
          <label>
            <span class="label-text">Server URL</span>
            <input type="text" placeholder="https://example.com" bind:value={serverUrl} />
          </label>
        </div>
        <div class="form-group">
          <label>
            <span class="label-text">Username</span>
            <input type="text" bind:value={username} />
          </label>
        </div>
        <div class="form-group">
          <label>
            <span class="label-text">Password</span>
            <input type="password" bind:value={password} />
          </label>
        </div>
        <div class="sync-setup-actions">
          <button class="btn" on:click={testConnection} disabled={loading || !serverUrl}>
            Test Connection
          </button>
          <button class="btn btn-primary" on:click={configureSync}
                  disabled={loading || !serverUrl || !username || !password}>
            Connect
          </button>
        </div>
        {#if connectionOk !== null}
          <div class="connection-result" class:ok={connectionOk} class:fail={!connectionOk}>
            {connectionOk ? 'Test OK' : 'Connection failed'}
          </div>
        {/if}
      </div>
    </div>
  {/if}
</div>

{#if showDisconnectConfirm}
  <button class="modal-overlay" on:click={() => showDisconnectConfirm = false}>
    <div class="modal">
      <h3>Disconnect?</h3>
      <p class="modal-desc">This will revoke this device's access to the sync server.</p>
      <div class="modal-actions">
        <button class="btn btn-danger" on:click={doDisconnect}>Disconnect</button>
        <button class="btn" on:click={() => showDisconnectConfirm = false}>Cancel</button>
      </div>
    </div>
  </button>
{/if}

{#if showResetKeyConfirm}
  <button class="modal-overlay" on:click={() => showResetKeyConfirm = false}>
    <div class="modal">
      <h3>Reset Key?</h3>
      <p class="modal-desc">This will clear the device token. You'll need to reconnect.</p>
      <div class="modal-actions">
        <button class="btn btn-danger" on:click={doResetKey}>Reset</button>
        <button class="btn" on:click={() => showResetKeyConfirm = false}>Cancel</button>
      </div>
    </div>
  </button>
{/if}

<style>
  .settings-section {
    padding: 1.5rem;
    max-width: 600px;
  }
  .settings-section h2 {
    margin: 0 0 0.25rem 0;
    font-size: 1.2rem;
    color: #e0e0e0;
  }
  .section-desc {
    color: #888;
    font-size: 0.85rem;
    margin-bottom: 1.25rem;
    line-height: 1.4;
  }
  .settings-card {
    background: #1e1e30;
    border: 1px solid #2a2a3e;
    border-radius: 8px;
    padding: 1rem 1.25rem;
    margin-bottom: 1rem;
  }
  .info-row {
    display: flex;
    padding: 0.4rem 0;
    border-bottom: 1px solid #2a2a3e;
    font-size: 0.9rem;
  }
  .info-row:last-child { border-bottom: none; }
  .info-label {
    width: 180px;
    min-width: 180px;
    color: #888;
  }
  .info-value { color: #e0e0e0; word-break: break-all; }
  .info-value.mono { font-family: monospace; font-size: 0.85rem; }
  .info-value.error { color: #ff6b6b; }
  .status-ok { color: #34d399; font-weight: 600; }
  .status-err { color: #ff6b6b; }
  .sync-actions {
    display: flex;
    gap: 0.5rem;
    flex-wrap: wrap;
    margin-bottom: 1rem;
  }
  .sync-interval {
    margin-bottom: 0.5rem;
  }
  .interval-row {
    display: flex;
    gap: 0.5rem;
    align-items: center;
  }
  .interval-row input {
    width: 100px;
  }
  .sync-autosync {
    margin-bottom: 0;
  }
  .sync-autosync label {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    cursor: pointer;
  }
  .sync-setup .form-group { margin-bottom: 1rem; }
  .sync-setup .form-group:last-of-type { margin-bottom: 0; }
  .sync-setup-actions {
    display: flex;
    gap: 0.5rem;
    margin-top: 1rem;
  }
  .connection-result {
    margin-top: 0.75rem;
    padding: 0.4rem 0.75rem;
    border-radius: 6px;
    font-size: 0.85rem;
  }
  .connection-result.ok {
    background: rgba(52, 211, 153, 0.1);
    border: 1px solid rgba(52, 211, 153, 0.3);
    color: #34d399;
  }
  .connection-result.fail {
    background: rgba(255, 107, 107, 0.1);
    border: 1px solid rgba(255, 107, 107, 0.3);
    color: #ff6b6b;
  }
  .error-msg {
    padding: 0.5rem 0.75rem;
    margin-bottom: 0.75rem;
    background: rgba(255, 107, 107, 0.1);
    border: 1px solid rgba(255, 107, 107, 0.3);
    border-radius: 6px;
    color: #ff6b6b;
    font-size: 0.85rem;
  }
  .result-msg {
    padding: 0.5rem 0.75rem;
    margin-bottom: 0.75rem;
    background: rgba(52, 211, 153, 0.1);
    border: 1px solid rgba(52, 211, 153, 0.3);
    border-radius: 6px;
    color: #34d399;
    font-size: 0.85rem;
  }
  .result-msg.warning {
    background: rgba(245, 158, 11, 0.1);
    border-color: rgba(245, 158, 11, 0.3);
    color: #f59e0b;
  }
  .modal-overlay {
    position: fixed;
    inset: 0;
    background: rgba(0,0,0,0.6);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 1000;
    border: none;
    padding: 0;
    cursor: pointer;
    width: 100%;
    color: inherit;
    font: inherit;
  }
  .modal {
    background: #1e1e2e;
    border: 1px solid #2a2a3e;
    border-radius: 10px;
    padding: 1.5rem;
    max-width: 420px;
    width: 90%;
    cursor: default;
  }
  .modal h3 { margin: 0 0 0.75rem 0; }
  .modal-desc { color: #888; font-size: 0.9rem; line-height: 1.5; margin-bottom: 1rem; }
  .modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
</style>
  • Step 2: Commit
git add plugins/sync/frontend/src/SyncSettings.svelte
git commit -m "feat: implement SyncSettings component"

Task 8: Implement SyncStatusBar Component

Covers: [S3, S4]

Files:

  • Create: verstak-official-plugins/plugins/sync/frontend/src/SyncStatusBar.svelte

  • Step 1: Create SyncStatusBar.svelte

<script>
  import { onMount, onDestroy } from 'svelte';

  export let api = null;

  let status = 'disabled';
  let loading = false;
  let interval = null;

  async function loadStatus() {
    try {
      const settings = await api.settings.read();
      status = settings?.lastStatus || 'disabled';
    } catch (e) {
      status = 'error';
    }
  }

  onMount(() => {
    loadStatus();
    interval = setInterval(loadStatus, 30000);
  });

  onDestroy(() => {
    if (interval) clearInterval(interval);
  });

  function statusColor(s) {
    switch (s) {
      case 'connected': return '#34d399';
      case 'error': return '#ff6b6b';
      case 'revoked': return '#ff6b6b';
      default: return '#888';
    }
  }

  function statusText(s) {
    switch (s) {
      case 'connected': return 'Sync: Connected';
      case 'error': return 'Sync: Error';
      case 'revoked': return 'Sync: Revoked';
      default: return 'Sync: Off';
    }
  }
</script>

<button
  class="sync-status-bar"
  style="color: {statusColor(status)}"
  on:click={() => api.commands.execute('verstak.sync.openSettings')}
  title="Click to open sync settings"
>
  <span class="status-dot" style="background: {statusColor(status)}"></span>
  <span class="status-text">{statusText(status)}</span>
</button>

<style>
  .sync-status-bar {
    display: flex;
    align-items: center;
    gap: 0.35rem;
    padding: 0.25rem 0.5rem;
    background: transparent;
    border: none;
    cursor: pointer;
    font-size: 0.75rem;
    font-family: inherit;
    color: inherit;
    transition: opacity 0.15s;
  }
  .sync-status-bar:hover {
    opacity: 0.8;
  }
  .status-dot {
    width: 6px;
    height: 6px;
    border-radius: 50%;
    flex-shrink: 0;
  }
  .status-text {
    white-space: nowrap;
  }
</style>
  • Step 2: Commit
git add plugins/sync/frontend/src/SyncStatusBar.svelte
git commit -m "feat: implement SyncStatusBar component"

Task 9: Build Plugin Frontend

Covers: [S3]

Files:

  • Create: verstak-official-plugins/plugins/sync/frontend/vite.config.js

  • Step 1: Create vite.config.js

import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';

export default defineConfig({
  plugins: [svelte()],
  build: {
    outDir: 'dist',
    lib: {
      entry: 'src/index.js',
      formats: ['iife'],
      name: 'VerstakSyncPlugin'
    },
    rollupOptions: {
      output: {
        entryFileNames: 'index.js',
        assetFileNames: '[name][extname]'
      }
    }
  }
});
  • Step 2: Install dependencies and build
cd verstak-official-plugins/plugins/sync/frontend
npm install
npm run build
  • Step 3: Commit
git add plugins/sync/frontend/
git commit -m "feat: build sync plugin frontend"

Phase 4: Testing

Task 10: Add E2E Tests

Covers: [S6]

Files:

  • Create: verstak-official-plugins/plugins/sync/e2e/sync-settings.spec.js

  • Step 1: Create e2e test

import { test, expect } from '@playwright/test';

test.describe('Sync Plugin Settings', () => {
  test('shows setup form when not configured', async ({ page }) => {
    await page.goto('/');
    await page.click('[data-plugin="verstak.sync"]');
    await page.click('[data-action="settings"]');

    await expect(page.locator('.sync-setup')).toBeVisible();
    await expect(page.locator('input[placeholder="https://example.com"]')).toBeVisible();
  });

  test('shows status when configured', async ({ page }) => {
    // Mock configured state
    await page.goto('/');
    await page.evaluate(() => {
      localStorage.setItem('verstak-sync-settings', JSON.stringify({
        serverUrl: 'https://sync.example.com',
        lastStatus: 'connected',
        deviceName: 'test-device'
      }));
    });

    await page.click('[data-plugin="verstak.sync"]');
    await page.click('[data-action="settings"]');

    await expect(page.locator('.sync-info')).toBeVisible();
    await expect(page.locator('.status-ok')).toBeVisible();
  });
});
  • Step 2: Commit
git add plugins/sync/e2e/
git commit -m "test: add sync plugin E2E tests"

Summary

Task Description Files
1 Initialize sync server repo main.go, README.md
2 Server core server.go, config.go, schema.go
3 Routes and handlers routes.go, handlers_api.go, etc.
4 Server tests server_test.go
5 Desktop backend API sync/client.go, sync/service.go, app.go
6 Plugin structure plugin.json, package.json, index.js
7 SyncSettings component SyncSettings.svelte
8 SyncStatusBar component SyncStatusBar.svelte
9 Build frontend vite.config.js
10 E2E tests sync-settings.spec.js