Skip to main content

solid_pod_rs/
webid.rs

1//! WebID profile document generation and validation.
2//!
3//! The embedded JSON-LD data island mirrors JSS
4//! `src/webid/profile.js::generateProfileJsonLd` (commits cccd081 #320,
5//! 01e12b0 #299): it carries both the legacy `solid:oidcIssuer` predicate
6//! and the LWS 1.0 Controlled Identifier `service` array, plus
7//! `foaf:isPrimaryTopicOf` / `schema:mainEntityOfPage` self-references.
8
9use serde_json::{json, Value};
10
11// ─── URL builders ──────────────────────────────────────────────────────────
12//
13// Tiny pure-string helpers that mirror the URL layout used throughout the
14// crate (and in JSS upstream). Exposed because downstream clients
15// (nostr-bbs-forum-client, nostr-bbs-pod-worker) were duplicating these
16// `format!` lines verbatim and drifting whenever the layout evolved.
17
18/// Root URL of a per-user pod: `{pod_base}/pods/{pubkey}/`.
19///
20/// `pod_base` is taken verbatim — callers should pass the value of
21/// `[pod].base_url` (e.g. `https://pods.example.com`). The pubkey is the
22/// hex-encoded BIP-340 x-only key.
23pub fn pod_root_url(pod_base: &str, pubkey: &str) -> String {
24    format!("{}/pods/{}/", pod_base.trim_end_matches('/'), pubkey)
25}
26
27/// Document URL of a user's WebID profile card:
28/// `{pod_base}/pods/{pubkey}/profile/card`.
29pub fn webid_document_url(pod_base: &str, pubkey: &str) -> String {
30    format!("{}/pods/{}/profile/card", pod_base.trim_end_matches('/'), pubkey)
31}
32
33/// WebID URI (the `#me` fragment): `{pod_base}/pods/{pubkey}/profile/card#me`.
34pub fn webid_url(pod_base: &str, pubkey: &str) -> String {
35    format!("{}#me", webid_document_url(pod_base, pubkey))
36}
37
38/// Git clone URL of the per-user pod (matches `pod_root_url`).
39/// solid-pod-rs-git enables clone/push at this URL when `git-auto-init`
40/// is wired by the operator. CF Workers tier cannot serve git — see
41/// nostr-rust-forum ADR-089.
42pub fn pod_git_clone_url(pod_base: &str, pubkey: &str) -> String {
43    pod_root_url(pod_base, pubkey)
44}
45
46/// Render a WebID profile as an HTML document with embedded JSON-LD.
47///
48/// Omits `solid:oidcIssuer`. Prefer [`generate_webid_html_with_issuer`]
49/// for Solid-OIDC flows.
50pub fn generate_webid_html(pubkey: &str, name: Option<&str>, pod_base: &str) -> String {
51    generate_webid_html_with_issuer(pubkey, name, pod_base, None)
52}
53
54/// Render a WebID profile with an optional Solid-OIDC issuer
55/// advertised via `solid:oidcIssuer` and, when present, an LWS 1.0
56/// `service` entry typed `lws:OpenIdProvider`.
57pub fn generate_webid_html_with_issuer(
58    pubkey: &str,
59    name: Option<&str>,
60    pod_base: &str,
61    oidc_issuer: Option<&str>,
62) -> String {
63    let display_name = name.unwrap_or("Solid Pod User");
64    let pod_url = format!("{pod_base}/pods/{pubkey}/");
65    let webid = format!("{pod_base}/pods/{pubkey}/profile/card#me");
66    // Document URL (WebID without fragment) — anchor for relative self
67    // references and for the cid:service fragment id.
68    let doc_url = webid.split('#').next().unwrap_or(&webid).to_string();
69
70    let key_id = format!("{doc_url}#nostr-key");
71
72    let mut context = json!({
73        "foaf": "http://xmlns.com/foaf/0.1/",
74        "solid": "http://www.w3.org/ns/solid/terms#",
75        "schema": "http://schema.org/",
76        "cid": "https://www.w3.org/ns/cid/v1#",
77        "lws": "https://www.w3.org/ns/lws#",
78        "isPrimaryTopicOf": { "@id": "foaf:isPrimaryTopicOf", "@type": "@id" },
79        "mainEntityOfPage": { "@id": "schema:mainEntityOfPage", "@type": "@id" },
80        "service": { "@id": "cid:service", "@container": "@set" },
81        "serviceEndpoint": { "@id": "cid:serviceEndpoint", "@type": "@id" },
82        "controller": { "@id": "cid:controller", "@type": "@id" },
83        "verificationMethod": { "@id": "cid:verificationMethod", "@container": "@set" },
84        "authentication": { "@id": "cid:authentication", "@container": "@set" },
85        "assertionMethod": { "@id": "cid:assertionMethod", "@container": "@set" },
86        "publicKeyJwk": "cid:publicKeyJwk",
87        "publicKeyMultibase": "cid:publicKeyMultibase",
88        "Multikey": "cid:Multikey",
89        "JsonWebKey": "cid:JsonWebKey"
90    });
91    // Keep context shape mutable in case future rows add more terms.
92    let _ = context.as_object_mut();
93
94    // CID v1 verificationMethod: Nostr x-only BIP-340 pubkey encoded as hex
95    // multibase: `f` (base16-lower multibase) + `eb` (multicodec
96    // `bip340-pub`, the 32-byte x-only form) + the 64-char x-only hex.
97    //
98    // D-2 (ADR-124 §7) — `feb` is the DELIBERATE WebID-side standard and is
99    // INTENTIONALLY distinct from the DID-document `fe70102`
100    // (`secp256k1-pub` = `0xe7` over the 33-byte SEC1-compressed even-y
101    // point). The two surfaces use two different W3C-registered multicodecs
102    // by design:
103    //   - DID doc  (did_nostr_types::format_multibase_schnorr): `fe70102`
104    //     — secp256k1-pub, 33-byte compressed (02 ‖ X), the create-agent /
105    //     did-nostr-CG canonical form.
106    //   - WebID CID v1 profile (here): `feb` — bip340-pub, 32-byte x-only,
107    //     the Controlled-Identifier-document form.
108    // Both round-trip to the same raw x-only pubkey, so I1/I2 hold across
109    // both. This is NOT drift to reconcile — aligning `feb`→`fe70102` here
110    // would mis-tag an x-only key as a compressed key. Kept in lockstep with
111    // the `try_elevate` needle in `auth/nip98.rs` (D-3), which matches this
112    // exact `feb<hex>` string. The elevation path runs AFTER NIP-98 Schnorr
113    // verification and never gates auth (I3).
114    let pubkey_multibase = format!("feb{pubkey}");
115
116    let mut body = json!({
117        "@context": context,
118        "@id": webid,
119        "@type": "foaf:Person",
120        "foaf:name": display_name,
121        "foaf:isPrimaryTopicOf": "",
122        "schema:mainEntityOfPage": "",
123        "solid:account": pod_url,
124        "solid:privateTypeIndex": format!("{pod_url}settings/privateTypeIndex"),
125        "solid:publicTypeIndex": format!("{pod_url}settings/publicTypeIndex"),
126        "schema:identifier": format!("did:nostr:{pubkey}"),
127        "controller": &webid,
128        "verificationMethod": [{
129            "@id": &key_id,
130            "@type": "Multikey",
131            "controller": &webid,
132            "publicKeyMultibase": &pubkey_multibase
133        }],
134        "authentication": [&key_id],
135        "assertionMethod": [&key_id]
136    });
137
138    if let Some(iss) = oidc_issuer {
139        // Legacy Solid-OIDC predicate (kept for existing clients).
140        body["solid:oidcIssuer"] = json!({ "@id": iss });
141        // LWS 1.0 Controlled Identifier service entry.
142        body["service"] = json!([{
143            "@id": format!("{doc_url}#oidc"),
144            "@type": "lws:OpenIdProvider",
145            "serviceEndpoint": iss
146        }]);
147    }
148
149    let body_json =
150        serde_json::to_string_pretty(&body).expect("serde_json::Value always serialises");
151
152    format!(
153        r#"<!DOCTYPE html>
154<html>
155<head>
156  <meta charset="utf-8">
157  <title>{display_name}</title>
158  <script type="application/ld+json">
159{body_json}
160  </script>
161</head>
162<body>
163  <h1>{display_name}</h1>
164  <p>WebID: <a href="{webid}">{webid}</a></p>
165  <p>Pod: <a href="{pod_url}">{pod_url}</a></p>
166</body>
167</html>"#
168    )
169}
170
171/// Locate and parse the JSON-LD data island from a WebID HTML document.
172fn parse_json_ld(data: &[u8]) -> Result<Option<Value>, String> {
173    let text =
174        std::str::from_utf8(data).map_err(|_| "WebID profile must be valid UTF-8".to_string())?;
175    let start = match text.find("application/ld+json") {
176        Some(s) => s,
177        None => return Ok(None),
178    };
179    let tag_end = match text[start..].find('>') {
180        Some(e) => e,
181        None => return Ok(None),
182    };
183    let json_start = start + tag_end + 1;
184    let script_end = match text[json_start..].find("</script>") {
185        Some(e) => e,
186        None => return Ok(None),
187    };
188    let json_str = text[json_start..json_start + script_end].trim();
189    let value: Value =
190        serde_json::from_str(json_str).map_err(|e| format!("WebID JSON-LD parse error: {e}"))?;
191    Ok(Some(value))
192}
193
194/// Follow-your-nose discovery — extract `solid:oidcIssuer` from a
195/// WebID HTML document. Returns `Ok(None)` when the profile does not
196/// advertise an issuer.
197pub fn extract_oidc_issuer(data: &[u8]) -> Result<Option<String>, String> {
198    let value = match parse_json_ld(data)? {
199        Some(v) => v,
200        None => return Ok(None),
201    };
202    let issuer = value
203        .get("solid:oidcIssuer")
204        .or_else(|| value.get("http://www.w3.org/ns/solid/terms#oidcIssuer"));
205    match issuer {
206        Some(Value::String(s)) => Ok(Some(s.clone())),
207        Some(Value::Object(m)) => {
208            if let Some(Value::String(s)) = m.get("@id") {
209                Ok(Some(s.clone()))
210            } else {
211                Ok(None)
212            }
213        }
214        _ => Ok(None),
215    }
216}
217
218/// Follow-your-nose discovery — extract the bare `nostr:pubkey`
219/// triple from a WebID HTML document. Returns `Ok(None)` when the
220/// profile does not advertise a Nostr public key.
221///
222/// JSS v0.0.190 Phase 1 (issue #437): the pod-resident NIP-05 endpoint
223/// and the `did-nostr` resolver both look up this predicate to map a
224/// WebID to its Nostr identity. Seeded into the profile by
225/// [`solid_pod_rs_idp::key_provisioning::provision_pod_keys`] (parity
226/// row 196). Parity row **128** / **197**.
227pub fn extract_nostr_pubkey(data: &[u8]) -> Result<Option<String>, String> {
228    let value = match parse_json_ld(data)? {
229        Some(v) => v,
230        None => return Ok(None),
231    };
232    let raw = value
233        .get("nostr:pubkey")
234        .or_else(|| value.get("https://nostr.org/ns#pubkey"));
235    match raw {
236        Some(Value::String(s)) => Ok(Some(s.clone())),
237        Some(Value::Object(m)) => {
238            if let Some(Value::String(s)) = m.get("@value").or_else(|| m.get("@id")) {
239                Ok(Some(s.clone()))
240            } else {
241                Ok(None)
242            }
243        }
244        _ => Ok(None),
245    }
246}
247
248/// LWS 1.0 Controlled Identifier discovery — return the
249/// `serviceEndpoint` of the first `service` entry whose `@type` is
250/// `lws:OpenIdProvider` (or the fully-expanded IRI). Mirrors the shape
251/// of [`extract_oidc_issuer`]; returns `Ok(None)` when absent.
252pub fn extract_cid_openid_provider(data: &[u8]) -> Result<Option<String>, String> {
253    let value = match parse_json_ld(data)? {
254        Some(v) => v,
255        None => return Ok(None),
256    };
257    let service = value
258        .get("service")
259        .or_else(|| value.get("cid:service"))
260        .or_else(|| value.get("https://www.w3.org/ns/cid/v1#service"));
261    let arr = match service {
262        Some(Value::Array(a)) => a,
263        _ => return Ok(None),
264    };
265    for entry in arr {
266        let Some(obj) = entry.as_object() else {
267            continue;
268        };
269        let ty = obj.get("@type");
270        let matches = match ty {
271            Some(Value::String(s)) => {
272                s == "lws:OpenIdProvider" || s == "https://www.w3.org/ns/lws#OpenIdProvider"
273            }
274            Some(Value::Array(ts)) => ts.iter().any(|t| {
275                matches!(
276                    t.as_str(),
277                    Some("lws:OpenIdProvider") | Some("https://www.w3.org/ns/lws#OpenIdProvider")
278                )
279            }),
280            _ => false,
281        };
282        if !matches {
283            continue;
284        }
285        let endpoint = obj
286            .get("serviceEndpoint")
287            .or_else(|| obj.get("cid:serviceEndpoint"))
288            .or_else(|| obj.get("https://www.w3.org/ns/cid/v1#serviceEndpoint"));
289        match endpoint {
290            Some(Value::String(s)) => return Ok(Some(s.clone())),
291            Some(Value::Object(m)) => {
292                if let Some(Value::String(s)) = m.get("@id") {
293                    return Ok(Some(s.clone()));
294                }
295            }
296            _ => {}
297        }
298    }
299    Ok(None)
300}
301
302/// Validate that a byte slice is a well-formed WebID profile.
303pub fn validate_webid_html(data: &[u8]) -> Result<(), String> {
304    let text =
305        std::str::from_utf8(data).map_err(|_| "WebID profile must be valid UTF-8".to_string())?;
306    if !text.contains("application/ld+json") {
307        return Err(
308            "WebID profile must contain a <script type=\"application/ld+json\"> block".to_string(),
309        );
310    }
311    // parse_json_ld surfaces syntactic errors.
312    parse_json_ld(data)?;
313    Ok(())
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    fn json_ld_body(html: &str) -> serde_json::Value {
321        let start = html.find("application/ld+json").expect("ld+json tag");
322        let tag_end = html[start..].find('>').expect("script >");
323        let body_start = start + tag_end + 1;
324        let body_end = html[body_start..].find("</script>").expect("/script");
325        let body = html[body_start..body_start + body_end].trim();
326        serde_json::from_str(body).expect("body parses")
327    }
328
329    #[test]
330    fn contains_pubkey() {
331        let html = generate_webid_html("abc123", None, "https://pods.example.com");
332        assert!(html.contains("abc123"));
333        assert!(html.contains("did:nostr:abc123"));
334    }
335
336    #[test]
337    fn validate_accepts_valid() {
338        let html = generate_webid_html("abc", Some("Alice"), "https://pods.example.com");
339        assert!(validate_webid_html(html.as_bytes()).is_ok());
340    }
341
342    #[test]
343    fn validate_rejects_missing_jsonld() {
344        let html = "<!DOCTYPE html><html><body>no ld+json</body></html>";
345        assert!(validate_webid_html(html.as_bytes()).is_err());
346    }
347
348    #[test]
349    fn generate_with_issuer_embeds_oidc_triple() {
350        let html = generate_webid_html_with_issuer(
351            "abc",
352            Some("Alice"),
353            "https://pods.example.com",
354            Some("https://op.example"),
355        );
356        assert!(html.contains("solid:oidcIssuer"));
357        assert!(html.contains("https://op.example"));
358    }
359
360    #[test]
361    fn extract_oidc_issuer_returns_issuer_id() {
362        let html = generate_webid_html_with_issuer(
363            "abc",
364            Some("Alice"),
365            "https://pods.example.com",
366            Some("https://op.example"),
367        );
368        let iss = extract_oidc_issuer(html.as_bytes()).unwrap();
369        assert_eq!(iss.as_deref(), Some("https://op.example"));
370    }
371
372    #[test]
373    fn extract_oidc_issuer_absent_returns_none() {
374        let html = generate_webid_html_with_issuer("abc", Some("Alice"), "https://p", None);
375        let iss = extract_oidc_issuer(html.as_bytes()).unwrap();
376        assert!(iss.is_none());
377    }
378
379    // --- Parity rows 154/155/165 ---------------------------------------
380
381    #[test]
382    fn emits_cid_service_when_issuer_present() {
383        let html = generate_webid_html_with_issuer(
384            "abc",
385            Some("Alice"),
386            "https://pods.example.com",
387            Some("https://op.example"),
388        );
389        // Context namespaces are present.
390        assert!(
391            html.contains("https://www.w3.org/ns/cid/v1#"),
392            "cid namespace missing"
393        );
394        assert!(
395            html.contains("https://www.w3.org/ns/lws#"),
396            "lws namespace missing"
397        );
398        // Service entry with LWS OpenIdProvider type.
399        assert!(
400            html.contains("lws:OpenIdProvider"),
401            "lws:OpenIdProvider type missing"
402        );
403        // Service @id resolves against document URL (fragment #oidc).
404        assert!(
405            html.contains("https://pods.example.com/pods/abc/profile/card#oidc"),
406            "service @id fragment missing"
407        );
408    }
409
410    #[test]
411    fn omits_cid_service_when_no_issuer() {
412        let html = generate_webid_html_with_issuer("abc", Some("Alice"), "https://p", None);
413        let body = json_ld_body(&html);
414        assert!(
415            body.get("service").is_none(),
416            "service array must be absent without issuer"
417        );
418        assert!(
419            !html.contains("lws:OpenIdProvider"),
420            "OpenIdProvider must not leak when issuer absent"
421        );
422    }
423
424    #[test]
425    fn emits_primary_topic_of_and_main_entity_of_page() {
426        let html =
427            generate_webid_html_with_issuer("abc", Some("Alice"), "https://pods.example.com", None);
428        let body = json_ld_body(&html);
429        assert_eq!(
430            body.get("foaf:isPrimaryTopicOf").and_then(|v| v.as_str()),
431            Some(""),
432            "foaf:isPrimaryTopicOf must be empty string (relative self-ref)"
433        );
434        assert_eq!(
435            body.get("schema:mainEntityOfPage").and_then(|v| v.as_str()),
436            Some(""),
437            "schema:mainEntityOfPage must be empty string (relative self-ref)"
438        );
439        // Context must declare both predicates.
440        let ctx = body.get("@context").expect("@context");
441        assert!(ctx.get("isPrimaryTopicOf").is_some());
442        assert!(ctx.get("mainEntityOfPage").is_some());
443    }
444
445    #[test]
446    fn extract_cid_openid_provider_returns_endpoint() {
447        let html = generate_webid_html_with_issuer(
448            "abc",
449            Some("Alice"),
450            "https://pods.example.com",
451            Some("https://op.example"),
452        );
453        let endpoint = extract_cid_openid_provider(html.as_bytes()).unwrap();
454        assert_eq!(endpoint.as_deref(), Some("https://op.example"));
455    }
456
457    #[test]
458    fn extract_cid_openid_provider_absent_returns_none() {
459        let html = generate_webid_html_with_issuer("abc", Some("Alice"), "https://p", None);
460        let endpoint = extract_cid_openid_provider(html.as_bytes()).unwrap();
461        assert!(endpoint.is_none());
462    }
463
464    #[test]
465    fn json_ld_body_is_valid_json() {
466        // Regression guard against hand-escaping: whatever issuer/name we
467        // feed in, the embedded body must parse with serde_json.
468        for issuer in [None, Some("https://op.example/path?q=1&x=2")] {
469            let html = generate_webid_html_with_issuer(
470                "abc",
471                Some(r#"Alice "Quoted" O'Neil"#),
472                "https://pods.example.com",
473                issuer,
474            );
475            let start = html
476                .find("application/ld+json")
477                .expect("ld+json tag present");
478            let tag_end = html[start..].find('>').expect("script open >");
479            let body_start = start + tag_end + 1;
480            let body_end = html[body_start..].find("</script>").expect("script close");
481            let body = html[body_start..body_start + body_end].trim();
482            serde_json::from_str::<serde_json::Value>(body).unwrap_or_else(|e| {
483                panic!("embedded JSON-LD failed to parse: {e}\n----\n{body}\n----")
484            });
485        }
486    }
487}