diff --git a/components/profile-modal.html b/components/profile-modal.html index 6b50607a..51421cdf 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -836,6 +836,33 @@ role="status" aria-live="polite" >

+ + diff --git a/js/services/settingsSyncFlags.js b/js/services/settingsSyncFlags.js new file mode 100644 index 00000000..ad89a060 --- /dev/null +++ b/js/services/settingsSyncFlags.js @@ -0,0 +1,58 @@ +// Per-pubkey opt-in flags for encrypted settings sync (todo #15). Shared by the +// storage and wallet sync services so the toggle state persists locally. +// Shape: { [pubkey]: { storage?: boolean, wallet?: boolean } }. Off by default. + +const FLAG_STORAGE_KEY = "bitvid:settings-sync:v1"; + +function readFlags() { + try { + if (typeof localStorage === "undefined") { + return {}; + } + const raw = localStorage.getItem(FLAG_STORAGE_KEY); + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch (error) { + return {}; + } +} + +function writeFlags(flags) { + try { + if (typeof localStorage === "undefined") { + return; + } + localStorage.setItem(FLAG_STORAGE_KEY, JSON.stringify(flags || {})); + } catch (error) { + // Best-effort; the flag is a convenience, not a source of truth. + } +} + +function normalizePubkey(pubkey) { + return typeof pubkey === "string" ? pubkey.trim() : ""; +} + +export function isSyncEnabled(pubkey, kind) { + const key = normalizePubkey(pubkey); + if (!key || !kind) { + return false; + } + return readFlags()[key]?.[kind] === true; +} + +export function setSyncEnabled(pubkey, kind, enabled) { + const key = normalizePubkey(pubkey); + if (!key || !kind) { + return; + } + const flags = readFlags(); + const entry = flags[key] && typeof flags[key] === "object" ? flags[key] : {}; + entry[kind] = enabled === true; + 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 50565771..20e369e9 100644 --- a/js/services/storageSyncService.js +++ b/js/services/storageSyncService.js @@ -9,37 +9,11 @@ import { encryptedSync as defaultEncryptedSync } from "../nostr/encryptedSyncFacade.js"; import defaultStorageService from "./storageService.js"; +import { isSyncEnabled, setSyncEnabled } from "./settingsSyncFlags.js"; import { userLogger } from "../utils/logger.js"; export const STORAGE_SYNC_DTAG = "bitvid:storage-connections"; -const FLAG_STORAGE_KEY = "bitvid:settings-sync:v1"; - -function readFlags() { - try { - if (typeof localStorage === "undefined") { - return {}; - } - const raw = localStorage.getItem(FLAG_STORAGE_KEY); - if (!raw) { - return {}; - } - const parsed = JSON.parse(raw); - return parsed && typeof parsed === "object" ? parsed : {}; - } catch (error) { - return {}; - } -} - -function writeFlags(flags) { - try { - if (typeof localStorage === "undefined") { - return; - } - localStorage.setItem(FLAG_STORAGE_KEY, JSON.stringify(flags || {})); - } catch (error) { - // Best-effort; the flag is a convenience, not a source of truth. - } -} +const SYNC_KIND = "storage"; export function createStorageSyncService({ encryptedSync = defaultEncryptedSync, @@ -54,23 +28,11 @@ export function createStorageSyncService({ } function isEnabled(pubkey) { - const key = normalizePubkey(pubkey); - if (!key) { - return false; - } - return readFlags()[key]?.storage === true; + return isSyncEnabled(normalizePubkey(pubkey), SYNC_KIND); } function setEnabledFlag(pubkey, enabled) { - const key = normalizePubkey(pubkey); - if (!key) { - return; - } - const flags = readFlags(); - const entry = flags[key] && typeof flags[key] === "object" ? flags[key] : {}; - entry.storage = enabled === true; - flags[key] = entry; - writeFlags(flags); + setSyncEnabled(normalizePubkey(pubkey), SYNC_KIND, enabled); } // Push the current local storage account record to the user's encrypted note. diff --git a/js/services/walletSyncService.js b/js/services/walletSyncService.js new file mode 100644 index 00000000..e1671a1e --- /dev/null +++ b/js/services/walletSyncService.js @@ -0,0 +1,111 @@ +// 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 { 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; + } + + async function push(pubkey) { + 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 encryptedSync.push(WALLET_SYNC_DTAG, payload); + } + + 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) { + setEnabledFlag(pubkey, true); + return push(pubkey); + } + + async function disable(pubkey) { + setEnabledFlag(pubkey, false); + return encryptedSync.clear(WALLET_SYNC_DTAG); + } + + return { + WALLET_SYNC_DTAG, + isAvailable, + isEnabled, + push, + pull, + enable, + disable, + }; +} + +export default createWalletSyncService; diff --git a/js/ui/profileModal/ProfileWalletController.js b/js/ui/profileModal/ProfileWalletController.js index 1039ee37..428d8075 100644 --- a/js/ui/profileModal/ProfileWalletController.js +++ b/js/ui/profileModal/ProfileWalletController.js @@ -1,5 +1,6 @@ import { devLogger } from "../../utils/logger.js"; import { NWC_URI_SCHEME } from "../profileModalContract.js"; +import { createWalletSyncService } from "../../services/walletSyncService.js"; const noop = () => {}; const SECRET_PLACEHOLDER = "*****"; @@ -15,6 +16,12 @@ export class ProfileWalletController { this.walletDisconnectButton = null; this.walletStatusText = null; this.walletPane = null; + + this.walletSyncSection = null; + this.walletSyncToggle = null; + this.walletSyncRestoreBtn = null; + this.walletSyncStatus = null; + this._walletSyncService = null; } cacheDomReferences() { @@ -25,6 +32,10 @@ export class ProfileWalletController { this.walletDisconnectButton = document.getElementById("profileWalletDisconnect") || null; this.walletStatusText = document.getElementById("profileWalletStatus") || null; this.walletPane = document.getElementById("profilePaneWallet") || null; + this.walletSyncSection = document.getElementById("profileWalletSync") || null; + this.walletSyncToggle = document.getElementById("walletSyncToggle") || null; + this.walletSyncRestoreBtn = document.getElementById("walletSyncRestoreBtn") || null; + this.walletSyncStatus = document.getElementById("walletSyncStatus") || null; // Backwards compatibility alias if needed by tests/external code this.mainController.profileWalletStatusText = this.walletStatusText; @@ -68,6 +79,146 @@ export class ProfileWalletController { void this.handleWalletDisconnect(); }); } + + if (this.walletSyncToggle instanceof HTMLElement) { + this.walletSyncToggle.addEventListener("change", () => { + void this.handleToggleSync(); + }); + } + + if (this.walletSyncRestoreBtn instanceof HTMLElement) { + this.walletSyncRestoreBtn.addEventListener("click", () => { + void this.handleRestoreSync(); + }); + } + } + + getWalletSyncService() { + if (!this._walletSyncService) { + this._walletSyncService = createWalletSyncService({ + nwcSettings: this.mainController.services.nwcSettings, + }); + } + return this._walletSyncService; + } + + renderSyncSection(pubkey) { + if (!(this.walletSyncSection instanceof HTMLElement)) { + return; + } + const sync = this.getWalletSyncService(); + if (!pubkey || !sync.isAvailable()) { + this.walletSyncSection.classList.add("hidden"); + return; + } + this.walletSyncSection.classList.remove("hidden"); + if (this.walletSyncToggle instanceof HTMLInputElement) { + this.walletSyncToggle.checked = sync.isEnabled(pubkey); + } + this.setSyncStatus(""); + } + + setSyncStatus(message, tone = "info") { + if (!(this.walletSyncStatus instanceof HTMLElement)) { + return; + } + this.walletSyncStatus.textContent = message || ""; + const toneClass = + tone === "success" + ? "text-status-success" + : tone === "error" + ? "text-status-danger" + : "text-muted"; + this.walletSyncStatus.className = `text-xs ${toneClass}`; + } + + async handleToggleSync() { + const pubkey = this.mainController.normalizeHexPubkey( + this.mainController.getActivePubkey() + ); + if (!pubkey) { + return; + } + const sync = this.getWalletSyncService(); + const enabled = + this.walletSyncToggle instanceof HTMLInputElement + ? this.walletSyncToggle.checked + : false; + try { + if (enabled) { + // Spending capability — require explicit confirmation before publishing. + const confirmed = + typeof window !== "undefined" && typeof window.confirm === "function" + ? window.confirm( + "This publishes an ENCRYPTED copy of your wallet connection to public relays.\n\n" + + "A wallet connect URI can SPEND from your wallet. Anyone who controls your " + + "Nostr key could decrypt this and spend from this wallet.\n\nContinue?" + ) + : true; + if (!confirmed) { + if (this.walletSyncToggle instanceof HTMLInputElement) { + this.walletSyncToggle.checked = false; + } + return; + } + this.setSyncStatus("Encrypting and publishing…"); + const result = await sync.enable(pubkey); + if (result?.ok) { + this.setSyncStatus( + `Synced to ${result.accepted}/${result.total} relays.`, + "success" + ); + this.mainController.showSuccess("Wallet connection synced (encrypted)."); + } else { + if (this.walletSyncToggle instanceof HTMLInputElement) { + this.walletSyncToggle.checked = false; + } + await sync.disable(pubkey).catch(() => {}); + this.setSyncStatus( + result?.error === "nothing-to-sync" + ? "Connect a wallet first, then enable sync." + : "Could not publish the encrypted copy. Try again.", + "error" + ); + } + } else { + this.setSyncStatus("Removing the synced copy…"); + await sync.disable(pubkey); + this.setSyncStatus("Sync turned off; the synced copy was cleared."); + this.mainController.showSuccess("Wallet sync turned off."); + } + } catch (error) { + devLogger.error("[ProfileModal] Wallet sync toggle failed:", error); + this.setSyncStatus("Sync failed. Please try again.", "error"); + } + } + + async handleRestoreSync() { + const pubkey = this.mainController.normalizeHexPubkey( + this.mainController.getActivePubkey() + ); + if (!pubkey) { + return; + } + const sync = this.getWalletSyncService(); + try { + this.setSyncStatus("Fetching and decrypting…"); + const result = await sync.pull(pubkey); + if (result?.found && result.imported) { + this.setSyncStatus("Restored from your Nostr account.", "success"); + this.mainController.showSuccess("Wallet connection restored."); + this.refreshWalletPaneState(); + } else if (result?.found && !result.imported) { + this.setSyncStatus("Found a copy but could not import it.", "error"); + } else if (result?.cleared) { + this.setSyncStatus("No synced wallet found (it was cleared)."); + } else { + this.setSyncStatus("No synced wallet found on your account."); + } + } catch (error) { + devLogger.error("[ProfileModal] Wallet sync restore failed:", error); + this.setSyncStatus("Restore failed. Please try again.", "error"); + } } applyWalletControlState() { @@ -151,6 +302,7 @@ export class ProfileWalletController { } } this.updateWalletStatus("Sign in to connect a wallet.", "info"); + this.renderSyncSection(""); this.applyWalletControlState(); return; } @@ -183,6 +335,9 @@ export class ProfileWalletController { this.updateWalletStatus("No wallet connected yet.", "info"); } + this.renderSyncSection( + this.mainController.normalizeHexPubkey(this.mainController.getActivePubkey()) + ); this.applyWalletControlState(); } @@ -459,11 +614,35 @@ 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. + const sync = this.getWalletSyncService(); + if (sync.isEnabled(normalizedActive)) { + try { + await sync.push(normalizedActive); + } catch (syncError) { + devLogger.warn( + "[ProfileModal] Wallet re-sync after save failed:", + syncError + ); + } + } } else { finalStatus = "Wallet connection removed."; finalVariant = "info"; this.mainController.showStatus("Wallet connection removed."); context.reason = "cleared"; + // Don't leave a now-removed spending URI lingering (encrypted) on relays. + const sync = this.getWalletSyncService(); + if (sync.isEnabled(normalizedActive)) { + try { + await sync.disable(normalizedActive); + } catch (syncError) { + devLogger.warn( + "[ProfileModal] Wallet sync clear after disconnect failed:", + syncError + ); + } + } } context.success = true; } catch (error) { diff --git a/tests/wallet-sync-service.test.mjs b/tests/wallet-sync-service.test.mjs new file mode 100644 index 00000000..59c01872 --- /dev/null +++ b/tests/wallet-sync-service.test.mjs @@ -0,0 +1,125 @@ +// NWC wallet sync (todo #15): the wallet connection (a bearer SPENDING secret) +// is pushed to an encrypted note and pulled+applied on another device. +// Scenarios assert observable outcomes at the nwcSettings + sync boundary: +// - enable() pushes the current nwcUri (+defaultZap) under the wallet d-tag +// - pull() applies the pulled URI via updateActiveNwcSettings (restore) +// - disable() clears the published note and flips the opt-in flag +// - push() with no wallet connected does not publish + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createWalletSyncService, + WALLET_SYNC_DTAG, +} from "../js/services/walletSyncService.js"; + +const PUBKEY = "b".repeat(64); +const URI = "nostr+walletconnect://deadbeef?relay=wss://relay.example&secret=cafe"; + +function makeNwcTwin(initial = {}) { + let settings = { nwcUri: "", defaultZap: undefined, ...initial }; + return { + applied: [], + getActiveNwcSettings: () => ({ ...settings }), + updateActiveNwcSettings: async (partial) => { + settings = { ...settings, ...partial }; + // record what was applied (do NOT log the secret in real code) + // eslint-disable-next-line no-unused-vars + const safe = { ...partial }; + return { ...settings }; + }, + }; +} + +function makeSyncTwin() { + let note = null; + return { + calls: { push: [], pull: 0, clear: 0 }, + isAvailable: () => true, + async push(dTag, payload) { + this.calls.push.push({ dTag, payload }); + note = { payload }; + return { ok: true, accepted: 1, total: 1 }; + }, + async pull() { + this.calls.pull += 1; + if (!note || note.cleared) { + return { found: false, cleared: Boolean(note?.cleared) }; + } + return { found: true, payload: note.payload, createdAt: 1 }; + }, + async clear() { + this.calls.clear += 1; + note = { cleared: true }; + return { ok: true, accepted: 1, total: 1 }; + }, + }; +} + +test("enable() pushes the current wallet URI under the wallet d-tag", async () => { + localStorage.clear(); + const nwc = makeNwcTwin({ nwcUri: URI, defaultZap: 500 }); + const sync = makeSyncTwin(); + const service = createWalletSyncService({ encryptedSync: sync, nwcSettings: nwc }); + + assert.equal(service.isEnabled(PUBKEY), false, "off by default"); + const result = await service.enable(PUBKEY); + assert.equal(result.ok, true); + assert.equal(sync.calls.push.length, 1); + assert.equal(sync.calls.push[0].dTag, WALLET_SYNC_DTAG); + assert.deepEqual(sync.calls.push[0].payload, { nwcUri: URI, defaultZap: 500 }); + assert.equal(service.isEnabled(PUBKEY), true, "opt-in flag persists"); +}); + +test("pull() restores the wallet URI via updateActiveNwcSettings on a fresh device", async () => { + localStorage.clear(); + const sharedSync = makeSyncTwin(); + // Device A pushes. + await createWalletSyncService({ + encryptedSync: sharedSync, + nwcSettings: makeNwcTwin({ nwcUri: URI, defaultZap: 21 }), + }).enable(PUBKEY); + + // Device B (no wallet) pulls. + const nwcB = makeNwcTwin({ nwcUri: "" }); + const serviceB = createWalletSyncService({ encryptedSync: sharedSync, nwcSettings: nwcB }); + + const pulled = await serviceB.pull(PUBKEY); + assert.equal(pulled.found, true); + assert.equal(pulled.imported, true); + assert.equal( + nwcB.getActiveNwcSettings().nwcUri, + URI, + "device B must now hold the restored wallet URI", + ); + assert.equal(nwcB.getActiveNwcSettings().defaultZap, 21); +}); + +test("disable() clears the note and a later pull finds nothing", async () => { + localStorage.clear(); + const nwc = makeNwcTwin({ nwcUri: URI }); + const sync = makeSyncTwin(); + const service = createWalletSyncService({ encryptedSync: sync, nwcSettings: nwc }); + + await service.enable(PUBKEY); + const cleared = await service.disable(PUBKEY); + assert.equal(cleared.ok, true); + assert.equal(sync.calls.clear, 1); + assert.equal(service.isEnabled(PUBKEY), false); + + const pulled = await service.pull(PUBKEY); + assert.equal(pulled.found, false); +}); + +test("push() with no wallet connected does not publish", async () => { + localStorage.clear(); + const nwc = makeNwcTwin({ nwcUri: "" }); + const sync = makeSyncTwin(); + const service = createWalletSyncService({ encryptedSync: sync, nwcSettings: nwc }); + + const result = await service.push(PUBKEY); + assert.equal(result.ok, false); + assert.equal(result.error, "nothing-to-sync"); + assert.equal(sync.calls.push.length, 0); +});