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