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: authenticate, list members
6//! (the community roster), run the join ceremony, remove members, and manage
7//! community policy.
8//!
9//! ## Two surfaces, and only one of them is a URL
10//!
11//! The VTC answers **holder verbs** — the applicant side of the join ceremony —
12//! on a single document endpoint, routed by the document's own `type`. Those are
13//! addressed to a community, not to a URL, so they travel over HTTPS, a mediated
14//! DIDComm session or TSP without changing. Build the client with
15//! `VtcClient::connect_didcomm` or `VtcClient::connect_tsp` (features
16//! `didcomm` / `tsp`) and they go over the session; build it any other way and
17//! they go over HTTPS.
18//!
19//! The **admin verbs** cannot. Each is gated on a bearer token *and* a per-route
20//! `Trust-Task` header, which is a URL-shaped surface; a session-only client
21//! answers them with [`VtcError::NoRestTransport`] rather than failing obscurely.
22//!
23//! The session transports are **delegated to `vta_sdk::client::VtaClient`**,
24//! which already owns session setup, `thid` demultiplexing, retry under one
25//! idempotency key and reply-proof verification. A second copy of that here
26//! would be a second thing to keep correct.
27//!
28//! ## Any DID method can be the holder
29//!
30//! [`VtcClient::submit_join_as`] takes a [`HolderKey`] and so signs as any DID
31//! method; [`VtcClient::submit_join`] is the `did:key` convenience wrapper over
32//! it. Over a session the question does not arise at all — the envelope proves
33//! the sender and the VTC never reads a document proof.
34//!
35//! This matters because a persona minted by a VTA is a `did:webvh`. A client
36//! that could sign only as a `did:key` made every such holder borrow an identity
37//! it does not otherwise use, and the borrowed one is the DID that would have
38//! become the member.
39//!
40//! It is deliberately thin: authentication reuses
41//! [`vta_sdk::auth_light::challenge_response_light`] (the challenge-response
42//! flow is audience-agnostic — pass the VTC's URL and DID and the server binds
43//! `aud` to itself), and the join wire types are re-exported from
44//! [`vta_sdk::protocols::join_requests`]. Only the VTC-specific REST shapes
45//! (member records, pagination) are defined here.
46//!
47//! ## Mount path
48//!
49//! A VTC mounts its API under a configurable base (default `/v1`). Pass the
50//! **full** API base to [`VtcClient::connect`] / [`VtcClient::with_token`] —
51//! e.g. `https://vtc.example.com/v1` — so both `/auth/*` and `/members` resolve.
52//!
53//! ## The `Trust-Task` header is mandatory
54//!
55//! The VTC gates **every** route on a per-route `Trust-Task` URL header
56//! (`vtc-service/src/routes/mod.rs`, the `tt(...)` wrapper) and answers `400`
57//! without it — only `/health` and the browser wallet's `/wallet/auth/*`
58//! aliases are exempt. This client sent it on nothing, so every method failed
59//! at the transport layer regardless of its body. [`task`] holds the URL for
60//! each route and `VtcClient::tt` attaches it; a new method must go through
61//! that helper, not a bare `self.http.get(...)`.
62//!
63//! ## Scope
64//!
65//! Authentication, the member roster, the admin join queue, removal, policy,
66//! the vetting admin surface (vetter grants, automatic grants, branding and
67//! statement withdrawals — what `cnm vetting` drives), and the applicant side
68//! of the join ceremony
69//! ([`VtcClient::submit_join`] / [`VtcClient::submit_join_as`], which sign
70//! their own document and need no token).
71
72use chrono::{DateTime, Utc};
73use serde::{Deserialize, Serialize};
74
75/// Re-exported so a caller can name the holder key without also depending on
76/// `vta-sdk` directly. A VTC client that has to reach past this crate for the
77/// type its own method takes is a client with a seam in it.
78pub use vta_sdk::trust_task_sign::HolderKey;
79
80/// Round-trip budget for a holder verb sent over a session, in seconds.
81///
82/// A join submit is not a local read: the community evaluates its policy and,
83/// on auto-admit, issues a VMC and a role VEC before it answers. The HTTPS path
84/// inherits `reqwest`'s own timeout; this is the session path's equivalent, and
85/// it exists at all because a call with no finite bound turns a community that
86/// has stopped answering into a client that never returns.
87#[cfg(feature = "didcomm")]
88const SESSION_TIMEOUT_SECS: u64 = 60;
89
90/// The `Trust-Task` URL each route this client calls is gated on, as declared
91/// in `vtc-service/src/routes/mod.rs`.
92///
93/// Kept as one block so the mapping is auditable against the server's router in
94/// a single read, rather than scattered as string literals down the file. A URL
95/// that drifts from the server's is a 400 at runtime, so this list is part of
96/// the client's contract, not decoration.
97pub mod rooms;
98
99pub mod task {
100    pub const MEMBERS_LIST: &str = "https://trusttasks.org/spec/vtc/members/list/0.1";
101    pub const MEMBERS_UPDATE: &str = "https://trusttasks.org/spec/vtc/members/update/0.1";
102    pub const MEMBERS_ADMIN_REMOVE: &str =
103        "https://trusttasks.org/spec/vtc/members/admin-remove/0.1";
104    pub const JOIN_REQUESTS_LIST: &str = "https://trusttasks.org/spec/vtc/join-requests/list/0.1";
105    pub const JOIN_REQUESTS_DECIDE: &str =
106        "https://trusttasks.org/spec/vtc/join-requests/decide/0.1";
107    pub const POLICY_LIST: &str = "https://trusttasks.org/spec/policy/list/0.2";
108    pub const POLICY_GET: &str = "https://trusttasks.org/spec/policy/get/0.1";
109    pub const POLICY_UPSERT: &str = "https://trusttasks.org/spec/policy/upsert/0.2";
110    pub const POLICY_ACTIVATE: &str = "https://trusttasks.org/spec/policy/activate/0.1";
111    pub const VETTING_VETTERS_GRANT: &str =
112        "https://trusttasks.org/spec/vtc/vetting/vetters/grant/0.1";
113    pub const VETTING_VETTERS_RESEND: &str =
114        "https://trusttasks.org/spec/vtc/vetting/vetters/resend/0.1";
115    pub const ENDORSEMENTS_REVOKE: &str = "https://trusttasks.org/spec/vtc/endorsements/revoke/0.1";
116}
117
118/// Re-export of the published join-request protocol wire types, so a consumer
119/// driving the join ceremony depends on one crate.
120pub use vta_sdk::protocols::join_requests;
121
122/// Re-export of the peer identity vetting wire types — the vetter grant, the
123/// grant listing and the automatic-grant configuration this client's vetting
124/// admin verbs send and return.
125pub use vta_sdk::protocols::vetting;
126
127/// Errors surfaced by the VTC client.
128#[derive(Debug, thiserror::Error)]
129pub enum VtcError {
130    /// A request needed a bearer token but the client has none — call
131    /// [`VtcClient::connect`] (or construct via [`VtcClient::with_token`]).
132    #[error("not authenticated — call VtcClient::connect first")]
133    NotAuthenticated,
134    /// The VTC returned a non-success HTTP status.
135    #[error("VTC returned HTTP {status}: {body}")]
136    Http { status: u16, body: String },
137    /// A request URL could not be built.
138    #[error("invalid request url: {0}")]
139    Url(String),
140    /// A transport-level error talking to the VTC.
141    #[error("transport error: {0}")]
142    Transport(#[from] reqwest::Error),
143    /// Challenge-response authentication failed.
144    #[error("authentication failed: {0}")]
145    Auth(#[from] vta_sdk::error::VtaError),
146    /// The operation's route no longer exists on the VTC and this client has no
147    /// replacement for it. Carries what to use instead.
148    #[error("unsupported by this client: {0}")]
149    Unsupported(&'static str),
150    /// Building or signing a holder Trust Task failed — e.g. an applicant DID
151    /// that is not a `did:key` passed to the `did:key` convenience wrapper, a
152    /// verification method with no fragment, or an undecodable private key.
153    #[error("could not sign the request document: {0}")]
154    Signing(String),
155    /// Opening or using a messaging session to the VTC failed.
156    ///
157    /// Distinct from [`Transport`](Self::Transport), which is HTTPS: a mediator
158    /// that will not route and a URL that will not resolve are different
159    /// faults with different fixes, and one error that covered both would send
160    /// the reader to the wrong half of the system.
161    #[error("session transport error: {0}")]
162    Session(String),
163    /// A verb that only exists on the HTTPS surface was called on a client
164    /// built with no REST base.
165    ///
166    /// The admin verbs are gated on a bearer token *and* a per-route
167    /// `Trust-Task` header, which is a URL-shaped surface — they cannot ride a
168    /// session. Rather than fail at the transport with something obscure, say
169    /// so: pass `rest_url` to the `connect_*` constructor.
170    #[error("this client has no REST base — {0} needs one; pass rest_url when connecting")]
171    NoRestTransport(&'static str),
172}
173
174/// A single member of the community, as returned by `GET /members`. Mirrors the
175/// VTC's `MemberResponse` (the fields a fleet/operator typically needs);
176/// unrecognised fields in the response are ignored.
177#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
178#[serde(rename_all = "camelCase")]
179pub struct MemberRecord {
180    /// The member's DID (for a fleet, the managed VTA's DID).
181    pub did: String,
182    /// The member's role on the wire (`"admin"`, `"moderator"`, `"member"`,
183    /// `"custom:…"`, …).
184    pub role: String,
185    #[serde(default)]
186    pub label: Option<String>,
187    pub joined_at: DateTime<Utc>,
188    /// Index of the member's revocation slot in the community status list, when
189    /// allocated.
190    #[serde(default)]
191    pub status_list_index: Option<u32>,
192    /// Id of the member's current membership credential (VMC), if issued.
193    #[serde(default)]
194    pub current_vmc_id: Option<String>,
195    #[serde(default)]
196    pub personhood: bool,
197    #[serde(default)]
198    pub joined_via_invitation: bool,
199    /// Community-defined extensions (opaque JSON). A fleet manager can stash
200    /// per-member operational state here — e.g. a `fleet_index` — at enrollment.
201    #[serde(default)]
202    pub extensions: serde_json::Value,
203}
204
205/// One page of a cursor-paginated VTC listing. Mirrors the server's
206/// `Paginated<T>` (`items` + `nextCursor`); `totalEstimate` is ignored.
207///
208/// The wire names are camelCase, as R3.1 requires and as the published Trust
209/// Task schemas have always said. The server sent `next_cursor` until the
210/// conformance witness (#1059) caught it; this mirror had followed the server
211/// rather than the schema, so both were wrong together and neither noticed.
212#[derive(Debug, Clone, Deserialize)]
213#[serde(rename_all = "camelCase")]
214struct Page<T> {
215    items: Vec<T>,
216    next_cursor: Option<String>,
217}
218
219/// A join request in the admin work queue (subset of the VTC's `JoinRequest`).
220#[derive(Debug, Clone, Deserialize, PartialEq)]
221#[serde(rename_all = "camelCase")]
222pub struct JoinRequestSummary {
223    /// The request id (used to approve / reject).
224    pub id: String,
225    /// The DID applying to join (for a fleet, the VTA being enrolled).
226    pub applicant_did: String,
227    /// Wire status: `"pending"`, `"approved"`, `"rejected"`, `"withdrawn"`.
228    pub status: String,
229    pub submitted_at: DateTime<Utc>,
230}
231
232/// Outcome of approving or rejecting a join request.
233#[derive(Debug, Clone, Deserialize, PartialEq)]
234#[serde(rename_all = "camelCase")]
235pub struct DecideResult {
236    pub request_id: String,
237    pub status: String,
238    /// The issued membership credential (VMC) — present on approve.
239    #[serde(default)]
240    pub vmc: Option<serde_json::Value>,
241    /// The issued role credential (VEC) — present on approve when a role applies.
242    #[serde(default)]
243    pub role_vec: Option<serde_json::Value>,
244}
245
246/// Outcome of removing a member (offboarding). The VTC flips the member's
247/// status-list revocation bit as part of removal.
248#[derive(Debug, Clone, Deserialize, PartialEq)]
249#[serde(rename_all = "camelCase")]
250pub struct RemoveResult {
251    pub did: String,
252    /// Wire disposition: `"tombstone"`, `"purge"`, `"historical"`.
253    pub disposition: String,
254    pub removed: bool,
255}
256
257/// Outcome of naming a member a vetter (`POST /vetting/vetters`).
258///
259/// Granting converges: while the member holds a live grant, asking again
260/// returns that grant rather than issuing a second. `created` says which
261/// happened, so an operator is not told "granted" about a grant that already
262/// stood.
263#[derive(Debug, Clone)]
264#[non_exhaustive]
265pub struct VetterGrant {
266    /// `true` when this call issued the grant (HTTP 201), `false` when the
267    /// member already held a live one (HTTP 200).
268    pub created: bool,
269    /// The grant: the `vtc/vetting/vetters/grant/0.1#response` payload.
270    pub grant: vetting::vetters::grant::v0_1::Response,
271}
272
273/// Outcome of revoking an endorsement (`DELETE /credentials/endorsements/{id}`)
274/// — which is how a vetter grant is withdrawn.
275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase")]
277#[non_exhaustive]
278pub struct EndorsementRevocation {
279    /// The revoked endorsement's id.
280    pub endorsement_id: String,
281    /// The credential the revocation applies to, and when.
282    pub revocation: RevocationDetail,
283    /// The credential's index on the community's revocation status list.
284    pub status_list_index: u32,
285}
286
287/// The credential a revocation applies to, and when it took effect.
288#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
289#[serde(rename_all = "camelCase")]
290#[non_exhaustive]
291pub struct RevocationDetail {
292    /// The revoked credential's `id`.
293    pub credential_id: String,
294    /// When the revocation took effect (RFC 3339).
295    pub revoked_at: String,
296}
297
298/// One vetting statement withdrawal notice, as `GET /vetting/revocations`
299/// reports it, with the admissions it touches.
300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
301#[serde(rename_all = "camelCase")]
302#[non_exhaustive]
303pub struct VettingRevocation {
304    /// The vetter who withdrew the statement.
305    pub issuer: String,
306    /// The statement's `id`.
307    pub statement_id: String,
308    /// The statement's `digestMultibase`.
309    pub statement_digest_multibase: String,
310    /// The vetter's reason, when given.
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub reason: Option<String>,
313    /// When the community recorded the notice.
314    pub recorded_at: DateTime<Utc>,
315    /// `needsReview` when a current member was admitted on the statement, else
316    /// `noAdmission`.
317    pub review_state: String,
318    /// Approved join requests that counted the statement.
319    #[serde(default)]
320    pub affected_join_requests: Vec<String>,
321    /// Of their applicants, those who are current members.
322    #[serde(default)]
323    pub affected_members: Vec<String>,
324}
325
326#[derive(Deserialize)]
327struct VettingRevocationList {
328    revocations: Vec<VettingRevocation>,
329}
330
331/// A client bound to one VTC's API base, holding a bearer token once
332/// authenticated.
333#[derive(Clone)]
334pub struct VtcClient {
335    http: reqwest::Client,
336    /// The VTC API base, including the mount (e.g. `https://vtc.example.com/v1`),
337    /// trailing slash trimmed.
338    base_url: String,
339    /// The VTC's own DID (the authentication audience / DIDComm recipient).
340    vtc_did: String,
341    /// Bearer access token, set after [`connect`](Self::connect).
342    token: Option<String>,
343    /// A messaging session to the VTC, when this client has one.
344    ///
345    /// Present only on a client built by [`connect_didcomm`](Self::connect_didcomm)
346    /// or [`connect_tsp`](Self::connect_tsp). When it is set, the **holder
347    /// verbs** — the ones the VTC routes by document `type` rather than by URL —
348    /// go over it instead of to `POST {base}/trust-tasks`. The admin verbs keep
349    /// using HTTPS regardless: they are gated on a bearer token and a
350    /// `Trust-Task` header, which is a URL-shaped surface.
351    ///
352    /// A `VtaClient` rather than a session of our own, and the name is the only
353    /// awkward part: that type is the SDK's *Trust-Task* client and the peer it
354    /// addresses is whatever DID it was connected to. Pointing it at a VTC gets
355    /// session setup, `thid` demultiplexing, retry under one idempotency key and
356    /// reply-proof verification for free — four things this crate would
357    /// otherwise own a second, drifting copy of.
358    #[cfg(feature = "didcomm")]
359    documents: Option<vta_sdk::client::VtaClient>,
360}
361
362/// Written by hand rather than derived, for two reasons.
363///
364/// The first is required: [`vta_sdk::client::VtaClient`] is not `Debug`, so a
365/// derive stops compiling the moment a session is held.
366///
367/// The second is the one worth keeping. The derive printed `token` — the bearer
368/// token, in full, into anything that formatted this struct: a `tracing` field,
369/// a test failure, an `unwrap` on an enclosing type. A credential that reaches a
370/// log is a credential that has left, and nothing about the derive said so. The
371/// presence of a token is worth reporting; its value never is.
372impl std::fmt::Debug for VtcClient {
373    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
374        let mut out = f.debug_struct("VtcClient");
375        out.field("base_url", &self.base_url)
376            .field("vtc_did", &self.vtc_did)
377            .field("authenticated", &self.token.is_some());
378        #[cfg(feature = "didcomm")]
379        out.field("session", &self.documents.is_some());
380        out.finish()
381    }
382}
383
384impl VtcClient {
385    /// Authenticate to the VTC as `client_did` (challenge-response, reusing the
386    /// VTA SDK's audience-agnostic flow) and return a ready client.
387    ///
388    /// `base_url` is the full API base including the mount (e.g.
389    /// `https://vtc.example.com/v1`); `vtc_did` is the community's DID.
390    pub async fn connect(
391        base_url: &str,
392        vtc_did: &str,
393        client_did: &str,
394        private_key_multibase: &str,
395    ) -> Result<Self, VtcError> {
396        // Finite request + connect timeouts (R1.2) — `reqwest::Client::new()`
397        // has neither, so a blackholed VTC would hang an operator forever.
398        let http = vta_sdk::http::rest_client();
399        let base_url = base_url.trim_end_matches('/').to_string();
400        let auth = vta_sdk::auth_light::challenge_response_light(
401            &http,
402            &base_url,
403            client_did,
404            private_key_multibase,
405            vtc_did,
406        )
407        .await?;
408        Ok(Self {
409            http,
410            base_url,
411            vtc_did: vtc_did.to_string(),
412            token: Some(auth.access_token),
413            #[cfg(feature = "didcomm")]
414            documents: None,
415        })
416    }
417
418    /// Construct a client from an already-obtained bearer token (e.g. a token
419    /// minted out of band, or for testing). `base_url` includes the mount.
420    pub fn with_token(base_url: &str, vtc_did: &str, token: impl Into<String>) -> Self {
421        Self {
422            http: vta_sdk::http::rest_client(),
423            base_url: base_url.trim_end_matches('/').to_string(),
424            vtc_did: vtc_did.to_string(),
425            token: Some(token.into()),
426            #[cfg(feature = "didcomm")]
427            documents: None,
428        }
429    }
430
431    /// Construct a client with **no** bearer token, for the applicant side of
432    /// the join ceremony.
433    ///
434    /// [`submit_join`](Self::submit_join) authenticates with the document's own
435    /// holder proof, so an applicant — who is by definition not yet a member and
436    /// has no token to get — needs exactly this. Every other method returns
437    /// [`VtcError::NotAuthenticated`], which is the honest answer rather than a
438    /// 401 from the server.
439    ///
440    /// `vtc_did` still matters: it is the audience the submitted document is
441    /// addressed to, and the VTC rejects a document addressed elsewhere.
442    pub fn anonymous(base_url: &str, vtc_did: &str) -> Self {
443        Self {
444            http: vta_sdk::http::rest_client(),
445            base_url: base_url.trim_end_matches('/').to_string(),
446            vtc_did: vtc_did.to_string(),
447            token: None,
448            #[cfg(feature = "didcomm")]
449            documents: None,
450        }
451    }
452
453    /// Reach the VTC over a mediated **DIDComm** session rather than a URL.
454    ///
455    /// `client_did` is the identity the holder verbs will be attributed to —
456    /// for a persona minted by a VTA, its `did:webvh`. It may be any DID method:
457    /// over a session the VTC takes the *authcrypt sender* as the proven holder
458    /// and never looks at a document proof, so nothing here has to be a
459    /// `did:key` (`vtc-service/src/trust_tasks/mod.rs::resolve_holder` short-
460    /// circuits on `sender_did`).
461    ///
462    /// `rest_url` is the HTTPS base, and stays optional but useful: the admin
463    /// verbs are token-and-header gated on a URL surface and cannot ride a
464    /// session, so a client built with `None` here answers them with
465    /// [`VtcError::NoRestTransport`]. Passing the base gives one client that can
466    /// do both.
467    #[cfg(feature = "didcomm")]
468    pub async fn connect_didcomm(
469        client_did: &str,
470        private_key_multibase: &str,
471        vtc_did: &str,
472        mediator_did: &str,
473        rest_url: Option<&str>,
474    ) -> Result<Self, VtcError> {
475        let documents = vta_sdk::client::VtaClient::connect_didcomm(
476            client_did,
477            private_key_multibase,
478            vtc_did,
479            mediator_did,
480            None,
481        )
482        .await
483        .map_err(|e| VtcError::Session(e.to_string()))?;
484        Ok(Self::over_session(documents, vtc_did, rest_url))
485    }
486
487    /// The same, over **TSP**, for a community that advertises `#tsp`.
488    ///
489    /// A separate constructor rather than a flag because the choice is the
490    /// community's, not the caller's: a VTC that does not advertise `#tsp`
491    /// cannot answer here, and the caller is expected to have discovered that
492    /// before choosing. The ceremony is identical either way — the same Trust
493    /// Task document, addressed to the same audience — so this changes the wire
494    /// and nothing else.
495    #[cfg(feature = "tsp")]
496    pub async fn connect_tsp(
497        client_did: &str,
498        private_key_multibase: &str,
499        vtc_did: &str,
500        mediator_did: &str,
501        rest_url: Option<&str>,
502    ) -> Result<Self, VtcError> {
503        let documents = vta_sdk::client::VtaClient::connect_tsp(
504            client_did,
505            private_key_multibase,
506            vtc_did,
507            mediator_did,
508            None,
509        )
510        .await
511        .map_err(|e| VtcError::Session(e.to_string()))?;
512        Ok(Self::over_session(documents, vtc_did, rest_url))
513    }
514
515    /// Wrap a connected session. One place to build the pairing, so a further
516    /// `connect_*` variant cannot forget the REST half.
517    #[cfg(feature = "didcomm")]
518    fn over_session(
519        documents: vta_sdk::client::VtaClient,
520        vtc_did: &str,
521        rest_url: Option<&str>,
522    ) -> Self {
523        Self {
524            http: vta_sdk::http::rest_client(),
525            base_url: rest_url
526                .unwrap_or_default()
527                .trim_end_matches('/')
528                .to_string(),
529            vtc_did: vtc_did.to_string(),
530            token: None,
531            documents: Some(documents),
532        }
533    }
534
535    /// The community's DID this client is bound to.
536    pub fn vtc_did(&self) -> &str {
537        &self.vtc_did
538    }
539
540    /// Start a request carrying the route's `Trust-Task` URL header and the
541    /// bearer token.
542    ///
543    /// Every authenticated call goes through here. The VTC rejects a request
544    /// with no `Trust-Task` header (400) before any handler sees it, so a
545    /// method that builds its request by hand is broken on arrival — which is
546    /// how every method in this client came to be.
547    fn tt(
548        &self,
549        method: reqwest::Method,
550        url: impl reqwest::IntoUrl,
551        task: &str,
552    ) -> Result<reqwest::RequestBuilder, VtcError> {
553        // Said here rather than at each call site, because every admin verb
554        // reaches the URL surface through this one helper. A client built for a
555        // session and given no REST base would otherwise request against an
556        // empty base and fail as a malformed URL — a fault that reads as a bug
557        // in this crate rather than as a missing argument at the constructor.
558        if self.base_url.is_empty() {
559            return Err(VtcError::NoRestTransport("this verb"));
560        }
561        let token = self.token()?;
562        Ok(self
563            .http
564            .request(method, url)
565            .header("Trust-Task", task)
566            .bearer_auth(token))
567    }
568
569    /// List every community member, optionally filtered by `role`, following the
570    /// cursor to completion. Requires an admin token. This is the fleet roster
571    /// when the community's members are managed VTAs.
572    pub async fn list_members(&self, role: Option<&str>) -> Result<Vec<MemberRecord>, VtcError> {
573        let mut out: Vec<MemberRecord> = Vec::new();
574        let mut cursor: Option<String> = None;
575
576        loop {
577            let mut params: Vec<(&str, &str)> = Vec::new();
578            if let Some(role) = role {
579                params.push(("role", role));
580            }
581            if let Some(cursor) = &cursor {
582                params.push(("cursor", cursor.as_str()));
583            }
584            let url =
585                reqwest::Url::parse_with_params(&format!("{}/members", self.base_url), &params)
586                    .map_err(|e| VtcError::Url(e.to_string()))?;
587
588            let resp = self
589                .tt(reqwest::Method::GET, url, task::MEMBERS_LIST)?
590                .send()
591                .await?;
592            if !resp.status().is_success() {
593                let status = resp.status().as_u16();
594                let body = resp.text().await.unwrap_or_default();
595                return Err(VtcError::Http { status, body });
596            }
597
598            let page: Page<MemberRecord> = resp.json().await?;
599            out.extend(page.items);
600            match page.next_cursor {
601                Some(next) => cursor = Some(next),
602                None => break,
603            }
604        }
605        Ok(out)
606    }
607
608    /// List join requests (the admin work queue), optionally filtered by
609    /// `status` (e.g. `"pending"`). Requires an admin token. For a fleet, these
610    /// are VTAs awaiting enrollment.
611    pub async fn list_join_requests(
612        &self,
613        status: Option<&str>,
614    ) -> Result<Vec<JoinRequestSummary>, VtcError> {
615        let mut out: Vec<JoinRequestSummary> = Vec::new();
616        let mut cursor: Option<String> = None;
617        loop {
618            let mut params: Vec<(&str, &str)> = Vec::new();
619            if let Some(status) = status {
620                params.push(("status", status));
621            }
622            if let Some(cursor) = &cursor {
623                params.push(("cursor", cursor.as_str()));
624            }
625            let url = reqwest::Url::parse_with_params(
626                &format!("{}/join-requests", self.base_url),
627                &params,
628            )
629            .map_err(|e| VtcError::Url(e.to_string()))?;
630
631            let resp = self
632                .tt(reqwest::Method::GET, url, task::JOIN_REQUESTS_LIST)?
633                .send()
634                .await?;
635            if !resp.status().is_success() {
636                let status = resp.status().as_u16();
637                let body = resp.text().await.unwrap_or_default();
638                return Err(VtcError::Http { status, body });
639            }
640            let page: Page<JoinRequestSummary> = resp.json().await?;
641            out.extend(page.items);
642            match page.next_cursor {
643                Some(next) => cursor = Some(next),
644                None => break,
645            }
646        }
647        Ok(out)
648    }
649
650    /// Approve a join request — admit the applicant and issue its membership
651    /// credential (VMC). Requires an admin token. For a fleet, this enrolls a
652    /// VTA that has applied to join.
653    pub async fn approve_join(&self, request_id: &str) -> Result<DecideResult, VtcError> {
654        self.decide(request_id, "approved", None).await
655    }
656
657    /// Reject a join request, optionally recording an operator rationale in the
658    /// audit trail. Requires an admin token.
659    pub async fn reject_join(
660        &self,
661        request_id: &str,
662        reason: Option<&str>,
663    ) -> Result<DecideResult, VtcError> {
664        self.decide(request_id, "rejected", reason).await
665    }
666
667    /// `POST /join-requests/{id}/decide` with `{ decision, reason? }`.
668    ///
669    /// The VTC previously exposed a `/approve` + `/reject` mount pair; both were
670    /// retired in favour of this single endpoint carrying the decision in the
671    /// body, and the old mounts are **gone** — this client was still posting to
672    /// them, so approve and reject were 404s independent of the missing header.
673    /// `decision` is the server's `Decision` enum on the wire (`approved` /
674    /// `rejected`), not the imperative verb the old paths used.
675    async fn decide(
676        &self,
677        request_id: &str,
678        decision: &str,
679        reason: Option<&str>,
680    ) -> Result<DecideResult, VtcError> {
681        let url = format!("{}/join-requests/{request_id}/decide", self.base_url);
682        let mut body = serde_json::json!({ "decision": decision });
683        if let Some(reason) = reason {
684            body["reason"] = serde_json::json!(reason);
685        }
686        let resp = self
687            .tt(reqwest::Method::POST, url, task::JOIN_REQUESTS_DECIDE)?
688            .json(&body)
689            .send()
690            .await?;
691        if !resp.status().is_success() {
692            let status = resp.status().as_u16();
693            let body = resp.text().await.unwrap_or_default();
694            return Err(VtcError::Http { status, body });
695        }
696        Ok(resp.json().await?)
697    }
698
699    /// Remove a member (offboarding). The VTC applies its removal disposition and
700    /// flips the member's status-list revocation bit. `reason` is an optional
701    /// admin note. Requires an admin token. For a fleet, this decommissions a
702    /// managed VTA.
703    pub async fn remove_member(
704        &self,
705        did: &str,
706        reason: Option<&str>,
707    ) -> Result<RemoveResult, VtcError> {
708        let url = format!("{}/members/{did}", self.base_url);
709        let mut req = self.tt(reqwest::Method::DELETE, url, task::MEMBERS_ADMIN_REMOVE)?;
710        if let Some(reason) = reason {
711            req = req.json(&serde_json::json!({ "reason": reason }));
712        }
713        let resp = req.send().await?;
714        if !resp.status().is_success() {
715            let status = resp.status().as_u16();
716            let body = resp.text().await.unwrap_or_default();
717            return Err(VtcError::Http { status, body });
718        }
719        Ok(resp.json().await?)
720    }
721
722    /// Update a member's community-defined `extensions` (opaque JSON) via
723    /// `PATCH /members/{did}`. A fleet manager records per-member operational
724    /// state here — e.g. the assigned `fleet_index` at enrollment, which the
725    /// roster then carries (see [`MemberRecord::extensions`]). Admin token.
726    pub async fn update_member_extensions(
727        &self,
728        did: &str,
729        extensions: serde_json::Value,
730    ) -> Result<(), VtcError> {
731        let resp = self
732            .tt(
733                reqwest::Method::PATCH,
734                format!("{}/members/{did}", self.base_url),
735                task::MEMBERS_UPDATE,
736            )?
737            .json(&serde_json::json!({ "extensions": extensions }))
738            .send()
739            .await?;
740        if !resp.status().is_success() {
741            let status = resp.status().as_u16();
742            let body = resp.text().await.unwrap_or_default();
743            return Err(VtcError::Http { status, body });
744        }
745        Ok(())
746    }
747
748    /// Submit a join request (the applicant side): sign a
749    /// `join-requests/submit/0.1` Trust Task with the applicant's holder key and
750    /// post it to the document endpoint. Returns the community's verdict —
751    /// auto-admit carries the issued VMC + role VEC inline, otherwise the
752    /// request is queued for an admin.
753    ///
754    /// **No bearer token.** The document's `eddsa-jcs-2022` proof *is* the
755    /// authentication: the VTC takes the proof's `verificationMethod` DID as the
756    /// applicant and requires the document `issuer` to match it
757    /// (`vtc-service/src/trust_tasks/mod.rs::resolve_holder`). So this is the
758    /// one method that works on a client built with neither
759    /// [`connect`](Self::connect) nor [`with_token`](Self::with_token) — an
760    /// applicant is by definition not yet a member.
761    ///
762    /// `applicant_did` is a `did:key` whose seed is `private_key_multibase`. It
763    /// is the DID that becomes the member on admission, *not* whatever identity
764    /// this client may hold a token for — a fleet manager submitting on behalf
765    /// of a VTA signs with that VTA's key.
766    ///
767    /// **A holder on any other DID method uses
768    /// [`submit_join_as`](Self::submit_join_as).** This method's `did:key`
769    /// restriction is a property of *this signature* — it derives the
770    /// verification method from the identifier — and not of the server, which
771    /// resolves the proof's `verificationMethod` through a DID resolver and has
772    /// accepted `did:webvh` since the vm-resolver work. A `did:webvh` persona is
773    /// the normal case for a holder minted by a VTA, so it must not have to
774    /// borrow a `did:key` to join.
775    ///
776    /// The document is addressed to [`vtc_did`](Self::vtc_did) (SPEC §4.8.2
777    /// audience binding), so a signed submit captured from one community cannot
778    /// be replayed into another.
779    ///
780    /// ## Why the key, and not just a body
781    ///
782    /// This used to POST the VP-framed body to `POST /join-requests`, a route
783    /// that no longer exists — the holder-facing join verbs (`submit`/`request`,
784    /// `manifest`, `status`) were folded into the single Trust-Task document
785    /// endpoint, routed by document `type`. That fold moved the applicant's
786    /// authentication from "a signature somewhere inside the body" to "a proof
787    /// over the whole document", which is why this signature grew the key.
788    pub async fn submit_join(
789        &self,
790        body: &join_requests::JoinRequestSubmitBody,
791        applicant_did: &str,
792        private_key_multibase: &str,
793    ) -> Result<join_requests::VerdictResponse, VtcError> {
794        let key = HolderKey::from_did_key(applicant_did, private_key_multibase)
795            .map_err(|e| VtcError::Signing(e.to_string()))?;
796        self.submit_join_as(body, &key).await
797    }
798
799    /// Submit a join request signed by a holder of **any** DID method.
800    ///
801    /// The general form of [`submit_join`](Self::submit_join), which is now a
802    /// `did:key` convenience wrapper over it. Everything that method's
803    /// documentation says about tokens, audience binding and which DID becomes
804    /// the member applies here unchanged; the only difference is that the
805    /// verification method is named rather than derived.
806    ///
807    /// A [`HolderKey`] names the verification method the proof will carry — for
808    /// a `did:webvh` persona, `did:webvh:<scid>:example.com:glenn#key-0`. The
809    /// server takes that method's DID as the applicant, so the key must be one
810    /// the holder's *published document* names: a proof this client signs
811    /// happily is still refused if the document does not carry the method.
812    pub async fn submit_join_as(
813        &self,
814        body: &join_requests::JoinRequestSubmitBody,
815        key: &HolderKey,
816    ) -> Result<join_requests::VerdictResponse, VtcError> {
817        let payload = serde_json::to_value(body)
818            .map_err(|e| VtcError::Url(format!("serialise submit payload: {e}")))?;
819
820        // Over a session the envelope proves the sender, so the VTC never reads
821        // a document proof and the holder key is not needed at all — see
822        // `resolve_holder`, which short-circuits on `sender_did`. The document
823        // still carries its audience binding, which is what stops a submit
824        // captured from one community being replayed into another.
825        #[cfg(feature = "didcomm")]
826        if let Some(documents) = &self.documents {
827            let value = documents
828                .dispatch_trust_task(
829                    join_requests::JOIN_REQUEST_SUBMIT_TYPE,
830                    payload,
831                    SESSION_TIMEOUT_SECS,
832                )
833                .await
834                .map_err(|e| VtcError::Session(e.to_string()))?;
835            return serde_json::from_value(value).map_err(|e| VtcError::Http {
836                status: 200,
837                body: format!("unexpected submit verdict: {e}"),
838            });
839        }
840
841        let doc = vta_sdk::trust_task_sign::build_signed_with(
842            join_requests::JOIN_REQUEST_SUBMIT_TYPE,
843            payload,
844            key,
845            &self.vtc_did,
846        )
847        .await
848        .map_err(|e| VtcError::Signing(e.to_string()))?;
849
850        // The document endpoint takes no `Trust-Task` header — the document's
851        // own `type` is the identity, which is exactly why one mount can serve
852        // every holder verb.
853        let resp = self
854            .http
855            .post(format!("{}/trust-tasks", self.base_url))
856            .header("content-type", "application/json")
857            .body(doc)
858            .send()
859            .await?;
860        if !resp.status().is_success() {
861            let status = resp.status().as_u16();
862            let body = resp.text().await.unwrap_or_default();
863            return Err(VtcError::Http { status, body });
864        }
865
866        // A Trust-Task request is answered with a `#response` document whose
867        // payload is the verdict.
868        let text = resp.text().await?;
869        let response_doc: trust_tasks_rs::TrustTask<serde_json::Value> =
870            serde_json::from_str(&text).map_err(|e| VtcError::Http {
871                status: 200,
872                body: format!(
873                    "unexpected submit response (not a Trust Task document): {e}: {text}"
874                ),
875            })?;
876        serde_json::from_value(response_doc.payload).map_err(|e| VtcError::Http {
877            status: 200,
878            body: format!("submit response payload is not a VerdictResponse: {e}"),
879        })
880    }
881
882    /// List the community's policies (opaque JSON descriptors). Admin token.
883    pub async fn list_policies(&self) -> Result<Vec<serde_json::Value>, VtcError> {
884        let mut out = Vec::new();
885        let mut cursor: Option<String> = None;
886        loop {
887            let mut params: Vec<(&str, &str)> = Vec::new();
888            if let Some(cursor) = &cursor {
889                params.push(("cursor", cursor.as_str()));
890            }
891            let url =
892                reqwest::Url::parse_with_params(&format!("{}/policies", self.base_url), &params)
893                    .map_err(|e| VtcError::Url(e.to_string()))?;
894            let resp = self
895                .tt(reqwest::Method::GET, url, task::POLICY_LIST)?
896                .send()
897                .await?;
898            if !resp.status().is_success() {
899                let status = resp.status().as_u16();
900                let body = resp.text().await.unwrap_or_default();
901                return Err(VtcError::Http { status, body });
902            }
903            let page: Page<serde_json::Value> = resp.json().await?;
904            out.extend(page.items);
905            match page.next_cursor {
906                Some(next) => cursor = Some(next),
907                None => break,
908            }
909        }
910        Ok(out)
911    }
912
913    /// Fetch one policy by id (opaque JSON, incl. the Rego source). Admin token.
914    pub async fn get_policy(&self, id: &str) -> Result<serde_json::Value, VtcError> {
915        self.get_json(&format!("policies/{id}"), task::POLICY_GET)
916            .await
917    }
918
919    /// Upload a new Rego policy bundle for `purpose` (`"join"`, `"removal"`,
920    /// …). Returns the upload descriptor (id, sha256, version). Admin token.
921    /// Upload alone does not activate it — call [`activate_policy`](Self::activate_policy).
922    pub async fn upload_policy(
923        &self,
924        purpose: &str,
925        rego_source: &str,
926    ) -> Result<serde_json::Value, VtcError> {
927        self.post_json(
928            "policies",
929            task::POLICY_UPSERT,
930            &serde_json::json!({ "purpose": purpose, "regoSource": rego_source }),
931        )
932        .await
933    }
934
935    /// Activate a previously-uploaded policy (make it live for decisions of its
936    /// purpose). Admin token.
937    pub async fn activate_policy(&self, id: &str) -> Result<serde_json::Value, VtcError> {
938        self.post_json(
939            &format!("policies/{id}/activate"),
940            task::POLICY_ACTIVATE,
941            &serde_json::json!({}),
942        )
943        .await
944    }
945
946    // -----------------------------------------------------------------------
947    // Peer identity vetting — the community-admin surface
948    // -----------------------------------------------------------------------
949
950    /// Every vetter grant, newest first (`GET /vetting/vetters`). Admin token.
951    ///
952    /// Each row carries the member, validity, revocation, whether it is live,
953    /// whether an admin or the automatic sweep issued it, and the vetter's
954    /// profile summary.
955    pub async fn list_vetter_grants(&self) -> Result<vetting::VetterGrantListResponse, VtcError> {
956        let url = self.api_url(&["vetting", "vetters"])?;
957        let resp = self.untasked(reqwest::Method::GET, url)?.send().await?;
958        Ok(expect_success(resp).await?.json().await?)
959    }
960
961    /// Name a current member a vetter (`vtc/vetting/vetters/grant/0.1`, over
962    /// `POST /vetting/vetters`). Admin token.
963    ///
964    /// `grant` is the task's payload: `validitySeconds` is one day to two
965    /// years, and absent takes the community's default of one year. A member
966    /// already holding a live grant gets that grant back with
967    /// [`VetterGrant::created`] `false`.
968    pub async fn grant_vetter(
969        &self,
970        grant: &vetting::vetters::grant::v0_1::Payload,
971    ) -> Result<VetterGrant, VtcError> {
972        let url = self.api_url(&["vetting", "vetters"])?;
973        let resp = self
974            .tt(reqwest::Method::POST, url, task::VETTING_VETTERS_GRANT)?
975            .json(grant)
976            .send()
977            .await?;
978        let resp = expect_success(resp).await?;
979        let created = resp.status() == reqwest::StatusCode::CREATED;
980        Ok(VetterGrant {
981            created,
982            grant: resp.json().await?,
983        })
984    }
985
986    /// Revoke an endorsement by id (`vtc/endorsements/revoke/0.1`, over
987    /// `DELETE /credentials/endorsements/{id}`) — how a vetter grant is
988    /// withdrawn. Admin token. Revoking a grant also deletes the vetter's
989    /// profile.
990    pub async fn revoke_endorsement(
991        &self,
992        endorsement_id: &str,
993    ) -> Result<EndorsementRevocation, VtcError> {
994        let url = self.api_url(&["credentials", "endorsements", endorsement_id])?;
995        let resp = self
996            .tt(reqwest::Method::DELETE, url, task::ENDORSEMENTS_REVOKE)?
997            .send()
998            .await?;
999        Ok(expect_success(resp).await?.json().await?)
1000    }
1001
1002    /// Deliver a vetter's live grant credential again
1003    /// (`vtc/vetting/vetters/resend/0.1`, over
1004    /// `POST /vetting/vetters/{memberDid}/resend`). Admin token.
1005    ///
1006    /// Success means the community handed the credential to its messaging
1007    /// transport — not that the member's wallet has it. A member with no live
1008    /// grant is a 404; a transport that would not take the delivery is a 503.
1009    pub async fn resend_vetter_grant(
1010        &self,
1011        member_did: &str,
1012    ) -> Result<vetting::vetters::resend::v0_1::Response, VtcError> {
1013        let url = self.api_url(&["vetting", "vetters", member_did, "resend"])?;
1014        let resp = self
1015            .tt(reqwest::Method::POST, url, task::VETTING_VETTERS_RESEND)?
1016            .send()
1017            .await?;
1018        Ok(expect_success(resp).await?.json().await?)
1019    }
1020
1021    /// The automatic vetter-grant configuration and the last sweep
1022    /// (`GET /vetting/auto-grant`). Admin token.
1023    pub async fn auto_grant(&self) -> Result<vetting::AutoGrantStatus, VtcError> {
1024        let url = self.api_url(&["vetting", "auto-grant"])?;
1025        let resp = self.untasked(reqwest::Method::GET, url)?.send().await?;
1026        Ok(expect_success(resp).await?.json().await?)
1027    }
1028
1029    /// Replace the automatic vetter-grant configuration
1030    /// (`PUT /vetting/auto-grant`). Admin token. An absent member takes its
1031    /// default, so read the current configuration first to change one value.
1032    pub async fn configure_auto_grant(
1033        &self,
1034        config: &vetting::AutoGrantConfig,
1035    ) -> Result<vetting::AutoGrantStatus, VtcError> {
1036        let url = self.api_url(&["vetting", "auto-grant"])?;
1037        let resp = self
1038            .untasked(reqwest::Method::PUT, url)?
1039            .json(config)
1040            .send()
1041            .await?;
1042        Ok(expect_success(resp).await?.json().await?)
1043    }
1044
1045    /// How the community presents itself to an applicant's client
1046    /// (`GET /community/branding`) — the join manifest 0.2 `branding`. Admin
1047    /// token.
1048    pub async fn branding(
1049        &self,
1050    ) -> Result<join_requests::manifest::v0_2::CommunityBranding, VtcError> {
1051        let url = self.api_url(&["community", "branding"])?;
1052        let resp = self.untasked(reqwest::Method::GET, url)?.send().await?;
1053        Ok(expect_success(resp).await?.json().await?)
1054    }
1055
1056    /// Replace the community's branding (`PUT /community/branding`) and return
1057    /// what was stored. Admin token. Every member is optional; an absent member
1058    /// is cleared.
1059    pub async fn set_branding(
1060        &self,
1061        branding: &join_requests::manifest::v0_2::CommunityBranding,
1062    ) -> Result<join_requests::manifest::v0_2::CommunityBranding, VtcError> {
1063        let url = self.api_url(&["community", "branding"])?;
1064        let resp = self
1065            .untasked(reqwest::Method::PUT, url)?
1066            .json(branding)
1067            .send()
1068            .await?;
1069        Ok(expect_success(resp).await?.json().await?)
1070    }
1071
1072    /// Every vetting statement withdrawal notice, newest first, with the
1073    /// admissions each touches (`GET /vetting/revocations`). Admin token.
1074    pub async fn vetting_revocations(&self) -> Result<Vec<VettingRevocation>, VtcError> {
1075        let url = self.api_url(&["vetting", "revocations"])?;
1076        let resp = self.untasked(reqwest::Method::GET, url)?.send().await?;
1077        let list: VettingRevocationList = expect_success(resp).await?.json().await?;
1078        Ok(list.revocations)
1079    }
1080
1081    /// `{base}/<segments…>`, each segment percent-encoded.
1082    ///
1083    /// A DID or an id interpolated into a path with `format!` is a path the
1084    /// caller controls: a `/` or `?` in it would address a different route.
1085    /// Pushing segments encodes them, so what is sent is what was meant.
1086    fn api_url(&self, segments: &[&str]) -> Result<reqwest::Url, VtcError> {
1087        if self.base_url.is_empty() {
1088            return Err(VtcError::NoRestTransport("this verb"));
1089        }
1090        let mut url =
1091            reqwest::Url::parse(&self.base_url).map_err(|e| VtcError::Url(e.to_string()))?;
1092        url.path_segments_mut()
1093            .map_err(|()| VtcError::Url(format!("{} cannot be a base URL", self.base_url)))?
1094            .pop_if_empty()
1095            .extend(segments);
1096        Ok(url)
1097    }
1098
1099    /// Start a bearer-authenticated request to an admin route that has **no**
1100    /// Trust Task of its own.
1101    ///
1102    /// The VTC mounts a few admin REST routes without a `Trust-Task` binding
1103    /// (the vetter listing, automatic grants, withdrawals, branding) rather
1104    /// than borrow a URI that describes something else. Those are the only
1105    /// callers of this; every route that does carry a task goes through
1106    /// [`tt`](Self::tt).
1107    fn untasked(
1108        &self,
1109        method: reqwest::Method,
1110        url: reqwest::Url,
1111    ) -> Result<reqwest::RequestBuilder, VtcError> {
1112        if self.base_url.is_empty() {
1113            return Err(VtcError::NoRestTransport("this verb"));
1114        }
1115        let token = self.token()?;
1116        Ok(self.http.request(method, url).bearer_auth(token))
1117    }
1118
1119    /// Authenticated GET returning JSON, carrying `task` as the Trust-Task URL.
1120    async fn get_json(&self, path: &str, task: &str) -> Result<serde_json::Value, VtcError> {
1121        let resp = self
1122            .tt(
1123                reqwest::Method::GET,
1124                format!("{}/{path}", self.base_url),
1125                task,
1126            )?
1127            .send()
1128            .await?;
1129        if !resp.status().is_success() {
1130            let status = resp.status().as_u16();
1131            let body = resp.text().await.unwrap_or_default();
1132            return Err(VtcError::Http { status, body });
1133        }
1134        Ok(resp.json().await?)
1135    }
1136
1137    /// Authenticated POST of a JSON body returning JSON, carrying `task` as the
1138    /// Trust-Task URL.
1139    async fn post_json(
1140        &self,
1141        path: &str,
1142        task: &str,
1143        body: &serde_json::Value,
1144    ) -> Result<serde_json::Value, VtcError> {
1145        let resp = self
1146            .tt(
1147                reqwest::Method::POST,
1148                format!("{}/{path}", self.base_url),
1149                task,
1150            )?
1151            .json(body)
1152            .send()
1153            .await?;
1154        if !resp.status().is_success() {
1155            let status = resp.status().as_u16();
1156            let body = resp.text().await.unwrap_or_default();
1157            return Err(VtcError::Http { status, body });
1158        }
1159        Ok(resp.json().await?)
1160    }
1161
1162    /// Bearer token or [`VtcError::NotAuthenticated`].
1163    fn token(&self) -> Result<&str, VtcError> {
1164        self.token.as_deref().ok_or(VtcError::NotAuthenticated)
1165    }
1166}
1167
1168/// The response when its status is a success, else [`VtcError::Http`] carrying
1169/// the status and the body — the body is where the VTC says what was wrong, so
1170/// a caller that turns this into operator guidance needs both.
1171async fn expect_success(resp: reqwest::Response) -> Result<reqwest::Response, VtcError> {
1172    if resp.status().is_success() {
1173        return Ok(resp);
1174    }
1175    let status = resp.status().as_u16();
1176    let body = resp.text().await.unwrap_or_default();
1177    Err(VtcError::Http { status, body })
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182    use super::*;
1183
1184    /// A DID or id placed in a path is one segment, whatever it contains.
1185    #[test]
1186    fn path_segments_are_encoded_not_interpolated() {
1187        let client = VtcClient::with_token("https://vtc.example.com/v1/", "did:web:vtc", "t");
1188        let url = client
1189            .api_url(&[
1190                "vetting",
1191                "vetters",
1192                "did:webvh:Qm:x.example/../admin?x",
1193                "resend",
1194            ])
1195            .unwrap();
1196        assert_eq!(
1197            url.as_str(),
1198            "https://vtc.example.com/v1/vetting/vetters/did:webvh:Qm:x.example%2F..%2Fadmin%3Fx/resend"
1199        );
1200    }
1201
1202    #[tokio::test]
1203    async fn vetting_admin_methods_without_token_are_not_authenticated() {
1204        let client = VtcClient::anonymous("https://vtc.example.com/v1", "did:web:vtc");
1205        assert!(matches!(
1206            client.list_vetter_grants().await,
1207            Err(VtcError::NotAuthenticated)
1208        ));
1209        assert!(matches!(
1210            client
1211                .grant_vetter(
1212                    &serde_json::from_value(serde_json::json!({ "memberDid": "did:key:z" }))
1213                        .unwrap()
1214                )
1215                .await,
1216            Err(VtcError::NotAuthenticated)
1217        ));
1218        assert!(matches!(
1219            client.revoke_endorsement("e1").await,
1220            Err(VtcError::NotAuthenticated)
1221        ));
1222        assert!(matches!(
1223            client.resend_vetter_grant("did:key:z").await,
1224            Err(VtcError::NotAuthenticated)
1225        ));
1226        assert!(matches!(
1227            client.auto_grant().await,
1228            Err(VtcError::NotAuthenticated)
1229        ));
1230        assert!(matches!(
1231            client.branding().await,
1232            Err(VtcError::NotAuthenticated)
1233        ));
1234        assert!(matches!(
1235            client.vetting_revocations().await,
1236            Err(VtcError::NotAuthenticated)
1237        ));
1238    }
1239
1240    #[test]
1241    fn a_withdrawal_row_deserializes_from_the_vtc_shape() {
1242        let rows: VettingRevocationList = serde_json::from_value(serde_json::json!({
1243            "revocations": [{
1244                "issuer": "did:key:zCarol",
1245                "statementId": "urn:uuid:s1",
1246                "statementDigestMultibase": "zDigest",
1247                "reason": "mistake",
1248                "recordedAt": "2026-09-01T00:00:00Z",
1249                "reviewState": "needsReview",
1250                "affectedJoinRequests": ["3f1c9a52-8c1e-4f2b-9d7a-0b6e5c4d3a21"],
1251                "affectedMembers": ["did:key:zAlice"]
1252            }]
1253        }))
1254        .unwrap();
1255        assert_eq!(rows.revocations[0].review_state, "needsReview");
1256        assert_eq!(rows.revocations[0].affected_members, vec!["did:key:zAlice"]);
1257    }
1258
1259    /// A holder on any DID method can name its verification method, which is
1260    /// the whole point of the general submit path.
1261    ///
1262    /// A `did:webvh` persona is what a VTA actually mints, so a client that
1263    /// could only sign as a `did:key` forced every such holder to borrow an
1264    /// identity it does not otherwise use — and the borrowed one is the DID
1265    /// that would have become the member.
1266    #[test]
1267    fn a_holder_key_names_any_did_method() {
1268        let webvh = HolderKey::new(
1269            "did:webvh:QmScid:example.com:glenn#key-0",
1270            "z3u2en7t5LR2WtQH5PfFqMqwVHBeXouLzo6haApm8XHqvjxq",
1271        )
1272        .expect("a did:webvh verification method is a verification method");
1273        assert_eq!(webvh.holder_did(), "did:webvh:QmScid:example.com:glenn");
1274
1275        // …and the `did:key` wrapper still derives its own, so the common case
1276        // keeps its shorter call.
1277        let key = HolderKey::from_did_key(
1278            "did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG",
1279            "z3u2en7t5LR2WtQH5PfFqMqwVHBeXouLzo6haApm8XHqvjxq",
1280        )
1281        .expect("a did:key derives its verification method");
1282        assert!(key.verification_method().starts_with("did:key:"));
1283    }
1284
1285    /// A verification method with no fragment names no key, and is refused at
1286    /// construction rather than producing a proof nothing can resolve.
1287    #[test]
1288    fn a_did_without_a_fragment_is_not_a_verification_method() {
1289        assert!(HolderKey::new("did:webvh:QmScid:example.com:glenn", "z3u2").is_err());
1290    }
1291
1292    /// The admin verbs say which argument is missing rather than failing as a
1293    /// malformed URL.
1294    ///
1295    /// A session-only client has no REST base, and every admin verb reaches the
1296    /// URL surface through `tt`. Without this the request is built against an
1297    /// empty base and the error reads as a bug in this crate rather than as a
1298    /// constructor that was not given `rest_url`.
1299    #[test]
1300    fn an_admin_verb_without_a_rest_base_says_so() {
1301        let client = VtcClient {
1302            http: vta_sdk::http::rest_client(),
1303            base_url: String::new(),
1304            vtc_did: "did:webvh:QmScid:example.com:acme".to_string(),
1305            token: Some("t".to_string()),
1306            #[cfg(feature = "didcomm")]
1307            documents: None,
1308        };
1309        let err = client
1310            .tt(reqwest::Method::GET, "http://x/members", task::MEMBERS_LIST)
1311            .expect_err("no REST base means no admin verb");
1312        assert!(
1313            matches!(err, VtcError::NoRestTransport(_)),
1314            "expected a missing-transport error, got {err:?}"
1315        );
1316    }
1317
1318    /// `Debug` never prints the bearer token.
1319    ///
1320    /// The derive did. A credential that reaches a log has left, and the only
1321    /// thing worth reporting is whether one is held.
1322    #[test]
1323    fn debug_does_not_leak_the_token() {
1324        let client = VtcClient::with_token(
1325            "https://vtc.example.com/v1",
1326            "did:webvh:QmScid:example.com:acme",
1327            "super-secret-bearer-token",
1328        );
1329        let rendered = format!("{client:?}");
1330        assert!(
1331            !rendered.contains("super-secret-bearer-token"),
1332            "the token is in Debug output: {rendered}"
1333        );
1334        assert!(rendered.contains("authenticated: true"), "{rendered}");
1335    }
1336
1337    #[test]
1338    fn member_page_deserializes_from_vtc_shape() {
1339        // A `Paginated<MemberResponse>` as the VTC serialises it (extra fields
1340        // present to prove they're ignored).
1341        let json = serde_json::json!({
1342            "items": [{
1343                "did": "did:key:z6MkStaffVta",
1344                "role": "member",
1345                "label": "Staff VTA",
1346                "joinedAt": "2026-06-23T00:00:00Z",
1347                "publishConsent": true,
1348                "departurePreference": "tombstone",
1349                "statusListIndex": 7,
1350                "currentVmcId": "urn:uuid:vmc-1",
1351                "extensions": {},
1352                "personhood": false,
1353                "joinedViaInvitation": true
1354            }],
1355            "nextCursor": null
1356        });
1357        let page: Page<MemberRecord> = serde_json::from_value(json).unwrap();
1358        assert_eq!(page.items.len(), 1);
1359        let m = &page.items[0];
1360        assert_eq!(m.did, "did:key:z6MkStaffVta");
1361        assert_eq!(m.role, "member");
1362        assert_eq!(m.status_list_index, Some(7));
1363        assert_eq!(m.current_vmc_id.as_deref(), Some("urn:uuid:vmc-1"));
1364        assert!(m.joined_via_invitation);
1365        assert!(page.next_cursor.is_none());
1366    }
1367
1368    #[tokio::test]
1369    async fn list_members_without_token_is_not_authenticated() {
1370        let client = VtcClient {
1371            http: reqwest::Client::new(),
1372            base_url: "https://vtc.example.com/v1".into(),
1373            vtc_did: "did:web:vtc.example.com".into(),
1374            token: None,
1375            #[cfg(feature = "didcomm")]
1376            documents: None,
1377        };
1378        // The token guard returns before any network I/O.
1379        let err = client.list_members(None).await;
1380        assert!(matches!(err, Err(VtcError::NotAuthenticated)), "{err:?}");
1381    }
1382
1383    #[test]
1384    fn decide_result_deserializes_camel_case() {
1385        let json = serde_json::json!({
1386            "requestId": "11111111-1111-1111-1111-111111111111",
1387            "status": "approved",
1388            "vmc": { "type": ["VerifiableCredential", "MembershipCredential"] },
1389            "roleVec": null
1390        });
1391        let d: DecideResult = serde_json::from_value(json).unwrap();
1392        assert_eq!(d.request_id, "11111111-1111-1111-1111-111111111111");
1393        assert_eq!(d.status, "approved");
1394        assert!(d.vmc.is_some());
1395        assert!(d.role_vec.is_none());
1396    }
1397
1398    #[test]
1399    fn join_request_and_remove_results_deserialize() {
1400        let jr: JoinRequestSummary = serde_json::from_value(serde_json::json!({
1401            "id": "22222222-2222-2222-2222-222222222222",
1402            "applicantDid": "did:key:z6MkApplicant",
1403            "status": "pending",
1404            "submittedAt": "2026-06-23T00:00:00Z"
1405        }))
1406        .unwrap();
1407        assert_eq!(jr.applicant_did, "did:key:z6MkApplicant");
1408        assert_eq!(jr.status, "pending");
1409
1410        let rm: RemoveResult = serde_json::from_value(serde_json::json!({
1411            "did": "did:key:z6MkGone",
1412            "disposition": "tombstone",
1413            "removed": true
1414        }))
1415        .unwrap();
1416        assert_eq!(rm.did, "did:key:z6MkGone");
1417        assert!(rm.removed);
1418    }
1419
1420    #[tokio::test]
1421    async fn admin_methods_without_token_are_not_authenticated() {
1422        let client = VtcClient {
1423            http: reqwest::Client::new(),
1424            base_url: "https://vtc.example.com/v1".into(),
1425            vtc_did: "did:web:vtc.example.com".into(),
1426            token: None,
1427            #[cfg(feature = "didcomm")]
1428            documents: None,
1429        };
1430        assert!(matches!(
1431            client.list_join_requests(Some("pending")).await,
1432            Err(VtcError::NotAuthenticated)
1433        ));
1434        assert!(matches!(
1435            client.approve_join("req-1").await,
1436            Err(VtcError::NotAuthenticated)
1437        ));
1438        assert!(matches!(
1439            client.remove_member("did:key:x", Some("reason")).await,
1440            Err(VtcError::NotAuthenticated)
1441        ));
1442    }
1443
1444    #[test]
1445    fn member_extensions_default_and_parse() {
1446        let none: MemberRecord = serde_json::from_value(serde_json::json!({
1447            "did": "did:key:z", "role": "member", "joinedAt": "2026-06-23T00:00:00Z"
1448        }))
1449        .unwrap();
1450        assert!(none.extensions.is_null());
1451        let with: MemberRecord = serde_json::from_value(serde_json::json!({
1452            "did": "did:key:z", "role": "member", "joinedAt": "2026-06-23T00:00:00Z",
1453            "extensions": { "fleet_index": 3 }
1454        }))
1455        .unwrap();
1456        assert_eq!(with.extensions["fleet_index"], 3);
1457    }
1458
1459    #[tokio::test]
1460    async fn policy_admin_methods_without_token_are_not_authenticated() {
1461        let client = VtcClient {
1462            http: reqwest::Client::new(),
1463            base_url: "https://vtc.example.com/v1".into(),
1464            vtc_did: "did:web:vtc.example.com".into(),
1465            token: None,
1466            #[cfg(feature = "didcomm")]
1467            documents: None,
1468        };
1469        assert!(matches!(
1470            client.list_policies().await,
1471            Err(VtcError::NotAuthenticated)
1472        ));
1473        assert!(matches!(
1474            client.get_policy("p1").await,
1475            Err(VtcError::NotAuthenticated)
1476        ));
1477        assert!(matches!(
1478            client.upload_policy("join", "package x").await,
1479            Err(VtcError::NotAuthenticated)
1480        ));
1481        assert!(matches!(
1482            client.activate_policy("p1").await,
1483            Err(VtcError::NotAuthenticated)
1484        ));
1485        assert!(matches!(
1486            client
1487                .update_member_extensions("did:key:z", serde_json::json!({}))
1488                .await,
1489            Err(VtcError::NotAuthenticated)
1490        ));
1491    }
1492}