Skip to main content

git_remote_htree/
nostr_client.rs

1//! Nostr client for publishing and fetching git repository references
2//!
3//! Uses kind 30078 (application-specific data) with hashtree structure:
4//! {
5//!   "kind": 30078,
6//!   "tags": [
7//!     ["d", "<repo-name>"],
8//!     ["l", "hashtree"]
9//!   ],
10//!   "content": "<merkle-root-hash>"
11//! }
12//!
13//! The merkle tree contains:
14//!   root/
15//!     refs/heads/main -> <sha>
16//!     refs/tags/v1.0 -> <sha>
17//!     objects/<sha1> -> data
18//!     objects/<sha2> -> data
19//!
20//! ## Identity file format
21//!
22//! The secrets file (`~/.hashtree/keys`) supports multiple signing keys with optional
23//! petnames:
24//! ```text
25//! nsec1... default
26//! nsec1... work
27//! nsec1... personal
28//! ```
29//!
30//! Or hex format:
31//! ```text
32//! <64-char-hex> default
33//! <64-char-hex> work
34//! ```
35//!
36//! Public read-only aliases can be stored in `~/.hashtree/aliases`:
37//! ```text
38//! npub1... sirius
39//! npub1... coworker
40//! ```
41//!
42//! For compatibility, public aliases in `~/.hashtree/keys` are also accepted.
43//!
44//! Then use: `htree://work/myrepo` or `htree://npub1.../myrepo`
45
46mod identity;
47mod repo_metadata;
48
49use crate::runtime::block_on_result;
50use anyhow::{Context, Result};
51use futures::{SinkExt, StreamExt};
52use hashtree_blossom::BlossomClient;
53use hashtree_core::{decode_tree_node, decrypt_chk, Cid, LinkType, TreeNode};
54use nostr_sdk::prelude::*;
55use serde::Deserialize;
56use std::collections::HashMap;
57use std::time::Duration;
58use tokio_tungstenite::{connect_async, tungstenite::Message as WsMessage};
59use tracing::{debug, info, warn};
60
61pub use identity::{
62    load_key_lists, load_keys, resolve_identity, resolve_self_identity, StoredKey, StoredKeyLists,
63};
64#[cfg(test)]
65use repo_metadata::pick_latest_event;
66use repo_metadata::{
67    append_repo_discovery_labels, build_git_repo_list_filter, build_repo_event_filter,
68    latest_repo_event_created_at, latest_trusted_pr_status_kinds, list_git_repo_announcements,
69    next_replaceable_created_at, pick_latest_repo_event, validate_repo_publish_relays,
70};
71
72/// Event kind for application-specific data (NIP-78)
73pub const KIND_APP_DATA: u16 = 30078;
74
75/// NIP-34 event kinds
76pub const KIND_PULL_REQUEST: u16 = 1618;
77pub const KIND_STATUS_OPEN: u16 = 1630;
78pub const KIND_STATUS_APPLIED: u16 = 1631;
79pub const KIND_STATUS_CLOSED: u16 = 1632;
80pub const KIND_STATUS_DRAFT: u16 = 1633;
81pub const KIND_REPO_ANNOUNCEMENT: u16 = 30617;
82
83/// Label for hashtree events
84pub const LABEL_HASHTREE: &str = "hashtree";
85pub const LABEL_GIT: &str = "git";
86
87/// Pull request status derived from trusted NIP-34 status events.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum PullRequestState {
90    Open,
91    Applied,
92    Closed,
93    Draft,
94}
95
96impl PullRequestState {
97    pub fn as_str(self) -> &'static str {
98        match self {
99            PullRequestState::Open => "open",
100            PullRequestState::Applied => "applied",
101            PullRequestState::Closed => "closed",
102            PullRequestState::Draft => "draft",
103        }
104    }
105
106    fn from_status_kind(status_kind: u16) -> Option<Self> {
107        match status_kind {
108            KIND_STATUS_OPEN => Some(PullRequestState::Open),
109            KIND_STATUS_APPLIED => Some(PullRequestState::Applied),
110            KIND_STATUS_CLOSED => Some(PullRequestState::Closed),
111            KIND_STATUS_DRAFT => Some(PullRequestState::Draft),
112            _ => None,
113        }
114    }
115
116    fn from_latest_status_kind(status_kind: Option<u16>) -> Self {
117        status_kind
118            .and_then(Self::from_status_kind)
119            .unwrap_or(PullRequestState::Open)
120    }
121}
122
123/// Filter used when listing PRs.
124#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
125pub enum PullRequestStateFilter {
126    #[default]
127    Open,
128    Applied,
129    Closed,
130    Draft,
131    All,
132}
133
134impl PullRequestStateFilter {
135    pub fn as_str(self) -> &'static str {
136        match self {
137            PullRequestStateFilter::Open => "open",
138            PullRequestStateFilter::Applied => "applied",
139            PullRequestStateFilter::Closed => "closed",
140            PullRequestStateFilter::Draft => "draft",
141            PullRequestStateFilter::All => "all",
142        }
143    }
144
145    fn includes(self, state: PullRequestState) -> bool {
146        match self {
147            PullRequestStateFilter::All => true,
148            PullRequestStateFilter::Open => state == PullRequestState::Open,
149            PullRequestStateFilter::Applied => state == PullRequestState::Applied,
150            PullRequestStateFilter::Closed => state == PullRequestState::Closed,
151            PullRequestStateFilter::Draft => state == PullRequestState::Draft,
152        }
153    }
154}
155
156/// PR metadata used by listing/filtering consumers.
157#[derive(Debug, Clone)]
158pub struct PullRequestListItem {
159    pub event_id: String,
160    pub author_pubkey: String,
161    pub state: PullRequestState,
162    pub subject: Option<String>,
163    pub commit_tip: Option<String>,
164    pub branch: Option<String>,
165    pub target_branch: Option<String>,
166    pub created_at: u64,
167}
168
169async fn fetch_events_via_raw_relay_query(
170    relays: &[String],
171    filter: Filter,
172    timeout: Duration,
173) -> Vec<Event> {
174    let request_json = ClientMessage::req(SubscriptionId::generate(), vec![filter]).as_json();
175    let mut events_by_id = HashMap::<String, Event>::new();
176
177    for relay_url in relays {
178        let relay_events = match tokio::time::timeout(timeout, async {
179            let (mut ws, _) = connect_async(relay_url).await?;
180            ws.send(WsMessage::Text(request_json.clone())).await?;
181
182            let mut relay_events = Vec::new();
183            while let Some(message) = ws.next().await {
184                let message = message?;
185                let WsMessage::Text(text) = message else {
186                    continue;
187                };
188
189                match RelayMessage::from_json(text.as_str()) {
190                    Ok(RelayMessage::Event { event, .. }) => relay_events.push(event.into_owned()),
191                    Ok(RelayMessage::EndOfStoredEvents(_)) => break,
192                    Ok(RelayMessage::Closed { message, .. }) => {
193                        debug!("Raw relay PR query closed by {}: {}", relay_url, message);
194                        break;
195                    }
196                    Ok(_) => {}
197                    Err(err) => {
198                        debug!(
199                            "Failed to parse raw relay response from {}: {}",
200                            relay_url, err
201                        );
202                    }
203                }
204            }
205
206            let _ = ws.close(None).await;
207            Ok::<Vec<Event>, anyhow::Error>(relay_events)
208        })
209        .await
210        {
211            Ok(Ok(events)) => events,
212            Ok(Err(err)) => {
213                debug!("Raw relay PR query failed for {}: {}", relay_url, err);
214                continue;
215            }
216            Err(_) => {
217                debug!("Raw relay PR query timed out for {}", relay_url);
218                continue;
219            }
220        };
221
222        for event in relay_events {
223            events_by_id.insert(event.id.to_hex(), event);
224        }
225    }
226
227    events_by_id.into_values().collect()
228}
229
230async fn connected_relay_count(client: &Client) -> (usize, usize) {
231    let relays = client.relays().await;
232    let total = relays.len();
233    let mut connected = 0;
234    for relay in relays.values() {
235        if relay.is_connected() {
236            connected += 1;
237        }
238    }
239    (connected, total)
240}
241
242async fn wait_for_any_connected_relay(client: &Client, timeout: Duration) -> bool {
243    let start = std::time::Instant::now();
244    loop {
245        if connected_relay_count(client).await.0 > 0 {
246            return true;
247        }
248        if start.elapsed() > timeout {
249            return false;
250        }
251        tokio::time::sleep(Duration::from_millis(50)).await;
252    }
253}
254
255type FetchedRefs = (HashMap<String, String>, Option<String>, Option<[u8; 32]>);
256
257#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
258enum RootResolveSource {
259    #[default]
260    Relay,
261    LocalDaemon,
262}
263
264#[derive(Debug, Clone)]
265struct ResolvedRoot {
266    root_hash: Option<String>,
267    encryption_key: Option<[u8; 32]>,
268    source: Option<RootResolveSource>,
269}
270use hashtree_config::Config;
271
272/// Result of publishing to relays
273#[derive(Debug, Clone)]
274pub struct RelayResult {
275    /// Relays that were configured
276    #[allow(dead_code)]
277    pub configured: Vec<String>,
278    /// Relays that connected
279    pub connected: Vec<String>,
280    /// Relays that failed to connect
281    pub failed: Vec<String>,
282}
283
284/// Result of uploading to blossom servers
285#[derive(Debug, Clone)]
286pub struct BlossomResult {
287    /// Servers that were configured
288    #[allow(dead_code)]
289    pub configured: Vec<String>,
290    /// Servers that accepted uploads
291    pub succeeded: Vec<String>,
292    /// Servers that failed
293    pub failed: Vec<String>,
294    /// Whether the repo tree is complete in the local hashtree store.
295    pub local_complete: bool,
296    /// Whether remote replication was incomplete but the local push can still be published.
297    pub degraded: bool,
298}
299
300/// Nostr client for git operations
301pub struct NostrClient {
302    pubkey: String,
303    /// nostr-sdk Keys for signing
304    keys: Option<Keys>,
305    relays: Vec<String>,
306    blossom: BlossomClient,
307    /// Cached refs from remote
308    cached_refs: HashMap<String, HashMap<String, String>>,
309    /// Cached root hashes (hashtree SHA256)
310    cached_root_hash: HashMap<String, String>,
311    /// Cached encryption keys
312    cached_encryption_key: HashMap<String, [u8; 32]>,
313    /// Source of the cached root hash, used to retry tentative daemon roots via relays.
314    cached_root_source: HashMap<String, RootResolveSource>,
315    /// URL secret for link-visible repos (#k=<hex>)
316    /// If set, encryption keys from nostr are XOR-masked and need unmasking
317    url_secret: Option<[u8; 32]>,
318    /// Whether this is a private (author-only) repo using NIP-44 encryption
319    is_private: bool,
320    /// Local htree daemon URL for peer-assisted root discovery
321    local_daemon_url: Option<String>,
322}
323
324#[derive(Debug, Clone, Default)]
325struct RootEventData {
326    root_hash: String,
327    encryption_key: Option<[u8; 32]>,
328    key_tag_name: Option<String>,
329    self_encrypted_ciphertext: Option<String>,
330    source: RootResolveSource,
331}
332
333#[derive(Debug, Deserialize)]
334struct DaemonResolveResponse {
335    hash: Option<String>,
336    #[serde(default)]
337    cid: Option<String>,
338    #[serde(default, rename = "key_tag")]
339    key: Option<String>,
340    #[serde(default, rename = "encryptedKey")]
341    encrypted_key: Option<String>,
342    #[serde(default, rename = "selfEncryptedKey")]
343    self_encrypted_key: Option<String>,
344    #[serde(default)]
345    source: Option<String>,
346}
347
348impl NostrClient {
349    /// Create a new client with pubkey, optional secret key, url secret, is_private flag, and config
350    pub fn new(
351        pubkey: &str,
352        secret_key: Option<String>,
353        url_secret: Option<[u8; 32]>,
354        is_private: bool,
355        config: &Config,
356    ) -> Result<Self> {
357        // Ensure rustls has a process-wide crypto provider even when used as a library (tests).
358        let _ = rustls::crypto::ring::default_provider().install_default();
359
360        // Use provided secret, or try environment variable
361        let secret_key = secret_key.or_else(|| std::env::var("NOSTR_SECRET_KEY").ok());
362
363        // Create nostr-sdk Keys if we have a secret
364        let keys = if let Some(ref secret_hex) = secret_key {
365            let secret_bytes = hex::decode(secret_hex).context("Invalid secret key hex")?;
366            let secret = nostr::SecretKey::from_slice(&secret_bytes)
367                .map_err(|e| anyhow::anyhow!("Invalid secret key: {}", e))?;
368            Some(Keys::new(secret))
369        } else {
370            None
371        };
372
373        // Create BlossomClient (needs keys for upload auth) from the resolved
374        // config passed by the helper instead of reloading defaults from disk.
375        let blossom_keys = keys.clone().unwrap_or_else(Keys::generate);
376        let mut read_servers = config.blossom.all_read_servers();
377        if let Some(local_url) =
378            hashtree_config::detect_local_daemon_url(Some(config.server.bind_address.as_str()))
379        {
380            if !read_servers.iter().any(|server| server == &local_url) {
381                read_servers.insert(0, local_url);
382            }
383        }
384        let blossom = BlossomClient::new_empty(blossom_keys)
385            .with_read_servers(read_servers)
386            .with_write_servers(config.blossom.all_write_servers())
387            .with_timeout(Duration::from_secs(30));
388
389        tracing::info!(
390            "BlossomClient created with read_servers: {:?}, write_servers: {:?}",
391            blossom.read_servers(),
392            blossom.write_servers()
393        );
394
395        let relays = hashtree_config::resolve_relays(
396            &config.nostr.relays,
397            Some(config.server.bind_address.as_str()),
398        );
399        let local_daemon_url =
400            hashtree_config::detect_local_daemon_url(Some(config.server.bind_address.as_str()))
401                .or_else(|| {
402                    config
403                        .blossom
404                        .read_servers
405                        .iter()
406                        .find(|url| {
407                            url.starts_with("http://127.0.0.1:")
408                                || url.starts_with("http://localhost:")
409                        })
410                        .cloned()
411                });
412
413        Ok(Self {
414            pubkey: pubkey.to_string(),
415            keys,
416            relays,
417            blossom,
418            cached_refs: HashMap::new(),
419            cached_root_hash: HashMap::new(),
420            cached_encryption_key: HashMap::new(),
421            cached_root_source: HashMap::new(),
422            url_secret,
423            is_private,
424            local_daemon_url,
425        })
426    }
427
428    fn format_repo_author(pubkey_hex: &str) -> String {
429        PublicKey::from_hex(pubkey_hex)
430            .ok()
431            .and_then(|pk| pk.to_bech32().ok())
432            .unwrap_or_else(|| pubkey_hex.to_string())
433    }
434
435    fn daemon_pubkey_identifier(&self) -> String {
436        Self::format_repo_author(&self.pubkey)
437    }
438
439    /// Check if we can sign (have secret key for this pubkey)
440    #[allow(dead_code)]
441    pub fn can_sign(&self) -> bool {
442        self.keys.is_some()
443    }
444
445    pub fn list_repos(&self) -> Result<Vec<String>> {
446        block_on_result(self.list_repos_async())
447    }
448
449    pub async fn list_repos_async(&self) -> Result<Vec<String>> {
450        let client = Client::default();
451
452        for relay in &self.relays {
453            if let Err(e) = client.add_relay(relay).await {
454                warn!("Failed to add relay {}: {}", relay, e);
455            }
456        }
457        client.connect().await;
458
459        if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
460            let _ = client.disconnect().await;
461            return Err(anyhow::anyhow!(
462                "Failed to connect to any relay while listing repos"
463            ));
464        }
465
466        let author = PublicKey::from_hex(&self.pubkey)
467            .map_err(|e| anyhow::anyhow!("Invalid pubkey: {}", e))?;
468        let filter = build_git_repo_list_filter(author);
469
470        let events = match tokio::time::timeout(
471            Duration::from_secs(3),
472            client.fetch_events(filter, Duration::from_secs(3)),
473        )
474        .await
475        {
476            Ok(Ok(events)) => events,
477            Ok(Err(e)) => {
478                let _ = client.disconnect().await;
479                return Err(anyhow::anyhow!(
480                    "Failed to fetch git repo events from relays: {}",
481                    e
482                ));
483            }
484            Err(_) => {
485                let _ = client.disconnect().await;
486                return Err(anyhow::anyhow!(
487                    "Timed out fetching git repo events from relays"
488                ));
489            }
490        };
491
492        let _ = client.disconnect().await;
493        let events = events.to_vec();
494
495        Ok(list_git_repo_announcements(&events)
496            .into_iter()
497            .map(|repo| repo.repo_name)
498            .collect())
499    }
500
501    /// Fetch refs for a repository from nostr
502    /// Returns refs parsed from the hashtree at the root hash
503    pub fn fetch_refs(&mut self, repo_name: &str) -> Result<HashMap<String, String>> {
504        let (refs, _, _) = self.fetch_refs_with_timeout(repo_name, 10)?;
505        Ok(refs)
506    }
507
508    /// Fetch refs with a quick timeout (3s) for push operations
509    /// Returns empty if timeout - allows push to proceed
510    #[allow(dead_code)]
511    pub fn fetch_refs_quick(&mut self, repo_name: &str) -> Result<HashMap<String, String>> {
512        let (refs, _, _) = self.fetch_refs_with_timeout(repo_name, 3)?;
513        Ok(refs)
514    }
515
516    /// Fetch refs and root hash info from nostr
517    /// Returns (refs, root_hash, encryption_key)
518    #[allow(dead_code)]
519    pub fn fetch_refs_with_root(&mut self, repo_name: &str) -> Result<FetchedRefs> {
520        self.fetch_refs_with_timeout(repo_name, 10)
521    }
522
523    pub(crate) fn refetch_refs_without_local_daemon(
524        &mut self,
525        repo_name: &str,
526        timeout_secs: u64,
527    ) -> Result<FetchedRefs> {
528        self.clear_cached_remote_state(repo_name);
529        self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, false)
530    }
531
532    fn clear_cached_remote_state(&mut self, repo_name: &str) {
533        self.cached_refs.remove(repo_name);
534        self.cached_root_hash.remove(repo_name);
535        self.cached_encryption_key.remove(repo_name);
536        self.cached_root_source.remove(repo_name);
537    }
538
539    fn cache_resolved_root(&mut self, repo_name: &str, resolved: &ResolvedRoot) {
540        if let Some(ref root) = resolved.root_hash {
541            self.cached_root_hash
542                .insert(repo_name.to_string(), root.clone());
543        } else {
544            self.cached_root_hash.remove(repo_name);
545        }
546
547        if let Some(key) = resolved.encryption_key {
548            self.cached_encryption_key
549                .insert(repo_name.to_string(), key);
550        } else {
551            self.cached_encryption_key.remove(repo_name);
552        }
553
554        if let Some(source) = resolved.source {
555            self.cached_root_source
556                .insert(repo_name.to_string(), source);
557        } else {
558            self.cached_root_source.remove(repo_name);
559        }
560    }
561
562    /// Fetch refs with configurable timeout
563    fn fetch_refs_with_timeout(
564        &mut self,
565        repo_name: &str,
566        timeout_secs: u64,
567    ) -> Result<FetchedRefs> {
568        debug!(
569            "Fetching refs for {} from {} (timeout {}s)",
570            repo_name, self.pubkey, timeout_secs
571        );
572
573        // Check cache first
574        if let Some(refs) = self.cached_refs.get(repo_name) {
575            let root = self.cached_root_hash.get(repo_name).cloned();
576            let key = self.cached_encryption_key.get(repo_name).cloned();
577            return Ok((refs.clone(), root, key));
578        }
579
580        self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, true)
581    }
582
583    fn fetch_refs_with_timeout_uncached(
584        &mut self,
585        repo_name: &str,
586        timeout_secs: u64,
587        allow_local_daemon: bool,
588    ) -> Result<FetchedRefs> {
589        let resolved = block_on_result(self.resolve_root_async_with_timeout(
590            repo_name,
591            timeout_secs,
592            allow_local_daemon,
593        ))?;
594
595        match self.fetch_refs_for_resolved_root(repo_name, &resolved) {
596            Ok(fetched) => Ok(fetched),
597            Err(err)
598                if resolved.source == Some(RootResolveSource::LocalDaemon)
599                    && allow_local_daemon =>
600            {
601                warn!(
602                    "Local daemon root for {} could not be read as a valid git tree: {}. Retrying via relays.",
603                    repo_name, err
604                );
605                self.clear_cached_remote_state(repo_name);
606                self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, false)
607            }
608            Err(err) => Err(err),
609        }
610    }
611
612    fn fetch_refs_for_resolved_root(
613        &mut self,
614        repo_name: &str,
615        resolved: &ResolvedRoot,
616    ) -> Result<FetchedRefs> {
617        if resolved.source != Some(RootResolveSource::LocalDaemon) {
618            self.cache_resolved_root(repo_name, resolved);
619        }
620
621        let refs = if let Some(ref root) = resolved.root_hash {
622            block_on_result(self.fetch_refs_from_hashtree(root, resolved.encryption_key.as_ref()))?
623        } else {
624            HashMap::new()
625        };
626
627        self.cache_resolved_root(repo_name, resolved);
628        self.cached_refs.insert(repo_name.to_string(), refs.clone());
629        Ok((refs, resolved.root_hash.clone(), resolved.encryption_key))
630    }
631
632    fn parse_root_event_data_from_event(event: &Event) -> RootEventData {
633        let root_hash = event
634            .tags
635            .iter()
636            .find(|t| t.as_slice().len() >= 2 && t.as_slice()[0].as_str() == "hash")
637            .map(|t| t.as_slice()[1].to_string())
638            .unwrap_or_else(|| event.content.to_string());
639
640        let (encryption_key, key_tag_name, self_encrypted_ciphertext) = event
641            .tags
642            .iter()
643            .find_map(|t| {
644                let slice = t.as_slice();
645                if slice.len() < 2 {
646                    return None;
647                }
648                let tag_name = slice[0].as_str();
649                let tag_value = slice[1].to_string();
650                if tag_name == "selfEncryptedKey" {
651                    return Some((None, Some(tag_name.to_string()), Some(tag_value)));
652                }
653                if tag_name == "key" || tag_name == "encryptedKey" {
654                    if let Ok(bytes) = hex::decode(&tag_value) {
655                        if bytes.len() == 32 {
656                            let mut key = [0u8; 32];
657                            key.copy_from_slice(&bytes);
658                            return Some((Some(key), Some(tag_name.to_string()), None));
659                        }
660                    }
661                }
662                None
663            })
664            .unwrap_or((None, None, None));
665
666        RootEventData {
667            root_hash,
668            encryption_key,
669            key_tag_name,
670            self_encrypted_ciphertext,
671            source: RootResolveSource::Relay,
672        }
673    }
674
675    fn parse_daemon_response_to_root_data(
676        response: DaemonResolveResponse,
677    ) -> Option<RootEventData> {
678        let parsed_cid = response.cid.as_deref().and_then(|cid| Cid::parse(cid).ok());
679        let root_hash = response
680            .hash
681            .or_else(|| parsed_cid.as_ref().map(|cid| hex::encode(cid.hash)))?;
682        if root_hash.is_empty() {
683            return None;
684        }
685
686        let mut data = RootEventData {
687            root_hash,
688            encryption_key: parsed_cid.and_then(|cid| cid.key),
689            key_tag_name: None,
690            self_encrypted_ciphertext: None,
691            source: RootResolveSource::LocalDaemon,
692        };
693
694        if let Some(ciphertext) = response.self_encrypted_key {
695            data.key_tag_name = Some("selfEncryptedKey".to_string());
696            data.self_encrypted_ciphertext = Some(ciphertext);
697            return Some(data);
698        }
699
700        let (tag_name, tag_value) = if let Some(v) = response.encrypted_key {
701            ("encryptedKey", v)
702        } else if let Some(v) = response.key {
703            ("key", v)
704        } else {
705            return Some(data);
706        };
707
708        if let Ok(bytes) = hex::decode(&tag_value) {
709            if bytes.len() == 32 {
710                let mut key = [0u8; 32];
711                key.copy_from_slice(&bytes);
712                data.encryption_key = Some(key);
713                data.key_tag_name = Some(tag_name.to_string());
714            }
715        }
716
717        Some(data)
718    }
719
720    async fn fetch_root_from_local_daemon(
721        &self,
722        repo_name: &str,
723        timeout: Duration,
724    ) -> Option<RootEventData> {
725        let base = self.local_daemon_url.as_ref()?;
726        let pubkey = self.daemon_pubkey_identifier();
727        let url = format!(
728            "{}/api/nostr/resolve/{}/{}",
729            base.trim_end_matches('/'),
730            pubkey,
731            repo_name
732        );
733
734        let client = reqwest::Client::builder().timeout(timeout).build().ok()?;
735        let response = client.get(&url).send().await.ok()?;
736        if !response.status().is_success() {
737            return None;
738        }
739
740        let payload: DaemonResolveResponse = response.json().await.ok()?;
741        let source = payload
742            .source
743            .clone()
744            .unwrap_or_else(|| "unknown".to_string());
745        let parsed = Self::parse_daemon_response_to_root_data(payload)?;
746        debug!(
747            "Resolved repo {} via local daemon source={}",
748            repo_name, source
749        );
750        Some(parsed)
751    }
752
753    async fn cache_public_root_in_local_daemon(
754        &self,
755        repo_name: &str,
756        root_hash: &str,
757        encryption_key: Option<(&[u8; 32], bool, bool)>,
758    ) {
759        let Some(base) = self.local_daemon_url.as_ref() else {
760            return;
761        };
762
763        let key = match encryption_key {
764            Some((key, false, false)) => Some(hex::encode(key)),
765            Some((_key, _is_link_visible, _is_self_private)) => return,
766            None => None,
767        };
768
769        let url = format!("{}/api/cache-tree-root", base.trim_end_matches('/'));
770        let pubkey = self.daemon_pubkey_identifier();
771        let payload = serde_json::json!({
772            "npub": pubkey,
773            "treeName": repo_name,
774            "hash": root_hash,
775            "key": key,
776            "visibility": "public",
777        });
778
779        let client = match reqwest::Client::builder()
780            .timeout(Duration::from_secs(2))
781            .build()
782        {
783            Ok(client) => client,
784            Err(err) => {
785                debug!("Could not build local daemon cache client: {}", err);
786                return;
787            }
788        };
789
790        match client.post(url).json(&payload).send().await {
791            Ok(response) if response.status().is_success() => {
792                debug!("Cached repo root for {} in local daemon", repo_name);
793            }
794            Ok(response) => {
795                debug!(
796                    "Local daemon root cache returned status {} for {}",
797                    response.status(),
798                    repo_name
799                );
800            }
801            Err(err) => {
802                debug!("Could not cache repo root in local daemon: {}", err);
803            }
804        }
805    }
806
807    async fn resolve_root_async_with_timeout(
808        &self,
809        repo_name: &str,
810        timeout_secs: u64,
811        allow_local_daemon: bool,
812    ) -> Result<ResolvedRoot> {
813        // Create nostr-sdk client
814        let client = Client::default();
815
816        // Add relays
817        for relay in &self.relays {
818            if let Err(e) = client.add_relay(relay).await {
819                warn!("Failed to add relay {}: {}", relay, e);
820            }
821        }
822
823        // Connect to relays - this starts async connection
824        client.connect().await;
825
826        let connect_timeout = Duration::from_secs(2);
827        let query_timeout = Duration::from_secs(timeout_secs.saturating_sub(2).max(3));
828        let local_daemon_timeout = Duration::from_secs(4);
829        let retry_delay = Duration::from_millis(300);
830        let max_attempts = 2;
831
832        let start = std::time::Instant::now();
833
834        // Build filter for kind 30078 events from this author with matching d-tag
835        let author = PublicKey::from_hex(&self.pubkey)
836            .map_err(|e| anyhow::anyhow!("Invalid pubkey: {}", e))?;
837
838        let filter = build_repo_event_filter(author, repo_name);
839
840        debug!("Querying relays for repo {} events", repo_name);
841
842        let mut root_data = None;
843        for attempt in 1..=max_attempts {
844            if allow_local_daemon {
845                if let Some(data) = self
846                    .fetch_root_from_local_daemon(repo_name, local_daemon_timeout)
847                    .await
848                {
849                    root_data = Some(data);
850                    break;
851                }
852            }
853
854            if !allow_local_daemon && attempt == 1 {
855                debug!(
856                    "Skipping local daemon while resolving {} because relay retry was requested",
857                    repo_name
858                );
859            }
860
861            if allow_local_daemon {
862                debug!(
863                    "Local daemon did not resolve {}; querying relays",
864                    repo_name
865                );
866            }
867
868            // Wait for at least one relay to connect (quick timeout - break immediately when one
869            // connects). We retry once because relays and the local daemon can both lag briefly.
870            let connect_start = std::time::Instant::now();
871            let mut last_log = std::time::Instant::now();
872            let mut has_connected_relay = false;
873            loop {
874                let (connected, total) = connected_relay_count(&client).await;
875                if connected > 0 {
876                    debug!(
877                        "Connected to {}/{} relay(s) in {:?} (attempt {}/{})",
878                        connected,
879                        total,
880                        start.elapsed(),
881                        attempt,
882                        max_attempts
883                    );
884                    has_connected_relay = true;
885                    break;
886                }
887                if last_log.elapsed() > Duration::from_millis(500) {
888                    debug!(
889                        "Connecting to relays... (0/{} after {:?}, attempt {}/{})",
890                        total,
891                        start.elapsed(),
892                        attempt,
893                        max_attempts
894                    );
895                    last_log = std::time::Instant::now();
896                }
897                if connect_start.elapsed() > connect_timeout {
898                    debug!(
899                        "Timeout waiting for relay connections - continuing with local-daemon fallback"
900                    );
901                    break;
902                }
903                tokio::time::sleep(Duration::from_millis(50)).await;
904            }
905
906            // Query with relay-level timeout.
907            // Using `EventSource::relays(Some(...))` preserves partial results from responsive
908            // relays instead of discarding everything when one relay stalls.
909            let events = if has_connected_relay {
910                match client.fetch_events(filter.clone(), query_timeout).await {
911                    Ok(events) => events.to_vec(),
912                    Err(e) => {
913                        warn!("Failed to fetch events: {}", e);
914                        vec![]
915                    }
916                }
917            } else {
918                vec![]
919            };
920
921            debug!(
922                "Got {} events from relays on attempt {}/{}",
923                events.len(),
924                attempt,
925                max_attempts
926            );
927            let relay_event = pick_latest_repo_event(events.iter(), repo_name);
928
929            if let Some(event) = relay_event {
930                debug!(
931                    "Found relay event with root hash: {}",
932                    &event.content[..12.min(event.content.len())]
933                );
934                root_data = Some(Self::parse_root_event_data_from_event(event));
935                break;
936            }
937
938            if attempt < max_attempts {
939                debug!(
940                    "No hashtree event found for {} on attempt {}/{}; retrying",
941                    repo_name, attempt, max_attempts
942                );
943                tokio::time::sleep(retry_delay).await;
944            }
945        }
946
947        // Disconnect
948        let _ = client.disconnect().await;
949
950        let root_data = match root_data {
951            Some(data) => data,
952            None => {
953                anyhow::bail!(
954                    "Repository '{}' not found (no hashtree event published by {})",
955                    repo_name,
956                    Self::format_repo_author(&self.pubkey)
957                );
958            }
959        };
960
961        let root_hash = root_data.root_hash;
962
963        if root_hash.is_empty() {
964            debug!("Empty root hash in event");
965            return Ok(ResolvedRoot {
966                root_hash: None,
967                encryption_key: None,
968                source: None,
969            });
970        }
971
972        let encryption_key = root_data.encryption_key;
973        let key_tag_name = root_data.key_tag_name;
974        let self_encrypted_ciphertext = root_data.self_encrypted_ciphertext;
975        let root_source = root_data.source;
976
977        // Process encryption key based on tag type
978        let unmasked_key = match key_tag_name.as_deref() {
979            Some("encryptedKey") => {
980                // Link-visible: XOR the masked key with url_secret
981                if let (Some(masked), Some(secret)) = (encryption_key, self.url_secret) {
982                    let mut unmasked = [0u8; 32];
983                    for i in 0..32 {
984                        unmasked[i] = masked[i] ^ secret[i];
985                    }
986                    Some(unmasked)
987                } else {
988                    anyhow::bail!(
989                        "This repo is link-visible and requires a secret key.\n\
990                         Use: htree://.../{repo_name}#k=<secret>\n\
991                         Ask the repo owner for the full URL with the secret."
992                    );
993                }
994            }
995            Some("selfEncryptedKey") => {
996                // Private: only decrypt if #private is in the URL
997                if !self.is_private {
998                    anyhow::bail!(
999                        "This repo is private (author-only).\n\
1000                         Use: htree://.../{repo_name}#private\n\
1001                         Only the author can access this repo."
1002                    );
1003                }
1004
1005                // Decrypt with NIP-44 using our secret key
1006                if let Some(keys) = &self.keys {
1007                    if let Some(ciphertext) = self_encrypted_ciphertext {
1008                        // Decrypt with NIP-44 (encrypted to self)
1009                        let pubkey = keys.public_key();
1010                        match nip44::decrypt(keys.secret_key(), &pubkey, &ciphertext) {
1011                            Ok(key_hex) => {
1012                                let key_bytes =
1013                                    hex::decode(&key_hex).context("Invalid decrypted key hex")?;
1014                                if key_bytes.len() != 32 {
1015                                    anyhow::bail!("Decrypted key wrong length");
1016                                }
1017                                let mut key = [0u8; 32];
1018                                key.copy_from_slice(&key_bytes);
1019                                Some(key)
1020                            }
1021                            Err(e) => {
1022                                anyhow::bail!(
1023                                    "Failed to decrypt private repo: {}\n\
1024                                     The repo may be corrupted or published with a different key.",
1025                                    e
1026                                );
1027                            }
1028                        }
1029                    } else {
1030                        anyhow::bail!("selfEncryptedKey tag has invalid format");
1031                    }
1032                } else {
1033                    anyhow::bail!(
1034                        "Cannot access this private repo.\n\
1035                         Private repos can only be accessed by their author.\n\
1036                         You don't have the secret key for this repo's owner."
1037                    );
1038                }
1039            }
1040            Some("key") | None => {
1041                // Public: use key directly
1042                encryption_key
1043            }
1044            Some(other) => {
1045                warn!("Unknown key tag type: {}", other);
1046                encryption_key
1047            }
1048        };
1049
1050        info!(
1051            "Found root hash {} for {} (encrypted: {}, link_visible: {})",
1052            &root_hash[..12.min(root_hash.len())],
1053            repo_name,
1054            unmasked_key.is_some(),
1055            self.url_secret.is_some()
1056        );
1057
1058        Ok(ResolvedRoot {
1059            root_hash: Some(root_hash),
1060            encryption_key: unmasked_key,
1061            source: Some(root_source),
1062        })
1063    }
1064
1065    /// Decrypt data if encryption key is provided, then decode as tree node
1066    fn decrypt_and_decode(&self, data: &[u8], key: Option<&[u8; 32]>) -> Result<TreeNode> {
1067        let decrypted_data: Vec<u8>;
1068        let data_to_decode = if let Some(k) = key {
1069            decrypted_data = decrypt_chk(data, k).context("Decryption failed")?;
1070            &decrypted_data
1071        } else {
1072            data
1073        };
1074
1075        decode_tree_node(data_to_decode).context("Failed to decode tree node")
1076    }
1077
1078    /// Fetch git refs from hashtree structure
1079    /// Structure: root -> .git/ -> refs/ -> heads/main -> <sha>
1080    async fn fetch_refs_from_hashtree(
1081        &self,
1082        root_hash: &str,
1083        encryption_key: Option<&[u8; 32]>,
1084    ) -> Result<HashMap<String, String>> {
1085        let mut refs = HashMap::new();
1086        debug!(
1087            "fetch_refs_from_hashtree: downloading root {}",
1088            &root_hash[..12]
1089        );
1090
1091        // Download root directory from Blossom - propagate errors properly
1092        let root_data = match self.blossom.download(root_hash).await {
1093            Ok(data) => {
1094                debug!("Downloaded {} bytes from blossom", data.len());
1095                data
1096            }
1097            Err(e) => {
1098                anyhow::bail!(
1099                    "Failed to download root hash {}: {}",
1100                    &root_hash[..12.min(root_hash.len())],
1101                    e
1102                );
1103            }
1104        };
1105
1106        // Parse root as directory node (decrypt if needed)
1107        let root_node = self
1108            .decrypt_and_decode(&root_data, encryption_key)
1109            .with_context(|| {
1110                format!(
1111                    "Failed to decode root node {} (encrypted: {})",
1112                    &root_hash[..12.min(root_hash.len())],
1113                    encryption_key.is_some()
1114                )
1115            })?;
1116        debug!("Decoded root node with {} links", root_node.links.len());
1117
1118        // Find .git directory
1119        debug!(
1120            "Root links: {:?}",
1121            root_node
1122                .links
1123                .iter()
1124                .map(|l| l.name.as_deref())
1125                .collect::<Vec<_>>()
1126        );
1127        let git_link = root_node
1128            .links
1129            .iter()
1130            .find(|l| l.name.as_deref() == Some(".git"));
1131        let (git_hash, git_key) = match git_link {
1132            Some(link) => {
1133                debug!("Found .git link with key: {}", link.key.is_some());
1134                (hex::encode(link.hash), link.key)
1135            }
1136            None => {
1137                debug!("No .git directory in hashtree root");
1138                return Ok(refs);
1139            }
1140        };
1141
1142        // Download .git directory
1143        let git_data = match self.blossom.download(&git_hash).await {
1144            Ok(data) => data,
1145            Err(e) => {
1146                anyhow::bail!(
1147                    "Failed to download .git directory ({}): {}",
1148                    &git_hash[..12],
1149                    e
1150                );
1151            }
1152        };
1153
1154        let git_node = self
1155            .decrypt_and_decode(&git_data, git_key.as_ref())
1156            .with_context(|| {
1157                format!(
1158                    "Failed to decode .git directory {} (encrypted: {})",
1159                    &git_hash[..12.min(git_hash.len())],
1160                    git_key.is_some()
1161                )
1162            })?;
1163        debug!(
1164            "Decoded .git node with {} links: {:?}",
1165            git_node.links.len(),
1166            git_node
1167                .links
1168                .iter()
1169                .map(|l| l.name.as_deref())
1170                .collect::<Vec<_>>()
1171        );
1172
1173        // Find refs directory
1174        let refs_link = git_node
1175            .links
1176            .iter()
1177            .find(|l| l.name.as_deref() == Some("refs"));
1178        let (refs_hash, refs_key) = match refs_link {
1179            Some(link) => (hex::encode(link.hash), link.key),
1180            None => {
1181                debug!("No refs directory in .git");
1182                return Ok(refs);
1183            }
1184        };
1185
1186        // Download refs directory
1187        let refs_data =
1188            self.blossom.download(&refs_hash).await.with_context(|| {
1189                format!("Failed to download refs directory {}", &refs_hash[..12])
1190            })?;
1191
1192        let refs_node = self
1193            .decrypt_and_decode(&refs_data, refs_key.as_ref())
1194            .with_context(|| {
1195                format!(
1196                    "Failed to decode refs directory {} (encrypted: {})",
1197                    &refs_hash[..12.min(refs_hash.len())],
1198                    refs_key.is_some()
1199                )
1200            })?;
1201
1202        // Look for HEAD in .git directory
1203        if let Some(head_link) = git_node
1204            .links
1205            .iter()
1206            .find(|l| l.name.as_deref() == Some("HEAD"))
1207        {
1208            let head_hash = hex::encode(head_link.hash);
1209            let head_data = self
1210                .blossom
1211                .download(&head_hash)
1212                .await
1213                .with_context(|| format!("Failed to download HEAD {}", &head_hash[..12]))?;
1214            // HEAD is a blob, decrypt if needed
1215            let head_content = if let Some(k) = head_link.key.as_ref() {
1216                let decrypted = decrypt_chk(&head_data, k)
1217                    .with_context(|| format!("Failed to decrypt HEAD {}", &head_hash[..12]))?;
1218                String::from_utf8_lossy(&decrypted).trim().to_string()
1219            } else {
1220                String::from_utf8_lossy(&head_data).trim().to_string()
1221            };
1222            refs.insert("HEAD".to_string(), head_content);
1223        }
1224
1225        // Recursively walk refs/ subdirectories (heads, tags, etc.)
1226        for subdir_link in &refs_node.links {
1227            if subdir_link.link_type != LinkType::Dir {
1228                continue;
1229            }
1230            let subdir_name = match &subdir_link.name {
1231                Some(n) => n.clone(),
1232                None => continue,
1233            };
1234            let subdir_hash = hex::encode(subdir_link.hash);
1235
1236            self.collect_refs_recursive(
1237                &subdir_hash,
1238                subdir_link.key.as_ref(),
1239                &format!("refs/{}", subdir_name),
1240                &mut refs,
1241            )
1242            .await?;
1243        }
1244
1245        debug!("Found {} refs from hashtree", refs.len());
1246        Ok(refs)
1247    }
1248
1249    /// Recursively collect refs from a directory
1250    async fn collect_refs_recursive(
1251        &self,
1252        dir_hash: &str,
1253        dir_key: Option<&[u8; 32]>,
1254        prefix: &str,
1255        refs: &mut HashMap<String, String>,
1256    ) -> Result<()> {
1257        let dir_data = self
1258            .blossom
1259            .download(dir_hash)
1260            .await
1261            .with_context(|| format!("Failed to download refs subtree {}", &dir_hash[..12]))?;
1262
1263        let dir_node = self
1264            .decrypt_and_decode(&dir_data, dir_key)
1265            .with_context(|| {
1266                format!(
1267                    "Failed to decode refs subtree {} (encrypted: {})",
1268                    &dir_hash[..12.min(dir_hash.len())],
1269                    dir_key.is_some()
1270                )
1271            })?;
1272
1273        for link in &dir_node.links {
1274            let name = match &link.name {
1275                Some(n) => n.clone(),
1276                None => continue,
1277            };
1278            let link_hash = hex::encode(link.hash);
1279            let ref_path = format!("{}/{}", prefix, name);
1280
1281            if link.link_type == LinkType::Dir {
1282                // Recurse into subdirectory
1283                Box::pin(self.collect_refs_recursive(
1284                    &link_hash,
1285                    link.key.as_ref(),
1286                    &ref_path,
1287                    refs,
1288                ))
1289                .await?;
1290            } else {
1291                // This is a ref file - read the SHA
1292                let ref_data = self
1293                    .blossom
1294                    .download(&link_hash)
1295                    .await
1296                    .with_context(|| format!("Failed to download ref {}", ref_path))?;
1297                // Decrypt if needed
1298                let sha = if let Some(k) = link.key.as_ref() {
1299                    let decrypted = decrypt_chk(&ref_data, k)
1300                        .with_context(|| format!("Failed to decrypt ref {}", ref_path))?;
1301                    String::from_utf8_lossy(&decrypted).trim().to_string()
1302                } else {
1303                    String::from_utf8_lossy(&ref_data).trim().to_string()
1304                };
1305                if !sha.is_empty() {
1306                    debug!("Found ref {} -> {}", ref_path, sha);
1307                    refs.insert(ref_path, sha);
1308                }
1309            }
1310        }
1311
1312        Ok(())
1313    }
1314
1315    /// Update a ref in local cache (will be published with publish_repo)
1316    #[allow(dead_code)]
1317    pub fn update_ref(&mut self, repo_name: &str, ref_name: &str, sha: &str) -> Result<()> {
1318        info!("Updating ref {} -> {} for {}", ref_name, sha, repo_name);
1319
1320        let refs = self.cached_refs.entry(repo_name.to_string()).or_default();
1321        refs.insert(ref_name.to_string(), sha.to_string());
1322
1323        Ok(())
1324    }
1325
1326    /// Delete a ref from local cache
1327    pub fn delete_ref(&mut self, repo_name: &str, ref_name: &str) -> Result<()> {
1328        info!("Deleting ref {} for {}", ref_name, repo_name);
1329
1330        if let Some(refs) = self.cached_refs.get_mut(repo_name) {
1331            refs.remove(ref_name);
1332        }
1333
1334        Ok(())
1335    }
1336
1337    /// Get cached root hash for a repository
1338    pub fn get_cached_root_hash(&self, repo_name: &str) -> Option<&String> {
1339        self.cached_root_hash.get(repo_name)
1340    }
1341
1342    /// Get cached encryption key for a repository
1343    pub fn get_cached_encryption_key(&self, repo_name: &str) -> Option<&[u8; 32]> {
1344        self.cached_encryption_key.get(repo_name)
1345    }
1346
1347    pub(crate) fn cached_root_is_from_local_daemon(&self, repo_name: &str) -> bool {
1348        self.cached_root_source.get(repo_name) == Some(&RootResolveSource::LocalDaemon)
1349    }
1350
1351    /// Get the Blossom client for direct downloads
1352    pub fn blossom(&self) -> &BlossomClient {
1353        &self.blossom
1354    }
1355
1356    /// Get the configured relay URLs
1357    pub fn relay_urls(&self) -> Vec<String> {
1358        self.relays.clone()
1359    }
1360
1361    /// Get the public key (hex)
1362    #[allow(dead_code)]
1363    pub fn pubkey(&self) -> &str {
1364        &self.pubkey
1365    }
1366
1367    /// Get the public key as npub bech32
1368    pub fn npub(&self) -> String {
1369        PublicKey::from_hex(&self.pubkey)
1370            .ok()
1371            .and_then(|pk| pk.to_bech32().ok())
1372            .unwrap_or_else(|| self.pubkey.clone())
1373    }
1374
1375    /// Publish repository to nostr as kind 30078 event
1376    /// Format:
1377    ///   kind: 30078
1378    ///   tags: [["d", repo_name], ["l", "hashtree"], ["hash", root_hash], ["key"|"encryptedKey", encryption_key]]
1379    ///   content: <merkle-root-hash>
1380    /// Returns: (npub URL, relay result with connected/failed details)
1381    /// If is_private is true, uses "encryptedKey" tag (XOR masked); otherwise uses "key" tag (plaintext CHK)
1382    pub fn publish_repo(
1383        &self,
1384        repo_name: &str,
1385        root_hash: &str,
1386        encryption_key: Option<(&[u8; 32], bool, bool)>,
1387    ) -> Result<(String, RelayResult)> {
1388        let keys = self.keys.as_ref().context(format!(
1389            "Cannot push: no secret key for {}. You can only push to your own repos.",
1390            &self.pubkey[..16]
1391        ))?;
1392
1393        info!(
1394            "Publishing repo {} with root hash {} (encrypted: {})",
1395            repo_name,
1396            root_hash,
1397            encryption_key.is_some()
1398        );
1399
1400        // Create a new multi-threaded runtime for nostr-sdk which spawns background tasks
1401        block_on_result(self.publish_repo_async(keys, repo_name, root_hash, encryption_key))
1402    }
1403
1404    async fn publish_repo_async(
1405        &self,
1406        keys: &Keys,
1407        repo_name: &str,
1408        root_hash: &str,
1409        encryption_key: Option<(&[u8; 32], bool, bool)>,
1410    ) -> Result<(String, RelayResult)> {
1411        // Create nostr-sdk client with our keys
1412        let client = Client::new(keys.clone());
1413
1414        let configured: Vec<String> = self.relays.clone();
1415        let mut connected: Vec<String> = Vec::new();
1416        let mut failed: Vec<String> = Vec::new();
1417
1418        // Add relays
1419        for relay in &self.relays {
1420            if let Err(e) = client.add_relay(relay).await {
1421                warn!("Failed to add relay {}: {}", relay, e);
1422                failed.push(relay.clone());
1423            }
1424        }
1425
1426        // Connect to relays - this starts async connection in background
1427        client.connect().await;
1428
1429        // Wait for at least one relay to connect (same pattern as fetch)
1430        let _ = wait_for_any_connected_relay(&client, Duration::from_secs(3)).await;
1431
1432        let publish_created_at = next_replaceable_created_at(
1433            Timestamp::now(),
1434            latest_repo_event_created_at(
1435                &client,
1436                keys.public_key(),
1437                repo_name,
1438                Duration::from_secs(2),
1439            )
1440            .await,
1441        );
1442
1443        // Build event with tags
1444        let mut tags = vec![
1445            Tag::custom(TagKind::custom("d"), vec![repo_name.to_string()]),
1446            Tag::custom(TagKind::custom("l"), vec![LABEL_HASHTREE.to_string()]),
1447            Tag::custom(TagKind::custom("hash"), vec![root_hash.to_string()]),
1448        ];
1449
1450        // Add encryption key if present (required for decryption)
1451        // Key modes:
1452        // - selfEncryptedKey: NIP-44 encrypted to self (author-only private)
1453        // - encryptedKey: XOR masked with URL secret (link-visible)
1454        // - key: plaintext CHK (public)
1455        if let Some((key, is_link_visible, is_self_private)) = encryption_key {
1456            if is_self_private {
1457                // NIP-44 encrypt to self
1458                let pubkey = keys.public_key();
1459                let key_hex = hex::encode(key);
1460                let encrypted =
1461                    nip44::encrypt(keys.secret_key(), &pubkey, &key_hex, nip44::Version::V2)
1462                        .map_err(|e| anyhow::anyhow!("NIP-44 encryption failed: {}", e))?;
1463                tags.push(Tag::custom(
1464                    TagKind::custom("selfEncryptedKey"),
1465                    vec![encrypted],
1466                ));
1467            } else if is_link_visible {
1468                // XOR masked key
1469                tags.push(Tag::custom(
1470                    TagKind::custom("encryptedKey"),
1471                    vec![hex::encode(key)],
1472                ));
1473            } else {
1474                // Public: plaintext CHK
1475                tags.push(Tag::custom(TagKind::custom("key"), vec![hex::encode(key)]));
1476            }
1477        }
1478
1479        append_repo_discovery_labels(&mut tags, repo_name);
1480
1481        // Sign the event
1482        let event = EventBuilder::new(Kind::Custom(KIND_APP_DATA), root_hash)
1483            .tags(tags)
1484            .custom_created_at(publish_created_at)
1485            .sign_with_keys(keys)
1486            .map_err(|e| anyhow::anyhow!("Failed to sign event: {}", e))?;
1487
1488        // Send event to connected relays
1489        match client.send_event(&event).await {
1490            Ok(output) => {
1491                // Track which relays confirmed
1492                for url in output.success.iter() {
1493                    let url_str = url.to_string();
1494                    if !connected.contains(&url_str) {
1495                        connected.push(url_str);
1496                    }
1497                }
1498                // Only mark as failed if we got explicit rejection
1499                for (url, _err) in output.failed.iter() {
1500                    let url_str = url.to_string();
1501                    if !failed.contains(&url_str) && !connected.contains(&url_str) {
1502                        failed.push(url_str);
1503                    }
1504                }
1505                info!(
1506                    "Sent event {} to {} relays ({} failed)",
1507                    output.id(),
1508                    output.success.len(),
1509                    output.failed.len()
1510                );
1511            }
1512            Err(e) => {
1513                warn!("Failed to send event: {}", e);
1514                // Mark all as failed
1515                for relay in &self.relays {
1516                    if !failed.contains(relay) {
1517                        failed.push(relay.clone());
1518                    }
1519                }
1520            }
1521        };
1522
1523        // Build the full htree:// URL with npub
1524        let npub_url = keys
1525            .public_key()
1526            .to_bech32()
1527            .map(|npub| format!("htree://{}/{}", npub, repo_name))
1528            .unwrap_or_else(|_| format!("htree://{}/{}", &self.pubkey[..16], repo_name));
1529
1530        let relay_validation = validate_repo_publish_relays(&configured, &connected);
1531
1532        // Disconnect and give time for cleanup
1533        let _ = client.disconnect().await;
1534        tokio::time::sleep(Duration::from_millis(50)).await;
1535
1536        relay_validation?;
1537
1538        self.cache_public_root_in_local_daemon(repo_name, root_hash, encryption_key)
1539            .await;
1540
1541        Ok((
1542            npub_url,
1543            RelayResult {
1544                configured,
1545                connected,
1546                failed,
1547            },
1548        ))
1549    }
1550
1551    /// Fetch pull requests targeting this repo, filtered by state.
1552    pub fn fetch_prs(
1553        &self,
1554        repo_name: &str,
1555        state_filter: PullRequestStateFilter,
1556    ) -> Result<Vec<PullRequestListItem>> {
1557        block_on_result(self.fetch_prs_async(repo_name, state_filter))
1558    }
1559
1560    pub async fn fetch_prs_async(
1561        &self,
1562        repo_name: &str,
1563        state_filter: PullRequestStateFilter,
1564    ) -> Result<Vec<PullRequestListItem>> {
1565        let client = Client::default();
1566
1567        for relay in &self.relays {
1568            if let Err(e) = client.add_relay(relay).await {
1569                warn!("Failed to add relay {}: {}", relay, e);
1570            }
1571        }
1572        client.connect().await;
1573
1574        // Wait for at least one relay (quick timeout)
1575        if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
1576            let _ = client.disconnect().await;
1577            return Err(anyhow::anyhow!(
1578                "Failed to connect to any relay while fetching PRs"
1579            ));
1580        }
1581
1582        // Query for kind 1618 PRs targeting this repo
1583        let repo_address = format!("{}:{}:{}", KIND_REPO_ANNOUNCEMENT, self.pubkey, repo_name);
1584        let pull_request_filter = Filter::new()
1585            .kind(Kind::Custom(KIND_PULL_REQUEST))
1586            .custom_tag(
1587                SingleLetterTag::lowercase(Alphabet::A),
1588                repo_address.clone(),
1589            );
1590
1591        let mut pr_events = match tokio::time::timeout(
1592            Duration::from_secs(3),
1593            client.fetch_events(pull_request_filter.clone(), Duration::from_secs(3)),
1594        )
1595        .await
1596        {
1597            Ok(Ok(events)) => events.to_vec(),
1598            Ok(Err(e)) => {
1599                let _ = client.disconnect().await;
1600                return Err(anyhow::anyhow!(
1601                    "Failed to fetch PR events from relays: {}",
1602                    e
1603                ));
1604            }
1605            Err(_) => {
1606                let _ = client.disconnect().await;
1607                return Err(anyhow::anyhow!("Timed out fetching PR events from relays"));
1608            }
1609        };
1610
1611        if pr_events.is_empty() {
1612            let fallback_events = fetch_events_via_raw_relay_query(
1613                &self.relays,
1614                pull_request_filter,
1615                Duration::from_secs(3),
1616            )
1617            .await;
1618            if !fallback_events.is_empty() {
1619                debug!(
1620                    "Raw relay fallback recovered {} PR event(s) for {}",
1621                    fallback_events.len(),
1622                    repo_name
1623                );
1624                pr_events = fallback_events;
1625            }
1626        }
1627
1628        if pr_events.is_empty() {
1629            let _ = client.disconnect().await;
1630            return Ok(Vec::new());
1631        }
1632
1633        // Collect PR event IDs for status query
1634        let pr_ids: Vec<String> = pr_events.iter().map(|e| e.id.to_hex()).collect();
1635
1636        // Query for status events referencing these PRs
1637        let status_event_filter = Filter::new()
1638            .kinds(vec![
1639                Kind::Custom(KIND_STATUS_OPEN),
1640                Kind::Custom(KIND_STATUS_APPLIED),
1641                Kind::Custom(KIND_STATUS_CLOSED),
1642                Kind::Custom(KIND_STATUS_DRAFT),
1643            ])
1644            .custom_tags(
1645                SingleLetterTag::lowercase(Alphabet::E),
1646                pr_ids.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1647            );
1648
1649        let mut status_events = match tokio::time::timeout(
1650            Duration::from_secs(3),
1651            client.fetch_events(status_event_filter.clone(), Duration::from_secs(3)),
1652        )
1653        .await
1654        {
1655            Ok(Ok(events)) => events.to_vec(),
1656            Ok(Err(e)) => {
1657                let _ = client.disconnect().await;
1658                return Err(anyhow::anyhow!(
1659                    "Failed to fetch PR status events from relays: {}",
1660                    e
1661                ));
1662            }
1663            Err(_) => {
1664                let _ = client.disconnect().await;
1665                return Err(anyhow::anyhow!(
1666                    "Timed out fetching PR status events from relays"
1667                ));
1668            }
1669        };
1670
1671        if status_events.is_empty() {
1672            let fallback_events = fetch_events_via_raw_relay_query(
1673                &self.relays,
1674                status_event_filter,
1675                Duration::from_secs(3),
1676            )
1677            .await;
1678            if !fallback_events.is_empty() {
1679                debug!(
1680                    "Raw relay fallback recovered {} PR status event(s) for {}",
1681                    fallback_events.len(),
1682                    repo_name
1683                );
1684                status_events = fallback_events;
1685            }
1686        }
1687
1688        let _ = client.disconnect().await;
1689
1690        // Build map: pr_event_id -> latest trusted status kind
1691        let latest_status =
1692            latest_trusted_pr_status_kinds(&pr_events, &status_events, &self.pubkey);
1693
1694        let mut prs = Vec::new();
1695        for event in &pr_events {
1696            let pr_id = event.id.to_hex();
1697            let state =
1698                PullRequestState::from_latest_status_kind(latest_status.get(&pr_id).copied());
1699            if !state_filter.includes(state) {
1700                continue;
1701            }
1702
1703            let mut subject = None;
1704            let mut commit_tip = None;
1705            let mut branch = None;
1706            let mut target_branch = None;
1707
1708            for tag in event.tags.iter() {
1709                let slice = tag.as_slice();
1710                if slice.len() >= 2 {
1711                    match slice[0].as_str() {
1712                        "subject" => subject = Some(slice[1].to_string()),
1713                        "c" => commit_tip = Some(slice[1].to_string()),
1714                        "branch" => branch = Some(slice[1].to_string()),
1715                        "target-branch" => target_branch = Some(slice[1].to_string()),
1716                        _ => {}
1717                    }
1718                }
1719            }
1720
1721            prs.push(PullRequestListItem {
1722                event_id: pr_id,
1723                author_pubkey: event.pubkey.to_hex(),
1724                state,
1725                subject,
1726                commit_tip,
1727                branch,
1728                target_branch,
1729                created_at: event.created_at.as_secs(),
1730            });
1731        }
1732
1733        // Newest first; tie-break by event id for deterministic output.
1734        prs.sort_by(|left, right| {
1735            right
1736                .created_at
1737                .cmp(&left.created_at)
1738                .then_with(|| right.event_id.cmp(&left.event_id))
1739        });
1740
1741        debug!(
1742            "Found {} PRs for {} (filter: {:?})",
1743            prs.len(),
1744            repo_name,
1745            state_filter
1746        );
1747        Ok(prs)
1748    }
1749
1750    /// Publish a kind 1631 (STATUS_APPLIED) event to mark a PR as merged
1751    pub fn publish_pr_merged_status(
1752        &self,
1753        pr_event_id: &str,
1754        pr_author_pubkey: &str,
1755    ) -> Result<()> {
1756        let keys = self
1757            .keys
1758            .as_ref()
1759            .context("Cannot publish status: no secret key")?;
1760
1761        block_on_result(self.publish_pr_merged_status_async(keys, pr_event_id, pr_author_pubkey))
1762    }
1763
1764    async fn publish_pr_merged_status_async(
1765        &self,
1766        keys: &Keys,
1767        pr_event_id: &str,
1768        pr_author_pubkey: &str,
1769    ) -> Result<()> {
1770        let client = Client::new(keys.clone());
1771
1772        for relay in &self.relays {
1773            if let Err(e) = client.add_relay(relay).await {
1774                warn!("Failed to add relay {}: {}", relay, e);
1775            }
1776        }
1777        client.connect().await;
1778
1779        // Wait for at least one relay
1780        if !wait_for_any_connected_relay(&client, Duration::from_secs(3)).await {
1781            anyhow::bail!("Failed to connect to any relay for status publish");
1782        }
1783
1784        let tags = vec![
1785            Tag::custom(TagKind::custom("e"), vec![pr_event_id.to_string()]),
1786            Tag::custom(TagKind::custom("p"), vec![pr_author_pubkey.to_string()]),
1787        ];
1788
1789        let event = EventBuilder::new(Kind::Custom(KIND_STATUS_APPLIED), "")
1790            .tags(tags)
1791            .sign_with_keys(keys)
1792            .map_err(|e| anyhow::anyhow!("Failed to sign status event: {}", e))?;
1793
1794        let publish_result = match client.send_event(&event).await {
1795            Ok(output) => {
1796                if output.success.is_empty() {
1797                    Err(anyhow::anyhow!(
1798                        "PR merged status was not confirmed by any relay"
1799                    ))
1800                } else {
1801                    info!(
1802                        "Published PR merged status to {} relays",
1803                        output.success.len()
1804                    );
1805                    Ok(())
1806                }
1807            }
1808            Err(e) => Err(anyhow::anyhow!("Failed to publish PR merged status: {}", e)),
1809        };
1810
1811        let _ = client.disconnect().await;
1812        tokio::time::sleep(Duration::from_millis(50)).await;
1813        publish_result
1814    }
1815
1816    /// Upload blob to blossom server
1817    #[allow(dead_code)]
1818    pub async fn upload_blob(&self, _hash: &str, data: &[u8]) -> Result<String> {
1819        let hash = self
1820            .blossom
1821            .upload(data)
1822            .await
1823            .map_err(|e| anyhow::anyhow!("Blossom upload failed: {}", e))?;
1824        Ok(hash)
1825    }
1826
1827    /// Upload blob only if it doesn't exist
1828    #[allow(dead_code)]
1829    pub async fn upload_blob_if_missing(&self, data: &[u8]) -> Result<(String, bool)> {
1830        self.blossom
1831            .upload_if_missing(data)
1832            .await
1833            .map_err(|e| anyhow::anyhow!("Blossom upload failed: {}", e))
1834    }
1835
1836    /// Download blob from blossom server
1837    #[allow(dead_code)]
1838    pub async fn download_blob(&self, hash: &str) -> Result<Vec<u8>> {
1839        self.blossom
1840            .download(hash)
1841            .await
1842            .map_err(|e| anyhow::anyhow!("Blossom download failed: {}", e))
1843    }
1844
1845    /// Try to download blob, returns None if not found
1846    #[allow(dead_code)]
1847    pub async fn try_download_blob(&self, hash: &str) -> Option<Vec<u8>> {
1848        self.blossom.try_download(hash).await
1849    }
1850}
1851
1852#[cfg(test)]
1853mod tests;