From 6f7f4196362406c428cdfceeccc8495de7bf4e66 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:01:54 -0400 Subject: [PATCH] feat(sync): storage-credential sync data layer (push/pull/clear) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the encrypted-sync core (0abb82b9) to the live client + active signer and adds the storage-credential sync service. No UI yet — the Storage pane toggle is the next increment. - js/nostr/encryptedSyncFacade.js: default encryptedSync instance bound to nostrClient (write set via getDeletePublishRelays, read relays, pool) and the active signer's signEvent. Keeps the core pure/testable. - js/services/storageService.js: exportAccountRecord / importAccountRecord — get the on-disk account record and restore it on another device. Import REPLACES the local record (same-pubkey envelope unlocks with the same signer); cross- device merge is a follow-up. - js/services/storageSyncService.js: enable/disable/push/pull under d-tag "bitvid:storage-connections", with a per-pubkey opt-in flag (off by default). enable() pushes immediately; disable() wipes the published note. tests/storage-sync-service.test.mjs: enable-pushes-exported-record, pull-restores on a fresh device, disable-clears, empty-record-doesn't-publish. Mutation-verified (skip-import and skip-clear both fail). Co-Authored-By: Claude Opus 4.8 --- js/nostr/encryptedSyncFacade.js | 45 +++++++++ js/services/storageService.js | 36 +++++++ js/services/storageSyncService.js | 140 +++++++++++++++++++++++++++ tests/storage-sync-service.test.mjs | 145 ++++++++++++++++++++++++++++ 4 files changed, 366 insertions(+) create mode 100644 js/nostr/encryptedSyncFacade.js create mode 100644 js/services/storageSyncService.js create mode 100644 tests/storage-sync-service.test.mjs diff --git a/js/nostr/encryptedSyncFacade.js b/js/nostr/encryptedSyncFacade.js new file mode 100644 index 00000000..3972bfb7 --- /dev/null +++ b/js/nostr/encryptedSyncFacade.js @@ -0,0 +1,45 @@ +// Default encrypted-sync instance wired to the live Nostr client + active signer. +// The pure core lives in ./encryptedSync.js; this file is the only place that +// reaches into the singletons, so the core stays unit-testable. + +import { nostrClient } from "../nostrClientFacade.js"; +import { getActiveSigner } from "./index.js"; +import { + publishEventToRelays, + summarizePublishResults, +} from "../nostrPublish.js"; +import { createEncryptedSyncManager } from "./encryptedSync.js"; + +function resolveReadRelays() { + const read = Array.isArray(nostrClient?.readRelays) ? nostrClient.readRelays : []; + if (read.length) { + return read; + } + return Array.isArray(nostrClient?.relays) ? nostrClient.relays : []; +} + +export const encryptedSync = createEncryptedSyncManager({ + getActivePubkey: () => nostrClient?.pubkey || "", + getSigner: () => getActiveSigner(), + // Write to the full write set (not the capped read set) so the update reaches + // everywhere — mirrors getDeletePublishRelays from the watch-history work. + getWriteRelays: () => + typeof nostrClient?.getDeletePublishRelays === "function" + ? nostrClient.getDeletePublishRelays() + : [], + getReadRelays: resolveReadRelays, + getPool: () => nostrClient?.pool || null, + publishEventToRelays, + summarizePublishResults, + signEvent: async (template) => { + const signer = getActiveSigner(); + if (!signer || typeof signer.signEvent !== "function") { + const err = new Error("Active signer missing signEvent support."); + err.code = "sign-event-missing"; + throw err; + } + return signer.signEvent(template); + }, +}); + +export default encryptedSync; diff --git a/js/services/storageService.js b/js/services/storageService.js index e6bb3bad..7df69d45 100644 --- a/js/services/storageService.js +++ b/js/services/storageService.js @@ -611,6 +611,42 @@ export class StorageService { } } + /** + * Exports the raw on-disk account record for a pubkey, for opt-in encrypted + * cross-login sync (todo #15). The record is already self-protected + * (encryptedMasterKey envelope + per-connection AES-GCM payloads) but its + * `meta` is plaintext (bucket/endpoint names), so callers MUST re-encrypt the + * whole record to self before publishing. Returns null when nothing is stored. + */ + async exportAccountRecord(pubkey) { + if (!pubkey) { + return null; + } + const account = await this._getAccount(pubkey); + return account || null; + } + + /** + * Imports an account record previously exported via {@link exportAccountRecord} + * (e.g. pulled from the user's encrypted Nostr note on a second device). The + * record is encrypted-to-self by the SAME pubkey, so its envelope unlocks with + * the same signer. This REPLACES the local record for the pubkey; the next + * unlock decrypts the imported master key. (Merge across devices is a follow-up.) + */ + async importAccountRecord(pubkey, record) { + if (!pubkey) { + throw new Error("Pubkey required to import storage account record."); + } + if (!record || typeof record !== "object" || !record.encryptedMasterKey) { + throw new Error("Invalid storage account record."); + } + const toSave = { ...record, pubkey }; + await this._saveAccount(toSave); + // Drop any in-memory master key so the next unlock uses the imported envelope. + this.lock(pubkey); + return toSave; + } + /** * Sets a connection as default for uploads (updates metadata). */ diff --git a/js/services/storageSyncService.js b/js/services/storageSyncService.js new file mode 100644 index 00000000..50565771 --- /dev/null +++ b/js/services/storageSyncService.js @@ -0,0 +1,140 @@ +// Opt-in encrypted cross-login sync for STORAGE credentials (todo #15, step 2). +// Wraps the encrypted-sync core: the on-disk storage account record (already +// self-protected, but with plaintext meta) is re-encrypted to self and published +// as a NIP-78 replaceable note under a stable d-tag, then pulled + imported on +// another device. +// +// Opt-in only, off by default. A per-pubkey local flag remembers the choice so +// the UI toggle persists and a future "re-push on change" knows whether to sync. + +import { encryptedSync as defaultEncryptedSync } from "../nostr/encryptedSyncFacade.js"; +import defaultStorageService from "./storageService.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. + } +} + +export function createStorageSyncService({ + encryptedSync = defaultEncryptedSync, + storage = defaultStorageService, +} = {}) { + function normalizePubkey(pubkey) { + return typeof pubkey === "string" ? pubkey.trim() : ""; + } + + function isAvailable() { + return Boolean(encryptedSync?.isAvailable?.()); + } + + function isEnabled(pubkey) { + const key = normalizePubkey(pubkey); + if (!key) { + return false; + } + return readFlags()[key]?.storage === true; + } + + 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); + } + + // Push the current local storage account record to the user's encrypted note. + async function push(pubkey) { + const key = normalizePubkey(pubkey); + if (!key) { + return { ok: false, error: "missing-pubkey" }; + } + let record; + try { + record = await storage.exportAccountRecord(key); + } catch (error) { + userLogger.warn("[storageSync] Failed to export storage record:", error); + return { ok: false, error: "export-failed", cause: error }; + } + if (!record) { + return { ok: false, error: "nothing-to-sync" }; + } + return encryptedSync.push(STORAGE_SYNC_DTAG, record); + } + + // Pull the encrypted note and import it into local storage. Returns the + // encrypted-sync pull result with `imported: true` when a record was applied. + async function pull(pubkey) { + const key = normalizePubkey(pubkey); + if (!key) { + return { found: false, error: "missing-pubkey" }; + } + const result = await encryptedSync.pull(STORAGE_SYNC_DTAG); + if (!result?.found) { + return result; + } + try { + await storage.importAccountRecord(key, result.payload); + } catch (error) { + userLogger.warn("[storageSync] Failed to import storage record:", error); + return { ...result, imported: false, error: "import-failed", cause: error }; + } + return { ...result, imported: true }; + } + + // Turn sync on: remember the choice and push immediately. + async function enable(pubkey) { + setEnabledFlag(pubkey, true); + return push(pubkey); + } + + // Turn sync off: remember the choice and wipe the published note. + async function disable(pubkey) { + setEnabledFlag(pubkey, false); + return encryptedSync.clear(STORAGE_SYNC_DTAG); + } + + return { + STORAGE_SYNC_DTAG, + isAvailable, + isEnabled, + push, + pull, + enable, + disable, + }; +} + +export const storageSyncService = createStorageSyncService(); + +export default storageSyncService; diff --git a/tests/storage-sync-service.test.mjs b/tests/storage-sync-service.test.mjs new file mode 100644 index 00000000..7301f45c --- /dev/null +++ b/tests/storage-sync-service.test.mjs @@ -0,0 +1,145 @@ +// Storage-credential sync (todo #15, step 2): the local storage account record +// is pushed to an encrypted note and pulled+imported on another device. +// Scenarios assert observable outcomes at the storage + sync boundary: +// - enable() pushes exactly the exported record under the storage d-tag +// - pull() imports the pulled payload into local storage (round-trip restore) +// - disable() clears the published note and flips the persisted opt-in flag +// - push() with nothing stored does not publish + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createStorageSyncService, + STORAGE_SYNC_DTAG, +} from "../js/services/storageSyncService.js"; + +const PUBKEY = "a".repeat(64); + +// A faithful-enough storage twin: holds one account record per pubkey. +function makeStorageTwin(initialRecord = null) { + const db = new Map(); + if (initialRecord) { + db.set(PUBKEY, initialRecord); + } + return { + db, + async exportAccountRecord(pubkey) { + return db.get(pubkey) || null; + }, + async importAccountRecord(pubkey, record) { + if (!record || !record.encryptedMasterKey) { + throw new Error("Invalid record"); + } + db.set(pubkey, { ...record, pubkey }); + return db.get(pubkey); + }, + }; +} + +// An encrypted-sync twin backed by a single "note" so push/pull/clear interact. +function makeSyncTwin({ available = true } = {}) { + let note = null; // { payload } | { cleared: true } + return { + calls: { push: [], pull: 0, clear: 0 }, + isAvailable: () => available, + async push(dTag, payload) { + this.calls.push.push({ dTag, payload }); + note = { payload }; + return { ok: true, accepted: 1, total: 1 }; + }, + async pull(dTag) { + this.calls.pull += 1; + if (!note || note.cleared) { + return { found: false, cleared: Boolean(note?.cleared) }; + } + return { found: true, payload: note.payload, createdAt: 123 }; + }, + async clear(dTag) { + this.calls.clear += 1; + note = { cleared: true }; + return { ok: true, accepted: 1, total: 1 }; + }, + }; +} + +const ACCOUNT_RECORD = { + pubkey: PUBKEY, + encryptedMasterKey: { method: "nip44", ciphertext: "deadbeef" }, + connections: { + conn1: { id: "conn1", provider: "cloudflare_r2", meta: { bucket: "b" }, encrypted: { cipher: "x", iv: "y" } }, + }, +}; + +test("enable() pushes the exported storage record under the storage d-tag and sets the flag", async () => { + localStorage.clear(); + const storage = makeStorageTwin(ACCOUNT_RECORD); + const sync = makeSyncTwin(); + const service = createStorageSyncService({ encryptedSync: sync, storage }); + + 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, "enable must push once"); + assert.equal(sync.calls.push[0].dTag, STORAGE_SYNC_DTAG); + assert.deepEqual( + sync.calls.push[0].payload, + ACCOUNT_RECORD, + "must push exactly the exported account record", + ); + assert.equal(service.isEnabled(PUBKEY), true, "opt-in flag must persist"); +}); + +test("pull() restores the synced record into local storage on a fresh device", async () => { + localStorage.clear(); + // Device A pushes. + const syncShared = makeSyncTwin(); + const storageA = makeStorageTwin(ACCOUNT_RECORD); + await createStorageSyncService({ encryptedSync: syncShared, storage: storageA }).enable(PUBKEY); + + // Device B (empty) pulls from the same note. + const storageB = makeStorageTwin(null); + const serviceB = createStorageSyncService({ encryptedSync: syncShared, storage: storageB }); + + assert.equal(await storageB.exportAccountRecord(PUBKEY), null, "device B starts empty"); + + const pulled = await serviceB.pull(PUBKEY); + assert.equal(pulled.found, true); + assert.equal(pulled.imported, true, "a found record must be imported"); + assert.deepEqual( + await storageB.exportAccountRecord(PUBKEY), + { ...ACCOUNT_RECORD, pubkey: PUBKEY }, + "device B must now hold the restored record", + ); +}); + +test("disable() clears the note and a later pull finds nothing", async () => { + localStorage.clear(); + const storage = makeStorageTwin(ACCOUNT_RECORD); + const sync = makeSyncTwin(); + const service = createStorageSyncService({ encryptedSync: sync, storage }); + + await service.enable(PUBKEY); + assert.equal(service.isEnabled(PUBKEY), true); + + const cleared = await service.disable(PUBKEY); + assert.equal(cleared.ok, true); + assert.equal(sync.calls.clear, 1, "disable must clear the published note"); + assert.equal(service.isEnabled(PUBKEY), false, "opt-in flag must flip off"); + + const pulled = await service.pull(PUBKEY); + assert.equal(pulled.found, false, "cleared note must not restore anything"); +}); + +test("push() with nothing stored does not publish", async () => { + localStorage.clear(); + const storage = makeStorageTwin(null); + const sync = makeSyncTwin(); + const service = createStorageSyncService({ encryptedSync: sync, storage }); + + 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, "must not publish an empty record"); +});