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