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)
|
# Checkpoint — grant kind-editing backend (2026-09-30, session stopped mid-Step-4)
|
||||||
|
|
||||||
## Where things are
|
## Where things are
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ 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[]>([]);
|
||||||
|
|
@ -53,15 +52,10 @@ 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. The grants list is
|
// approvals/rejections made elsewhere are reflected here.
|
||||||
// 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 signerStatus()
|
void refresh();
|
||||||
.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
|
||||||
|
|
@ -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 = () => {
|
const badge = () => {
|
||||||
switch (status.phase) {
|
switch (status.phase) {
|
||||||
case 'connected':
|
case 'connected':
|
||||||
|
|
@ -289,50 +248,21 @@ 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. Edit an event-kind scope or revoke one to go back
|
These requests run without asking. Revoke one to go back to approving it every time.
|
||||||
to approving it every time.
|
|
||||||
</p>
|
</p>
|
||||||
{grants.map((grant) => {
|
{grants.map((grant) => (
|
||||||
const key = grantKey(grant);
|
<div key={`${grant.app_pubkey}:${grant.method}`} className="signer-pending-item">
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -176,73 +176,4 @@ 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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -423,7 +423,12 @@ pub async fn serve() -> Result<(), AppError> {
|
||||||
row.nip05 = meta.nip05.clone();
|
row.nip05 = meta.nip05.clone();
|
||||||
changed = true;
|
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 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();
|
||||||
|
|
|
||||||
|
|
@ -87,14 +87,8 @@ 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() && !is_generic_pairing_label(&label) {
|
if !relay_urls.is_empty() {
|
||||||
publish_metadata_blocking(&keys, &label, None, None, relay_urls);
|
publish_metadata_blocking(&keys, &label, None, None, relay_urls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,9 +142,6 @@ 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 {
|
||||||
|
|
@ -171,7 +162,7 @@ pub fn import_profile(
|
||||||
});
|
});
|
||||||
|
|
||||||
let relay_urls = relays::enabled_urls(settings);
|
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(
|
publish_metadata_blocking(
|
||||||
&keys,
|
&keys,
|
||||||
&label,
|
&label,
|
||||||
|
|
@ -575,23 +566,6 @@ 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
|
||||||
|
|
@ -985,26 +959,6 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -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. A
|
// during pairing — a user rename always wins.
|
||||||
// placeholder coming FROM the network is never a name
|
let real_name = meta
|
||||||
// (network_display_name rejects it): early builds
|
.display_name
|
||||||
// published the empty-label default "My Profile" as
|
.as_deref()
|
||||||
// kind-0, and copying that over the name the user typed
|
.or(meta.name.as_deref())
|
||||||
// at pairing is how display names got lost.
|
.map(str::trim)
|
||||||
let real_name = profiles::network_display_name(&meta);
|
.filter(|name| !name.is_empty());
|
||||||
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();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue