vtc-client 0.41.0

Client SDK for Verifiable Trust Communities (VTC) — auth, members, join, removal, policies
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
//! Community boards: the `vtc/boards/*/0.1` tasks (VTI-BRD-001 – 093).
//!
//! Members propose ideas and leave notes; a facilitator accepts, declines and merges ideas
//! (accepting opens the idea's election from the board's template), selects among them once
//! their elections are tallied, and hides notes. Voting itself is not a board task: it is the
//! idea's election, through [`crate::elections`] and the member's own VTA.
//!
//! The methods here send as every `acl/*` call does ([`crate::acl`]), signed with a
//! [`HolderKey`] or over the client's session — what an operator's tool (`cnm`) holds. A member
//! whose key lives in their VTA has the VTA sign the document as them
//! (`vault/sign-trust-task`) and sends it with [`VtcClient::send_signed_document`]; build its
//! payload with [`checked`] so it is refused here, not by the community, when out of schema.
//!
//! Requests are validated against the published schema before they are sent, and replies are
//! decoded into the generated `Response` types.

use serde_json::{Value, json};
use trust_tasks_rs::validate::ValidatedPayload;

use crate::{HolderKey, VtcClient, VtcError};

/// The generated wire types, re-exported so a caller names the reply types without depending
/// on `trust-tasks-rs` itself.
pub use vti_specs::vtc::boards::{
    create::v0_1 as create,
    delete::v0_1 as delete,
    facilitators::{
        add::v0_1 as facilitators_add, list::v0_1 as facilitators_list,
        remove::v0_1 as facilitators_remove,
    },
    ideas::{
        decide::v0_1 as ideas_decide, propose::v0_1 as ideas_propose, select::v0_1 as ideas_select,
        update::v0_1 as ideas_update, withdraw::v0_1 as ideas_withdraw,
    },
    list::v0_1 as list,
    notes::{
        add::v0_1 as notes_add, hide::v0_1 as notes_hide, list::v0_1 as notes_list,
        withdraw::v0_1 as notes_withdraw,
    },
    show::v0_1 as show,
    update::v0_1 as update,
};

/// The Type URI of each task, read off the generated payloads.
pub mod task {
    use trust_tasks_rs::Payload;

    pub const CREATE: &str = <super::create::Payload as Payload>::TYPE_URI;
    pub const UPDATE: &str = <super::update::Payload as Payload>::TYPE_URI;
    pub const DELETE: &str = <super::delete::Payload as Payload>::TYPE_URI;
    pub const LIST: &str = <super::list::Payload as Payload>::TYPE_URI;
    pub const SHOW: &str = <super::show::Payload as Payload>::TYPE_URI;
    pub const IDEAS_PROPOSE: &str = <super::ideas_propose::Payload as Payload>::TYPE_URI;
    pub const IDEAS_UPDATE: &str = <super::ideas_update::Payload as Payload>::TYPE_URI;
    pub const IDEAS_WITHDRAW: &str = <super::ideas_withdraw::Payload as Payload>::TYPE_URI;
    pub const IDEAS_DECIDE: &str = <super::ideas_decide::Payload as Payload>::TYPE_URI;
    pub const IDEAS_SELECT: &str = <super::ideas_select::Payload as Payload>::TYPE_URI;
    pub const NOTES_ADD: &str = <super::notes_add::Payload as Payload>::TYPE_URI;
    pub const NOTES_LIST: &str = <super::notes_list::Payload as Payload>::TYPE_URI;
    pub const NOTES_WITHDRAW: &str = <super::notes_withdraw::Payload as Payload>::TYPE_URI;
    pub const NOTES_HIDE: &str = <super::notes_hide::Payload as Payload>::TYPE_URI;
    pub const FACILITATORS_ADD: &str = <super::facilitators_add::Payload as Payload>::TYPE_URI;
    pub const FACILITATORS_REMOVE: &str =
        <super::facilitators_remove::Payload as Payload>::TYPE_URI;
    pub const FACILITATORS_LIST: &str = <super::facilitators_list::Payload as Payload>::TYPE_URI;
}

/// `payload` for `type_uri`, once it validates against that task's published schema.
///
/// # Errors
/// [`VtcError::InvalidPayload`] for a payload out of schema, or a Type URI that is not one of
/// the board tasks.
pub fn checked(type_uri: &str, payload: Value) -> Result<Value, VtcError> {
    fn v<P: ValidatedPayload>(p: &Value) -> Result<(), VtcError> {
        P::validate_value(p).map_err(|e| VtcError::InvalidPayload(e.to_string()))
    }
    match type_uri {
        task::CREATE => v::<create::Payload>(&payload)?,
        task::UPDATE => v::<update::Payload>(&payload)?,
        task::LIST => v::<list::Payload>(&payload)?,
        task::SHOW => v::<show::Payload>(&payload)?,
        task::IDEAS_PROPOSE => v::<ideas_propose::Payload>(&payload)?,
        task::IDEAS_UPDATE => v::<ideas_update::Payload>(&payload)?,
        task::IDEAS_WITHDRAW => v::<ideas_withdraw::Payload>(&payload)?,
        task::IDEAS_DECIDE => v::<ideas_decide::Payload>(&payload)?,
        task::IDEAS_SELECT => v::<ideas_select::Payload>(&payload)?,
        task::NOTES_ADD => v::<notes_add::Payload>(&payload)?,
        task::NOTES_LIST => v::<notes_list::Payload>(&payload)?,
        task::NOTES_WITHDRAW => v::<notes_withdraw::Payload>(&payload)?,
        task::NOTES_HIDE => v::<notes_hide::Payload>(&payload)?,
        task::DELETE => v::<delete::Payload>(&payload)?,
        task::FACILITATORS_ADD => v::<facilitators_add::Payload>(&payload)?,
        task::FACILITATORS_REMOVE => v::<facilitators_remove::Payload>(&payload)?,
        task::FACILITATORS_LIST => v::<facilitators_list::Payload>(&payload)?,
        other => {
            return Err(VtcError::InvalidPayload(format!(
                "{other} is not a vtc/boards task"
            )));
        }
    }
    Ok(payload)
}

/// The error codes `type_uri` declares, for decoding a refusal.
#[must_use]
pub fn declared(type_uri: &str) -> &'static [trust_tasks_rs::DeclaredErrorCode] {
    match type_uri {
        task::CREATE => create::ERROR_CODES,
        task::UPDATE => update::ERROR_CODES,
        task::DELETE => delete::ERROR_CODES,
        task::LIST => list::ERROR_CODES,
        task::FACILITATORS_ADD => facilitators_add::ERROR_CODES,
        task::FACILITATORS_REMOVE => facilitators_remove::ERROR_CODES,
        task::FACILITATORS_LIST => facilitators_list::ERROR_CODES,
        task::SHOW => show::ERROR_CODES,
        task::IDEAS_PROPOSE => ideas_propose::ERROR_CODES,
        task::IDEAS_UPDATE => ideas_update::ERROR_CODES,
        task::IDEAS_WITHDRAW => ideas_withdraw::ERROR_CODES,
        task::IDEAS_DECIDE => ideas_decide::ERROR_CODES,
        task::IDEAS_SELECT => ideas_select::ERROR_CODES,
        task::NOTES_ADD => notes_add::ERROR_CODES,
        task::NOTES_LIST => notes_list::ERROR_CODES,
        task::NOTES_WITHDRAW => notes_withdraw::ERROR_CODES,
        task::NOTES_HIDE => notes_hide::ERROR_CODES,
        _ => &[],
    }
}

/// `{ cursor?, limit? }` merged into `body`.
fn paged(mut body: Value, cursor: Option<&str>, limit: Option<u32>) -> Value {
    if let Some(c) = cursor {
        body["cursor"] = json!(c);
    }
    if let Some(l) = limit {
        body["limit"] = json!(l);
    }
    body
}

impl VtcClient {
    /// One board task, checked against its schema, signed with `key` (or over the session) and
    /// decoded into `R`.
    async fn board_task<R: serde::de::DeserializeOwned>(
        &self,
        type_uri: &str,
        payload: Value,
        key: &HolderKey,
    ) -> Result<R, VtcError> {
        let payload = checked(type_uri, payload)?;
        self.acl_task(type_uri, payload, key, declared(type_uri))
            .await
    }

    /// `vtc/boards/create/0.1` — a board from `body` (title, audience, windows, template, …).
    /// Takes `vtc.boards.manage` and `vtc.elections.manage`, both unqualified (VTI-BRD-002).
    ///
    /// # Errors
    /// [`VtcError::InvalidPayload`] out of schema; the VTC's refusal otherwise
    /// (`templateInvalid`, `ballotBoxNotTrusted`, `scheduleInvalid`, …).
    pub async fn boards_create(
        &self,
        body: Value,
        key: &HolderKey,
    ) -> Result<create::Response, VtcError> {
        self.board_task(task::CREATE, body, key).await
    }

    /// `vtc/boards/update/0.1` — change a board, or close or archive it (`status`).
    ///
    /// # Errors
    /// As [`Self::boards_create`].
    pub async fn boards_update(
        &self,
        body: Value,
        key: &HolderKey,
    ) -> Result<update::Response, VtcError> {
        self.board_task(task::UPDATE, body, key).await
    }

