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

1935 lines
45 KiB
Markdown

# 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
<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**
```bash
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**
```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**
```bash
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**
```javascript
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**
```bash
cd verstak-official-plugins/plugins/sync/frontend
npm install
npm run build
```
- [ ] **Step 3: Commit**
```bash
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**
```javascript
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**
```bash
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 |