feat(sync): warn before overwriting a newer remote copy (conflict guard)

Saves push silently as before, but if the relay copy is NEWER than the one this
device last pushed (another device changed it), the user is now asked to confirm
before overwriting — preventing a silent multi-device clobber. No nag on normal
saves.

- js/services/settingsSyncFlags.js: track per-pubkey+kind last-pushed created_at
  (getSyncPushedAt/setSyncPushedAt).
- js/services/syncConflict.js: pushWithConflictCheck — peek remote created_at
  (list-only, no decrypt), compare to last push, call confirmOverwrite only on a
  real conflict, then push strictly-newer than remote so the replace wins.
- storage/walletSyncService push()/enable() route through it and accept a
  confirmOverwrite callback.
- ProfileStorage/WalletController supply confirmOverwrite (window.confirm) and
  surface a "kept the newer copy" status when the user declines.

tests/sync-conflict.test.mjs: no-remote→silent, newer→prompt(+decline blocks
push), confirm→strictly-newer push, older→no prompt. Mutation-verified
(ignoring the decline fails). Service-test twins gained exists().

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-21 21:21:54 -04:00
1 parent c829a42a45
commit dec5cf8fe1
9 files changed
+275 -14

No files matched your search

+24
View File
@@ -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;
+12 -4
View File
@@ -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.
+50
View File
@@ -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;
+12 -4
View File
@@ -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) {
+27 -3
View File
@@ -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);
+29 -3
View File
@@ -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:",
+3
View File
@@ -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 };
+115
View File
@@ -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);
});
+3
View File
@@ -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 };