Files
2026-05-27 23:29:53 +01:00

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 VaultItem objects with the same id have different password values and different modifiedAt timestamps
  • THEN mergeVaultItems with strategy "lww" returns the password from the item with the later modifiedAt

Scenario: Append-only merges history without losing entries

  • WHEN two VaultItem objects have divergent history arrays
  • THEN mergeVaultItems with strategy "append" returns a union of both histories sorted by timestamp, with deduplication by historyId

Scenario: Atomic strategy replaces the entire object on conflict

  • WHEN two VaultItem objects have conflicting folderId values
  • THEN mergeVaultItems with strategy "atomic" returns the remote copy in its entirety, discarding local changes to that field

Scenario: Merge is commutative

  • WHEN mergeVaultItems(local, remote, strategy) and mergeVaultItems(remote, local, strategy) are both called
  • THEN both calls produce deeply equal results

Scenario: Unknown strategy is rejected

  • WHEN mergeVaultItems is called with an unrecognized strategy name
  • THEN a VaultSyncError with code VAULTSYNC.MERGE.UNKNOWN_STRATEGY is 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 diffVaultItems is called with two items that differ only in title
  • THEN the result contains a single replace operation at path /title

Scenario: Diff is empty for identical items

  • WHEN diffVaultItems is called with two deeply equal items
  • THEN the result is an empty array

Scenario: Apply patch transforms item correctly

  • WHEN applyPatch is called with a valid patch document
  • THEN the resulting item reflects all operations in order

Scenario: Invalid patch operation is rejected

  • WHEN applyPatch is called with a replace operation targeting a non-existent path
  • THEN a VaultSyncError with code VAULTSYNC.PATCH.INVALID is 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 pull is 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.merged lists both

Scenario: Push returns only changed items since last sync

  • WHEN push is called after local modifications to two items
  • THEN the returned encrypted blob contains only those two items and SyncResult.merged lists them

Scenario: Bidirectional sync reconciles both directions

  • WHEN sync is 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 SyncResult reports both merged and conflict-resolved items

Scenario: Deleted items are propagated

  • WHEN a item is marked deleted locally and push is called
  • THEN the sync payload includes a tombstone AND SyncResult.deleted lists the item id

Scenario: Injectable clock and RNG are accepted

  • WHEN SyncEngine is constructed with clock and rng options
  • 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 SyncQueue AND no network calls are attempted

Scenario: Flush replays queued operations on reconnect

  • WHEN flush is 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 password field
  • THEN flush collapses them into a single update containing only the final password value

Scenario: Queue survives serialization round-trip

  • WHEN the SyncQueue is 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 SyncQueue is constructed with clock and rng options
  • 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 SyncPayload is 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 decryptSyncPayload is called with a different key than was used for encryption
  • THEN a VaultSyncError with code VAULTSYNC.CRYPTO.DECRYPT_FAILED is thrown

Scenario: Tampered ciphertext is detected

  • WHEN a single byte of the ciphertext is flipped before decryption
  • THEN a VaultSyncError with code VAULTSYNC.CRYPTO.DECRYPT_FAILED is thrown

Scenario: Injectable RNG determines nonce

  • WHEN encryptSyncPayload is called with a test RNG in options.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.