From e8d2abbd1b5f8ea62a73d2909a43b252723c4d2e Mon Sep 17 00:00:00 2001 From: Avi Date: Thu, 1 Oct 2026 10:36:30 -0500 Subject: [PATCH 1/3] feat(signer): inline kind-scope editing for always-allow grants The 'Always-allow permissions' card on the Signer screen now edits an existing grant's event-kind scope via the signer_grant_update RPC: Edit toggles an input (comma-separated kinds, client-validated), Save applies, Revert restores. Grants list is left alone by the 5s poll so an open editor is never yanked out from under the user. --- frontend/src/screens/SignerScreen.tsx | 100 ++++++++++++++++++++---- frontend/src/test/SignerScreen.test.tsx | 69 ++++++++++++++++ 2 files changed, 154 insertions(+), 15 deletions(-) diff --git a/frontend/src/screens/SignerScreen.tsx b/frontend/src/screens/SignerScreen.tsx index 94a1fe3..1248028 100644 --- a/frontend/src/screens/SignerScreen.tsx +++ b/frontend/src/screens/SignerScreen.tsx @@ -27,6 +27,7 @@ export function SignerScreen() { signerApprove, signerGrantsList, signerGrantRevoke, + signerGrantUpdate, } = useApp(); const [status, setStatus] = useState(EMPTY_STATUS); const [grants, setGrants] = useState([]); @@ -52,10 +53,15 @@ export function SignerScreen() { }, []); // Poll so approval requests appear without needing a manual refresh, and so - // approvals/rejections made elsewhere are reflected here. + // approvals/rejections made elsewhere are reflected here. The grants list is + // loaded on mount and refreshed after our own grant actions; the 1 s poll + // only touches the request queue so the grants card (with its open kind + // editor) stays stable while typing. useEffect(() => { const timer = window.setInterval(() => { - void refresh(); + void signerStatus() + .then(setStatus) + .catch((err: unknown) => setError(err instanceof Error ? err.message : String(err))); }, 1000); return () => window.clearInterval(timer); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -113,6 +119,41 @@ export function SignerScreen() { } }; + // Grant kind editing: an "edit" draft keyed by app:method, holding the + // comma-separated kind list being typed. Empty input means "all kinds" + // (the same explicit broadening the backend uses). + const [grantDraft, setGrantDraft] = useState<{ key: string; kinds: string } | null>(null); + const [kindError, setKindError] = useState(null); + + const grantKey = (g: SignerGrant) => `${g.app_pubkey}:${g.method}`; + + const startEditGrant = (grant: SignerGrant) => { + setKindError(null); + setGrantDraft({ key: grantKey(grant), kinds: (grant.allowed_kinds ?? []).join(', ') }); + }; + + const onSaveKinds = async (grant: SignerGrant) => { + if (!grantDraft) return; + const parts = grantDraft.kinds + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + const bad = parts.find((s) => !/^\d+$/.test(s)); + if (bad !== undefined) { + setKindError(`“${bad}” is not an event kind number.`); + return; + } + setError(null); + try { + await signerGrantUpdate(grant.app_pubkey, grant.method, parts.map(Number)); + setGrants(await signerGrantsList()); + setGrantDraft(null); + setKindError(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + const badge = () => { switch (status.phase) { case 'connected': @@ -248,21 +289,50 @@ export function SignerScreen() {

- These requests run without asking. Revoke one to go back to approving it every time. + These requests run without asking. Edit an event-kind scope or revoke one to go back + to approving it every time.

