diff --git a/CHECKPOINT-encryption.md b/CHECKPOINT-encryption.md index 5c53946..72a0cdd 100644 --- a/CHECKPOINT-encryption.md +++ b/CHECKPOINT-encryption.md @@ -1,53 +1,3 @@ -# 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 diff --git a/frontend/src/screens/SignerScreen.tsx b/frontend/src/screens/SignerScreen.tsx index 1248028..94a1fe3 100644 --- a/frontend/src/screens/SignerScreen.tsx +++ b/frontend/src/screens/SignerScreen.tsx @@ -27,7 +27,6 @@ export function SignerScreen() { signerApprove, signerGrantsList, signerGrantRevoke, - signerGrantUpdate, } = useApp(); const [status, setStatus] = useState(EMPTY_STATUS); const [grants, setGrants] = useState([]); @@ -53,15 +52,10 @@ export function SignerScreen() { }, []); // Poll so approval requests appear without needing a manual refresh, and so - // 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. + // approvals/rejections made elsewhere are reflected here. useEffect(() => { const timer = window.setInterval(() => { - void signerStatus() - .then(setStatus) - .catch((err: unknown) => setError(err instanceof Error ? err.message : String(err))); + void refresh(); }, 1000); return () => window.clearInterval(timer); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -119,41 +113,6 @@ 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': @@ -289,50 +248,21 @@ export function SignerScreen() {

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

- {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 && ( - - )} - -
+ {grants.map((grant) => ( +
+
+ {grantLabel(grant)} +

for {shortHexId(grant.app_pubkey)}

- ); - })} +
+ +
+
+ ))}
)} diff --git a/frontend/src/test/SignerScreen.test.tsx b/frontend/src/test/SignerScreen.test.tsx index bad7146..45345c6 100644 --- a/frontend/src/test/SignerScreen.test.tsx +++ b/frontend/src/test/SignerScreen.test.tsx @@ -176,73 +176,4 @@ 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(); - }); }); diff --git a/src/ipc.rs b/src/ipc.rs index dc0789c..db5081d 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -423,7 +423,12 @@ pub async fn serve() -> Result<(), AppError> { row.nip05 = meta.nip05.clone(); changed = true; } - let real_name = profiles::network_display_name(&meta); + let real_name = meta + .display_name + .as_deref() + .or(meta.name.as_deref()) + .map(str::trim) + .filter(|name| !name.is_empty()); 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 6dda70e..ce36161 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -87,14 +87,8 @@ 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() && !is_generic_pairing_label(&label) { + if !relay_urls.is_empty() { publish_metadata_blocking(&keys, &label, None, None, relay_urls); } @@ -148,9 +142,6 @@ 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 { @@ -171,7 +162,7 @@ pub fn import_profile( }); let relay_urls = relays::enabled_urls(settings); - if !relay_urls.is_empty() && !is_generic_pairing_label(&label) { + if !relay_urls.is_empty() { publish_metadata_blocking( &keys, &label, @@ -575,23 +566,6 @@ 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 @@ -985,26 +959,6 @@ 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 9b220f1..cc85cb8 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. 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); + // 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()); if let Some(name) = real_name { if row.label == pairing_label { row.label = name.to_string();