1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct QdpService {
24 pub kind: String,
26 pub value: String,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct FrontDoorRecord {
32 pub domain: String,
33 pub agent_type: AgentType,
34 pub front_door_did: String,
36 #[serde(default)]
37 pub name: Option<String>,
38 #[serde(default)]
39 pub webid: Option<String>,
40 #[serde(default)]
42 pub services: Vec<QdpService>,
43 #[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 #[serde(default)]
52 pub profile_url: Option<String>,
53}
54
55pub 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}
80fn 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 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 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 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 let _ = writeln!(t, " QDP:hasMetadata [ QDP:metadataType \"profile\" ] .");
209 t
210 }
211
212 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 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 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 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}