feat(sync): offer-to-pull encrypted settings on login (one-time, non-blocking)

Completes todo #15. On login, if an encrypted copy of the user's storage and/or
wallet settings exists on their Nostr account — and this device hasn't already
opted in — offer to restore it.

- js/nostr/encryptedSync.js: exists(dTag) — cheap list-only existence peek (NO
  decrypt, so it doesn't spend the signer budget); decryption only on accept.
- storage/walletSyncService: hasRemote() wrappers over exists().
- js/services/settingsRestorePrompt.js: one-time per-pubkey offer. Only prompts
  for items with a remote copy not already enabled locally; if nothing remote
  exists it does NOT consume the one-time offer (a note published later can still
  prompt next login). On accept, pulls + applies.
- app.js handleLoginModalSuccess: fires maybeOfferSettingsRestore(pubkey) after
  login, deferred ~2s so relays connect; best-effort, never blocks login; toasts
  on restore.

tests/settings-restore-prompt.test.mjs: offer+restore-only-remote-items, decline,
prompt-once, skip-already-enabled, don't-consume-when-empty. Mutation-verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-21 18:08:43 -04:00
1 parent d552e9017c
commit 4186b4c4ba
6 files changed
+374 -1

No files matched your search

+54
View File
@@ -57,6 +57,9 @@ import {
import watchHistoryService from "./watchHistoryService.js";
import r2Service from "./services/r2Service.js";
import storageService from "./services/storageService.js";
import { storageSyncService } from "./services/storageSyncService.js";
import { createWalletSyncService } from "./services/walletSyncService.js";
import { createSettingsRestorePrompt } from "./services/settingsRestorePrompt.js";
import {
getVideoNoteErrorMessage,
normalizeVideoNotePayload,
@@ -1624,6 +1627,57 @@ class Application {
);
}
}
if (pubkey) {
// Best-effort, never blocks login: offer to restore encrypted settings
// synced from another device.
this.maybeOfferSettingsRestore(pubkey);
}
}
// One-time offer to restore encrypted storage/wallet settings on login (todo
// #15). Existence check is list-only (no decrypt); decryption happens only if
// the user accepts. Deferred so relays can connect first; failures are silent.
maybeOfferSettingsRestore(pubkey) {
const key =
this.normalizeHexPubkey(pubkey) ||
(typeof pubkey === "string" ? pubkey : "");
if (!key) {
return;
}
setTimeout(() => {
try {
if (!this._settingsRestorePrompt) {
this._settingsRestorePrompt = createSettingsRestorePrompt({
storageSync: storageSyncService,
walletSync: createWalletSyncService({
nwcSettings: this.nwcSettingsService,
}),
});
}
Promise.resolve(
this._settingsRestorePrompt.maybeOffer(key, {
onRestored: (items) => {
const hasStorage = items.includes("storage");
const hasWallet = items.includes("wallet");
const label =
hasStorage && hasWallet
? "storage and wallet settings"
: hasWallet
? "wallet connection"
: "storage settings";
this.showSuccess(
`Restored your ${label} from your Nostr account.`,
);
},
}),
).catch((error) => {
devLogger.warn("[settingsRestore] offer failed:", error);
});
} catch (error) {
devLogger.warn("[settingsRestore] offer setup failed:", error);
}
}, 2000);
}
async handleLoginModalError(payload = {}) {
+43 -1
View File
@@ -260,6 +260,48 @@ export function createEncryptedSyncManager(deps = {}) {
return { found: true, payload, createdAt: newest.created_at, event: newest };
}
// Cheap existence peek for the login-restore prompt: list-only, NO decrypt
// (does not spend the signer's decrypt budget). Returns whether a non-cleared
// note is present for the d-tag.
async function exists(dTag) {
const tag = normalizeDTag(dTag);
const pubkey = resolvePubkey();
if (!tag || !pubkey) {
return { exists: false };
}
const relays =
(typeof getReadRelays === "function" ? getReadRelays() : []) || [];
if (typeof listEvents !== "function" || !relays.length) {
return { exists: false };
}
let events = [];
try {
events = await listEvents(relays, [
{ kinds: [APP_DATA_KIND], authors: [pubkey], "#d": [tag], limit: 20 },
]);
} catch (err) {
return { exists: false, error: "fetch-failed" };
}
const newest = selectNewest(Array.isArray(events) ? events.flat() : []);
if (!newest) {
return { exists: false };
}
const rawContent =
typeof newest.content === "string" ? newest.content.trim() : "";
if (!rawContent) {
return { exists: false, cleared: true };
}
try {
const parsed = JSON.parse(rawContent);
if (parsed && parsed.cleared === true) {
return { exists: false, cleared: true };
}
} catch (err) {
// Unparseable content still counts as "something is there".
}
return { exists: true, createdAt: newest.created_at };
}
async function clear(dTag, options = {}) {
const tag = normalizeDTag(dTag);
if (!tag) {
@@ -287,7 +329,7 @@ export function createEncryptedSyncManager(deps = {}) {
return publishTemplate(template);
}
return { isAvailable, push, pull, clear, APP_DATA_KIND };
return { isAvailable, push, pull, exists, clear, APP_DATA_KIND };
}
export default createEncryptedSyncManager;
+127
View File
@@ -0,0 +1,127 @@
// One-time "offer to pull on login" for encrypted settings sync (todo #15).
// On login, if an encrypted copy of the user's storage and/or wallet settings
// exists on their Nostr account — and this device hasn't already opted in — offer
// to restore it. Runs AFTER login (never blocks it) and respects the signer
// budget: the existence check is list-only (no decrypt); decryption happens only
// if the user accepts.
import { storageSyncService as defaultStorageSync } from "./storageSyncService.js";
const OFFERED_KEY = "bitvid:settings-sync:offered:v1";
function readOffered() {
try {
if (typeof localStorage === "undefined") {
return {};
}
const raw = localStorage.getItem(OFFERED_KEY);
const parsed = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === "object" ? parsed : {};
} catch (error) {
return {};
}
}
function markOffered(pubkey) {
try {
if (typeof localStorage === "undefined") {
return;
}
const map = readOffered();
map[pubkey] = true;
localStorage.setItem(OFFERED_KEY, JSON.stringify(map));
} catch (error) {
// Best-effort.
}
}
function hasOffered(pubkey) {
return readOffered()[pubkey] === true;
}
const ITEM_LABELS = {
storage: "storage settings",
wallet: "wallet connection",
};
export function createSettingsRestorePrompt({
storageSync = defaultStorageSync,
walletSync = null,
confirm = (message) =>
typeof window !== "undefined" && typeof window.confirm === "function"
? window.confirm(message)
: false,
logger = null,
} = {}) {
// Items not already enabled on THIS device that have a remote copy to restore.
async function collectCandidates(pubkey) {
const candidates = [];
const services = [
["storage", storageSync],
["wallet", walletSync],
];
for (const [kind, service] of services) {
if (!service?.isAvailable?.() || service.isEnabled(pubkey)) {
continue;
}
try {
if (await service.hasRemote()) {
candidates.push(kind);
}
} catch (error) {
logger?.warn?.(`[settingsRestore] ${kind} hasRemote check failed`, error);
}
}
return candidates;
}
async function maybeOffer(pubkey, { onRestored } = {}) {
const key = typeof pubkey === "string" ? pubkey.trim() : "";
if (!key) {
return { offered: false, reason: "no-pubkey" };
}
if (hasOffered(key)) {
return { offered: false, reason: "already-offered" };
}
const candidates = await collectCandidates(key);
if (!candidates.length) {
// Don't mark offered — a note may be published later from another device;
// the existence check is cheap (list-only) so re-checking next login is fine.
return { offered: false, reason: "no-remote" };
}
// We are about to prompt — mark so we never nag again on this device.
markOffered(key);
const list = candidates.map((kind) => ITEM_LABELS[kind]).join(" and ");
const accepted = confirm(
`Found an encrypted copy of your ${list} on your Nostr account. ` +
`Restore ${candidates.length > 1 ? "them" : "it"} to this device?`
);
if (!accepted) {
return { offered: true, accepted: false };
}
const restored = [];
const serviceByKind = { storage: storageSync, wallet: walletSync };
for (const kind of candidates) {
try {
const result = await serviceByKind[kind].pull(key);
if (result?.imported) {
restored.push(kind);
}
} catch (error) {
logger?.warn?.(`[settingsRestore] ${kind} pull failed`, error);
}
}
if (restored.length && typeof onRestored === "function") {
onRestored(restored);
}
return { offered: true, accepted: true, restored };
}
return { maybeOffer };
}
export default createSettingsRestorePrompt;
+11
View File
@@ -35,6 +35,16 @@ export function createStorageSyncService({
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) {
const key = normalizePubkey(pubkey);
@@ -90,6 +100,7 @@ export function createStorageSyncService({
STORAGE_SYNC_DTAG,
isAvailable,
isEnabled,
hasRemote,
push,
pull,
enable,
+11
View File
@@ -48,6 +48,16 @@ export function createWalletSyncService({
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) {
const key = normalizePubkey(pubkey);
if (!key) {
@@ -101,6 +111,7 @@ export function createWalletSyncService({
WALLET_SYNC_DTAG,
isAvailable,
isEnabled,
hasRemote,
push,
pull,
enable,
+128
View File
@@ -0,0 +1,128 @@
// Offer-to-pull on login (todo #15): after login, if an encrypted copy exists on
// the user's account and this device hasn't opted in, offer to restore — once.
// Scenarios assert observable outcomes:
// - prompts and restores only the items that have a remote copy + are not
// already enabled locally
// - declining does not restore
// - it never nags twice once it has prompted (per pubkey)
// - if nothing remote exists, it does NOT consume the one-time offer
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import { createSettingsRestorePrompt } from "../js/services/settingsRestorePrompt.js";
const PUBKEY = "c".repeat(64);
function makeSyncStub({ available = true, enabled = false, remote = false } = {}) {
return {
pulls: 0,
isAvailable: () => available,
isEnabled: () => enabled,
hasRemote: async () => remote,
pull: async function () {
this.pulls += 1;
return { found: true, imported: true };
},
};
}
test("offers and restores items that have a remote copy and aren't already enabled", async () => {
localStorage.clear();
const storageSync = makeSyncStub({ remote: true });
const walletSync = makeSyncStub({ remote: false }); // no wallet note
const restoredCalls = [];
const prompt = createSettingsRestorePrompt({
storageSync,
walletSync,
confirm: () => true,
});
const result = await prompt.maybeOffer(PUBKEY, {
onRestored: (items) => restoredCalls.push(items),
});
assert.equal(result.offered, true);
assert.equal(result.accepted, true);
assert.deepEqual(result.restored, ["storage"]);
assert.equal(storageSync.pulls, 1, "storage (has remote) must be pulled");
assert.equal(walletSync.pulls, 0, "wallet (no remote) must NOT be pulled");
assert.deepEqual(restoredCalls, [["storage"]]);
});
test("declining the prompt restores nothing", async () => {
localStorage.clear();
const storageSync = makeSyncStub({ remote: true });
const prompt = createSettingsRestorePrompt({
storageSync,
walletSync: makeSyncStub({ remote: false }),
confirm: () => false,
});
const result = await prompt.maybeOffer(PUBKEY);
assert.equal(result.offered, true);
assert.equal(result.accepted, false);
assert.equal(storageSync.pulls, 0, "decline must not pull");
});
test("does not offer twice once it has prompted (per pubkey)", async () => {
localStorage.clear();
let confirmCount = 0;
const prompt = createSettingsRestorePrompt({
storageSync: makeSyncStub({ remote: true }),
walletSync: makeSyncStub({ remote: false }),
confirm: () => {
confirmCount += 1;
return false;
},
});
await prompt.maybeOffer(PUBKEY);
const second = await prompt.maybeOffer(PUBKEY);
assert.equal(confirmCount, 1, "must prompt only once");
assert.equal(second.offered, false);
assert.equal(second.reason, "already-offered");
});
test("skips items already enabled on this device", async () => {
localStorage.clear();
const storageSync = makeSyncStub({ remote: true, enabled: true }); // already opted in here
const walletSync = makeSyncStub({ remote: true, enabled: false });
const prompt = createSettingsRestorePrompt({
storageSync,
walletSync,
confirm: () => true,
});
const result = await prompt.maybeOffer(PUBKEY);
assert.deepEqual(result.restored, ["wallet"], "only the not-yet-enabled item is offered");
assert.equal(storageSync.pulls, 0);
assert.equal(walletSync.pulls, 1);
});
test("nothing remote: does NOT consume the one-time offer (can prompt later)", async () => {
localStorage.clear();
const storageSync = makeSyncStub({ remote: false });
const walletSync = makeSyncStub({ remote: false });
let confirmCount = 0;
const prompt = createSettingsRestorePrompt({
storageSync,
walletSync,
confirm: () => {
confirmCount += 1;
return true;
},
});
const first = await prompt.maybeOffer(PUBKEY);
assert.equal(first.offered, false);
assert.equal(first.reason, "no-remote");
assert.equal(confirmCount, 0);
// Later a note appears; the offer is still available.
storageSync.hasRemote = async () => true;
const later = await prompt.maybeOffer(PUBKEY);
assert.equal(later.offered, true);
assert.equal(later.accepted, true);
assert.deepEqual(later.restored, ["storage"]);
});