Skip to main content

vtc_client/
lib.rs

1//! Client SDK for a Verifiable Trust Community (VTC).
2//!
3//! The VTA SDK ([`vta_sdk`]) is the client for *VTAs*; this crate is the
4//! equivalent for *VTCs*. It lets an operator or an integration drive a VTC's
5//! member-facing and admin-facing surface over REST: authenticate, list members
6//! (the community roster), run the join ceremony, remove members, and manage
7//! community policy.
8//!
9//! It is deliberately thin: authentication reuses
10//! [`vta_sdk::auth_light::challenge_response_light`] (the challenge-response
11//! flow is audience-agnostic — pass the VTC's URL and DID and the server binds
12//! `aud` to itself), and the join wire types are re-exported from
13//! [`vta_sdk::protocols::join_requests`]. Only the VTC-specific REST shapes
14//! (member records, pagination) are defined here.
15//!
16//! ## Mount path
17//!
18//! A VTC mounts its API under a configurable base (default `/v1`). Pass the
19//! **full** API base to [`VtcClient::connect`] / [`VtcClient::with_token`] —
20//! e.g. `https://vtc.example.com/v1` — so both `/auth/*` and `/members` resolve.
21//!
22//! ## The `Trust-Task` header is mandatory
23//!
24//! The VTC gates **every** route on a per-route `Trust-Task` URL header
25//! (`vtc-service/src/routes/mod.rs`, the `tt(...)` wrapper) and answers `400`
26//! without it — only `/health` and the browser wallet's `/wallet/auth/*`
27//! aliases are exempt. This client sent it on nothing, so every method failed
28//! at the transport layer regardless of its body. [`task`] holds the URL for
29//! each route and `VtcClient::tt` attaches it; a new method must go through
30//! that helper, not a bare `self.http.get(...)`.
31//!
32//! ## Scope
33//!
34//! Authentication, the member roster, the admin join queue, removal, policy,
35//! and the applicant side of the join ceremony
36//! ([`VtcClient::submit_join`], which signs its own document and needs no
37//! token).
38
39use chrono::{DateTime, Utc};
40use serde::{Deserialize, Serialize};
41
42/// The `Trust-Task` URL each route this client calls is gated on, as declared
43/// in `vtc-service/src/routes/mod.rs`.
44///
45/// Kept as one block so the mapping is auditable against the server's router in
46/// a single read, rather than scattered as string literals down the file. A URL
47/// that drifts from the server's is a 400 at runtime, so this list is part of
48/// the client's contract, not decoration.
49pub mod task {
50    pub const MEMBERS_LIST: &str = "https://trusttasks.org/spec/vtc/members/list/0.1";
51    pub const MEMBERS_UPDATE: &str = "https://trusttasks.org/spec/vtc/members/update/0.1";
52    pub const MEMBERS_ADMIN_REMOVE: &str =
53        "https://trusttasks.org/spec/vtc/members/admin-remove/0.1";
54    pub const JOIN_REQUESTS_LIST: &str = "https://trusttasks.org/spec/vtc/join-requests/list/0.1";
55    pub const JOIN_REQUESTS_DECIDE: &str =
56        "https://trusttasks.org/spec/vtc/join-requests/decide/0.1";
57    pub const POLICY_LIST: &str = "https://trusttasks.org/spec/policy/list/0.2";
58    pub const POLICY_GET: &str = "https://trusttasks.org/spec/policy/get/0.1";
59    pub const POLICY_UPSERT: &str = "https://trusttasks.org/spec/policy/upsert/0.2";
60    pub const POLICY_ACTIVATE: &str = "https://trusttasks.org/spec/policy/activate/0.1";
61}
62
63/// Re-export of the published join-request protocol wire types, so a consumer
64/// driving the join ceremony depends on one crate.
65pub use vta_sdk::protocols::join_requests;
66
67/// Errors surfaced by the VTC client.
68#[derive(Debug, thiserror::Error)]
69pub enum VtcError {
70    /// A request needed a bearer token but the client has none — call
71    /// [`VtcClient::connect`] (or construct via [`VtcClient::with_token`]).
72    #[error("not authenticated — call VtcClient::connect first")]
73    NotAuthenticated,
74    /// The VTC returned a non-success HTTP status.
75    #[error("VTC returned HTTP {status}: {body}")]
76    Http { status: u16, body: String },
77    /// A request URL could not be built.
78    #[error("invalid request url: {0}")]
79    Url(String),
80    /// A transport-level error talking to the VTC.
81    #[error("transport error: {0}")]
82    Transport(#[from] reqwest::Error),
83    /// Challenge-response authentication failed.
84    #[error("authentication failed: {0}")]
85    Auth(#[from] vta_sdk::error::VtaError),
86    /// The operation's route no longer exists on the VTC and this client has no
87    /// replacement for it. Carries what to use instead.
88    #[error("unsupported by this client: {0}")]
89    Unsupported(&'static str),
90    /// Building or signing a holder Trust Task failed — e.g. a non-`did:key`
91    /// applicant, or an undecodable private key.
92    #[error("could not sign the request document: {0}")]
93    Signing(String),
94}
95
96/// A single member of the community, as returned by `GET /members`. Mirrors the
97/// VTC's `MemberResponse` (the fields a fleet/operator typically needs);
98/// unrecognised fields in the response are ignored.
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100#[serde(rename_all = "camelCase")]
101pub struct MemberRecord {
102    /// The member's DID (for a fleet, the managed VTA's DID).
103    pub did: String,
104    /// The member's role on the wire (`"admin"`, `"moderator"`, `"member"`,
105    /// `"custom:…"`, …).
106    pub role: String,
107    #[serde(default)]
108    pub label: Option<String>,
109    pub joined_at: DateTime<Utc>,
110    /// Index of the member's revocation slot in the community status list, when
111    /// allocated.
112    #[serde(default)]
113    pub status_list_index: Option<u32>,
114    /// Id of the member's current membership credential (VMC), if issued.
115    #[serde(default)]
116    pub current_vmc_id: Option<String>,
117    #[serde(default)]
118    pub personhood: bool,
119    #[serde(default)]
120    pub joined_via_invitation: bool,
121    /// Community-defined extensions (opaque JSON). A fleet manager can stash
122    /// per-member operational state here — e.g. a `fleet_index` — at enrollment.
123    #[serde(default)]
124    pub extensions: serde_json::Value,
125}
126
127/// One page of a cursor-paginated VTC listing. Mirrors the server's
128/// `Paginated<T>` (`items` + `next_cursor`); `total_estimate` is ignored.
129#[derive(Debug, Clone, Deserialize)]
130struct Page<T> {
131    items: Vec<T>,
132    next_cursor: Option<String>,
133}
134
135/// A join request in the admin work queue (subset of the VTC's `JoinRequest`).
136#[derive(Debug, Clone, Deserialize, PartialEq)]
137#[serde(rename_all = "camelCase")]
138pub struct JoinRequestSummary {
139    /// The request id (used to approve / reject).
140    pub id: String,
141    /// The DID applying to join (for a fleet, the VTA being enrolled).
142    pub applicant_did: String,
143    /// Wire status: `"pending"`, `"approved"`, `"rejected"`, `"withdrawn"`.
144    pub status: String,
145    pub submitted_at: DateTime<Utc>,
146}
147
148/// Outcome of approving or rejecting a join request.
149#[derive(Debug, Clone, Deserialize, PartialEq)]
150#[serde(rename_all = "camelCase")]
151pub struct DecideResult {
152    pub request_id: String,
153    pub status: String,
154    /// The issued membership credential (VMC) — present on approve.
155    #[serde(default)]
156    pub vmc: Option<serde_json::Value>,
157    /// The issued role credential (VEC) — present on approve when a role applies.
158    #[serde(default)]
159    pub role_vec: Option<serde_json::Value>,
160}
161
162/// Outcome of removing a member (offboarding). The VTC flips the member's
163/// status-list revocation bit as part of removal.
164#[derive(Debug, Clone, Deserialize, PartialEq)]
165#[serde(rename_all = "camelCase")]
166pub struct RemoveResult {
167    pub did: String,
168    /// Wire disposition: `"tombstone"`, `"purge"`, `"historical"`.
169    pub disposition: String,
170    pub removed: bool,
171}
172
173/// A client bound to one VTC's API base, holding a bearer token once
174/// authenticated.
175#[derive(Debug, Clone)]
176pub struct VtcClient {
177    http: reqwest::Client,
178    /// The VTC API base, including the mount (e.g. `https://vtc.example.com/v1`),
179    /// trailing slash trimmed.
180    base_url: String,
181    /// The VTC's own DID (the authentication audience / DIDComm recipient).
182    vtc_did: String,
183    /// Bearer access token, set after [`connect`](Self::connect).
184    token: Option<String>,
185}
186
187impl VtcClient {
188    /// Authenticate to the VTC as `client_did` (challenge-response, reusing the
189    /// VTA SDK's audience-agnostic flow) and return a ready client.
190    ///
191    /// `base_url` is the full API base including the mount (e.g.
192    /// `https://vtc.example.com/v1`); `vtc_did` is the community's DID.
193    pub async fn connect(
194        base_url: &str,
195        vtc_did: &str,
196        client_did: &str,
197        private_key_multibase: &str,
198    ) -> Result<Self, VtcError> {
199        // Finite request + connect timeouts (R1.2) — `reqwest::Client::new()`
200        // has neither, so a blackholed VTC would hang an operator forever.
201        let http = vta_sdk::http::rest_client();
202        let base_url = base_url.trim_end_matches('/').to_string();
203        let auth = vta_sdk::auth_light::challenge_response_light(
204            &http,
205            &base_url,
206            client_did,
207            private_key_multibase,
208            vtc_did,
209        )
210        .await?;
211        Ok(Self {
212            http,
213            base_url,
214            vtc_did: vtc_did.to_string(),
215            token: Some(auth.access_token),
216        })
217    }
218
219    /// Construct a client from an already-obtained bearer token (e.g. a token
220    /// minted out of band, or for testing). `base_url` includes the mount.
221    pub fn with_token(base_url: &str, vtc_did: &str, token: impl Into<String>) -> Self {
222        Self {
223            http: vta_sdk::http::rest_client(),
224            base_url: base_url.trim_end_matches('/').to_string(),
225            vtc_did: vtc_did.to_string(),
226            token: Some(token.into()),
227        }
228    }
229
230    /// Construct a client with **no** bearer token, for the applicant side of
231    /// the join ceremony.
232    ///
233    /// [`submit_join`](Self::submit_join) authenticates with the document's own
234    /// holder proof, so an applicant — who is by definition not yet a member and
235    /// has no token to get — needs exactly this. Every other method returns
236    /// [`VtcError::NotAuthenticated`], which is the honest answer rather than a
237    /// 401 from the server.
238    ///
239    /// `vtc_did` still matters: it is the audience the submitted document is
240    /// addressed to, and the VTC rejects a document addressed elsewhere.
241    pub fn anonymous(base_url: &str, vtc_did: &str) -> Self {
242        Self {
243            http: vta_sdk::http::rest_client(),
244            base_url: base_url.trim_end_matches('/').to_string(),
245            vtc_did: vtc_did.to_string(),
246            token: None,
247        }
248    }
249
250    /// The community's DID this client is bound to.
251    pub fn vtc_did(&self) -> &str {
252        &self.vtc_did
253    }
254
255    /// Start a request carrying the route's `Trust-Task` URL header and the
256    /// bearer token.
257    ///
258    /// Every authenticated call goes through here. The VTC rejects a request
259    /// with no `Trust-Task` header (400) before any handler sees it, so a
260    /// method that builds its request by hand is broken on arrival — which is
261    /// how every method in this client came to be.
262    fn tt(
263        &self,
264        method: reqwest::Method,
265        url: impl reqwest::IntoUrl,
266        task: &str,
267    ) -> Result<reqwest::RequestBuilder, VtcError> {
268        let token = self.token()?;
269        Ok(self
270            .http
271            .request(method, url)
272            .header("Trust-Task", task)
273            .bearer_auth(token))
274    }
275
276    /// List every community member, optionally filtered by `role`, following the
277    /// cursor to completion. Requires an admin token. This is the fleet roster
278    /// when the community's members are managed VTAs.
279    pub async fn list_members(&self, role: Option<&str>) -> Result<Vec<MemberRecord>, VtcError> {
280        let mut out: Vec<MemberRecord> = Vec::new();
281        let mut cursor: Option<String> = None;
282
283        loop {
284            let mut params: Vec<(&str, &str)> = Vec::new();
285            if let Some(role) = role {
286                params.push(("role", role));
287            }
288            if let Some(cursor) = &cursor {
289                params.push(("cursor", cursor.as_str()));
290            }
291            let url =
292                reqwest::Url::parse_with_params(&format!("{}/members", self.base_url), &params)
293                    .map_err(|e| VtcError::Url(e.to_string()))?;
294
295            let resp = self
296                .tt(reqwest::Method::GET, url, task::MEMBERS_LIST)?
297                .send()
298                .await?;
299            if !resp.status().is_success() {
300                let status = resp.status().as_u16();
301                let body = resp.text().await.unwrap_or_default();
302                return Err(VtcError::Http { status, body });
303            }
304
305            let page: Page<MemberRecord> = resp.json().await?;
306            out.extend(page.items);
307            match page.next_cursor {
308                Some(next) => cursor = Some(next),
309                None => break,
310            }
311        }
312        Ok(out)
313    }
314
315    /// List join requests (the admin work queue), optionally filtered by
316    /// `status` (e.g. `"pending"`). Requires an admin token. For a fleet, these
317    /// are VTAs awaiting enrollment.
318    pub async fn list_join_requests(
319        &self,
320        status: Option<&str>,
321    ) -> Result<Vec<JoinRequestSummary>, VtcError> {
322        let mut out: Vec<JoinRequestSummary> = Vec::new();
323        let mut cursor: Option<String> = None;
324        loop {
325            let mut params: Vec<(&str, &str)> = Vec::new();
326            if let Some(status) = status {
327                params.push(("status", status));
328            }
329            if let Some(cursor) = &cursor {
330                params.push(("cursor", cursor.as_str()));
331            }
332            let url = reqwest::Url::parse_with_params(
333                &format!("{}/join-requests", self.base_url),
334                &params,
335            )
336            .map_err(|e| VtcError::Url(e.to_string()))?;
337
338            let resp = self
339                .tt(reqwest::Method::GET, url, task::JOIN_REQUESTS_LIST)?
340                .send()
341                .await?;
342            if !resp.status().is_success() {
343                let status = resp.status().as_u16();
344                let body = resp.text().await.unwrap_or_default();
345                return Err(VtcError::Http { status, body });
346            }
347            let page: Page<JoinRequestSummary> = resp.json().await?;
348            out.extend(page.items);
349            match page.next_cursor {
350                Some(next) => cursor = Some(next),
351                None => break,
352            }
353        }
354        Ok(out)
355    }
356
357    /// Approve a join request — admit the applicant and issue its membership
358    /// credential (VMC). Requires an admin token. For a fleet, this enrolls a
359    /// VTA that has applied to join.
360    pub async fn approve_join(&self, request_id: &str) -> Result<DecideResult, VtcError> {
361        self.decide(request_id, "approved", None).await
362    }
363
364    /// Reject a join request, optionally recording an operator rationale in the
365    /// audit trail. Requires an admin token.
366    pub async fn reject_join(
367        &self,
368        request_id: &str,
369        reason: Option<&str>,
370    ) -> Result<DecideResult, VtcError> {
371        self.decide(request_id, "rejected", reason).await
372    }
373
374    /// `POST /join-requests/{id}/decide` with `{ decision, reason? }`.
375    ///
376    /// The VTC previously exposed a `/approve` + `/reject` mount pair; both were
377    /// retired in favour of this single endpoint carrying the decision in the
378    /// body, and the old mounts are **gone** — this client was still posting to
379    /// them, so approve and reject were 404s independent of the missing header.
380    /// `decision` is the server's `Decision` enum on the wire (`approved` /
381    /// `rejected`), not the imperative verb the old paths used.
382    async fn decide(
383        &self,
384        request_id: &str,
385        decision: &str,
386        reason: Option<&str>,
387    ) -> Result<DecideResult, VtcError> {
388        let url = format!("{}/join-requests/{request_id}/decide", self.base_url);
389        let mut body = serde_json::json!({ "decision": decision });
390        if let Some(reason) = reason {
391            body["reason"] = serde_json::json!(reason);
392        }
393        let resp = self
394            .tt(reqwest::Method::POST, url, task::JOIN_REQUESTS_DECIDE)?
395            .json(&body)
396            .send()
397            .await?;
398        if !resp.status().is_success() {
399            let status = resp.status().as_u16();
400            let body = resp.text().await.unwrap_or_default();
401            return Err(VtcError::Http { status, body });
402        }
403        Ok(resp.json().await?)
404    }
405
406    /// Remove a member (offboarding). The VTC applies its removal disposition and
407    /// flips the member's status-list revocation bit. `reason` is an optional
408    /// admin note. Requires an admin token. For a fleet, this decommissions a
409    /// managed VTA.
410    pub async fn remove_member(
411        &self,
412        did: &str,
413        reason: Option<&str>,
414    ) -> Result<RemoveResult, VtcError> {
415        let url = format!("{}/members/{did}", self.base_url);
416        let mut req = self.tt(reqwest::Method::DELETE, url, task::MEMBERS_ADMIN_REMOVE)?;
417        if let Some(reason) = reason {
418            req = req.json(&serde_json::json!({ "reason": reason }));
419        }
420        let resp = req.send().await?;
421        if !resp.status().is_success() {
422            let status = resp.status().as_u16();
423            let body = resp.text().await.unwrap_or_default();
424            return Err(VtcError::Http { status, body });
425        }
426        Ok(resp.json().await?)
427    }
428
429    /// Update a member's community-defined `extensions` (opaque JSON) via
430    /// `PATCH /members/{did}`. A fleet manager records per-member operational
431    /// state here — e.g. the assigned `fleet_index` at enrollment, which the
432    /// roster then carries (see [`MemberRecord::extensions`]). Admin token.
433    pub async fn update_member_extensions(
434        &self,
435        did: &str,
436        extensions: serde_json::Value,
437    ) -> Result<(), VtcError> {
438        let resp = self
439            .tt(
440                reqwest::Method::PATCH,
441                format!("{}/members/{did}", self.base_url),
442                task::MEMBERS_UPDATE,
443            )?
444            .json(&serde_json::json!({ "extensions": extensions }))
445            .send()
446            .await?;
447        if !resp.status().is_success() {
448            let status = resp.status().as_u16();
449            let body = resp.text().await.unwrap_or_default();
450            return Err(VtcError::Http { status, body });
451        }
452        Ok(())
453    }
454
455    /// Submit a join request (the applicant side): sign a
456    /// `join-requests/submit/0.1` Trust Task with the applicant's holder key and
457    /// post it to the document endpoint. Returns the community's verdict —
458    /// auto-admit carries the issued VMC + role VEC inline, otherwise the
459    /// request is queued for an admin.
460    ///
461    /// **No bearer token.** The document's `eddsa-jcs-2022` proof *is* the
462    /// authentication: the VTC takes the proof's `verificationMethod` DID as the
463    /// applicant and requires the document `issuer` to match it
464    /// (`vtc-service/src/trust_tasks/mod.rs::resolve_holder`). So this is the
465    /// one method that works on a client built with neither
466    /// [`connect`](Self::connect) nor [`with_token`](Self::with_token) — an
467    /// applicant is by definition not yet a member.
468    ///
469    /// `applicant_did` must be a `did:key` (the server's proof resolver accepts
470    /// no other method) whose seed is `private_key_multibase`. It is the DID
471    /// that becomes the member on admission, *not* whatever identity this client
472    /// may hold a token for — a fleet manager submitting on behalf of a VTA
473    /// signs with that VTA's key.
474    ///
475    /// The document is addressed to [`vtc_did`](Self::vtc_did) (SPEC §4.8.2
476    /// audience binding), so a signed submit captured from one community cannot
477    /// be replayed into another.
478    ///
479    /// ## Why the key, and not just a body
480    ///
481    /// This used to POST the VP-framed body to `POST /join-requests`, a route
482    /// that no longer exists — the holder-facing join verbs (`submit`/`request`,
483    /// `manifest`, `status`) were folded into the single Trust-Task document
484    /// endpoint, routed by document `type`. That fold moved the applicant's
485    /// authentication from "a signature somewhere inside the body" to "a proof
486    /// over the whole document", which is why this signature grew the key.
487    pub async fn submit_join(
488        &self,
489        body: &join_requests::JoinRequestSubmitBody,
490        applicant_did: &str,
491        private_key_multibase: &str,
492    ) -> Result<join_requests::VerdictResponse, VtcError> {
493        let payload = serde_json::to_value(body)
494            .map_err(|e| VtcError::Url(format!("serialise submit payload: {e}")))?;
495        let doc = vta_sdk::trust_task_sign::build_signed(
496            join_requests::JOIN_REQUEST_SUBMIT_TYPE,
497            payload,
498            applicant_did,
499            private_key_multibase,
500            &self.vtc_did,
501        )
502        .await
503        .map_err(|e| VtcError::Signing(e.to_string()))?;
504
505        // The document endpoint takes no `Trust-Task` header — the document's
506        // own `type` is the identity, which is exactly why one mount can serve
507        // every holder verb.
508        let resp = self
509            .http
510            .post(format!("{}/trust-tasks", self.base_url))
511            .header("content-type", "application/json")
512            .body(doc)
513            .send()
514            .await?;
515        if !resp.status().is_success() {
516            let status = resp.status().as_u16();
517            let body = resp.text().await.unwrap_or_default();
518            return Err(VtcError::Http { status, body });
519        }
520
521        // A Trust-Task request is answered with a `#response` document whose
522        // payload is the verdict.
523        let text = resp.text().await?;
524        let response_doc: trust_tasks_rs::TrustTask<serde_json::Value> =
525            serde_json::from_str(&text).map_err(|e| VtcError::Http {
526                status: 200,
527                body: format!(
528                    "unexpected submit response (not a Trust Task document): {e}: {text}"
529                ),
530            })?;
531        serde_json::from_value(response_doc.payload).map_err(|e| VtcError::Http {
532            status: 200,
533            body: format!("submit response payload is not a VerdictResponse: {e}"),
534        })
535    }
536
537    /// List the community's policies (opaque JSON descriptors). Admin token.
538    pub async fn list_policies(&self) -> Result<Vec<serde_json::Value>, VtcError> {
539        let mut out = Vec::new();
540        let mut cursor: Option<String> = None;
541        loop {
542            let mut params: Vec<(&str, &str)> = Vec::new();
543            if let Some(cursor) = &cursor {
544                params.push(("cursor", cursor.as_str()));
545            }
546            let url =
547                reqwest::Url::parse_with_params(&format!("{}/policies", self.base_url), &params)
548                    .map_err(|e| VtcError::Url(e.to_string()))?;
549            let resp = self
550                .tt(reqwest::Method::GET, url, task::POLICY_LIST)?
551                .send()
552                .await?;
553            if !resp.status().is_success() {
554                let status = resp.status().as_u16();
555                let body = resp.text().await.unwrap_or_default();
556                return Err(VtcError::Http { status, body });
557            }
558            let page: Page<serde_json::Value> = resp.json().await?;
559            out.extend(page.items);
560            match page.next_cursor {
561                Some(next) => cursor = Some(next),
562                None => break,
563            }
564        }
565        Ok(out)
566    }
567
568    /// Fetch one policy by id (opaque JSON, incl. the Rego source). Admin token.
569    pub async fn get_policy(&self, id: &str) -> Result<serde_json::Value, VtcError> {
570        self.get_json(&format!("policies/{id}"), task::POLICY_GET)
571            .await
572    }
573
574    /// Upload a new Rego policy bundle for `purpose` (`"join"`, `"removal"`,
575    /// …). Returns the upload descriptor (id, sha256, version). Admin token.
576    /// Upload alone does not activate it — call [`activate_policy`](Self::activate_policy).
577    pub async fn upload_policy(
578        &self,
579        purpose: &str,
580        rego_source: &str,
581    ) -> Result<serde_json::Value, VtcError> {
582        self.post_json(
583            "policies",
584            task::POLICY_UPSERT,
585            &serde_json::json!({ "purpose": purpose, "regoSource": rego_source }),
586        )
587        .await
588    }
589
590    /// Activate a previously-uploaded policy (make it live for decisions of its
591    /// purpose). Admin token.
592    pub async fn activate_policy(&self, id: &str) -> Result<serde_json::Value, VtcError> {
593        self.post_json(
594            &format!("policies/{id}/activate"),
595            task::POLICY_ACTIVATE,
596            &serde_json::json!({}),
597        )
598        .await
599    }
600
601    /// Authenticated GET returning JSON, carrying `task` as the Trust-Task URL.
602    async fn get_json(&self, path: &str, task: &str) -> Result<serde_json::Value, VtcError> {
603        let resp = self
604            .tt(
605                reqwest::Method::GET,
606                format!("{}/{path}", self.base_url),
607                task,
608            )?
609            .send()
610            .await?;
611        if !resp.status().is_success() {
612            let status = resp.status().as_u16();
613            let body = resp.text().await.unwrap_or_default();
614            return Err(VtcError::Http { status, body });
615        }
616        Ok(resp.json().await?)
617    }
618
619    /// Authenticated POST of a JSON body returning JSON, carrying `task` as the
620    /// Trust-Task URL.
621    async fn post_json(
622        &self,
623        path: &str,
624        task: &str,
625        body: &serde_json::Value,
626    ) -> Result<serde_json::Value, VtcError> {
627        let resp = self
628            .tt(
629                reqwest::Method::POST,
630                format!("{}/{path}", self.base_url),
631                task,
632            )?
633            .json(body)
634            .send()
635            .await?;
636        if !resp.status().is_success() {
637            let status = resp.status().as_u16();
638            let body = resp.text().await.unwrap_or_default();
639            return Err(VtcError::Http { status, body });
640        }
641        Ok(resp.json().await?)
642    }
643
644    /// Bearer token or [`VtcError::NotAuthenticated`].
645    fn token(&self) -> Result<&str, VtcError> {
646        self.token.as_deref().ok_or(VtcError::NotAuthenticated)
647    }
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    #[test]
655    fn member_page_deserializes_from_vtc_shape() {
656        // A `Paginated<MemberResponse>` as the VTC serialises it (extra fields
657        // present to prove they're ignored).
658        let json = serde_json::json!({
659            "items": [{
660                "did": "did:key:z6MkStaffVta",
661                "role": "member",
662                "label": "Staff VTA",
663                "joinedAt": "2026-06-23T00:00:00Z",
664                "publishConsent": true,
665                "departurePreference": "tombstone",
666                "statusListIndex": 7,
667                "currentVmcId": "urn:uuid:vmc-1",
668                "extensions": {},
669                "personhood": false,
670                "joinedViaInvitation": true
671            }],
672            "next_cursor": null
673        });
674        let page: Page<MemberRecord> = serde_json::from_value(json).unwrap();
675        assert_eq!(page.items.len(), 1);
676        let m = &page.items[0];
677        assert_eq!(m.did, "did:key:z6MkStaffVta");
678        assert_eq!(m.role, "member");
679        assert_eq!(m.status_list_index, Some(7));
680        assert_eq!(m.current_vmc_id.as_deref(), Some("urn:uuid:vmc-1"));
681        assert!(m.joined_via_invitation);
682        assert!(page.next_cursor.is_none());
683    }
684
685    #[tokio::test]
686    async fn list_members_without_token_is_not_authenticated() {
687        let client = VtcClient {
688            http: reqwest::Client::new(),
689            base_url: "https://vtc.example.com/v1".into(),
690            vtc_did: "did:web:vtc.example.com".into(),
691            token: None,
692        };
693        // The token guard returns before any network I/O.
694        let err = client.list_members(None).await;
695        assert!(matches!(err, Err(VtcError::NotAuthenticated)), "{err:?}");
696    }
697
698    #[test]
699    fn decide_result_deserializes_camel_case() {
700        let json = serde_json::json!({
701            "requestId": "11111111-1111-1111-1111-111111111111",
702            "status": "approved",
703            "vmc": { "type": ["VerifiableCredential", "MembershipCredential"] },
704            "roleVec": null
705        });
706        let d: DecideResult = serde_json::from_value(json).unwrap();
707        assert_eq!(d.request_id, "11111111-1111-1111-1111-111111111111");
708        assert_eq!(d.status, "approved");
709        assert!(d.vmc.is_some());
710        assert!(d.role_vec.is_none());
711    }
712
713    #[test]
714    fn join_request_and_remove_results_deserialize() {
715        let jr: JoinRequestSummary = serde_json::from_value(serde_json::json!({
716            "id": "22222222-2222-2222-2222-222222222222",
717            "applicantDid": "did:key:z6MkApplicant",
718            "status": "pending",
719            "submittedAt": "2026-06-23T00:00:00Z"
720        }))
721        .unwrap();
722        assert_eq!(jr.applicant_did, "did:key:z6MkApplicant");
723        assert_eq!(jr.status, "pending");
724
725        let rm: RemoveResult = serde_json::from_value(serde_json::json!({
726            "did": "did:key:z6MkGone",
727            "disposition": "tombstone",
728            "removed": true
729        }))
730        .unwrap();
731        assert_eq!(rm.did, "did:key:z6MkGone");
732        assert!(rm.removed);
733    }
734
735    #[tokio::test]
736    async fn admin_methods_without_token_are_not_authenticated() {
737        let client = VtcClient {
738            http: reqwest::Client::new(),
739            base_url: "https://vtc.example.com/v1".into(),
740            vtc_did: "did:web:vtc.example.com".into(),
741            token: None,
742        };
743        assert!(matches!(
744            client.list_join_requests(Some("pending")).await,
745            Err(VtcError::NotAuthenticated)
746        ));
747        assert!(matches!(
748            client.approve_join("req-1").await,
749            Err(VtcError::NotAuthenticated)
750        ));
751        assert!(matches!(
752            client.remove_member("did:key:x", Some("reason")).await,
753            Err(VtcError::NotAuthenticated)
754        ));
755    }
756
757    #[test]
758    fn member_extensions_default_and_parse() {
759        let none: MemberRecord = serde_json::from_value(serde_json::json!({
760            "did": "did:key:z", "role": "member", "joinedAt": "2026-06-23T00:00:00Z"
761        }))
762        .unwrap();
763        assert!(none.extensions.is_null());
764        let with: MemberRecord = serde_json::from_value(serde_json::json!({
765            "did": "did:key:z", "role": "member", "joinedAt": "2026-06-23T00:00:00Z",
766            "extensions": { "fleet_index": 3 }
767        }))
768        .unwrap();
769        assert_eq!(with.extensions["fleet_index"], 3);
770    }
771
772    #[tokio::test]
773    async fn policy_admin_methods_without_token_are_not_authenticated() {
774        let client = VtcClient {
775            http: reqwest::Client::new(),
776            base_url: "https://vtc.example.com/v1".into(),
777            vtc_did: "did:web:vtc.example.com".into(),
778            token: None,
779        };
780        assert!(matches!(
781            client.list_policies().await,
782            Err(VtcError::NotAuthenticated)
783        ));
784        assert!(matches!(
785            client.get_policy("p1").await,
786            Err(VtcError::NotAuthenticated)
787        ));
788        assert!(matches!(
789            client.upload_policy("join", "package x").await,
790            Err(VtcError::NotAuthenticated)
791        ));
792        assert!(matches!(
793            client.activate_policy("p1").await,
794            Err(VtcError::NotAuthenticated)
795        ));
796        assert!(matches!(
797            client
798                .update_member_extensions("did:key:z", serde_json::json!({}))
799                .await,
800            Err(VtcError::NotAuthenticated)
801        ));
802    }
803}