From 7376ff91e075f1fd3702edffa81156b16692f29c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:17:35 -0400 Subject: [PATCH] feat(sync): Storage pane UI for encrypted settings sync (opt-in toggle + restore) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the storage-credential sync (6f7f4196) into the profile Storage pane. UI (components/profile-modal.html): a "Cross-device sync" card shown only when storage is unlocked — an opt-in toggle ("Sync these settings to my Nostr account (encrypted)"), a "Restore from my Nostr account" button, the public-relay / key-compromise warning copy, and a status line. Controller (ProfileStorageController): - toggle on -> storageSyncService.enable() (encrypt + publish), with rollback + message if nothing is stored yet; toggle off -> disable() (wipe the note) - restore -> pull() + re-render the pane so the restored connection shows - after a successful Save, re-push the synced copy when sync is enabled Also routes the core's relay READ through the subscription manager (injected listEvents) instead of pool.list, satisfying lint:pool-access for the new file; the facade supplies it via nostrClient.getSubscriptionManager().list. Co-Authored-By: Claude Opus 4.8 --- components/profile-modal.html | 27 ++++ js/nostr/encryptedSync.js | 8 +- js/nostr/encryptedSyncFacade.js | 12 ++ .../profileModal/ProfileStorageController.js | 140 ++++++++++++++++++ tests/encrypted-sync.test.mjs | 1 + 5 files changed, 185 insertions(+), 3 deletions(-) diff --git a/components/profile-modal.html b/components/profile-modal.html index 6cebbf32..6b50607a 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -942,6 +942,33 @@

