openvtc-core 0.5.0

OpenVTC Core Library
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
//! Questions put to a community whose answers arrive later: its manifest, the
//! vetter directory, a vetter's own profile, and a resend of a vetter's grant.
//!
//! Each question is a signed Trust Task sent over DIDComm. The answer is a
//! `#response` — or a `trust-task-error` — threaded on it, and it reaches the
//! inbound handler whenever the community gets round to it. Matching the two
//! takes the request's document id, which is what a [`CommunityQuery`] keeps.
//!
//! Questions live in memory only (`#[serde(skip)]` on the book). After a
//! restart nobody is waiting for an answer, and an answer to a question we no
//! longer remember is dropped. That is the right outcome for a directory page
//! nobody is looking at, and it also means a `trust-task-error` is claimed only
//! when it answers something we asked. Anything else still reaches the join
//! handler, which owns the other errors a community sends.

use chrono::{DateTime, Duration, Utc};
use vta_sdk::protocols::vetting::{
    VETTING_REQUEST_ERR_CAPACITY, VETTING_REQUEST_ERR_DECLINED, VETTING_REQUEST_ERR_INVALID_TICKET,
    VETTING_REQUEST_ERR_METHOD_UNAVAILABLE, VETTING_REQUEST_ERR_NOT_ELIGIBLE,
    VETTING_VETTER_PROFILE_ERR_NOT_ELIGIBLE, VETTING_VETTER_RESEND_ERR_NOT_GRANTED, vetters,
};

use super::book::VettingBook;
use crate::config::account::PersonaId;

/// How long a question waits before the person asking is told there was no
/// answer (R1.2: a quiet community is an error, not a hang).
pub const QUERY_TIMEOUT: Duration = Duration::seconds(30);

/// What was asked.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum QueryKind {
    /// `vtc/join-requests/manifest/0.2`.
    Manifest,
    /// `vtc/vetting/vetters/list/0.1`.
    VetterList,
    /// `vtc/vetting/vetters/profile/0.1`.
    VetterProfile,
    /// `vtc/vetting/vetters/resend/0.1`.
    VetterResend,
    /// `vtc/vetting/vetters/pcs-root/0.1` — hidden-vetting enrolment.
    PcsRoot,
    /// `vtc/vetting/vetters/pcs-tokens/0.1` — one tick of the drip.
    PcsTokens,
    /// `vtc/vetting/vetters/event-mode/0.1` — a place at a named event.
    PcsEventMode,
    /// `vtc/vetting/pcs-challenge/0.1` — the nonce a submission binds.
    PcsChallenge,
}

impl QueryKind {
    /// Whether a refusal of this question with `code` is something to tell anyone about.
    ///
    /// Two drip refusals are not: `tickNotYet` says to wait for the window, and the schedule
    /// does, and `alreadyServed` says the tick is done. Saying either in the status line would
    /// read as a fault on every pass that raced a window.
    #[must_use]
    pub fn refusal_is_news(self, code: &str) -> bool {
        !(self == QueryKind::PcsTokens
            && matches!(
                code,
                super::hidden::TOKENS_TICK_NOT_YET | super::hidden::TOKENS_ALREADY_SERVED
            ))
    }

    /// What was asked for, as the end of "no answer about …".
    #[must_use]
    pub fn describe(self) -> &'static str {
        match self {
            QueryKind::Manifest => "its vetting requirements",
            QueryKind::VetterList => "its vetter directory",
            QueryKind::VetterProfile => "your vetter profile",
            QueryKind::VetterResend => "your vetter credential",
            QueryKind::PcsRoot => "your hidden-vetting credential",
            QueryKind::PcsTokens => "your attestation tokens",
            QueryKind::PcsEventMode => "your place at the event",
            QueryKind::PcsChallenge => "the challenge for your submission",
        }
    }
}

/// A question put to a community.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CommunityQuery {
    /// The request document's id — what the answer threads on.
    pub document_id: String,
    /// The community asked.
    pub community: String,
    /// The persona that asked.
    pub persona: PersonaId,
    /// What was asked.
    pub kind: QueryKind,
    /// When.
    pub sent_at: DateTime<Utc>,
}

/// An answer to a [`CommunityQuery`], for whoever asked. Nothing here is
/// persisted; a published profile's record is updated by the handler itself.
///
/// No `PartialEq`: a directory page is the generated
/// `vtc/vetting/vetters/list/0.1` response, and the generated wire types derive
/// only `Serialize`, `Deserialize`, `Clone` and `Debug`. Comparing two answers
/// means comparing what they carry.
#[derive(Clone, Debug)]
pub enum CommunityAnswer {
    /// The community's manifest arrived and its requirements are now known.
    Manifest {
        /// The community.
        community: String,
    },
    /// A page of the vetter directory.
    Vetters {
        /// The list request's document id.
        query: String,
        /// The community.
        community: String,
        /// The page.
        page: vetters::list::v0_1::Response,
    },
    /// The community stored our vetter profile.
    ProfileStored {
        /// The community.
        community: String,
        /// Whether it lists us.
        listed: bool,
        /// When it stored the profile.
        updated_at: DateTime<Utc>,
    },
    /// The community is delivering our vetter credential again.
    Resent {
        /// The community.
        community: String,
        /// The credential's `validUntil`.
        valid_until: DateTime<Utc>,
    },
    /// The community issued the challenge a hidden-vetting submission binds, and it is now on
    /// the application. Carried as an answer, not only as a notice, so a join waiting on it can
    /// tell its own question's answer from any other.
    Challenge {
        /// The challenge request's document id.
        query: String,
        /// The community.
        community: String,
    },
    /// The community refused the question.
    Refused {
        /// The request's document id.
        query: String,
        /// The community.
        community: String,
        /// What was asked.
        kind: QueryKind,
        /// The error code.
        code: String,
        /// The community's note, if any.
        message: Option<String>,
    },
    /// The community answered in a shape this client cannot read — a contract
    /// mismatch, not a refusal (R6.4).
    Unreadable {
        /// The request's document id.
        query: String,
        /// The community.
        community: String,
        /// What was asked.
        kind: QueryKind,
        /// The parser's detail.
        detail: String,
    },
}

impl CommunityAnswer {
    /// The community that answered.
    #[must_use]
    pub fn community(&self) -> &str {
        match self {
            CommunityAnswer::Manifest { community }
            | CommunityAnswer::Vetters { community, .. }
            | CommunityAnswer::ProfileStored { community, .. }
            | CommunityAnswer::Resent { community, .. }
            | CommunityAnswer::Challenge { community, .. }
            | CommunityAnswer::Refused { community, .. }
            | CommunityAnswer::Unreadable { community, .. } => community,
        }
    }
}

/// What a `vetting/request` refusal means, and what to do about it.
///
/// Both ends read this. The applicant sees why their request was turned down;
/// the vetter sees why one they never saw was turned down for them. The wire
/// code on its own (`vetting/request:invalidTicket`) says neither — it appeared
/// verbatim under a vetter's name on the applicant's page, where it reads as a
/// fault in the software rather than as a ticket that needs replacing (rule
/// R6.4).
#[must_use]
pub fn request_refusal_words(code: &str) -> &'static str {
    match code {
        VETTING_REQUEST_ERR_INVALID_TICKET => {
            "their ticket did not match — it may have been used up, expired, or been issued for \
             a different community or a different persona of theirs. Ask them for a fresh one."
        }
        VETTING_REQUEST_ERR_CAPACITY => {
            "they have as many open requests as they take. Try again later, or ask another \
             vetter."
        }
        VETTING_REQUEST_ERR_NOT_ELIGIBLE => {
            "the community does not count them as a vetter — their grant may have lapsed or \
             been revoked. Ask another vetter."
        }
        VETTING_REQUEST_ERR_METHOD_UNAVAILABLE => {
            "their ticket does not offer the way of meeting you asked for."
        }
        VETTING_REQUEST_ERR_DECLINED => "they declined to take it.",
        _ => "they refused it, and gave no reason this client understands.",
    }
}

