12 KiB
Spec: @suaveplan/vault-sync
Capability
Domain-specific sync and conflict-resolution engine for the Vaulted password-manager ecosystem. Builds on @suaveplan/crdt and @suaveplan/diff (JSON Patch) to provide offline-first, end-to-end-encrypted vault synchronisation. Implements vault-item merge strategies: last-write-wins for passwords and metadata, append-only for audit history and TOTP counter increments, and atomic move/rename for folder and item relocations.
Requirements
Requirement: CRDT-based document merge for vault items
The package SHALL export mergeVaultItems(local: VaultItem, remote: VaultItem, strategy: MergeStrategy): VaultItem which applies a CRDT merge to reconcile divergent copies of the same vault item. The merge SHALL be commutative, associative, and idempotent so that peers applying the same pair of items in any order converge to the same result. MergeStrategy MUST support "lww" (last-write-wins), "append" (append-only set), and "atomic" (atomic replace). Unrecognized strategies MUST throw VaultSyncError with code VAULTSYNC.MERGE.UNKNOWN_STRATEGY.
Scenario: Last-write-wins selects the later timestamp for password fields
- WHEN two
VaultItemobjects with the sameidhave differentpasswordvalues and differentmodifiedAttimestamps - THEN
mergeVaultItemswith strategy"lww"returns the password from the item with the latermodifiedAt
Scenario: Append-only merges history without losing entries
- WHEN two
VaultItemobjects have divergenthistoryarrays - THEN
mergeVaultItemswith strategy"append"returns a union of both histories sorted bytimestamp, with deduplication byhistoryId
Scenario: Atomic strategy replaces the entire object on conflict
- WHEN two
VaultItemobjects have conflictingfolderIdvalues - THEN
mergeVaultItemswith strategy"atomic"returns the remote copy in its entirety, discarding local changes to that field
Scenario: Merge is commutative
- WHEN
mergeVaultItems(local, remote, strategy)andmergeVaultItems(remote, local, strategy)are both called - THEN both calls produce deeply equal results
Scenario: Unknown strategy is rejected
- WHEN
mergeVaultItemsis called with an unrecognized strategy name - THEN a
VaultSyncErrorwith codeVAULTSYNC.MERGE.UNKNOWN_STRATEGYis thrown
Verified by: src/merge/merge.test.ts::"lww selects later timestamp for password" AND src/merge/merge.test.ts::"append merges history without duplication" AND src/merge/merge.test.ts::"append sorts merged history by timestamp" AND src/merge/merge.test.ts::"atomic replaces entire object on conflict" AND src/merge/merge.test.ts::"merge is commutative" AND src/merge/merge.test.ts::"merge is idempotent" AND src/merge/merge.test.ts::"unknown strategy throws"
Requirement: JSON Patch diff and apply for incremental sync
The package SHALL export diffVaultItems(before: VaultItem, after: VaultItem): JsonPatchOperation[] producing a JSON Patch (RFC 6902) document representing the minimal set of changes needed to transform before into after. applyPatch(item: VaultItem, patch: JsonPatchOperation[]): VaultItem SHALL apply the patch immutably and return the modified item. Patch application MUST validate that array indices are in bounds and that replace/remove operations target existing paths; invalid patches MUST throw VaultSyncError with code VAULTSYNC.PATCH.INVALID.
Scenario: Diff produces minimal patch for field change
- WHEN
diffVaultItemsis called with two items that differ only intitle - THEN the result contains a single
replaceoperation at path/title
Scenario: Diff is empty for identical items
- WHEN
diffVaultItemsis called with two deeply equal items - THEN the result is an empty array
Scenario: Apply patch transforms item correctly
- WHEN
applyPatchis called with a valid patch document - THEN the resulting item reflects all operations in order
Scenario: Invalid patch operation is rejected
- WHEN
applyPatchis called with areplaceoperation targeting a non-existent path - THEN a
VaultSyncErrorwith codeVAULTSYNC.PATCH.INVALIDis thrown
Verified by: src/patch/patch.test.ts::"diff produces single replace for title change" AND src/patch/patch.test.ts::"diff is empty for identical items" AND src/patch/patch.test.ts::"apply patch transforms item correctly" AND src/patch/patch.test.ts::"rejects replace on missing path" AND src/patch/patch.test.ts::"rejects remove on missing path" AND src/patch/patch.test.ts::"rejects out-of-bounds array index" AND src/patch/patch.test.ts::"round-trip diff then apply yields original"
Requirement: Sync protocol — pull, push, and bidirectional reconciliation
The package SHALL export SyncEngine with an optional constructor options bag { clock?: Clock; rng?: Rng } and methods pull(remoteSnapshot: VaultSnapshot): Promise<SyncResult>, push(): Promise<SyncResult>, and sync(remoteSnapshot: VaultSnapshot): Promise<SyncResult>. pull SHALL fetch the remote snapshot, compute a diff against the local state, apply remote wins for "lww" fields and "append" for history, and return a SyncResult listing merged, conflicts, and deleted. push SHALL serialize local changes since the last successful sync into an encrypted blob and return it. sync SHALL perform a bidirectional reconciliation: push local changes first, then pull remote changes, resolving any conflicts with the configured strategy. All network transport is delegated to the caller; SyncEngine operates on in-memory snapshots.
Scenario: Pull applies remote changes to local state
- WHEN
pullis called with a remote snapshot containing one new item and one updated item - THEN the local state reflects the new item AND the updated fields, and
SyncResult.mergedlists both
Scenario: Push returns only changed items since last sync
- WHEN
pushis called after local modifications to two items - THEN the returned encrypted blob contains only those two items and
SyncResult.mergedlists them
Scenario: Bidirectional sync reconciles both directions
- WHEN
syncis called with a remote snapshot that has diverged from local state in both directions - THEN the resulting local state contains the union with conflicts resolved by strategy AND
SyncResultreports both merged and conflict-resolved items
Scenario: Deleted items are propagated
- WHEN a item is marked deleted locally and
pushis called - THEN the sync payload includes a tombstone AND
SyncResult.deletedlists the item id
Scenario: Injectable clock and RNG are accepted
- WHEN
SyncEngineis constructed withclockandrngoptions - THEN construction succeeds and the options are used for timestamp generation and nonce generation respectively
Verified by: src/engine/engine.test.ts::"pull applies remote changes" AND src/engine/engine.test.ts::"push returns only local changes since last sync" AND src/engine/engine.test.ts::"bidirectional sync reconciles divergent states" AND src/engine/engine.test.ts::"propagates deletions as tombstones" AND src/engine/engine.test.ts::"detects concurrent edits as conflicts" AND src/engine/engine.test.ts::"resolves conflicts with configured strategy" AND src/engine/engine.test.ts::"empty pull returns empty result" AND src/engine/engine.test.ts::"empty push returns empty result" AND src/engine/engine.test.ts::"accepts injectable clock and rng"
Requirement: Offline-first semantics and sync-queue durability
The package SHALL maintain a durable SyncQueue that accepts optional constructor options { clock?: Clock; rng?: Rng } and records every local mutation (create, update, delete) as an operation with a monotonic vector clock. When offline, operations SHALL accumulate in the queue without blocking callers. On reconnection, SyncEngine.flush() SHALL replay the queue in order, collapsing redundant operations (e.g. two updates to the same field become the latest update only). The queue MUST be serializable to and restorable from Uint8Array so that @suaveplan/db-sqlite can persist it. Vector clocks MUST use peer ids and Lamport timestamps to establish partial ordering.
Scenario: Mutations queue while offline
- WHEN several item updates occur while the sync engine is offline
- THEN all updates are appended to the
SyncQueueAND no network calls are attempted
Scenario: Flush replays queued operations on reconnect
- WHEN
flushis called after a period of offline mutations - THEN the queue is drained in order AND each operation is included in the next push payload
Scenario: Redundant updates are collapsed
- WHEN the queue contains three updates to the same item's
passwordfield - THEN
flushcollapses them into a single update containing only the final password value
Scenario: Queue survives serialization round-trip
- WHEN the
SyncQueueis serialized to bytes and then restored - THEN the restored queue contains the same operations in the same order with identical vector clocks
Scenario: Injectable clock and RNG are accepted
- WHEN
SyncQueueis constructed withclockandrngoptions - THEN construction succeeds and the options are used for vector-clock timestamps and nonce generation
Verified by: src/queue/queue.test.ts::"mutations queue while offline" AND src/queue/queue.test.ts::"flush replays queued operations" AND src/queue/queue.test.ts::"collapses redundant updates to same field" AND src/queue/queue.test.ts::"serializes and restores queue" AND src/queue/queue.test.ts::"vector clock establishes partial order" AND src/queue/queue.test.ts::"concurrent operations from different peers are ordered by clock" AND src/queue/queue.test.ts::"queue honors max size limit" AND src/queue/queue.test.ts::"accepts injectable clock and rng"
Requirement: End-to-end encryption of sync payloads
The package SHALL export encryptSyncPayload(payload: SyncPayload, key: Uint8Array, options?: { rng?: Rng }): EncryptedSyncPayload and decryptSyncPayload(encrypted: EncryptedSyncPayload, key: Uint8Array): SyncPayload. Encryption SHALL use XChaCha20-Poly1305 (via @suaveplan/crypto-cipher) with a random 192-bit nonce drawn from options.rng when provided. The EncryptedSyncPayload MUST include the nonce, ciphertext, and tag. Decryption failure (bad key, tampered tag) MUST throw VaultSyncError with code VAULTSYNC.CRYPTO.DECRYPT_FAILED. The sync engine MUST never persist plaintext payloads.
Scenario: Encrypt then decrypt round-trips
- WHEN a
SyncPayloadis encrypted with a 32-byte key and then decrypted with the same key - THEN the decrypted payload deeply equals the original
Scenario: Different key fails decryption
- WHEN
decryptSyncPayloadis called with a different key than was used for encryption - THEN a
VaultSyncErrorwith codeVAULTSYNC.CRYPTO.DECRYPT_FAILEDis thrown
Scenario: Tampered ciphertext is detected
- WHEN a single byte of the ciphertext is flipped before decryption
- THEN a
VaultSyncErrorwith codeVAULTSYNC.CRYPTO.DECRYPT_FAILEDis thrown
Scenario: Injectable RNG determines nonce
- WHEN
encryptSyncPayloadis called with a test RNG inoptions.rng - THEN the nonce is deterministic for that RNG
Verified by: src/crypto-sync/crypto-sync.test.ts::"encrypt decrypt round-trips" AND src/crypto-sync/crypto-sync.test.ts::"wrong key fails decryption" AND src/crypto-sync/crypto-sync.test.ts::"tampered ciphertext is rejected" AND src/crypto-sync/crypto-sync.test.ts::"nonce is unique per encryption" AND src/crypto-sync/crypto-sync.test.ts::"payload serialization is stable" AND src/crypto-sync/crypto-sync.test.ts::"injectable rng determines nonce"
Error codes
VAULTSYNC.MERGE.UNKNOWN_STRATEGY, VAULTSYNC.PATCH.INVALID, VAULTSYNC.SYNC.NO_SNAPSHOT, VAULTSYNC.QUEUE.OVERFLOW, VAULTSYNC.CRYPTO.DECRYPT_FAILED, VAULTSYNC.CRYPTO.MISSING_KEY
Version
Published: 0.1.0 (proposed). Spec delta: vault-sync.