fix: isolate sync operations by user and vault

This commit is contained in:
mirivlad 2026-07-10 03:13:17 +08:00
parent 5c8644d5e1
commit 086bc4ea4a
8 changed files with 712 additions and 83 deletions

View File

@ -7,7 +7,7 @@ Standalone sync server for Verstak2 platform.
This server provides synchronization between devices running Verstak2. It handles: This server provides synchronization between devices running Verstak2. It handles:
- Device registration and authentication - Device registration and authentication
- Operation log sync with server sequence numbers and conflict detection - Vault-scoped operation log sync with server sequence numbers and conflict detection
- Blob storage for attachments - Blob storage for attachments
- User management with email confirmation - User management with email confirmation
@ -139,7 +139,7 @@ internal/server/ - Server implementation
Desktop sync client: Desktop sync client:
- `POST /api/client/pair` - Pair a desktop client with username/password and return a device token - `POST /api/client/pair` - Pair a desktop client with username/password and its persistent `vault_id`, then return a device token
- `POST /api/auth/test` - Validate username/password from the desktop client - `POST /api/auth/test` - Validate username/password from the desktop client
- `GET /api/client/me` - Return current authenticated client/device details - `GET /api/client/me` - Return current authenticated client/device details
- `POST /api/client/revoke-current` - Revoke the current desktop device token - `POST /api/client/revoke-current` - Revoke the current desktop device token
@ -165,8 +165,15 @@ Operational endpoints:
- `/register`, `/login`, `/dashboard`, `/forgot`, `/reset`, `/logout` - User web UI - `/register`, `/login`, `/dashboard`, `/forgot`, `/reset`, `/logout` - User web UI
Sync operations are generic records with `entity_type`, `entity_id`, `op_type`, Sync operations are generic records with `entity_type`, `entity_id`, `op_type`,
`payload_json`, `device_id`, and sequencing metadata. The server stores and `payload_json`, `device_id`, and sequencing metadata. A pairing token is bound
orders operations; Verstak desktop owns the v2 payload semantics. to one user and vault. The server derives the stored device ID and operation
scope from that token, ignores a caller-supplied `device_id` for authorization,
and returns only operations and cursors from the authenticated user/vault.
Verstak desktop owns the v2 payload semantics.
New device enrollment requires a non-empty `vault_id`. The `legacy:` prefix is
reserved for server-side migration of older records and cannot be selected by
new clients.
## Development ## Development

View File

@ -18,16 +18,16 @@ migrated into a deterministic legacy scope.
**Files:** **Files:**
- Modify: `internal/server/server_test.go` - Modify: `internal/server/server_test.go`
- [ ] Add helpers that create confirmed users and token-authenticated devices - [x] Add helpers that create confirmed users and token-authenticated devices
with a specified vault ID. with a specified vault ID.
- [ ] Add a failing test where separate users push and pull from the same - [x] Add a failing test where separate users push and pull from the same
vault ID; each pull must contain only its own operation and cursor. vault ID; each pull must contain only its own operation and cursor.
- [ ] Add a failing test where one user owns devices in two vaults; pulls must - [x] Add a failing test where one user owns devices in two vaults; pulls must
remain vault-local. remain vault-local.
- [ ] Add a failing test that sends another device's ID in `push`; assert the - [x] Add a failing test that sends another device's ID in `push`; assert the
stored and returned operation uses the authenticated device ID. stored and returned operation uses the authenticated device ID.
- [ ] Add a failing test for identical idempotency keys in different scopes. - [x] Add a failing test for identical idempotency keys in different scopes.
- [ ] Run: `go test ./internal/server -run 'TestSync.*Isolation|TestSyncPush'` - [x] Run: `go test ./internal/server -run 'TestSync.*Isolation|TestSyncPush'`
and confirm the new assertions fail for the intended missing behaviour. and confirm the new assertions fail for the intended missing behaviour.
## Task 2: Add idempotent SQLite scope migration ## Task 2: Add idempotent SQLite scope migration
@ -37,15 +37,15 @@ migrated into a deterministic legacy scope.
- Modify: `internal/server/server.go` - Modify: `internal/server/server.go`
- Test: `internal/server/server_test.go` - Test: `internal/server/server_test.go`
- [ ] Define `vault_id` on new devices and `user_id`/`vault_id` on new - [x] Define `vault_id` on new devices and `user_id`/`vault_id` on new
operations; define scoped tombstone and idempotency primary keys. operations; define scoped tombstone and idempotency primary keys.
- [ ] Add startup migration helpers that inspect columns, add compatible - [x] Add startup migration helpers that inspect columns, add compatible
columns, backfill owner IDs, assign `legacy:<user_id>` to old scopes, and columns, backfill owner IDs, assign `legacy:<user_id>` to old scopes, and
rebuild the two tables whose primary keys change. rebuild the two tables whose primary keys change.
- [ ] Add a failing legacy-schema fixture test, then make it pass by opening - [x] Add a failing legacy-schema fixture test, then make it pass by opening
the database through `NewServer` and asserting its operation has the the database through `NewServer` and asserting its operation has the
expected owner and legacy scope. expected owner and legacy scope.
- [ ] Run: `go test ./internal/server -run 'Test.*Migration|TestSync.*'`. - [x] Run: `go test ./internal/server -run 'Test.*Migration|TestSync.*'`.
## Task 3: Apply authenticated scope to sync handlers ## Task 3: Apply authenticated scope to sync handlers
@ -54,15 +54,15 @@ migrated into a deterministic legacy scope.
- Modify: `internal/server/handlers_api.go` - Modify: `internal/server/handlers_api.go`
- Test: `internal/server/server_test.go` - Test: `internal/server/server_test.go`
- [ ] Extend authenticated device lookup to provide the effective vault scope; - [x] Extend authenticated device lookup to provide the effective vault scope;
missing user ownership must not authorize sync operations. missing user ownership must not authorize sync operations.
- [ ] Require `vault_id` when creating a new client pairing and store it with - [x] Require `vault_id` when creating a new client pairing and store it with
the device. the device.
- [ ] Make push use authenticated device/user/vault values for inserts, - [x] Make push use authenticated device/user/vault values for inserts,
conflicts, revisions, tombstones, and idempotency lookup/storage. conflicts, revisions, tombstones, and idempotency lookup/storage.
- [ ] Make pull filter operations and its reported cursor by authenticated - [x] Make pull filter operations and its reported cursor by authenticated
user/vault. user/vault.
- [ ] Run the focused tests from Task 1 until green, then - [x] Run the focused tests from Task 1 until green, then
`go test ./internal/server`. `go test ./internal/server`.
## Task 4: Send the current vault ID while pairing ## Task 4: Send the current vault ID while pairing
@ -73,13 +73,17 @@ migrated into a deterministic legacy scope.
- Modify: `../verstak-desktop/internal/api/app.go` - Modify: `../verstak-desktop/internal/api/app.go`
- Modify: `../verstak-desktop/internal/api/app_test.go` - Modify: `../verstak-desktop/internal/api/app_test.go`
- [ ] Add `vault_id` to the pair request and expose it in the pairing client - [x] Add `vault_id` to the pair request and expose it in the pairing client
method without changing push/pull wire compatibility. method without changing push/pull wire compatibility.
- [ ] Read the open vault metadata in `syncConfigure`; reject configuration if - [x] Read the open vault metadata in `syncConfigure`; reject configuration if
the vault ID is absent. the vault ID is absent.
- [ ] Add a failing client/API test that captures the pair request and asserts - [x] Add a failing client/API test that captures the pair request and asserts
the persistent vault ID is sent. the persistent vault ID is sent.
- [ ] Run: `go test ./internal/core/sync ./internal/api`. - [x] Rebind desktop sync state, cursor, and persisted device identity whenever
a vault is created, opened, or switched.
- [x] Hydrate missing legacy vault device IDs from the authenticated sync
server before a token-based sync can use a global fallback.
- [x] Run: `go test ./internal/core/sync ./internal/api`.
## Task 5: Document, verify, and publish ## Task 5: Document, verify, and publish
@ -87,9 +91,9 @@ migrated into a deterministic legacy scope.
- Modify: `README.md` - Modify: `README.md`
- Modify: `docs/superpowers/specs/2026-07-10-sync-tenant-isolation-design.md` - Modify: `docs/superpowers/specs/2026-07-10-sync-tenant-isolation-design.md`
- [ ] Document that pairing is vault-bound and that sync cursors are scoped. - [x] Document that pairing is vault-bound and that sync cursors are scoped.
- [ ] Run `gofmt` on all changed Go files. - [x] Run `gofmt` on all changed Go files.
- [ ] Run `go test ./...` in both `verstak-sync-server` and - [x] Run `go test ./...` in both `verstak-sync-server` and
`verstak-desktop`, then `git diff --check` in both repositories. `verstak-desktop`, then `git diff --check` in both repositories.
- [ ] Commit and push the sync-server and desktop changes as coordinated - [x] Commit and push the sync-server and desktop changes as coordinated
security commits. security commits.