/// A refusal in words the person who asked can act on. `community` is the
/// community's display name.
///
/// The codes the registry defines get their own sentence. A framework code
/// says which kind of failure it was, because "denied", "could not read" and
/// "something broke there" call for different next steps (R6.4).
#[must_use]
pub fn refusal_words(kind: QueryKind, community: &str, code: &str) -> String {
    if matches!(
        kind,
        QueryKind::PcsRoot
            | QueryKind::PcsTokens
            | QueryKind::PcsEventMode
            | QueryKind::PcsChallenge
    ) && !code.ends_with("permissionDenied")
        && !code.ends_with("malformedRequest")
    {
        return format!(
            "{community} refused {}: {}",
            kind.describe(),
            super::hidden::refusal_words(code)
        );
    }
    match code {
        VETTING_VETTER_PROFILE_ERR_NOT_ELIGIBLE => format!(
            "{community} did not accept your profile: it does not count you as an active member \
             holding a live vetter credential. If it named you a vetter, ask it to resend the \
             credential."
        ),
        VETTING_VETTER_RESEND_ERR_NOT_GRANTED => format!(
            "{community} holds no live vetter credential for you — it has not named you a \
             vetter, or the grant expired or was revoked. Ask its admins for the vetter role."
        ),
        c if c.ends_with("permissionDenied") => format!(
            "{community} would not answer this persona about {} ({code}).",
            kind.describe()
        ),
        c if c.ends_with("malformedRequest") => format!(
            "{community} could not read the request for {} ({code}) — this client and the \
             community disagree about the task, so try again after updating.",
            kind.describe()
        ),
        _ => format!(
            "{community} refused the request for {} ({code}).",
            kind.describe()
        ),
    }
}

impl VettingBook {
    /// Remember a question sent to a community.
    pub fn ask(&mut self, query: CommunityQuery) {
        self.queries.push(query);
    }

    /// Whether any question to `community` is unanswered.
    #[must_use]
    pub fn asked(&self, community: &str) -> bool {
        self.queries.iter().any(|q| q.community == community)
    }

    /// The unanswered question of `kind` to `community`, if there is one.
    #[must_use]
    pub fn waiting_on(&self, community: &str, kind: QueryKind) -> Option<&CommunityQuery> {
        self.queries
            .iter()
            .find(|q| q.community == community && q.kind == kind)
    }

    /// Take the question `community`'s reply threaded on `thread` answers.
    /// `kind`, when given, must match too: a directory page threaded on a
    /// profile request answers nothing.
    pub fn take_query(
        &mut self,
        community: &str,
        thread: &str,
        kind: Option<QueryKind>,
    ) -> Option<CommunityQuery> {
        let i = self.queries.iter().position(|q| {
            q.community == community && q.document_id == thread && kind.is_none_or(|k| q.kind == k)
        })?;
        Some(self.queries.remove(i))
    }

    /// Take a manifest question to `community`: the one `thread` names, or else
    /// any. A manifest is the same whoever asked, so a reply to one question
    /// answers every question about it.
    pub fn take_manifest_queries(
        &mut self,
        community: &str,
        thread: Option<&str>,
    ) -> Vec<CommunityQuery> {
        let threaded =
            thread.and_then(|t| self.take_query(community, t, Some(QueryKind::Manifest)));
        let mut taken: Vec<CommunityQuery> = threaded.into_iter().collect();
        let (answered, rest): (Vec<_>, Vec<_>) = std::mem::take(&mut self.queries)
            .into_iter()
            .partition(|q| q.community == community && q.kind == QueryKind::Manifest);
        self.queries = rest;
        taken.extend(answered);
        taken
    }

