mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-02 19:02:28 +00:00
Mirror the per-account nsec key store for remote-signer sessions: move from a
single slot (bitvid:nip46:session:v1) to a per-user-pubkey map
(bitvid:nip46:sessions:v2) so several saved NIP-46 accounts each keep their own
reconnectable session and the profile switcher can move between them.
Previously only the last-connected session survived, so switching among saved
NIP-46 accounts reconnected the wrong one (or failed).
- nip46Client.js: new per-pubkey session map keyed by userPubkey.
readStoredNip46Session takes an optional target pubkey; writeStoredNip46-
SessionSync writes both the map and the v1 last-connected default;
clearStoredNip46Session(pubkey) forgets one account (no-arg wipes all). Legacy
v1 slot migrated into the map on first read. Adds listStoredNip46SessionPubkeys().
- SignerManager: useStoredRemoteSigner({ pubkey }) selects the target account's
session and, on access-denial, forgets only that account. getStoredNip46-
Metadata(pubkey) is per-account.
- nip46 provider threads expectPubkey into the reuse call so a switch reconnects
the requested account, with the expectPubkey guard as a backstop.
nip46Connector.js is dead code; the live path is entirely in SignerManager.
Tests: tests/nip46-multi-account.test.mjs (7).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
128 lines
5.2 KiB
JavaScript
128 lines
5.2 KiB
JavaScript
// Per-account NIP-46 remote-signer session storage: several saved NIP-46
|
|
// accounts each keep their own reconnectable session so the profile switcher
|
|
// can move between them. Before this, a single slot (bitvid:nip46:session:v1)
|
|
// meant only the last-connected session survived, so switching among saved
|
|
// NIP-46 accounts silently reconnected the wrong one (or failed).
|
|
//
|
|
// test_integrity_note:
|
|
// change_type: ["new_tests"]
|
|
// scenarios:
|
|
// - id: SCN-nip46-multi-account-storage
|
|
// given: "two NIP-46 accounts each persist a stored session"
|
|
// when: "the store is read by user pubkey / cleared for one account"
|
|
// then: "each account resolves its own session; clearing one keeps the other"
|
|
// observable_outcomes:
|
|
// - "readStoredNip46Session(pubkey) returns that account's remotePubkey/relays"
|
|
// - "clearStoredNip46Session(pubkey) removes only the named account"
|
|
// - "the legacy v1 single slot migrates into the per-pubkey map on read"
|
|
// determinism_controls:
|
|
// - "in-memory localStorage polyfill; no network/clock dependence"
|
|
// anti_cheat_rationale:
|
|
// prevents: ["hard-coded return value", "over-mocking internal logic"]
|
|
// relaxation:
|
|
// did_relax_any_assertion: false
|
|
|
|
import "./test-helpers/setup-localstorage.mjs";
|
|
import test, { beforeEach } from "node:test";
|
|
import { strict as assert } from "node:assert";
|
|
import {
|
|
writeStoredNip46SessionSync,
|
|
readStoredNip46Session,
|
|
clearStoredNip46Session,
|
|
listStoredNip46SessionPubkeys,
|
|
} from "../js/nostr/nip46Client.js";
|
|
|
|
const USER_A =
|
|
"00000000000000000000000000000000000000000000000000000000000000a1";
|
|
const USER_B =
|
|
"00000000000000000000000000000000000000000000000000000000000000b2";
|
|
const REMOTE_A =
|
|
"1111111111111111111111111111111111111111111111111111111111111111";
|
|
const REMOTE_B =
|
|
"2222222222222222222222222222222222222222222222222222222222222222";
|
|
const NIP46_SESSION_STORAGE_KEY = "bitvid:nip46:session:v1";
|
|
const NIP46_SESSIONS_MAP_STORAGE_KEY = "bitvid:nip46:sessions:v2";
|
|
|
|
function session(userPubkey, remotePubkey, relay) {
|
|
return {
|
|
version: 1,
|
|
clientPublicKey:
|
|
"3333333333333333333333333333333333333333333333333333333333333333",
|
|
remotePubkey,
|
|
relays: [relay],
|
|
encryption: "nip44.v2",
|
|
userPubkey,
|
|
lastConnectedAt: 1700000000000,
|
|
encryptedSecrets: "cipher",
|
|
keyEncryption: { salt: "c2FsdA==", iv: "aXY=", iterations: 1000, hash: "SHA-256" },
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
});
|
|
|
|
test("each saved account resolves its own session", () => {
|
|
writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
|
|
writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
|
|
|
|
assert.equal(readStoredNip46Session(USER_A).remotePubkey, REMOTE_A);
|
|
assert.equal(readStoredNip46Session(USER_B).remotePubkey, REMOTE_B);
|
|
assert.deepEqual(readStoredNip46Session(USER_A).relays, ["wss://a.example"]);
|
|
});
|
|
|
|
test("lists every account that has a stored session", () => {
|
|
writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
|
|
writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
|
|
assert.deepEqual(listStoredNip46SessionPubkeys().sort(), [USER_A, USER_B].sort());
|
|
});
|
|
|
|
test("clearing one account keeps the others reconnectable", () => {
|
|
writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
|
|
writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
|
|
|
|
clearStoredNip46Session(USER_A);
|
|
|
|
assert.equal(readStoredNip46Session(USER_A), null);
|
|
assert.equal(readStoredNip46Session(USER_B).remotePubkey, REMOTE_B);
|
|
});
|
|
|
|
test("no-arg read returns the last-connected account as the default", () => {
|
|
writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
|
|
writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
|
|
assert.equal(readStoredNip46Session().remotePubkey, REMOTE_B);
|
|
});
|
|
|
|
test("no-arg clear wipes every stored session (logout)", () => {
|
|
writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
|
|
writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
|
|
|
|
clearStoredNip46Session();
|
|
|
|
assert.deepEqual(listStoredNip46SessionPubkeys(), []);
|
|
assert.equal(readStoredNip46Session(USER_A), null);
|
|
assert.equal(readStoredNip46Session(), null);
|
|
});
|
|
|
|
test("a legacy v1 single-slot session migrates into the per-pubkey map", () => {
|
|
// Simulate an install that only ever had the old single slot.
|
|
localStorage.setItem(
|
|
NIP46_SESSION_STORAGE_KEY,
|
|
JSON.stringify(session(USER_A, REMOTE_A, "wss://a.example")),
|
|
);
|
|
assert.equal(localStorage.getItem(NIP46_SESSIONS_MAP_STORAGE_KEY), null);
|
|
|
|
assert.equal(readStoredNip46Session(USER_A).remotePubkey, REMOTE_A);
|
|
assert.deepEqual(listStoredNip46SessionPubkeys(), [USER_A]);
|
|
|
|
// A second account can now be added alongside the migrated one.
|
|
writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
|
|
assert.equal(readStoredNip46Session(USER_A).remotePubkey, REMOTE_A);
|
|
assert.equal(readStoredNip46Session(USER_B).remotePubkey, REMOTE_B);
|
|
});
|
|
|
|
test("requesting an account with no stored session returns null (not a wrong one)", () => {
|
|
writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
|
|
assert.equal(readStoredNip46Session(USER_B), null);
|
|
});
|