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("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
85pub 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
145pub 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
167pub 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
186pub 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#[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
249pub 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 if action == InvitationResponseAction::Accept {
280 validate_inviter_authority(record.role, inviter_role)?;
281 }
282 let state = effective_invitation_state(record, now)?;
283 let target = match action {
284 InvitationResponseAction::Accept => InvitationState::Accepted,
285 InvitationResponseAction::Decline => InvitationState::Declined,
286 };
287 if state == target {
288 return Ok(InvitationResponsePlan {
289 state,
290 changed: false,
291 grant_role: false,
292 notification_kind: None,
293 });
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
317pub 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
351pub fn validate_inviter_authority(
355 offered_role: i32,
356 inviter_role: i32,
357) -> Result<(), InvitationError> {
358 if !(1..=3).contains(&offered_role)
359 || !(1..=3).contains(&inviter_role)
360 || inviter_role < offered_role
361 {
362 return Err(InvitationError::InviterAuthorityLost);
363 }
364 Ok(())
365}
366
367pub fn validate_invitation_record_projection(
370 record: &InvitationRecord,
371 original: &Recipient,
372) -> Result<(), InvitationError> {
373 if normalize_invitation_recipient(record)? != *original
374 || !(1..=3).contains(&record.role)
375 || !(1..=5).contains(&record.state)
376 {
377 return Err(InvitationError::InvalidRecord);
378 }
379 validate_invitation_resolution(&InvitationResolution {
380 inviter: record.inviter.clone(),
381 inviter_via_agent_label: record.inviter_via_agent_label.clone(),
382 ..Default::default()
383 })
384 .map_err(|_| InvitationError::InvalidRecord)
385}
386
387pub fn validate_spool_invitation_projection(
388 event: &crate::heddle::api::v1alpha2::SpoolEvent,
389 original: &Recipient,
390) -> Result<(), InvitationError> {
391 if let Some(crate::heddle::api::v1alpha2::spool_event::Payload::Invitation(record)) =
392 &event.payload
393 {
394 validate_invitation_record_projection(record, original)?;
395 }
396 Ok(())
397}
398
399pub fn validate_notification_invitation_projection(
400 record: &crate::heddle::api::v1alpha2::NotificationRecord,
401 original: &Recipient,
402) -> Result<(), InvitationError> {
403 if let Some(invitation) = &record.invitation {
404 validate_invitation_record_projection(invitation, original)?;
405 }
406 Ok(())
407}
408
409pub fn validate_attention_invitation_projection(
410 item: &crate::heddle::api::v1alpha2::AttentionItem,
411 original: &Recipient,
412) -> Result<(), InvitationError> {
413 if let Some(invitation) = &item.invitation {
414 validate_invitation_record_projection(invitation, original)?;
415 }
416 Ok(())
417}