    /// Forget a question whose send failed, so nothing waits for it.
    pub fn forget_query(&mut self, document_id: &str) -> Option<CommunityQuery> {
        let i = self
            .queries
            .iter()
            .position(|q| q.document_id == document_id)?;
        Some(self.queries.remove(i))
    }

    /// Remove and return every question unanswered for `after`.
    pub fn expire_queries(&mut self, now: DateTime<Utc>, after: Duration) -> Vec<CommunityQuery> {
        let (expired, rest): (Vec<_>, Vec<_>) = std::mem::take(&mut self.queries)
            .into_iter()
            .partition(|q| now - q.sent_at >= after);
        self.queries = rest;
        expired
    }
}

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

    fn query(id: &str, community: &str, kind: QueryKind, sent_at: DateTime<Utc>) -> CommunityQuery {
        CommunityQuery {
            document_id: id.into(),
            community: community.into(),
            persona: PersonaId::new(),
            kind,
            sent_at,
        }
    }

    #[test]
    fn a_reply_takes_only_the_question_it_threads_on() {
        let now = Utc::now();
        let mut book = VettingBook::default();
        book.ask(query("q1", "did:web:a", QueryKind::VetterList, now));
        book.ask(query("q2", "did:web:a", QueryKind::VetterProfile, now));

        assert!(
            book.take_query("did:web:b", "q1", None).is_none(),
            "another community cannot answer our question"
        );
        assert!(
            book.take_query("did:web:a", "q2", Some(QueryKind::VetterList))
                .is_none(),
            "a directory page does not answer a profile request"
        );
        assert_eq!(
            book.take_query("did:web:a", "q1", Some(QueryKind::VetterList))
                .map(|q| q.document_id),
            Some("q1".to_string())
        );
        assert!(book.take_query("did:web:a", "q1", None).is_none(), "once");
        assert!(
            book.waiting_on("did:web:a", QueryKind::VetterProfile)
                .is_some()
        );
    }

    #[test]
    fn a_manifest_answers_every_question_about_it() {
        let now = Utc::now();
        let mut book = VettingBook::default();
        book.ask(query("m1", "did:web:a", QueryKind::Manifest, now));
        book.ask(query("m2", "did:web:a", QueryKind::Manifest, now));
        book.ask(query("m3", "did:web:b", QueryKind::Manifest, now));
        let taken = book.take_manifest_queries("did:web:a", Some("unrelated-thread"));
        assert_eq!(taken.len(), 2);
        assert_eq!(book.queries.len(), 1);
    }

    #[test]
    fn unanswered_questions_expire_and_are_not_persisted() {
        let now = Utc::now();
        let mut book = VettingBook::default();
        book.ask(query(
            "old",
            "did:web:a",
            QueryKind::VetterResend,
            now - QUERY_TIMEOUT,
        ));
        book.ask(query("new", "did:web:a", QueryKind::VetterList, now));
        let expired = book.expire_queries(now, QUERY_TIMEOUT);
        assert_eq!(expired.len(), 1);
        assert_eq!(expired[0].document_id, "old");
        assert!(book.is_empty(), "questions are memory only");
        assert_eq!(serde_json::to_value(&book).unwrap(), serde_json::json!({}));
    }

    #[test]
    fn refusals_say_what_to_do_next() {
        let not_granted = refusal_words(
            QueryKind::VetterResend,
            "Kernel",
            VETTING_VETTER_RESEND_ERR_NOT_GRANTED,
        );
        assert!(not_granted.contains("has not named you a vetter"));
        let not_eligible = refusal_words(
            QueryKind::VetterProfile,
            "Kernel",
            VETTING_VETTER_PROFILE_ERR_NOT_ELIGIBLE,
        );
        assert!(not_eligible.contains("resend"));
        assert!(
            refusal_words(QueryKind::VetterList, "Kernel", "permissionDenied")
                .contains("would not answer this persona")
        );
        assert!(
            refusal_words(QueryKind::VetterList, "Kernel", "malformedRequest").contains("disagree")
        );
    }
}