Skip to main content

nap_core/provider/
remote.rs

1// SPDX-FileCopyrightText: 2026 Digital Creations
2// SPDX-License-Identifier: MIT
3//! Remote provider for Lore server
4//!
5//! Manages repository operations on a remote Lore instance.
6
7use anyhow::{Context, Result};
8use tracing::info;
9
10use super::{Provider, ProviderStatus, ProviderType};
11
12/// Remote provider for custom Lore server
13pub struct RemoteProvider {
14    url_base: String,
15    workspace_id: String,
16    auth_token: Option<String>,
17    http_url: Option<String>,
18}
19
20impl RemoteProvider {
21    /// Create a new remote provider
22    pub fn new(url_base: &str, workspace_id: &str) -> Self {
23        Self {
24            url_base: url_base.to_string(),
25            workspace_id: workspace_id.to_string(),
26            auth_token: std::env::var("NAP_REMOTE_AUTH_TOKEN").ok(),
27            http_url: None,
28        }
29    }
30
31    /// Create a new remote provider with default workspace ID
32    pub fn new_with_default_workspace(url_base: &str) -> Self {
33        Self {
34            url_base: url_base.to_string(),
35            workspace_id: super::get_default_workspace_id(),
36            auth_token: std::env::var("NAP_REMOTE_AUTH_TOKEN").ok(),
37            http_url: None,
38        }
39    }
40
41    pub fn with_http_url(mut self, url: &str) -> Result<Self> {
42        super::http::validate_origin(url)?;
43        self.http_url = Some(url.trim_end_matches('/').to_string());
44        Ok(self)
45    }
46
47    /// Set custom auth token
48    pub fn with_auth_token(mut self, token: &str) -> Self {
49        self.auth_token = Some(token.to_string());
50        self
51    }
52
53    async fn probe(&self) -> Result<()> {
54        let rpc = reqwest::Url::parse(&self.url_base)?;
55        let tls_edge = rpc.host_str() == Some("lore.portals.works")
56            || (matches!(rpc.scheme(), "grpcs" | "lores" | "https")
57                && matches!(rpc.port(), None | Some(443)));
58        if tls_edge {
59            // TLS edges may expose only selected HTTP routes, while gRPC remains
60            // available on the origin. Connectivity does not require repository auth.
61            tonic::transport::Endpoint::from_shared(super::http::default_origin(&self.url_base)?)?
62                .connect_timeout(std::time::Duration::from_secs(10))
63                .connect()
64                .await
65                .context("Failed to connect to Lore TLS endpoint")?;
66        } else {
67            reqwest::Client::builder()
68                .timeout(std::time::Duration::from_secs(10))
69                .build()?
70                .get(self.http_health_url()?)
71                .send()
72                .await
73                .context("Failed to connect to remote Lore server")?
74                .error_for_status()
75                .context("Remote Lore server health check failed")?;
76        }
77        Ok(())
78    }
79
80    /// Parse URL to extract HTTP health check endpoint
81    ///
82    /// Lore server uses port 41337 for gRPC/QUIC (lore:// URLs) and port 41339 for HTTP.
83    /// This function converts lore://host:41337 to http://host:41339/health_check
84    fn http_health_url(&self) -> Result<String> {
85        Ok(format!(
86            "{}/health_check",
87            self.http_url
88                .clone()
89                .map(Ok)
90                .unwrap_or_else(|| super::http::default_origin(&self.url_base))?
91        ))
92    }
93}
94
95#[async_trait::async_trait]
96impl Provider for RemoteProvider {
97    fn provider_type(&self) -> ProviderType {
98        ProviderType::Remote
99    }
100
101    fn name(&self) -> &str {
102        "Remote Lore Server"
103    }
104
105    async fn initialize(&self) -> Result<()> {
106        info!("Initializing Remote provider for {}", self.url_base);
107        info!("Remote provider initialized");
108        Ok(())
109    }
110
111    async fn ensure_ready(&self) -> Result<()> {
112        info!("Ensuring Remote provider is ready");
113
114        self.initialize().await?;
115
116        self.probe().await?;
117
118        info!("Remote provider is ready");
119        Ok(())
120    }
121
122    fn lore_url_base(&self) -> Result<String> {
123        Ok(self.url_base.clone())
124    }
125
126    fn workspace_id(&self) -> &str {
127        &self.workspace_id
128    }
129
130    async fn health_check(&self) -> Result<bool> {
131        Ok(self.probe().await.is_ok())
132    }
133
134    async fn status(&self) -> Result<ProviderStatus> {
135        let healthy = self.health_check().await.unwrap_or(false);
136
137        let message = if healthy {
138            "Connected".to_string()
139        } else {
140            "Server unreachable".to_string()
141        };
142
143        Ok(ProviderStatus {
144            provider_type: self.provider_type(),
145            ready: healthy,
146            healthy,
147            url_base: self.url_base.clone(),
148            workspace_id: self.workspace_id.clone(),
149            message,
150        })
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_remote_provider_creation() {
160        let provider = RemoteProvider::new("lore://localhost:41337", "default");
161        assert_eq!(provider.provider_type(), ProviderType::Remote);
162        assert_eq!(provider.name(), "Remote Lore Server");
163        assert_eq!(provider.workspace_id(), "default");
164        assert_eq!(provider.url_base, "lore://localhost:41337");
165    }
166
167    #[test]
168    fn test_remote_provider_custom_auth() {
169        let provider = RemoteProvider::new("lore://localhost:41337", "default")
170            .with_auth_token("custom-token");
171        assert_eq!(provider.auth_token, Some("custom-token".to_string()));
172    }
173
174    #[test]
175    fn test_http_health_url() {
176        let provider = RemoteProvider::new("lore://localhost:41337", "default");
177        assert_eq!(
178            provider.http_health_url().unwrap(),
179            "http://localhost:41339/health_check"
180        );
181
182        let provider = RemoteProvider::new("lores://example.com:41337", "default");
183        assert_eq!(
184            provider.http_health_url().unwrap(),
185            "https://example.com:41339/health_check"
186        );
187
188        // Test without port in URL
189        let provider = RemoteProvider::new("lore://192.168.0.27", "default");
190        assert_eq!(
191            provider.http_health_url().unwrap(),
192            "http://192.168.0.27:41339/health_check"
193        );
194    }
195}