1use 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
90pub 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
150pub 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
172pub 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
200pub 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#[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
263pub 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
331pub 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
365pub 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
379pub 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
431pub 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}