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