From d7cf2a5fda8af37a6aed90ee20ed714906777abb Mon Sep 17 00:00:00 2001 From: Avi Date: Wed, 30 Sep 2026 15:03:05 -0500 Subject: [PATCH] feat(signer): grant kind-editing backend (vault + IPC + api plumbing) Step 4 remainder, first half: Vault::update_signer_grant_kinds replaces a standing grant's kind scope (sorted, deduped; empty = all kinds, a deliberate broadening matching legacy semantics). New signer_grant_update IPC + api/AppProvider/fakeBackend plumbing. The Signer-screen editor UI is the next unit; no UI surface calls the RPC yet. --- frontend/src/lib/api.ts | 6 ++++ frontend/src/state/AppProvider.tsx | 12 +++++++ frontend/src/test/fakeBackend.ts | 19 ++++++++++ src/ipc.rs | 25 +++++++++++++ src/vault.rs | 58 ++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 59b53c1..4aa17f7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -144,6 +144,12 @@ export const api = { app_pubkey: appPubkey, grant_method: grantMethod, }), + signerGrantUpdate: (appPubkey: string, grantMethod: string, grantKinds: number[]) => + call<{ updated: boolean }>('signer_grant_update', { + app_pubkey: appPubkey, + grant_method: grantMethod, + grant_kinds: grantKinds, + }), deleteProfile: (npub: string) => call('delete_profile', { npub }), undoDelete: () => call('undo_delete'), diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index 89fce9e..0a154f8 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -94,6 +94,11 @@ interface AppContextValue { signerApprove: (id: string, approved: boolean, always?: boolean) => Promise; signerGrantsList: () => Promise; signerGrantRevoke: (appPubkey: string, grantMethod: string) => Promise<{ removed: boolean }>; + signerGrantUpdate: ( + appPubkey: string, + grantMethod: string, + grantKinds: number[], + ) => Promise<{ updated: boolean }>; deleteProfile: (npub: string) => Promise; undoDelete: () => Promise; clearLastDeleted: () => void; @@ -305,6 +310,11 @@ export function AppProvider({ children }: { children: ReactNode }) { (appPubkey: string, grantMethod: string) => api.signerGrantRevoke(appPubkey, grantMethod), [], ); + const signerGrantUpdate = useCallback( + (appPubkey: string, grantMethod: string, grantKinds: number[]) => + api.signerGrantUpdate(appPubkey, grantMethod, grantKinds), + [], + ); const setVaultPassword = useCallback( (currentPassword: string | null, newPassword: string) => @@ -398,6 +408,7 @@ export function AppProvider({ children }: { children: ReactNode }) { signerApprove, signerGrantsList, signerGrantRevoke, + signerGrantUpdate, deleteProfile, undoDelete, publishProfileMetadata, @@ -459,6 +470,7 @@ export function AppProvider({ children }: { children: ReactNode }) { signerApprove, signerGrantsList, signerGrantRevoke, + signerGrantUpdate, copyText, ], ); diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index 11b7752..bf47169 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -434,6 +434,25 @@ export function createFakeBackend(initial?: AppState): FakeBackend { return { removed: backend.signerGrants.length < before }; } + case 'signer_grant_update': { + const app = String(params.app_pubkey ?? ''); + const method = String(params.grant_method ?? ''); + // Mirrors Vault::update_signer_grant_kinds: normalise (sort + dedupe) + // and replace the kinds of every matching grant. + const kinds = [...((params.grant_kinds as number[]) ?? [])] + .sort((a, b) => a - b) + .filter((k, i, arr) => i === 0 || k !== arr[i - 1]); + let updated = false; + backend.signerGrants = backend.signerGrants.map((g) => { + if (g.app_pubkey === app && g.method === method) { + updated = true; + return { ...g, allowed_kinds: kinds }; + } + return g; + }); + return { updated }; + } + case 'relay_add': { const url = String(params.url); const nextSettings: Settings = { diff --git a/src/ipc.rs b/src/ipc.rs index d013d97..db5081d 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -223,6 +223,16 @@ pub enum Request { app_pubkey: String, grant_method: String, }, + /// Edit the kind scope of a standing `sign_event` grant (interactive + /// grant editing). `grant_kinds` replaces the covered kinds verbatim + /// after normalising; an EMPTY list means "all kinds", so broadening is + /// a deliberate act, never the result of an omitted field. + SignerGrantUpdate { + app_pubkey: String, + grant_method: String, + #[serde(default)] + grant_kinds: Vec, + }, DeleteProfile { npub: String, }, @@ -766,6 +776,21 @@ async fn run(app: &Arc>, request: Request) -> Result { + let mut guard = app.lock().await; + let updated = + guard + .vault + .update_signer_grant_kinds(&app_pubkey, &grant_method, &grant_kinds); + if updated { + guard.save_vault()?; + } + Ok(json!({ "updated": updated })) + } // Network-only requests (no shared state lock) Request::RelayTest { url } => { diff --git a/src/vault.rs b/src/vault.rs index 0791f26..80db15b 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -260,6 +260,33 @@ impl Vault { Ok(()) } + /// Replace the kind scope of an existing grant (interactive grant + /// editing). Returns whether a matching grant was updated. + /// + /// `allowed_kinds` is normalised (sorted, de-duplicated). An EMPTY list + /// means "all kinds" — the same semantics a legacy grant carries — so + /// broadening to all kinds is a deliberate editor action, never the + /// result of omission. Grants for non-signing methods always keep an + /// empty list; editing one is a no-op on the kinds field by construction. + pub fn update_signer_grant_kinds( + &mut self, + app_pubkey: &str, + method: &str, + allowed_kinds: &[u16], + ) -> bool { + let mut normalised: Vec = allowed_kinds.to_vec(); + normalised.sort_unstable(); + normalised.dedup(); + let mut found = false; + for g in self.signer_grants.iter_mut() { + if g.app_pubkey == app_pubkey && g.method == method { + g.allowed_kinds = normalised.clone(); + found = true; + } + } + found + } + /// Drop a standing grant; returns whether one was removed. pub fn revoke_signer_grant(&mut self, app_pubkey: &str, method: &str) -> bool { let before = self.signer_grants.len(); @@ -877,6 +904,37 @@ mod tests { assert!(vault.has_signer_grant("aa", "nip44_decrypt", None)); } + #[test] + fn signer_grant_kinds_can_be_edited() { + let mut vault = Vault::empty(); + vault.grant_signer_method("aa", "sign_event", &[1]).unwrap(); + vault + .grant_signer_method("aa", "nip44_decrypt", &[]) + .unwrap(); + + // Narrowing: add kind 30023 (normalised: sorted + de-duplicated). + assert!(vault.update_signer_grant_kinds("aa", "sign_event", &[30023, 1, 1])); + assert!(vault.has_signer_grant("aa", "sign_event", Some(1))); + assert!(vault.has_signer_grant("aa", "sign_event", Some(30023))); + assert!(!vault.has_signer_grant("aa", "sign_event", Some(6))); + let g = vault + .signer_grants + .iter() + .find(|g| g.method == "sign_event") + .unwrap(); + assert_eq!(g.allowed_kinds, vec![1, 30023]); + + // Editing an unknown (app, method) reports false and changes nothing. + assert!(!vault.update_signer_grant_kinds("zz", "sign_event", &[1])); + assert_eq!(vault.signer_grants.len(), 2); + + // Explicitly broadening to ALL kinds is the empty list — the same + // representation legacy grants use. + assert!(vault.update_signer_grant_kinds("aa", "sign_event", &[])); + assert!(vault.has_signer_grant("aa", "sign_event", Some(6))); + assert!(vault.has_signer_grant("aa", "sign_event", None)); + } + static COUNTER: AtomicU32 = AtomicU32::new(0); fn temp_vault_path() -> PathBuf {