mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
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]>
122 lines
3.8 KiB
JavaScript
122 lines
3.8 KiB
JavaScript
// Opt-in encrypted cross-login sync for STORAGE credentials (todo #15, step 2).
|
|
// Wraps the encrypted-sync core: the on-disk storage account record (already
|
|
// self-protected, but with plaintext meta) is re-encrypted to self and published
|
|
// as a NIP-78 replaceable note under a stable d-tag, then pulled + imported on
|
|
// another device.
|
|
//
|
|
// Opt-in only, off by default. A per-pubkey local flag remembers the choice so
|
|
// the UI toggle persists and a future "re-push on change" knows whether to sync.
|
|
|
|
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";
|
|
const SYNC_KIND = "storage";
|
|
|
|
export function createStorageSyncService({
|
|
encryptedSync = defaultEncryptedSync,
|
|
storage = defaultStorageService,
|
|
} = {}) {
|
|
function normalizePubkey(pubkey) {
|
|
return typeof pubkey === "string" ? pubkey.trim() : "";
|
|
}
|
|
|
|
function isAvailable() {
|
|
return Boolean(encryptedSync?.isAvailable?.());
|
|
}
|
|
|
|
function isEnabled(pubkey) {
|
|
return isSyncEnabled(normalizePubkey(pubkey), SYNC_KIND);
|
|
}
|
|
|
|
function setEnabledFlag(pubkey, enabled) {
|
|
setSyncEnabled(normalizePubkey(pubkey), SYNC_KIND, enabled);
|
|
}
|
|
|
|
// Cheap check (no decrypt) for whether a synced note exists — for the
|
|
// login-restore prompt.
|
|
async function hasRemote() {
|
|
if (typeof encryptedSync.exists !== "function") {
|
|
return false;
|
|
}
|
|
const result = await encryptedSync.exists(STORAGE_SYNC_DTAG);
|
|
return Boolean(result?.exists);
|
|
}
|
|
|
|
// Push the current local storage account record to the user's encrypted note.
|
|
async function push(pubkey, { confirmOverwrite } = {}) {
|
|
const key = normalizePubkey(pubkey);
|
|
if (!key) {
|
|
return { ok: false, error: "missing-pubkey" };
|
|
}
|
|
let record;
|
|
try {
|
|
record = await storage.exportAccountRecord(key);
|
|
} catch (error) {
|
|
userLogger.warn("[storageSync] Failed to export storage record:", error);
|
|
return { ok: false, error: "export-failed", cause: error };
|
|
}
|
|
if (!record) {
|
|
return { ok: false, error: "nothing-to-sync" };
|
|
}
|
|
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
|
|
// encrypted-sync pull result with `imported: true` when a record was applied.
|
|
async function pull(pubkey) {
|
|
const key = normalizePubkey(pubkey);
|
|
if (!key) {
|
|
return { found: false, error: "missing-pubkey" };
|
|
}
|
|
const result = await encryptedSync.pull(STORAGE_SYNC_DTAG);
|
|
if (!result?.found) {
|
|
return result;
|
|
}
|
|
try {
|
|
await storage.importAccountRecord(key, result.payload);
|
|
} catch (error) {
|
|
userLogger.warn("[storageSync] Failed to import storage record:", error);
|
|
return { ...result, imported: false, error: "import-failed", cause: error };
|
|
}
|
|
return { ...result, imported: true };
|
|
}
|
|
|
|
// Turn sync on: remember the choice and push immediately.
|
|
async function enable(pubkey, options = {}) {
|
|
setEnabledFlag(pubkey, true);
|
|
return push(pubkey, options);
|
|
}
|
|
|
|
// Turn sync off: remember the choice and wipe the published note.
|
|
async function disable(pubkey) {
|
|
setEnabledFlag(pubkey, false);
|
|
return encryptedSync.clear(STORAGE_SYNC_DTAG);
|
|
}
|
|
|
|
return {
|
|
STORAGE_SYNC_DTAG,
|
|
isAvailable,
|
|
isEnabled,
|
|
hasRemote,
|
|
push,
|
|
pull,
|
|
enable,
|
|
disable,
|
|
};
|
|
}
|
|
|
|
export const storageSyncService = createStorageSyncService();
|
|
|
|
export default storageSyncService;
|