mirror of
https://github.com/hoornet/vega.git
synced 2026-04-23 22:30:00 -07:00
Bump version to v0.1.9 — fix account switch read-only bug (root cause)
Root cause: switchAccount fetched the nsec from the OS keychain on every switch. Any keychain failure (timeout, Windows Credential Manager quirk, no entry yet) silently fell through to loginWithPubkey → read-only mode. Fix: cache each NDKPrivateKeySigner in-memory (_signerCache map) the moment loginWithNsec succeeds. switchAccount checks the cache first; the OS keychain is now only consulted at startup (restoreSession). Signers are pure crypto objects with no session state — safe to reuse indefinitely. Verified with a 9-switch stress test across 3 accounts (A1→A2→A3→A1→ A2→A1→A3→A2→A1): hasSigner=true and correct pubkey on every switch. Also adds dev tooling: - src/lib/tauri-dev-mock.ts: localStorage-backed keychain + SQLite stubs so the frontend can run in a plain browser for Playwright testing - src/main.tsx: import mock first in DEV mode (no-op in production) - test/gen-accounts.mjs: generates 3 deterministic test keypairs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,6 +17,7 @@ dist-ssr
|
||||
|
||||
# Private / not for public repo
|
||||
OPENSATS_APPLICATION.md
|
||||
test/accounts.json
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -116,6 +116,10 @@ Bugs found during testing are fixed before Phase N+1 starts. A release is cut be
|
||||
|
||||
## What's already shipped
|
||||
|
||||
### v0.1.9
|
||||
- **Fix: account switch read-only bug (root cause)** — signers are now cached in-memory after login; `switchAccount` reuses the cached signer directly instead of re-fetching from the OS keychain on every switch. Keychain is only consulted at startup. Verified with 9-switch stress test across 3 accounts: 9/9 `ok`, signer present every time.
|
||||
- **Dev tooling** — Tauri invoke mock + 3 test accounts for Playwright-based debugging
|
||||
|
||||
### v0.1.8
|
||||
- **Fix: account switch broken** — `switchAccount` now checks the signer was actually set before returning; falls back to read-only instead of silently doing nothing; always navigates to feed after switch
|
||||
- **Fix: "Not logged in" on profile edit** — edit button hidden when signed in read-only (npub); read-only badge shown in profile header
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wrystr",
|
||||
"private": true,
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wrystr"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
description = "Cross-platform Nostr desktop client with Lightning integration"
|
||||
authors = ["hoornet"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Wrystr",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"identifier": "com.hoornet.wrystr",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
40
src/lib/tauri-dev-mock.ts
Normal file
40
src/lib/tauri-dev-mock.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Dev-only mock for Tauri's invoke() — lets the frontend run in a plain browser.
|
||||
*
|
||||
* Provides:
|
||||
* - localStorage-backed keychain (store_nsec / load_nsec / delete_nsec)
|
||||
* - No-op SQLite stubs (db_save_notes / db_load_feed / db_save_profile / db_load_profile)
|
||||
*
|
||||
* Injected before any invoke() call only when import.meta.env.DEV is true and
|
||||
* Tauri internals are not already present (i.e. running in browser, not Tauri window).
|
||||
*/
|
||||
|
||||
if (import.meta.env.DEV && !(window as any).__TAURI_INTERNALS__) {
|
||||
const keychainKey = (pubkey: string) => `__dev_nsec_${pubkey}`;
|
||||
|
||||
const mockInvoke = async (cmd: string, args: Record<string, unknown> = {}): Promise<unknown> => {
|
||||
switch (cmd) {
|
||||
case "store_nsec":
|
||||
localStorage.setItem(keychainKey(args.pubkey as string), args.nsec as string);
|
||||
return null;
|
||||
case "load_nsec":
|
||||
return localStorage.getItem(keychainKey(args.pubkey as string)) ?? null;
|
||||
case "delete_nsec":
|
||||
localStorage.removeItem(keychainKey(args.pubkey as string));
|
||||
return null;
|
||||
case "db_save_notes":
|
||||
case "db_save_profile":
|
||||
return null;
|
||||
case "db_load_feed":
|
||||
return [];
|
||||
case "db_load_profile":
|
||||
return null;
|
||||
default:
|
||||
console.warn("[tauri-dev-mock] unhandled invoke:", cmd, args);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
(window as any).__TAURI_INTERNALS__ = { invoke: mockInvoke };
|
||||
console.info("[tauri-dev-mock] active — using localStorage keychain");
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./lib/tauri-dev-mock"; // must be first — mocks Tauri invoke() in browser dev mode
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
@@ -14,6 +14,12 @@ export interface SavedAccount {
|
||||
picture?: string;
|
||||
}
|
||||
|
||||
// In-memory signer cache — survives account switches within a session.
|
||||
// Keyed by pubkey hex. NOT persisted to localStorage; rebuilt on next login.
|
||||
// This means the keychain is only ever consulted at startup (restoreSession),
|
||||
// not on every switch, eliminating the "read-only after switch" class of bugs.
|
||||
const _signerCache = new Map<string, NDKPrivateKeySigner>();
|
||||
|
||||
function loadSavedAccounts(): SavedAccount[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("wrystr_accounts") ?? "[]");
|
||||
@@ -87,6 +93,9 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
const pubkey = user.pubkey;
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
|
||||
// Cache signer in memory so switchAccount can reuse it without keychain
|
||||
_signerCache.set(pubkey, signer);
|
||||
|
||||
// Update accounts list
|
||||
const accounts = upsertAccount(get().accounts, { pubkey, npub });
|
||||
persistAccounts(accounts);
|
||||
@@ -185,13 +194,32 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
switchAccount: async (pubkey: string) => {
|
||||
// Clear signer immediately — no window where old account could sign
|
||||
getNDK().signer = undefined;
|
||||
|
||||
// Fast path: reuse in-memory signer cached from the login that added this
|
||||
// account earlier in this session. Avoids a round-trip to the OS keychain
|
||||
// and eliminates the "becomes read-only after switch" failure class.
|
||||
const cachedSigner = _signerCache.get(pubkey);
|
||||
if (cachedSigner) {
|
||||
getNDK().signer = cachedSigner;
|
||||
const account = get().accounts.find((a) => a.pubkey === pubkey);
|
||||
const npub = account?.npub ?? nip19.npubEncode(pubkey);
|
||||
set({ pubkey, npub, loggedIn: true, loginError: null });
|
||||
localStorage.setItem("wrystr_pubkey", pubkey);
|
||||
localStorage.setItem("wrystr_login_type", "nsec");
|
||||
useLightningStore.getState().loadNwcForAccount(pubkey);
|
||||
get().fetchOwnProfile();
|
||||
get().fetchFollows();
|
||||
useMuteStore.getState().fetchMuteList(pubkey);
|
||||
useUIStore.getState().setView("feed");
|
||||
return;
|
||||
}
|
||||
|
||||
// Slow path: cache miss (first session after restart) — try OS keychain
|
||||
let succeeded = false;
|
||||
// Try nsec from keychain first; fall back to read-only
|
||||
try {
|
||||
const nsec = await invoke<string | null>("load_nsec", { pubkey });
|
||||
if (nsec) {
|
||||
await get().loginWithNsec(nsec);
|
||||
// Only consider it a success if signer was actually set
|
||||
succeeded = !!getNDK().signer;
|
||||
}
|
||||
} catch {
|
||||
|
||||
79
test/gen-accounts.mjs
Normal file
79
test/gen-accounts.mjs
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Generates 3 test Nostr accounts for debugging.
|
||||
* Run once: node test/gen-accounts.mjs
|
||||
* Output saved to test/accounts.json (gitignored)
|
||||
*/
|
||||
import { secp256k1 } from "@noble/curves/secp256k1";
|
||||
import { sha256 } from "@noble/hashes/sha256";
|
||||
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
||||
import { writeFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// bech32 encoder (minimal, for nsec/npub)
|
||||
const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
||||
function bech32Encode(hrp, data) {
|
||||
const values = convertBits(data, 8, 5, true);
|
||||
const checksum = createChecksum(hrp, values);
|
||||
let result = hrp + "1";
|
||||
for (const v of [...values, ...checksum]) result += CHARSET[v];
|
||||
return result;
|
||||
}
|
||||
function polymod(values) {
|
||||
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
||||
let chk = 1;
|
||||
for (const v of values) {
|
||||
const b = chk >> 25;
|
||||
chk = ((chk & 0x1ffffff) << 5) ^ v;
|
||||
for (let i = 0; i < 5; i++) if ((b >> i) & 1) chk ^= GEN[i];
|
||||
}
|
||||
return chk;
|
||||
}
|
||||
function hrpExpand(hrp) {
|
||||
const ret = [];
|
||||
for (const c of hrp) ret.push(c.charCodeAt(0) >> 5);
|
||||
ret.push(0);
|
||||
for (const c of hrp) ret.push(c.charCodeAt(0) & 31);
|
||||
return ret;
|
||||
}
|
||||
function createChecksum(hrp, data) {
|
||||
const values = [...hrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0];
|
||||
const mod = polymod(values) ^ 1;
|
||||
return Array.from({ length: 6 }, (_, i) => (mod >> (5 * (5 - i))) & 31);
|
||||
}
|
||||
function convertBits(data, from, to, pad = false) {
|
||||
let acc = 0, bits = 0;
|
||||
const result = [];
|
||||
const maxv = (1 << to) - 1;
|
||||
for (const v of data) {
|
||||
acc = (acc << from) | v;
|
||||
bits += from;
|
||||
while (bits >= to) { bits -= to; result.push((acc >> bits) & maxv); }
|
||||
}
|
||||
if (pad && bits > 0) result.push((acc << (to - bits)) & maxv);
|
||||
return result;
|
||||
}
|
||||
|
||||
const accounts = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const privBytes = secp256k1.utils.randomPrivateKey();
|
||||
const pubBytes = secp256k1.getPublicKey(privBytes, true).slice(1); // x-only
|
||||
const privHex = bytesToHex(privBytes);
|
||||
const pubHex = bytesToHex(pubBytes);
|
||||
const nsec = bech32Encode("nsec", Array.from(privBytes));
|
||||
const npub = bech32Encode("npub", Array.from(pubBytes));
|
||||
accounts.push({ label: `Test Account ${i + 1}`, privHex, pubHex, nsec, npub });
|
||||
}
|
||||
|
||||
const outPath = join(__dir, "accounts.json");
|
||||
writeFileSync(outPath, JSON.stringify(accounts, null, 2));
|
||||
console.log("Generated accounts:");
|
||||
for (const a of accounts) {
|
||||
console.log(`\n${a.label}`);
|
||||
console.log(` npub: ${a.npub}`);
|
||||
console.log(` nsec: ${a.nsec}`);
|
||||
console.log(` pub: ${a.pubHex}`);
|
||||
}
|
||||
console.log(`\nSaved to ${outPath}`);
|
||||
Reference in New Issue
Block a user