Skip to main content

heddle_api/v2/
invitation_code.rs

1//! Creator-only invitation code response validation. Hosts supply authenticated
2//! subjects and current transactional state; projections never establish access.
3
4use crate::heddle::api::v1alpha2::{
5    GetInvitationCodeResponse, GetSignupInvitationCodeResponse, InvitationRecord, InvitationState,
6    SignupInvitation, invitation_record,
7};
8use prost_types::Timestamp;
9
10#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
11pub enum InvitationCodeError {
12    #[error("Creator: invitation code read requires the original creator")]
13    Creator,
14    #[error("Authority: invitation code read requires current inviter admin authority")]
15    Authority,
16    #[error("Expiry: invitation code read requires valid current time and finite expiry")]
17    Expiry,
18    #[error("NotPending: terminal invitation code response must be empty")]
19    NotPending,
20}
21
22/// Trusted host context, never caller-supplied request fields. Clients may use
23/// independently known state as a disclosure check, never as authorization.
24pub struct InvitationCodeReadContext<'a> {
25    pub caller_subject: &'a str,
26    pub creator_subject: &'a str,
27    pub now: &'a Timestamp,
28}
29
30pub fn validate_signup_invitation_code_response(
31    response: &GetSignupInvitationCodeResponse,
32    invitation: &SignupInvitation,
33    context: &InvitationCodeReadContext<'_>,
34) -> Result<(), InvitationCodeError> {
35    validate_code_response(
36        &response.redemption_secret,
37        invitation.redeemed,
38        invitation.revoked,
39        invitation.expires_at.as_ref(),
40        context,
41    )
42}
43
44pub fn validate_invitation_code_response(
45    response: &GetInvitationCodeResponse,
46    invitation: &InvitationRecord,
47    context: &InvitationCodeReadContext<'_>,
48    inviter_role: i32,
49) -> Result<(), InvitationCodeError> {
50    validate_creator(context)?;
51    if inviter_role != 3 {
52        return Err(InvitationCodeError::Authority);
53    }
54    let non_link = !matches!(
55        invitation.recipient.as_ref(),
56        Some(invitation_record::Recipient::Email(email)) if !email.is_empty()
57    );
58    validate_code_response(
59        &response.redemption_secret,
60        invitation.state != InvitationState::Pending as i32,
61        non_link,
62        invitation.expires_at.as_ref(),
63        context,
64    )
65}
66
67fn valid_timestamp(time: &Timestamp) -> bool {
68    (-62_135_596_800..=253_402_300_799).contains(&time.seconds)
69        && (0..1_000_000_000).contains(&time.nanos)
70}
71
72fn validate_code_response(
73    secret: &[u8],
74    redeemed: bool,
75    revoked: bool,
76    expires_at: Option<&Timestamp>,
77    context: &InvitationCodeReadContext<'_>,
78) -> Result<(), InvitationCodeError> {
79    validate_creator(context)?;
80    let expiry = expires_at.ok_or(InvitationCodeError::Expiry)?;
81    if !valid_timestamp(expiry) || !valid_timestamp(context.now) {
82        return Err(InvitationCodeError::Expiry);
83    }
84    let expired = (context.now.seconds, context.now.nanos) >= (expiry.seconds, expiry.nanos);
85    if !secret.is_empty() && (redeemed || revoked || expired) {
86        return Err(InvitationCodeError::NotPending);
87    }
88    Ok(())
89}
90
91fn validate_creator(context: &InvitationCodeReadContext<'_>) -> Result<(), InvitationCodeError> {
92    if context.caller_subject.is_empty()
93        || context.creator_subject.is_empty()
94        || context.caller_subject != context.creator_subject
95    {
96        return Err(InvitationCodeError::Creator);
97    }
98    Ok(())
99}