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)
|
# Checkpoint — stdin EAGAIN fix, accent theme, identity backfill (2026-09-28 evening)
|
||||||
|
|
||||||
## Where things are
|
## Where things are
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
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).
|
/// 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> {
|
async fn contact_pubkeys(settings: &Settings, owner_hex: &str) -> Result<Vec<PublicKey>, AppError> {
|
||||||
let owner = owner_pubkey(owner_hex)?;
|
let owner = owner_pubkey(owner_hex)?;
|
||||||
let relay_urls = enabled_relays(settings);
|
let relay_urls = profile_lookup_relays(settings);
|
||||||
if relay_urls.is_empty() {
|
if relay_urls.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
@ -283,6 +286,35 @@ fn enabled_relays(settings: &Settings) -> Vec<String> {
|
||||||
relays::enabled_urls(settings)
|
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.
|
/// Accumulates notes into a bounded, de-duplicated, newest-first feed.
|
||||||
struct FeedBuilder {
|
struct FeedBuilder {
|
||||||
items: HashMap<String, FeedItem>,
|
items: HashMap<String, FeedItem>,
|
||||||
|
|
@ -539,16 +571,49 @@ mod tests {
|
||||||
assert_eq!(items[0].author, followed.public_key().to_hex());
|
assert_eq!(items[0].author, followed.public_key().to_hex());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn contact_feed_with_no_relays_is_empty() {
|
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 {
|
let settings = Settings {
|
||||||
relays: Vec::new(),
|
relays: Vec::new(),
|
||||||
..Default::default()
|
..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
|
.await
|
||||||
.unwrap();
|
.expect_err("an invalid hex owner must error");
|
||||||
assert!(feed.is_empty());
|
assert_eq!(err.kind(), crate::errors::ErrorKind::Internal);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
@ -575,18 +640,6 @@ mod tests {
|
||||||
assert_eq!(err.kind(), crate::errors::ErrorKind::Internal);
|
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]
|
#[test]
|
||||||
fn zero_limit_still_returns_an_empty_feed_without_relays() {
|
fn zero_limit_still_returns_an_empty_feed_without_relays() {
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
|
|
|
||||||
37
src/ipc.rs
37
src/ipc.rs
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
@ -359,10 +369,10 @@ pub async fn serve() -> Result<(), AppError> {
|
||||||
.vault
|
.vault
|
||||||
.profiles
|
.profiles
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| {
|
// Any row still wearing a create/pairing
|
||||||
p.signer_mode == SignerMode::Nip46Client
|
// placeholder gets a real name from the network,
|
||||||
&& profiles::is_generic_pairing_label(&p.label)
|
// whether its key is local or remote.
|
||||||
})
|
.filter(|p| profiles::is_generic_pairing_label(&p.label))
|
||||||
.filter_map(|p| {
|
.filter_map(|p| {
|
||||||
PublicKey::parse(&p.public_key)
|
PublicKey::parse(&p.public_key)
|
||||||
.ok()
|
.ok()
|
||||||
|
|
@ -377,7 +387,9 @@ pub async fn serve() -> Result<(), AppError> {
|
||||||
}
|
}
|
||||||
let relay_urls = {
|
let relay_urls = {
|
||||||
let guard = app.lock().await;
|
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 {
|
for (npub, identity) in pending {
|
||||||
let found = tokio::time::timeout(
|
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 }))
|
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 } => {
|
||||||
|
|
|
||||||
|
|
@ -559,7 +559,7 @@ pub fn find_stored_profile<'a>(
|
||||||
/// never resolved its real identity from the network, so it stays a
|
/// never resolved its real identity from the network, so it stays a
|
||||||
/// candidate for automatic name backfill on every launch. A user rename
|
/// candidate for automatic name backfill on every launch. A user rename
|
||||||
/// always falls outside this list and is therefore never overwritten.
|
/// 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.
|
/// True when `label` is one of the generic pairing placeholders.
|
||||||
pub fn is_generic_pairing_label(label: &str) -> bool {
|
pub fn is_generic_pairing_label(label: &str) -> bool {
|
||||||
|
|
@ -947,13 +947,15 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn generic_pairing_labels_gate_the_backfill() {
|
fn generic_pairing_labels_gate_the_backfill() {
|
||||||
// The background backfill upgrades a row's label ONLY while it still
|
// 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("Amber"));
|
||||||
assert!(is_generic_pairing_label("Remote Signer"));
|
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
|
// Any real name — fetched or user-typed — is never a candidate, so
|
||||||
// automatic enrichment can never overwrite it.
|
// automatic enrichment can never overwrite it.
|
||||||
assert!(!is_generic_pairing_label("satoshi"));
|
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("amber"));
|
||||||
assert!(!is_generic_pairing_label(""));
|
assert!(!is_generic_pairing_label(""));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
58
src/vault.rs
58
src/vault.rs
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue