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), ¶ms)
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 ¶ms,
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), ¶ms)
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}