diff --git a/crates/buzz-relay/src/api/agents.rs b/crates/buzz-relay/src/api/agents.rs deleted file mode 100644 index a7d7be4c0..000000000 --- a/crates/buzz-relay/src/api/agents.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Agent ownership lookup — GET /api/agents/:pubkey/ownership (NIP-98 auth). -//! -//! Returns the relay-authoritative `agent_owner_pubkey` mapping and whether -//! the authenticated caller is the registered owner. Used by the desktop to -//! gate observer activity visibility without relying on channel membership or -//! local managed-agent store state. - -use std::sync::Arc; - -use axum::{ - extract::{Path, State}, - http::{HeaderMap, StatusCode}, - response::Json, -}; -use serde::Serialize; - -use crate::state::AppState; - -use super::bridge::{canonical_url, check_nip98_replay, verify_bridge_auth}; -use super::{api_error, internal_error}; - -/// Response body for the agent-ownership lookup endpoint. -#[derive(Debug, Serialize)] -pub struct AgentOwnershipResponse { - /// Hex-encoded pubkey of the agent whose ownership was queried. - pub agent_pubkey: String, - /// Hex-encoded pubkey of the registered owner, if one is set. - pub owner_pubkey: Option, - /// Whether the authenticated caller is the registered owner of the agent. - pub is_owner: bool, -} - -/// Resolve whether the authenticated user owns `agent_pubkey` per relay DB. -pub async fn get_agent_ownership( - State(state): State>, - headers: HeaderMap, - Path(agent_pubkey): Path, -) -> Result, (StatusCode, Json)> { - let agent_hex = agent_pubkey.trim().to_ascii_lowercase(); - if agent_hex.len() != 64 || !agent_hex.chars().all(|c| c.is_ascii_hexdigit()) { - return Err(api_error(StatusCode::BAD_REQUEST, "invalid agent pubkey")); - } - - let agent_bytes = hex::decode(&agent_hex) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid agent pubkey hex"))?; - - let path = format!("/api/agents/{agent_hex}/ownership"); - let url = canonical_url(&state.config.relay_url, &path); - let (actor_pubkey, event_id_bytes) = - verify_bridge_auth(&headers, "GET", &url, None, state.config.require_auth_token)?; - check_nip98_replay(&state, event_id_bytes)?; - - let actor_bytes = actor_pubkey.to_bytes().to_vec(); - let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); - super::relay_members::enforce_relay_membership(&state, &actor_bytes, auth_tag).await?; - - let owner_pubkey = state - .db - .get_agent_channel_policy(&agent_bytes) - .await - .map_err(|e| internal_error(&format!("ownership lookup failed: {e}")))? - .and_then(|(_policy, owner)| owner); - - let is_owner = state - .db - .is_agent_owner(&agent_bytes, &actor_bytes) - .await - .map_err(|e| internal_error(&format!("ownership check failed: {e}")))?; - - Ok(Json(AgentOwnershipResponse { - agent_pubkey: agent_hex, - owner_pubkey: owner_pubkey.map(hex::encode), - is_owner, - })) -} diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 6d519a162..e7c1b6fd7 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -1,6 +1,5 @@ //! HTTP API — media, git, NIP-05, and the Nostr HTTP bridge. -pub mod agents; pub mod bridge; pub mod events; pub mod git; diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 3c9a1cf93..2837644d2 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -638,6 +638,42 @@ struct AgentObserverRoute { direction: AgentObserverDirection, } +/// Resolve the verified owner of `agent` from its live `kind:0` NIP-OA proof. +/// +/// Reads the agent's latest global `kind:0` profile, extracts the single +/// well-formed `auth` tag, and cryptographically verifies it. Returns the +/// attested owner pubkey on success, or `None` when the agent has no live +/// profile, the profile carries no/invalid `auth` tag, or the author does not +/// match the agent. This is the kind:0 authority the desktop UI gates on; the +/// relay consults it so delivery and visibility agree. +async fn resolve_live_kind0_owner(state: &Arc, agent: &PublicKey) -> Option { + use buzz_core::kind::KIND_PROFILE; + use buzz_db::EventQuery; + + let profile = state + .db + .query_events(&EventQuery { + kinds: Some(vec![KIND_PROFILE as i32]), + authors: Some(vec![agent.to_bytes().to_vec()]), + limit: Some(1), + global_only: true, + ..Default::default() + }) + .await + .ok()? + .into_iter() + .next()?; + + // Defensive: the query filters by author, but never trust ownership of a + // frame to a profile whose signer doesn't match the agent. + if profile.event.pubkey != *agent { + return None; + } + + let auth_tag = buzz_sdk::nip_oa::extract_single_auth_tag_json(&profile.event).ok()?; + buzz_sdk::nip_oa::verify_auth_tag(&auth_tag, agent).ok() +} + /// Handle encrypted agent observer frames (kind 24200). /// /// These frames bypass storage and are routed as global ephemeral events. The @@ -712,29 +748,39 @@ async fn handle_agent_observer_event( let agent_bytes = route.agent.to_bytes().to_vec(); let owner_bytes = route.owner.to_bytes().to_vec(); let cache_key = (agent_bytes.clone(), owner_bytes.clone()); + // Authority order: session NIP-OA fast path → live kind:0 proof → DB cache + // fallback. kind:0 is the single source of truth the desktop UI also gates + // on; the relay defers to it so delivery and visibility agree. The DB column + // (written from NIP-42 AUTH at handlers/auth.rs) is only consulted when the + // agent has no live kind:0 auth tag (BYO/CLI agents that AUTH without + // publishing a profile). When kind:0 *is* present, the DB must not be able to + // contradict it — a kind:0 owner mismatch denies rather than falling through. let is_owner = if session_owner_match { true } else { - match state.observer_owner_cache.get(&cache_key) { - Some(cached) => cached, - None => { - let result = state.db.is_agent_owner(&agent_bytes, &owner_bytes).await; - match result { - Ok(v) => { - state.observer_owner_cache.insert(cache_key, v); - v - } - Err(e) => { - warn!(conn_id = %conn_id, event_id = %event_id_hex, "agent observer owner check failed: {e}"); - conn.send(RelayMessage::ok( - event_id_hex, - false, - "error: internal server error", - )); - return; + match resolve_live_kind0_owner(&state, &route.agent).await { + Some(kind0_owner) => kind0_owner == route.owner, + None => match state.observer_owner_cache.get(&cache_key) { + Some(cached) => cached, + None => { + let result = state.db.is_agent_owner(&agent_bytes, &owner_bytes).await; + match result { + Ok(v) => { + state.observer_owner_cache.insert(cache_key, v); + v + } + Err(e) => { + warn!(conn_id = %conn_id, event_id = %event_id_hex, "agent observer owner check failed: {e}"); + conn.send(RelayMessage::ok( + event_id_hex, + false, + "error: internal server error", + )); + return; + } } } - } + }, } }; if !is_owner { @@ -1001,6 +1047,168 @@ mod tests { assert!(err.contains("NIP-44")); } + /// Tests for `resolve_live_kind0_owner`, the kind:0 authority the relay's + /// observer-frame delivery gate consults before falling back to the DB + /// cache. These mirror the DB-backed harness in `handlers::identity_archive` + /// and no-op gracefully when no Postgres is reachable. + mod live_kind0_owner { + use std::sync::Arc; + + use nostr::{Event, EventBuilder, Keys, Kind, Tag}; + + use crate::handlers::event::resolve_live_kind0_owner; + use crate::state::AppState; + + async fn test_pool() -> Option { + let url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".into()); + sqlx::PgPool::connect(&url).await.ok() + } + + async fn test_state(pool: sqlx::PgPool) -> Option> { + let db = buzz_db::Db::from_pool(pool.clone()); + let config = crate::config::Config::from_env().ok()?; + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(buzz_search::SearchConfig { + url: config.typesense_url.clone(), + api_key: config.typesense_key.clone(), + collection: "events".to_string(), + }); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + let (state, _audit_shutdown) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + Some(Arc::new(state)) + } + + fn auth_tag(owner_keys: &Keys, agent_pubkey: &nostr::PublicKey) -> Tag { + let tag_json = buzz_sdk::nip_oa::compute_auth_tag(owner_keys, agent_pubkey, "") + .expect("compute auth tag"); + buzz_sdk::nip_oa::parse_auth_tag(&tag_json).expect("parse auth tag") + } + + fn profile_event(agent_keys: &Keys, auth_tag: Tag, created_at: u64) -> Event { + EventBuilder::new(Kind::Metadata, "{}") + .tags([auth_tag]) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(agent_keys) + .expect("sign profile") + } + + /// A live kind:0 carrying a valid auth tag yields the attested owner. + #[tokio::test] + async fn resolves_owner_from_live_kind0_auth_tag() { + let Some(pool) = test_pool().await else { + return; + }; + let Some(state) = test_state(pool).await else { + return; + }; + + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key(); + let now = nostr::Timestamp::now().as_secs(); + + let profile = profile_event(&agent_keys, auth_tag(&owner_keys, &agent_pubkey), now); + state + .db + .replace_addressable_event(&profile, None) + .await + .expect("insert agent kind:0"); + + let resolved = resolve_live_kind0_owner(&state, &agent_pubkey).await; + assert_eq!( + resolved, + Some(owner_keys.public_key()), + "live kind:0 auth tag must resolve to the attested owner" + ); + } + + /// When the live kind:0 attests a *different* owner, the resolver returns + /// that owner — the gate then denies (kind:0 mismatch) rather than + /// consulting the DB, so a stale DB row cannot contradict kind:0. + #[tokio::test] + async fn resolves_updated_owner_after_kind0_flip() { + let Some(pool) = test_pool().await else { + return; + }; + let Some(state) = test_state(pool).await else { + return; + }; + + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let new_owner_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key(); + let now = nostr::Timestamp::now().as_secs(); + + let profile = profile_event(&agent_keys, auth_tag(&owner_keys, &agent_pubkey), now); + state + .db + .replace_addressable_event(&profile, None) + .await + .expect("insert agent kind:0"); + + let flipped = + profile_event(&agent_keys, auth_tag(&new_owner_keys, &agent_pubkey), now + 1); + state + .db + .replace_addressable_event(&flipped, None) + .await + .expect("replace agent kind:0"); + + let resolved = resolve_live_kind0_owner(&state, &agent_pubkey).await; + assert_eq!( + resolved, + Some(new_owner_keys.public_key()), + "resolver must reflect the latest kind:0 owner, not the original" + ); + } + + /// An agent with no published kind:0 (BYO/CLI agents that AUTH only) + /// yields None, so the gate falls back to the DB cache. + #[tokio::test] + async fn returns_none_when_agent_has_no_live_kind0() { + let Some(pool) = test_pool().await else { + return; + }; + let Some(state) = test_state(pool).await else { + return; + }; + + // A freshly generated agent with no profile stored. + let agent_keys = Keys::generate(); + let resolved = resolve_live_kind0_owner(&state, &agent_keys.public_key()).await; + assert_eq!( + resolved, None, + "an agent with no live kind:0 must yield None so the gate uses the DB fallback" + ); + } + } + mod fanout_access { use std::collections::HashMap; use std::sync::atomic::AtomicU8; diff --git a/crates/buzz-relay/src/handlers/identity_archive.rs b/crates/buzz-relay/src/handlers/identity_archive.rs index 1e8ed3634..61e5ef270 100644 --- a/crates/buzz-relay/src/handlers/identity_archive.rs +++ b/crates/buzz-relay/src/handlers/identity_archive.rs @@ -248,7 +248,8 @@ async fn verify_owner_consent( target_hex: &str, actor_hex: &str, ) -> Result<(), String> { - let request_auth = extract_single_auth_tag_json(event)?; + let request_auth = buzz_sdk::nip_oa::extract_single_auth_tag_json(event) + .map_err(|e| e.to_string())?; let request_owner = verify_auth_tag_owner(&request_auth, target_hex) .map_err(|e| format!("invalid request auth tag: {e}"))?; if request_owner != actor_hex { @@ -278,7 +279,8 @@ async fn verify_owner_consent( return Err("live kind:0 author did not match target".to_string()); } - let live_auth = extract_single_auth_tag_json(&profile.event)?; + let live_auth = buzz_sdk::nip_oa::extract_single_auth_tag_json(&profile.event) + .map_err(|e| e.to_string())?; let live_owner = verify_auth_tag_owner(&live_auth, target_hex) .map_err(|e| format!("invalid live kind:0 auth tag: {e}"))?; if live_owner != actor_hex { @@ -288,26 +290,6 @@ async fn verify_owner_consent( Ok(()) } -fn extract_single_auth_tag_json(event: &Event) -> Result { - let mut found: Option> = None; - for tag in event.tags.iter() { - let parts = tag.as_slice(); - if parts.first().map(|s| s.as_str()) != Some("auth") { - continue; - } - if parts.len() != 4 { - return Err("auth tag must have exactly four elements".to_string()); - } - if found.is_some() { - return Err("multiple auth tags".to_string()); - } - found = Some(parts.iter().map(|s| s.to_string()).collect()); - } - - let parts = found.ok_or_else(|| "missing auth tag".to_string())?; - serde_json::to_string(&parts).map_err(|e| format!("failed to encode auth tag: {e}")) -} - fn verify_auth_tag_owner(auth_tag_json: &str, target_hex: &str) -> Result { let target_pubkey = PublicKey::from_hex(target_hex).map_err(|e| format!("invalid target pubkey: {e}"))?; diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 703a5b109..226592a07 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -64,10 +64,6 @@ pub fn build_router(state: Arc) -> Router { .route("/events", post(api::bridge::submit_event)) .route("/query", post(api::bridge::query_events)) .route("/count", post(api::bridge::count_events)) - .route( - "/api/agents/{pubkey}/ownership", - get(api::agents::get_agent_ownership), - ) // Webhook trigger (secret-authenticated, no NIP-98) .route("/hooks/{id}", post(api::bridge::workflow_webhook)) // Huddle audio WebSocket route diff --git a/crates/buzz-sdk/src/nip_oa.rs b/crates/buzz-sdk/src/nip_oa.rs index 43f33eb29..ba212f3d8 100644 --- a/crates/buzz-sdk/src/nip_oa.rs +++ b/crates/buzz-sdk/src/nip_oa.rs @@ -23,7 +23,7 @@ use nostr::hashes::sha256::Hash as Sha256Hash; use nostr::hashes::Hash; use nostr::secp256k1::schnorr::Signature; use nostr::secp256k1::Message; -use nostr::{Keys, PublicKey, Tag, SECP256K1}; +use nostr::{Event, Keys, PublicKey, Tag, SECP256K1}; use serde_json::Value; use crate::SdkError; @@ -302,6 +302,48 @@ pub fn parse_auth_tag(json_str: &str) -> Result { .map_err(|e| SdkError::InvalidInput(format!("failed to construct Tag: {e}"))) } +/// Extract exactly one well-formed NIP-OA `auth` tag from an event's tag list, +/// returning it as a JSON-encoded 4-element array string. +/// +/// This is the shared, strict extractor used by both the relay (event ingress, +/// observer-frame delivery gate, identity-archive consent) and desktop's +/// ownership resolution, so they enforce identical structure: +/// +/// - **Exactly one** `auth` tag must be present (zero or more than one is an error). +/// - That tag must have **exactly four** elements (`["auth", owner, conditions, sig]`). +/// +/// No cryptographic verification is performed here — pass the returned JSON to +/// [`verify_auth_tag`] for that. Keeping extraction and verification separate +/// lets callers fail fast on malformed input before doing Schnorr work. +/// +/// # Errors +/// +/// Returns [`SdkError::InvalidInput`] if there are zero `auth` tags, more than +/// one `auth` tag, an `auth` tag without exactly four elements, or the result +/// cannot be JSON-encoded. +pub fn extract_single_auth_tag_json(event: &Event) -> Result { + let mut found: Option> = None; + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(|s| s.as_str()) != Some("auth") { + continue; + } + if parts.len() != 4 { + return Err(SdkError::InvalidInput( + "auth tag must have exactly four elements".into(), + )); + } + if found.is_some() { + return Err(SdkError::InvalidInput("multiple auth tags".into())); + } + found = Some(parts.iter().map(|s| s.to_string()).collect()); + } + + let parts = found.ok_or_else(|| SdkError::InvalidInput("missing auth tag".into()))?; + serde_json::to_string(&parts) + .map_err(|e| SdkError::InvalidInput(format!("failed to encode auth tag: {e}"))) +} + // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -600,4 +642,73 @@ mod tests { serde_json::json!(["auth", OWNER_PUBKEY_HEX, "kind=1&", "a".repeat(128)]).to_string(); assert!(parse_auth_tag(&bad).is_err()); } + + // ── Single auth-tag extraction ──────────────────────────────────────── + + use nostr::{EventBuilder, Kind}; + + /// Build a signed kind:0 event carrying the given raw tags. + fn event_with_tags(tags: Vec) -> Event { + let keys = Keys::generate(); + EventBuilder::new(Kind::Custom(0), "{}") + .tags(tags) + .sign_with_keys(&keys) + .expect("event must sign") + } + + fn auth_tag(owner: &str, conditions: &str, sig: &str) -> Tag { + Tag::parse(["auth", owner, conditions, sig]).expect("auth tag must parse") + } + + #[test] + fn test_extract_single_auth_tag_happy_path() { + let sig = "a".repeat(128); + let event = event_with_tags(vec![auth_tag(OWNER_PUBKEY_HEX, "kind=0", &sig)]); + + let json = extract_single_auth_tag_json(&event).expect("exactly one auth tag"); + let parts: Vec = serde_json::from_str(&json).expect("valid JSON array"); + assert_eq!(parts, vec!["auth", OWNER_PUBKEY_HEX, "kind=0", &sig]); + } + + #[test] + fn test_extract_single_auth_tag_ignores_other_tags() { + let sig = "b".repeat(128); + let event = event_with_tags(vec![ + Tag::parse(["p", AGENT_PUBKEY_HEX]).unwrap(), + auth_tag(OWNER_PUBKEY_HEX, "", &sig), + Tag::parse(["t", "hashtag"]).unwrap(), + ]); + + let json = extract_single_auth_tag_json(&event).expect("exactly one auth tag"); + assert!(json.contains(OWNER_PUBKEY_HEX)); + } + + #[test] + fn test_extract_missing_auth_tag_is_error() { + let event = event_with_tags(vec![Tag::parse(["p", AGENT_PUBKEY_HEX]).unwrap()]); + let err = extract_single_auth_tag_json(&event).expect_err("no auth tag must error"); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn test_extract_multiple_auth_tags_is_error() { + let sig = "c".repeat(128); + let event = event_with_tags(vec![ + auth_tag(OWNER_PUBKEY_HEX, "kind=0", &sig), + auth_tag(AGENT_PUBKEY_HEX, "kind=0", &sig), + ]); + let err = + extract_single_auth_tag_json(&event).expect_err("two auth tags must error"); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn test_extract_wrong_arity_auth_tag_is_error() { + // An `auth` tag with three elements (missing signature) must be rejected. + let event = + event_with_tags(vec![Tag::parse(["auth", OWNER_PUBKEY_HEX, "kind=0"]).unwrap()]); + let err = extract_single_auth_tag_json(&event) + .expect_err("malformed auth tag arity must error"); + assert!(matches!(err, SdkError::InvalidInput(_))); + } } diff --git a/desktop/src-tauri/src/commands/agent_ownership.rs b/desktop/src-tauri/src/commands/agent_ownership.rs deleted file mode 100644 index a607d06d7..000000000 --- a/desktop/src-tauri/src/commands/agent_ownership.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Relay-authoritative agent ownership lookup for activity visibility gates. - -use reqwest::Method; -use serde::Serialize; -use tauri::State; - -use crate::{ - app_state::AppState, - relay::{get_relay_json, relay_api_base_url_with_override}, -}; - -#[derive(Debug, Serialize, serde::Deserialize)] -pub struct AgentOwnershipStatus { - /// Lowercase hex pubkey of the queried agent. - pub agent_pubkey: String, - /// Lowercase hex owner pubkey from relay `agent_owner_pubkey`, if set. - pub owner_pubkey: Option, - /// True iff the current workspace identity is the relay-recorded owner. - pub is_owner: bool, -} - -/// Resolve whether the current identity owns `agent_pubkey` per relay DB. -#[tauri::command] -pub async fn resolve_agent_ownership( - agent_pubkey: String, - state: State<'_, AppState>, -) -> Result { - let agent_hex = agent_pubkey.trim().to_ascii_lowercase(); - if agent_hex.len() != 64 { - return Err("agent pubkey must be 64 hex characters".to_string()); - } - - let api_base = relay_api_base_url_with_override(&state); - let path = format!("/api/agents/{agent_hex}/ownership"); - let url = format!("{api_base}{path}"); - - get_relay_json::(&state, Method::GET, &url, &[]).await -} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index f2f9837dc..a8bce1081 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,6 +1,5 @@ mod agent_discovery; mod agent_models; -mod agent_ownership; mod agent_settings; mod agents; mod canvas; @@ -31,7 +30,6 @@ mod workspace; pub use agent_discovery::*; pub use agent_models::*; -pub use agent_ownership::*; pub use agent_settings::*; pub use agents::*; pub use canvas::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0a5f5e4d7..266733abe 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -749,7 +749,6 @@ pub fn run() { unarchive_identity, list_archived_identities, resolve_oa_owner, - resolve_agent_ownership, list_relay_agents, list_managed_agents, create_managed_agent, diff --git a/desktop/src/shared/api/tauriAgentOwnership.ts b/desktop/src/shared/api/tauriAgentOwnership.ts index f66265ec6..851d401a1 100644 --- a/desktop/src/shared/api/tauriAgentOwnership.ts +++ b/desktop/src/shared/api/tauriAgentOwnership.ts @@ -1,48 +1,33 @@ -import { invokeTauri } from "@/shared/api/tauri"; import { resolveOaOwner } from "@/shared/api/tauriIdentityArchive"; export type AgentOwnershipStatus = { /** Lowercase hex pubkey of the queried agent. */ agentPubkey: string; - /** Lowercase hex owner pubkey from relay `agent_owner_pubkey`, if set. */ + /** Lowercase hex owner pubkey from the agent's live `kind:0` NIP-OA `auth` tag, if any. */ ownerPubkey: string | null; - /** True iff the current workspace identity is the relay-recorded owner. */ + /** True iff the current workspace identity is the verified kind:0 owner. */ isOwner: boolean; }; -type RawAgentOwnershipStatus = { - agent_pubkey: string; - owner_pubkey: string | null; - is_owner: boolean; -}; - /** - * Resolve whether the current identity owns `agentPubkey` per relay DB. - * Authoritative gate for observer activity visibility. + * Resolve whether the current identity owns `agentPubkey`. + * + * Authority is the agent's live `kind:0` NIP-OA `auth` tag, verified locally + * via {@link resolveOaOwner} — the same proof the relay now gates observer-frame + * delivery on. This is a thin wrapper that adapts {@link OwnerOfAgent} to the + * stable {@link AgentOwnershipStatus} shape consumed by `useCanViewAgentActivity` + * and `useChannelAgentSessions`. + * + * An agent with no kind:0, no `auth` tag, or a tag that fails verification + * resolves to `{ ownerPubkey: null, isOwner: false }`. */ export async function resolveAgentOwnership( agentPubkey: string, ): Promise { - try { - const raw = await invokeTauri( - "resolve_agent_ownership", - { agentPubkey }, - ); - return { - agentPubkey: raw.agent_pubkey, - ownerPubkey: raw.owner_pubkey, - isOwner: raw.is_owner, - }; - } catch (error) { - const owner = await resolveOaOwner(agentPubkey).catch(() => null); - if (!owner) { - throw error; - } - - return { - agentPubkey: agentPubkey.toLowerCase(), - ownerPubkey: owner.owner, - isOwner: owner.isMe, - }; - } + const owner = await resolveOaOwner(agentPubkey); + return { + agentPubkey: agentPubkey.toLowerCase(), + ownerPubkey: owner?.owner ?? null, + isOwner: owner?.isMe ?? false, + }; } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index eee786c14..e79636349 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -87,8 +87,6 @@ type E2eConfig = { // - `resetMockRelayMembers` (relayRole) archivedIdentities?: string[]; oaOwnerIsMe?: boolean; - /** Drives `resolve_agent_ownership` for activity visibility gates. */ - agentOwnerIsMe?: boolean; relayRole?: "owner" | "admin" | "member" | null; // Descriptors returned by the mocked `pick_and_upload_media` / // `upload_media_bytes` commands. Lets a spec drive the attachment flow @@ -6654,20 +6652,6 @@ export function maybeInstallE2eTauriMocks() { : "ff".repeat(32); return { owner, is_me: isMe }; } - case "resolve_agent_ownership": { - const agentPubkey = - (payload as { agentPubkey?: string }).agentPubkey?.toLowerCase() ?? - "aa".repeat(32); - const isOwner = activeConfig?.mock?.agentOwnerIsMe ?? false; - const owner = isOwner - ? (identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey) - : "ff".repeat(32); - return { - agent_pubkey: agentPubkey, - owner_pubkey: owner, - is_owner: isOwner, - }; - } case "list_archived_identities": { const archived = activeConfig?.mock?.archivedIdentities ?? []; return { archived };