diff --git a/js/services/settingsSyncFlags.js b/js/services/settingsSyncFlags.js index ad89a060..7e7b72e3 100644 --- a/js/services/settingsSyncFlags.js +++ b/js/services/settingsSyncFlags.js @@ -55,4 +55,28 @@ export function setSyncEnabled(pubkey, kind, enabled) { writeFlags(flags); } +// Remember the created_at of the note we last published, per pubkey+kind, so a +// later save can detect when the relay copy is NEWER (another device changed it) +// and warn before overwriting. +export function getSyncPushedAt(pubkey, kind) { + const key = normalizePubkey(pubkey); + if (!key || !kind) { + return 0; + } + const value = Number(readFlags()[key]?.[`${kind}PushedAt`]); + return Number.isFinite(value) ? value : 0; +} + +export function setSyncPushedAt(pubkey, kind, createdAt) { + const key = normalizePubkey(pubkey); + if (!key || !kind) { + return; + } + const flags = readFlags(); + const entry = flags[key] && typeof flags[key] === "object" ? flags[key] : {}; + entry[`${kind}PushedAt`] = Number(createdAt) || 0; + flags[key] = entry; + writeFlags(flags); +} + export const SETTINGS_SYNC_FLAG_KEY = FLAG_STORAGE_KEY; diff --git a/js/services/storageSyncService.js b/js/services/storageSyncService.js index 37716ea5..e1b1bff8 100644 --- a/js/services/storageSyncService.js +++ b/js/services/storageSyncService.js @@ -10,6 +10,7 @@ import { encryptedSync as defaultEncryptedSync } from "../nostr/encryptedSyncFacade.js"; import defaultStorageService from "./storageService.js"; import { isSyncEnabled, setSyncEnabled } from "./settingsSyncFlags.js"; +import { pushWithConflictCheck } from "./syncConflict.js"; import { userLogger } from "../utils/logger.js"; export const STORAGE_SYNC_DTAG = "bitvid:storage-connections"; @@ -46,7 +47,7 @@ export function createStorageSyncService({ } // Push the current local storage account record to the user's encrypted note. - async function push(pubkey) { + async function push(pubkey, { confirmOverwrite } = {}) { const key = normalizePubkey(pubkey); if (!key) { return { ok: false, error: "missing-pubkey" }; @@ -61,7 +62,14 @@ export function createStorageSyncService({ if (!record) { return { ok: false, error: "nothing-to-sync" }; } - return encryptedSync.push(STORAGE_SYNC_DTAG, record); + return pushWithConflictCheck({ + encryptedSync, + dTag: STORAGE_SYNC_DTAG, + kind: SYNC_KIND, + pubkey: key, + payload: record, + confirmOverwrite, + }); } // Pull the encrypted note and import it into local storage. Returns the @@ -85,9 +93,9 @@ export function createStorageSyncService({ } // Turn sync on: remember the choice and push immediately. - async function enable(pubkey) { + async function enable(pubkey, options = {}) { setEnabledFlag(pubkey, true); - return push(pubkey); + return push(pubkey, options); } // Turn sync off: remember the choice and wipe the published note. diff --git a/js/services/syncConflict.js b/js/services/syncConflict.js new file mode 100644 index 00000000..0c1efd7e --- /dev/null +++ b/js/services/syncConflict.js @@ -0,0 +1,50 @@ +// Conflict-aware push for encrypted settings sync (todo #15 follow-up). +// +// On save we normally push silently. But if the relay copy is NEWER than the one +// we last pushed, another device changed it — overwriting would lose that. So we +// peek the remote created_at (list-only, no decrypt), compare it to our last +// push, and only when the remote is newer do we ask the caller's confirmOverwrite +// callback. On confirm (or no conflict) we push, forcing created_at strictly +// newer than the remote so the replace wins, and record the new push timestamp. + +import { getSyncPushedAt, setSyncPushedAt } from "./settingsSyncFlags.js"; + +export async function pushWithConflictCheck({ + encryptedSync, + dTag, + kind, + pubkey, + payload, + confirmOverwrite, +} = {}) { + let remoteCreatedAt = 0; + if (encryptedSync && typeof encryptedSync.exists === "function") { + try { + const peek = await encryptedSync.exists(dTag); + remoteCreatedAt = Number(peek?.createdAt) || 0; + } catch (error) { + remoteCreatedAt = 0; + } + } + + const lastPushed = getSyncPushedAt(pubkey, kind); + if (remoteCreatedAt && remoteCreatedAt > lastPushed) { + const confirmed = + typeof confirmOverwrite === "function" + ? await confirmOverwrite({ remoteCreatedAt, lastPushed }) + : true; + if (!confirmed) { + return { ok: false, skipped: true, conflict: true, remoteCreatedAt }; + } + } + + const result = await encryptedSync.push(dTag, payload, { + afterCreatedAt: remoteCreatedAt, + }); + if (result?.ok && Number.isFinite(result.createdAt)) { + setSyncPushedAt(pubkey, kind, result.createdAt); + } + return result; +} + +export default pushWithConflictCheck; diff --git a/js/services/walletSyncService.js b/js/services/walletSyncService.js index 915789f5..583e76bc 100644 --- a/js/services/walletSyncService.js +++ b/js/services/walletSyncService.js @@ -12,6 +12,7 @@ import { encryptedSync as defaultEncryptedSync } from "../nostr/encryptedSyncFacade.js"; import { isSyncEnabled, setSyncEnabled } from "./settingsSyncFlags.js"; +import { pushWithConflictCheck } from "./syncConflict.js"; import { userLogger } from "../utils/logger.js"; export const WALLET_SYNC_DTAG = "bitvid:nwc"; @@ -58,7 +59,7 @@ export function createWalletSyncService({ return Boolean(result?.exists); } - async function push(pubkey) { + async function push(pubkey, { confirmOverwrite } = {}) { const key = normalizePubkey(pubkey); if (!key) { return { ok: false, error: "missing-pubkey" }; @@ -67,7 +68,14 @@ export function createWalletSyncService({ if (!payload.nwcUri) { return { ok: false, error: "nothing-to-sync" }; } - return encryptedSync.push(WALLET_SYNC_DTAG, payload); + return pushWithConflictCheck({ + encryptedSync, + dTag: WALLET_SYNC_DTAG, + kind: SYNC_KIND, + pubkey: key, + payload, + confirmOverwrite, + }); } async function pull(pubkey) { @@ -97,9 +105,9 @@ export function createWalletSyncService({ return { ...result, imported: true }; } - async function enable(pubkey) { + async function enable(pubkey, options = {}) { setEnabledFlag(pubkey, true); - return push(pubkey); + return push(pubkey, options); } async function disable(pubkey) { diff --git a/js/ui/profileModal/ProfileStorageController.js b/js/ui/profileModal/ProfileStorageController.js index 305f2ad4..679443d7 100644 --- a/js/ui/profileModal/ProfileStorageController.js +++ b/js/ui/profileModal/ProfileStorageController.js @@ -214,6 +214,16 @@ export class ProfileStorageController { this.updateStorageFormVisibility(); } + confirmSyncOverwrite() { + if (typeof window === "undefined" || typeof window.confirm !== "function") { + return true; + } + return window.confirm( + "A newer copy of your storage settings is on your account (changed on " + + "another device). Overwrite it with this one?" + ); + } + renderSyncSection(pubkey) { if (!(this.storageSyncSection instanceof HTMLElement)) { return; @@ -259,13 +269,20 @@ export class ProfileStorageController { try { if (enabled) { this.setSyncStatus("Encrypting and publishing…"); - const result = await storageSyncService.enable(pubkey); + const result = await storageSyncService.enable(pubkey, { + confirmOverwrite: () => this.confirmSyncOverwrite(), + }); if (result?.ok) { this.setSyncStatus( `Synced to ${result.accepted}/${result.total} relays.`, "success" ); this.mainController.showSuccess("Storage settings synced (encrypted)."); + } else if (result?.conflict) { + // User declined to overwrite a newer copy from another device. + this.setSyncStatus( + "Kept the newer copy on your account. Use Restore to pull it, or save again to overwrite.", + ); } else { // Roll the toggle back so it reflects reality. if (this.storageSyncToggle instanceof HTMLInputElement) { @@ -723,12 +740,19 @@ export class ProfileStorageController { this.setStorageFormStatus("Connection saved.", "success"); this.mainController.showSuccess("Storage connection saved."); - // Keep the encrypted synced copy current if the user opted in. + // Keep the encrypted synced copy current if the user opted in. Warn before + // overwriting a newer copy changed on another device. if (storageSyncService.isEnabled(pubkey)) { try { - const syncResult = await storageSyncService.push(pubkey); + const syncResult = await storageSyncService.push(pubkey, { + confirmOverwrite: () => this.confirmSyncOverwrite(), + }); if (syncResult?.ok) { this.setSyncStatus("Synced copy updated.", "success"); + } else if (syncResult?.conflict) { + this.setSyncStatus( + "Synced copy on your account is newer — not overwritten.", + ); } } catch (syncError) { devLogger.warn("[ProfileModal] Storage re-sync after save failed:", syncError); diff --git a/js/ui/profileModal/ProfileWalletController.js b/js/ui/profileModal/ProfileWalletController.js index db663aee..752e36ca 100644 --- a/js/ui/profileModal/ProfileWalletController.js +++ b/js/ui/profileModal/ProfileWalletController.js @@ -102,6 +102,16 @@ export class ProfileWalletController { return this._walletSyncService; } + confirmSyncOverwrite() { + if (typeof window === "undefined" || typeof window.confirm !== "function") { + return true; + } + return window.confirm( + "A newer copy of your wallet connection is on your account (changed on " + + "another device). Overwrite it with this one?" + ); + } + renderSyncSection(pubkey) { if (!(this.walletSyncSection instanceof HTMLElement)) { return; @@ -162,13 +172,21 @@ export class ProfileWalletController { return; } this.setSyncStatus("Encrypting and publishing…"); - const result = await sync.enable(pubkey); + const result = await sync.enable(pubkey, { + confirmOverwrite: () => this.confirmSyncOverwrite(), + }); if (result?.ok) { this.setSyncStatus( `Synced to ${result.accepted}/${result.total} relays.`, "success" ); this.mainController.showSuccess("Wallet connection synced (encrypted)."); + } else if (result?.conflict) { + // User declined to overwrite a newer copy from another device. Keep + // sync on (flag set) and the newer remote intact. + this.setSyncStatus( + "Kept the newer copy on your account. Use Restore to pull it, or save again to overwrite.", + ); } else { if (this.walletSyncToggle instanceof HTMLInputElement) { this.walletSyncToggle.checked = false; @@ -614,11 +632,19 @@ export class ProfileWalletController { finalVariant = "success"; this.mainController.showSuccess("Wallet settings saved."); context.reason = "saved"; - // Keep the encrypted synced copy current if the user opted in. + // Keep the encrypted synced copy current if the user opted in. Warn + // before overwriting a newer copy changed on another device. const sync = this.getWalletSyncService(); if (sync.isEnabled(normalizedActive)) { try { - await sync.push(normalizedActive); + const syncResult = await sync.push(normalizedActive, { + confirmOverwrite: () => this.confirmSyncOverwrite(), + }); + if (syncResult?.conflict) { + this.setSyncStatus( + "Synced copy on your account is newer — not overwritten.", + ); + } } catch (syncError) { devLogger.warn( "[ProfileModal] Wallet re-sync after save failed:", diff --git a/tests/storage-sync-service.test.mjs b/tests/storage-sync-service.test.mjs index 7301f45c..97d811c5 100644 --- a/tests/storage-sync-service.test.mjs +++ b/tests/storage-sync-service.test.mjs @@ -43,6 +43,9 @@ function makeSyncTwin({ available = true } = {}) { return { calls: { push: [], pull: 0, clear: 0 }, isAvailable: () => available, + async exists() { + return { exists: Boolean(note) && !note.cleared, createdAt: 0 }; + }, async push(dTag, payload) { this.calls.push.push({ dTag, payload }); note = { payload }; diff --git a/tests/sync-conflict.test.mjs b/tests/sync-conflict.test.mjs new file mode 100644 index 00000000..98ebeb5e --- /dev/null +++ b/tests/sync-conflict.test.mjs @@ -0,0 +1,115 @@ +// Conflict-aware push (todo #15 follow-up): a save pushes silently UNLESS the +// relay copy is newer than the one we last pushed (another device changed it) — +// then it must ask confirmOverwrite before clobbering it. On confirm it forces a +// strictly-newer created_at so the replace wins; on decline it does not push. + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { pushWithConflictCheck } from "../js/services/syncConflict.js"; +import { getSyncPushedAt } from "../js/services/settingsSyncFlags.js"; + +const PUBKEY = "a".repeat(64); +const DTAG = "bitvid:nwc"; +const KIND = "wallet"; + +function makeEncryptedSync(remoteCreatedAt) { + return { + pushes: [], + async exists() { + return remoteCreatedAt + ? { exists: true, createdAt: remoteCreatedAt } + : { exists: false }; + }, + async push(dTag, payload, options) { + const createdAt = Math.max(2_000_000_000, (options?.afterCreatedAt || 0) + 1); + this.pushes.push({ dTag, payload, options, createdAt }); + return { ok: true, accepted: 1, total: 1, createdAt }; + }, + }; +} + +test("no remote copy → pushes silently, records the push timestamp", async () => { + localStorage.clear(); + const es = makeEncryptedSync(0); + let confirmCalls = 0; + const result = await pushWithConflictCheck({ + encryptedSync: es, + dTag: DTAG, + kind: KIND, + pubkey: PUBKEY, + payload: { nwcUri: "x" }, + confirmOverwrite: () => { + confirmCalls += 1; + return false; + }, + }); + assert.equal(result.ok, true); + assert.equal(confirmCalls, 0, "no conflict → never prompt"); + assert.equal(es.pushes.length, 1); + assert.equal(getSyncPushedAt(PUBKEY, KIND), es.pushes[0].createdAt, "records push time"); +}); + +test("remote NEWER than our last push → prompts; decline does NOT push", async () => { + localStorage.clear(); + const es = makeEncryptedSync(9_999_999_999); // far-future remote = newer + let prompted = 0; + const result = await pushWithConflictCheck({ + encryptedSync: es, + dTag: DTAG, + kind: KIND, + pubkey: PUBKEY, + payload: { nwcUri: "x" }, + confirmOverwrite: () => { + prompted += 1; + return false; // user declines + }, + }); + assert.equal(prompted, 1, "newer remote must prompt"); + assert.equal(result.ok, false); + assert.equal(result.conflict, true); + assert.equal(es.pushes.length, 0, "declining must NOT overwrite the newer remote"); +}); + +test("remote NEWER but user confirms → pushes strictly-newer than remote", async () => { + localStorage.clear(); + const remote = 9_999_999_999; + const es = makeEncryptedSync(remote); + const result = await pushWithConflictCheck({ + encryptedSync: es, + dTag: DTAG, + kind: KIND, + pubkey: PUBKEY, + payload: { nwcUri: "x" }, + confirmOverwrite: () => true, + }); + assert.equal(result.ok, true); + assert.equal(es.pushes.length, 1); + assert.ok( + es.pushes[0].createdAt > remote, + "the overwrite must be strictly newer than the remote so the replace wins", + ); +}); + +test("remote OLDER than our last push → no prompt (we already own the latest)", async () => { + localStorage.clear(); + // First push establishes our last-pushed timestamp. + const es = makeEncryptedSync(0); + await pushWithConflictCheck({ + encryptedSync: es, dTag: DTAG, kind: KIND, pubkey: PUBKEY, payload: { nwcUri: "x" }, + }); + const ours = getSyncPushedAt(PUBKEY, KIND); + + // Now remote reports an OLDER created_at than what we last pushed. + const es2 = makeEncryptedSync(ours - 100); + es2.pushes = []; + let prompted = 0; + const result = await pushWithConflictCheck({ + encryptedSync: es2, dTag: DTAG, kind: KIND, pubkey: PUBKEY, + payload: { nwcUri: "y" }, + confirmOverwrite: () => { prompted += 1; return false; }, + }); + assert.equal(prompted, 0, "remote not newer than our last push → no prompt"); + assert.equal(result.ok, true); + assert.equal(es2.pushes.length, 1); +}); diff --git a/tests/wallet-sync-service.test.mjs b/tests/wallet-sync-service.test.mjs index 59c01872..dc6ec7b4 100644 --- a/tests/wallet-sync-service.test.mjs +++ b/tests/wallet-sync-service.test.mjs @@ -37,6 +37,9 @@ function makeSyncTwin() { return { calls: { push: [], pull: 0, clear: 0 }, isAvailable: () => true, + async exists() { + return { exists: Boolean(note) && !note.cleared, createdAt: 0 }; + }, async push(dTag, payload) { this.calls.push.push({ dTag, payload }); note = { payload };