Skip to main content

heddle_api/v2/
invitation.rs

1//! Portable invitation validation and host transaction planning.
2
3use crate::heddle::api::common::{CallFailure, CallFailureCode, ErrorDetail, ErrorReason};
4use crate::heddle::api::v1alpha2::{
5    CreateInvitationRequest, CreateInvitationResponse, InvitationRecord, InvitationState,
6    invitation_record::Recipient,
7};
8use crate::heddle::api::v1alpha2::{InvitationResolution, invitation_resolution::Status};
9use prost_types::Timestamp;
10
11pub const SPOOL_INVITATION: &str = "spool_invitation";
12pub const SPOOL_INVITATION_DECLINED: &str = "spool_invitation_declined";
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
15pub enum InvitationError {
16    #[error("recipient required")]
17    RecipientRequired,
18    #[error("email invitation expiry required")]
19    ExpiryRequired,
20    #[error("invalid invitation expiry")]
21    InvalidExpiry,
22    #[error("invalid email")]
23    InvalidEmail,
24    #[error("invalid handle")]
25    InvalidHandle,
26    #[error("invalid human account UUID")]
27    InvalidAccountId,
28    #[error("no such user")]
29    HandleNotFound,
30    #[error("sign in required")]
31    Unauthenticated,
32    #[error("invitation unavailable")]
33    Unavailable,
34    #[error("invitation is in a terminal or invalid state")]
35    Lifecycle,
36    #[error("invalid invitation input or projection")]
37    InvalidRecord,
38    #[error("human session required")]
39    HumanSessionRequired,
40    #[error("inviter authority lost")]
41    InviterAuthorityLost,
42}
43
44impl InvitationError {
45    pub const fn code(self) -> CallFailureCode {
46        match self {
47            Self::HandleNotFound | Self::Unavailable => CallFailureCode::NotFound,
48            Self::Unauthenticated => CallFailureCode::Unauthenticated,
49            Self::Lifecycle | Self::InviterAuthorityLost => CallFailureCode::FailedPrecondition,
50            Self::HumanSessionRequired => CallFailureCode::PermissionDenied,
51            _ => CallFailureCode::InvalidArgument,
52        }
53    }
54    pub const fn reason(self) -> ErrorReason {
55        match self {
56            Self::HumanSessionRequired => ErrorReason::InvitationHumanSessionRequired,
57            Self::InviterAuthorityLost => ErrorReason::InvitationInviterAuthorityLost,
58            Self::HandleNotFound => ErrorReason::InvitationHandleNotFound,
59            Self::Unavailable => ErrorReason::ResourceNotFound,
60            Self::Unauthenticated => ErrorReason::CredentialMissing,
61            Self::Lifecycle => ErrorReason::LifecycleState,
62            Self::RecipientRequired | Self::ExpiryRequired => ErrorReason::FieldRequired,
63            _ => ErrorReason::FieldInvalid,
64        }
65    }
66    pub const fn field(self) -> &'static str {
67        match self {
68            Self::RecipientRequired => "invitation.recipient",
69            Self::ExpiryRequired | Self::InvalidExpiry => "invitation.expires_at",
70            Self::InvalidEmail => "invitation.email",
71            Self::InvalidHandle | Self::HandleNotFound => "invitation.handle",
72            Self::InvalidAccountId => "invitation.account_id",
73            Self::Unauthenticated => "",
74            _ => "invitation",
75        }
76    }
77    pub fn failure(self) -> CallFailure {
78        CallFailure {
79            code: self.code() as i32,
80            message: self.to_string(),
81            error: Some(ErrorDetail {
82                reason: self.reason() as i32,
83                field: self.field().into(),
84                ..Default::default()
85            }),
86        }
87    }
88}
89
90/// Input shape only; host grammar/provider binding validation remains required.
91pub fn normalize_invitation_recipient(
92    record: &InvitationRecord,
93) -> Result<Recipient, InvitationError> {
94    match record
95        .recipient
96        .as_ref()
97        .ok_or(InvitationError::RecipientRequired)?
98    {
99        Recipient::Email(value) => {
100            if value.len() > 320 {
101                return Err(InvitationError::InvalidEmail);
102            }
103            let normalized = value.trim().to_ascii_lowercase();
104            let parts: Vec<_> = normalized.split('@').collect();
105            if parts.len() != 2
106                || parts.iter().any(|p| p.is_empty())
107                || normalized
108                    .chars()
109                    .any(|c| c.is_whitespace() || c.is_control())
110            {
111                return Err(InvitationError::InvalidEmail);
112            }
113            Ok(Recipient::Email(normalized))
114        }
115        Recipient::Handle(value) => {
116            if value.len() > 256 {
117                return Err(InvitationError::InvalidHandle);
118            }
119            let normalized = value.trim().to_ascii_lowercase();
120            if normalized.is_empty()
121                || normalized
122                    .chars()
123                    .any(|c| c.is_whitespace() || c.is_control())
124                || normalized.contains(['/', '@', '#'])
125            {
126                return Err(InvitationError::InvalidHandle);
127            }
128            Ok(Recipient::Handle(normalized))
129        }
130        Recipient::AccountId(value) => {
131            if !is_account_uuid(value) {
132                return Err(InvitationError::InvalidAccountId);
133            }
134            Ok(Recipient::AccountId(value.to_ascii_lowercase()))
135        }
136    }
137}
138
139fn is_account_uuid(value: &str) -> bool {
140    value.len() == 36
141        && value.bytes().enumerate().all(|(i, b)| {
142            if matches!(i, 8 | 13 | 18 | 23) {
143                b == b'-'
144            } else {
145                b.is_ascii_hexdigit()
146            }
147        })
148}
149
150/// SERVER ONLY: call after spool administration authorization. The resolver MUST
151/// reuse ResolveHandles' publicly claimed eligibility, lookup and rate budget.
152/// The returned account binding is private storage; never serialize it into a
153/// handle invitation or response. Explicit account IDs still need host validation
154/// that they identify a human account, never an independent agent principal.
155pub fn resolve_invitation_recipient(
156    record: &InvitationRecord,
157    resolve_public_handle: impl FnOnce(&str) -> Option<String>,
158) -> Result<Option<String>, InvitationError> {
159    match normalize_invitation_recipient(record)? {
160        Recipient::Email(_) => Ok(None),
161        Recipient::AccountId(id) => Ok(Some(id)),
162        Recipient::Handle(handle) => {
163            let id = resolve_public_handle(&handle).ok_or(InvitationError::HandleNotFound)?;
164            if !is_account_uuid(&id) {
165                return Err(InvitationError::HandleNotFound);
166            }
167            Ok(Some(id.to_ascii_lowercase()))
168        }
169    }
170}
171
172/// Create rejects every server projection field. The host additionally checks
173/// reference validity, authorization, future expiry, explicit human UUIDs and quotas.
174pub fn validate_create_invitation(record: &InvitationRecord) -> Result<(), InvitationError> {
175    let recipient = normalize_invitation_recipient(record)?;
176    if matches!(recipient, Recipient::Email(_)) && record.expires_at.is_none() {
177        return Err(InvitationError::ExpiryRequired);
178    }
179    if record.expires_at.as_ref().is_some_and(|expiry| {
180        !(-62_135_596_800..=253_402_300_799).contains(&expiry.seconds)
181            || !(0..1_000_000_000).contains(&expiry.nanos)
182    }) {
183        return Err(InvitationError::InvalidExpiry);
184    }
185    if !record.version.is_empty()
186        || !(1..=3).contains(&record.role)
187        || record.state != InvitationState::Unspecified as i32
188        || record.created_at.is_some()
189        || record.updated_at.is_some()
190        || record.inviter.is_some()
191        || !record.inviter_via_agent_label.is_empty()
192        || !record.spool_name.is_empty()
193        || record.spool_address.is_some()
194    {
195        return Err(InvitationError::InvalidRecord);
196    }
197    Ok(())
198}
199
200/// Prevent exposing a private handle binding by substituting the account_id
201/// arm, or exposing a link secret on an account/handle invitation.
202pub fn validate_create_invitation_response(
203    request: &CreateInvitationRequest,
204    response: &CreateInvitationResponse,
205) -> Result<(), InvitationError> {
206    let input = request
207        .invitation
208        .as_ref()
209        .ok_or(InvitationError::InvalidRecord)?;
210    let output = response
211        .invitation
212        .as_ref()
213        .ok_or(InvitationError::InvalidRecord)?;
214    let recipient = normalize_invitation_recipient(input)?;
215    if normalize_invitation_recipient(output)? != recipient
216        || output.state != InvitationState::Pending as i32
217        || output.r#ref != input.r#ref
218        || output.role != input.role
219        || output.expires_at != input.expires_at
220        || matches!(recipient, Recipient::Email(_)) == response.redemption_secret.is_empty()
221    {
222        return Err(InvitationError::InvalidRecord);
223    }
224    validate_invitation_record_projection(output, &recipient)
225}
226
227#[derive(Clone, Copy, Debug, Eq, PartialEq)]
228pub enum InvitationResponseAction {
229    Accept,
230    Decline,
231}
232
233/// A transaction plan, not a mutation or proof of authority. Hosts commit the
234/// state/version, grant, attention update, receipt and notification/outbox once
235/// atomically, serializing all Accept/Decline/Redeem/Revoke/expiry races.
236#[derive(Clone, Copy, Debug, Eq, PartialEq)]
237pub struct InvitationResponsePlan {
238    pub state: InvitationState,
239    pub changed: bool,
240    pub grant_role: bool,
241    pub notification_kind: Option<&'static str>,
242}
243
244pub fn effective_invitation_state(
245    record: &InvitationRecord,
246    now: &Timestamp,
247) -> Result<InvitationState, InvitationError> {
248    let state = InvitationState::try_from(record.state).map_err(|_| InvitationError::Lifecycle)?;
249    if state == InvitationState::Unspecified {
250        return Err(InvitationError::Lifecycle);
251    }
252    if state == InvitationState::Pending
253        && record
254            .expires_at
255            .as_ref()
256            .is_some_and(|expiry| (expiry.seconds, expiry.nanos) <= (now.seconds, now.nanos))
257    {
258        return Ok(InvitationState::Expired);
259    }
260    Ok(state)
261}
262
263/// Account values MUST come from verified session/credential and private stored
264/// binding, never request fields, public handles or caller-selected principals.
265/// Accept/Decline require a verified human session; delegation is insufficient.
266/// This gate MUST run again before replaying a receipt, even for terminal states.
267pub fn plan_invitation_response(
268    record: &InvitationRecord,
269    stored_recipient_account: Option<&str>,
270    authenticated_account: Option<&str>,
271    action: InvitationResponseAction,
272    now: &Timestamp,
273    human_session: bool,
274    inviter_role: i32,
275) -> Result<InvitationResponsePlan, InvitationError> {
276    let caller = authenticated_account
277        .filter(|id| !id.is_empty())
278        .ok_or(InvitationError::Unauthenticated)?;
279    if !human_session {
280        return Err(InvitationError::HumanSessionRequired);
281    }
282    let recipient = stored_recipient_account.ok_or(InvitationError::Unavailable)?;
283    if !is_account_uuid(caller)
284        || !is_account_uuid(recipient)
285        || !caller.eq_ignore_ascii_case(recipient)
286        || !matches!(
287            record.recipient,
288            Some(Recipient::Handle(_) | Recipient::AccountId(_))
289        )
290    {
291        return Err(InvitationError::Unavailable);
292    }
293    let state = effective_invitation_state(record, now)?;
294    let target = match action {
295        InvitationResponseAction::Accept => InvitationState::Accepted,
296        InvitationResponseAction::Decline => InvitationState::Declined,
297    };
298    if state == target {
299        return Ok(InvitationResponsePlan {
300            state,
301            changed: false,
302            grant_role: false,
303            notification_kind: None,
304        });
305    }
306    if action == InvitationResponseAction::Accept {
307        validate_inviter_authority(record.role, inviter_role)?;
308    }
309    if state != InvitationState::Pending {
310        return Err(InvitationError::Lifecycle);
311    }
312    Ok(InvitationResponsePlan {
313        state: target,
314        changed: true,
315        grant_role: action == InvitationResponseAction::Accept,
316        notification_kind: (action == InvitationResponseAction::Decline)
317            .then_some(SPOOL_INVITATION_DECLINED),
318    })
319}
320
321#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
322pub enum InvitationResolutionError {
323    #[error("UNAVAILABLE invitation resolution contains disclosed details")]
324    UnavailableDetails,
325    #[error("inviter requires an already-public handle")]
326    InviterHandle,
327    #[error("agent label requires an inviter handle")]
328    AgentWithoutInviter,
329}
330
331/// Validate the wire-visible invitation preview before a client displays it.
332/// The server remains responsible for checking the secret and resolving the
333/// public handle, account lifecycle and agent label at read time.
334pub fn validate_invitation_resolution(
335    response: &InvitationResolution,
336) -> Result<(), InvitationResolutionError> {
337    if response.status == Status::Unavailable as i32
338        && (response.spool.is_some()
339            || !response.spool_name.is_empty()
340            || response.role != 0
341            || response.expires_at.is_some()
342            || response.inviter.is_some()
343            || !response.inviter_via_agent_label.is_empty())
344    {
345        return Err(InvitationResolutionError::UnavailableDetails);
346    }
347    if response
348        .inviter
349        .as_ref()
350        .is_some_and(|owner| owner.handle.is_empty() || is_account_uuid(&owner.handle))
351    {
352        return Err(InvitationResolutionError::InviterHandle);
353    }
354    if !response.inviter_via_agent_label.is_empty()
355        && !response
356            .inviter
357            .as_ref()
358            .is_some_and(|owner| !owner.handle.is_empty())
359    {
360        return Err(InvitationResolutionError::AgentWithoutInviter);
361    }
362    Ok(())
363}
364
365/// Trusted CURRENT effective inviter role/credential ceilings, loaded under the
366/// transition lock. Every offered role requires ADMINISTRATOR on Create,
367/// pending Accept, email Redeem and GetInvitationCode. Accepted retries are no-ops.
368/// Also use on every authority change to auto-revoke affected pending invites.
369pub fn validate_inviter_authority(
370    offered_role: i32,
371    inviter_role: i32,
372) -> Result<(), InvitationError> {
373    if !(1..=3).contains(&offered_role) || inviter_role != 3 {
374        return Err(InvitationError::InviterAuthorityLost);
375    }
376    Ok(())
377}
378
379/// Validate every wire projection against the originally stored recipient arm,
380/// never the resolved private binding. Hosts must call before emit/replay.
381pub fn validate_invitation_record_projection(
382    record: &InvitationRecord,
383    original: &Recipient,
384) -> Result<(), InvitationError> {
385    if normalize_invitation_recipient(record)? != *original
386        || !(1..=3).contains(&record.role)
387        || !(1..=5).contains(&record.state)
388    {
389        return Err(InvitationError::InvalidRecord);
390    }
391    validate_invitation_resolution(&InvitationResolution {
392        inviter: record.inviter.clone(),
393        inviter_via_agent_label: record.inviter_via_agent_label.clone(),
394        ..Default::default()
395    })
396    .map_err(|_| InvitationError::InvalidRecord)
397}
398
399pub fn validate_spool_invitation_projection(
400    event: &crate::heddle::api::v1alpha2::SpoolEvent,
401    original: &Recipient,
402) -> Result<(), InvitationError> {
403    if let Some(crate::heddle::api::v1alpha2::spool_event::Payload::Invitation(record)) =
404        &event.payload
405    {
406        validate_invitation_record_projection(record, original)?;
407    }
408    Ok(())
409}
410
411pub fn validate_notification_invitation_projection(
412    record: &crate::heddle::api::v1alpha2::NotificationRecord,
413    original: &Recipient,
414) -> Result<(), InvitationError> {
415    if let Some(invitation) = &record.invitation {
416        validate_invitation_record_projection(invitation, original)?;
417    }
418    Ok(())
419}
420
421pub fn validate_attention_invitation_projection(
422    item: &crate::heddle::api::v1alpha2::AttentionItem,
423    original: &Recipient,
424) -> Result<(), InvitationError> {
425    if let Some(invitation) = &item.invitation {
426        validate_invitation_record_projection(invitation, original)?;
427    }
428    Ok(())
429}
430
431/// SERVER ONLY: host loads ALL invitations by the immutable inviter subject on
432/// this spool. On loss of admin, atomically persist these replacements with new
433/// versions, dismiss attention, destroy codes and emit stream updates. Serialize
434/// with acceptance; includes every pending offered role, even expired records.
435pub fn plan_inviter_authority_loss(
436    invitations: &[InvitationRecord],
437    inviter_role: i32,
438    now: &Timestamp,
439) -> Vec<InvitationRecord> {
440    if inviter_role == 3 {
441        return Vec::new();
442    }
443    invitations
444        .iter()
445        .filter(|record| record.state == InvitationState::Pending as i32)
446        .map(|record| InvitationRecord {
447            state: InvitationState::Revoked as i32,
448            updated_at: Some(*now),
449            ..record.clone()
450        })
451        .collect()
452}