sync: overhaul sync system — device pairing, server_sequence, auto-sync, dashboards
BREAKING: replace legacy API keys with device tokens via pairing flow. - Server: /api/client/pair, revoke, me endpoints; server_sequence + tombstones + idempotency - Desktop client: PairDevice, GetMe, RevokeCurrent; auto-sync loop every 60s - Config: device_token stored in separate file (0600), not config.yml - Client DB: last_pull_seq migration for incremental pull - Frontend (Svelte): settings modal with connect/disconnect/interval - User dashboard (/dashboard): device list with status, revoke with password - Admin dashboard (/admin/dashboard): devices table from /admin/api/devices - CLI (cmd/verstak): updated for ServerSequence/GetState changes - Fix: autoSyncLoop falls back to SQLite sync_state for server URL - Fix: SyncSetInterval preserves server_url/device_id from SQLite
This commit is contained in:
+114
-26
@@ -14,11 +14,12 @@ import (
|
||||
|
||||
// Client communicates with the Verstak Sync Server.
|
||||
type Client struct {
|
||||
ServerURL string
|
||||
APIKey string
|
||||
DeviceID string
|
||||
VaultRoot string
|
||||
HTTP *http.Client
|
||||
ServerURL string
|
||||
APIKey string // legacy API key
|
||||
DeviceToken string // new device token
|
||||
DeviceID string
|
||||
VaultRoot string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a sync client.
|
||||
@@ -32,6 +33,56 @@ func NewClient(serverURL, apiKey, deviceID, vaultRoot string) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// PairDevice calls POST /api/client/pair and returns device_id and device_token.
|
||||
func (c *Client) PairDevice(serverURL, username, password, deviceName, clientVersion string) (deviceID, deviceToken string, err error) {
|
||||
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
|
||||
}
|
||||
|
||||
// GetMe calls GET /api/client/me and returns device info.
|
||||
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) GetMe() (*DeviceInfo, error) {
|
||||
var resp DeviceInfo
|
||||
if err := c.get("/api/client/me", &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// RevokeCurrent calls POST /api/client/revoke-current.
|
||||
func (c *Client) RevokeCurrent() error {
|
||||
var resp struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
return c.post("/api/client/revoke-current", nil, &resp)
|
||||
}
|
||||
|
||||
// RegisterDevice calls POST /api/v1/device/register and returns the API key.
|
||||
func (c *Client) RegisterDevice(name string) (apiKey string, err error) {
|
||||
body := map[string]string{"name": name}
|
||||
@@ -81,24 +132,28 @@ func (c *Client) Login(username, password string) (token string, err error) {
|
||||
|
||||
// PushRequest is the payload for POST /sync/push.
|
||||
type PushRequest struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Ops []PushOp `json:"ops"`
|
||||
DeviceID string `json:"device_id"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
Ops []PushOp `json:"ops"`
|
||||
}
|
||||
|
||||
// PushOp is a single operation in a push request.
|
||||
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"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// PushResponse is the response from POST /sync/push.
|
||||
type PushResponse struct {
|
||||
Accepted []string `json:"accepted"`
|
||||
Count int `json:"count"`
|
||||
Accepted []string `json:"accepted"`
|
||||
Count int `json:"count"`
|
||||
Conflicts []map[string]interface{} `json:"conflicts"`
|
||||
}
|
||||
|
||||
// Push sends local operations to the server.
|
||||
@@ -124,18 +179,18 @@ func (c *Client) Push(ops []Op) (*PushResponse, error) {
|
||||
|
||||
// PullRequest is the payload for POST /sync/pull.
|
||||
type PullRequest struct {
|
||||
SinceRevision int `json:"since_revision"`
|
||||
SinceSequence int `json:"since_sequence"`
|
||||
}
|
||||
|
||||
// PullResponse is the response from POST /sync/pull.
|
||||
type PullResponse struct {
|
||||
ServerRevision int `json:"server_revision"`
|
||||
Ops []Op `json:"ops"`
|
||||
ServerSequence int `json:"server_sequence"`
|
||||
Ops []Op `json:"ops"`
|
||||
}
|
||||
|
||||
// Pull fetches remote operations since a given revision.
|
||||
func (c *Client) Pull(sinceRevision int) (*PullResponse, error) {
|
||||
req := PullRequest{SinceRevision: sinceRevision}
|
||||
// Pull fetches remote operations since a given sequence.
|
||||
func (c *Client) Pull(sinceSequence int) (*PullResponse, error) {
|
||||
req := PullRequest{SinceSequence: sinceSequence}
|
||||
var resp PullResponse
|
||||
if err := c.post("/api/v1/sync/pull", req, &resp); err != nil {
|
||||
return nil, err
|
||||
@@ -166,7 +221,7 @@ func (c *Client) UploadBlob(localPath string) (sha256 string, err error) {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
@@ -190,7 +245,7 @@ func (c *Client) DownloadBlob(sha256, destPath string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
@@ -213,17 +268,50 @@ func (c *Client) DownloadBlob(sha256, destPath string) error {
|
||||
|
||||
// --- internal ---
|
||||
|
||||
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 err := json.NewEncoder(&b).Encode(body); err != nil {
|
||||
return err
|
||||
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.APIKey)
|
||||
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 {
|
||||
|
||||
+45
-14
@@ -30,15 +30,16 @@ const (
|
||||
|
||||
// Op represents a sync operation.
|
||||
type Op struct {
|
||||
ID string `json:"id"`
|
||||
OpID string `json:"op_id"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
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"`
|
||||
ID string `json:"id"`
|
||||
OpID string `json:"op_id"`
|
||||
ServerSequence int `json:"server_sequence,omitempty"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// Service records and manages sync operations.
|
||||
@@ -74,6 +75,17 @@ func (s *Service) RecordOp(entityType, entityID, opType string, payload interfac
|
||||
return err
|
||||
}
|
||||
|
||||
// RecordRemoteOp writes a remote op to the local sync_ops table (already applied server-side).
|
||||
func (s *Service) RecordRemoteOp(op Op) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.db.Exec(
|
||||
`INSERT OR IGNORE INTO sync_ops (id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at, pushed_at, applied_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
op.OpID+"-remote", op.OpID, op.DeviceID, op.EntityType, op.EntityID, op.OpType, op.PayloadJSON, op.CreatedAt, now, now,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUnpushedOps returns ops that have not been pushed yet.
|
||||
func (s *Service) GetUnpushedOps() ([]Op, error) {
|
||||
rows, err := s.db.Query(
|
||||
@@ -111,10 +123,10 @@ func (s *Service) MarkApplied(opIDs []string) error {
|
||||
}
|
||||
|
||||
// GetState returns the current sync state.
|
||||
func (s *Service) GetState() (serverURL, apiKey string, lastPushRev int, lastSyncAt string, err error) {
|
||||
func (s *Service) GetState() (serverURL, apiKey string, lastPullSeq int, lastSyncAt string, err error) {
|
||||
err = s.db.QueryRow(
|
||||
`SELECT server_url, api_key, last_push_rev, COALESCE(last_sync_at,'') FROM sync_state WHERE device_id=?`,
|
||||
s.deviceID).Scan(&serverURL, &apiKey, &lastPushRev, &lastSyncAt)
|
||||
`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
|
||||
}
|
||||
@@ -124,14 +136,33 @@ func (s *Service) GetState() (serverURL, apiKey string, lastPushRev int, lastSyn
|
||||
// SetState saves sync connection state.
|
||||
func (s *Service) SetState(serverURL, apiKey string) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO sync_state (device_id, server_url, api_key, last_push_rev, last_sync_at)
|
||||
`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`,
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
server_url=excluded.server_url,
|
||||
api_key=excluded.api_key`,
|
||||
s.deviceID, serverURL, apiKey,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetLastPullSeq updates the last pulled server sequence.
|
||||
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
|
||||
}
|
||||
|
||||
// GetDeviceID returns the device ID used by this service.
|
||||
func (s *Service) GetDeviceID() string {
|
||||
return s.deviceID
|
||||
}
|
||||
|
||||
// SetLastSyncAt updates the last sync timestamp.
|
||||
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
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func scanOps(rows *sql.Rows) ([]Op, error) {
|
||||
|
||||
Reference in New Issue
Block a user