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