From d09c4ec1f089ab6c6de701464424836a4fcf22aa Mon Sep 17 00:00:00 2001 From: Avi Date: Thu, 1 Oct 2026 13:31:36 -0500 Subject: [PATCH] feat(signer): interactive kind scope in the approval prompt (Step 4 finish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Always allow…' on a sign_event request now opens an inline kind editor prefilled with the request's own kind, so the standing grant's scope is chosen while the user sees the event. Approved with grant_kinds, the backend records exactly the edited scope (normalised); without them the fallback stays the request's own kind. Both approval UIs (Signer and Signer Mode) share the parseKindsInput helper; the bunker status JSON now carries pending 'details' so the legacy screen can prefill too. Also fixes a latent bug: AppProvider.signerApprove dropped the 'always' argument, so the legacy 'Always allow' button never actually recorded a grant. --- frontend/src/lib/api.ts | 18 ++- frontend/src/lib/permissions.ts | 17 +++ frontend/src/screens/SignerModeScreen.tsx | 122 ++++++++++++++------ frontend/src/screens/SignerScreen.tsx | 126 +++++++++++++++------ frontend/src/state/AppProvider.tsx | 25 ++++- frontend/src/test/SignerScreen.test.tsx | 129 ++++++++++++++++++++++ frontend/src/test/fakeBackend.ts | 52 ++++++++- frontend/src/test/permissions.test.ts | 18 +++ src/ipc.rs | 17 ++- src/signer/nip46_client.rs | 32 ++++-- 10 files changed, 464 insertions(+), 92 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4aa17f7..6b2de5b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -127,15 +127,25 @@ export const api = { nip46Disconnect: () => call('nip46_disconnect'), nip46CancelPairing: () => call('nip46_cancel_pairing'), nip46Status: () => call('nip46_status'), - nip46Approve: (id: string, approved: boolean, always = false) => - call('nip46_approve', { id, approved, always }), + nip46Approve: (id: string, approved: boolean, always = false, grantKinds?: number[]) => + call('nip46_approve', { + id, + approved, + always, + grant_kinds: grantKinds ?? null, + }), // Legacy NIP-46 bunker (deprecated, kept for compatibility) signerConnect: (uri: string) => call('signer_connect', { uri }), signerDisconnect: () => call('signer_disconnect'), signerStatus: () => call('signer_status'), - signerApprove: (id: string, approved: boolean, always = false) => - call('signer_approve', { id, approved, always }), + signerApprove: (id: string, approved: boolean, always = false, grantKinds?: number[]) => + call('signer_approve', { + id, + approved, + always, + grant_kinds: grantKinds ?? null, + }), // Standing "always allow" grants for apps using us as their signer. signerGrantsList: () => call('signer_grants_list'), diff --git a/frontend/src/lib/permissions.ts b/frontend/src/lib/permissions.ts index aed1d04..814c54a 100644 --- a/frontend/src/lib/permissions.ts +++ b/frontend/src/lib/permissions.ts @@ -50,3 +50,20 @@ export function formatExpiry(expiresAt?: number): string | null { if (Number.isNaN(date.getTime())) return null; return date.toLocaleString(); } + +/** Parse a comma-separated event-kind list typed by the user. + * Returns the kinds (sorted, de-duplicated) or an error naming the first + * non-numeric token. An EMPTY input yields an empty list — the explicit + * "all kinds" broadening, matching the backend's representation. */ +export type KindsParseResult = { ok: true; kinds: number[] } | { ok: false; error: string }; + +export function parseKindsInput(input: string): KindsParseResult { + const parts = input + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + const bad = parts.find((s) => !/^\d+$/.test(s)); + if (bad !== undefined) return { ok: false, error: `“${bad}” is not an event kind number.` }; + const kinds = [...new Set(parts.map(Number))].sort((a, b) => a - b); + return { ok: true, kinds }; +} diff --git a/frontend/src/screens/SignerModeScreen.tsx b/frontend/src/screens/SignerModeScreen.tsx index 51a46c8..f15f7dd 100644 --- a/frontend/src/screens/SignerModeScreen.tsx +++ b/frontend/src/screens/SignerModeScreen.tsx @@ -5,8 +5,13 @@ import { Badge } from '../components/Badge'; import { Button } from '../components/Button'; import { ErrorText } from '../components/ErrorText'; import { Icon } from '../components/Icon'; -import { declaredPermissionRows, formatExpiry } from '../lib/permissions'; -import type { SignerMode, EmbeddedSignerStatus, Nip46SignerStatus } from '../lib/types'; +import { declaredPermissionRows, formatExpiry, parseKindsInput } from '../lib/permissions'; +import type { + SignerMode, + EmbeddedSignerStatus, + Nip46SignerStatus, + PendingApproval, +} from '../lib/types'; import { useApp } from '../state/AppProvider'; export function SignerModeScreen() { @@ -232,10 +237,10 @@ export function SignerModeScreen() { ); const handleNip46Approve = useCallback( - async (id: string, approved: boolean, always = false) => { + async (id: string, approved: boolean, always = false, grantKinds?: number[]) => { setError(null); try { - const status = await nip46Approve(id, approved, always); + const status = await nip46Approve(id, approved, always, grantKinds); setNip46StatusState(status); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -244,6 +249,28 @@ export function SignerModeScreen() { [nip46Approve], ); + // Interactive kind scope at approval time (same pattern as SignerScreen): + // "Always allow…" on a sign_event request opens a kind editor prefilled + // with the request's own kind before the grant is recorded. + const [alwaysDraft, setAlwaysDraft] = useState<{ id: string; kinds: string } | null>(null); + const [alwaysError, setAlwaysError] = useState(null); + + const startAlwaysAllow = (request: PendingApproval) => { + setAlwaysError(null); + setAlwaysDraft({ id: request.id, kinds: String(request.details?.event_kind ?? '') }); + }; + + const onSaveAlwaysAllow = async (request: PendingApproval) => { + if (!alwaysDraft) return; + const parsed = parseKindsInput(alwaysDraft.kinds); + if (!parsed.ok) { + setAlwaysError(parsed.error); + return; + } + await handleNip46Approve(request.id, true, true, parsed.kinds); + setAlwaysDraft(null); + }; + const modeBadge = () => { if (mode === 'nip46_client') { return isNip46Active ? ( @@ -574,34 +601,67 @@ export function SignerModeScreen() { {(nip46StatusState?.pending_approvals?.length ?? 0) > 0 && (

Pending ({nip46StatusState!.pending_approvals!.length})

- {(nip46StatusState!.pending_approvals ?? []).map((r) => ( -
-
- {r.method} -

{r.summary}

+ {(nip46StatusState?.pending_approvals ?? []).map((r) => { + const editingAlways = alwaysDraft?.id === r.id; + return ( +
+
+ {r.method} +

{r.summary}

+ {editingAlways && ( +
+ + setAlwaysDraft({ id: r.id, kinds: e.target.value }) + } + /> + + +
+ )} + {editingAlways && alwaysError && {alwaysError}} +
+
+ + {r.method === 'sign_event' && !editingAlways ? ( + + ) : ( + + )} + +
-
- - - -
-
- ))} + ); + })}
)}
diff --git a/frontend/src/screens/SignerScreen.tsx b/frontend/src/screens/SignerScreen.tsx index 1248028..f4390ec 100644 --- a/frontend/src/screens/SignerScreen.tsx +++ b/frontend/src/screens/SignerScreen.tsx @@ -5,8 +5,8 @@ import { Button } from '../components/Button'; import { ErrorText } from '../components/ErrorText'; import { Icon } from '../components/Icon'; import { shortHexId } from '../lib/format'; -import { grantLabel } from '../lib/permissions'; -import type { SignerGrant, SignerStatus } from '../lib/types'; +import { grantLabel, parseKindsInput } from '../lib/permissions'; +import type { PendingApproval, SignerGrant, SignerStatus } from '../lib/types'; import { useApp } from '../state/AppProvider'; const EMPTY_STATUS: SignerStatus = { @@ -99,10 +99,15 @@ export function SignerScreen() { } }; - const onApprove = async (id: string, approved: boolean, always = false) => { + const onApprove = async ( + id: string, + approved: boolean, + always = false, + grantKinds?: number[], + ) => { setError(null); try { - setStatus(await signerApprove(id, approved, always)); + setStatus(await signerApprove(id, approved, always, grantKinds)); setGrants(await signerGrantsList()); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -127,6 +132,28 @@ export function SignerScreen() { const grantKey = (g: SignerGrant) => `${g.app_pubkey}:${g.method}`; + // Interactive kind scope at APPROVAL time: "Always allow…" on a sign_event + // request opens a kind editor prefilled with the request's own kind, so the + // grant's scope is chosen while the user sees the event, not only after. + const [alwaysDraft, setAlwaysDraft] = useState<{ id: string; kinds: string } | null>(null); + const [alwaysError, setAlwaysError] = useState(null); + + const startAlwaysAllow = (request: PendingApproval) => { + setAlwaysError(null); + setAlwaysDraft({ id: request.id, kinds: String(request.details?.event_kind ?? '') }); + }; + + const onSaveAlwaysAllow = async (request: PendingApproval) => { + if (!alwaysDraft) return; + const parsed = parseKindsInput(alwaysDraft.kinds); + if (!parsed.ok) { + setAlwaysError(parsed.error); + return; + } + await onApprove(request.id, true, true, parsed.kinds); + setAlwaysDraft(null); + }; + const startEditGrant = (grant: SignerGrant) => { setKindError(null); setGrantDraft({ key: grantKey(grant), kinds: (grant.allowed_kinds ?? []).join(', ') }); @@ -134,18 +161,14 @@ export function SignerScreen() { 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.`); + const parsed = parseKindsInput(grantDraft.kinds); + if (!parsed.ok) { + setKindError(parsed.error); return; } setError(null); try { - await signerGrantUpdate(grant.app_pubkey, grant.method, parts.map(Number)); + await signerGrantUpdate(grant.app_pubkey, grant.method, parsed.kinds); setGrants(await signerGrantsList()); setGrantDraft(null); setKindError(null); @@ -252,31 +275,62 @@ export function SignerScreen() { The connected app wants to do the following with the active profile's keys. Review each one before approving it.

- {status.pending.map((request) => ( -
-
- {request.method} -

{request.summary}

+ {status.pending.map((request) => { + const editingAlways = alwaysDraft?.id === request.id; + return ( +
+
+ {request.method} +

{request.summary}

+ {editingAlways && ( +
+ + setAlwaysDraft({ id: request.id, kinds: e.target.value }) + } + /> + + +
+ )} + {editingAlways && alwaysError && {alwaysError}} +
+
+ + {request.method === 'sign_event' && !editingAlways ? ( + + ) : ( + + )} + +
-
- - - -
-
- ))} + ); + })}
)} diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index 0a154f8..ed27939 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -86,12 +86,22 @@ interface AppContextValue { nip46Disconnect: () => Promise; nip46CancelPairing: () => Promise; nip46Status: () => Promise; - nip46Approve: (id: string, approved: boolean, always?: boolean) => Promise; + nip46Approve: ( + id: string, + approved: boolean, + always?: boolean, + grantKinds?: number[], + ) => Promise; // Legacy NIP-46 bunker (deprecated) signerConnect: (uri: string) => Promise; signerDisconnect: () => Promise; signerStatus: () => Promise; - signerApprove: (id: string, approved: boolean, always?: boolean) => Promise; + signerApprove: ( + id: string, + approved: boolean, + always?: boolean, + grantKinds?: number[], + ) => Promise; signerGrantsList: () => Promise; signerGrantRevoke: (appPubkey: string, grantMethod: string) => Promise<{ removed: boolean }>; signerGrantUpdate: ( @@ -301,7 +311,8 @@ export function AppProvider({ children }: { children: ReactNode }) { const nip46Status = useCallback(() => api.nip46Status(), []); const nip46PairStart = useCallback((label: string) => api.nip46PairStart(label), []); const nip46Approve = useCallback( - (id: string, approved: boolean, always = false) => api.nip46Approve(id, approved, always), + (id: string, approved: boolean, always = false, grantKinds?: number[]) => + api.nip46Approve(id, approved, always, grantKinds), [], ); @@ -340,9 +351,11 @@ export function AppProvider({ children }: { children: ReactNode }) { const signerConnect = useCallback((uri: string) => api.signerConnect(uri), []); const signerDisconnect = useCallback(() => api.signerDisconnect(), []); const signerStatus = useCallback(() => api.signerStatus(), []); - const signerApprove = useCallback((id: string, approved: boolean) => { - return api.signerApprove(id, approved); - }, []); + const signerApprove = useCallback( + (id: string, approved: boolean, always = false, grantKinds?: number[]) => + api.signerApprove(id, approved, always, grantKinds), + [], + ); const deleteProfile = useCallback( (npub: string) => applyState(api.deleteProfile(npub)), diff --git a/frontend/src/test/SignerScreen.test.tsx b/frontend/src/test/SignerScreen.test.tsx index bad7146..96ae105 100644 --- a/frontend/src/test/SignerScreen.test.tsx +++ b/frontend/src/test/SignerScreen.test.tsx @@ -245,4 +245,133 @@ describe('SignerScreen', () => { }); expect(await screen.findByText('Sign events — all kinds')).toBeInTheDocument(); }); + + it('edits the kind scope when always-allowing a sign_event request', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + backend.setSigner({ + phase: 'connected', + peer: 'ab12', + relays: ['wss://relay.damus.io'], + connectedRelays: ['wss://relay.damus.io'], + error: null, + pending: [ + { + id: 'req-9', + method: 'sign_event', + summary: 'Sign event kind 1: “Scoped always”', + details: { + method: 'sign_event', + summary: 'Sign event kind 1', + event_kind: 1, + destination_relays: [], + content_preview: 'Scoped always', + is_sensitive: false, + }, + }, + ], + }); + + expect(await screen.findByText(/Scoped always/, {}, { timeout: 3000 })).toBeInTheDocument(); + // sign_event offers the interactive scope editor, prefilled with the + // request's own kind. + await user.click(screen.getByRole('button', { name: /Always allow…/ })); + const input = screen.getByLabelText('Event kinds to always allow'); + expect(input).toHaveValue('1'); + await user.clear(input); + await user.type(input, '1, 30023'); + await user.click(screen.getByRole('button', { name: 'Allow' })); + + await waitFor(() => { + expect( + backend.requests.some( + (r) => + r.method === 'signer_approve' && + r.params?.id === 'req-9' && + r.params?.approved === true && + r.params?.always === true && + JSON.stringify(r.params?.grant_kinds) === '[1,30023]', + ), + ).toBe(true); + }); + expect(backend.signerGrants).toEqual([ + { app_pubkey: 'ab12', method: 'sign_event', allowed_kinds: [1, 30023] }, + ]); + }); + + it('rejects a non-numeric kind in the approval scope editor before calling the backend', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + backend.setSigner({ + phase: 'connected', + peer: 'ab12', + relays: ['wss://relay.damus.io'], + connectedRelays: ['wss://relay.damus.io'], + error: null, + pending: [ + { + id: 'req-10', + method: 'sign_event', + summary: 'Sign event kind 1: “Bad scope”', + details: { + method: 'sign_event', + summary: 'Sign event kind 1', + event_kind: 1, + destination_relays: [], + content_preview: 'Bad scope', + is_sensitive: false, + }, + }, + ], + }); + + await screen.findByText(/Bad scope/, {}, { timeout: 3000 }); + await user.click(screen.getByRole('button', { name: /Always allow…/ })); + const input = screen.getByLabelText('Event kinds to always allow'); + await user.clear(input); + await user.type(input, 'one'); + await user.click(screen.getByRole('button', { name: 'Allow' })); + + expect(await screen.findByText(/one.*not an event kind/)).toBeInTheDocument(); + expect(backend.requests.some((r) => r.method === 'signer_approve')).toBe(false); + }); + + it('non-signing methods keep the plain Always allow button', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + backend.setSigner({ + phase: 'connected', + peer: 'ab12', + relays: ['wss://relay.damus.io'], + connectedRelays: ['wss://relay.damus.io'], + error: null, + pending: [{ id: 'req-11', method: 'nip44_decrypt', summary: 'Decrypt a message' }], + }); + + await screen.findByText('Decrypt a message', {}, { timeout: 3000 }); + // No kind editor for methods without a kind dimension. + expect(screen.queryByRole('button', { name: /Always allow…/ })).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Always allow' })); + + await waitFor(() => { + expect( + backend.requests.some( + (r) => + r.method === 'signer_approve' && r.params?.id === 'req-11' && r.params?.always === true, + ), + ).toBe(true); + }); + expect(backend.signerGrants).toEqual([ + { app_pubkey: 'ab12', method: 'nip44_decrypt', allowed_kinds: [] }, + ]); + }); }); diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index bf47169..ab80789 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -234,8 +234,36 @@ export function createFakeBackend(initial?: AppState): FakeBackend { backend.setNip46(next); return next; } - case 'nip46_approve': - return backend.nip46; + case 'nip46_approve': { + const id = String(params.id ?? ''); + const entry = backend.nip46.pending_approvals?.find((r) => r.id === id); + const next = { + ...backend.nip46, + pending_approvals: (backend.nip46.pending_approvals ?? []).filter((r) => r.id !== id), + }; + backend.setNip46(next); + // Mirrors respond_to_approval_with_always: an always-allow on a + // sign_event grant is kind-scoped — grant_kinds as edited, else the + // kind of the request being approved. + if (params.approved === true && params.always === true && entry) { + const kinds = + (params.grant_kinds as number[] | null | undefined) ?? + (entry.method === 'sign_event' && entry.details?.event_kind != null + ? [entry.details.event_kind] + : []); + const normalised = [...new Set(kinds)].sort((a, b) => a - b); + const peer = backend.nip46.signer_pubkey ?? ''; + if ( + !backend.signerGrants.some((g) => g.app_pubkey === peer && g.method === entry.method) + ) { + backend.signerGrants = [ + ...backend.signerGrants, + { app_pubkey: peer, method: entry.method, allowed_kinds: normalised }, + ]; + } + } + return next; + } case 'create_profile': { const label = String(params.label ?? ''); @@ -411,10 +439,26 @@ export function createFakeBackend(initial?: AppState): FakeBackend { }; backend.setSigner(next); if (params.approved === true && params.always === true && entry) { - if (!backend.signerGrants.some((g) => g.method === entry.method)) { + // Mirrors respond_to_approval_with_always: grant_kinds as edited, + // else the kind of the request being approved (sign_event only). + const kinds = + (params.grant_kinds as number[] | null | undefined) ?? + (entry.method === 'sign_event' && entry.details?.event_kind != null + ? [entry.details.event_kind] + : []); + const normalised = [...new Set(kinds)].sort((a, b) => a - b); + if ( + !backend.signerGrants.some( + (g) => g.app_pubkey === (backend.signer.peer ?? '') && g.method === entry.method, + ) + ) { backend.signerGrants = [ ...backend.signerGrants, - { app_pubkey: backend.signer.peer ?? '', method: entry.method }, + { + app_pubkey: backend.signer.peer ?? '', + method: entry.method, + allowed_kinds: normalised, + }, ]; } } diff --git a/frontend/src/test/permissions.test.ts b/frontend/src/test/permissions.test.ts index d5d6f08..a930d51 100644 --- a/frontend/src/test/permissions.test.ts +++ b/frontend/src/test/permissions.test.ts @@ -3,6 +3,7 @@ import { declaredPermissionRows, formatExpiry, grantLabel, + parseKindsInput, permissionLabel, } from '../lib/permissions'; @@ -51,3 +52,20 @@ describe('formatExpiry', () => { expect(formatExpiry(undefined)).toBeNull(); }); }); + +describe('parseKindsInput', () => { + it('parses, de-duplicates and sorts a kind list', () => { + expect(parseKindsInput('30023, 1,1')).toEqual({ ok: true, kinds: [1, 30023] }); + }); + + it('empty input is the explicit all-kinds list', () => { + expect(parseKindsInput(' , ')).toEqual({ ok: true, kinds: [] }); + expect(parseKindsInput('')).toEqual({ ok: true, kinds: [] }); + }); + + it('names the first non-numeric token', () => { + const r = parseKindsInput('1, hello, 7'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/hello/); + }); +}); diff --git a/src/ipc.rs b/src/ipc.rs index dc0789c..ebce82f 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -187,12 +187,16 @@ pub enum Request { Nip46Status, /// Approve/reject a pending NIP-46 request. `always = true` additionally /// records a standing grant so this peer's future requests of the same - /// method run without prompting. + /// method run without prompting. `grant_kinds` (with `always`) scopes a + /// `sign_event` grant to the given event kinds as edited in the approval + /// UI; `None` falls back to the kind of the request being approved. Nip46Approve { id: String, approved: bool, #[serde(default)] always: bool, + #[serde(default)] + grant_kinds: Option>, }, /// ===== LEGACY NIP-46 BUNKER (server mode) ===== /// Start the NIP-46 remote signer for a `nostrconnect://` link (acting as bunker). @@ -214,6 +218,10 @@ pub enum Request { /// grant for this peer + method. #[serde(default)] always: bool, + /// Kind scope for the grant (with `always`), as edited in the + /// approval UI; `None` falls back to the approved request's kind. + #[serde(default)] + grant_kinds: Option>, }, /// List standing "always allow" grants for apps using us as signer. SignerGrantsList, @@ -561,6 +569,7 @@ fn nip46_status_as_bunker_json(status: &crate::signer::types::Nip46Status) -> se "id": p.id, "method": p.method, "summary": p.summary, + "details": p.details, })).collect::>(), }) } @@ -694,12 +703,13 @@ async fn run(app: &Arc>, request: Request) -> Result { let Some(signer) = ensure_nip46_signer(app).await else { return Err(AppError::config("NIP-46 signer not initialized")); }; signer - .respond_to_approval_with_always(&id, approved, always) + .respond_to_approval_with_always(&id, approved, always, grant_kinds) .await?; let status = signer.status().await; Ok(json!(status)) @@ -743,12 +753,13 @@ async fn run(app: &Arc>, request: Request) -> Result { let guard = app.lock().await; if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() { if let Some(signer) = &guard.nip46_signer { signer - .respond_to_approval_with_always(&id, approved, always) + .respond_to_approval_with_always(&id, approved, always, grant_kinds) .await?; let status = signer.status().await; return Ok(nip46_status_as_bunker_json(&status)); diff --git a/src/signer/nip46_client.rs b/src/signer/nip46_client.rs index 9b220f1..eeb88ca 100644 --- a/src/signer/nip46_client.rs +++ b/src/signer/nip46_client.rs @@ -1404,7 +1404,7 @@ impl Nip46ClientSigner { /// Approve or reject a pending request. pub async fn respond_to_approval(&self, id: &str, approved: bool) -> Result<(), AppError> { - self.respond_to_approval_with_always(id, approved, false) + self.respond_to_approval_with_always(id, approved, false, None) .await } @@ -1416,6 +1416,7 @@ impl Nip46ClientSigner { id: &str, approved: bool, always: bool, + grant_kinds: Option>, ) -> Result<(), AppError> { let (entry, peer_hex) = { let mut inner = self.inner.lock().await; @@ -1433,13 +1434,28 @@ impl Nip46ClientSigner { (entry, peer) }; if approved && always && !peer_hex.is_empty() { - // A "sign_event" always-allow covers only the kinds of the - // request the user actually saw — never other kinds. Other - // gated methods have no kind dimension. - let kinds: Vec = if entry.method == "sign_event" { - entry.details.event_kind.into_iter().collect() - } else { - Vec::new() + // The scope of an always-allow grant: + // - `grant_kinds = Some(list)` — the user edited the scope in the + // approval UI; normalize (sorted + de-duped) and store verbatim. + // An empty Some list broadens to all kinds, the same explicit + // act the grant editor requires, never the result of omission. + // - `grant_kinds = None` — fall back to the request the user + // actually saw: a "sign_event" always-allow covers only that + // event's kind; other gated methods have no kind dimension. + let kinds: Vec = match grant_kinds { + Some(list) => { + let mut normalised = list; + normalised.sort_unstable(); + normalised.dedup(); + normalised + } + None => { + if entry.method == "sign_event" { + entry.details.event_kind.into_iter().collect() + } else { + Vec::new() + } + } }; let mut app = self.app.lock().await; app.vault