Compare commits

..

No commits in common. "8070dfc0a246c46ef0917b03579a0ceeb93c11df" and "eea6f0e6b150188c9c11d3165e902eebc716f5ad" have entirely different histories.

8 changed files with 25 additions and 337 deletions

View file

@ -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

View file

@ -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<AppState>('delete_profile', { npub }),
undoDelete: () => call<AppState>('undo_delete'),

View file

@ -94,11 +94,6 @@ interface AppContextValue {
signerApprove: (id: string, approved: boolean, always?: boolean) => Promise<SignerStatus>;
signerGrantsList: () => Promise<SignerGrant[]>;
signerGrantRevoke: (appPubkey: string, grantMethod: string) => Promise<{ removed: boolean }>;
signerGrantUpdate: (
appPubkey: string,
grantMethod: string,
grantKinds: number[],
) => Promise<{ updated: boolean }>;
deleteProfile: (npub: string) => Promise<AppState>;
undoDelete: () => Promise<AppState>;
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,
],
);

View file

@ -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 = {

View file

@ -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<Vec<PublicKey>, 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<String> {
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<String> {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut urls: Vec<String> = 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<String, FeedItem>,
@ -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::<Vec<_>>()
);
// 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 {

View file

@ -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<u16>,
},
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<Mutex<App>>, request: Request) -> Result<serde_json::Valu
}
Ok(json!({ "removed": removed }))
}
Request::SignerGrantUpdate {
app_pubkey,
grant_method,
grant_kinds,
} => {
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 } => {

View file

@ -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(""));
}

View file

@ -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<u16> = 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 {