Skip to main content

cairn_mod/cli/
trust_chain.rs

1//! `cairn trust-chain show` — admin-side trust-chain transparency
2//! query (#37).
3//!
4//! Wraps `tools.cairn.admin.getTrustChain`
5//! (src/server/admin/get_trust_chain.rs). **Admin role only** —
6//! the server's auth check uses `verify_and_authorize_admin_only`,
7//! so a moderator-role session file produces a 403 that surfaces
8//! as `CliError::CairnStatus { status: 403, .. }` here.
9//!
10//! No query parameters; the endpoint takes no inputs and returns
11//! a single envelope (signingKeys / maintainers / serviceRecord /
12//! instance + top-level serviceDid).
13//!
14//! Pattern matches `cli/audit.rs` exactly: typed `Input` →
15//! `show()` orchestrator → typed `TrustChainResponse` → pure
16//! `format_*` functions. Same `acquire_service_auth` token-refresh
17//! shape (factor pending in #28; not in scope here).
18
19use std::path::Path;
20use std::time::Duration;
21
22use reqwest::Client;
23use serde::{Deserialize, Serialize};
24
25use super::auth::acquire_service_auth;
26use super::error::CliError;
27use super::output::truncate;
28use super::pds::PdsClient;
29use super::session::SessionFile;
30
31const GET_TRUST_CHAIN_LXM: &str = "tools.cairn.admin.getTrustChain";
32
33/// Wire shape of the `getTrustChain` envelope. Mirrors the server's
34/// `Output` struct in `src/server/admin/get_trust_chain.rs`, which
35/// references `tools.cairn.admin.defs#signingKeyEntry` etc.
36#[derive(Debug, Clone, Deserialize, Serialize)]
37pub struct TrustChainResponse {
38    /// Service DID (the labeler identity).
39    #[serde(rename = "serviceDid")]
40    pub service_did: String,
41    /// Declared signing keys, ordered by `validFrom` ascending.
42    #[serde(rename = "signingKeys")]
43    pub signing_keys: Vec<SigningKeyEntry>,
44    /// Current maintainer roster.
45    pub maintainers: Vec<MaintainerEntry>,
46    /// Published service record summary. Absent when the
47    /// deployment has no `[labeler]` config block, OR when no
48    /// publish has happened yet (handler-side detail; both
49    /// half-states surface as `None` here).
50    #[serde(
51        rename = "serviceRecord",
52        skip_serializing_if = "Option::is_none",
53        default
54    )]
55    pub service_record: Option<ServiceRecordSummary>,
56    /// Instance metadata (build version + serving URL).
57    pub instance: InstanceInfo,
58}
59
60/// One entry in the `signingKeys` array.
61#[derive(Debug, Clone, Deserialize, Serialize)]
62pub struct SigningKeyEntry {
63    /// Multibase-encoded public key (matches `verificationMethod[*].
64    /// publicKeyMultibase` in the labeler's DID document).
65    #[serde(rename = "publicKeyMultibase")]
66    pub public_key_multibase: String,
67    /// RFC-3339 timestamp the key became valid.
68    #[serde(rename = "validFrom")]
69    pub valid_from: String,
70    /// RFC-3339 timestamp the key was rotated out. Absent when
71    /// still valid (rotation-shaped schema, §F8; v1.1 has no
72    /// rotation flow yet).
73    #[serde(rename = "validTo", skip_serializing_if = "Option::is_none", default)]
74    pub valid_to: Option<String>,
75    /// RFC-3339 timestamp the row was inserted.
76    #[serde(rename = "createdAt")]
77    pub created_at: String,
78    /// `true` when `validTo` is absent or in the future.
79    #[serde(rename = "isActive")]
80    pub is_active: bool,
81}
82
83/// One entry in the `maintainers` array.
84#[derive(Debug, Clone, Deserialize, Serialize)]
85pub struct MaintainerEntry {
86    /// Maintainer DID.
87    pub did: String,
88    /// Role discriminator (`"mod"` or `"admin"`).
89    pub role: String,
90    /// RFC-3339 timestamp the row was inserted.
91    #[serde(rename = "addedAt")]
92    pub added_at: String,
93    /// DID of the moderator who added this entry via the HTTP-
94    /// attested admin endpoint. Absent when the row was inserted
95    /// via the CLI / direct SQL.
96    #[serde(rename = "addedBy", skip_serializing_if = "Option::is_none", default)]
97    pub added_by: Option<String>,
98    /// `true` iff `addedBy` is present (verified caller DID
99    /// recorded). `false` for CLI-initiated inserts pre-dating
100    /// HTTP-attested moderator-add flows.
101    #[serde(rename = "provenanceAttested")]
102    pub provenance_attested: bool,
103}
104
105/// `serviceRecord` body when the labeler has both a declared
106/// taxonomy and a published record.
107#[derive(Debug, Clone, Deserialize, Serialize)]
108pub struct ServiceRecordSummary {
109    /// SHA-256 hex of the published record's canonical encoding.
110    #[serde(rename = "contentHash")]
111    pub content_hash: String,
112    /// Declared label-value short names from the `[labeler]`
113    /// config block.
114    #[serde(rename = "labelValues")]
115    pub label_values: Vec<String>,
116}
117
118/// Instance metadata footer.
119#[derive(Debug, Clone, Deserialize, Serialize)]
120pub struct InstanceInfo {
121    /// Semver of the running cairn-mod binary.
122    pub version: String,
123    /// Public-facing service endpoint URL.
124    #[serde(rename = "serviceEndpoint")]
125    pub service_endpoint: String,
126}
127
128/// Input to `cairn trust-chain show`. Endpoint takes no per-call
129/// parameters; only the session-related override lives here.
130#[derive(Debug, Clone, Default)]
131pub struct TrustChainShowInput {
132    /// Per-invocation override of the session's stored Cairn URL.
133    pub cairn_server_override: Option<String>,
134}
135
136/// Fetch the trust-chain envelope via the admin HTTP endpoint.
137///
138/// Wraps the `tools.cairn.admin.getTrustChain` handler at
139/// [src/server/admin/get_trust_chain.rs](..) — GET, no query params;
140/// server enforces ADMIN role (`verify_and_authorize_admin_only`),
141/// returns the envelope.
142pub async fn show(
143    session: &mut SessionFile,
144    session_path: &Path,
145    input: TrustChainShowInput,
146) -> Result<TrustChainResponse, CliError> {
147    let cairn_server = input
148        .cairn_server_override
149        .as_deref()
150        .unwrap_or(&session.cairn_server_url)
151        .trim_end_matches('/')
152        .to_string();
153    let pds = PdsClient::new(&session.pds_url)?;
154    let token = acquire_service_auth(&pds, session, session_path, GET_TRUST_CHAIN_LXM).await?;
155
156    let url = format!("{cairn_server}/xrpc/{GET_TRUST_CHAIN_LXM}");
157    let client = Client::builder()
158        .timeout(Duration::from_secs(30))
159        .build()
160        .expect("reqwest build");
161    let resp = client
162        .get(&url)
163        .bearer_auth(&token)
164        .send()
165        .await
166        .map_err(|source| CliError::Http {
167            url: url.clone(),
168            source,
169        })?;
170    if !resp.status().is_success() {
171        let status = resp.status().as_u16();
172        let body = resp.text().await.unwrap_or_default();
173        return Err(CliError::CairnStatus { url, status, body });
174    }
175    let bytes = resp.bytes().await.map_err(|source| CliError::Http {
176        url: url.clone(),
177        source,
178    })?;
179    serde_json::from_slice::<TrustChainResponse>(&bytes)
180        .map_err(|source| CliError::MalformedResponse { url, source })
181}
182
183/// Tabular human output. Sections: service-DID line, signing-keys
184/// table, maintainers table, service-record block (or "(not
185/// published)" line), instance footer. Sections separated by
186/// blank lines; matches the existing `cli/audit.rs` aesthetic.
187pub fn format_show_human(resp: &TrustChainResponse) -> String {
188    use std::fmt::Write;
189    let mut s = String::new();
190    let _ = writeln!(s, "service: {}", resp.service_did);
191
192    // ---- signing keys ----
193    let _ = writeln!(s);
194    let _ = writeln!(s, "signing keys ({}):", resp.signing_keys.len());
195    if resp.signing_keys.is_empty() {
196        let _ = writeln!(s, "  (none)");
197    } else {
198        let key_w = resp
199            .signing_keys
200            .iter()
201            .map(|k| k.public_key_multibase.len().min(48))
202            .max()
203            .unwrap_or(20)
204            .max(20);
205        let _ = writeln!(
206            s,
207            "  {:<key_w$}  {:<24}  {:<6}",
208            "PUBLIC_KEY_MULTIBASE",
209            "VALID_FROM",
210            "ACTIVE",
211            key_w = key_w,
212        );
213        for k in &resp.signing_keys {
214            let key = truncate(&k.public_key_multibase, 48);
215            let active = if k.is_active { "yes" } else { "no" };
216            let _ = writeln!(
217                s,
218                "  {:<key_w$}  {:<24}  {:<6}",
219                key,
220                k.valid_from,
221                active,
222                key_w = key_w,
223            );
224        }
225    }
226
227    // ---- maintainers ----
228    let _ = writeln!(s);
229    let _ = writeln!(s, "maintainers ({}):", resp.maintainers.len());
230    if resp.maintainers.is_empty() {
231        let _ = writeln!(s, "  (none)");
232    } else {
233        let did_w = resp
234            .maintainers
235            .iter()
236            .map(|m| m.did.len().min(40))
237            .max()
238            .unwrap_or(8)
239            .max(8);
240        let _ = writeln!(
241            s,
242            "  {:<did_w$}  {:<5}  {:<24}  {:<10}",
243            "DID",
244            "ROLE",
245            "ADDED_AT",
246            "PROVENANCE",
247            did_w = did_w,
248        );
249        for m in &resp.maintainers {
250            let did = truncate(&m.did, 40);
251            let prov = if m.provenance_attested {
252                "attested"
253            } else {
254                "unattested"
255            };
256            let _ = writeln!(
257                s,
258                "  {:<did_w$}  {:<5}  {:<24}  {:<10}",
259                did,
260                m.role,
261                m.added_at,
262                prov,
263                did_w = did_w,
264            );
265        }
266    }
267
268    // ---- service record ----
269    let _ = writeln!(s);
270    match &resp.service_record {
271        Some(sr) => {
272            let _ = writeln!(s, "service record:");
273            let _ = writeln!(s, "  content_hash: {}", sr.content_hash);
274            let _ = writeln!(s, "  label values: {}", sr.label_values.join(", "));
275        }
276        None => {
277            let _ = writeln!(s, "service record: (not published)");
278        }
279    }
280
281    // ---- instance footer ----
282    let _ = writeln!(s);
283    let _ = writeln!(s, "instance:");
284    let _ = writeln!(s, "  version:          {}", resp.instance.version);
285    let _ = write!(s, "  service endpoint: {}", resp.instance.service_endpoint);
286    s
287}
288
289/// JSON output. Pretty-printed for shell readability; clients
290/// piping through `jq` get the canonical wire shape.
291pub fn format_show_json(resp: &TrustChainResponse) -> String {
292    serde_json::to_string_pretty(resp).expect("TrustChainResponse serializes")
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn sample(service_record: Option<ServiceRecordSummary>) -> TrustChainResponse {
300        TrustChainResponse {
301            service_did: "did:plc:cairn0000000000000000000000".into(),
302            signing_keys: vec![SigningKeyEntry {
303                public_key_multibase: "zKeyAbc123".into(),
304                valid_from: "2026-04-23T00:00:00.000Z".into(),
305                valid_to: None,
306                created_at: "2026-04-23T00:00:00.000Z".into(),
307                is_active: true,
308            }],
309            maintainers: vec![
310                MaintainerEntry {
311                    did: "did:plc:admin00000000000000000000".into(),
312                    role: "admin".into(),
313                    added_at: "2026-04-25T00:00:00.000Z".into(),
314                    added_by: None,
315                    provenance_attested: false,
316                },
317                MaintainerEntry {
318                    did: "did:plc:byhttp00000000000000000000".into(),
319                    role: "mod".into(),
320                    added_at: "2026-04-26T00:00:00.000Z".into(),
321                    added_by: Some("did:plc:admin00000000000000000000".into()),
322                    provenance_attested: true,
323                },
324            ],
325            service_record,
326            instance: InstanceInfo {
327                version: "1.2.0".into(),
328                service_endpoint: "https://labeler.example".into(),
329            },
330        }
331    }
332
333    #[test]
334    fn format_human_shows_required_sections() {
335        let r = sample(Some(ServiceRecordSummary {
336            content_hash: "abcdef0123".into(),
337            label_values: vec!["spam".into(), "abuse".into()],
338        }));
339        let s = format_show_human(&r);
340        assert!(s.contains("service: did:plc:cairn0000000000000000000000"));
341        assert!(s.contains("signing keys (1)"));
342        assert!(s.contains("zKeyAbc123"));
343        assert!(s.contains("maintainers (2)"));
344        assert!(s.contains("attested"));
345        assert!(s.contains("unattested"));
346        assert!(s.contains("service record:"));
347        assert!(s.contains("content_hash: abcdef0123"));
348        assert!(s.contains("spam, abuse"));
349        assert!(s.contains("version:          1.2.0"));
350        assert!(s.contains("https://labeler.example"));
351    }
352
353    #[test]
354    fn format_human_marks_service_record_not_published_when_absent() {
355        let r = sample(None);
356        let s = format_show_human(&r);
357        assert!(s.contains("service record: (not published)"));
358        assert!(
359            !s.contains("content_hash:"),
360            "no content_hash line when service_record is None"
361        );
362    }
363
364    #[test]
365    fn format_json_round_trips() {
366        let r = sample(Some(ServiceRecordSummary {
367            content_hash: "deadbeef".into(),
368            label_values: vec!["spam".into()],
369        }));
370        let json = format_show_json(&r);
371        let parsed: TrustChainResponse = serde_json::from_str(&json).expect("round trip");
372        assert_eq!(parsed.service_did, r.service_did);
373        assert_eq!(parsed.signing_keys.len(), 1);
374        assert_eq!(parsed.maintainers.len(), 2);
375        assert_eq!(
376            parsed
377                .service_record
378                .as_ref()
379                .map(|s| s.content_hash.as_str()),
380            Some("deadbeef")
381        );
382        assert_eq!(parsed.instance.version, "1.2.0");
383    }
384
385    #[test]
386    fn deserialize_omits_optional_fields() {
387        // Wire shape with no validTo, no addedBy, no serviceRecord —
388        // exact bytes the server emits via skip_serializing_if.
389        let body = r#"{
390          "serviceDid": "did:plc:x",
391          "signingKeys": [{
392            "publicKeyMultibase": "zk",
393            "validFrom": "2026-01-01T00:00:00.000Z",
394            "createdAt": "2026-01-01T00:00:00.000Z",
395            "isActive": true
396          }],
397          "maintainers": [{
398            "did": "did:plc:m",
399            "role": "admin",
400            "addedAt": "2026-01-01T00:00:00.000Z",
401            "provenanceAttested": false
402          }],
403          "instance": {
404            "version": "1.2.0",
405            "serviceEndpoint": "https://labeler.example"
406          }
407        }"#;
408        let parsed: TrustChainResponse = serde_json::from_str(body).expect("parse");
409        assert!(parsed.signing_keys[0].valid_to.is_none());
410        assert!(parsed.maintainers[0].added_by.is_none());
411        assert!(parsed.service_record.is_none());
412    }
413}