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