Compare commits
No commits in common. "d1622a0bf9d9b6c85c29aa8d2a3c9351c12ae66a" and "8070dfc0a246c46ef0917b03579a0ceeb93c11df" have entirely different histories.
d1622a0bf9
...
8070dfc0a2
6 changed files with 30 additions and 260 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ export function SignerScreen() {
|
|||
signerApprove,
|
||||
signerGrantsList,
|
||||
signerGrantRevoke,
|
||||
signerGrantUpdate,
|
||||
} = useApp();
|
||||
const [status, setStatus] = useState<SignerStatus>(EMPTY_STATUS);
|
||||
const [grants, setGrants] = useState<SignerGrant[]>([]);
|
||||
|
|
@ -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<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 = () => {
|
||||
switch (status.phase) {
|
||||
case 'connected':
|
||||
|
|
@ -289,50 +248,21 @@ export function SignerScreen() {
|
|||
</header>
|
||||
<div className="card-body signer-pending">
|
||||
<p className="hint">
|
||||
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.
|
||||
</p>
|
||||
{grants.map((grant) => {
|
||||
const key = grantKey(grant);
|
||||
const editing = grantDraft?.key === key;
|
||||
return (
|
||||
<div key={key} className="signer-pending-item">
|
||||
{grants.map((grant) => (
|
||||
<div key={`${grant.app_pubkey}:${grant.method}`} className="signer-pending-item">
|
||||
<div className="signer-pending-info">
|
||||
<code className="mono signer-pending-method">{grantLabel(grant)}</code>
|
||||
<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 className="settings-inline">
|
||||
{grant.method === 'sign_event' && !editing && (
|
||||
<Button variant="secondary" onClick={() => startEditGrant(grant)}>
|
||||
Edit kinds
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => void onRevokeGrant(grant)}>
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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(<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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
///
|
||||
/// 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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue