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