Skip to main content

solid_pod_rs/
interop.rs

1//! Interop / discovery helpers.
2//!
3//! This module rounds out the crate's public Solid surface with small,
4//! framework-agnostic helpers for ecosystem discovery flows:
5//!
6//! - **`.well-known/solid`** — Solid Protocol §4.1.2 discovery document.
7//! - **WebFinger** — RFC 7033, used to map acct: URIs to WebIDs.
8//! - **NIP-05 verification** — Nostr pubkey ↔ DNS name binding.
9//! - **Dev-mode session bypass** — consumer-crate helper for tests.
10//!
11//! None of these helpers perform network I/O on their own; they return
12//! response bodies and signal objects that the consumer crate wires
13//! into its HTTP server.
14
15use serde::{Deserialize, Serialize};
16
17use crate::error::PodError;
18
19// ---------------------------------------------------------------------------
20// .well-known/solid discovery document
21// ---------------------------------------------------------------------------
22
23/// Solid Protocol `.well-known/solid` discovery document. The doc
24/// advertises the OIDC issuer, the pod URL, and the Notifications
25/// endpoint.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SolidWellKnown {
28    #[serde(rename = "@context")]
29    pub context: serde_json::Value,
30
31    pub solid_oidc_issuer: String,
32
33    pub notification_gateway: String,
34
35    pub storage: String,
36
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub webfinger: Option<String>,
39}
40
41/// Build the discovery document for a pod root.
42pub fn well_known_solid(
43    pod_base: &str,
44    oidc_issuer: &str,
45) -> SolidWellKnown {
46    let base = pod_base.trim_end_matches('/');
47    SolidWellKnown {
48        context: serde_json::json!("https://www.w3.org/ns/solid/terms"),
49        solid_oidc_issuer: oidc_issuer.trim_end_matches('/').to_string(),
50        notification_gateway: format!("{base}/.notifications"),
51        storage: format!("{base}/"),
52        webfinger: Some(format!("{base}/.well-known/webfinger")),
53    }
54}
55
56// ---------------------------------------------------------------------------
57// WebFinger (RFC 7033)
58// ---------------------------------------------------------------------------
59
60/// WebFinger JRD (JSON Resource Descriptor) response.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct WebFingerJrd {
63    pub subject: String,
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    pub aliases: Vec<String>,
66    #[serde(default, skip_serializing_if = "Vec::is_empty")]
67    pub links: Vec<WebFingerLink>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct WebFingerLink {
72    pub rel: String,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub href: Option<String>,
75    #[serde(skip_serializing_if = "Option::is_none", rename = "type")]
76    pub content_type: Option<String>,
77}
78
79/// Produce a WebFinger JRD response pointing `acct:user@host` at the
80/// user's WebID. Returns `None` if the resource is not recognised.
81pub fn webfinger_response(
82    resource: &str,
83    pod_base: &str,
84    webid: &str,
85) -> Option<WebFingerJrd> {
86    if !resource.starts_with("acct:") && !resource.starts_with("https://") {
87        return None;
88    }
89    let base = pod_base.trim_end_matches('/');
90    Some(WebFingerJrd {
91        subject: resource.to_string(),
92        aliases: vec![webid.to_string()],
93        links: vec![
94            WebFingerLink {
95                rel: "http://openid.net/specs/connect/1.0/issuer".to_string(),
96                href: Some(format!("{base}/")),
97                content_type: None,
98            },
99            WebFingerLink {
100                rel: "http://www.w3.org/ns/solid#webid".to_string(),
101                href: Some(webid.to_string()),
102                content_type: None,
103            },
104            WebFingerLink {
105                rel: "http://www.w3.org/ns/pim/space#storage".to_string(),
106                href: Some(format!("{base}/")),
107                content_type: None,
108            },
109        ],
110    })
111}
112
113// ---------------------------------------------------------------------------
114// NIP-05 verification
115// ---------------------------------------------------------------------------
116
117/// NIP-05 response document (the JSON served at
118/// `.well-known/nostr.json?name=<local>`).
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct Nip05Document {
121    pub names: std::collections::HashMap<String, String>,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub relays: Option<std::collections::HashMap<String, Vec<String>>>,
124}
125
126/// Verify a NIP-05 identifier (`local@example.com`) against a fetched
127/// NIP-05 document. Returns the resolved hex pubkey on success.
128pub fn verify_nip05(
129    identifier: &str,
130    document: &Nip05Document,
131) -> Result<String, PodError> {
132    let (local, _domain) = identifier
133        .split_once('@')
134        .ok_or_else(|| PodError::Nip98(format!("invalid NIP-05 identifier: {identifier}")))?;
135    let lookup = if local.is_empty() { "_" } else { local };
136    let pubkey = document
137        .names
138        .get(lookup)
139        .ok_or_else(|| PodError::NotFound(format!("NIP-05 name not found: {lookup}")))?;
140    if pubkey.len() != 64 || hex::decode(pubkey).is_err() {
141        return Err(PodError::Nip98(format!(
142            "NIP-05 pubkey malformed for {identifier}"
143        )));
144    }
145    Ok(pubkey.clone())
146}
147
148/// Build the NIP-05 document structure for a pod's own hosted names.
149pub fn nip05_document(
150    names: impl IntoIterator<Item = (String, String)>,
151) -> Nip05Document {
152    Nip05Document {
153        names: names.into_iter().collect(),
154        relays: None,
155    }
156}
157
158// ---------------------------------------------------------------------------
159// Dev-mode session bypass
160// ---------------------------------------------------------------------------
161
162/// Dev-mode session — ergonomic handle a consumer crate can plug into
163/// its request-processing pipeline in place of NIP-98/OIDC verification
164/// during tests or local development. The bypass is only constructable
165/// via explicit allow, never through a header the client supplies.
166#[derive(Debug, Clone)]
167pub struct DevSession {
168    pub webid: String,
169    pub pubkey: Option<String>,
170    pub is_admin: bool,
171}
172
173/// Build a dev-session bypass. Callers are expected to gate this on a
174/// top-level `ENABLE_DEV_SESSION=1` or similar environment check —
175/// the helper itself will not read env to avoid accidental activation.
176pub fn dev_session(webid: impl Into<String>, is_admin: bool) -> DevSession {
177    DevSession {
178        webid: webid.into(),
179        pubkey: None,
180        is_admin,
181    }
182}
183
184// ---------------------------------------------------------------------------
185// Tests
186// ---------------------------------------------------------------------------
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn well_known_solid_advertises_oidc_and_storage() {
194        let d = well_known_solid("https://pod.example/", "https://op.example/");
195        assert_eq!(d.solid_oidc_issuer, "https://op.example");
196        assert!(d.notification_gateway.ends_with(".notifications"));
197        assert!(d.storage.ends_with('/'));
198    }
199
200    #[test]
201    fn webfinger_returns_links_for_acct() {
202        let j = webfinger_response(
203            "acct:alice@pod.example",
204            "https://pod.example",
205            "https://pod.example/profile/card#me",
206        )
207        .unwrap();
208        assert_eq!(j.subject, "acct:alice@pod.example");
209        assert!(j.links.iter().any(|l| l.rel == "http://www.w3.org/ns/solid#webid"));
210    }
211
212    #[test]
213    fn webfinger_rejects_unknown_scheme() {
214        assert!(webfinger_response("mailto:a@b", "https://p", "https://w").is_none());
215    }
216
217    #[test]
218    fn nip05_verify_returns_pubkey() {
219        let mut names = std::collections::HashMap::new();
220        names.insert("alice".to_string(), "a".repeat(64));
221        let doc = nip05_document(names);
222        let pk = verify_nip05("alice@pod.example", &doc).unwrap();
223        assert_eq!(pk, "a".repeat(64));
224    }
225
226    #[test]
227    fn nip05_verify_rejects_malformed_pubkey() {
228        let mut names = std::collections::HashMap::new();
229        names.insert("alice".to_string(), "shortkey".to_string());
230        let doc = nip05_document(names);
231        assert!(verify_nip05("alice@p", &doc).is_err());
232    }
233
234    #[test]
235    fn nip05_root_name_resolves_via_underscore() {
236        let mut names = std::collections::HashMap::new();
237        names.insert("_".to_string(), "b".repeat(64));
238        let doc = nip05_document(names);
239        assert!(verify_nip05("@pod.example", &doc).is_ok());
240    }
241
242    #[test]
243    fn dev_session_stores_admin_flag() {
244        let s = dev_session("https://me/profile#me", true);
245        assert!(s.is_admin);
246        assert_eq!(s.webid, "https://me/profile#me");
247    }
248}
249
250// ---------------------------------------------------------------------------
251// did:nostr resolver (Sprint 6 D)
252// ---------------------------------------------------------------------------
253
254/// did:nostr resolver — DID-Doc publication + bidirectional
255/// `alsoKnownAs`/`owl:sameAs` verification.
256///
257/// Mirrors `JavaScriptSolidServer/src/auth/did-nostr.js`: given
258/// `did:nostr:<pubkey>` hosted on an origin, fetch
259/// `https://<origin>/.well-known/did/nostr/<pubkey>.json`, iterate the
260/// `alsoKnownAs` entries, fetch each candidate WebID profile, and
261/// verify it carries an `owl:sameAs` / `schema:sameAs` back-link to
262/// `did:nostr:<pubkey>`. Only a verified WebID is returned.
263///
264/// Defence-in-depth: every outbound request (DID Doc + each WebID
265/// candidate) runs through the configured [`SsrfPolicy`] before
266/// network I/O. A small in-memory TTL cache covers both success and
267/// negative results so a dark origin does not hammer the downstream.
268#[cfg(feature = "did-nostr")]
269pub mod did_nostr {
270    use std::collections::HashMap;
271    use std::sync::{Arc, RwLock};
272    use std::time::{Duration, Instant};
273
274    use reqwest::Client;
275    use serde::{Deserialize, Serialize};
276    use url::Url;
277
278    use crate::security::ssrf::SsrfPolicy;
279
280    /// Compose the well-known DID Doc location for a Nostr pubkey
281    /// hosted on a given origin. Mirrors JSS `did-nostr.js:79` where
282    /// the resolver URL is `<base>/<pubkey>.json`.
283    pub fn did_nostr_well_known_url(origin: &str, pubkey: &str) -> String {
284        format!(
285            "{}/.well-known/did/nostr/{}.json",
286            origin.trim_end_matches('/'),
287            pubkey
288        )
289    }
290
291    /// Build a minimal DID Doc for publication at the well-known URL.
292    /// Tier-1 schema (matches JSS): `id`, `alsoKnownAs`, and a single
293    /// `verificationMethod` entry of type `NostrSchnorrKey2024` derived
294    /// from the x-only pubkey.
295    pub fn did_nostr_document(pubkey: &str, also_known_as: &[String]) -> serde_json::Value {
296        serde_json::json!({
297            "@context": ["https://www.w3.org/ns/did/v1"],
298            "id": format!("did:nostr:{}", pubkey),
299            "alsoKnownAs": also_known_as,
300            "verificationMethod": [{
301                "id": format!("did:nostr:{}#nostr-schnorr", pubkey),
302                "type": "NostrSchnorrKey2024",
303                "controller": format!("did:nostr:{}", pubkey),
304                "publicKeyHex": pubkey,
305            }]
306        })
307    }
308
309    /// Parsed DID Doc. Only the subset of fields relevant to WebID
310    /// resolution is typed; unknown fields are ignored.
311    #[derive(Debug, Clone, Deserialize, Serialize)]
312    pub struct DidNostrDoc {
313        pub id: String,
314        #[serde(default, rename = "alsoKnownAs")]
315        pub also_known_as: Vec<String>,
316    }
317
318    /// TTL-cached `did:nostr:<pubkey>` → WebID resolver with per-hop
319    /// SSRF enforcement.
320    pub struct DidNostrResolver {
321        ssrf: Arc<SsrfPolicy>,
322        client: Client,
323        cache: Arc<RwLock<HashMap<String, CachedEntry>>>,
324        success_ttl: Duration,
325        failure_ttl: Duration,
326    }
327
328    struct CachedEntry {
329        fetched: Instant,
330        web_id: Option<String>,
331    }
332
333    impl DidNostrResolver {
334        /// Construct a resolver with the default HTTP client (10 s
335        /// timeout) and TTLs matching JSS (5 min success, 1 min
336        /// failure).
337        pub fn new(ssrf: Arc<SsrfPolicy>) -> Self {
338            let client = Client::builder()
339                .timeout(Duration::from_secs(10))
340                .build()
341                .unwrap_or_else(|_| Client::new());
342            Self {
343                ssrf,
344                client,
345                cache: Arc::new(RwLock::new(HashMap::new())),
346                success_ttl: Duration::from_secs(300),
347                failure_ttl: Duration::from_secs(60),
348            }
349        }
350
351        /// Override the default success / failure cache TTLs.
352        pub fn with_ttls(mut self, success: Duration, failure: Duration) -> Self {
353            self.success_ttl = success;
354            self.failure_ttl = failure;
355            self
356        }
357
358        /// Resolve `did:nostr:<pubkey>` against `origin` to a verified
359        /// WebID. Returns `None` if:
360        ///
361        /// - SSRF policy denies the origin or any WebID candidate.
362        /// - DID Doc fetch fails or the doc's `id` does not match
363        ///   `did:nostr:<pubkey>`.
364        /// - `alsoKnownAs` is empty.
365        /// - No candidate WebID carries a back-link (`owl:sameAs` or
366        ///   `schema:sameAs`) to the same `did:nostr:<pubkey>`.
367        ///
368        /// Both success and failure are cached; subsequent calls
369        /// within the matching TTL are served from memory without
370        /// network I/O.
371        pub async fn resolve(&self, origin: &str, pubkey: &str) -> Option<String> {
372            let cache_key = format!("{origin}|{pubkey}");
373
374            // Cache lookup (read lock).
375            if let Ok(guard) = self.cache.read() {
376                if let Some(entry) = guard.get(&cache_key) {
377                    let ttl = if entry.web_id.is_some() {
378                        self.success_ttl
379                    } else {
380                        self.failure_ttl
381                    };
382                    if entry.fetched.elapsed() < ttl {
383                        return entry.web_id.clone();
384                    }
385                }
386            }
387
388            let result = self.resolve_uncached(origin, pubkey).await;
389
390            if let Ok(mut guard) = self.cache.write() {
391                guard.insert(
392                    cache_key,
393                    CachedEntry {
394                        fetched: Instant::now(),
395                        web_id: result.clone(),
396                    },
397                );
398            }
399
400            result
401        }
402
403        async fn resolve_uncached(&self, origin: &str, pubkey: &str) -> Option<String> {
404            // 1. SSRF check on origin.
405            let origin_url = Url::parse(origin).ok()?;
406            self.ssrf.resolve_and_check(&origin_url).await.ok()?;
407
408            // 2. Fetch DID Doc.
409            let url = did_nostr_well_known_url(origin, pubkey);
410            let resp = self
411                .client
412                .get(&url)
413                .header("accept", "application/did+json, application/json")
414                .send()
415                .await
416                .ok()?
417                .error_for_status()
418                .ok()?;
419            let doc: DidNostrDoc = resp.json().await.ok()?;
420
421            if doc.id != format!("did:nostr:{pubkey}") {
422                return None;
423            }
424
425            // 3. Iterate candidates; return the first verified WebID.
426            let did_iri = format!("did:nostr:{pubkey}");
427            for candidate in &doc.also_known_as {
428                if let Some(web_id) = self.try_candidate(candidate, &did_iri).await {
429                    return Some(web_id);
430                }
431            }
432            None
433        }
434
435        async fn try_candidate(&self, candidate: &str, did_iri: &str) -> Option<String> {
436            let url = Url::parse(candidate).ok()?;
437            self.ssrf.resolve_and_check(&url).await.ok()?;
438            let resp = self
439                .client
440                .get(url.as_str())
441                .header("accept", "text/turtle, application/ld+json")
442                .send()
443                .await
444                .ok()?
445                .error_for_status()
446                .ok()?;
447            let body = resp.text().await.ok()?;
448
449            // Back-link check — literal string match suffices for the
450            // bidirectional guarantee because the DID IRI is by spec a
451            // verbatim literal (no relativisation in either RDF flavour).
452            let has_predicate = body.contains("owl:sameAs")
453                || body.contains("schema:sameAs")
454                || body.contains("http://www.w3.org/2002/07/owl#sameAs")
455                || body.contains("https://schema.org/sameAs");
456            if has_predicate && body.contains(did_iri) {
457                Some(candidate.to_string())
458            } else {
459                None
460            }
461        }
462    }
463}
464
465// ---------------------------------------------------------------------------
466// NodeInfo 2.1 (Sprint 7 C)
467// ---------------------------------------------------------------------------
468
469/// `/.well-known/nodeinfo` discovery document (JSON), per
470/// nodeinfo.diaspora.software §6. Points clients at one or more
471/// versioned NodeInfo docs.
472pub fn nodeinfo_discovery(base_url: &str) -> serde_json::Value {
473    serde_json::json!({
474        "links": [
475            {
476                "rel": "http://nodeinfo.diaspora.software/ns/schema/2.1",
477                "href": format!(
478                    "{}/.well-known/nodeinfo/2.1",
479                    base_url.trim_end_matches('/')
480                )
481            }
482        ]
483    })
484}
485
486/// `/.well-known/nodeinfo/2.1` content document, per
487/// nodeinfo.diaspora.software §3 (schema 2.1).
488pub fn nodeinfo_2_1(
489    software_name: &str,
490    software_version: &str,
491    open_registrations: bool,
492    total_users: u64,
493) -> serde_json::Value {
494    serde_json::json!({
495        "version": "2.1",
496        "software": {
497            "name": software_name,
498            "version": software_version,
499            "repository": "https://github.com/dreamlab-ai/solid-pod-rs",
500            "homepage": "https://github.com/dreamlab-ai/solid-pod-rs"
501        },
502        "protocols": ["solid", "activitypub"],
503        "services": {
504            "inbound": [],
505            "outbound": []
506        },
507        "openRegistrations": open_registrations,
508        "usage": {
509            "users": {
510                "total": total_users
511            }
512        },
513        "metadata": {}
514    })
515}