feat(sync): Storage pane UI for encrypted settings sync (opt-in toggle + restore)

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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-21 17:17:35 -04:00
1 parent 6f7f419636
commit 7376ff91e0
5 files changed
+185 -3

No files matched your search

+27
View File
@@ -942,6 +942,33 @@
</button>
</div>
<p id="storageFormStatus" class="text-sm" role="status" aria-live="polite"></p>
<div id="profileStorageSync" class="mt-2 rounded-lg border border-default p-4 space-y-3 hidden">
<div class="space-y-1">
<h4 class="text-sm font-semibold text-text">Cross-device sync</h4>
<p class="text-xs text-muted">
Save an <strong>encrypted</strong> copy of these storage
settings to your Nostr account so you don't have to
re-enter them on other devices.
</p>
</div>
<label class="flex items-start gap-2 text-sm text-muted-strong">
<input id="storageSyncToggle" type="checkbox" class="checkbox mt-0.5" />
<span>Sync these settings to my Nostr account (encrypted)</span>
</label>
<p class="text-xs text-status-warning">
The encrypted copy lives on public relays. It is readable
only with your Nostr key, but anyone who controls your key
could decrypt it — and the relays can see that your account
stores bitvid settings.
</p>
<div class="flex flex-wrap items-center gap-3">
<button id="storageSyncRestoreBtn" type="button" class="btn-ghost focus-ring">
Restore from my Nostr account
</button>
</div>
<p id="storageSyncStatus" class="text-xs" role="status" aria-live="polite"></p>
</div>
</div>
</div>
</section>
+5 -3
View File
@@ -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) {
+12
View File
@@ -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) => {
@@ -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");
+1
View File
@@ -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) => ({