- {grants.map((grant) => ( -
-
- {grantLabel(grant)} -

for {shortHexId(grant.app_pubkey)}

+ {grants.map((grant) => { + const key = grantKey(grant); + const editing = grantDraft?.key === key; + return ( +
+
+ {grantLabel(grant)} +

for {shortHexId(grant.app_pubkey)}

+ {editing && ( +
+ setGrantDraft({ key, kinds: e.target.value })} + /> + + +
+ )} + {editing && kindError && {kindError}} +
+
+ {grant.method === 'sign_event' && !editing && ( + + )} + +
-
- -
-
- ))} + ); + })}
)} diff --git a/frontend/src/test/SignerScreen.test.tsx b/frontend/src/test/SignerScreen.test.tsx index 45345c6..bad7146 100644 --- a/frontend/src/test/SignerScreen.test.tsx +++ b/frontend/src/test/SignerScreen.test.tsx @@ -176,4 +176,73 @@ describe('SignerScreen', () => { ).toBe(true); }); }); + + it('edits the kind scope of a sign_event grant', async () => { + const backend = createFakeBackend(); + backend.signerGrants = [{ app_pubkey: 'aa11', method: 'sign_event', allowed_kinds: [1] }]; + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + // The grants list arrives on the initial load poll. + expect( + await screen.findByText('Sign events — kinds 1', {}, { timeout: 3000 }), + ).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Edit kinds' })); + const input = screen.getByLabelText('Allowed event kinds'); + await user.clear(input); + await user.type(input, '1, 30023'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect( + backend.requests.some( + (r) => + r.method === 'signer_grant_update' && + r.params?.app_pubkey === 'aa11' && + r.params?.grant_method === 'sign_event' && + JSON.stringify(r.params?.grant_kinds) === '[1,30023]', + ), + ).toBe(true); + }); + expect(backend.signerGrants[0].allowed_kinds).toEqual([1, 30023]); + expect(await screen.findByText('Sign events — kinds 1, 30023')).toBeInTheDocument(); + }); + + it('rejects a non-numeric kind before calling the backend', async () => { + const backend = createFakeBackend(); + backend.signerGrants = [{ app_pubkey: 'aa11', method: 'sign_event', allowed_kinds: [1] }]; + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await screen.findByText('Sign events — kinds 1', {}, { timeout: 3000 }); + await user.click(screen.getByRole('button', { name: 'Edit kinds' })); + const input = screen.getByLabelText('Allowed event kinds'); + await user.clear(input); + await user.type(input, '1, hello'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByText(/hello.*not an event kind/)).toBeInTheDocument(); + expect(backend.requests.some((r) => r.method === 'signer_grant_update')).toBe(false); + }); + + it('empty kind list broadens the grant to all kinds', async () => { + const backend = createFakeBackend(); + backend.signerGrants = [{ app_pubkey: 'aa11', method: 'sign_event', allowed_kinds: [1] }]; + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await screen.findByText('Sign events — kinds 1', {}, { timeout: 3000 }); + await user.click(screen.getByRole('button', { name: 'Edit kinds' })); + await user.clear(screen.getByLabelText('Allowed event kinds')); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(backend.signerGrants[0].allowed_kinds).toEqual([]); + }); + expect(await screen.findByText('Sign events — all kinds')).toBeInTheDocument(); + }); }); From 83f594074b6223312f04fed4f7cbde00bb8b9260 Mon Sep 17 00:00:00 2001 From: Avi Date: Thu, 1 Oct 2026 10:48:22 -0500 Subject: [PATCH 2/3] fix(profiles): never treat placeholder kind-0 as a real display name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An early build auto-published the empty-label default 'My Profile' as new accounts' kind-0 metadata. That poisoned kind-0 then propagated back: pairing-time enrichment and the background backfill both copied it over the name the user typed, so profiles like npub1p437… display 'My Profile' forever and the loop can never resolve a 'real' name. - network_display_name(): shared resolver that rejects blank AND placeholder names fetched from the network (unit-tested) - create_profile / import_profile: never auto-publish a generic placeholder as kind-0; placeholders stay local until the user names the profile, and import falls back to the shortened npub - backfill + pairing enrichment now use the shared resolver --- src/ipc.rs | 7 +----- src/profiles.rs | 50 ++++++++++++++++++++++++++++++++++++-- src/signer/nip46_client.rs | 14 +++++------ 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/src/ipc.rs b/src/ipc.rs index db5081d..dc0789c 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -423,12 +423,7 @@ pub async fn serve() -> Result<(), AppError> { row.nip05 = meta.nip05.clone(); changed = true; } - let real_name = meta - .display_name - .as_deref() - .or(meta.name.as_deref()) - .map(str::trim) - .filter(|name| !name.is_empty()); + let real_name = profiles::network_display_name(&meta); if let Some(name) = real_name { if profiles::is_generic_pairing_label(&row.label) { row.label = name.to_string(); diff --git a/src/profiles.rs b/src/profiles.rs index ce36161..6dda70e 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -87,8 +87,14 @@ pub fn create_profile( // Publish kind 0 metadata event so other clients can see the username/display name. // Best-effort: relay failures here never block profile creation. + // + // Generic placeholders are never published: an early build auto-published + // the empty-label default "My Profile" as the account's kind-0, and that + // poisoned metadata then became the "real" name every client (including + // this app's own backfill) resolved forever. A placeholder stays local + // until the user names the profile, and only then goes on the network. let relay_urls = relays::enabled_urls(settings); - if !relay_urls.is_empty() { + if !relay_urls.is_empty() && !is_generic_pairing_label(&label) { publish_metadata_blocking(&keys, &label, None, None, relay_urls); } @@ -142,6 +148,9 @@ pub fn import_profile( .as_ref() .and_then(|m| m.display_name.as_deref().or(m.name.as_deref())) .filter(|name| !name.trim().is_empty()) + // Placeholder kind-0 (see create_profile) is not a name; fall + // back to the shortened npub instead of importing "My Profile". + .filter(|name| !is_generic_pairing_label(name.trim())) .map(str::to_string) .unwrap_or_else(|| shorten_npub(&keys.public_key())) } else { @@ -162,7 +171,7 @@ pub fn import_profile( }); let relay_urls = relays::enabled_urls(settings); - if !relay_urls.is_empty() { + if !relay_urls.is_empty() && !is_generic_pairing_label(&label) { publish_metadata_blocking( &keys, &label, @@ -566,6 +575,23 @@ pub fn is_generic_pairing_label(label: &str) -> bool { GENERIC_PAIRING_LABELS.contains(&label) } +/// The real display name carried by fetched kind-0 metadata, if any. +/// +/// Prefers `display_name` over `name`, rejects blanks, and rejects generic +/// placeholders: early builds published the empty-label default "My Profile" +/// as kind-0, so a placeholder arriving FROM the network is pollution, not a +/// name, and must never be copied over a profile row's label. +pub fn network_display_name(metadata: &Metadata) -> Option { + metadata + .display_name + .as_deref() + .or(metadata.name.as_deref()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .filter(|name| !is_generic_pairing_label(name)) + .map(str::to_string) +} + /// Create or refresh the vault profile for a remote (NIP-46) identity. /// /// When a NIP-46 client connection is established the identity lives on the @@ -959,6 +985,26 @@ mod tests { assert!(!is_generic_pairing_label("amber")); assert!(!is_generic_pairing_label("")); } + + #[test] + fn network_display_name_rejects_placeholder_kind0() { + // The exact pollution case: an early build published "My Profile" as + // the account's kind-0. Resolution must treat it as "no name found", + // never as the profile's display name. + let polluted = Metadata::new() + .name("My Profile") + .display_name("My Profile"); + assert_eq!(network_display_name(&polluted), None); + + let blank = Metadata::new().name(" ").display_name(""); + assert_eq!(network_display_name(&blank), None); + + let real = Metadata::new().name("satoshi").display_name("Satoshi ✦"); + assert_eq!(network_display_name(&real).as_deref(), Some("Satoshi ✦")); + + let name_only = Metadata::new().name("satoshi"); + assert_eq!(network_display_name(&name_only).as_deref(), Some("satoshi")); + } use crate::vault::{KdfParams, Vault, VaultCrypto}; fn populated_vault() -> Vault { diff --git a/src/signer/nip46_client.rs b/src/signer/nip46_client.rs index cc85cb8..9b220f1 100644 --- a/src/signer/nip46_client.rs +++ b/src/signer/nip46_client.rs @@ -2583,13 +2583,13 @@ impl Nip46ClientSigner { } // Upgrade the generic pairing label to the real display // name only while the row still carries the label we set - // during pairing — a user rename always wins. - let real_name = meta - .display_name - .as_deref() - .or(meta.name.as_deref()) - .map(str::trim) - .filter(|name| !name.is_empty()); + // during pairing — a user rename always wins. A + // placeholder coming FROM the network is never a name + // (network_display_name rejects it): early builds + // published the empty-label default "My Profile" as + // kind-0, and copying that over the name the user typed + // at pairing is how display names got lost. + let real_name = profiles::network_display_name(&meta); if let Some(name) = real_name { if row.label == pairing_label { row.label = name.to_string(); From d1622a0bf9d9b6c85c29aa8d2a3c9351c12ae66a Mon Sep 17 00:00:00 2001 From: Avi Date: Thu, 1 Oct 2026 10:56:47 -0500 Subject: [PATCH 3/3] docs(checkpoint): placeholder kind-0 fix + grant editing UI @ 83f5940 --- CHECKPOINT-encryption.md | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/CHECKPOINT-encryption.md b/CHECKPOINT-encryption.md index 72a0cdd..5c53946 100644 --- a/CHECKPOINT-encryption.md +++ b/CHECKPOINT-encryption.md @@ -1,3 +1,53 @@ +# Checkpoint — placeholder kind-0 fix + grant editing UI (2026-10-01) + +## Where things are +- Project: `/home/avi/Projects/Keynctr`, branch `master` @ **`83f5940`** + ("fix(profiles): never treat placeholder kind-0 as a real display name"). +- Working tree: clean for tracked files (always-untracked: COSMIC_THEME.md, + icon jpeg, deferred/). +- **Unpushed: `e8d2abb` + `83f5940`** on top of remote HEAD `8070dfc`. + +## What was completed (user-facing) +1. **Grant kind-editing UI** (`e8d2abb`): the Signer screen's "Always-allow + permissions" card now edits each grant's event-kind scope inline + (Edit → comma-separated kinds → Save/Revert). Backed by the + `signer_grant_update` RPC from `d7cf2a5`. +2. **Profile-name fix** (`83f5940`): placeholder "My Profile" names no longer + stick. Verified live: npub1p437…'s kind-0 on purplepag.es/damus says + "My Profile" (ts 1789050653) — an early build auto-published the + empty-label default as new accounts' kind-0, and pairing enrichment + + backfill then copied that poisoned value over typed names forever. Now: + - shared `profiles::network_display_name()` rejects blank AND placeholder + names fetched FROM the network (unit-tested); + - create/import never auto-publish a generic placeholder as kind-0; + - import falls back to the shortened npub instead of "My Profile". + +## Commits this session (newest first) +- `83f5940` fix(profiles): never treat placeholder kind-0 as a real display name +- `e8d2abb` feat(signer): inline kind-scope editing for always-allow grants +(checkpoint commits interleaved; earlier session: `d7cf2a5`, `ec8b515`, `aef47dd`) + +## Verification (all green, 2026-10-01) +- `cargo test` → 227 unit + 6 e2e, 0 failed · `cargo clippy --all-targets` → 0 warnings +- `cargo fmt --check` clean · `cargo build --release` rebuilt 10:48 +- frontend: `npm test` 142 passed; `typecheck`, `lint`, `format:check`, + `build`, `electron:build` green. (Full-suite failures seen earlier today + were load-induced 5s timeouts on a busy box — passed on quiet reruns.) + +## How to verify in the app +1. Fully quit and relaunch Keynctr (new backend, 10:48). +2. For npub1p437…: Profiles → rename → Publish name (pushes a clean kind-0 + network-wide). The backfill will no longer overwrite it with "My Profile". +3. Signer screen → Always-allow permissions → Edit a grant's kinds → Save. + +## Next steps +- **PUSH BLOCKED**: token supplied 2026-10-01 rejected by Forgejo API + ("access token does not exist") — expired/wrong kind. Need a fresh token + with write scope to push `master`. +- Optional: remove /tmp/kn-base worktree when done. + +--- + # Checkpoint — grant kind-editing backend (2026-09-30, session stopped mid-Step-4) ## Where things are