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};
64use repo_metadata::{
65    append_repo_discovery_labels, build_git_repo_list_filter, build_repo_announcement_filter,
66    build_repo_event_filter, extract_repo_announcement_euc, latest_repo_announcement_created_at,
67    latest_repo_event_created_at, latest_trusted_pr_status_kinds, list_git_repo_announcements,
68    next_replaceable_created_at, pick_latest_event, pick_latest_repo_event,
69    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/// Optional NIP-34 metadata to publish alongside the htree root event.
285#[derive(Debug, Clone, Default, PartialEq, Eq)]
286pub struct RepoAnnouncementOptions {
287    /// NIP-34 earliest unique commit. Usually the root commit; forks should keep the source euc.
288    pub earliest_unique_commit: Option<String>,
289    /// Mark the repo announcement as a personal fork (`["t", "personal-fork"]`).
290    pub personal_fork: bool,
291    /// Iris/htree extension for showing the exact htree source URL.
292    pub forked_from: Option<String>,
293}
294
295/// Result of uploading to blossom servers
296#[derive(Debug, Clone)]
297pub struct BlossomResult {
298    /// Servers that were configured
299    #[allow(dead_code)]
300    pub configured: Vec<String>,
301    /// Servers that accepted uploads
302    pub succeeded: Vec<String>,
303    /// Servers that failed
304    pub failed: Vec<String>,
305    /// Whether the repo tree is complete in the local hashtree store.
306    pub local_complete: bool,
307    /// Whether remote replication was incomplete but the local push can still be published.
308    pub degraded: bool,
309}
310
311/// Nostr client for git operations
312pub struct NostrClient {
313    pubkey: String,
314    /// nostr-sdk Keys for signing
315    keys: Option<Keys>,
316    relays: Vec<String>,
317    blossom: BlossomClient,
318    /// Cached refs from remote
319    cached_refs: HashMap<String, HashMap<String, String>>,
320    /// Cached root hashes (hashtree SHA256)
321    cached_root_hash: HashMap<String, String>,
322    /// Cached encryption keys
323    cached_encryption_key: HashMap<String, [u8; 32]>,
324    /// Source of the cached root hash, used to retry tentative daemon roots via relays.
325    cached_root_source: HashMap<String, RootResolveSource>,
326    /// URL secret for link-visible repos (#k=<hex>)
327    /// If set, encryption keys from nostr are XOR-masked and need unmasking
328    url_secret: Option<[u8; 32]>,
329    /// Whether this is a private (author-only) repo using NIP-44 encryption
330    is_private: bool,
331    /// Local htree daemon URL for peer-assisted root discovery
332    local_daemon_url: Option<String>,
333}
334
335#[derive(Debug, Clone, Default)]
336struct RootEventData {
337    root_hash: String,
338    encryption_key: Option<[u8; 32]>,
339    key_tag_name: Option<String>,
340    self_encrypted_ciphertext: Option<String>,
341    source: RootResolveSource,
342}
343
344#[derive(Debug, Deserialize)]
345struct DaemonResolveResponse {
346    hash: Option<String>,
347    #[serde(default)]
348    cid: Option<String>,
349    #[serde(default, rename = "key_tag")]
350    key: Option<String>,
351    #[serde(default, rename = "encryptedKey")]
352    encrypted_key: Option<String>,
353    #[serde(default, rename = "selfEncryptedKey")]
354    self_encrypted_key: Option<String>,
355    #[serde(default)]
356    source: Option<String>,
357}
358
359impl NostrClient {
360    /// Create a new client with pubkey, optional secret key, url secret, is_private flag, and config
361    pub fn new(
362        pubkey: &str,
363        secret_key: Option<String>,
364        url_secret: Option<[u8; 32]>,
365        is_private: bool,
366        config: &Config,
367    ) -> Result<Self> {
368        // Ensure rustls has a process-wide crypto provider even when used as a library (tests).
369        let _ = rustls::crypto::ring::default_provider().install_default();
370
371        // Use provided secret, or try environment variable
372        let secret_key = secret_key.or_else(|| std::env::var("NOSTR_SECRET_KEY").ok());
373
374        // Create nostr-sdk Keys if we have a secret
375        let keys = if let Some(ref secret_hex) = secret_key {
376            let secret_bytes = hex::decode(secret_hex).context("Invalid secret key hex")?;
377            let secret = nostr::SecretKey::from_slice(&secret_bytes)
378                .map_err(|e| anyhow::anyhow!("Invalid secret key: {}", e))?;
379            Some(Keys::new(secret))
380        } else {
381            None
382        };
383
384        // Create BlossomClient (needs keys for upload auth) from the resolved
385        // config passed by the helper instead of reloading defaults from disk.
386        let blossom_keys = keys.clone().unwrap_or_else(Keys::generate);
387        let mut read_servers = config.blossom.all_read_servers();
388        if let Some(local_url) =
389            hashtree_config::detect_local_daemon_url(Some(config.server.bind_address.as_str()))
390        {
391            if !read_servers.iter().any(|server| server == &local_url) {
392                read_servers.insert(0, local_url);
393            }
394        }
395        let blossom = BlossomClient::new_empty(blossom_keys)
396            .with_read_servers(read_servers)
397            .with_write_servers(config.blossom.all_write_servers())
398            .with_timeout(Duration::from_secs(30));
399
400        tracing::info!(
401            "BlossomClient created with read_servers: {:?}, write_servers: {:?}",
402            blossom.read_servers(),
403            blossom.write_servers()
404        );
405
406        let relays = hashtree_config::resolve_relays(
407            &config.nostr.relays,
408            Some(config.server.bind_address.as_str()),
409        );
410        let local_daemon_url =
411            hashtree_config::detect_local_daemon_url(Some(config.server.bind_address.as_str()))
412                .or_else(|| {
413                    config
414                        .blossom
415                        .read_servers
416                        .iter()
417                        .find(|url| {
418                            url.starts_with("http://127.0.0.1:")
419                                || url.starts_with("http://localhost:")
420                        })
421                        .cloned()
422                });
423
424        Ok(Self {
425            pubkey: pubkey.to_string(),
426            keys,
427            relays,
428            blossom,
429            cached_refs: HashMap::new(),
430            cached_root_hash: HashMap::new(),
431            cached_encryption_key: HashMap::new(),
432            cached_root_source: HashMap::new(),
433            url_secret,
434            is_private,
435            local_daemon_url,
436        })
437    }
438
439    fn format_repo_author(pubkey_hex: &str) -> String {
440        PublicKey::from_hex(pubkey_hex)
441            .ok()
442            .and_then(|pk| pk.to_bech32().ok())
443            .unwrap_or_else(|| pubkey_hex.to_string())
444    }
445
446    fn daemon_pubkey_identifier(&self) -> String {
447        Self::format_repo_author(&self.pubkey)
448    }
449
450    /// Check if we can sign (have secret key for this pubkey)
451    #[allow(dead_code)]
452    pub fn can_sign(&self) -> bool {
453        self.keys.is_some()
454    }
455
456    pub fn list_repos(&self) -> Result<Vec<String>> {
457        block_on_result(self.list_repos_async())
458    }
459
460    pub async fn list_repos_async(&self) -> Result<Vec<String>> {
461        let client = Client::default();
462
463        for relay in &self.relays {
464            if let Err(e) = client.add_relay(relay).await {
465                warn!("Failed to add relay {}: {}", relay, e);
466            }
467        }
468        client.connect().await;
469
470        if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
471            let _ = client.disconnect().await;
472            return Err(anyhow::anyhow!(
473                "Failed to connect to any relay while listing repos"
474            ));
475        }
476
477        let author = PublicKey::from_hex(&self.pubkey)
478            .map_err(|e| anyhow::anyhow!("Invalid pubkey: {}", e))?;
479        let filter = build_git_repo_list_filter(author);
480
481        let events = match tokio::time::timeout(
482            Duration::from_secs(3),
483            client.fetch_events(filter, Duration::from_secs(3)),
484        )
485        .await
486        {
487            Ok(Ok(events)) => events,
488            Ok(Err(e)) => {
489                let _ = client.disconnect().await;
490                return Err(anyhow::anyhow!(
491                    "Failed to fetch git repo events from relays: {}",
492                    e
493                ));
494            }
495            Err(_) => {
496                let _ = client.disconnect().await;
497                return Err(anyhow::anyhow!(
498                    "Timed out fetching git repo events from relays"
499                ));
500            }
501        };
502
503        let _ = client.disconnect().await;
504        let events = events.to_vec();
505
506        Ok(list_git_repo_announcements(&events)
507            .into_iter()
508            .map(|repo| repo.repo_name)
509            .collect())
510    }
511
512    /// Fetch refs for a repository from nostr
513    /// Returns refs parsed from the hashtree at the root hash
514    pub fn fetch_refs(&mut self, repo_name: &str) -> Result<HashMap<String, String>> {
515        let (refs, _, _) = self.fetch_refs_with_timeout(repo_name, 10)?;
516        Ok(refs)
517    }
518
519    /// Fetch refs with a quick timeout (3s) for push operations
520    /// Returns empty if timeout - allows push to proceed
521    #[allow(dead_code)]
522    pub fn fetch_refs_quick(&mut self, repo_name: &str) -> Result<HashMap<String, String>> {
523        let (refs, _, _) = self.fetch_refs_with_timeout(repo_name, 3)?;
524        Ok(refs)
525    }
526
527    /// Fetch refs and root hash info from nostr
528    /// Returns (refs, root_hash, encryption_key)
529    #[allow(dead_code)]
530    pub fn fetch_refs_with_root(&mut self, repo_name: &str) -> Result<FetchedRefs> {
531        self.fetch_refs_with_timeout(repo_name, 10)
532    }
533
534    pub(crate) fn refetch_refs_without_local_daemon(
535        &mut self,
536        repo_name: &str,
537        timeout_secs: u64,
538    ) -> Result<FetchedRefs> {
539        self.clear_cached_remote_state(repo_name);
540        self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, false)
541    }
542
543    fn clear_cached_remote_state(&mut self, repo_name: &str) {
544        self.cached_refs.remove(repo_name);
545        self.cached_root_hash.remove(repo_name);
546        self.cached_encryption_key.remove(repo_name);
547        self.cached_root_source.remove(repo_name);
548    }
549
550    fn cache_resolved_root(&mut self, repo_name: &str, resolved: &ResolvedRoot) {
551        if let Some(ref root) = resolved.root_hash {
552            self.cached_root_hash
553                .insert(repo_name.to_string(), root.clone());
554        } else {
555            self.cached_root_hash.remove(repo_name);
556        }
557
558        if let Some(key) = resolved.encryption_key {
559            self.cached_encryption_key
560                .insert(repo_name.to_string(), key);
561        } else {
562            self.cached_encryption_key.remove(repo_name);
563        }
564
565        if let Some(source) = resolved.source {
566            self.cached_root_source
567                .insert(repo_name.to_string(), source);
568        } else {
569            self.cached_root_source.remove(repo_name);
570        }
571    }
572
573    /// Fetch refs with configurable timeout
574    fn fetch_refs_with_timeout(
575        &mut self,
576        repo_name: &str,
577        timeout_secs: u64,
578    ) -> Result<FetchedRefs> {
579        debug!(
580            "Fetching refs for {} from {} (timeout {}s)",
581            repo_name, self.pubkey, timeout_secs
582        );
583
584        // Check cache first
585        if let Some(refs) = self.cached_refs.get(repo_name) {
586            let root = self.cached_root_hash.get(repo_name).cloned();
587            let key = self.cached_encryption_key.get(repo_name).cloned();
588            return Ok((refs.clone(), root, key));
589        }
590
591        self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, true)
592    }
593
594    fn fetch_refs_with_timeout_uncached(
595        &mut self,
596        repo_name: &str,
597        timeout_secs: u64,
598        allow_local_daemon: bool,
599    ) -> Result<FetchedRefs> {
600        let resolved = block_on_result(self.resolve_root_async_with_timeout(
601            repo_name,
602            timeout_secs,
603            allow_local_daemon,
604        ))?;
605
606        match self.fetch_refs_for_resolved_root(repo_name, &resolved) {
607            Ok(fetched) => Ok(fetched),
608            Err(err)
609                if resolved.source == Some(RootResolveSource::LocalDaemon)
610                    && allow_local_daemon =>
611            {
612                warn!(
613                    "Local daemon root for {} could not be read as a valid git tree: {}. Retrying via relays.",
614                    repo_name, err
615                );
616                self.clear_cached_remote_state(repo_name);
617                self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, false)
618            }
619            Err(err) => Err(err),
620        }
621    }
622
623    fn fetch_refs_for_resolved_root(
624        &mut self,
625        repo_name: &str,
626        resolved: &ResolvedRoot,
627    ) -> Result<FetchedRefs> {
628        if resolved.source != Some(RootResolveSource::LocalDaemon) {
629            self.cache_resolved_root(repo_name, resolved);
630        }
631
632        let refs = if let Some(ref root) = resolved.root_hash {
633            block_on_result(self.fetch_refs_from_hashtree(root, resolved.encryption_key.as_ref()))?
634        } else {
635            HashMap::new()
636        };
637
638        self.cache_resolved_root(repo_name, resolved);
639        self.cached_refs.insert(repo_name.to_string(), refs.clone());
640        Ok((refs, resolved.root_hash.clone(), resolved.encryption_key))
641    }
642
643    fn parse_root_event_data_from_event(event: &Event) -> RootEventData {
644        let root_hash = event
645            .tags
646            .iter()
647            .find(|t| t.as_slice().len() >= 2 && t.as_slice()[0].as_str() == "hash")
648            .map(|t| t.as_slice()[1].to_string())
649            .unwrap_or_else(|| event.content.to_string());
650
651        let (encryption_key, key_tag_name, self_encrypted_ciphertext) = event
652            .tags
653            .iter()
654            .find_map(|t| {
655                let slice = t.as_slice();
656                if slice.len() < 2 {
657                    return None;
658                }
659                let tag_name = slice[0].as_str();
660                let tag_value = slice[1].to_string();
661                if tag_name == "selfEncryptedKey" {
662                    return Some((None, Some(tag_name.to_string()), Some(tag_value)));
663                }
664                if tag_name == "key" || tag_name == "encryptedKey" {
665                    if let Ok(bytes) = hex::decode(&tag_value) {
666                        if bytes.len() == 32 {
667                            let mut key = [0u8; 32];
668                            key.copy_from_slice(&bytes);
669                            return Some((Some(key), Some(tag_name.to_string()), None));
670                        }
671                    }
672                }
673                None
674            })
675            .unwrap_or((None, None, None));
676
677        RootEventData {
678            root_hash,
679            encryption_key,
680            key_tag_name,
681            self_encrypted_ciphertext,
682            source: RootResolveSource::Relay,
683        }
684    }
685
686    fn parse_daemon_response_to_root_data(
687        response: DaemonResolveResponse,
688    ) -> Option<RootEventData> {
689        let parsed_cid = response.cid.as_deref().and_then(|cid| Cid::parse(cid).ok());
690        let root_hash = response
691            .hash
692            .or_else(|| parsed_cid.as_ref().map(|cid| hex::encode(cid.hash)))?;
693        if root_hash.is_empty() {
694            return None;
695        }
696
697        let mut data = RootEventData {
698            root_hash,
699            encryption_key: parsed_cid.and_then(|cid| cid.key),
700            key_tag_name: None,
701            self_encrypted_ciphertext: None,
702            source: RootResolveSource::LocalDaemon,
703        };
704
705        if let Some(ciphertext) = response.self_encrypted_key {
706            data.key_tag_name = Some("selfEncryptedKey".to_string());
707            data.self_encrypted_ciphertext = Some(ciphertext);
708            return Some(data);
709        }
710
711        let (tag_name, tag_value) = if let Some(v) = response.encrypted_key {
712            ("encryptedKey", v)
713        } else if let Some(v) = response.key {
714            ("key", v)
715        } else {
716            return Some(data);
717        };
718
719        if let Ok(bytes) = hex::decode(&tag_value) {
720            if bytes.len() == 32 {
721                let mut key = [0u8; 32];
722                key.copy_from_slice(&bytes);
723                data.encryption_key = Some(key);
724                data.key_tag_name = Some(tag_name.to_string());
725            }
726        }
727
728        Some(data)
729    }
730
731    async fn fetch_root_from_local_daemon(
732        &self,
733        repo_name: &str,
734        timeout: Duration,
735    ) -> Option<RootEventData> {
736        let base = self.local_daemon_url.as_ref()?;
737        let pubkey = self.daemon_pubkey_identifier();
738        let url = format!(
739            "{}/api/nostr/resolve/{}/{}",
740            base.trim_end_matches('/'),
741            pubkey,
742            repo_name
743        );
744
745        let client = reqwest::Client::builder().timeout(timeout).build().ok()?;
746        let response = client.get(&url).send().await.ok()?;
747        if !response.status().is_success() {
748            return None;
749        }
750
751        let payload: DaemonResolveResponse = response.json().await.ok()?;
752        let source = payload
753            .source
754            .clone()
755            .unwrap_or_else(|| "unknown".to_string());
756        let parsed = Self::parse_daemon_response_to_root_data(payload)?;
757        debug!(
758            "Resolved repo {} via local daemon source={}",
759            repo_name, source
760        );
761        Some(parsed)
762    }
763
764    async fn cache_public_root_in_local_daemon(
765        &self,
766        repo_name: &str,
767        root_hash: &str,
768        encryption_key: Option<(&[u8; 32], bool, bool)>,
769    ) {
770        let Some(base) = self.local_daemon_url.as_ref() else {
771            return;
772        };
773
774        let key = match encryption_key {
775            Some((key, false, false)) => Some(hex::encode(key)),
776            Some((_key, _is_link_visible, _is_self_private)) => return,
777            None => None,
778        };
779
780        let url = format!("{}/api/cache-tree-root", base.trim_end_matches('/'));
781        let pubkey = self.daemon_pubkey_identifier();
782        let payload = serde_json::json!({
783            "npub": pubkey,
784            "treeName": repo_name,
785            "hash": root_hash,
786            "key": key,
787            "visibility": "public",
788        });
789
790        let client = match reqwest::Client::builder()
791            .timeout(Duration::from_secs(2))
792            .build()
793        {
794            Ok(client) => client,
795            Err(err) => {
796                debug!("Could not build local daemon cache client: {}", err);
797                return;
798            }
799        };
800
801        match client.post(url).json(&payload).send().await {
802            Ok(response) if response.status().is_success() => {
803                debug!("Cached repo root for {} in local daemon", repo_name);
804            }
805            Ok(response) => {
806                debug!(
807                    "Local daemon root cache returned status {} for {}",
808                    response.status(),
809                    repo_name
810                );
811            }
812            Err(err) => {
813                debug!("Could not cache repo root in local daemon: {}", err);
814            }
815        }
816    }
817
818    async fn resolve_root_async_with_timeout(
819        &self,
820        repo_name: &str,
821        timeout_secs: u64,
822        allow_local_daemon: bool,
823    ) -> Result<ResolvedRoot> {
824        // Create nostr-sdk client
825        let client = Client::default();
826
827        // Add relays
828        for relay in &self.relays {
829            if let Err(e) = client.add_relay(relay).await {
830                warn!("Failed to add relay {}: {}", relay, e);
831            }
832        }
833
834        // Connect to relays - this starts async connection
835        client.connect().await;
836
837        let connect_timeout = Duration::from_secs(2);
838        let query_timeout = Duration::from_secs(timeout_secs.saturating_sub(2).max(3));
839        let local_daemon_timeout = Duration::from_secs(4);
840        let retry_delay = Duration::from_millis(300);
841        let max_attempts = 2;
842
843        let start = std::time::Instant::now();
844
845        // Build filter for kind 30078 events from this author with matching d-tag
846        let author = PublicKey::from_hex(&self.pubkey)
847            .map_err(|e| anyhow::anyhow!("Invalid pubkey: {}", e))?;
848
849        let filter = build_repo_event_filter(author, repo_name);
850
851        debug!("Querying relays for repo {} events", repo_name);
852
853        let mut root_data = None;
854        for attempt in 1..=max_attempts {
855            if allow_local_daemon {
856                if let Some(data) = self
857                    .fetch_root_from_local_daemon(repo_name, local_daemon_timeout)
858                    .await
859                {
860                    root_data = Some(data);
861                    break;
862                }
863            }
864
865            if !allow_local_daemon && attempt == 1 {
866                debug!(
867                    "Skipping local daemon while resolving {} because relay retry was requested",
868                    repo_name
869                );
870            }
871
872            if allow_local_daemon {
873                debug!(
874                    "Local daemon did not resolve {}; querying relays",
875                    repo_name
876                );
877            }
878
879            // Wait for at least one relay to connect (quick timeout - break immediately when one
880            // connects). We retry once because relays and the local daemon can both lag briefly.
881            let connect_start = std::time::Instant::now();
882            let mut last_log = std::time::Instant::now();
883            let mut has_connected_relay = false;
884            loop {
885                let (connected, total) = connected_relay_count(&client).await;
886                if connected > 0 {
887                    debug!(
888                        "Connected to {}/{} relay(s) in {:?} (attempt {}/{})",
889                        connected,
890                        total,
891                        start.elapsed(),
892                        attempt,
893                        max_attempts
894                    );
895                    has_connected_relay = true;
896                    break;
897                }
898                if last_log.elapsed() > Duration::from_millis(500) {
899                    debug!(
900                        "Connecting to relays... (0/{} after {:?}, attempt {}/{})",
901                        total,
902                        start.elapsed(),
903                        attempt,
904                        max_attempts
905                    );
906                    last_log = std::time::Instant::now();
907                }
908                if connect_start.elapsed() > connect_timeout {
909                    debug!(
910                        "Timeout waiting for relay connections - continuing with local-daemon fallback"
911                    );
912                    break;
913                }
914                tokio::time::sleep(Duration::from_millis(50)).await;
915            }
916
917            // Query with relay-level timeout.
918            // Using `EventSource::relays(Some(...))` preserves partial results from responsive
919            // relays instead of discarding everything when one relay stalls.
920            let events = if has_connected_relay {
921                match client.fetch_events(filter.clone(), query_timeout).await {
922                    Ok(events) => events.to_vec(),
923                    Err(e) => {
924                        warn!("Failed to fetch events: {}", e);
925                        vec![]
926                    }
927                }
928            } else {
929                vec![]
930            };
931
932            debug!(
933                "Got {} events from relays on attempt {}/{}",
934                events.len(),
935                attempt,
936                max_attempts
937            );
938            let relay_event = pick_latest_repo_event(events.iter(), repo_name);
939
940            if let Some(event) = relay_event {
941                debug!(
942                    "Found relay event with root hash: {}",
943                    &event.content[..12.min(event.content.len())]
944                );
945                root_data = Some(Self::parse_root_event_data_from_event(event));
946                break;
947            }
948
949            if attempt < max_attempts {
950                debug!(
951                    "No hashtree event found for {} on attempt {}/{}; retrying",
952                    repo_name, attempt, max_attempts
953                );
954                tokio::time::sleep(retry_delay).await;
955            }
956        }
957
958        // Disconnect
959        let _ = client.disconnect().await;
960
961        let root_data = match root_data {
962            Some(data) => data,
963            None => {
964                anyhow::bail!(
965                    "Repository '{}' not found (no hashtree event published by {})",
966                    repo_name,
967                    Self::format_repo_author(&self.pubkey)
968                );
969            }
970        };
971
972        let root_hash = root_data.root_hash;
973
974        if root_hash.is_empty() {
975            debug!("Empty root hash in event");
976            return Ok(ResolvedRoot {
977                root_hash: None,
978                encryption_key: None,
979                source: None,
980            });
981        }
982
983        let encryption_key = root_data.encryption_key;
984        let key_tag_name = root_data.key_tag_name;
985        let self_encrypted_ciphertext = root_data.self_encrypted_ciphertext;
986        let root_source = root_data.source;
987
988        // Process encryption key based on tag type
989        let unmasked_key = match key_tag_name.as_deref() {
990            Some("encryptedKey") => {
991                // Link-visible: XOR the masked key with url_secret
992                if let (Some(masked), Some(secret)) = (encryption_key, self.url_secret) {
993                    let mut unmasked = [0u8; 32];
994                    for i in 0..32 {
995                        unmasked[i] = masked[i] ^ secret[i];
996                    }
997                    Some(unmasked)
998                } else {
999                    anyhow::bail!(
1000                        "This repo is link-visible and requires a secret key.\n\
1001                         Use: htree://.../{repo_name}#k=<secret>\n\
1002                         Ask the repo owner for the full URL with the secret."
1003                    );
1004                }
1005            }
1006            Some("selfEncryptedKey") => {
1007                // Private: only decrypt if #private is in the URL
1008                if !self.is_private {
1009                    anyhow::bail!(
1010                        "This repo is private (author-only).\n\
1011                         Use: htree://.../{repo_name}#private\n\
1012                         Only the author can access this repo."
1013                    );
1014                }
1015
1016                // Decrypt with NIP-44 using our secret key
1017                if let Some(keys) = &self.keys {
1018                    if let Some(ciphertext) = self_encrypted_ciphertext {
1019                        // Decrypt with NIP-44 (encrypted to self)
1020                        let pubkey = keys.public_key();
1021                        match nip44::decrypt(keys.secret_key(), &pubkey, &ciphertext) {
1022                            Ok(key_hex) => {
1023                                let key_bytes =
1024                                    hex::decode(&key_hex).context("Invalid decrypted key hex")?;
1025                                if key_bytes.len() != 32 {
1026                                    anyhow::bail!("Decrypted key wrong length");
1027                                }
1028                                let mut key = [0u8; 32];
1029                                key.copy_from_slice(&key_bytes);
1030                                Some(key)
1031                            }
1032                            Err(e) => {
1033                                anyhow::bail!(
1034                                    "Failed to decrypt private repo: {}\n\
1035                                     The repo may be corrupted or published with a different key.",
1036                                    e
1037                                );
1038                            }
1039                        }
1040                    } else {
1041                        anyhow::bail!("selfEncryptedKey tag has invalid format");
1042                    }
1043                } else {
1044                    anyhow::bail!(
1045                        "Cannot access this private repo.\n\
1046                         Private repos can only be accessed by their author.\n\
1047                         You don't have the secret key for this repo's owner."
1048                    );
1049                }
1050            }
1051            Some("key") | None => {
1052                // Public: use key directly
1053                encryption_key
1054            }
1055            Some(other) => {
1056                warn!("Unknown key tag type: {}", other);
1057                encryption_key
1058            }
1059        };
1060
1061        info!(
1062            "Found root hash {} for {} (encrypted: {}, link_visible: {})",
1063            &root_hash[..12.min(root_hash.len())],
1064            repo_name,
1065            unmasked_key.is_some(),
1066            self.url_secret.is_some()
1067        );
1068
1069        Ok(ResolvedRoot {
1070            root_hash: Some(root_hash),
1071            encryption_key: unmasked_key,
1072            source: Some(root_source),
1073        })
1074    }
1075
1076    /// Decrypt data if encryption key is provided, then decode as tree node
1077    fn decrypt_and_decode(&self, data: &[u8], key: Option<&[u8; 32]>) -> Result<TreeNode> {
1078        let decrypted_data: Vec<u8>;
1079        let data_to_decode = if let Some(k) = key {
1080            decrypted_data = decrypt_chk(data, k).context("Decryption failed")?;
1081            &decrypted_data
1082        } else {
1083            data
1084        };
1085
1086        decode_tree_node(data_to_decode).context("Failed to decode tree node")
1087    }
1088
1089    /// Fetch git refs from hashtree structure
1090    /// Structure: root -> .git/ -> refs/ -> heads/main -> <sha>
1091    async fn fetch_refs_from_hashtree(
1092        &self,
1093        root_hash: &str,
1094        encryption_key: Option<&[u8; 32]>,
1095    ) -> Result<HashMap<String, String>> {
1096        let mut refs = HashMap::new();
1097        debug!(
1098            "fetch_refs_from_hashtree: downloading root {}",
1099            &root_hash[..12]
1100        );
1101
1102        // Download root directory from Blossom - propagate errors properly
1103        let root_data = match self.blossom.download(root_hash).await {
1104            Ok(data) => {
1105                debug!("Downloaded {} bytes from blossom", data.len());
1106                data
1107            }
1108            Err(e) => {
1109                anyhow::bail!(
1110                    "Failed to download root hash {}: {}",
1111                    &root_hash[..12.min(root_hash.len())],
1112                    e
1113                );
1114            }
1115        };
1116
1117        // Parse root as directory node (decrypt if needed)
1118        let root_node = self
1119            .decrypt_and_decode(&root_data, encryption_key)
1120            .with_context(|| {
1121                format!(
1122                    "Failed to decode root node {} (encrypted: {})",
1123                    &root_hash[..12.min(root_hash.len())],
1124                    encryption_key.is_some()
1125                )
1126            })?;
1127        debug!("Decoded root node with {} links", root_node.links.len());
1128
1129        // Find .git directory
1130        debug!(
1131            "Root links: {:?}",
1132            root_node
1133                .links
1134                .iter()
1135                .map(|l| l.name.as_deref())
1136                .collect::<Vec<_>>()
1137        );
1138        let git_link = root_node
1139            .links
1140            .iter()
1141            .find(|l| l.name.as_deref() == Some(".git"));
1142        let (git_hash, git_key) = match git_link {
1143            Some(link) => {
1144                debug!("Found .git link with key: {}", link.key.is_some());
1145                (hex::encode(link.hash), link.key)
1146            }
1147            None => {
1148                debug!("No .git directory in hashtree root");
1149                return Ok(refs);
1150            }
1151        };
1152
1153        // Download .git directory
1154        let git_data = match self.blossom.download(&git_hash).await {
1155            Ok(data) => data,
1156            Err(e) => {
1157                anyhow::bail!(
1158                    "Failed to download .git directory ({}): {}",
1159                    &git_hash[..12],
1160                    e
1161                );
1162            }
1163        };
1164
1165        let git_node = self
1166            .decrypt_and_decode(&git_data, git_key.as_ref())
1167            .with_context(|| {
1168                format!(
1169                    "Failed to decode .git directory {} (encrypted: {})",
1170                    &git_hash[..12.min(git_hash.len())],
1171                    git_key.is_some()
1172                )
1173            })?;
1174        debug!(
1175            "Decoded .git node with {} links: {:?}",
1176            git_node.links.len(),
1177            git_node
1178                .links
1179                .iter()
1180                .map(|l| l.name.as_deref())
1181                .collect::<Vec<_>>()
1182        );
1183
1184        // Find refs directory
1185        let refs_link = git_node
1186            .links
1187            .iter()
1188            .find(|l| l.name.as_deref() == Some("refs"));
1189        let (refs_hash, refs_key) = match refs_link {
1190            Some(link) => (hex::encode(link.hash), link.key),
1191            None => {
1192                debug!("No refs directory in .git");
1193                return Ok(refs);
1194            }
1195        };
1196
1197        // Download refs directory
1198        let refs_data =
1199            self.blossom.download(&refs_hash).await.with_context(|| {
1200                format!("Failed to download refs directory {}", &refs_hash[..12])
1201            })?;
1202
1203        let refs_node = self
1204            .decrypt_and_decode(&refs_data, refs_key.as_ref())
1205            .with_context(|| {
1206                format!(
1207                    "Failed to decode refs directory {} (encrypted: {})",
1208                    &refs_hash[..12.min(refs_hash.len())],
1209                    refs_key.is_some()
1210                )
1211            })?;
1212
1213        // Look for HEAD in .git directory
1214        if let Some(head_link) = git_node
1215            .links
1216            .iter()
1217            .find(|l| l.name.as_deref() == Some("HEAD"))
1218        {
1219            let head_hash = hex::encode(head_link.hash);
1220            let head_data = self
1221                .blossom
1222                .download(&head_hash)
1223                .await
1224                .with_context(|| format!("Failed to download HEAD {}", &head_hash[..12]))?;
1225            // HEAD is a blob, decrypt if needed
1226            let head_content = if let Some(k) = head_link.key.as_ref() {
1227                let decrypted = decrypt_chk(&head_data, k)
1228                    .with_context(|| format!("Failed to decrypt HEAD {}", &head_hash[..12]))?;
1229                String::from_utf8_lossy(&decrypted).trim().to_string()
1230            } else {
1231                String::from_utf8_lossy(&head_data).trim().to_string()
1232            };
1233            refs.insert("HEAD".to_string(), head_content);
1234        }
1235
1236        // Recursively walk refs/ subdirectories (heads, tags, etc.)
1237        for subdir_link in &refs_node.links {
1238            if subdir_link.link_type != LinkType::Dir {
1239                continue;
1240            }
1241            let subdir_name = match &subdir_link.name {
1242                Some(n) => n.clone(),
1243                None => continue,
1244            };
1245            let subdir_hash = hex::encode(subdir_link.hash);
1246
1247            self.collect_refs_recursive(
1248                &subdir_hash,
1249                subdir_link.key.as_ref(),
1250                &format!("refs/{}", subdir_name),
1251                &mut refs,
1252            )
1253            .await?;
1254        }
1255
1256        debug!("Found {} refs from hashtree", refs.len());
1257        Ok(refs)
1258    }
1259
1260    /// Recursively collect refs from a directory
1261    async fn collect_refs_recursive(
1262        &self,
1263        dir_hash: &str,
1264        dir_key: Option<&[u8; 32]>,
1265        prefix: &str,
1266        refs: &mut HashMap<String, String>,
1267    ) -> Result<()> {
1268        let dir_data = self
1269            .blossom
1270            .download(dir_hash)
1271            .await
1272            .with_context(|| format!("Failed to download refs subtree {}", &dir_hash[..12]))?;
1273
1274        let dir_node = self
1275            .decrypt_and_decode(&dir_data, dir_key)
1276            .with_context(|| {
1277                format!(
1278                    "Failed to decode refs subtree {} (encrypted: {})",
1279                    &dir_hash[..12.min(dir_hash.len())],
1280                    dir_key.is_some()
1281                )
1282            })?;
1283
1284        for link in &dir_node.links {
1285            let name = match &link.name {
1286                Some(n) => n.clone(),
1287                None => continue,
1288            };
1289            let link_hash = hex::encode(link.hash);
1290            let ref_path = format!("{}/{}", prefix, name);
1291
1292            if link.link_type == LinkType::Dir {
1293                // Recurse into subdirectory
1294                Box::pin(self.collect_refs_recursive(
1295                    &link_hash,
1296                    link.key.as_ref(),
1297                    &ref_path,
1298                    refs,
1299                ))
1300                .await?;
1301            } else {
1302                // This is a ref file - read the SHA
1303                let ref_data = self
1304                    .blossom
1305                    .download(&link_hash)
1306                    .await
1307                    .with_context(|| format!("Failed to download ref {}", ref_path))?;
1308                // Decrypt if needed
1309                let sha = if let Some(k) = link.key.as_ref() {
1310                    let decrypted = decrypt_chk(&ref_data, k)
1311                        .with_context(|| format!("Failed to decrypt ref {}", ref_path))?;
1312                    String::from_utf8_lossy(&decrypted).trim().to_string()
1313                } else {
1314                    String::from_utf8_lossy(&ref_data).trim().to_string()
1315                };
1316                if !sha.is_empty() {
1317                    debug!("Found ref {} -> {}", ref_path, sha);
1318                    refs.insert(ref_path, sha);
1319                }
1320            }
1321        }
1322
1323        Ok(())
1324    }
1325
1326    /// Update a ref in local cache (will be published with publish_repo)
1327    #[allow(dead_code)]
1328    pub fn update_ref(&mut self, repo_name: &str, ref_name: &str, sha: &str) -> Result<()> {
1329        info!("Updating ref {} -> {} for {}", ref_name, sha, repo_name);
1330
1331        let refs = self.cached_refs.entry(repo_name.to_string()).or_default();
1332        refs.insert(ref_name.to_string(), sha.to_string());
1333
1334        Ok(())
1335    }
1336
1337    /// Delete a ref from local cache
1338    pub fn delete_ref(&mut self, repo_name: &str, ref_name: &str) -> Result<()> {
1339        info!("Deleting ref {} for {}", ref_name, repo_name);
1340
1341        if let Some(refs) = self.cached_refs.get_mut(repo_name) {
1342            refs.remove(ref_name);
1343        }
1344
1345        Ok(())
1346    }
1347
1348    /// Get cached root hash for a repository
1349    pub fn get_cached_root_hash(&self, repo_name: &str) -> Option<&String> {
1350        self.cached_root_hash.get(repo_name)
1351    }
1352
1353    /// Get cached encryption key for a repository
1354    pub fn get_cached_encryption_key(&self, repo_name: &str) -> Option<&[u8; 32]> {
1355        self.cached_encryption_key.get(repo_name)
1356    }
1357
1358    pub(crate) fn cached_root_is_from_local_daemon(&self, repo_name: &str) -> bool {
1359        self.cached_root_source.get(repo_name) == Some(&RootResolveSource::LocalDaemon)
1360    }
1361
1362    /// Get the Blossom client for direct downloads
1363    pub fn blossom(&self) -> &BlossomClient {
1364        &self.blossom
1365    }
1366
1367    /// Get the configured relay URLs
1368    pub fn relay_urls(&self) -> Vec<String> {
1369        self.relays.clone()
1370    }
1371
1372    /// Get the public key (hex)
1373    #[allow(dead_code)]
1374    pub fn pubkey(&self) -> &str {
1375        &self.pubkey
1376    }
1377
1378    /// Get the public key as npub bech32
1379    pub fn npub(&self) -> String {
1380        PublicKey::from_hex(&self.pubkey)
1381            .ok()
1382            .and_then(|pk| pk.to_bech32().ok())
1383            .unwrap_or_else(|| self.pubkey.clone())
1384    }
1385
1386    /// Publish repository to nostr as kind 30078 event
1387    /// Format:
1388    ///   kind: 30078
1389    ///   tags: [["d", repo_name], ["l", "hashtree"], ["hash", root_hash], ["key"|"encryptedKey", encryption_key]]
1390    ///   content: <merkle-root-hash>
1391    /// Returns: (npub URL, relay result with connected/failed details)
1392    /// If is_private is true, uses "encryptedKey" tag (XOR masked); otherwise uses "key" tag (plaintext CHK)
1393    pub fn publish_repo(
1394        &self,
1395        repo_name: &str,
1396        root_hash: &str,
1397        encryption_key: Option<(&[u8; 32], bool, bool)>,
1398    ) -> Result<(String, RelayResult)> {
1399        self.publish_repo_with_announcement(repo_name, root_hash, encryption_key, None)
1400    }
1401
1402    pub fn publish_repo_with_announcement(
1403        &self,
1404        repo_name: &str,
1405        root_hash: &str,
1406        encryption_key: Option<(&[u8; 32], bool, bool)>,
1407        repo_announcement: Option<RepoAnnouncementOptions>,
1408    ) -> Result<(String, RelayResult)> {
1409        let keys = self.keys.as_ref().context(format!(
1410            "Cannot push: no secret key for {}. You can only push to your own repos.",
1411            &self.pubkey[..16]
1412        ))?;
1413
1414        info!(
1415            "Publishing repo {} with root hash {} (encrypted: {})",
1416            repo_name,
1417            root_hash,
1418            encryption_key.is_some()
1419        );
1420
1421        // Create a new multi-threaded runtime for nostr-sdk which spawns background tasks
1422        block_on_result(self.publish_repo_async(
1423            keys,
1424            repo_name,
1425            root_hash,
1426            encryption_key,
1427            repo_announcement,
1428        ))
1429    }
1430
1431    async fn publish_repo_async(
1432        &self,
1433        keys: &Keys,
1434        repo_name: &str,
1435        root_hash: &str,
1436        encryption_key: Option<(&[u8; 32], bool, bool)>,
1437        repo_announcement: Option<RepoAnnouncementOptions>,
1438    ) -> Result<(String, RelayResult)> {
1439        // Create nostr-sdk client with our keys
1440        let client = Client::new(keys.clone());
1441
1442        let configured: Vec<String> = self.relays.clone();
1443        let mut connected: Vec<String> = Vec::new();
1444        let mut failed: Vec<String> = Vec::new();
1445
1446        // Add relays
1447        for relay in &self.relays {
1448            if let Err(e) = client.add_relay(relay).await {
1449                warn!("Failed to add relay {}: {}", relay, e);
1450                failed.push(relay.clone());
1451            }
1452        }
1453
1454        // Connect to relays - this starts async connection in background
1455        client.connect().await;
1456
1457        // Wait for at least one relay to connect (same pattern as fetch)
1458        let _ = wait_for_any_connected_relay(&client, Duration::from_secs(3)).await;
1459
1460        let publish_created_at = next_replaceable_created_at(
1461            Timestamp::now(),
1462            latest_repo_event_created_at(
1463                &client,
1464                keys.public_key(),
1465                repo_name,
1466                Duration::from_secs(2),
1467            )
1468            .await,
1469        );
1470
1471        // Build event with tags
1472        let mut tags = vec![
1473            Tag::custom(TagKind::custom("d"), vec![repo_name.to_string()]),
1474            Tag::custom(TagKind::custom("l"), vec![LABEL_HASHTREE.to_string()]),
1475            Tag::custom(TagKind::custom("hash"), vec![root_hash.to_string()]),
1476        ];
1477
1478        // Add encryption key if present (required for decryption)
1479        // Key modes:
1480        // - selfEncryptedKey: NIP-44 encrypted to self (author-only private)
1481        // - encryptedKey: XOR masked with URL secret (link-visible)
1482        // - key: plaintext CHK (public)
1483        if let Some((key, is_link_visible, is_self_private)) = encryption_key {
1484            if is_self_private {
1485                // NIP-44 encrypt to self
1486                let pubkey = keys.public_key();
1487                let key_hex = hex::encode(key);
1488                let encrypted =
1489                    nip44::encrypt(keys.secret_key(), &pubkey, &key_hex, nip44::Version::V2)
1490                        .map_err(|e| anyhow::anyhow!("NIP-44 encryption failed: {}", e))?;
1491                tags.push(Tag::custom(
1492                    TagKind::custom("selfEncryptedKey"),
1493                    vec![encrypted],
1494                ));
1495            } else if is_link_visible {
1496                // XOR masked key
1497                tags.push(Tag::custom(
1498                    TagKind::custom("encryptedKey"),
1499                    vec![hex::encode(key)],
1500                ));
1501            } else {
1502                // Public: plaintext CHK
1503                tags.push(Tag::custom(TagKind::custom("key"), vec![hex::encode(key)]));
1504            }
1505        }
1506
1507        append_repo_discovery_labels(&mut tags, repo_name);
1508
1509        // Sign the event
1510        let event = EventBuilder::new(Kind::Custom(KIND_APP_DATA), root_hash)
1511            .tags(tags)
1512            .custom_created_at(publish_created_at)
1513            .sign_with_keys(keys)
1514            .map_err(|e| anyhow::anyhow!("Failed to sign event: {}", e))?;
1515
1516        // Send event to connected relays
1517        match client.send_event(&event).await {
1518            Ok(output) => {
1519                // Track which relays confirmed
1520                for url in output.success.iter() {
1521                    let url_str = url.to_string();
1522                    if !connected.contains(&url_str) {
1523                        connected.push(url_str);
1524                    }
1525                }
1526                // Only mark as failed if we got explicit rejection
1527                for (url, _err) in output.failed.iter() {
1528                    let url_str = url.to_string();
1529                    if !failed.contains(&url_str) && !connected.contains(&url_str) {
1530                        failed.push(url_str);
1531                    }
1532                }
1533                info!(
1534                    "Sent event {} to {} relays ({} failed)",
1535                    output.id(),
1536                    output.success.len(),
1537                    output.failed.len()
1538                );
1539            }
1540            Err(e) => {
1541                warn!("Failed to send event: {}", e);
1542                // Mark all as failed
1543                for relay in &self.relays {
1544                    if !failed.contains(relay) {
1545                        failed.push(relay.clone());
1546                    }
1547                }
1548            }
1549        };
1550
1551        // Build the full htree:// URL with npub
1552        let npub_url = keys
1553            .public_key()
1554            .to_bech32()
1555            .map(|npub| format!("htree://{}/{}", npub, repo_name))
1556            .unwrap_or_else(|_| format!("htree://{}/{}", &self.pubkey[..16], repo_name));
1557
1558        let relay_validation = validate_repo_publish_relays(&configured, &connected);
1559
1560        if relay_validation.is_ok() {
1561            if let Some(repo_announcement) = repo_announcement.as_ref() {
1562                if let Err(err) = self
1563                    .publish_repo_announcement_async(
1564                        &client,
1565                        keys,
1566                        repo_name,
1567                        &npub_url,
1568                        repo_announcement,
1569                    )
1570                    .await
1571                {
1572                    warn!("Failed to publish NIP-34 repo announcement: {}", err);
1573                }
1574            }
1575        }
1576
1577        // Disconnect and give time for cleanup
1578        let _ = client.disconnect().await;
1579        tokio::time::sleep(Duration::from_millis(50)).await;
1580
1581        relay_validation?;
1582
1583        self.cache_public_root_in_local_daemon(repo_name, root_hash, encryption_key)
1584            .await;
1585
1586        Ok((
1587            npub_url,
1588            RelayResult {
1589                configured,
1590                connected,
1591                failed,
1592            },
1593        ))
1594    }
1595
1596    fn build_repo_announcement_tags(
1597        repo_name: &str,
1598        clone_url: &str,
1599        relays: &[String],
1600        options: &RepoAnnouncementOptions,
1601    ) -> Vec<Tag> {
1602        let mut tags = vec![
1603            Tag::custom(TagKind::custom("d"), vec![repo_name.to_string()]),
1604            Tag::custom(TagKind::custom("name"), vec![repo_name.to_string()]),
1605            Tag::custom(TagKind::custom("clone"), vec![clone_url.to_string()]),
1606        ];
1607
1608        if !relays.is_empty() {
1609            tags.push(Tag::custom(TagKind::custom("relays"), relays.to_vec()));
1610        }
1611
1612        if let Some(euc) = options
1613            .earliest_unique_commit
1614            .as_deref()
1615            .map(str::trim)
1616            .filter(|value| !value.is_empty())
1617        {
1618            tags.push(Tag::custom(
1619                TagKind::custom("r"),
1620                vec![euc.to_string(), "euc".to_string()],
1621            ));
1622        }
1623
1624        if options.personal_fork {
1625            tags.push(Tag::custom(
1626                TagKind::custom("t"),
1627                vec!["personal-fork".to_string()],
1628            ));
1629        }
1630
1631        if let Some(forked_from) = options
1632            .forked_from
1633            .as_deref()
1634            .map(str::trim)
1635            .filter(|value| !value.is_empty())
1636        {
1637            tags.push(Tag::custom(
1638                TagKind::custom("forked-from"),
1639                vec![forked_from.to_string()],
1640            ));
1641        }
1642
1643        tags
1644    }
1645
1646    async fn publish_repo_announcement_async(
1647        &self,
1648        client: &Client,
1649        keys: &Keys,
1650        repo_name: &str,
1651        clone_url: &str,
1652        options: &RepoAnnouncementOptions,
1653    ) -> Result<()> {
1654        let created_at = next_replaceable_created_at(
1655            Timestamp::now(),
1656            latest_repo_announcement_created_at(
1657                client,
1658                keys.public_key(),
1659                repo_name,
1660                Duration::from_secs(2),
1661            )
1662            .await,
1663        );
1664        let tags = Self::build_repo_announcement_tags(repo_name, clone_url, &self.relays, options);
1665        let event = EventBuilder::new(Kind::Custom(KIND_REPO_ANNOUNCEMENT), "")
1666            .tags(tags)
1667            .custom_created_at(created_at)
1668            .sign_with_keys(keys)
1669            .map_err(|e| anyhow::anyhow!("Failed to sign NIP-34 repo announcement: {}", e))?;
1670
1671        let output = client
1672            .send_event(&event)
1673            .await
1674            .map_err(|e| anyhow::anyhow!("Failed to publish NIP-34 repo announcement: {}", e))?;
1675        if output.success.is_empty() {
1676            anyhow::bail!("NIP-34 repo announcement was not confirmed by any relay");
1677        }
1678
1679        info!(
1680            "Published NIP-34 repo announcement {} to {} relays",
1681            output.id(),
1682            output.success.len()
1683        );
1684        Ok(())
1685    }
1686
1687    pub fn fetch_repo_announcement_euc(
1688        &self,
1689        owner_pubkey_hex: &str,
1690        repo_name: &str,
1691    ) -> Result<Option<String>> {
1692        block_on_result(self.fetch_repo_announcement_euc_async(owner_pubkey_hex, repo_name))
1693    }
1694
1695    async fn fetch_repo_announcement_euc_async(
1696        &self,
1697        owner_pubkey_hex: &str,
1698        repo_name: &str,
1699    ) -> Result<Option<String>> {
1700        let owner = PublicKey::from_hex(owner_pubkey_hex)
1701            .map_err(|e| anyhow::anyhow!("Invalid repo owner pubkey: {}", e))?;
1702        let client = Client::default();
1703
1704        for relay in &self.relays {
1705            if let Err(e) = client.add_relay(relay).await {
1706                debug!(
1707                    "Failed to add relay {} for repo announcement lookup: {}",
1708                    relay, e
1709                );
1710            }
1711        }
1712        client.connect().await;
1713
1714        if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
1715            let _ = client.disconnect().await;
1716            return Ok(None);
1717        }
1718
1719        let filter = build_repo_announcement_filter(owner, repo_name);
1720        let events = match tokio::time::timeout(
1721            Duration::from_secs(3),
1722            client.fetch_events(filter, Duration::from_secs(3)),
1723        )
1724        .await
1725        {
1726            Ok(Ok(events)) => events.to_vec(),
1727            Ok(Err(err)) => {
1728                debug!(
1729                    "Failed to fetch NIP-34 repo announcement for {}: {}",
1730                    repo_name, err
1731                );
1732                Vec::new()
1733            }
1734            Err(_) => {
1735                debug!(
1736                    "Timed out fetching NIP-34 repo announcement for {}",
1737                    repo_name
1738                );
1739                Vec::new()
1740            }
1741        };
1742
1743        let _ = client.disconnect().await;
1744        Ok(pick_latest_event(events.iter()).and_then(extract_repo_announcement_euc))
1745    }
1746
1747    /// Fetch pull requests targeting this repo, filtered by state.
1748    pub fn fetch_prs(
1749        &self,
1750        repo_name: &str,
1751        state_filter: PullRequestStateFilter,
1752    ) -> Result<Vec<PullRequestListItem>> {
1753        block_on_result(self.fetch_prs_async(repo_name, state_filter))
1754    }
1755
1756    pub async fn fetch_prs_async(
1757        &self,
1758        repo_name: &str,
1759        state_filter: PullRequestStateFilter,
1760    ) -> Result<Vec<PullRequestListItem>> {
1761        let client = Client::default();
1762
1763        for relay in &self.relays {
1764            if let Err(e) = client.add_relay(relay).await {
1765                warn!("Failed to add relay {}: {}", relay, e);
1766            }
1767        }
1768        client.connect().await;
1769
1770        // Wait for at least one relay (quick timeout)
1771        if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
1772            let _ = client.disconnect().await;
1773            return Err(anyhow::anyhow!(
1774                "Failed to connect to any relay while fetching PRs"
1775            ));
1776        }
1777
1778        // Query for kind 1618 PRs targeting this repo
1779        let repo_address = format!("{}:{}:{}", KIND_REPO_ANNOUNCEMENT, self.pubkey, repo_name);
1780        let pull_request_filter = Filter::new()
1781            .kind(Kind::Custom(KIND_PULL_REQUEST))
1782            .custom_tag(
1783                SingleLetterTag::lowercase(Alphabet::A),
1784                repo_address.clone(),
1785            );
1786
1787        let mut pr_events = match tokio::time::timeout(
1788            Duration::from_secs(3),
1789            client.fetch_events(pull_request_filter.clone(), Duration::from_secs(3)),
1790        )
1791        .await
1792        {
1793            Ok(Ok(events)) => events.to_vec(),
1794            Ok(Err(e)) => {
1795                let _ = client.disconnect().await;
1796                return Err(anyhow::anyhow!(
1797                    "Failed to fetch PR events from relays: {}",
1798                    e
1799                ));
1800            }
1801            Err(_) => {
1802                let _ = client.disconnect().await;
1803                return Err(anyhow::anyhow!("Timed out fetching PR events from relays"));
1804            }
1805        };
1806
1807        if pr_events.is_empty() {
1808            let fallback_events = fetch_events_via_raw_relay_query(
1809                &self.relays,
1810                pull_request_filter,
1811                Duration::from_secs(3),
1812            )
1813            .await;
1814            if !fallback_events.is_empty() {
1815                debug!(
1816                    "Raw relay fallback recovered {} PR event(s) for {}",
1817                    fallback_events.len(),
1818                    repo_name
1819                );
1820                pr_events = fallback_events;
1821            }
1822        }
1823
1824        if pr_events.is_empty() {
1825            let _ = client.disconnect().await;
1826            return Ok(Vec::new());
1827        }
1828
1829        // Collect PR event IDs for status query
1830        let pr_ids: Vec<String> = pr_events.iter().map(|e| e.id.to_hex()).collect();
1831
1832        // Query for status events referencing these PRs
1833        let status_event_filter = Filter::new()
1834            .kinds(vec![
1835                Kind::Custom(KIND_STATUS_OPEN),
1836                Kind::Custom(KIND_STATUS_APPLIED),
1837                Kind::Custom(KIND_STATUS_CLOSED),
1838                Kind::Custom(KIND_STATUS_DRAFT),
1839            ])
1840            .custom_tags(
1841                SingleLetterTag::lowercase(Alphabet::E),
1842                pr_ids.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1843            );
1844
1845        let mut status_events = match tokio::time::timeout(
1846            Duration::from_secs(3),
1847            client.fetch_events(status_event_filter.clone(), Duration::from_secs(3)),
1848        )
1849        .await
1850        {
1851            Ok(Ok(events)) => events.to_vec(),
1852            Ok(Err(e)) => {
1853                let _ = client.disconnect().await;
1854                return Err(anyhow::anyhow!(
1855                    "Failed to fetch PR status events from relays: {}",
1856                    e
1857                ));
1858            }
1859            Err(_) => {
1860                let _ = client.disconnect().await;
1861                return Err(anyhow::anyhow!(
1862                    "Timed out fetching PR status events from relays"
1863                ));
1864            }
1865        };
1866
1867        if status_events.is_empty() {
1868            let fallback_events = fetch_events_via_raw_relay_query(
1869                &self.relays,
1870                status_event_filter,
1871                Duration::from_secs(3),
1872            )
1873            .await;
1874            if !fallback_events.is_empty() {
1875                debug!(
1876                    "Raw relay fallback recovered {} PR status event(s) for {}",
1877                    fallback_events.len(),
1878                    repo_name
1879                );
1880                status_events = fallback_events;
1881            }
1882        }
1883
1884        let _ = client.disconnect().await;
1885
1886        // Build map: pr_event_id -> latest trusted status kind
1887        let latest_status =
1888            latest_trusted_pr_status_kinds(&pr_events, &status_events, &self.pubkey);
1889
1890        let mut prs = Vec::new();
1891        for event in &pr_events {
1892            let pr_id = event.id.to_hex();
1893            let state =
1894                PullRequestState::from_latest_status_kind(latest_status.get(&pr_id).copied());
1895            if !state_filter.includes(state) {
1896                continue;
1897            }
1898
1899            let mut subject = None;
1900            let mut commit_tip = None;
1901            let mut branch = None;
1902            let mut target_branch = None;
1903
1904            for tag in event.tags.iter() {
1905                let slice = tag.as_slice();
1906                if slice.len() >= 2 {
1907                    match slice[0].as_str() {
1908                        "subject" => subject = Some(slice[1].to_string()),
1909                        "c" => commit_tip = Some(slice[1].to_string()),
1910                        "branch" => branch = Some(slice[1].to_string()),
1911                        "target-branch" => target_branch = Some(slice[1].to_string()),
1912                        _ => {}
1913                    }
1914                }
1915            }
1916
1917            prs.push(PullRequestListItem {
1918                event_id: pr_id,
1919                author_pubkey: event.pubkey.to_hex(),
1920                state,
1921                subject,
1922                commit_tip,
1923                branch,
1924                target_branch,
1925                created_at: event.created_at.as_secs(),
1926            });
1927        }
1928
1929        // Newest first; tie-break by event id for deterministic output.
1930        prs.sort_by(|left, right| {
1931            right
1932                .created_at
1933                .cmp(&left.created_at)
1934                .then_with(|| right.event_id.cmp(&left.event_id))
1935        });
1936
1937        debug!(
1938            "Found {} PRs for {} (filter: {:?})",
1939            prs.len(),
1940            repo_name,
1941            state_filter
1942        );
1943        Ok(prs)
1944    }
1945
1946    /// Publish a kind 1631 (STATUS_APPLIED) event to mark a PR as merged
1947    pub fn publish_pr_merged_status(
1948        &self,
1949        pr_event_id: &str,
1950        pr_author_pubkey: &str,
1951    ) -> Result<()> {
1952        let keys = self
1953            .keys
1954            .as_ref()
1955            .context("Cannot publish status: no secret key")?;
1956
1957        block_on_result(self.publish_pr_merged_status_async(keys, pr_event_id, pr_author_pubkey))
1958    }
1959
1960    async fn publish_pr_merged_status_async(
1961        &self,
1962        keys: &Keys,
1963        pr_event_id: &str,
1964        pr_author_pubkey: &str,
1965    ) -> Result<()> {
1966        let client = Client::new(keys.clone());
1967
1968        for relay in &self.relays {
1969            if let Err(e) = client.add_relay(relay).await {
1970                warn!("Failed to add relay {}: {}", relay, e);
1971            }
1972        }
1973        client.connect().await;
1974
1975        // Wait for at least one relay
1976        if !wait_for_any_connected_relay(&client, Duration::from_secs(3)).await {
1977            anyhow::bail!("Failed to connect to any relay for status publish");
1978        }
1979
1980        let tags = vec![
1981            Tag::custom(TagKind::custom("e"), vec![pr_event_id.to_string()]),
1982            Tag::custom(TagKind::custom("p"), vec![pr_author_pubkey.to_string()]),
1983        ];
1984
1985        let event = EventBuilder::new(Kind::Custom(KIND_STATUS_APPLIED), "")
1986            .tags(tags)
1987            .sign_with_keys(keys)
1988            .map_err(|e| anyhow::anyhow!("Failed to sign status event: {}", e))?;
1989
1990        let publish_result = match client.send_event(&event).await {
1991            Ok(output) => {
1992                if output.success.is_empty() {
1993                    Err(anyhow::anyhow!(
1994                        "PR merged status was not confirmed by any relay"
1995                    ))
1996                } else {
1997                    info!(
1998                        "Published PR merged status to {} relays",
1999                        output.success.len()
2000                    );
2001                    Ok(())
2002                }
2003            }
2004            Err(e) => Err(anyhow::anyhow!("Failed to publish PR merged status: {}", e)),
2005        };
2006
2007        let _ = client.disconnect().await;
2008        tokio::time::sleep(Duration::from_millis(50)).await;
2009        publish_result
2010    }
2011
2012    /// Upload blob to blossom server
2013    #[allow(dead_code)]
2014    pub async fn upload_blob(&self, _hash: &str, data: &[u8]) -> Result<String> {
2015        let hash = self
2016            .blossom
2017            .upload(data)
2018            .await
2019            .map_err(|e| anyhow::anyhow!("Blossom upload failed: {}", e))?;
2020        Ok(hash)
2021    }
2022
2023    /// Upload blob only if it doesn't exist
2024    #[allow(dead_code)]
2025    pub async fn upload_blob_if_missing(&self, data: &[u8]) -> Result<(String, bool)> {
2026        self.blossom
2027            .upload_if_missing(data)
2028            .await
2029            .map_err(|e| anyhow::anyhow!("Blossom upload failed: {}", e))
2030    }
2031
2032    /// Download blob from blossom server
2033    #[allow(dead_code)]
2034    pub async fn download_blob(&self, hash: &str) -> Result<Vec<u8>> {
2035        self.blossom
2036            .download(hash)
2037            .await
2038            .map_err(|e| anyhow::anyhow!("Blossom download failed: {}", e))
2039    }
2040
2041    /// Try to download blob, returns None if not found
2042    #[allow(dead_code)]
2043    pub async fn try_download_blob(&self, hash: &str) -> Option<Vec<u8>> {
2044        self.blossom.try_download(hash).await
2045    }
2046}
2047
2048#[cfg(test)]
2049mod tests;