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.
This commit is contained in:
Avi 2026-09-30 15:03:05 -05:00
commit d7cf2a5fda
5 changed files with 120 additions and 0 deletions

View file

@ -144,6 +144,12 @@ export const api = {
app_pubkey: appPubkey, app_pubkey: appPubkey,
grant_method: grantMethod, 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<AppState>('delete_profile', { npub }), deleteProfile: (npub: string) => call<AppState>('delete_profile', { npub }),
undoDelete: () => call<AppState>('undo_delete'), undoDelete: () => call<AppState>('undo_delete'),

View file

@ -94,6 +94,11 @@ interface AppContextValue {
signerApprove: (id: string, approved: boolean, always?: boolean) => Promise<SignerStatus>; signerApprove: (id: string, approved: boolean, always?: boolean) => Promise<SignerStatus>;
signerGrantsList: () => Promise<SignerGrant[]>; signerGrantsList: () => Promise<SignerGrant[]>;
signerGrantRevoke: (appPubkey: string, grantMethod: string) => Promise<{ removed: boolean }>; signerGrantRevoke: (appPubkey: string, grantMethod: string) => Promise<{ removed: boolean }>;
signerGrantUpdate: (
appPubkey: string,
grantMethod: string,
grantKinds: number[],
) => Promise<{ updated: boolean }>;
deleteProfile: (npub: string) => Promise<AppState>; deleteProfile: (npub: string) => Promise<AppState>;
undoDelete: () => Promise<AppState>; undoDelete: () => Promise<AppState>;
clearLastDeleted: () => void; clearLastDeleted: () => void;
@ -305,6 +310,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
(appPubkey: string, grantMethod: string) => api.signerGrantRevoke(appPubkey, grantMethod), (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( const setVaultPassword = useCallback(
(currentPassword: string | null, newPassword: string) => (currentPassword: string | null, newPassword: string) =>
@ -398,6 +408,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
signerApprove, signerApprove,
signerGrantsList, signerGrantsList,
signerGrantRevoke, signerGrantRevoke,
signerGrantUpdate,
deleteProfile, deleteProfile,
undoDelete, undoDelete,
publishProfileMetadata, publishProfileMetadata,
@ -459,6 +470,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
signerApprove, signerApprove,
signerGrantsList, signerGrantsList,
signerGrantRevoke, signerGrantRevoke,
signerGrantUpdate,
copyText, copyText,
], ],
); );

View file

@ -434,6 +434,25 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
return { removed: backend.signerGrants.length < before }; 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': { case 'relay_add': {
const url = String(params.url); const url = String(params.url);
const nextSettings: Settings = { const nextSettings: Settings = {

View file

@ -223,6 +223,16 @@ pub enum Request {
app_pubkey: String, app_pubkey: String,
grant_method: 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<u16>,
},
DeleteProfile { DeleteProfile {
npub: String, npub: String,
}, },
@ -766,6 +776,21 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
} }
Ok(json!({ "removed": removed })) Ok(json!({ "removed": removed }))
} }
Request::SignerGrantUpdate {
app_pubkey,
grant_method,
grant_kinds,
} => {
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) // Network-only requests (no shared state lock)
Request::RelayTest { url } => { Request::RelayTest { url } => {

View file

@ -260,6 +260,33 @@ impl Vault {
Ok(()) 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<u16> = 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. /// Drop a standing grant; returns whether one was removed.
pub fn revoke_signer_grant(&mut self, app_pubkey: &str, method: &str) -> bool { pub fn revoke_signer_grant(&mut self, app_pubkey: &str, method: &str) -> bool {
let before = self.signer_grants.len(); let before = self.signer_grants.len();
@ -877,6 +904,37 @@ mod tests {
assert!(vault.has_signer_grant("aa", "nip44_decrypt", None)); 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); static COUNTER: AtomicU32 = AtomicU32::new(0);
fn temp_vault_path() -> PathBuf { fn temp_vault_path() -> PathBuf {