fix: isolate sync operations by user and vault

This commit is contained in:
2026-07-10 03:13:17 +08:00
parent 5c8644d5e1
commit 086bc4ea4a
8 changed files with 712 additions and 83 deletions
@@ -18,16 +18,16 @@ migrated into a deterministic legacy scope.
**Files:**
- 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.
- [ ] 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.
- [ ] 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.
- [ ] 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.
- [ ] Add a failing test for identical idempotency keys in different scopes.
- [ ] Run: `go test ./internal/server -run 'TestSync.*Isolation|TestSyncPush'`
- [x] Add a failing test for identical idempotency keys in different scopes.
- [x] Run: `go test ./internal/server -run 'TestSync.*Isolation|TestSyncPush'`
and confirm the new assertions fail for the intended missing behaviour.
## Task 2: Add idempotent SQLite scope migration
@@ -37,15 +37,15 @@ migrated into a deterministic legacy scope.
- Modify: `internal/server/server.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.
- [ ] 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
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
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
@@ -54,15 +54,15 @@ migrated into a deterministic legacy scope.
- Modify: `internal/server/handlers_api.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.
- [ ] 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.
- [ ] 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.
- [ ] Make pull filter operations and its reported cursor by authenticated
- [x] Make pull filter operations and its reported cursor by authenticated
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`.
## 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_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.
- [ ] 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.
- [ ] 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.
- [ ] 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
@@ -87,9 +91,9 @@ migrated into a deterministic legacy scope.
- Modify: `README.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.
- [ ] Run `gofmt` on all changed Go files.
- [ ] Run `go test ./...` in both `verstak-sync-server` and
- [x] Document that pairing is vault-bound and that sync cursors are scoped.
- [x] Run `gofmt` on all changed Go files.
- [x] Run `go test ./...` in both `verstak-sync-server` and
`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.
@@ -23,9 +23,10 @@ in this change.
## Data model
`server_devices` gains a nullable `vault_id`. A device created through
`/api/client/pair` must have a non-empty vault ID. The authenticated device
therefore identifies one user and one vault.
`server_devices` gains a nullable `vault_id`. A device created through either
enrollment endpoint must have a non-empty vault ID that does not use the
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
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
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
`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.
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
not readable through sync APIs. The server must not guess an owner from a
request body.
@@ -75,5 +86,7 @@ Focused server tests must prove:
4. scoped idempotency does not replay another tenant's response;
5. a legacy SQLite database is upgraded with its existing operation retained
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.