    /// `vtc/boards/delete/0.1` — delete a closed board whose voting has ended: its ideas, notes
    /// and each election's content go, each election keeping a tombstone for the community's
    /// retention (VTI-BRD-007, VTI-BRD-008). Takes `vtc.boards.manage` unqualified and a step-up
    /// bound to this one request; without the gesture the refusal carries `stepUpRequest`.
    ///
    /// # Errors
    /// [`VtcError::InvalidPayload`] out of schema; the VTC's refusal otherwise (`notClosed`,
    /// `electionsRunning`, `notFound`, `permissionDenied`).
    pub async fn boards_delete(
        &self,
        board_id: &str,
        reason: &str,
        key: &HolderKey,
    ) -> Result<delete::Response, VtcError> {
        self.board_task(
            task::DELETE,
            json!({ "boardId": board_id, "reason": reason }),
            key,
        )
        .await
    }

    /// `vtc/boards/facilitators/add/0.1` — appoint the active member `entry_id` a facilitator
    /// of `board_id` (VTI-BRD-090): `vtc.boards.manage` qualified to that board, written on their
    /// entry and delegated by the signer, who must facilitate the board themselves.
    ///
    /// # Errors
    /// [`VtcError::InvalidPayload`] out of schema; the VTC's refusal otherwise (`notFound`,
    /// `entryNotFound`, `alreadyFacilitator`, `permissionDenied`).
    pub async fn boards_facilitators_add(
        &self,
        board_id: &str,
        entry_id: &str,
        reason: Option<&str>,
        key: &HolderKey,
    ) -> Result<facilitators_add::Response, VtcError> {
        let mut body = json!({ "boardId": board_id, "entryId": entry_id });
        if let Some(r) = reason {
            body["reason"] = json!(r);
        }
        self.board_task(task::FACILITATORS_ADD, body, key).await
    }

    /// `vtc/boards/facilitators/remove/0.1` — end `entry_id`'s appointment to facilitate
    /// `board_id`: one's own, or anyone's with `vtc.boards.manage` unqualified (VTI-BRD-091).
    ///
    /// # Errors
    /// [`VtcError::InvalidPayload`] out of schema; the VTC's refusal otherwise (`notFound`,
    /// `notFacilitator`, `permissionDenied`).
    pub async fn boards_facilitators_remove(
        &self,
        board_id: &str,
        entry_id: &str,
        reason: Option<&str>,
        key: &HolderKey,
    ) -> Result<facilitators_remove::Response, VtcError> {
        let mut body = json!({ "boardId": board_id, "entryId": entry_id });
        if let Some(r) = reason {
            body["reason"] = json!(r);
        }
        self.board_task(task::FACILITATORS_REMOVE, body, key).await
    }

    /// `vtc/boards/facilitators/list/0.1` — who is appointed to facilitate `board_id`, by entry,
    /// with when, by whom, and any review under way (VTI-BRD-093).
    ///
    /// # Errors
    /// The VTC's refusal (`notFound`, `permissionDenied`).
    pub async fn boards_facilitators_list(
        &self,
        board_id: &str,
        key: &HolderKey,
    ) -> Result<facilitators_list::Response, VtcError> {
        self.board_task(task::FACILITATORS_LIST, json!({ "boardId": board_id }), key)
            .await
    }

    /// `vtc/boards/list/0.1` — the boards the caller may see.
    ///
    /// # Errors
    /// The VTC's refusal.
    pub async fn boards_list(
        &self,
        status: Option<&str>,
        cursor: Option<&str>,
        limit: Option<u32>,
        key: &HolderKey,
    ) -> Result<list::Response, VtcError> {
        let mut body = json!({});
        if let Some(s) = status {
            body["status"] = json!(s);
        }
        self.board_task(task::LIST, paged(body, cursor, limit), key)
            .await
    }

    /// `vtc/boards/show/0.1` — one board and a page of its ideas, optionally only those in
    /// `states`.
    ///
    /// # Errors
    /// `notFound` for a board the caller may not see; the VTC's refusal otherwise.
    pub async fn boards_show(
        &self,
        board_id: &str,
        states: &[&str],
        cursor: Option<&str>,
        limit: Option<u32>,
        key: &HolderKey,
    ) -> Result<show::Response, VtcError> {
        let mut body = json!({ "boardId": board_id });
        if !states.is_empty() {
            body["state"] = json!(states);
        }
        self.board_task(task::SHOW, paged(body, cursor, limit), key)
            .await
    }