View File

@ -23,9 +23,10 @@ in this change.
## Data model ## Data model
`server_devices` gains a nullable `vault_id`. A device created through `server_devices` gains a nullable `vault_id`. A device created through either
`/api/client/pair` must have a non-empty vault ID. The authenticated device enrollment endpoint must have a non-empty vault ID that does not use the
therefore identifies one user and one vault. reserved `legacy:` prefix. The authenticated device therefore identifies one
user and one vault.
`server_ops` gains `user_id` and `vault_id`. New writes always set both from `server_ops` gains `user_id` and `vault_id`. New writes always set both from
the authenticated device. Pull and conflict queries filter both fields. the authenticated device. Pull and conflict queries filter both fields.
@ -40,6 +41,12 @@ legacy scope during startup migration. This preserves existing single-vault
accounts while preventing data from crossing account boundaries. New pairings accounts while preventing data from crossing account boundaries. New pairings
never use the legacy scope. never use the legacy scope.
Desktop sync state, operation queues, cursors, and persisted device IDs are
vault-local. The desktop recreates its sync service whenever the active vault
is created, opened, or switched. When it opens a legacy vault state without a
stored device ID, it obtains the authenticated ID from `/api/client/me` before
syncing and persists it locally.
## API contract ## API contract
`POST /api/client/pair` accepts a required `vault_id`. The desktop gets it `POST /api/client/pair` accepts a required `vault_id`. The desktop gets it
@ -61,6 +68,10 @@ new operation/device fields, backfills `user_id` from each operation's device,
and assigns the explicit legacy scope when an old device has no vault ID. and assigns the explicit legacy scope when an old device has no vault ID.
Tables whose primary key must change are rebuilt transactionally. Tables whose primary key must change are rebuilt transactionally.
The prior global idempotency cache is intentionally discarded during migration:
it is only a replay cache and retaining it could replay one tenant's response
for another tenant.
If an operation cannot be associated with a user, it remains unscoped and is If an operation cannot be associated with a user, it remains unscoped and is
not readable through sync APIs. The server must not guess an owner from a not readable through sync APIs. The server must not guess an owner from a
request body. request body.
@ -75,5 +86,7 @@ Focused server tests must prove:
4. scoped idempotency does not replay another tenant's response; 4. scoped idempotency does not replay another tenant's response;
5. a legacy SQLite database is upgraded with its existing operation retained 5. a legacy SQLite database is upgraded with its existing operation retained
in the matching legacy scope. in the matching legacy scope.
6. switching the active desktop vault rebinds the sync queue, cursor, and
device identity to the new vault.
Desktop tests must prove that pairing sends the opened vault's persistent ID. Desktop tests must prove that pairing sends the opened vault's persistent ID.

View File

