diff --git a/CHECKPOINT-encryption.md b/CHECKPOINT-encryption.md index 72a0cdd..633fa80 100644 --- a/CHECKPOINT-encryption.md +++ b/CHECKPOINT-encryption.md @@ -1,138 +1,3 @@ -# Checkpoint — grant kind-editing backend (2026-09-30, session stopped mid-Step-4) - -## Where things are -- Project: `/home/avi/Projects/Keynctr`, branch `master` @ **`d7cf2a5`** - ("feat(signer): grant kind-editing backend (vault + IPC + api plumbing)"). - Previous: `ec8b515` + `aef47dd` (profile-relay queries, checkpoint above), - `eea6f0e` (white launcher icon), `abc2781` (one-click update script). -- Working tree: clean for tracked files. Untracked intentionally NOT - committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, - `deferred/SignerConnectionPanel.tsx.wip/`. -- origin/master behind: local ahead by 4 commits (push still blocked — no - Forgejo credentials stored; needs a token via the one-shot extraHeader - method). - -## What was completed (this checkpoint) -**Grant kind-editing backend — Step 4 remainder, first half** (session was -stopped by the user mid-build; the UI half is NOT started): -1. `vault.rs`: `Vault::update_signer_grant_kinds(app, method, kinds)` — - replaces a standing grant's kind scope, normalised (sorted + deduped). - Empty list = "all kinds" (same representation legacy grants use), so - broadening is explicit, never from omission. Unknown (app, method) - returns false, changes nothing. New unit test - `signer_grant_kinds_can_be_edited`. -2. `ipc.rs`: `Request::SignerGrantUpdate { app_pubkey, grant_method, - grant_kinds }` (field names avoid the internal `method` tag; kinds use - `serde(default)` so legacy payloads stay valid) + handler that saves the - vault only when a grant actually changed. -3. Frontend plumbing only, NO UI yet: `api.signerGrantUpdate`, the - AppProvider context type + callback + value/deps lists, and the - `signer_grant_update` case in `fakeBackend.ts` mirroring the backend - normalisation. - -## Commits added this session (newest first) -- (this checkpoint commit) -- `d7cf2a5` feat(signer): grant kind-editing backend -- `ec8b515` docs(checkpoint): profile-relay queries @ aef47dd -- `aef47dd` fix(feed): query profile relays for kind-3 follow lists and - kind-0 metadata (WIP of the previous dead session, verified + dedupe bug - fixed: trailing-slash normalising via a seen-set) - -## Verification (2026-09-30 @ d7cf2a5) -- `cargo test` -> 226 unit passed, 0 failed (e2e 6 green at aef47dd run). -- `cargo clippy --all-targets` -> 0 warnings; `cargo fmt --check` clean. -- `cargo check` green. NOTE: release binary at target/release/keynectr was - built at aef47dd, NOT d7cf2a5 — rebuild before shipping the new RPC. -- `frontend`: `npm run typecheck` clean; `npm test` -> 139 passed (19 - files). No new UI tests yet (no UI yet). - -## How to resume -- NEXT UNIT (was next when stopped): the Signer-screen grant editor — - inline kind editing on the "Always-allow permissions" card in - `frontend/src/screens/SignerScreen.tsx` (grants list, lines ~243-268): - edit kinds for a `sign_event` grant -> `signerGrantUpdate(app, method, - kinds)`; include an explicit "all kinds" option (empty list), Revoke - stays as is. Add `SignerScreen.test.tsx` cases against the fakeBackend - `signer_grant_update` case, then the full gates + release rebuild + - checkpoint update. -- GUI dev loop: `cd frontend && npx vite --port 5173`, then - `NOSTR_GUI_DEV_URL=http://localhost:5173 KEYNCTR_ENABLE_GPU=1 npx electron .` -- Push when a token is available: `git push origin master` (4 ahead). - -## Outstanding / next steps -- Grant editor UI (above) — second half of Step 4 remainder. -- Live pass: "My contacts" with the rebuilt backend (aef47dd fix). -- Push 4 commits to Forgejo. - ---- - -# Checkpoint — profile-relay queries for contacts + metadata backfill (2026-09-30) - -## Where things are -- Project: `/home/avi/Projects/Keynctr`, branch `master` @ **`aef47dd`** - ("fix(feed): query profile relays for kind-3 follow lists and kind-0 - metadata"). Previous: `eea6f0e` (white monochrome launcher icon), - `abc2781` (one-click update script), `7891ccc` (Step 7 rename/hygiene, - checkpoint `33ab4fe`). -- Working tree: clean for tracked files. Untracked intentionally NOT - committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, - `deferred/SignerConnectionPanel.tsx.wip/`. -- Release binary rebuilt at `aef47dd` 2026-09-30 14:18 (verified by mtime - after `touch`ing the changed sources — not a cache hit). - -## What was completed -**Profile-relay lookup for profile-scoped data** (finishes the empty -"My contacts" diagnosis on the network side): kind-3 follow lists and -kind-0 metadata usually live on profile/outbox relays — purplepag.es -aggregates them network-wide — not on the user's note read relays. Now: -1. `feed.rs`: `PROFILE_RELAYS = ["wss://purplepag.es"]` plus - `profile_lookup_relays(settings)` = enabled relays + profile relays, - de-duplicated with trailing-slash normalising (a user entry - `wss://purplepag.es/` no longer doubles the always-on entry — the WIP - version of this failed its own test; rewritten via a HashSet seen-set). - `contact_pubkeys` (kind-3 fetch) now queries that set; notes queries - deliberately keep using only enabled relays. -2. `ipc.rs` backfill loop: kind-0 fetch also uses `profile_lookup_relays`, - and the candidate filter dropped the `SignerMode::Nip46Client` - restriction — any row still wearing a placeholder gets a real name, - local keys included. -3. `profiles.rs`: `GENERIC_PAIRING_LABELS` now includes "My Profile" - (`normalise_label`'s empty-input default), so locally created - placeholder rows are backfilled too. Test updated: user renames still - never overwritten. - -## Commits added this session (newest first) -- `aef47dd` fix(feed): query profile relays for kind-3 follow lists and - kind-0 metadata (includes the fmt fix + dedupe rewrite of the WIP) -- (this checkpoint commit) - -## Verification -- `cargo test` -> 225 unit + 6 e2e passed, 0 failed. -- `cargo clippy --all-targets` -> 0 warnings. `cargo fmt --check` -> clean. -- `cargo build --release` -> rebuilt at aef47dd (binary mtime 14:18). -- No frontend files touched -> frontend gates not applicable. - -## How to resume / reproduce -- CLI ground truth for the contacts fix: - `target/release/keynectr feed --contacts 20` with the active key that - has a follow list published anywhere on the network — rows should now - appear even when purplepag.es is not in the user's read relays. -- GUI: `cd ~/Projects/Keynctr/frontend && npx vite --port 5173` then - `NOSTR_GUI_DEV_URL=http://localhost:5173 KEYNCTR_ENABLE_GPU=1 npx electron .` - (running windows need a relaunch/rebuild to pick up the new backend). -- Backfill trace: `grep -a 'auto-name' ~/Tools/keynctr-debug/pairing-trace.log`. - -## Outstanding / next steps -- Live pass: reopen "My contacts" in the GUI with the rebuilt backend and - confirm the feed populates for the account that has follow lists on - profile relays. -- Step 4 permissions UI follow-ups (interactive grant editing in the - approval modal) — the only buildable step left from the plan. -- Live pass: KDF migration on a real unlock + second-Amber account - switching. - ---- - # Checkpoint — stdin EAGAIN fix, accent theme, identity backfill (2026-09-28 evening) ## Where things are diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4aa17f7..59b53c1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -144,12 +144,6 @@ export const api = { app_pubkey: appPubkey, grant_method: grantMethod, }), - signerGrantUpdate: (appPubkey: string, grantMethod: string, grantKinds: number[]) => - call<{ updated: boolean }>('signer_grant_update', { - app_pubkey: appPubkey, - grant_method: grantMethod, - grant_kinds: grantKinds, - }), deleteProfile: (npub: string) => call('delete_profile', { npub }), undoDelete: () => call('undo_delete'), diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index 0a154f8..89fce9e 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -94,11 +94,6 @@ interface AppContextValue { signerApprove: (id: string, approved: boolean, always?: boolean) => Promise; signerGrantsList: () => Promise; signerGrantRevoke: (appPubkey: string, grantMethod: string) => Promise<{ removed: boolean }>; - signerGrantUpdate: ( - appPubkey: string, - grantMethod: string, - grantKinds: number[], - ) => Promise<{ updated: boolean }>; deleteProfile: (npub: string) => Promise; undoDelete: () => Promise; clearLastDeleted: () => void; @@ -310,11 +305,6 @@ export function AppProvider({ children }: { children: ReactNode }) { (appPubkey: string, grantMethod: string) => api.signerGrantRevoke(appPubkey, grantMethod), [], ); - const signerGrantUpdate = useCallback( - (appPubkey: string, grantMethod: string, grantKinds: number[]) => - api.signerGrantUpdate(appPubkey, grantMethod, grantKinds), - [], - ); const setVaultPassword = useCallback( (currentPassword: string | null, newPassword: string) => @@ -408,7 +398,6 @@ export function AppProvider({ children }: { children: ReactNode }) { signerApprove, signerGrantsList, signerGrantRevoke, - signerGrantUpdate, deleteProfile, undoDelete, publishProfileMetadata, @@ -470,7 +459,6 @@ export function AppProvider({ children }: { children: ReactNode }) { signerApprove, signerGrantsList, signerGrantRevoke, - signerGrantUpdate, copyText, ], ); diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index bf47169..11b7752 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -434,25 +434,6 @@ export function createFakeBackend(initial?: AppState): FakeBackend { return { removed: backend.signerGrants.length < before }; } - case 'signer_grant_update': { - const app = String(params.app_pubkey ?? ''); - const method = String(params.grant_method ?? ''); - // Mirrors Vault::update_signer_grant_kinds: normalise (sort + dedupe) - // and replace the kinds of every matching grant. - const kinds = [...((params.grant_kinds as number[]) ?? [])] - .sort((a, b) => a - b) - .filter((k, i, arr) => i === 0 || k !== arr[i - 1]); - let updated = false; - backend.signerGrants = backend.signerGrants.map((g) => { - if (g.app_pubkey === app && g.method === method) { - updated = true; - return { ...g, allowed_kinds: kinds }; - } - return g; - }); - return { updated }; - } - case 'relay_add': { const url = String(params.url); const nextSettings: Settings = { diff --git a/src/feed.rs b/src/feed.rs index 737b98c..f876012 100644 --- a/src/feed.rs +++ b/src/feed.rs @@ -96,12 +96,9 @@ pub async fn profile_feed( } /// Fetch the hex public keys followed by an owner profile (kind 3 contact list). -/// -/// Profile data lives on profile relays, not necessarily on the user's read -/// relays, so the query set is the enabled relays plus PROFILE_RELAYS. async fn contact_pubkeys(settings: &Settings, owner_hex: &str) -> Result, AppError> { let owner = owner_pubkey(owner_hex)?; - let relay_urls = profile_lookup_relays(settings); + let relay_urls = enabled_relays(settings); if relay_urls.is_empty() { return Ok(Vec::new()); } @@ -286,35 +283,6 @@ fn enabled_relays(settings: &Settings) -> Vec { relays::enabled_urls(settings) } -/// Relays always consulted for profile-scoped data (kind 0 metadata, kind 3 -/// follow lists), even when the user has not added them as read relays. -/// -/// Most clients publish profile data to outbox/profile relays (purplepag.es -/// aggregates them network-wide) rather than to the user's note relays, so a -/// follow-list or metadata query limited to the enabled read relays misses -/// real data. Notes queries deliberately keep using only the enabled relays. -pub const PROFILE_RELAYS: &[&str] = &["wss://purplepag.es"]; - -/// Enabled relays plus the always-on profile relays, de-duplicated. -/// -/// Trailing slashes are normalised away so `wss://purplepag.es/` and -/// `wss://purplepag.es` count as the same relay (they are equal to the -/// network), both against each other and against the always-on entries. -pub fn profile_lookup_relays(settings: &Settings) -> Vec { - let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - let mut urls: Vec = Vec::new(); - for url in enabled_relays(settings) - .into_iter() - .chain(PROFILE_RELAYS.iter().map(|u| (*u).to_string())) - { - let normalised = url.trim_end_matches('/').to_string(); - if seen.insert(normalised.clone()) { - urls.push(normalised); - } - } - urls -} - /// Accumulates notes into a bounded, de-duplicated, newest-first feed. struct FeedBuilder { items: HashMap, @@ -571,49 +539,16 @@ mod tests { assert_eq!(items[0].author, followed.public_key().to_hex()); } - #[test] - fn profile_lookup_relays_always_include_the_profile_relays() { - // With no enabled relays the query set still contains the always-on - // profile relays — that is the whole point (follow lists and kind-0 - // usually live there, not on the user's note relays). - let settings = Settings { - relays: Vec::new(), - ..Default::default() - }; - assert_eq!( - profile_lookup_relays(&settings), - PROFILE_RELAYS - .iter() - .map(|u| u.to_string()) - .collect::>() - ); - - // Enabled relays pass through, and a profile relay the user already - // added is not duplicated (trailing slash included). - let settings = Settings { - relays: vec![ - crate::settings::RelayConfig::new("wss://notes.example"), - crate::settings::RelayConfig::new(PROFILE_RELAYS[0]), - crate::settings::RelayConfig::new(format!("{}/", PROFILE_RELAYS[0])), - ], - ..Default::default() - }; - let urls = profile_lookup_relays(&settings); - assert_eq!(urls.len(), 2, "no duplicate profile relay: {urls:?}"); - } - #[tokio::test] - async fn contact_feed_with_invalid_owner_errors_before_network() { - // Owner parsing happens before any relay work, so an invalid key - // errors immediately even though profile relays are always present. + async fn contact_feed_with_no_relays_is_empty() { let settings = Settings { relays: Vec::new(), ..Default::default() }; - let err = contact_feed(&settings, DEFAULT_LIMIT, "not-hex") + let feed = contact_feed(&settings, DEFAULT_LIMIT, "00".repeat(32).as_str()) .await - .expect_err("an invalid hex owner must error"); - assert_eq!(err.kind(), crate::errors::ErrorKind::Internal); + .unwrap(); + assert!(feed.is_empty()); } #[tokio::test] @@ -640,6 +575,18 @@ mod tests { assert_eq!(err.kind(), crate::errors::ErrorKind::Internal); } + #[tokio::test] + async fn contact_feed_with_invalid_owner_errors() { + let settings = Settings { + relays: Vec::new(), + ..Default::default() + }; + let err = contact_feed(&settings, DEFAULT_LIMIT, "not-hex") + .await + .expect_err("an invalid hex owner must error"); + assert_eq!(err.kind(), crate::errors::ErrorKind::Internal); + } + #[test] fn zero_limit_still_returns_an_empty_feed_without_relays() { let settings = Settings { diff --git a/src/ipc.rs b/src/ipc.rs index db5081d..43de978 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -223,16 +223,6 @@ pub enum Request { app_pubkey: String, grant_method: String, }, - /// Edit the kind scope of a standing `sign_event` grant (interactive - /// grant editing). `grant_kinds` replaces the covered kinds verbatim - /// after normalising; an EMPTY list means "all kinds", so broadening is - /// a deliberate act, never the result of an omitted field. - SignerGrantUpdate { - app_pubkey: String, - grant_method: String, - #[serde(default)] - grant_kinds: Vec, - }, DeleteProfile { npub: String, }, @@ -369,10 +359,10 @@ pub async fn serve() -> Result<(), AppError> { .vault .profiles .iter() - // Any row still wearing a create/pairing - // placeholder gets a real name from the network, - // whether its key is local or remote. - .filter(|p| profiles::is_generic_pairing_label(&p.label)) + .filter(|p| { + p.signer_mode == SignerMode::Nip46Client + && profiles::is_generic_pairing_label(&p.label) + }) .filter_map(|p| { PublicKey::parse(&p.public_key) .ok() @@ -387,9 +377,7 @@ pub async fn serve() -> Result<(), AppError> { } let relay_urls = { let guard = app.lock().await; - // Metadata lives on profile relays, not just the user's - // read relays — include the always-on profile relays. - crate::feed::profile_lookup_relays(&guard.settings) + relays::enabled_urls(&guard.settings) }; for (npub, identity) in pending { let found = tokio::time::timeout( @@ -776,21 +764,6 @@ async fn run(app: &Arc>, request: Request) -> Result { - let mut guard = app.lock().await; - let updated = - guard - .vault - .update_signer_grant_kinds(&app_pubkey, &grant_method, &grant_kinds); - if updated { - guard.save_vault()?; - } - Ok(json!({ "updated": updated })) - } // Network-only requests (no shared state lock) Request::RelayTest { url } => { diff --git a/src/profiles.rs b/src/profiles.rs index ce36161..2da0bf1 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -559,7 +559,7 @@ pub fn find_stored_profile<'a>( /// never resolved its real identity from the network, so it stays a /// candidate for automatic name backfill on every launch. A user rename /// always falls outside this list and is therefore never overwritten. -pub const GENERIC_PAIRING_LABELS: &[&str] = &["Amber", "Remote Signer", "My Profile"]; +pub const GENERIC_PAIRING_LABELS: &[&str] = &["Amber", "Remote Signer"]; /// True when `label` is one of the generic pairing placeholders. pub fn is_generic_pairing_label(label: &str) -> bool { @@ -947,15 +947,13 @@ mod tests { #[test] fn generic_pairing_labels_gate_the_backfill() { // The background backfill upgrades a row's label ONLY while it still - // wears one of the placeholders the UI assigns at pairing or create - // time ("My Profile" is normalise_label's empty-input default). + // wears one of the placeholders the UI assigns at pairing time. assert!(is_generic_pairing_label("Amber")); assert!(is_generic_pairing_label("Remote Signer")); - assert!(is_generic_pairing_label("My Profile")); // Any real name — fetched or user-typed — is never a candidate, so // automatic enrichment can never overwrite it. assert!(!is_generic_pairing_label("satoshi")); - assert!(!is_generic_pairing_label("god is decentralized")); + assert!(!is_generic_pairing_label("My Profile")); assert!(!is_generic_pairing_label("amber")); assert!(!is_generic_pairing_label("")); } diff --git a/src/vault.rs b/src/vault.rs index 80db15b..0791f26 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -260,33 +260,6 @@ impl Vault { Ok(()) } - /// Replace the kind scope of an existing grant (interactive grant - /// editing). Returns whether a matching grant was updated. - /// - /// `allowed_kinds` is normalised (sorted, de-duplicated). An EMPTY list - /// means "all kinds" — the same semantics a legacy grant carries — so - /// broadening to all kinds is a deliberate editor action, never the - /// result of omission. Grants for non-signing methods always keep an - /// empty list; editing one is a no-op on the kinds field by construction. - pub fn update_signer_grant_kinds( - &mut self, - app_pubkey: &str, - method: &str, - allowed_kinds: &[u16], - ) -> bool { - let mut normalised: Vec = allowed_kinds.to_vec(); - normalised.sort_unstable(); - normalised.dedup(); - let mut found = false; - for g in self.signer_grants.iter_mut() { - if g.app_pubkey == app_pubkey && g.method == method { - g.allowed_kinds = normalised.clone(); - found = true; - } - } - found - } - /// Drop a standing grant; returns whether one was removed. pub fn revoke_signer_grant(&mut self, app_pubkey: &str, method: &str) -> bool { let before = self.signer_grants.len(); @@ -904,37 +877,6 @@ mod tests { assert!(vault.has_signer_grant("aa", "nip44_decrypt", None)); } - #[test] - fn signer_grant_kinds_can_be_edited() { - let mut vault = Vault::empty(); - vault.grant_signer_method("aa", "sign_event", &[1]).unwrap(); - vault - .grant_signer_method("aa", "nip44_decrypt", &[]) - .unwrap(); - - // Narrowing: add kind 30023 (normalised: sorted + de-duplicated). - assert!(vault.update_signer_grant_kinds("aa", "sign_event", &[30023, 1, 1])); - assert!(vault.has_signer_grant("aa", "sign_event", Some(1))); - assert!(vault.has_signer_grant("aa", "sign_event", Some(30023))); - assert!(!vault.has_signer_grant("aa", "sign_event", Some(6))); - let g = vault - .signer_grants - .iter() - .find(|g| g.method == "sign_event") - .unwrap(); - assert_eq!(g.allowed_kinds, vec![1, 30023]); - - // Editing an unknown (app, method) reports false and changes nothing. - assert!(!vault.update_signer_grant_kinds("zz", "sign_event", &[1])); - assert_eq!(vault.signer_grants.len(), 2); - - // Explicitly broadening to ALL kinds is the empty list — the same - // representation legacy grants use. - assert!(vault.update_signer_grant_kinds("aa", "sign_event", &[])); - assert!(vault.has_signer_grant("aa", "sign_event", Some(6))); - assert!(vault.has_signer_grant("aa", "sign_event", None)); - } - static COUNTER: AtomicU32 = AtomicU32::new(0); fn temp_vault_path() -> PathBuf {