+ + diff --git a/js/nostr/encryptedSync.js b/js/nostr/encryptedSync.js index af3c3538..2aaf9ea6 100644 --- a/js/nostr/encryptedSync.js +++ b/js/nostr/encryptedSync.js @@ -90,6 +90,7 @@ export function createEncryptedSyncManager(deps = {}) { getWriteRelays, getReadRelays, getPool, + listEvents, publishEventToRelays, summarizePublishResults, signEvent, @@ -206,14 +207,15 @@ export function createEncryptedSyncManager(deps = {}) { const relays = (typeof getReadRelays === "function" ? getReadRelays() : []) || []; - const pool = typeof getPool === "function" ? getPool() : null; - if (!pool || !relays.length) { + if (typeof listEvents !== "function" || !relays.length) { return { found: false, error: "no-relays" }; } + // Reads go through the injected listEvents (subscription manager in prod) — + // never pool.list directly (lint:pool-access). let events = []; try { - events = await pool.list(relays, [ + events = await listEvents(relays, [ { kinds: [APP_DATA_KIND], authors: [pubkey], "#d": [tag], limit: 20 }, ]); } catch (err) { diff --git a/js/nostr/encryptedSyncFacade.js b/js/nostr/encryptedSyncFacade.js index 3972bfb7..18d72f93 100644 --- a/js/nostr/encryptedSyncFacade.js +++ b/js/nostr/encryptedSyncFacade.js @@ -29,6 +29,18 @@ export const encryptedSync = createEncryptedSyncManager({ : [], getReadRelays: resolveReadRelays, getPool: () => nostrClient?.pool || null, + // Reads are routed through the subscription manager (deduped + health-gated), + // never pool.list directly (lint:pool-access). + listEvents: (relays, filters) => { + const manager = + typeof nostrClient?.getSubscriptionManager === "function" + ? nostrClient.getSubscriptionManager() + : null; + if (!manager || typeof manager.list !== "function") { + return Promise.resolve([]); + } + return manager.list({ filters, relays }); + }, publishEventToRelays, summarizePublishResults, signEvent: async (template) => { diff --git a/js/ui/profileModal/ProfileStorageController.js b/js/ui/profileModal/ProfileStorageController.js index 91f5d488..305f2ad4 100644 --- a/js/ui/profileModal/ProfileStorageController.js +++ b/js/ui/profileModal/ProfileStorageController.js @@ -6,6 +6,7 @@ import { } from "../../services/s3Service.js"; import { getActiveSigner } from "../../nostr/client.js"; import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js"; +import { storageSyncService } from "../../services/storageSyncService.js"; export class ProfileStorageController { constructor(mainController) { @@ -34,6 +35,11 @@ export class ProfileStorageController { this.storageForcePathStyleInput = null; this.storageForcePathStyleLabel = null; + this.storageSyncSection = null; + this.storageSyncToggle = null; + this.storageSyncRestoreBtn = null; + this.storageSyncStatus = null; + this.storageUnlockFailure = null; } @@ -60,6 +66,10 @@ export class ProfileStorageController { this.storageS3Helper = document.getElementById("storageS3Helper") || null; this.storageForcePathStyleInput = document.getElementById("storageForcePathStyle") || null; this.storageForcePathStyleLabel = document.getElementById("storageForcePathStyleLabel") || null; + this.storageSyncSection = document.getElementById("profileStorageSync") || null; + this.storageSyncToggle = document.getElementById("storageSyncToggle") || null; + this.storageSyncRestoreBtn = document.getElementById("storageSyncRestoreBtn") || null; + this.storageSyncStatus = document.getElementById("storageSyncStatus") || null; } registerEventListeners() { @@ -98,6 +108,18 @@ export class ProfileStorageController { this.handlePublicUrlInput(); }); } + + if (this.storageSyncToggle instanceof HTMLElement) { + this.storageSyncToggle.addEventListener("change", () => { + void this.handleToggleSync(); + }); + } + + if (this.storageSyncRestoreBtn instanceof HTMLElement) { + this.storageSyncRestoreBtn.addEventListener("click", () => { + void this.handleRestoreSync(); + }); + } } async populateStoragePane() { @@ -188,9 +210,115 @@ export class ProfileStorageController { this.storageFormSection.classList.add("hidden"); } + this.renderSyncSection(isUnlocked ? pubkey : ""); this.updateStorageFormVisibility(); } + renderSyncSection(pubkey) { + if (!(this.storageSyncSection instanceof HTMLElement)) { + return; + } + // Only meaningful once unlocked: there is something to sync and the signer + // that encrypts is present. + if (!pubkey || !storageSyncService.isAvailable()) { + this.storageSyncSection.classList.add("hidden"); + return; + } + this.storageSyncSection.classList.remove("hidden"); + if (this.storageSyncToggle instanceof HTMLInputElement) { + this.storageSyncToggle.checked = storageSyncService.isEnabled(pubkey); + } + this.setSyncStatus(""); + } + + setSyncStatus(message, tone = "info") { + if (!(this.storageSyncStatus instanceof HTMLElement)) { + return; + } + this.storageSyncStatus.textContent = message || ""; + const toneClass = + tone === "success" + ? "text-status-success" + : tone === "error" + ? "text-status-danger" + : "text-muted"; + this.storageSyncStatus.className = `text-xs ${toneClass}`; + } + + async handleToggleSync() { + const pubkey = this.mainController.normalizeHexPubkey( + this.mainController.getActivePubkey() + ); + if (!pubkey) { + return; + } + const enabled = + this.storageSyncToggle instanceof HTMLInputElement + ? this.storageSyncToggle.checked + : false; + try { + if (enabled) { + this.setSyncStatus("Encrypting and publishing…"); + const result = await storageSyncService.enable(pubkey); + if (result?.ok) { + this.setSyncStatus( + `Synced to ${result.accepted}/${result.total} relays.`, + "success" + ); + this.mainController.showSuccess("Storage settings synced (encrypted)."); + } else { + // Roll the toggle back so it reflects reality. + if (this.storageSyncToggle instanceof HTMLInputElement) { + this.storageSyncToggle.checked = false; + } + await storageSyncService.disable(pubkey).catch(() => {}); + this.setSyncStatus( + result?.error === "nothing-to-sync" + ? "Nothing to sync yet — save a connection first." + : "Could not publish the encrypted copy. Try again.", + "error" + ); + } + } else { + this.setSyncStatus("Removing the synced copy…"); + await storageSyncService.disable(pubkey); + this.setSyncStatus("Sync turned off; the synced copy was cleared."); + this.mainController.showSuccess("Storage sync turned off."); + } + } catch (error) { + devLogger.error("[ProfileModal] Storage 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; + } + try { + this.setSyncStatus("Fetching and decrypting…"); + const result = await storageSyncService.pull(pubkey); + if (result?.found && result.imported) { + this.setSyncStatus("Restored from your Nostr account.", "success"); + this.mainController.showSuccess("Storage settings restored."); + // Re-render the pane so the restored connection shows. + await this.populateStoragePane(); + } 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 settings found (it was cleared)."); + } else { + this.setSyncStatus("No synced settings found on your account."); + } + } catch (error) { + devLogger.error("[ProfileModal] Storage sync restore failed:", error); + this.setSyncStatus("Restore failed. Please try again.", "error"); + } + } + fillStorageForm(conn) { if (!conn) return; const { @@ -594,6 +722,18 @@ export class ProfileStorageController { await storageService.saveConnection(pubkey, "default", payload, meta); this.setStorageFormStatus("Connection saved.", "success"); this.mainController.showSuccess("Storage connection saved."); + + // Keep the encrypted synced copy current if the user opted in. + if (storageSyncService.isEnabled(pubkey)) { + try { + const syncResult = await storageSyncService.push(pubkey); + if (syncResult?.ok) { + this.setSyncStatus("Synced copy updated.", "success"); + } + } catch (syncError) { + devLogger.warn("[ProfileModal] Storage re-sync after save failed:", syncError); + } + } } catch (error) { devLogger.error("Failed to save connection:", error); this.setStorageFormStatus("Failed to save connection.", "error"); diff --git a/tests/encrypted-sync.test.mjs b/tests/encrypted-sync.test.mjs index 75ad25be..e4a75062 100644 --- a/tests/encrypted-sync.test.mjs +++ b/tests/encrypted-sync.test.mjs @@ -78,6 +78,7 @@ function makeManager({ nip44 = true } = {}) { getWriteRelays: () => ["wss://write.relay"], getReadRelays: () => ["wss://read.relay"], getPool: () => relay, + listEvents: (relays, filters) => relay.list(relays, filters), publishEventToRelays, summarizePublishResults, signEvent: async (tpl) => ({