@ -50,6 +50,7 @@ func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
Password string `json:"password"` Password string `json:"password"`
DeviceName string `json:"device_name"` DeviceName string `json:"device_name"`
ClientVersion string `json:"client_version"` ClientVersion string `json:"client_version"`
VaultID string `json:"vault_id"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "bad json") jsonErr(w, 400, "bad json")
@ -59,6 +60,15 @@ func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "login and password required") jsonErr(w, 400, "login and password required")
return return
} }
req.VaultID = strings.TrimSpace(req.VaultID)
if req.VaultID == "" {
jsonErr(w, 400, "vault_id required")
return
}
if strings.HasPrefix(req.VaultID, "legacy:") {
jsonErr(w, 400, "vault_id uses reserved prefix")
return
}
if req.DeviceName == "" { if req.DeviceName == "" {
req.DeviceName = "unknown" req.DeviceName = "unknown"
} }
@ -95,10 +105,10 @@ func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
apiKey := make([]byte, 20) apiKey := make([]byte, 20)
rand.Read(apiKey) rand.Read(apiKey)
_, err = s.db.Exec(`INSERT INTO server_devices _, err = s.db.Exec(`INSERT INTO server_devices
(id, name, api_key, token_hash, token_prefix, token_suffix, user_id, client_version, last_ip, last_seen, created_at) (id, name, api_key, token_hash, token_prefix, token_suffix, user_id, vault_id, client_version, last_ip, last_seen, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
deviceID, req.DeviceName, hex.EncodeToString(apiKey), tokenHash, prefix, suffix, deviceID, req.DeviceName, hex.EncodeToString(apiKey), tokenHash, prefix, suffix,
userID, req.ClientVersion, ip, now, now) userID, req.VaultID, req.ClientVersion, ip, now, now)
if err != nil { if err != nil {
jsonErr(w, 500, err.Error()) jsonErr(w, 500, err.Error())
return return
@ -272,6 +282,7 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
Name string `json:"name"` Name string `json:"name"`
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
VaultID string `json:"vault_id"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON") jsonErr(w, 400, "invalid JSON")
@ -285,6 +296,15 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 401, "username and password required") jsonErr(w, 401, "username and password required")
return return
} }
req.VaultID = strings.TrimSpace(req.VaultID)
if req.VaultID == "" {
jsonErr(w, 400, "vault_id required")
return
}
if strings.HasPrefix(req.VaultID, "legacy:") {
jsonErr(w, 400, "vault_id uses reserved prefix")
return
}
var userID, hash string var userID, hash string
var confirmed, blocked int var confirmed, blocked int
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?", err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
@ -311,8 +331,8 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
deviceID := apiKey[:12] deviceID := apiKey[:12]
now := time.Now().UTC().Format(time.RFC3339) now := time.Now().UTC().Format(time.RFC3339)
_, err = s.db.Exec( _, err = s.db.Exec(
"INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)", "INSERT INTO server_devices (id, name, api_key, user_id, vault_id, last_seen, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
deviceID, req.Name, apiKey, now, now, deviceID, req.Name, apiKey, userID, req.VaultID, now, now,
) )
if err != nil { if err != nil {
jsonErr(w, 500, err.Error()) jsonErr(w, 500, err.Error())
@ -326,7 +346,7 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
_, _, ok := s.requireAuth(w, r) scope, ok := s.requireSyncScope(w, r)
if !ok { if !ok {
return return
} }
@ -354,7 +374,9 @@ func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
} }
if req.IdempotencyKey != "" { if req.IdempotencyKey != "" {
var cachedJSON string var cachedJSON string
err := s.db.QueryRow("SELECT response_json FROM server_idempotency_keys WHERE idempotency_key=?", req.IdempotencyKey).Scan(&cachedJSON) err := s.db.QueryRow(`SELECT response_json FROM server_idempotency_keys
WHERE user_id=? AND vault_id=? AND idempotency_key=?`,
scope.UserID, scope.VaultID, req.IdempotencyKey).Scan(&cachedJSON)
if err == nil { if err == nil {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Write([]byte(cachedJSON)) w.Write([]byte(cachedJSON))
@ -371,9 +393,9 @@ func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
if op.LastSeenServerSeq > 0 { if op.LastSeenServerSeq > 0 {
conflictRows, err := s.db.Query(` conflictRows, err := s.db.Query(`
SELECT op_id, device_id, op_type, server_sequence FROM server_ops SELECT op_id, device_id, op_type, server_sequence FROM server_ops
WHERE entity_type=? AND entity_id=? AND device_id!=? WHERE user_id=? AND vault_id=? AND entity_type=? AND entity_id=? AND device_id!=?
AND server_sequence > ? AND op_type != 'delete' AND server_sequence > ? AND op_type != 'delete'
ORDER BY server_sequence`, op.EntityType, op.EntityID, req.DeviceID, op.LastSeenServerSeq) ORDER BY server_sequence`, scope.UserID, scope.VaultID, op.EntityType, op.EntityID, scope.DeviceID, op.LastSeenServerSeq)
if err == nil { if err == nil {
for conflictRows.Next() { for conflictRows.Next() {
var cOpID, cDevID, cOpType string var cOpID, cDevID, cOpType string
@ -392,9 +414,9 @@ func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
} }
} }
res, err := s.db.Exec( res, err := s.db.Exec(
`INSERT OR IGNORE INTO server_ops (op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, idempotency_key, client_sequence, last_seen_server_seq, created_at, pushed_at) `INSERT OR IGNORE INTO server_ops (op_id, server_sequence, user_id, vault_id, device_id, entity_type, entity_id, op_type, payload_json, idempotency_key, client_sequence, last_seen_server_seq, created_at, pushed_at)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
op.OpID, req.DeviceID, op.EntityType, op.EntityID, op.OpType, op.PayloadJSON, op.OpID, scope.UserID, scope.VaultID, scope.DeviceID, op.EntityType, op.EntityID, op.OpType, op.PayloadJSON,
req.IdempotencyKey, op.ClientSequence, op.LastSeenServerSeq, op.CreatedAt, now, req.IdempotencyKey, op.ClientSequence, op.LastSeenServerSeq, op.CreatedAt, now,
) )
if err != nil { if err != nil {
@ -404,15 +426,16 @@ func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
if n == 0 { if n == 0 {
continue continue
} }
seqRes, err := s.db.Exec("INSERT INTO server_revisions (op_id, device_id) VALUES (?, ?)", op.OpID, req.DeviceID) seqRes, err := s.db.Exec("INSERT INTO server_revisions (op_id, device_id) VALUES (?, ?)", op.OpID, scope.DeviceID)
if err != nil { if err != nil {
continue continue
} }
seq, _ := seqRes.LastInsertId() seq, _ := seqRes.LastInsertId()
s.db.Exec("UPDATE server_ops SET server_sequence=? WHERE op_id=?", seq, op.OpID) s.db.Exec("UPDATE server_ops SET server_sequence=? WHERE op_id=?", seq, op.OpID)
if op.OpType == "delete" { if op.OpType == "delete" {
s.db.Exec(`INSERT OR REPLACE INTO server_tombstones (entity_type, entity_id, op_id, deleted_at) VALUES (?, ?, ?, ?)`, s.db.Exec(`INSERT OR REPLACE INTO server_tombstones
op.EntityType, op.EntityID, op.OpID, now) (user_id, vault_id, entity_type, entity_id, op_id, deleted_at) VALUES (?, ?, ?, ?, ?, ?)`,
scope.UserID, scope.VaultID, op.EntityType, op.EntityID, op.OpID, now)
} }
accepted = append(accepted, op.OpID) accepted = append(accepted, op.OpID)
} }
@ -423,15 +446,16 @@ func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
} }
if req.IdempotencyKey != "" { if req.IdempotencyKey != "" {
if respJSON, err := json.Marshal(resp); err == nil { if respJSON, err := json.Marshal(resp); err == nil {
s.db.Exec("INSERT OR IGNORE INTO server_idempotency_keys (idempotency_key, response_json, created_at) VALUES (?, ?, ?)", s.db.Exec(`INSERT OR IGNORE INTO server_idempotency_keys
req.IdempotencyKey, string(respJSON), now) (user_id, vault_id, idempotency_key, response_json, created_at) VALUES (?, ?, ?, ?, ?)`,
scope.UserID, scope.VaultID, req.IdempotencyKey, string(respJSON), now)
} }
} }
jsonOK(w, resp) jsonOK(w, resp)
} }
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
_, _, ok := s.requireAuth(w, r) scope, ok := s.requireSyncScope(w, r)
if !ok { if !ok {
return return
} }
@ -447,12 +471,13 @@ func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
return return
} }
var serverSeq int var serverSeq int
s.db.QueryRow("SELECT COALESCE(MAX(server_sequence), 0) FROM server_ops").Scan(&serverSeq) s.db.QueryRow(`SELECT COALESCE(MAX(server_sequence), 0) FROM server_ops
WHERE user_id=? AND vault_id=?`, scope.UserID, scope.VaultID).Scan(&serverSeq)
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, created_at SELECT op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, created_at
FROM server_ops FROM server_ops
WHERE server_sequence > ? AND server_sequence IS NOT NULL WHERE user_id=? AND vault_id=? AND server_sequence > ? AND server_sequence IS NOT NULL
ORDER BY server_sequence`, req.SinceSequence) ORDER BY server_sequence`, scope.UserID, scope.VaultID, req.SinceSequence)
if err != nil { if err != nil {
jsonErr(w, 500, err.Error()) jsonErr(w, 500, err.Error())
return return

View File

@ -7,7 +7,38 @@ import (
"time" "time"
) )
type authenticatedDevice struct {
DeviceID string
UserID string
VaultID string
}
func (s *Server) requireAuth(w http.ResponseWriter, r *http.Request) (deviceID, userID string, ok bool) { func (s *Server) requireAuth(w http.ResponseWriter, r *http.Request) (deviceID, userID string, ok bool) {
device, ok := s.authenticateDevice(w, r)
if !ok {
return "", "", false
}
return device.DeviceID, device.UserID, true
}
func (s *Server) requireSyncScope(w http.ResponseWriter, r *http.Request) (authenticatedDevice, bool) {
device, ok := s.authenticateDevice(w, r)
if !ok {
return authenticatedDevice{}, false
}
if device.UserID == "" {
jsonErr(w, http.StatusForbidden, "device is not associated with a user")
return authenticatedDevice{}, false
}
device.VaultID = effectiveVaultScope(device.UserID, device.VaultID)
if device.VaultID == "" {
jsonErr(w, http.StatusForbidden, "device is not associated with a vault")
return authenticatedDevice{}, false
}
return device, true
}
func (s *Server) authenticateDevice(w http.ResponseWriter, r *http.Request) (authenticatedDevice, bool) {
key := r.Header.Get("Authorization") key := r.Header.Get("Authorization")
key = strings.TrimPrefix(key, "Bearer ") key = strings.TrimPrefix(key, "Bearer ")
if key == "" { if key == "" {
@ -15,46 +46,43 @@ func (s *Server) requireAuth(w http.ResponseWriter, r *http.Request) (deviceID,
} }
if key == "" { if key == "" {
jsonErr(w, 401, "API key required") jsonErr(w, 401, "API key required")
return "", "", false return authenticatedDevice{}, false
} }
hash := sha256Hex(key)
var deviceIDVal, userIDVal, revokedAt sql.NullString var device authenticatedDevice
err := s.db.QueryRow("SELECT id, user_id, revoked_at FROM server_devices WHERE token_hash=?", hash).Scan(&deviceIDVal, &userIDVal, &revokedAt) var userID, vaultID, revokedAt sql.NullString
if err == nil { err := s.db.QueryRow(`SELECT id, user_id, vault_id, revoked_at
if revokedAt.Valid && revokedAt.String != "" { FROM server_devices WHERE token_hash=?`, sha256Hex(key)).
jsonErr(w, 401, "device revoked") Scan(&device.DeviceID, &userID, &vaultID, &revokedAt)
return "", "", false if err != nil {
} err = s.db.QueryRow(`SELECT id, user_id, vault_id, revoked_at
if userIDVal.Valid && userIDVal.String != "" { FROM server_devices WHERE api_key=?`, key).
var blocked int Scan(&device.DeviceID, &userID, &vaultID, &revokedAt)
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", userIDVal.String).Scan(&blocked)
if blocked != 0 {
jsonErr(w, 403, "user blocked")
return "", "", false
}
}
s.db.Exec("UPDATE server_devices SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), deviceIDVal.String)
return deviceIDVal.String, userIDVal.String, true
} }
err = s.db.QueryRow("SELECT id, user_id, revoked_at FROM server_devices WHERE api_key=?", key).Scan(&deviceIDVal, &userIDVal, &revokedAt)
if err != nil { if err != nil {
jsonErr(w, 401, "invalid API key") jsonErr(w, 401, "invalid API key")
return "", "", false return authenticatedDevice{}, false
} }
if revokedAt.Valid && revokedAt.String != "" { if revokedAt.Valid && revokedAt.String != "" {
jsonErr(w, 401, "device revoked") jsonErr(w, 401, "device revoked")
return "", "", false return authenticatedDevice{}, false
} }
if userIDVal.Valid && userIDVal.String != "" { if userID.Valid {
device.UserID = userID.String
}
if vaultID.Valid {
device.VaultID = vaultID.String
}
if device.UserID != "" {
var blocked int var blocked int
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", userIDVal.String).Scan(&blocked) s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", device.UserID).Scan(&blocked)
if blocked != 0 { if blocked != 0 {
jsonErr(w, 403, "user blocked") jsonErr(w, 403, "user blocked")
return "", "", false return authenticatedDevice{}, false
} }
} }
s.db.Exec("UPDATE server_devices SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), deviceIDVal.String) s.db.Exec("UPDATE server_devices SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), device.DeviceID)
return deviceIDVal.String, userIDVal.String, true return device, true
} }
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool { func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool {

View File

@ -1,5 +1,10 @@
package server package server
import (
"database/sql"
"fmt"
)
const serverSchema = ` const serverSchema = `
CREATE TABLE IF NOT EXISTS server_users ( CREATE TABLE IF NOT EXISTS server_users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@ -20,6 +25,7 @@ CREATE TABLE IF NOT EXISTS server_devices (
token_prefix TEXT, token_prefix TEXT,
token_suffix TEXT, token_suffix TEXT,
user_id TEXT, user_id TEXT,
vault_id TEXT,
client_version TEXT, client_version TEXT,
last_ip TEXT, last_ip TEXT,
last_seen TEXT, last_seen TEXT,
@ -36,6 +42,8 @@ CREATE TABLE IF NOT EXISTS server_user_devices (
CREATE TABLE IF NOT EXISTS server_ops ( CREATE TABLE IF NOT EXISTS server_ops (
op_id TEXT PRIMARY KEY, op_id TEXT PRIMARY KEY,
server_sequence INTEGER, server_sequence INTEGER,
user_id TEXT NOT NULL,
vault_id TEXT NOT NULL,
device_id TEXT NOT NULL, device_id TEXT NOT NULL,
entity_type TEXT NOT NULL, entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL, entity_id TEXT NOT NULL,
@ -49,17 +57,22 @@ CREATE TABLE IF NOT EXISTS server_ops (
); );
CREATE TABLE IF NOT EXISTS server_tombstones ( CREATE TABLE IF NOT EXISTS server_tombstones (
user_id TEXT NOT NULL,
vault_id TEXT NOT NULL,
entity_type TEXT NOT NULL, entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL, entity_id TEXT NOT NULL,
op_id TEXT NOT NULL, op_id TEXT NOT NULL,
deleted_at TEXT NOT NULL, deleted_at TEXT NOT NULL,
PRIMARY KEY (entity_type, entity_id) PRIMARY KEY (user_id, vault_id, entity_type, entity_id)
); );
CREATE TABLE IF NOT EXISTS server_idempotency_keys ( CREATE TABLE IF NOT EXISTS server_idempotency_keys (
idempotency_key TEXT PRIMARY KEY, user_id TEXT NOT NULL,
vault_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
response_json TEXT NOT NULL, response_json TEXT NOT NULL,
created_at TEXT NOT NULL created_at TEXT NOT NULL,
PRIMARY KEY (user_id, vault_id, idempotency_key)
); );
CREATE TABLE IF NOT EXISTS server_email_tokens ( CREATE TABLE IF NOT EXISTS server_email_tokens (
@ -107,3 +120,197 @@ CREATE TABLE IF NOT EXISTS server_smtp_config (
value TEXT NOT NULL value TEXT NOT NULL
); );
` `
type sqliteColumn struct {
primaryKeyOrder int
}
func migrateServerSchema(db *sql.DB) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, change := range []struct {
table string
column string
definition string
}{
{"server_devices", "user_id", "TEXT"},
{"server_devices", "vault_id", "TEXT"},
{"server_ops", "user_id", "TEXT"},
{"server_ops", "vault_id", "TEXT"},
} {
if err := ensureSQLiteColumn(tx, change.table, change.column, change.definition); err != nil {
return err
}
}
if err := backfillDeviceOwners(tx); err != nil {
return err
}
if err := backfillOperationScope(tx); err != nil {
return err
}
if err := migrateTombstoneScope(tx); err != nil {
return err
}
if err := migrateIdempotencyScope(tx); err != nil {
return err
}
if _, err := tx.Exec(`CREATE INDEX IF NOT EXISTS idx_server_ops_scope_sequence
ON server_ops(user_id, vault_id, server_sequence)`); err != nil {
return err
}
if _, err := tx.Exec(`CREATE INDEX IF NOT EXISTS idx_server_devices_scope
ON server_devices(user_id, vault_id)`); err != nil {
return err
}
return tx.Commit()
}
func ensureSQLiteColumn(tx *sql.Tx, table, column, definition string) error {
columns, err := sqliteTableColumns(tx, table)
if err != nil {
return err
}
if _, ok := columns[column]; ok {
return nil
}
if _, err := tx.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition)); err != nil {
return fmt.Errorf("add %s.%s: %w", table, column, err)
}
return nil
}
func sqliteTableColumns(tx *sql.Tx, table string) (map[string]sqliteColumn, error) {
rows, err := tx.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return nil, err
}
defer rows.Close()
columns := make(map[string]sqliteColumn)
for rows.Next() {
var cid, notNull, primaryKeyOrder int
var name, dataType string
var defaultValue sql.NullString
if err := rows.Scan(&cid, &name, &dataType, &notNull, &defaultValue, &primaryKeyOrder); err != nil {
return nil, err
}
columns[name] = sqliteColumn{primaryKeyOrder: primaryKeyOrder}
}
return columns, rows.Err()
}
func backfillDeviceOwners(tx *sql.Tx) error {
_, err := tx.Exec(`UPDATE server_devices
SET user_id = (
SELECT user_id FROM server_user_devices
WHERE device_id = server_devices.id
)
WHERE COALESCE(user_id, '') = ''
AND 1 = (
SELECT COUNT(*) FROM server_user_devices
WHERE device_id = server_devices.id
)`)
return err
}
func backfillOperationScope(tx *sql.Tx) error {
if _, err := tx.Exec(`UPDATE server_ops
SET user_id = (
SELECT user_id FROM server_devices
WHERE id = server_ops.device_id
)
WHERE COALESCE(user_id, '') = ''`); err != nil {
return err
}
_, err := tx.Exec(`UPDATE server_ops
SET vault_id = COALESCE(
NULLIF((SELECT vault_id FROM server_devices WHERE id = server_ops.device_id), ''),
'legacy:' || user_id
)
WHERE COALESCE(vault_id, '') = ''
AND COALESCE(user_id, '') != ''`)
return err
}
func migrateTombstoneScope(tx *sql.Tx) error {
if hasScopedPrimaryKey(tx, "server_tombstones", "user_id", "vault_id", "entity_type", "entity_id") {
return nil
}
if _, err := tx.Exec("ALTER TABLE server_tombstones RENAME TO server_tombstones_legacy"); err != nil {
return err
}
if _, err := tx.Exec(`CREATE TABLE server_tombstones (
user_id TEXT NOT NULL,
vault_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
op_id TEXT NOT NULL,
deleted_at TEXT NOT NULL,
PRIMARY KEY (user_id, vault_id, entity_type, entity_id)
)`); err != nil {
return err
}
if _, err := tx.Exec(`INSERT OR IGNORE INTO server_tombstones
(user_id, vault_id, entity_type, entity_id, op_id, deleted_at)
SELECT o.user_id, o.vault_id, legacy.entity_type, legacy.entity_id, legacy.op_id, legacy.deleted_at
FROM server_tombstones_legacy AS legacy
JOIN server_ops AS o ON o.op_id = legacy.op_id
WHERE COALESCE(o.user_id, '') != '' AND COALESCE(o.vault_id, '') != ''`); err != nil {
return err
}
_, err := tx.Exec("DROP TABLE server_tombstones_legacy")
return err
}
func migrateIdempotencyScope(tx *sql.Tx) error {
if hasScopedPrimaryKey(tx, "server_idempotency_keys", "user_id", "vault_id", "idempotency_key") {
return nil
}
if _, err := tx.Exec("ALTER TABLE server_idempotency_keys RENAME TO server_idempotency_keys_legacy"); err != nil {
return err
}
if _, err := tx.Exec(`CREATE TABLE server_idempotency_keys (
user_id TEXT NOT NULL,
vault_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
response_json TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, vault_id, idempotency_key)
)`); err != nil {
return err
}
// The previous cache was global. Discard it rather than risk replaying a
// response for another user or vault after the migration.
_, err := tx.Exec("DROP TABLE server_idempotency_keys_legacy")
return err
}
func hasScopedPrimaryKey(tx *sql.Tx, table string, want ...string) bool {
columns, err := sqliteTableColumns(tx, table)
if err != nil {
return false
}
for index, name := range want {
column, ok := columns[name]
if !ok || column.primaryKeyOrder != index+1 {
return false
}
}
return true
}
func effectiveVaultScope(userID, vaultID string) string {
if vaultID != "" {
return vaultID
}
if userID == "" {
return ""
}
return "legacy:" + userID
}

View File

@ -139,6 +139,10 @@ func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) {
return nil, fmt.Errorf("schema: %w", err) return nil, fmt.Errorf("schema: %w", err)
} }
} }
if err := migrateServerSchema(db); err != nil {
db.Close()
return nil, fmt.Errorf("migrate schema: %w", err)
}
blobsDir := filepath.Join(dataDir, "blobs") blobsDir := filepath.Join(dataDir, "blobs")
if err := os.MkdirAll(blobsDir, 0750); err != nil { if err := os.MkdirAll(blobsDir, 0750); err != nil {

View File

@ -2,6 +2,7 @@ package server
import ( import (
"bytes" "bytes"
"database/sql"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@ -9,6 +10,8 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
"golang.org/x/crypto/bcrypt"
) )
func TestNewServer(t *testing.T) { func TestNewServer(t *testing.T) {
@ -67,10 +70,11 @@ func TestSyncPushPullStoresSequencedOps(t *testing.T) {
defer s.Close() defer s.Close()
s.SetupRoutes() s.SetupRoutes()
insertSyncUser(t, s, "user-a")
now := time.Now().UTC().Format(time.RFC3339) now := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec( if _, err := s.db.Exec(
"INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)", "INSERT INTO server_devices (id, name, api_key, user_id, vault_id, last_seen, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
"device-a", "Device A", "api-key", now, now, "device-a", "Device A", "api-key", "user-a", "vault-a", now, now,
); err != nil { ); err != nil {
t.Fatalf("insert device: %v", err) t.Fatalf("insert device: %v", err)
} }
@ -167,6 +171,343 @@ func TestRevokedLegacyAPIKeyCannotPushOrPull(t *testing.T) {
} }
} }
func TestSyncPullDoesNotExposeOtherUserOperations(t *testing.T) {
s, ts := newSyncHTTPServer(t)
defer s.Close()
defer ts.Close()
insertSyncUser(t, s, "user-a")
insertSyncUser(t, s, "user-b")
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
insertSyncDevice(t, s, "device-b", "user-b", "token-b")
postJSON(t, ts.URL+"/api/v1/sync/push", "token-a", syncPushBody("device-a", "op-user-a", ""))
pull := postJSON(t, ts.URL+"/api/v1/sync/pull", "token-b", map[string]interface{}{
"since_sequence": 0,
})
if got := len(pull["ops"].([]interface{})); got != 0 {
t.Fatalf("other user pulled %d operation(s), want 0: %#v", got, pull)
}
if got := int(pull["server_sequence"].(float64)); got != 0 {
t.Fatalf("other user cursor = %d, want 0: %#v", got, pull)
}
}
func TestSyncPushUsesAuthenticatedDeviceIdentity(t *testing.T) {
s, ts := newSyncHTTPServer(t)
defer s.Close()
defer ts.Close()
insertSyncUser(t, s, "user-a")
insertSyncUser(t, s, "user-b")
insertSyncDevice(t, s, "device-authenticated", "user-a", "token-a")
insertSyncDevice(t, s, "device-forged", "user-b", "token-b")
postJSON(t, ts.URL+"/api/v1/sync/push", "token-a", syncPushBody("device-forged", "op-auth-device", ""))
var storedDeviceID string
if err := s.db.QueryRow("SELECT device_id FROM server_ops WHERE op_id=?", "op-auth-device").Scan(&storedDeviceID); err != nil {
t.Fatalf("read stored operation: %v", err)
}
if storedDeviceID != "device-authenticated" {
t.Fatalf("stored device = %q, want authenticated device", storedDeviceID)
}
}
func TestSyncPushScopesIdempotencyResponsesByUser(t *testing.T) {
s, ts := newSyncHTTPServer(t)
defer s.Close()
defer ts.Close()
insertSyncUser(t, s, "user-a")
insertSyncUser(t, s, "user-b")
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
insertSyncDevice(t, s, "device-b", "user-b", "token-b")
postJSON(t, ts.URL+"/api/v1/sync/push", "token-a", syncPushBody("device-a", "op-user-a", "same-key"))
response := postJSON(t, ts.URL+"/api/v1/sync/push", "token-b", syncPushBody("device-b", "op-user-b", "same-key"))
accepted := response["accepted"].([]interface{})
if len(accepted) != 1 || accepted[0] != "op-user-b" {
t.Fatalf("user-b received idempotency response %#v, want only op-user-b", response)
}
}
func TestSyncPushDoesNotReportOtherTenantConflicts(t *testing.T) {
s, ts := newSyncHTTPServer(t)
defer s.Close()
defer ts.Close()
insertSyncUser(t, s, "user-a")
insertSyncUser(t, s, "user-b")
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
insertSyncDevice(t, s, "device-b", "user-b", "token-b")
postJSON(t, ts.URL+"/api/v1/sync/push", "token-a", syncPushBody("device-a", "op-user-a-1", ""))
postJSON(t, ts.URL+"/api/v1/sync/push", "token-a", syncPushBody("device-a", "op-user-a-2", ""))
body := syncPushBody("device-b", "op-user-b", "")
body["ops"].([]map[string]interface{})[0]["last_seen_server_seq"] = 1
response := postJSON(t, ts.URL+"/api/v1/sync/push", "token-b", body)
if response["conflicts"] != nil {
t.Fatalf("other tenant conflict leaked into response: %#v", response)
}
}
func TestSyncPullDoesNotExposeOtherVaultOperations(t *testing.T) {
s, ts := newSyncHTTPServer(t)
defer s.Close()
defer ts.Close()
password := "correct horse battery staple"
insertPairableUser(t, s, "user-a", "alice", password)
deviceA := pairSyncDevice(t, ts.URL, "alice", password, "vault-a")
deviceB := pairSyncDevice(t, ts.URL, "alice", password, "vault-b")
postJSON(t, ts.URL+"/api/v1/sync/push", deviceA.token, syncPushBody(deviceA.id, "op-vault-a", ""))
postJSON(t, ts.URL+"/api/v1/sync/push", deviceB.token, syncPushBody(deviceB.id, "op-vault-b", ""))
pull := postJSON(t, ts.URL+"/api/v1/sync/pull", deviceA.token, map[string]interface{}{
"since_sequence": 0,
})
ops := pull["ops"].([]interface{})
if len(ops) != 1 || ops[0].(map[string]interface{})["op_id"] != "op-vault-a" {
t.Fatalf("vault-a pulled %#v, want only op-vault-a", pull)
}
}
func TestClientPairRequiresVaultID(t *testing.T) {
s, ts := newSyncHTTPServer(t)
defer s.Close()
defer ts.Close()
password := "correct horse battery staple"
insertPairableUser(t, s, "user-a", "alice", password)
status, response := postJSONStatus(t, ts.URL+"/api/client/pair", "", map[string]interface{}{
"login": "alice",
"password": password,
"device_name": "Desktop",
})
if status != http.StatusBadRequest || response["error"] != "vault_id required" {
t.Fatalf("pair without vault ID status=%d response=%#v, want 400 vault_id required", status, response)
}
}
func TestClientPairRejectsReservedLegacyVaultID(t *testing.T) {
s, ts := newSyncHTTPServer(t)
defer s.Close()
defer ts.Close()
password := "correct horse battery staple"
insertPairableUser(t, s, "user-a", "alice", password)
status, response := postJSONStatus(t, ts.URL+"/api/client/pair", "", map[string]interface{}{
"login": "alice",
"password": password,
"device_name": "Desktop",
"vault_id": "legacy:user-a",
})
if status != http.StatusBadRequest || response["error"] != "vault_id uses reserved prefix" {
t.Fatalf("pair with reserved vault ID status=%d response=%#v, want 400 reserved prefix", status, response)
}
}
func TestDeviceRegisterRequiresValidVaultID(t *testing.T) {
s, ts := newSyncHTTPServer(t)
defer s.Close()
defer ts.Close()
password := "correct horse battery staple"
insertPairableUser(t, s, "user-a", "alice", password)
status, response := postJSONStatus(t, ts.URL+"/api/v1/device/register", "", map[string]interface{}{
"name": "Desktop",
"username": "alice",
"password": password,
})
if status != http.StatusBadRequest || response["error"] != "vault_id required" {
t.Fatalf("register without vault ID status=%d response=%#v, want 400 vault_id required", status, response)
}
status, response = postJSONStatus(t, ts.URL+"/api/v1/device/register", "", map[string]interface{}{
"name": "Desktop",
"username": "alice",
"password": password,
"vault_id": "legacy:user-a",
})
if status != http.StatusBadRequest || response["error"] != "vault_id uses reserved prefix" {
t.Fatalf("register with reserved vault ID status=%d response=%#v, want 400 reserved prefix", status, response)
}
}
func TestNewServerMigratesLegacyOperationScope(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "legacy.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("open legacy db: %v", err)
}
legacySchema := []string{
`CREATE TABLE server_devices (
id TEXT PRIMARY KEY, name TEXT NOT NULL, api_key TEXT NOT NULL UNIQUE,
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
)`,
`CREATE TABLE server_user_devices (
user_id TEXT NOT NULL, device_id TEXT NOT NULL,
PRIMARY KEY (user_id, device_id)
)`,
`CREATE TABLE server_ops (
op_id TEXT PRIMARY KEY, server_sequence INTEGER, device_id TEXT NOT NULL,
entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, op_type TEXT NOT NULL,
payload_json TEXT NOT NULL, idempotency_key TEXT, client_sequence INTEGER DEFAULT 0,
last_seen_server_seq INTEGER DEFAULT 0, created_at TEXT NOT NULL,
pushed_at TEXT NOT NULL
)`,
`CREATE TABLE 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 server_idempotency_keys (
idempotency_key TEXT PRIMARY KEY, response_json TEXT NOT NULL, created_at TEXT NOT NULL
)`,
}
for _, stmt := range legacySchema {
if _, err := db.Exec(stmt); err != nil {
t.Fatalf("create legacy schema: %v", err)
}
}
now := time.Now().UTC().Format(time.RFC3339)
if _, err := db.Exec(`INSERT INTO server_devices (id, name, api_key, last_seen, created_at)
VALUES (?, ?, ?, ?, ?)`, "legacy-device", "Legacy", "legacy-key", now, now); err != nil {
t.Fatalf("insert legacy device: %v", err)
}
if _, err := db.Exec(`INSERT INTO server_user_devices (user_id, device_id) VALUES (?, ?)`, "legacy-user", "legacy-device"); err != nil {
t.Fatalf("insert legacy device owner: %v", err)
}
if _, err := db.Exec(`INSERT INTO server_ops
(op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, created_at, pushed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, "legacy-op", 1, "legacy-device", "file", "Docs/one.txt", "create", `{}`, now, now); err != nil {
t.Fatalf("insert legacy operation: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("close legacy db: %v", err)
}
s, err := NewServer(dbPath, filepath.Join(dir, "data"), &Config{Port: 47732})
if err != nil {
t.Fatalf("NewServer migration: %v", err)
}
assertLegacyOperationScope(t, s)
if err := s.Close(); err != nil {
t.Fatalf("close migrated db: %v", err)
}
s, err = NewServer(dbPath, filepath.Join(dir, "data"), &Config{Port: 47732})
if err != nil {
t.Fatalf("NewServer repeated migration: %v", err)
}
defer s.Close()
assertLegacyOperationScope(t, s)
}
func assertLegacyOperationScope(t *testing.T, s *Server) {
t.Helper()
var userID, vaultID string
if err := s.db.QueryRow("SELECT user_id, vault_id FROM server_ops WHERE op_id=?", "legacy-op").Scan(&userID, &vaultID); err != nil {
t.Fatalf("read migrated operation: %v", err)
}
if userID != "legacy-user" || vaultID != "legacy:legacy-user" {
t.Fatalf("migrated scope = %q/%q, want legacy-user/legacy:legacy-user", userID, vaultID)
}
}
func newSyncHTTPServer(t *testing.T) (*Server, *httptest.Server) {
t.Helper()
dir := t.TempDir()
s, err := NewServer(filepath.Join(dir, "test.db"), filepath.Join(dir, "data"), &Config{Port: 47732})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
s.SetupRoutes()
return s, httptest.NewServer(s.mux)
}
func insertSyncUser(t *testing.T, s *Server, userID string) {
t.Helper()
now := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(`INSERT INTO server_users
(id, username, email, password_hash, confirmed, created_at)
VALUES (?, ?, ?, ?, 1, ?)`, userID, userID, userID+"@example.test", "unused", now); err != nil {
t.Fatalf("insert sync user %s: %v", userID, err)
}
}
func insertPairableUser(t *testing.T, s *Server, userID, username, password string) {
t.Helper()
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
now := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(`INSERT INTO server_users
(id, username, email, password_hash, confirmed, created_at)
VALUES (?, ?, ?, ?, 1, ?)`, userID, username, username+"@example.test", string(passwordHash), now); err != nil {
t.Fatalf("insert pairable user: %v", err)
}
}
func insertSyncDevice(t *testing.T, s *Server, deviceID, userID, token string) {
t.Helper()
now := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(`INSERT INTO server_devices
(id, name, api_key, token_hash, user_id, last_seen, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`, deviceID, deviceID, "legacy-"+deviceID, sha256Hex(token), userID, now, now); err != nil {
t.Fatalf("insert sync device %s: %v", deviceID, err)
}
}
func syncPushBody(deviceID, opID, idempotencyKey string) map[string]interface{} {
return map[string]interface{}{
"device_id": deviceID,
"idempotency_key": idempotencyKey,
"ops": []map[string]interface{}{
{
"op_id": opID,
"entity_type": "file",
"entity_id": "Docs/one.txt",
"op_type": "create",
"payload_json": `{"path":"Docs/one.txt","content":"hello"}`,
"created_at": "2026-07-10T00:00:00Z",
},
},
}
}
type pairedSyncDevice struct {
id string
token string
}
func pairSyncDevice(t *testing.T, serverURL, username, password, vaultID string) pairedSyncDevice {
t.Helper()
response := postJSON(t, serverURL+"/api/client/pair", "", map[string]interface{}{
"login": username,
"password": password,
"device_name": "Desktop " + vaultID,
"vault_id": vaultID,
})
return pairedSyncDevice{
id: response["device_id"].(string),
token: response["device_token"].(string),
}
}
func postJSON(t *testing.T, url, token string, body interface{}) map[string]interface{} { func postJSON(t *testing.T, url, token string, body interface{}) map[string]interface{} {
t.Helper() t.Helper()
status, out := postJSONStatus(t, url, token, body) status, out := postJSONStatus(t, url, token, body)