From aef47dd1efdb92cab017eb7fd49fd7fa91a3945a Mon Sep 17 00:00:00 2001 From: Avi Date: Wed, 30 Sep 2026 14:18:33 -0500 Subject: [PATCH 1/4] fix(feed): query profile relays for kind-3 follow lists and kind-0 metadata The 'My contacts' feed and the identity backfill only queried the user's enabled read relays, but follow lists and metadata live on profile/outbox relays (purplepag.es aggregates them network-wide). Add PROFILE_RELAYS as an always-consulted set for profile-scoped queries (notes queries keep using only enabled relays), de-duplicated with trailing-slash normalising. Backfill now also upgrades local rows wearing the 'My Profile' create-time placeholder, not just Nip46Client rows. --- src/feed.rs | 89 +++++++++++++++++++++++++++++++++++++++---------- src/ipc.rs | 12 ++++--- src/profiles.rs | 8 +++-- 3 files changed, 83 insertions(+), 26 deletions(-) diff --git a/src/feed.rs b/src/feed.rs index f876012..737b98c 100644 --- a/src/feed.rs +++ b/src/feed.rs @@ -96,9 +96,12 @@ 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 = enabled_relays(settings); + let relay_urls = profile_lookup_relays(settings); if relay_urls.is_empty() { return Ok(Vec::new()); } @@ -283,6 +286,35 @@ 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, @@ -539,16 +571,49 @@ mod tests { assert_eq!(items[0].author, followed.public_key().to_hex()); } - #[tokio::test] - async fn contact_feed_with_no_relays_is_empty() { + #[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() }; - let feed = contact_feed(&settings, DEFAULT_LIMIT, "00".repeat(32).as_str()) + 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. + let settings = Settings { + relays: Vec::new(), + ..Default::default() + }; + let err = contact_feed(&settings, DEFAULT_LIMIT, "not-hex") .await - .unwrap(); - assert!(feed.is_empty()); + .expect_err("an invalid hex owner must error"); + assert_eq!(err.kind(), crate::errors::ErrorKind::Internal); } #[tokio::test] @@ -575,18 +640,6 @@ 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 43de978..d013d97 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -359,10 +359,10 @@ pub async fn serve() -> Result<(), AppError> { .vault .profiles .iter() - .filter(|p| { - p.signer_mode == SignerMode::Nip46Client - && profiles::is_generic_pairing_label(&p.label) - }) + // 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_map(|p| { PublicKey::parse(&p.public_key) .ok() @@ -377,7 +377,9 @@ pub async fn serve() -> Result<(), AppError> { } let relay_urls = { let guard = app.lock().await; - relays::enabled_urls(&guard.settings) + // 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) }; for (npub, identity) in pending { let found = tokio::time::timeout( diff --git a/src/profiles.rs b/src/profiles.rs index 2da0bf1..ce36161 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"]; +pub const GENERIC_PAIRING_LABELS: &[&str] = &["Amber", "Remote Signer", "My Profile"]; /// True when `label` is one of the generic pairing placeholders. pub fn is_generic_pairing_label(label: &str) -> bool { @@ -947,13 +947,15 @@ 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 time. + // wears one of the placeholders the UI assigns at pairing or create + // time ("My Profile" is normalise_label's empty-input default). 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("My Profile")); + assert!(!is_generic_pairing_label("god is decentralized")); assert!(!is_generic_pairing_label("amber")); assert!(!is_generic_pairing_label("")); } From ec8b515d05b1f1df7dc4e59885c10a54a147c7b1 Mon Sep 17 00:00:00 2001 From: Avi Date: Wed, 30 Sep 2026 14:19:54 -0500 Subject: [PATCH 2/4] docs(checkpoint): profile-relay queries @ aef47dd --- CHECKPOINT-encryption.md | 67 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/CHECKPOINT-encryption.md b/CHECKPOINT-encryption.md index 633fa80..9d4c858 100644 --- a/CHECKPOINT-encryption.md +++ b/CHECKPOINT-encryption.md @@ -1,3 +1,70 @@ +# 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 From d7cf2a5fda8af37a6aed90ee20ed714906777abb Mon Sep 17 00:00:00 2001 From: Avi Date: Wed, 30 Sep 2026 15:03:05 -0500 Subject: [PATCH 3/4] feat(signer): grant kind-editing backend (vault + IPC + api plumbing) Step 4 remainder, first half: Vault::update_signer_grant_kinds replaces a standing grant's kind scope (sorted, deduped; empty = all kinds, a deliberate broadening matching legacy semantics). New signer_grant_update IPC + api/AppProvider/fakeBackend plumbing. The Signer-screen editor UI is the next unit; no UI surface calls the RPC yet. --- frontend/src/lib/api.ts | 6 ++++ frontend/src/state/AppProvider.tsx | 12 +++++++ frontend/src/test/fakeBackend.ts | 19 ++++++++++ src/ipc.rs | 25 +++++++++++++ src/vault.rs | 58 ++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 59b53c1..4aa17f7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -144,6 +144,12 @@ 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 89fce9e..0a154f8 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -94,6 +94,11 @@ 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; @@ -305,6 +310,11 @@ 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) => @@ -398,6 +408,7 @@ export function AppProvider({ children }: { children: ReactNode }) { signerApprove, signerGrantsList, signerGrantRevoke, + signerGrantUpdate, deleteProfile, undoDelete, publishProfileMetadata, @@ -459,6 +470,7 @@ 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 11b7752..bf47169 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -434,6 +434,25 @@ 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/ipc.rs b/src/ipc.rs index d013d97..db5081d 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -223,6 +223,16 @@ 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, }, @@ -766,6 +776,21 @@ 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/vault.rs b/src/vault.rs index 0791f26..80db15b 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -260,6 +260,33 @@ 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(); @@ -877,6 +904,37 @@ 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 { From 8070dfc0a246c46ef0917b03579a0ceeb93c11df Mon Sep 17 00:00:00 2001 From: Avi Date: Wed, 30 Sep 2026 15:05:08 -0500 Subject: [PATCH 4/4] docs(checkpoint): grant kind-editing backend @ d7cf2a5; session stopped here --- CHECKPOINT-encryption.md | 68 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/CHECKPOINT-encryption.md b/CHECKPOINT-encryption.md index 9d4c858..72a0cdd 100644 --- a/CHECKPOINT-encryption.md +++ b/CHECKPOINT-encryption.md @@ -1,3 +1,71 @@ +# 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