    /// `vtc/boards/ideas/decide/0.1` — `accept` (opens the idea's election), `decline` (with a
    /// reason), `merge` (into `merge_into`) or `reopen`.
    ///
    /// # Errors
    /// The VTC's refusal (`stateInvalid`, `ownIdea`, `electionRefused`, …).
    pub async fn boards_decide(
        &self,
        idea_id: &str,
        decision: &str,
        reason: Option<&str>,
        merge_into: Option<&str>,
        key: &HolderKey,
    ) -> Result<ideas_decide::Response, VtcError> {
        let mut body = json!({ "ideaId": idea_id, "decision": decision });
        if let Some(r) = reason {
            body["reason"] = json!(r);
        }
        if let Some(m) = merge_into {
            body["mergeInto"] = json!(m);
        }
        self.board_task(task::IDEAS_DECIDE, body, key).await
    }

    /// `vtc/boards/ideas/select/0.1` — `selected` (optionally into `slot_id`) or `notSelected`,
    /// once the idea's election is tallied.
    ///
    /// # Errors
    /// The VTC's refusal (`notTallied`, `slotTaken`, `reasonRequired`, …).
    pub async fn boards_select(
        &self,
        idea_id: &str,
        decision: &str,
        slot_id: Option<&str>,
        departs_from_rule: bool,
        reason: Option<&str>,
        key: &HolderKey,
    ) -> Result<ideas_select::Response, VtcError> {
        let mut body = json!({ "ideaId": idea_id, "decision": decision });
        if let Some(s) = slot_id {
            body["slotId"] = json!(s);
        }
        if departs_from_rule {
            body["departsFromRule"] = json!(true);
        }
        if let Some(r) = reason {
            body["reason"] = json!(r);
        }
        self.board_task(task::IDEAS_SELECT, body, key).await
    }

    /// `vtc/boards/notes/list/0.1` — the notes on a board (`scope` `board` or `all`), or on one
    /// idea.
    ///
    /// # Errors
    /// The VTC's refusal.
    pub async fn boards_notes(
        &self,
        board_id: &str,
        idea_id: Option<&str>,
        scope: Option<&str>,
        cursor: Option<&str>,
        limit: Option<u32>,
        key: &HolderKey,
    ) -> Result<notes_list::Response, VtcError> {
        let mut body = json!({ "boardId": board_id });
        if let Some(i) = idea_id {
            body["ideaId"] = json!(i);
        }
        if let Some(s) = scope {
            body["scope"] = json!(s);
        }
        self.board_task(task::NOTES_LIST, paged(body, cursor, limit), key)
            .await
    }

    /// `vtc/boards/notes/hide/0.1` — hide a note with a reason, or show it again.
    ///
    /// # Errors
    /// The VTC's refusal (`stateInvalid`, `reasonRequired`, …).
    pub async fn boards_hide_note(
        &self,
        note_id: &str,
        hide: bool,
        reason: Option<&str>,
        key: &HolderKey,
    ) -> Result<notes_hide::Response, VtcError> {
        let mut body = json!({ "noteId": note_id, "action": if hide { "hide" } else { "unhide" } });
        if let Some(r) = reason {
            body["reason"] = json!(r);
        }
        self.board_task(task::NOTES_HIDE, body, key).await
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const BOARD: &str = "brd_Uc7N2qLm4Rt8Vx1Z00";
    const IDEA: &str = "ida_Pk3Wq9Ls2Dn6Hy4B00";

    #[test]
    fn requests_out_of_schema_are_refused_before_sending() {
        assert!(checked(task::SHOW, json!({ "boardId": BOARD })).is_ok());
        assert!(checked(task::SHOW, json!({ "boardId": "board-1" })).is_err());
        assert!(
            checked(
                task::IDEAS_DECIDE,
                json!({ "ideaId": IDEA, "decision": "accept" })
            )
            .is_ok()
        );
        assert!(
            checked(
                task::IDEAS_DECIDE,
                json!({ "ideaId": IDEA, "decision": "approve" })
            )
            .is_err()
        );
        assert!(checked(task::NOTES_ADD, json!({ "boardId": BOARD, "text": "" })).is_err());
        assert!(checked(task::LIST, json!({ "limit": 500 })).is_err());
    }

    #[test]
    fn a_type_uri_outside_the_family_is_refused() {
        assert!(checked("https://trusttasks.org/spec/vtc/groups/list/0.2", json!({})).is_err());
        assert!(declared("https://trusttasks.org/spec/vtc/groups/list/0.2").is_empty());
    }

    #[test]
    fn every_task_names_its_declared_codes() {
        for t in [
            task::CREATE,
            task::UPDATE,
            task::SHOW,
            task::IDEAS_PROPOSE,
            task::IDEAS_DECIDE,
            task::IDEAS_SELECT,
            task::NOTES_ADD,
            task::NOTES_HIDE,
        ] {
            assert!(!declared(t).is_empty(), "{t}");
        }
    }
}