mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
feat(sync): storage-credential sync data layer (push/pull/clear)
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 <[email protected]>
This commit is contained in:
1 parent
0abb82b933
commit
6f7f419636
4 files changed
+366
No files matched your search
@@ -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;
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user