mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +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]>
131 lines
3.9 KiB
JavaScript
131 lines
3.9 KiB
JavaScript
// Opt-in encrypted cross-login sync for the NWC WALLET connection (todo #15).
|
|
// Mirrors storageSyncService but for the wallet settings managed by
|
|
// NwcSettingsService.
|
|
//
|
|
// HIGHEST SENSITIVITY: the NWC URI is a bearer SPENDING secret. Anyone who can
|
|
// decrypt this note (i.e. anyone who controls the user's Nostr key) can spend
|
|
// from the connected wallet. The UI MUST gate enabling this behind an explicit
|
|
// confirmation. Off by default; never log the decrypted URI.
|
|
//
|
|
// NwcSettingsService is a per-app instance (not a singleton), so this is a
|
|
// factory — the caller injects the live service.
|
|
|
|
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";
|
|
const SYNC_KIND = "wallet";
|
|
|
|
export function createWalletSyncService({
|
|
encryptedSync = defaultEncryptedSync,
|
|
nwcSettings,
|
|
} = {}) {
|
|
function normalizePubkey(pubkey) {
|
|
return typeof pubkey === "string" ? pubkey.trim() : "";
|
|
}
|
|
|
|
function isAvailable() {
|
|
return Boolean(encryptedSync?.isAvailable?.()) && Boolean(nwcSettings);
|
|
}
|
|
|
|
function isEnabled(pubkey) {
|
|
return isSyncEnabled(normalizePubkey(pubkey), SYNC_KIND);
|
|
}
|
|
|
|
function setEnabledFlag(pubkey, enabled) {
|
|
setSyncEnabled(normalizePubkey(pubkey), SYNC_KIND, enabled);
|
|
}
|
|
|
|
// Build the minimal syncable payload from current wallet settings.
|
|
function buildPayload() {
|
|
const settings = nwcSettings?.getActiveNwcSettings?.() || {};
|
|
const nwcUri = typeof settings.nwcUri === "string" ? settings.nwcUri.trim() : "";
|
|
const payload = { nwcUri };
|
|
if (settings.defaultZap !== undefined) {
|
|
payload.defaultZap = settings.defaultZap;
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
// 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(WALLET_SYNC_DTAG);
|
|
return Boolean(result?.exists);
|
|
}
|
|
|
|
async function push(pubkey, { confirmOverwrite } = {}) {
|
|
const key = normalizePubkey(pubkey);
|
|
if (!key) {
|
|
return { ok: false, error: "missing-pubkey" };
|
|
}
|
|
const payload = buildPayload();
|
|
if (!payload.nwcUri) {
|
|
return { ok: false, error: "nothing-to-sync" };
|
|
}
|
|
return pushWithConflictCheck({
|
|
encryptedSync,
|
|
dTag: WALLET_SYNC_DTAG,
|
|
kind: SYNC_KIND,
|
|
pubkey: key,
|
|
payload,
|
|
confirmOverwrite,
|
|
});
|
|
}
|
|
|
|
async function pull(pubkey) {
|
|
const key = normalizePubkey(pubkey);
|
|
if (!key) {
|
|
return { found: false, error: "missing-pubkey" };
|
|
}
|
|
const result = await encryptedSync.pull(WALLET_SYNC_DTAG);
|
|
if (!result?.found) {
|
|
return result;
|
|
}
|
|
const payload = result.payload || {};
|
|
const nwcUri = typeof payload.nwcUri === "string" ? payload.nwcUri.trim() : "";
|
|
if (!nwcUri) {
|
|
return { ...result, imported: false, error: "empty-payload" };
|
|
}
|
|
try {
|
|
const partial = { nwcUri };
|
|
if (payload.defaultZap !== undefined) {
|
|
partial.defaultZap = payload.defaultZap;
|
|
}
|
|
await nwcSettings.updateActiveNwcSettings(partial);
|
|
} catch (error) {
|
|
userLogger.warn("[walletSync] Failed to apply pulled wallet settings:", error);
|
|
return { ...result, imported: false, error: "import-failed", cause: error };
|
|
}
|
|
return { ...result, imported: true };
|
|
}
|
|
|
|
async function enable(pubkey, options = {}) {
|
|
setEnabledFlag(pubkey, true);
|
|
return push(pubkey, options);
|
|
}
|
|
|
|
async function disable(pubkey) {
|
|
setEnabledFlag(pubkey, false);
|
|
return encryptedSync.clear(WALLET_SYNC_DTAG);
|
|
}
|
|
|
|
return {
|
|
WALLET_SYNC_DTAG,
|
|
isAvailable,
|
|
isEnabled,
|
|
hasRemote,
|
|
push,
|
|
pull,
|
|
enable,
|
|
disable,
|
|
};
|
|
}
|
|
|
|
export default createWalletSyncService;
|