Skip to main content

qualia_client_core/
front_door.rs

1//! **Front-door record** — how a domain is discovered as an agent (QDP: `draft-webcivics-QDP`).
2//!
3//! Two forms, per the plan (§0.5) — **DNS is the primary, no-hosting anchor**:
4//! - **DNS TXT at `_qdp.<domain>`** (QDP §3.6): the **Front Door DID** (a contextually-isolated, per-domain
5//!   DID) + compact peering material. A domain owner adds one record at their registrar/Cloudflare — **no
6//!   server needed**. This is the minimum viable front-door.
7//! - **`/.well-known/QDP` profile** (QDP §3.1): the *rich* agent profile in **Turtle + JSON-LD + CBOR-LD**
8//!   (Solid-compatible) — the **optional** enhancement for those who have hosting (web server / Cloudflare
9//!   Worker+R2 / Solid POD). The DNS record may point to it (`qdp:profile`).
10//!
11//! Private keys are NEVER placed in either form (QDP §5). CBOR-LD term-dictionary compaction (the q42 vocab)
12//! is deferred — `to_cbor_ld` is CBOR of the JSON-LD document (linked data in CBOR), which is lossless.
13
14use serde::{Deserialize, Serialize};
15use serde_json::json;
16
17use crate::domains::AgentType;
18
19const NS_QDP: &str = "https://webcivics.github.io/QDP/ontdev/QDP#";
20
21/// A service a domain-agent exposes (QDP §3.4): eCash address, Solid POD, SPARQL endpoint, …
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct QdpService {
24    /// `"ecash" | "solidpod" | "sparql" | "endpoint" | "webid"`.
25    pub kind: String,
26    pub value: String,
27}
28
29/// The front-door record for a domain acting as a QDP agent.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct FrontDoorRecord {
32    pub domain: String,
33    pub agent_type: AgentType,
34    /// The contextually-isolated **Front Door DID** for this domain (QDP §3.6).
35    pub front_door_did: String,
36    #[serde(default)]
37    pub name: Option<String>,
38    #[serde(default)]
39    pub webid: Option<String>,
40    /// Services (eCash address, Solid POD, SPARQL…). Only `ecash` is carried in the compact DNS form.
41    #[serde(default)]
42    pub services: Vec<QdpService>,
43    // --- compact peering material (carried in DNS, no hosting) ---
44    #[serde(default)]
45    pub identity_pubkey_hex: Option<String>,
46    #[serde(default)]
47    pub wireguard_pubkey_hex: Option<String>,
48    #[serde(default)]
49    pub overlay_addr: Option<String>,
50    /// Optional pointer to the rich hosted profile (`/.well-known/QDP` or a Solid POD).
51    #[serde(default)]
52    pub profile_url: Option<String>,
53}
54
55/// The DNS record name the front-door TXT lives at (QDP §3.6).
56pub fn dns_record_name(domain: &str) -> String {
57    format!("_qdp.{domain}")
58}
59
60fn agent_type_token(t: &AgentType) -> &'static str {
61    match t {
62        AgentType::NaturalPerson => "person",
63        AgentType::Organization => "org",
64        AgentType::AiAgent => "ai",
65        AgentType::HumanitarianService => "service",
66        AgentType::ContentProvider => "content",
67        AgentType::Group => "group",
68    }
69}
70fn agent_type_from_token(s: &str) -> AgentType {
71    match s {
72        "org" => AgentType::Organization,
73        "ai" => AgentType::AiAgent,
74        "service" => AgentType::HumanitarianService,
75        "content" => AgentType::ContentProvider,
76        "group" => AgentType::Group,
77        _ => AgentType::NaturalPerson,
78    }
79}
80/// The QDP `rdf:type` for an agent (QDP §3.3 controlled vocabulary).
81fn agent_type_class(t: &AgentType) -> &'static str {
82    match t {
83        AgentType::NaturalPerson => "foaf:Person",
84        AgentType::Organization | AgentType::Group => "schema:Organization",
85        AgentType::AiAgent => "QDP:AIAgent",
86        AgentType::HumanitarianService => "QDP:EssentialService",
87        AgentType::ContentProvider => "QDP:ContentProvider",
88    }
89}
90
91fn ecash(rec: &FrontDoorRecord) -> Option<&str> {
92    rec.services
93        .iter()
94        .find(|s| s.kind == "ecash")
95        .map(|s| s.value.as_str())
96}
97
98impl FrontDoorRecord {
99    /// The **DNS TXT value** for `_qdp.<domain>` — compact, no hosting. An RDF snippet (QDP §3.6 shows
100    /// `qdp:signer <did>`), extended with the peering material. Keep it small (DNS strings are 255 bytes).
101    pub fn to_dns_txt(&self) -> String {
102        let mut parts = vec![
103            format!("qdp:signer <{}>", self.front_door_did),
104            format!("qdp:agentType \"{}\"", agent_type_token(&self.agent_type)),
105        ];
106        if let Some(k) = &self.identity_pubkey_hex {
107            parts.push(format!("qdp:identityKey \"{k}\""));
108        }
109        if let Some(w) = &self.wireguard_pubkey_hex {
110            parts.push(format!("qdp:wireguard \"{w}\""));
111        }
112        if let Some(o) = &self.overlay_addr {
113            parts.push(format!("qdp:overlay \"{o}\""));
114        }
115        if let Some(e) = ecash(self) {
116            parts.push(format!("qdp:ecash \"{e}\""));
117        }
118        if let Some(u) = &self.profile_url {
119            parts.push(format!("qdp:profile <{u}>"));
120        }
121        parts.join(" ; ")
122    }
123
124    /// Parse a `_qdp.<domain>` TXT value back into a record (`domain` comes from the record name).
125    pub fn from_dns_txt(domain: &str, txt: &str) -> Result<Self, String> {
126        let mut rec = FrontDoorRecord {
127            domain: domain.to_string(),
128            agent_type: AgentType::NaturalPerson,
129            front_door_did: String::new(),
130            name: None,
131            webid: None,
132            services: vec![],
133            identity_pubkey_hex: None,
134            wireguard_pubkey_hex: None,
135            overlay_addr: None,
136            profile_url: None,
137        };
138        for clause in txt.split(';') {
139            let clause = clause.trim();
140            let Some((pred, obj)) = clause.split_once(char::is_whitespace) else {
141                continue;
142            };
143            let obj = obj.trim();
144            let uri = || {
145                obj.trim_start_matches('<')
146                    .trim_end_matches('>')
147                    .to_string()
148            };
149            let lit = || obj.trim_matches('"').to_string();
150            match pred.trim() {
151                "qdp:signer" => rec.front_door_did = uri(),
152                "qdp:agentType" => rec.agent_type = agent_type_from_token(&lit()),
153                "qdp:identityKey" => rec.identity_pubkey_hex = Some(lit()),
154                "qdp:wireguard" => rec.wireguard_pubkey_hex = Some(lit()),
155                "qdp:overlay" => rec.overlay_addr = Some(lit()),
156                "qdp:ecash" => rec.services.push(QdpService {
157                    kind: "ecash".into(),
158                    value: lit(),
159                }),
160                "qdp:profile" => rec.profile_url = Some(uri()),
161                _ => {}
162            }
163        }
164        if rec.front_door_did.is_empty() {
165            return Err("front-door record has no qdp:signer".into());
166        }
167        Ok(rec)
168    }
169
170    /// The rich `/.well-known/QDP` profile in **Turtle** (QDP §3.2/§4). Needs hosting.
171    pub fn to_turtle(&self) -> String {
172        use std::fmt::Write as _;
173        let mut t = String::new();
174        let _ = writeln!(t, "@prefix QDP: <{NS_QDP}> .");
175        let _ = writeln!(
176            t,
177            "@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> ."
178        );
179        let _ = writeln!(t, "@prefix foaf: <http://xmlns.com/foaf/0.1/> .");
180        let _ = writeln!(t, "@prefix schema: <https://schema.org/> .");
181        let _ = writeln!(t);
182        let _ = writeln!(t, "<#this> a {} ;", agent_type_class(&self.agent_type));
183        let _ = writeln!(t, "    schema:domain \"{}\" ;", self.domain);
184        let _ = writeln!(t, "    QDP:signer <{}> ;", self.front_door_did);
185        if let Some(w) = &self.webid {
186            let _ = writeln!(t, "    QDP:webid <{w}> ;");
187        }
188        if let Some(n) = &self.name {
189            let _ = writeln!(t, "    foaf:name \"{n}\" ;");
190        }
191        for s in &self.services {
192            match s.kind.as_str() {
193                "ecash" => {
194                    let _ = writeln!(t, "    QDP:hasEcashAccount \"{}\" ;", s.value);
195                }
196                "solidpod" => {
197                    let _ = writeln!(t, "    QDP:hasSolidPod <{}> ;", s.value);
198                }
199                "sparql" => {
200                    let _ = writeln!(t, "    QDP:sparqlEndpoint <{}> ;", s.value);
201                }
202                _ => {
203                    let _ = writeln!(t, "    QDP:serviceEndpoint <{}> ;", s.value);
204                }
205            }
206        }
207        // close with a metadata node (satisfies QDP:hasMetadata SHACL minCount 1).
208        let _ = writeln!(t, "    QDP:hasMetadata [ QDP:metadataType \"profile\" ] .");
209        t
210    }
211
212    /// The rich profile as **JSON-LD** (Solid-native; QDP §3.2). Needs hosting.
213    pub fn to_json_ld(&self) -> serde_json::Value {
214        let mut node = json!({
215            "@context": {
216                "QDP": NS_QDP,
217                "foaf": "http://xmlns.com/foaf/0.1/",
218                "schema": "https://schema.org/",
219            },
220            "@id": "#this",
221            "@type": agent_type_class(&self.agent_type),
222            "schema:domain": self.domain,
223            "QDP:signer": { "@id": self.front_door_did },
224        });
225        let obj = node.as_object_mut().unwrap();
226        if let Some(w) = &self.webid {
227            obj.insert("QDP:webid".into(), json!({ "@id": w }));
228        }
229        if let Some(n) = &self.name {
230            obj.insert("foaf:name".into(), json!(n));
231        }
232        if let Some(e) = ecash(self) {
233            obj.insert("QDP:hasEcashAccount".into(), json!(e));
234        }
235        if let Some(s) = self.services.iter().find(|s| s.kind == "solidpod") {
236            obj.insert("QDP:hasSolidPod".into(), json!({ "@id": s.value }));
237        }
238        obj.insert(
239            "QDP:hasMetadata".into(),
240            json!({ "QDP:metadataType": "profile" }),
241        );
242        node
243    }
244
245    /// The rich profile as **CBOR-LD** — CBOR of the JSON-LD document (linked data in CBOR, lossless).
246    /// Full term-dictionary compaction (the q42 vocab) is a follow-on.
247    pub fn to_cbor_ld(&self) -> Result<Vec<u8>, String> {
248        let mut buf = Vec::new();
249        ciborium::into_writer(&self.to_json_ld(), &mut buf).map_err(|e| e.to_string())?;
250        Ok(buf)
251    }
252
253    /// Decode a CBOR-LD profile back to the JSON-LD document.
254    pub fn from_cbor_ld(bytes: &[u8]) -> Result<serde_json::Value, String> {
255        ciborium::from_reader(bytes).map_err(|e| e.to_string())
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    fn sample() -> FrontDoorRecord {
264        FrontDoorRecord {
265            domain: "alice.example".into(),
266            agent_type: AgentType::NaturalPerson,
267            front_door_did: "did:qdp:alice-frontdoor".into(),
268            name: Some("Alice".into()),
269            webid: Some("https://alice.example/profile#me".into()),
270            services: vec![
271                QdpService {
272                    kind: "ecash".into(),
273                    value: "ecash:qq123".into(),
274                },
275                QdpService {
276                    kind: "solidpod".into(),
277                    value: "https://alice.example/pod/".into(),
278                },
279            ],
280            identity_pubkey_hex: Some("aa".repeat(32)),
281            wireguard_pubkey_hex: Some("bb".repeat(32)),
282            overlay_addr: Some("fd00::1".into()),
283            profile_url: Some("https://alice.example/.well-known/QDP".into()),
284        }
285    }
286
287    #[test]
288    fn dns_record_name_is_underscore_qdp() {
289        assert_eq!(dns_record_name("alice.example"), "_qdp.alice.example");
290    }
291
292    #[test]
293    fn dns_txt_roundtrips_the_no_hosting_anchor() {
294        let rec = sample();
295        let txt = rec.to_dns_txt();
296        assert!(txt.contains("qdp:signer <did:qdp:alice-frontdoor>"));
297        assert!(txt.contains("qdp:wireguard"));
298        let back = FrontDoorRecord::from_dns_txt("alice.example", &txt).unwrap();
299        // The DNS-carried subset round-trips.
300        assert_eq!(back.front_door_did, rec.front_door_did);
301        assert_eq!(back.agent_type, rec.agent_type);
302        assert_eq!(back.wireguard_pubkey_hex, rec.wireguard_pubkey_hex);
303        assert_eq!(back.overlay_addr, rec.overlay_addr);
304        assert_eq!(back.profile_url, rec.profile_url);
305        assert_eq!(ecash(&back), Some("ecash:qq123"));
306    }
307
308    #[test]
309    fn from_dns_txt_requires_a_signer() {
310        assert!(FrontDoorRecord::from_dns_txt("x.example", "qdp:agentType \"person\"").is_err());
311    }
312
313    #[test]
314    fn turtle_has_qdp_mandatory_fields() {
315        let t = sample().to_turtle();
316        assert!(t.contains("@prefix QDP:"));
317        assert!(t.contains("a foaf:Person"));
318        assert!(t.contains("schema:domain \"alice.example\""));
319        assert!(t.contains("QDP:signer <did:qdp:alice-frontdoor>"));
320        assert!(t.contains("QDP:hasEcashAccount \"ecash:qq123\""));
321        assert!(t.contains("QDP:hasMetadata"));
322    }
323
324    #[test]
325    fn jsonld_has_type_domain_and_ecash() {
326        let j = sample().to_json_ld();
327        assert_eq!(j["@type"], "foaf:Person");
328        assert_eq!(j["schema:domain"], "alice.example");
329        assert_eq!(j["QDP:hasEcashAccount"], "ecash:qq123");
330        assert_eq!(j["QDP:signer"]["@id"], "did:qdp:alice-frontdoor");
331    }
332
333    #[test]
334    fn cbor_ld_roundtrips_the_jsonld() {
335        let rec = sample();
336        let bytes = rec.to_cbor_ld().unwrap();
337        let back = FrontDoorRecord::from_cbor_ld(&bytes).unwrap();
338        assert_eq!(
339            back,
340            rec.to_json_ld(),
341            "CBOR-LD is a lossless encoding of the JSON-LD"
342        );
343    }
344
345    #[test]
346    fn org_and_ai_map_to_qdp_types() {
347        let mut r = sample();
348        r.agent_type = AgentType::Organization;
349        assert!(r.to_turtle().contains("a schema:Organization"));
350        r.agent_type = AgentType::AiAgent;
351        assert!(r.to_turtle().contains("a QDP:AIAgent"));
352    }
353}