Compare commits

..

3 commits

Author SHA1 Message Date
Avi
d1622a0bf9 docs(checkpoint): placeholder kind-0 fix + grant editing UI @ 83f5940 2026-10-01 10:56:47 -05:00
Avi
83f594074b fix(profiles): never treat placeholder kind-0 as a real display name
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
2026-10-01 10:48:22 -05:00
Avi
e8d2abbd1b 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.
2026-10-01 10:36:30 -05:00
6 changed files with 260 additions and 30 deletions

View file

@ -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) # Checkpoint — grant kind-editing backend (2026-09-30, session stopped mid-Step-4)
## Where things are ## Where things are

View file

@ -27,6 +27,7 @@ export function SignerScreen() {
signerApprove, signerApprove,
signerGrantsList, signerGrantsList,
signerGrantRevoke, signerGrantRevoke,
signerGrantUpdate,
} = useApp(); } = useApp();
const [status, setStatus] = useState<SignerStatus>(EMPTY_STATUS); const [status, setStatus] = useState<SignerStatus>(EMPTY_STATUS);
const [grants, setGrants] = useState<SignerGrant[]>([]); const [grants, setGrants] = useState<SignerGrant[]>([]);
@ -52,10 +53,15 @@ export function SignerScreen() {
}, []); }, []);
// Poll so approval requests appear without needing a manual refresh, and so // 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(() => { useEffect(() => {
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
void refresh(); void signerStatus()
.then(setStatus)
.catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)));
}, 1000); }, 1000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps // 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<string | null>(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 = () => { const badge = () => {
switch (status.phase) { switch (status.phase) {
case 'connected': case 'connected':
@ -248,21 +289,50 @@ export function SignerScreen() {
</header> </header>
<div className="card-body signer-pending"> <div className="card-body signer-pending">
<p className="hint"> <p className="hint">
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.
</p> </p>
{grants.map((grant) => ( {grants.map((grant) => {
<div key={`${grant.app_pubkey}:${grant.method}`} className="signer-pending-item"> const key = grantKey(grant);
const editing = grantDraft?.key === key;
return (
<div key={key} className="signer-pending-item">
<div className="signer-pending-info"> <div className="signer-pending-info">
<code className="mono signer-pending-method">{grantLabel(grant)}</code> <code className="mono signer-pending-method">{grantLabel(grant)}</code>
<p>for {shortHexId(grant.app_pubkey)}</p> <p>for {shortHexId(grant.app_pubkey)}</p>
{editing && (
<div className="settings-inline signer-grant-edit">
<input
className="signer-grant-kinds"
aria-label="Allowed event kinds"
placeholder="e.g. 1, 30023 (empty = all kinds)"
value={grantDraft.kinds}
onChange={(e) => setGrantDraft({ key, kinds: e.target.value })}
/>
<Button variant="primary" onClick={() => void onSaveKinds(grant)}>
<Icon name="check" size={16} />
Save
</Button>
<Button variant="secondary" onClick={() => setGrantDraft(null)}>
Cancel
</Button>
</div>
)}
{editing && kindError && <ErrorText>{kindError}</ErrorText>}
</div> </div>
<div className="settings-inline"> <div className="settings-inline">
{grant.method === 'sign_event' && !editing && (
<Button variant="secondary" onClick={() => startEditGrant(grant)}>
Edit kinds
</Button>
)}
<Button variant="secondary" onClick={() => void onRevokeGrant(grant)}> <Button variant="secondary" onClick={() => void onRevokeGrant(grant)}>
Revoke Revoke
</Button> </Button>
</div> </div>
</div> </div>
))} );
})}
</div> </div>
</section> </section>
)} )}

View file

@ -176,4 +176,73 @@ describe('SignerScreen', () => {
).toBe(true); ).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(<SignerScreen />);
// 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(<SignerScreen />);
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(<SignerScreen />);
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();
});
}); });

View file

@ -423,12 +423,7 @@ pub async fn serve() -> Result<(), AppError> {
row.nip05 = meta.nip05.clone(); row.nip05 = meta.nip05.clone();
changed = true; changed = true;
} }
let real_name = meta let real_name = profiles::network_display_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 let Some(name) = real_name {
if profiles::is_generic_pairing_label(&row.label) { if profiles::is_generic_pairing_label(&row.label) {
row.label = name.to_string(); row.label = name.to_string();

View file

@ -87,8 +87,14 @@ pub fn create_profile(
// Publish kind 0 metadata event so other clients can see the username/display name. // Publish kind 0 metadata event so other clients can see the username/display name.
// Best-effort: relay failures here never block profile creation. // 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); 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); publish_metadata_blocking(&keys, &label, None, None, relay_urls);
} }
@ -142,6 +148,9 @@ pub fn import_profile(
.as_ref() .as_ref()
.and_then(|m| m.display_name.as_deref().or(m.name.as_deref())) .and_then(|m| m.display_name.as_deref().or(m.name.as_deref()))
.filter(|name| !name.trim().is_empty()) .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) .map(str::to_string)
.unwrap_or_else(|| shorten_npub(&keys.public_key())) .unwrap_or_else(|| shorten_npub(&keys.public_key()))
} else { } else {
@ -162,7 +171,7 @@ pub fn import_profile(
}); });
let relay_urls = relays::enabled_urls(settings); 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( publish_metadata_blocking(
&keys, &keys,
&label, &label,
@ -566,6 +575,23 @@ pub fn is_generic_pairing_label(label: &str) -> bool {
GENERIC_PAIRING_LABELS.contains(&label) 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<String> {
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. /// 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 /// 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("amber"));
assert!(!is_generic_pairing_label("")); 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}; use crate::vault::{KdfParams, Vault, VaultCrypto};
fn populated_vault() -> Vault { fn populated_vault() -> Vault {

View file

@ -2583,13 +2583,13 @@ impl Nip46ClientSigner {
} }
// Upgrade the generic pairing label to the real display // Upgrade the generic pairing label to the real display
// name only while the row still carries the label we set // name only while the row still carries the label we set
// during pairing — a user rename always wins. // during pairing — a user rename always wins. A
let real_name = meta // placeholder coming FROM the network is never a name
.display_name // (network_display_name rejects it): early builds
.as_deref() // published the empty-label default "My Profile" as
.or(meta.name.as_deref()) // kind-0, and copying that over the name the user typed
.map(str::trim) // at pairing is how display names got lost.
.filter(|name| !name.is_empty()); let real_name = profiles::network_display_name(&meta);
if let Some(name) = real_name { if let Some(name) = real_name {
if row.label == pairing_label { if row.label == pairing_label {
row.label = name.to_string(); row.label = name.to_string();