Compare commits
4 commits
eea6f0e6b1
...
8070dfc0a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8070dfc0a2 | ||
|
|
d7cf2a5fda | ||
|
|
ec8b515d05 | ||
|
|
aef47dd1ef |
8 changed files with 337 additions and 25 deletions
|
|
@ -1,3 +1,138 @@
|
|||
# Checkpoint — grant kind-editing backend (2026-09-30, session stopped mid-Step-4)
|
||||
|
||||
## Where things are
|
||||
- Project: `/home/avi/Projects/Keynctr`, branch `master` @ **`d7cf2a5`**
|
||||
("feat(signer): grant kind-editing backend (vault + IPC + api plumbing)").
|
||||
Previous: `ec8b515` + `aef47dd` (profile-relay queries, checkpoint above),
|
||||
`eea6f0e` (white launcher icon), `abc2781` (one-click update script).
|
||||
- Working tree: clean for tracked files. Untracked intentionally NOT
|
||||
committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`,
|
||||
`deferred/SignerConnectionPanel.tsx.wip/`.
|
||||
- origin/master behind: local ahead by 4 commits (push still blocked — no
|
||||
Forgejo credentials stored; needs a token via the one-shot extraHeader
|
||||
method).
|
||||
|
||||
## What was completed (this checkpoint)
|
||||
**Grant kind-editing backend — Step 4 remainder, first half** (session was
|
||||
stopped by the user mid-build; the UI half is NOT started):
|
||||
1. `vault.rs`: `Vault::update_signer_grant_kinds(app, method, kinds)` —
|
||||
replaces a standing grant's kind scope, normalised (sorted + deduped).
|
||||
Empty list = "all kinds" (same representation legacy grants use), so
|
||||
broadening is explicit, never from omission. Unknown (app, method)
|
||||
returns false, changes nothing. New unit test
|
||||
`signer_grant_kinds_can_be_edited`.
|
||||
2. `ipc.rs`: `Request::SignerGrantUpdate { app_pubkey, grant_method,
|
||||
grant_kinds }` (field names avoid the internal `method` tag; kinds use
|
||||
`serde(default)` so legacy payloads stay valid) + handler that saves the
|
||||
vault only when a grant actually changed.
|
||||
3. Frontend plumbing only, NO UI yet: `api.signerGrantUpdate`, the
|
||||
AppProvider context type + callback + value/deps lists, and the
|
||||
`signer_grant_update` case in `fakeBackend.ts` mirroring the backend
|
||||
normalisation.
|
||||
|
||||
## Commits added this session (newest first)
|
||||
- (this checkpoint commit)
|
||||
- `d7cf2a5` feat(signer): grant kind-editing backend
|
||||
- `ec8b515` docs(checkpoint): profile-relay queries @ aef47dd
|
||||
- `aef47dd` fix(feed): query profile relays for kind-3 follow lists and
|
||||
kind-0 metadata (WIP of the previous dead session, verified + dedupe bug
|
||||
fixed: trailing-slash normalising via a seen-set)
|
||||
|
||||
## Verification (2026-09-30 @ d7cf2a5)
|
||||
- `cargo test` -> 226 unit passed, 0 failed (e2e 6 green at aef47dd run).
|
||||
- `cargo clippy --all-targets` -> 0 warnings; `cargo fmt --check` clean.
|
||||
- `cargo check` green. NOTE: release binary at target/release/keynectr was
|
||||
built at aef47dd, NOT d7cf2a5 — rebuild before shipping the new RPC.
|
||||
- `frontend`: `npm run typecheck` clean; `npm test` -> 139 passed (19
|
||||
files). No new UI tests yet (no UI yet).
|
||||
|
||||
## How to resume
|
||||
- NEXT UNIT (was next when stopped): the Signer-screen grant editor —
|
||||
inline kind editing on the "Always-allow permissions" card in
|
||||
`frontend/src/screens/SignerScreen.tsx` (grants list, lines ~243-268):
|
||||
edit kinds for a `sign_event` grant -> `signerGrantUpdate(app, method,
|
||||
kinds)`; include an explicit "all kinds" option (empty list), Revoke
|
||||
stays as is. Add `SignerScreen.test.tsx` cases against the fakeBackend
|
||||
`signer_grant_update` case, then the full gates + release rebuild +
|
||||
checkpoint update.
|
||||
- GUI dev loop: `cd frontend && npx vite --port 5173`, then
|
||||
`NOSTR_GUI_DEV_URL=http://localhost:5173 KEYNCTR_ENABLE_GPU=1 npx electron .`
|
||||
- Push when a token is available: `git push origin master` (4 ahead).
|
||||
|
||||
## Outstanding / next steps
|
||||
- Grant editor UI (above) — second half of Step 4 remainder.
|
||||
- Live pass: "My contacts" with the rebuilt backend (aef47dd fix).
|
||||
- Push 4 commits to Forgejo.
|
||||
|
||||
---
|
||||
|
||||
# Checkpoint — profile-relay queries for contacts + metadata backfill (2026-09-30)
|
||||
|
||||
## Where things are
|
||||
- Project: `/home/avi/Projects/Keynctr`, branch `master` @ **`aef47dd`**
|
||||
("fix(feed): query profile relays for kind-3 follow lists and kind-0
|
||||
metadata"). Previous: `eea6f0e` (white monochrome launcher icon),
|
||||
`abc2781` (one-click update script), `7891ccc` (Step 7 rename/hygiene,
|
||||
checkpoint `33ab4fe`).
|
||||
- Working tree: clean for tracked files. Untracked intentionally NOT
|
||||
committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`,
|
||||
`deferred/SignerConnectionPanel.tsx.wip/`.
|
||||
- Release binary rebuilt at `aef47dd` 2026-09-30 14:18 (verified by mtime
|
||||
after `touch`ing the changed sources — not a cache hit).
|
||||
|
||||
## What was completed
|
||||
**Profile-relay lookup for profile-scoped data** (finishes the empty
|
||||
"My contacts" diagnosis on the network side): kind-3 follow lists and
|
||||
kind-0 metadata usually live on profile/outbox relays — purplepag.es
|
||||
aggregates them network-wide — not on the user's note read relays. Now:
|
||||
1. `feed.rs`: `PROFILE_RELAYS = ["wss://purplepag.es"]` plus
|
||||
`profile_lookup_relays(settings)` = enabled relays + profile relays,
|
||||
de-duplicated with trailing-slash normalising (a user entry
|
||||
`wss://purplepag.es/` no longer doubles the always-on entry — the WIP
|
||||
version of this failed its own test; rewritten via a HashSet seen-set).
|
||||
`contact_pubkeys` (kind-3 fetch) now queries that set; notes queries
|
||||
deliberately keep using only enabled relays.
|
||||
2. `ipc.rs` backfill loop: kind-0 fetch also uses `profile_lookup_relays`,
|
||||
and the candidate filter dropped the `SignerMode::Nip46Client`
|
||||
restriction — any row still wearing a placeholder gets a real name,
|
||||
local keys included.
|
||||
3. `profiles.rs`: `GENERIC_PAIRING_LABELS` now includes "My Profile"
|
||||
(`normalise_label`'s empty-input default), so locally created
|
||||
placeholder rows are backfilled too. Test updated: user renames still
|
||||
never overwritten.
|
||||
|
||||
## Commits added this session (newest first)
|
||||
- `aef47dd` fix(feed): query profile relays for kind-3 follow lists and
|
||||
kind-0 metadata (includes the fmt fix + dedupe rewrite of the WIP)
|
||||
- (this checkpoint commit)
|
||||
|
||||
## Verification
|
||||
- `cargo test` -> 225 unit + 6 e2e passed, 0 failed.
|
||||
- `cargo clippy --all-targets` -> 0 warnings. `cargo fmt --check` -> clean.
|
||||
- `cargo build --release` -> rebuilt at aef47dd (binary mtime 14:18).
|
||||
- No frontend files touched -> frontend gates not applicable.
|
||||
|
||||
## How to resume / reproduce
|
||||
- CLI ground truth for the contacts fix:
|
||||
`target/release/keynectr feed --contacts 20` with the active key that
|
||||
has a follow list published anywhere on the network — rows should now
|
||||
appear even when purplepag.es is not in the user's read relays.
|
||||
- GUI: `cd ~/Projects/Keynctr/frontend && npx vite --port 5173` then
|
||||
`NOSTR_GUI_DEV_URL=http://localhost:5173 KEYNCTR_ENABLE_GPU=1 npx electron .`
|
||||
(running windows need a relaunch/rebuild to pick up the new backend).
|
||||
- Backfill trace: `grep -a 'auto-name' ~/Tools/keynctr-debug/pairing-trace.log`.
|
||||
|
||||
## Outstanding / next steps
|
||||
- Live pass: reopen "My contacts" in the GUI with the rebuilt backend and
|
||||
confirm the feed populates for the account that has follow lists on
|
||||
profile relays.
|
||||
- Step 4 permissions UI follow-ups (interactive grant editing in the
|
||||
approval modal) — the only buildable step left from the plan.
|
||||
- Live pass: KDF migration on a real unlock + second-Amber account
|
||||
switching.
|
||||
|
||||
---
|
||||
|
||||
# Checkpoint — stdin EAGAIN fix, accent theme, identity backfill (2026-09-28 evening)
|
||||
|
||||
## Where things are
|
||||
|
|
|
|||
|
|
@ -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<AppState>('delete_profile', { npub }),
|
||||
undoDelete: () => call<AppState>('undo_delete'),
|
||||
|
|
|
|||
|
|
@ -94,6 +94,11 @@ interface AppContextValue {
|
|||
signerApprove: (id: string, approved: boolean, always?: boolean) => Promise<SignerStatus>;
|
||||
signerGrantsList: () => Promise<SignerGrant[]>;
|
||||
signerGrantRevoke: (appPubkey: string, grantMethod: string) => Promise<{ removed: boolean }>;
|
||||
signerGrantUpdate: (
|
||||
appPubkey: string,
|
||||
grantMethod: string,
|
||||
grantKinds: number[],
|
||||
) => Promise<{ updated: boolean }>;
|
||||
deleteProfile: (npub: string) => Promise<AppState>;
|
||||
undoDelete: () => Promise<AppState>;
|
||||
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,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
89
src/feed.rs
89
src/feed.rs
|
|
@ -96,9 +96,12 @@ pub async fn profile_feed(
|
|||
}
|
||||
|
||||
/// Fetch the hex public keys followed by an owner profile (kind 3 contact list).
|
||||
///
|
||||
/// Profile data lives on profile relays, not necessarily on the user's read
|
||||
/// relays, so the query set is the enabled relays plus PROFILE_RELAYS.
|
||||
async fn contact_pubkeys(settings: &Settings, owner_hex: &str) -> Result<Vec<PublicKey>, AppError> {
|
||||
let owner = owner_pubkey(owner_hex)?;
|
||||
let relay_urls = enabled_relays(settings);
|
||||
let relay_urls = profile_lookup_relays(settings);
|
||||
if relay_urls.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
|
@ -283,6 +286,35 @@ fn enabled_relays(settings: &Settings) -> Vec<String> {
|
|||
relays::enabled_urls(settings)
|
||||
}
|
||||
|
||||
/// Relays always consulted for profile-scoped data (kind 0 metadata, kind 3
|
||||
/// follow lists), even when the user has not added them as read relays.
|
||||
///
|
||||
/// Most clients publish profile data to outbox/profile relays (purplepag.es
|
||||
/// aggregates them network-wide) rather than to the user's note relays, so a
|
||||
/// follow-list or metadata query limited to the enabled read relays misses
|
||||
/// real data. Notes queries deliberately keep using only the enabled relays.
|
||||
pub const PROFILE_RELAYS: &[&str] = &["wss://purplepag.es"];
|
||||
|
||||
/// Enabled relays plus the always-on profile relays, de-duplicated.
|
||||
///
|
||||
/// Trailing slashes are normalised away so `wss://purplepag.es/` and
|
||||
/// `wss://purplepag.es` count as the same relay (they are equal to the
|
||||
/// network), both against each other and against the always-on entries.
|
||||
pub fn profile_lookup_relays(settings: &Settings) -> Vec<String> {
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut urls: Vec<String> = Vec::new();
|
||||
for url in enabled_relays(settings)
|
||||
.into_iter()
|
||||
.chain(PROFILE_RELAYS.iter().map(|u| (*u).to_string()))
|
||||
{
|
||||
let normalised = url.trim_end_matches('/').to_string();
|
||||
if seen.insert(normalised.clone()) {
|
||||
urls.push(normalised);
|
||||
}
|
||||
}
|
||||
urls
|
||||
}
|
||||
|
||||
/// Accumulates notes into a bounded, de-duplicated, newest-first feed.
|
||||
struct FeedBuilder {
|
||||
items: HashMap<String, FeedItem>,
|
||||
|
|
@ -539,16 +571,49 @@ mod tests {
|
|||
assert_eq!(items[0].author, followed.public_key().to_hex());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn contact_feed_with_no_relays_is_empty() {
|
||||
#[test]
|
||||
fn profile_lookup_relays_always_include_the_profile_relays() {
|
||||
// With no enabled relays the query set still contains the always-on
|
||||
// profile relays — that is the whole point (follow lists and kind-0
|
||||
// usually live there, not on the user's note relays).
|
||||
let settings = Settings {
|
||||
relays: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
let feed = contact_feed(&settings, DEFAULT_LIMIT, "00".repeat(32).as_str())
|
||||
assert_eq!(
|
||||
profile_lookup_relays(&settings),
|
||||
PROFILE_RELAYS
|
||||
.iter()
|
||||
.map(|u| u.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// Enabled relays pass through, and a profile relay the user already
|
||||
// added is not duplicated (trailing slash included).
|
||||
let settings = Settings {
|
||||
relays: vec![
|
||||
crate::settings::RelayConfig::new("wss://notes.example"),
|
||||
crate::settings::RelayConfig::new(PROFILE_RELAYS[0]),
|
||||
crate::settings::RelayConfig::new(format!("{}/", PROFILE_RELAYS[0])),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let urls = profile_lookup_relays(&settings);
|
||||
assert_eq!(urls.len(), 2, "no duplicate profile relay: {urls:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn contact_feed_with_invalid_owner_errors_before_network() {
|
||||
// Owner parsing happens before any relay work, so an invalid key
|
||||
// errors immediately even though profile relays are always present.
|
||||
let settings = Settings {
|
||||
relays: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
let err = contact_feed(&settings, DEFAULT_LIMIT, "not-hex")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(feed.is_empty());
|
||||
.expect_err("an invalid hex owner must error");
|
||||
assert_eq!(err.kind(), crate::errors::ErrorKind::Internal);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -575,18 +640,6 @@ mod tests {
|
|||
assert_eq!(err.kind(), crate::errors::ErrorKind::Internal);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn contact_feed_with_invalid_owner_errors() {
|
||||
let settings = Settings {
|
||||
relays: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
let err = contact_feed(&settings, DEFAULT_LIMIT, "not-hex")
|
||||
.await
|
||||
.expect_err("an invalid hex owner must error");
|
||||
assert_eq!(err.kind(), crate::errors::ErrorKind::Internal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_limit_still_returns_an_empty_feed_without_relays() {
|
||||
let settings = Settings {
|
||||
|
|
|
|||
37
src/ipc.rs
37
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<u16>,
|
||||
},
|
||||
DeleteProfile {
|
||||
npub: String,
|
||||
},
|
||||
|
|
@ -359,10 +369,10 @@ pub async fn serve() -> Result<(), AppError> {
|
|||
.vault
|
||||
.profiles
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.signer_mode == SignerMode::Nip46Client
|
||||
&& profiles::is_generic_pairing_label(&p.label)
|
||||
})
|
||||
// Any row still wearing a create/pairing
|
||||
// placeholder gets a real name from the network,
|
||||
// whether its key is local or remote.
|
||||
.filter(|p| profiles::is_generic_pairing_label(&p.label))
|
||||
.filter_map(|p| {
|
||||
PublicKey::parse(&p.public_key)
|
||||
.ok()
|
||||
|
|
@ -377,7 +387,9 @@ pub async fn serve() -> Result<(), AppError> {
|
|||
}
|
||||
let relay_urls = {
|
||||
let guard = app.lock().await;
|
||||
relays::enabled_urls(&guard.settings)
|
||||
// Metadata lives on profile relays, not just the user's
|
||||
// read relays — include the always-on profile relays.
|
||||
crate::feed::profile_lookup_relays(&guard.settings)
|
||||
};
|
||||
for (npub, identity) in pending {
|
||||
let found = tokio::time::timeout(
|
||||
|
|
@ -764,6 +776,21 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
|
|||
}
|
||||
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)
|
||||
Request::RelayTest { url } => {
|
||||
|
|
|
|||
|
|
@ -559,7 +559,7 @@ pub fn find_stored_profile<'a>(
|
|||
/// never resolved its real identity from the network, so it stays a
|
||||
/// candidate for automatic name backfill on every launch. A user rename
|
||||
/// always falls outside this list and is therefore never overwritten.
|
||||
pub const GENERIC_PAIRING_LABELS: &[&str] = &["Amber", "Remote Signer"];
|
||||
pub const GENERIC_PAIRING_LABELS: &[&str] = &["Amber", "Remote Signer", "My Profile"];
|
||||
|
||||
/// True when `label` is one of the generic pairing placeholders.
|
||||
pub fn is_generic_pairing_label(label: &str) -> bool {
|
||||
|
|
@ -947,13 +947,15 @@ mod tests {
|
|||
#[test]
|
||||
fn generic_pairing_labels_gate_the_backfill() {
|
||||
// The background backfill upgrades a row's label ONLY while it still
|
||||
// wears one of the placeholders the UI assigns at pairing time.
|
||||
// wears one of the placeholders the UI assigns at pairing or create
|
||||
// time ("My Profile" is normalise_label's empty-input default).
|
||||
assert!(is_generic_pairing_label("Amber"));
|
||||
assert!(is_generic_pairing_label("Remote Signer"));
|
||||
assert!(is_generic_pairing_label("My Profile"));
|
||||
// Any real name — fetched or user-typed — is never a candidate, so
|
||||
// automatic enrichment can never overwrite it.
|
||||
assert!(!is_generic_pairing_label("satoshi"));
|
||||
assert!(!is_generic_pairing_label("My Profile"));
|
||||
assert!(!is_generic_pairing_label("god is decentralized"));
|
||||
assert!(!is_generic_pairing_label("amber"));
|
||||
assert!(!is_generic_pairing_label(""));
|
||||
}
|
||||
|
|
|
|||
58
src/vault.rs
58
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<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.
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue