1use std::{cell::RefCell, fmt, rc::Rc, time::Duration as StdDuration};
2
3use chrono::{DateTime, Datelike, SecondsFormat, Utc};
4use lenso::prelude::*;
5use lenso_capability_email_dispatch as email;
6use lenso_capability_email_dispatch::{
7 DispatchRequest, DispatchResponseOutcome, EmailDispatchInvocationError,
8};
9use lenso_capability_notification_admin as admin;
10use lenso_capability_notification_delivery as delivery;
11use lenso_capability_notification_template as notification_template;
12use lenso_capability_notification_transactional as transactional;
13use lenso_capability_secrets::{ResolveRequest, SecretsInvocationError};
14use lenso_postgres_kit::OwnedPostgres;
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17use zeroize::Zeroizing;
18
19use crate::contracts::{
20 DispatchOutcome, EMAIL_DISPATCH_OBSERVED_EVENT, EMAIL_RECEIPT_OBSERVED_EVENT,
21 EmailDispatchObserved, EmailReceiptObserved, ORGANIZATION_INVITATION_ACCEPTED_EVENT,
22 ORGANIZATION_INVITATION_EXPIRED_EVENT, ORGANIZATION_INVITATION_REVOKED_EVENT,
23 OrganizationInvitationLifecycle, ReceiptKind, RemoteReceiptSummary, SanitizedFailure,
24};
25use crate::domain::MAX_SAFE_WIRE_INTEGER;
26use crate::error::{ErrorCode, NotificationError};
27use crate::events::{NotificationEventApplier, ObservationEnvelope};
28use crate::migrations::schema_plan;
29use crate::operator::verify_managed_catalog;
30use crate::public::{
31 AccessRequestNotificationEvent, AccessRequestNotificationTemplateV1, AccessRequestRoleV1,
32 AccessRequestScopeV1, CreateAccessRequestNotificationIntent, CreateTransactionalEmailIntent,
33 EmailRecipient, IntentSource, OrganizationInvitationTemplateV1, RenderedTemplate,
34 access_request_template_id, create_access_request_notification_in_tx,
35 create_transactional_email_intent_in_tx, find_access_request_notification_replay,
36 find_transactional_email_intent_replay,
37};
38use crate::repository::{
39 ADMIN_ATTEMPT_LIMIT, ADMIN_RECEIPT_LIMIT, ADMIN_RETRY_REQUEST_LIMIT, AttemptRecord,
40 DeliveryDetail, DeliverySummary, PostgresNotificationRepository, ReceiptRecord, RetryRecord,
41 RetryResult,
42};
43use crate::runtime::{DispatchWork, claim_one_due};
44use crate::snapshot::AeadSnapshotProtector;
45
46const DEPENDENCY_TIMEOUT: StdDuration = StdDuration::from_secs(10);
47#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
48#[serde(deny_unknown_fields)]
49pub struct NotificationConfig {
50 schema: String,
51 database_url_secret: String,
52 snapshot_key_secret: String,
53 transactional_callers: Vec<String>,
54 dispatch_callers: Vec<String>,
55 receipt_callers: Vec<String>,
56 admin_callers: Vec<String>,
57}
58
59impl NotificationConfig {
60 pub fn new(
61 database_url_secret: impl Into<String>,
62 snapshot_key_secret: impl Into<String>,
63 transactional_callers: Vec<String>,
64 dispatch_callers: Vec<String>,
65 receipt_callers: Vec<String>,
66 admin_callers: Vec<String>,
67 ) -> Result<Self, NotificationConfigError> {
68 let config = Self {
69 schema: "notification".to_owned(),
70 database_url_secret: database_url_secret.into(),
71 snapshot_key_secret: snapshot_key_secret.into(),
72 transactional_callers,
73 dispatch_callers,
74 receipt_callers,
75 admin_callers,
76 };
77 config.validate()?;
78 Ok(config)
79 }
80
81 fn validate(&self) -> Result<(), NotificationConfigError> {
82 if self.schema != "notification" {
83 return Err(NotificationConfigError::InvalidSchema);
84 }
85 if !valid_secret_reference(&self.database_url_secret)
86 || !valid_secret_reference(&self.snapshot_key_secret)
87 || self.database_url_secret == self.snapshot_key_secret
88 {
89 return Err(NotificationConfigError::InvalidSecretReference);
90 }
91 for callers in [
92 &self.transactional_callers,
93 &self.dispatch_callers,
94 &self.receipt_callers,
95 &self.admin_callers,
96 ] {
97 if callers.is_empty()
98 || callers.len() > 64
99 || callers.iter().any(|caller| !valid_instance(caller))
100 || callers
101 .iter()
102 .enumerate()
103 .any(|(index, caller)| callers[..index].contains(caller))
104 {
105 return Err(NotificationConfigError::InvalidCallers);
106 }
107 }
108 Ok(())
109 }
110}
111
112#[derive(Clone, Debug, Error, Eq, PartialEq)]
113pub enum NotificationConfigError {
114 #[error("the legacy Notification schema identity must remain `notification`")]
115 InvalidSchema,
116 #[error("database and snapshot keys require distinct valid secret references")]
117 InvalidSecretReference,
118 #[error("each authority role requires unique valid caller Instance keys")]
119 InvalidCallers,
120}
121
122fn validate_config(config: &NotificationConfig) -> Result<(), RuntimeFailure> {
123 config
124 .validate()
125 .map_err(|error| RuntimeFailure::InvalidResolvedPlan {
126 detail: error.to_string(),
127 })
128}
129
130#[lenso::plugin(
131 lifecycle,
132 configuration_schema = "configuration.schema.json",
133 validate = validate_config
134)]
135#[derive(Clone)]
136struct NotificationPlugin {
137 #[config]
138 config: NotificationConfig,
139 secrets: Port<lenso_capability_secrets::SecretsClient>,
140 email: Port<email::EmailDispatchClient>,
141 templates: Port<notification_template::NotificationTemplateClient>,
142 state: Rc<RefCell<Option<PreparedNotification>>>,
143}
144
145#[derive(Clone)]
146struct PreparedNotification {
147 postgres: OwnedPostgres,
148 protector: AeadSnapshotProtector,
149 email_provider_instance: String,
150}
151
152impl fmt::Debug for PreparedNotification {
153 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
154 formatter
155 .debug_struct("PreparedNotification")
156 .field("schema", &self.postgres.schema())
157 .field("email_provider_instance", &self.email_provider_instance)
158 .finish_non_exhaustive()
159 }
160}
161
162impl fmt::Debug for NotificationPlugin {
163 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
164 formatter
165 .debug_struct("NotificationPlugin")
166 .field("prepared", &self.state.borrow().is_some())
167 .field(
168 "transactional_caller_count",
169 &self.config.transactional_callers.len(),
170 )
171 .field("dispatch_caller_count", &self.config.dispatch_callers.len())
172 .field("receipt_caller_count", &self.config.receipt_callers.len())
173 .field("admin_caller_count", &self.config.admin_callers.len())
174 .finish_non_exhaustive()
175 }
176}
177
178#[lenso::provides(transactional::Transactional, delivery::Delivery, admin::Admin)]
179impl NotificationPlugin {
180 async fn create_organization_invitation(
181 &self,
182 context: Ctx,
183 request: transactional::CreateOrganizationInvitationRequest,
184 ) -> PluginResult<
185 transactional::CreateOrganizationInvitationResponse,
186 transactional::CreateOrganizationInvitationError,
187 > {
188 let Some(caller) =
189 authorized_caller(&context, &self.config.transactional_callers).map(str::to_owned)
190 else {
191 return Err(PluginError::domain(
192 transactional::CreateOrganizationInvitationError::Unauthorized,
193 ));
194 };
195 let now = Utc::now();
196 if !valid_create_request(&request, now) {
197 return Err(PluginError::domain(
198 transactional::CreateOrganizationInvitationError::InvalidIntent,
199 ));
200 }
201 let expires_at = parse_time(&request.template.expires_at).map_err(|_| {
202 PluginError::domain(transactional::CreateOrganizationInvitationError::InvalidIntent)
203 })?;
204 let locale = match request.recipient.locale {
205 transactional::CreateOrganizationInvitationRequestRecipientLocale::En => "en",
206 transactional::CreateOrganizationInvitationRequestRecipientLocale::EnUS => "en-US",
207 };
208 let intent = CreateTransactionalEmailIntent {
209 source: intent_source(caller, request.source),
210 recipient: EmailRecipient {
211 address: request.recipient.address,
212 display_name: request.recipient.display_name,
213 locale: locale.to_owned(),
214 },
215 template: OrganizationInvitationTemplateV1 {
216 organization_id: request.template.organization_id,
217 organization_name: request.template.organization_name,
218 invitation_id: request.template.invitation_id,
219 invitation_url: request.template.invitation_url,
220 inviter_display_name: request.template.inviter_display_name,
221 role_name: request.template.role_name,
222 expires_at,
223 },
224 idempotency_key: request.idempotency_key,
225 correlation_id: request.correlation_id,
226 causation_id: request.causation_id,
227 requested_by: request.requested_by,
228 };
229 let prepared = self.prepared().map_err(PluginError::runtime)?;
230 if let Some(receipt) =
231 find_transactional_email_intent_replay(prepared.postgres.pool(), &intent, now)
232 .await
233 .map_err(map_create_error)?
234 {
235 return Ok(transactional::CreateOrganizationInvitationResponse {
236 delivery_id: receipt.delivery_id,
237 idempotent_replay: true,
238 intent_id: receipt.intent_id,
239 status: transactional::CreateOrganizationInvitationResponseStatus::Queued,
240 });
241 }
242 let rendered = self
243 .render_organization_invitation(context, &intent)
244 .await
245 .map_err(PluginError::runtime)?;
246 let mut transaction = prepared
247 .postgres
248 .pool()
249 .begin()
250 .await
251 .map_err(|error| PluginError::runtime(runtime(error)))?;
252 let receipt = create_transactional_email_intent_in_tx(
253 &mut transaction,
254 &intent,
255 &rendered,
256 now,
257 &prepared.protector,
258 )
259 .await
260 .map_err(map_create_error)?;
261 transaction
262 .commit()
263 .await
264 .map_err(|error| PluginError::runtime(runtime(error)))?;
265 Ok(transactional::CreateOrganizationInvitationResponse {
266 delivery_id: receipt.delivery_id,
267 idempotent_replay: receipt.idempotent_replay,
268 intent_id: receipt.intent_id,
269 status: transactional::CreateOrganizationInvitationResponseStatus::Queued,
270 })
271 }
272
273 async fn create_access_request_notification(
274 &self,
275 context: Ctx,
276 request: transactional::CreateAccessRequestNotificationRequest,
277 ) -> PluginResult<
278 transactional::CreateAccessRequestNotificationResponse,
279 transactional::CreateAccessRequestNotificationError,
280 > {
281 let Some(caller) =
282 authorized_caller(&context, &self.config.transactional_callers).map(str::to_owned)
283 else {
284 return Err(PluginError::domain(
285 transactional::CreateAccessRequestNotificationError::Unauthorized,
286 ));
287 };
288 let now = Utc::now();
289 if !valid_access_request_notification_request(&request, now) {
290 return Err(PluginError::domain(
291 transactional::CreateAccessRequestNotificationError::InvalidIntent,
292 ));
293 }
294 let event = match request.event {
295 transactional::CreateAccessRequestNotificationRequestEvent::Submitted => {
296 AccessRequestNotificationEvent::Submitted
297 }
298 transactional::CreateAccessRequestNotificationRequestEvent::Approved => {
299 AccessRequestNotificationEvent::Approved
300 }
301 transactional::CreateAccessRequestNotificationRequestEvent::Denied => {
302 AccessRequestNotificationEvent::Denied
303 }
304 transactional::CreateAccessRequestNotificationRequestEvent::Expiring => {
305 AccessRequestNotificationEvent::Expiring
306 }
307 };
308 let expires_at = request
309 .expires_at
310 .as_deref()
311 .map(parse_time)
312 .transpose()
313 .map_err(|_| {
314 PluginError::domain(
315 transactional::CreateAccessRequestNotificationError::InvalidIntent,
316 )
317 })?;
318 let locale = match request.recipient.locale {
319 transactional::CreateAccessRequestNotificationRequestRecipientLocale::En => "en",
320 transactional::CreateAccessRequestNotificationRequestRecipientLocale::EnUS => "en-US",
321 };
322 let intent = CreateAccessRequestNotificationIntent {
323 source: IntentSource {
324 module_id: caller,
325 entity_type: "access_request".to_owned(),
326 entity_id: request.request_id.clone(),
327 },
328 recipient: EmailRecipient {
329 address: request.recipient.address,
330 display_name: request.recipient.display_name,
331 locale: locale.to_owned(),
332 },
333 template: AccessRequestNotificationTemplateV1 {
334 request_id: request.request_id,
335 organization_id: request.organization_id,
336 event,
337 role: AccessRequestRoleV1 {
338 role_id: request.role.role_id,
339 display_name: request.role.display_name,
340 },
341 scope: AccessRequestScopeV1 {
342 kind: request.scope.kind,
343 id: request.scope.id,
344 display_name: request.scope.display_name,
345 },
346 expires_at,
347 },
348 idempotency_key: request.idempotency_key,
349 correlation_id: request.correlation_id,
350 causation_id: request.causation_id,
351 requested_by: request.requested_by,
352 };
353 let prepared = self.prepared().map_err(PluginError::runtime)?;
354 if let Some(receipt) =
355 find_access_request_notification_replay(prepared.postgres.pool(), &intent, now)
356 .await
357 .map_err(map_access_request_create_error)?
358 {
359 return Ok(transactional::CreateAccessRequestNotificationResponse {
360 delivery_id: receipt.delivery_id,
361 idempotent_replay: true,
362 intent_id: receipt.intent_id,
363 status: transactional::CreateAccessRequestNotificationResponseStatus::Queued,
364 });
365 }
366 let rendered = self
367 .render_access_request_notification(context, &intent)
368 .await
369 .map_err(PluginError::runtime)?;
370 let mut transaction = prepared
371 .postgres
372 .pool()
373 .begin()
374 .await
375 .map_err(|error| PluginError::runtime(runtime(error)))?;
376 let receipt = create_access_request_notification_in_tx(
377 &mut transaction,
378 &intent,
379 &rendered,
380 now,
381 &prepared.protector,
382 )
383 .await
384 .map_err(map_access_request_create_error)?;
385 transaction
386 .commit()
387 .await
388 .map_err(|error| PluginError::runtime(runtime(error)))?;
389 Ok(transactional::CreateAccessRequestNotificationResponse {
390 delivery_id: receipt.delivery_id,
391 idempotent_replay: receipt.idempotent_replay,
392 intent_id: receipt.intent_id,
393 status: transactional::CreateAccessRequestNotificationResponseStatus::Queued,
394 })
395 }
396
397 async fn observe_invitation_lifecycle(
398 &self,
399 context: Ctx,
400 request: transactional::ObserveInvitationLifecycleRequest,
401 ) -> PluginResult<
402 transactional::ObserveInvitationLifecycleResponse,
403 transactional::ObserveInvitationLifecycleError,
404 > {
405 let Some(caller) =
406 authorized_caller(&context, &self.config.transactional_callers).map(str::to_owned)
407 else {
408 return Err(PluginError::domain(
409 transactional::ObserveInvitationLifecycleError::Unauthorized,
410 ));
411 };
412 if !valid_lifecycle_request(&request) {
413 return Err(PluginError::domain(
414 transactional::ObserveInvitationLifecycleError::InvalidObservation,
415 ));
416 }
417 let observed_at = parse_time(&request.observed_at).map_err(|_| {
418 PluginError::domain(transactional::ObserveInvitationLifecycleError::InvalidObservation)
419 })?;
420 let prepared = self.prepared().map_err(PluginError::runtime)?;
421 let event_name = match request.lifecycle {
422 transactional::ObserveInvitationLifecycleRequestLifecycle::Accepted => {
423 ORGANIZATION_INVITATION_ACCEPTED_EVENT
424 }
425 transactional::ObserveInvitationLifecycleRequestLifecycle::Expired => {
426 ORGANIZATION_INVITATION_EXPIRED_EVENT
427 }
428 transactional::ObserveInvitationLifecycleRequestLifecycle::Revoked => {
429 ORGANIZATION_INVITATION_REVOKED_EVENT
430 }
431 };
432 let payload = OrganizationInvitationLifecycle {
433 invitation_id: request.invitation_id.clone(),
434 organization_id: request.organization_id,
435 observed_at,
436 };
437 apply_observation(
438 prepared.postgres.pool(),
439 ObservationEnvelope {
440 id: request.observation_id,
441 event_name: event_name.to_owned(),
442 event_version: 1,
443 source_module: caller,
444 aggregate_id: request.invitation_id,
445 occurred_at: observed_at,
446 payload: serde_json::to_value(payload)
447 .map_err(|error| PluginError::runtime(runtime(error)))?,
448 },
449 )
450 .await
451 .map_err(map_lifecycle_error)?;
452 Ok(transactional::ObserveInvitationLifecycleResponse { recorded: true })
453 }
454
455 async fn dispatch_due(
456 &self,
457 context: Ctx,
458 _request: delivery::DispatchDueRequest,
459 ) -> PluginResult<delivery::DispatchDueResponse, delivery::DispatchDueError> {
460 if !authorized(&context, &self.config.dispatch_callers) {
461 return Err(PluginError::domain(
462 delivery::DispatchDueError::Unauthorized,
463 ));
464 }
465 let prepared = self.prepared().map_err(PluginError::runtime)?;
466 let now = Utc::now();
467 let Some(work) = claim_one_due(prepared.postgres.pool(), &prepared.protector, now)
468 .await
469 .map_err(|error| PluginError::runtime(runtime(error)))?
470 else {
471 return Err(PluginError::domain(
472 delivery::DispatchDueError::NoDeliveryDue,
473 ));
474 };
475 let dispatch = dispatch_request(&work);
476 let response = match self.email.dispatch(dispatch).await {
477 Ok(response) => response,
478 Err(EmailDispatchInvocationError::Domain(email::DispatchError::InvalidDispatch)) => {
479 let observed = permanent_rejection(
480 &work,
481 &prepared.email_provider_instance,
482 "email_invalid_dispatch",
483 now,
484 );
485 apply_dispatch(prepared.postgres.pool(), &work, &observed)
486 .await
487 .map_err(|error| PluginError::runtime(runtime(error)))?;
488 return Err(PluginError::domain(
489 delivery::DispatchDueError::DispatchRejected,
490 ));
491 }
492 Err(EmailDispatchInvocationError::Domain(email::DispatchError::UnsupportedMessage)) => {
493 let observed = permanent_rejection(
494 &work,
495 &prepared.email_provider_instance,
496 "email_unsupported_message",
497 now,
498 );
499 apply_dispatch(prepared.postgres.pool(), &work, &observed)
500 .await
501 .map_err(|error| PluginError::runtime(runtime(error)))?;
502 return Err(PluginError::domain(
503 delivery::DispatchDueError::DispatchRejected,
504 ));
505 }
506 Err(EmailDispatchInvocationError::Domain(email::DispatchError::Unknown(_))) => {
507 let observed = unknown_dispatch(
508 &work,
509 &prepared.email_provider_instance,
510 now,
511 "email_dispatch_unknown_domain_error",
512 );
513 apply_dispatch(prepared.postgres.pool(), &work, &observed)
514 .await
515 .map_err(|error| PluginError::runtime(runtime(error)))?;
516 return Err(PluginError::runtime(email_protocol_violation()));
517 }
518 Err(EmailDispatchInvocationError::Runtime(error)) => {
519 let observed = unknown_dispatch(
520 &work,
521 &prepared.email_provider_instance,
522 now,
523 "email_dispatch_runtime_failure",
524 );
525 apply_dispatch(prepared.postgres.pool(), &work, &observed)
526 .await
527 .map_err(|storage| PluginError::runtime(runtime(storage)))?;
528 return Err(PluginError::runtime(error));
529 }
530 };
531 let observed =
532 match dispatch_observation(&work, &prepared.email_provider_instance, response) {
533 Ok(observed) => observed,
534 Err(error) => {
535 let observed = unknown_dispatch(
536 &work,
537 &prepared.email_provider_instance,
538 now,
539 "email_dispatch_protocol_failure",
540 );
541 apply_dispatch(prepared.postgres.pool(), &work, &observed)
542 .await
543 .map_err(|storage| PluginError::runtime(runtime(storage)))?;
544 return Err(PluginError::runtime(error));
545 }
546 };
547 apply_dispatch(prepared.postgres.pool(), &work, &observed)
548 .await
549 .map_err(|error| PluginError::runtime(runtime(error)))?;
550 let detail = PostgresNotificationRepository::from_pool(prepared.postgres.pool().clone())
551 .get_delivery(&work.claim.delivery_id)
552 .await
553 .map_err(|error| PluginError::runtime(runtime(error)))?
554 .ok_or_else(|| PluginError::runtime(runtime("claimed delivery disappeared")))?;
555 let next_attempt_at = detail
556 .delivery
557 .next_attempt_at
558 .map(format_time)
559 .transpose()
560 .map_err(PluginError::runtime)?;
561 let observed_at = format_time(observed.observed_at).map_err(PluginError::runtime)?;
562 Ok(delivery::DispatchDueResponse {
563 attempt_id: work.claim.attempt_id,
564 delivery_id: work.claim.delivery_id,
565 next_attempt_at,
566 observed_at,
567 run_id: work.claim.run_id,
568 status: delivery_status(&detail.delivery.status).map_err(PluginError::runtime)?,
569 })
570 }
571
572 async fn observe_receipt(
573 &self,
574 context: Ctx,
575 request: delivery::ObserveReceiptRequest,
576 ) -> PluginResult<delivery::ObserveReceiptResponse, delivery::ObserveReceiptError> {
577 let Some(caller) =
578 authorized_caller(&context, &self.config.receipt_callers).map(str::to_owned)
579 else {
580 return Err(PluginError::domain(
581 delivery::ObserveReceiptError::Unauthorized,
582 ));
583 };
584 if !valid_receipt_request(&request) {
585 return Err(PluginError::domain(
586 delivery::ObserveReceiptError::InvalidReceipt,
587 ));
588 }
589 let observed_at = parse_time(&request.observed_at)
590 .map_err(|_| PluginError::domain(delivery::ObserveReceiptError::InvalidReceipt))?;
591 let prepared = self.prepared().map_err(PluginError::runtime)?;
592 let kind = match request.kind {
593 delivery::ObserveReceiptRequestKind::Delivered => ReceiptKind::Delivered,
594 delivery::ObserveReceiptRequestKind::Bounced => ReceiptKind::Bounced,
595 delivery::ObserveReceiptRequestKind::Rejected => ReceiptKind::Rejected,
596 };
597 let aggregate_id = request.delivery_id.clone();
598 let payload = receipt_observation(caller.clone(), &request, kind, observed_at);
599 apply_observation(
600 prepared.postgres.pool(),
601 ObservationEnvelope {
602 id: request.observation_id,
603 event_name: EMAIL_RECEIPT_OBSERVED_EVENT.to_owned(),
604 event_version: 1,
605 source_module: caller,
606 aggregate_id,
607 occurred_at: observed_at,
608 payload: serde_json::to_value(payload)
609 .map_err(|error| PluginError::runtime(runtime(error)))?,
610 },
611 )
612 .await
613 .map_err(map_receipt_error)?;
614 Ok(delivery::ObserveReceiptResponse { recorded: true })
615 }
616
617 async fn list_deliveries(
618 &self,
619 context: Ctx,
620 request: admin::ListDeliveriesRequest,
621 ) -> PluginResult<admin::ListDeliveriesResponse, admin::ListDeliveriesError> {
622 if !authorized(&context, &self.config.admin_callers) {
623 return Err(PluginError::domain(
624 admin::ListDeliveriesError::Unauthorized,
625 ));
626 }
627 if !valid_list_request(&request) {
628 return Err(PluginError::domain(
629 admin::ListDeliveriesError::InvalidFilter,
630 ));
631 }
632 let limit = request.limit.unwrap_or(100);
633 let status = request.status.as_ref().map(admin_status);
634 let prepared = self.prepared().map_err(PluginError::runtime)?;
635 let rows = PostgresNotificationRepository::from_pool(prepared.postgres.pool().clone())
636 .list_deliveries(limit + 1, request.cursor.as_deref(), status)
637 .await
638 .map_err(|error| PluginError::runtime(runtime(error)))?;
639 let has_more = i64::try_from(rows.len()).is_ok_and(|count| count > limit);
640 let records = rows
641 .into_iter()
642 .take(usize::try_from(limit).unwrap_or_default())
643 .map(admin_delivery)
644 .collect::<Result<Vec<_>, _>>()
645 .map_err(PluginError::runtime)?;
646 let next_cursor = has_more
647 .then(|| records.last().map(|record| record.id.clone()))
648 .flatten();
649 Ok(admin::ListDeliveriesResponse {
650 next_cursor,
651 records,
652 })
653 }
654
655 async fn get_delivery(
656 &self,
657 context: Ctx,
658 request: admin::GetDeliveryRequest,
659 ) -> PluginResult<admin::GetDeliveryResponse, admin::GetDeliveryError> {
660 if !authorized(&context, &self.config.admin_callers) {
661 return Err(PluginError::domain(admin::GetDeliveryError::Unauthorized));
662 }
663 if !required_bounded(&request.delivery_id, 1, 160) {
664 return Err(PluginError::domain(admin::GetDeliveryError::InvalidRequest));
665 }
666 let prepared = self.prepared().map_err(PluginError::runtime)?;
667 let detail = PostgresNotificationRepository::from_pool(prepared.postgres.pool().clone())
668 .get_delivery(&request.delivery_id)
669 .await
670 .map_err(map_get_error)?
671 .ok_or_else(|| PluginError::domain(admin::GetDeliveryError::DeliveryNotFound))?;
672 admin_detail(detail).map_err(PluginError::runtime)
673 }
674
675 async fn retry_delivery(
676 &self,
677 context: Ctx,
678 request: admin::RetryDeliveryRequest,
679 ) -> PluginResult<admin::RetryDeliveryResponse, admin::RetryDeliveryError> {
680 let Some(caller) =
681 authorized_caller(&context, &self.config.admin_callers).map(str::to_owned)
682 else {
683 return Err(PluginError::domain(admin::RetryDeliveryError::Unauthorized));
684 };
685 if !valid_retry_request(&request) {
686 return Err(PluginError::domain(
687 admin::RetryDeliveryError::InvalidRequest,
688 ));
689 }
690 if request.revision == MAX_SAFE_WIRE_INTEGER {
691 return Err(PluginError::domain(
692 admin::RetryDeliveryError::RetryNotAllowed,
693 ));
694 }
695 let prepared = self.prepared().map_err(PluginError::runtime)?;
696 let result = PostgresNotificationRepository::from_pool(prepared.postgres.pool().clone())
697 .request_manual_retry(
698 &request.delivery_id,
699 request.revision,
700 &request.idempotency_key,
701 &caller,
702 Utc::now(),
703 )
704 .await
705 .map_err(map_retry_error)?;
706 admin_retry(result).map_err(PluginError::runtime)
707 }
708}
709
710impl NotificationPlugin {
711 fn prepared(&self) -> Result<PreparedNotification, RuntimeFailure> {
712 self.state
713 .borrow()
714 .clone()
715 .ok_or_else(|| RuntimeFailure::PluginFailure {
716 detail: "Notification Plugin is not prepared".to_owned(),
717 })
718 }
719
720 async fn render_organization_invitation(
721 &self,
722 context: Ctx,
723 intent: &CreateTransactionalEmailIntent,
724 ) -> Result<RenderedTemplate, RuntimeFailure> {
725 self.templates
726 .render_with_context(context, organization_invitation_render_request(intent))
727 .await
728 .map(rendered_template)
729 .map_err(map_template_render_error)
730 }
731
732 async fn render_access_request_notification(
733 &self,
734 context: Ctx,
735 intent: &CreateAccessRequestNotificationIntent,
736 ) -> Result<RenderedTemplate, RuntimeFailure> {
737 self.templates
738 .render_with_context(context, access_request_render_request(intent))
739 .await
740 .map(rendered_template)
741 .map_err(map_template_render_error)
742 }
743}
744
745fn organization_invitation_render_request(
746 intent: &CreateTransactionalEmailIntent,
747) -> notification_template::RenderRequest {
748 notification_template::RenderRequest {
749 template_id: crate::public::ORGANIZATION_INVITATION_TEMPLATE_ID.to_owned(),
750 version: Some(crate::public::ORGANIZATION_INVITATION_TEMPLATE_VERSION.to_owned()),
751 locale: intent.recipient.locale.clone(),
752 variables: render_variables([
753 (
754 "expires_at",
755 format_template_time(intent.template.expires_at),
756 ),
757 ("invitation_url", intent.template.invitation_url.clone()),
758 (
759 "inviter_display_name",
760 trimmed_optional(intent.template.inviter_display_name.as_deref()),
761 ),
762 ("locale", intent.recipient.locale.clone()),
763 (
764 "organization_name",
765 intent.template.organization_name.trim().to_owned(),
766 ),
767 (
768 "recipient_display_name",
769 trimmed_optional(intent.recipient.display_name.as_deref()),
770 ),
771 (
772 "role_name",
773 trimmed_optional(intent.template.role_name.as_deref()),
774 ),
775 ]),
776 }
777}
778
779fn access_request_render_request(
780 intent: &CreateAccessRequestNotificationIntent,
781) -> notification_template::RenderRequest {
782 let role = intent
783 .template
784 .role
785 .display_name
786 .as_deref()
787 .map(str::trim)
788 .filter(|value| !value.is_empty())
789 .unwrap_or(&intent.template.role.role_id)
790 .to_owned();
791 let scope = intent
792 .template
793 .scope
794 .display_name
795 .as_deref()
796 .map(str::trim)
797 .filter(|value| !value.is_empty())
798 .unwrap_or(&intent.template.scope.id)
799 .to_owned();
800 let mut variables = vec![
801 render_variable("locale", intent.recipient.locale.clone()),
802 render_variable("organization_id", intent.template.organization_id.clone()),
803 render_variable(
804 "recipient_display_name",
805 trimmed_optional(intent.recipient.display_name.as_deref()),
806 ),
807 render_variable("request_id", intent.template.request_id.clone()),
808 render_variable("role", role),
809 render_variable("scope", scope),
810 render_variable("scope_kind", intent.template.scope.kind.clone()),
811 ];
812 if intent.template.event != AccessRequestNotificationEvent::Denied {
813 variables.push(render_variable(
814 "expires_at",
815 intent
816 .template
817 .expires_at
818 .map(format_template_time)
819 .unwrap_or_default(),
820 ));
821 }
822 variables.sort_by(|left, right| left.name.cmp(&right.name));
823 notification_template::RenderRequest {
824 template_id: access_request_template_id(intent.template.event).to_owned(),
825 version: Some(crate::public::ACCESS_REQUEST_TEMPLATE_VERSION.to_owned()),
826 locale: intent.recipient.locale.clone(),
827 variables,
828 }
829}
830
831fn render_variables<const N: usize>(
832 variables: [(&str, String); N],
833) -> Vec<notification_template::RenderRequestVariablesItem> {
834 variables
835 .into_iter()
836 .map(|(name, value)| render_variable(name, value))
837 .collect()
838}
839
840fn render_variable(name: &str, value: String) -> notification_template::RenderRequestVariablesItem {
841 notification_template::RenderRequestVariablesItem {
842 name: name.to_owned(),
843 value,
844 }
845}
846
847fn trimmed_optional(value: Option<&str>) -> String {
848 value.map(str::trim).unwrap_or_default().to_owned()
849}
850
851fn format_template_time(value: DateTime<Utc>) -> String {
852 value.to_rfc3339_opts(SecondsFormat::Secs, true)
853}
854
855fn rendered_template(response: notification_template::RenderResponse) -> RenderedTemplate {
856 RenderedTemplate {
857 template_id: response.template_id,
858 template_version: response.version,
859 requested_locale: response.requested_locale,
860 resolved_locale: response.resolved_locale,
861 fallback_used: response.fallback_used,
862 renderer_identity: response.renderer_identity,
863 template_digest: response.template_digest,
864 content_digest: response.content_digest,
865 subject: response.subject,
866 text: response.text,
867 html: response.html,
868 }
869}
870
871fn map_template_render_error(
872 error: notification_template::NotificationTemplateRenderInvocationError,
873) -> RuntimeFailure {
874 match error {
875 notification_template::NotificationTemplateRenderInvocationError::Runtime(error) => error,
876 notification_template::NotificationTemplateRenderInvocationError::Domain(
877 notification_template::RenderError::NotFound,
878 ) => RuntimeFailure::PluginFailure {
879 detail: "required Notification Template version is unavailable".to_owned(),
880 },
881 notification_template::NotificationTemplateRenderInvocationError::Domain(
882 notification_template::RenderError::Unauthorized,
883 ) => RuntimeFailure::PluginFailure {
884 detail:
885 "Notification is not authorized to render through its configured Template Provider"
886 .to_owned(),
887 },
888 notification_template::NotificationTemplateRenderInvocationError::Domain(_) => {
889 RuntimeFailure::ProtocolViolation {
890 capability: notification_template::CAPABILITY_ID,
891 }
892 }
893 }
894}
895
896fn authorized(context: &Ctx, allowed: &[String]) -> bool {
897 authorized_caller(context, allowed).is_some()
898}
899
900fn authorized_caller<'a>(context: &'a Ctx, allowed: &[String]) -> Option<&'a str> {
901 context
902 .caller_instance()
903 .filter(|caller| allowed.iter().any(|allowed| allowed == caller))
904}
905
906fn required_bounded(value: &str, minimum: usize, maximum: usize) -> bool {
907 let length = value.chars().count();
908 !value.trim().is_empty() && (minimum..=maximum).contains(&length)
909}
910
911fn optional_bounded(value: Option<&str>, maximum: usize) -> bool {
912 value.is_none_or(|value| value.chars().count() <= maximum)
913}
914
915fn valid_digest(value: &str) -> bool {
916 value.strip_prefix("sha256:").is_some_and(|digest| {
917 digest.len() == 64
918 && digest
919 .bytes()
920 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
921 })
922}
923
924fn valid_create_request(
925 request: &transactional::CreateOrganizationInvitationRequest,
926 now: DateTime<Utc>,
927) -> bool {
928 required_bounded(&request.source.entity_type, 1, 160)
929 && request.source.entity_type == "organization_invitation"
930 && required_bounded(&request.source.entity_id, 1, 240)
931 && required_bounded(&request.recipient.address, 3, 320)
932 && request.recipient.address.contains('@')
933 && optional_bounded(request.recipient.display_name.as_deref(), 240)
934 && required_bounded(&request.template.organization_id, 1, 240)
935 && required_bounded(&request.template.organization_name, 1, 240)
936 && required_bounded(&request.template.invitation_id, 1, 240)
937 && request.source.entity_id == request.template.invitation_id
938 && required_bounded(&request.template.invitation_url, 1, 4_096)
939 && (request.template.invitation_url.starts_with("https://")
940 || request
941 .template
942 .invitation_url
943 .starts_with("http://localhost"))
944 && optional_bounded(request.template.inviter_display_name.as_deref(), 240)
945 && optional_bounded(request.template.role_name.as_deref(), 160)
946 && valid_time(&request.template.expires_at)
947 && parse_time(&request.template.expires_at).is_ok_and(|expires_at| expires_at > now)
948 && required_bounded(&request.idempotency_key, 1, 240)
949 && required_bounded(&request.correlation_id, 1, 240)
950 && optional_bounded(request.causation_id.as_deref(), 240)
951 && optional_bounded(request.requested_by.as_deref(), 240)
952}
953
954fn valid_access_request_notification_request(
955 request: &transactional::CreateAccessRequestNotificationRequest,
956 now: DateTime<Utc>,
957) -> bool {
958 let event = match &request.event {
959 transactional::CreateAccessRequestNotificationRequestEvent::Submitted => "submitted",
960 transactional::CreateAccessRequestNotificationRequestEvent::Approved => "approved",
961 transactional::CreateAccessRequestNotificationRequestEvent::Denied => "denied",
962 transactional::CreateAccessRequestNotificationRequestEvent::Expiring => "expiring",
963 };
964 let expected_idempotency_key = format!("access-request:{}:{event}", request.request_id);
965 let expiry = request
966 .expires_at
967 .as_deref()
968 .and_then(|value| parse_time(value).ok());
969 let expiry_valid = match &request.event {
970 transactional::CreateAccessRequestNotificationRequestEvent::Expiring => {
971 expiry.is_some_and(|value| value > now)
972 }
973 transactional::CreateAccessRequestNotificationRequestEvent::Denied => {
974 request.expires_at.is_none()
975 }
976 _ => request.expires_at.is_none() || expiry.is_some_and(|value| value > now),
977 };
978 required_bounded(&request.request_id, 1, 160)
979 && required_bounded(&request.organization_id, 1, 240)
980 && required_bounded(&request.recipient.address, 3, 320)
981 && request.recipient.address.contains('@')
982 && optional_bounded(request.recipient.display_name.as_deref(), 240)
983 && required_bounded(&request.role.role_id, 1, 160)
984 && optional_bounded(request.role.display_name.as_deref(), 160)
985 && required_bounded(&request.scope.kind, 1, 160)
986 && required_bounded(&request.scope.id, 1, 240)
987 && optional_bounded(request.scope.display_name.as_deref(), 240)
988 && request.expires_at.as_deref().is_none_or(valid_time)
989 && expiry_valid
990 && request.idempotency_key == expected_idempotency_key
991 && required_bounded(&request.correlation_id, 1, 240)
992 && optional_bounded(request.causation_id.as_deref(), 240)
993 && optional_bounded(request.requested_by.as_deref(), 240)
994}
995
996fn valid_lifecycle_request(request: &transactional::ObserveInvitationLifecycleRequest) -> bool {
997 required_bounded(&request.observation_id, 1, 240)
998 && required_bounded(&request.organization_id, 1, 240)
999 && required_bounded(&request.invitation_id, 1, 240)
1000 && valid_time(&request.observed_at)
1001}
1002
1003fn valid_receipt_request(request: &delivery::ObserveReceiptRequest) -> bool {
1004 required_bounded(&request.observation_id, 1, 240)
1005 && required_bounded(&request.delivery_id, 1, 160)
1006 && required_bounded(&request.attempt_id, 1, 160)
1007 && required_bounded(&request.run_id, 1, 160)
1008 && valid_time(&request.observed_at)
1009 && required_bounded(&request.remote_id, 1, 320)
1010 && valid_digest(&request.digest)
1011}
1012
1013fn valid_list_request(request: &admin::ListDeliveriesRequest) -> bool {
1014 request.limit.is_none_or(|limit| (1..=200).contains(&limit))
1015 && request
1016 .cursor
1017 .as_deref()
1018 .is_none_or(|cursor| required_bounded(cursor, 1, 160))
1019}
1020
1021fn valid_retry_request(request: &admin::RetryDeliveryRequest) -> bool {
1022 required_bounded(&request.delivery_id, 1, 160)
1023 && (1..=MAX_SAFE_WIRE_INTEGER).contains(&request.revision)
1024 && required_bounded(&request.idempotency_key, 1, 240)
1025}
1026
1027fn intent_source(
1028 caller: String,
1029 source: transactional::CreateOrganizationInvitationRequestSource,
1030) -> IntentSource {
1031 IntentSource {
1032 module_id: caller,
1033 entity_type: source.entity_type,
1034 entity_id: source.entity_id,
1035 }
1036}
1037
1038fn receipt_observation(
1039 caller: String,
1040 request: &delivery::ObserveReceiptRequest,
1041 kind: ReceiptKind,
1042 observed_at: DateTime<Utc>,
1043) -> EmailReceiptObserved {
1044 EmailReceiptObserved {
1045 delivery_id: request.delivery_id.clone(),
1046 attempt_id: request.attempt_id.clone(),
1047 function_run_id: request.run_id.clone(),
1048 kind,
1049 source: caller,
1050 observed_at,
1051 remote_id: request.remote_id.clone(),
1052 digest: request.digest.clone(),
1053 }
1054}
1055
1056impl Lifecycle for NotificationPlugin {
1057 async fn prepare(&self, context: PrepareContext) -> Result<(), RuntimeFailure> {
1058 let dependencies = context.dependencies().clone();
1059 let cancellation = context.cancellation();
1060 let email_providers = email::EmailDispatchClient::many_from_dependencies(&dependencies)?;
1061 let [email_provider] = email_providers.as_slice() else {
1062 return Err(RuntimeFailure::InvalidResolvedPlan {
1063 detail: "Notification requires exactly one lenso.email-dispatch@1 Provider"
1064 .to_owned(),
1065 });
1066 };
1067 let email_provider_instance = email_provider.provider_instance().to_owned();
1068 let secrets = lenso_capability_secrets::SecretsClient::from_dependencies(&dependencies)?;
1069 let database_context =
1070 dependencies.invocation_context_after(DEPENDENCY_TIMEOUT, cancellation.clone())?;
1071 let database_url =
1072 resolve_secret(&secrets, database_context, &self.config.database_url_secret).await?;
1073 let snapshot_context =
1074 dependencies.invocation_context_after(DEPENDENCY_TIMEOUT, cancellation)?;
1075 let snapshot_key =
1076 resolve_secret(&secrets, snapshot_context, &self.config.snapshot_key_secret).await?;
1077 let protector = AeadSnapshotProtector::from_base64_key(
1078 &snapshot_key,
1079 self.config.snapshot_key_secret.clone(),
1080 )
1081 .map_err(runtime)?;
1082 let postgres = OwnedPostgres::prepare(
1083 &database_url,
1084 schema_plan(self.config.schema.clone()).map_err(|error| {
1085 RuntimeFailure::InvalidResolvedPlan {
1086 detail: error.to_string(),
1087 }
1088 })?,
1089 )
1090 .await
1091 .map_err(runtime)?;
1092 if let Err(error) = verify_managed_catalog(postgres.pool()).await {
1093 postgres.pool().close().await;
1094 return Err(runtime(error));
1095 }
1096 self.state.replace(Some(PreparedNotification {
1097 postgres,
1098 protector,
1099 email_provider_instance,
1100 }));
1101 Ok(())
1102 }
1103
1104 async fn deactivate(&self, _context: DeactivateContext) -> Result<(), RuntimeFailure> {
1105 let prepared = self.state.borrow_mut().take();
1106 if let Some(prepared) = prepared {
1107 prepared.postgres.pool().close().await;
1108 }
1109 Ok(())
1110 }
1111}
1112
1113fn dispatch_request(work: &DispatchWork) -> DispatchRequest {
1114 work.request.clone()
1115}
1116
1117fn dispatch_observation(
1118 work: &DispatchWork,
1119 email_provider_instance: &str,
1120 response: email::DispatchResponse,
1121) -> Result<EmailDispatchObserved, RuntimeFailure> {
1122 if !valid_dispatch_response(&response, email_provider_instance) {
1123 return Err(email_protocol_violation());
1124 }
1125 let observed_at = parse_time(&response.observed_at).map_err(|_| email_protocol_violation())?;
1126 let failure = response
1127 .failure
1128 .map(|failure| {
1129 Ok(SanitizedFailure {
1130 code: failure.code,
1131 classification: failure.classification,
1132 retry_after_ms: failure
1133 .retry_after_ms
1134 .map(u64::try_from)
1135 .transpose()
1136 .map_err(|_| email_protocol_violation())?,
1137 })
1138 })
1139 .transpose()?;
1140 Ok(EmailDispatchObserved {
1141 delivery_id: work.claim.delivery_id.clone(),
1142 attempt_id: work.claim.attempt_id.clone(),
1143 function_run_id: work.claim.run_id.clone(),
1144 outcome: match response.outcome {
1145 DispatchResponseOutcome::Accepted => DispatchOutcome::Accepted,
1146 DispatchResponseOutcome::TemporaryFailure => DispatchOutcome::TemporaryFailure,
1147 DispatchResponseOutcome::PermanentFailure => DispatchOutcome::PermanentFailure,
1148 DispatchResponseOutcome::DeliveryUnknown => DispatchOutcome::DeliveryUnknown,
1149 },
1150 provider: email_provider_instance.to_owned(),
1151 observed_at,
1152 remote_receipt: response.remote_receipt.map(|receipt| RemoteReceiptSummary {
1153 remote_id: receipt.remote_id,
1154 source: email_provider_instance.to_owned(),
1155 digest: receipt.digest,
1156 }),
1157 failure,
1158 })
1159}
1160
1161fn valid_dispatch_response(
1162 response: &email::DispatchResponse,
1163 email_provider_instance: &str,
1164) -> bool {
1165 if response.provider != email_provider_instance || !valid_time(&response.observed_at) {
1166 return false;
1167 }
1168 let remote_receipt_valid = response.remote_receipt.as_ref().is_none_or(|receipt| {
1169 required_bounded(&receipt.remote_id, 1, 320)
1170 && receipt.source == email_provider_instance
1171 && valid_digest(&receipt.digest)
1172 });
1173 if !remote_receipt_valid {
1174 return false;
1175 }
1176 match &response.outcome {
1177 DispatchResponseOutcome::Accepted => response.failure.is_none(),
1178 DispatchResponseOutcome::TemporaryFailure => {
1179 response.remote_receipt.is_none()
1180 && response.failure.as_ref().is_some_and(|failure| {
1181 valid_dispatch_failure(failure, "temporary_failure")
1182 && failure
1183 .retry_after_ms
1184 .is_none_or(|delay| (0..=86_400_000).contains(&delay))
1185 })
1186 }
1187 DispatchResponseOutcome::PermanentFailure => {
1188 response.remote_receipt.is_none()
1189 && response.failure.as_ref().is_some_and(|failure| {
1190 valid_dispatch_failure(failure, "permanent_failure")
1191 && failure.retry_after_ms.is_none()
1192 })
1193 }
1194 DispatchResponseOutcome::DeliveryUnknown => {
1195 response.remote_receipt.is_none()
1196 && response.failure.as_ref().is_some_and(|failure| {
1197 valid_dispatch_failure(failure, "delivery_unknown")
1198 && failure.retry_after_ms.is_none()
1199 })
1200 }
1201 }
1202}
1203
1204fn valid_dispatch_failure(failure: &email::DispatchResponseFailure, classification: &str) -> bool {
1205 required_bounded(&failure.code, 1, 160)
1206 && required_bounded(&failure.classification, 1, 160)
1207 && failure.classification == classification
1208}
1209
1210fn email_protocol_violation() -> RuntimeFailure {
1211 RuntimeFailure::ProtocolViolation {
1212 capability: email::CAPABILITY_ID,
1213 }
1214}
1215
1216fn permanent_rejection(
1217 work: &DispatchWork,
1218 email_provider_instance: &str,
1219 code: &str,
1220 observed_at: DateTime<Utc>,
1221) -> EmailDispatchObserved {
1222 EmailDispatchObserved {
1223 delivery_id: work.claim.delivery_id.clone(),
1224 attempt_id: work.claim.attempt_id.clone(),
1225 function_run_id: work.claim.run_id.clone(),
1226 outcome: DispatchOutcome::PermanentFailure,
1227 provider: email_provider_instance.to_owned(),
1228 observed_at,
1229 remote_receipt: None,
1230 failure: Some(SanitizedFailure {
1231 code: code.to_owned(),
1232 classification: "permanent_failure".to_owned(),
1233 retry_after_ms: None,
1234 }),
1235 }
1236}
1237
1238fn unknown_dispatch(
1239 work: &DispatchWork,
1240 email_provider_instance: &str,
1241 observed_at: DateTime<Utc>,
1242 code: &str,
1243) -> EmailDispatchObserved {
1244 EmailDispatchObserved {
1245 delivery_id: work.claim.delivery_id.clone(),
1246 attempt_id: work.claim.attempt_id.clone(),
1247 function_run_id: work.claim.run_id.clone(),
1248 outcome: DispatchOutcome::DeliveryUnknown,
1249 provider: email_provider_instance.to_owned(),
1250 observed_at,
1251 remote_receipt: None,
1252 failure: Some(SanitizedFailure {
1253 code: code.to_owned(),
1254 classification: "delivery_unknown".to_owned(),
1255 retry_after_ms: None,
1256 }),
1257 }
1258}
1259
1260async fn apply_dispatch(
1261 pool: &sqlx::PgPool,
1262 work: &DispatchWork,
1263 observed: &EmailDispatchObserved,
1264) -> Result<(), NotificationError> {
1265 apply_observation(
1266 pool,
1267 ObservationEnvelope {
1268 id: format!("dispatch-observation:{}", work.claim.attempt_id),
1269 event_name: EMAIL_DISPATCH_OBSERVED_EVENT.to_owned(),
1270 event_version: 1,
1271 source_module: observed.provider.clone(),
1272 aggregate_id: work.claim.delivery_id.clone(),
1273 occurred_at: observed.observed_at,
1274 payload: serde_json::to_value(observed).map_err(|error| {
1275 NotificationError::new(
1276 ErrorCode::Internal,
1277 "Notification dispatch observation encoding failed",
1278 )
1279 .with_source(error)
1280 })?,
1281 },
1282 )
1283 .await
1284}
1285
1286async fn apply_observation(
1287 pool: &sqlx::PgPool,
1288 envelope: ObservationEnvelope,
1289) -> Result<(), NotificationError> {
1290 NotificationEventApplier::new(pool.clone())
1291 .apply(&envelope)
1292 .await
1293}
1294
1295fn delivery_status(status: &str) -> Result<delivery::DispatchDueResponseStatus, RuntimeFailure> {
1296 match status {
1297 "accepted" => Ok(delivery::DispatchDueResponseStatus::Accepted),
1298 "retry_scheduled" => Ok(delivery::DispatchDueResponseStatus::RetryScheduled),
1299 "failed" => Ok(delivery::DispatchDueResponseStatus::Failed),
1300 "delivery_unknown" => Ok(delivery::DispatchDueResponseStatus::DeliveryUnknown),
1301 other => Err(runtime(format!(
1302 "stored delivery status `{other}` is invalid after dispatch"
1303 ))),
1304 }
1305}
1306
1307fn admin_detail(value: DeliveryDetail) -> Result<admin::GetDeliveryResponse, RuntimeFailure> {
1308 if value.attempts.len() > ADMIN_ATTEMPT_LIMIT
1309 || value.receipts.len() > ADMIN_RECEIPT_LIMIT
1310 || value.retry_requests.len() > ADMIN_RETRY_REQUEST_LIMIT
1311 {
1312 return Err(invalid_admin_projection());
1313 }
1314 let correlation_id = value.delivery.correlation_id.clone();
1315 Ok(admin::GetDeliveryResponse {
1316 attempts: value
1317 .attempts
1318 .into_iter()
1319 .map(admin_attempt)
1320 .collect::<Result<Vec<_>, _>>()?,
1321 delivery: admin_delivery(value.delivery)?,
1322 open_in_story_correlation_id: correlation_id,
1323 receipts: value
1324 .receipts
1325 .into_iter()
1326 .map(admin_receipt)
1327 .collect::<Result<Vec<_>, _>>()?,
1328 retry_requests: value
1329 .retry_requests
1330 .into_iter()
1331 .map(admin_retry_record)
1332 .collect::<Result<Vec<_>, _>>()?,
1333 })
1334}
1335
1336fn admin_delivery(value: DeliverySummary) -> Result<admin::Delivery, RuntimeFailure> {
1337 if !required_bounded(&value.id, 1, 160)
1338 || !required_bounded(&value.recipient_mask, 3, 320)
1339 || !required_bounded(&value.template_id, 1, 160)
1340 || !required_bounded(&value.template_version, 1, 80)
1341 || !required_bounded(&value.locale, 2, 32)
1342 || !(1..=MAX_SAFE_WIRE_INTEGER).contains(&value.revision)
1343 || !(0..=10).contains(&value.attempt_count)
1344 || !(1..=10).contains(&value.max_attempts)
1345 || value.attempt_count > value.max_attempts
1346 || value.redacted_preview.chars().count() > 160
1347 || !valid_digest(&value.content_digest)
1348 || !required_bounded(&value.correlation_id, 1, 240)
1349 || !optional_bounded(value.final_reason.as_deref(), 160)
1350 {
1351 return Err(invalid_admin_projection());
1352 }
1353 let status = match value.status.as_str() {
1354 "queued" => admin::DeliveryStatus::Queued,
1355 "attempting" => admin::DeliveryStatus::Attempting,
1356 "accepted" => admin::DeliveryStatus::Accepted,
1357 "retry_scheduled" => admin::DeliveryStatus::RetryScheduled,
1358 "delivered" => admin::DeliveryStatus::Delivered,
1359 "failed" => admin::DeliveryStatus::Failed,
1360 "delivery_unknown" => admin::DeliveryStatus::DeliveryUnknown,
1361 other => {
1362 let _ = other;
1363 return Err(invalid_admin_projection());
1364 }
1365 };
1366 Ok(admin::Delivery {
1367 attempt_count: i64::from(value.attempt_count),
1368 content_digest: value.content_digest,
1369 correlation_id: value.correlation_id,
1370 created_at: format_time(value.created_at)?,
1371 final_reason: value.final_reason,
1372 id: value.id,
1373 locale: value.locale,
1374 max_attempts: i64::from(value.max_attempts),
1375 next_attempt_at: value.next_attempt_at.map(format_time).transpose()?,
1376 recipient_mask: value.recipient_mask,
1377 redacted_preview: value.redacted_preview,
1378 retry_now_eligible: value.status == "retry_scheduled"
1379 && value.attempt_count < value.max_attempts,
1380 revision: value.revision,
1381 status,
1382 template_id: value.template_id,
1383 template_version: value.template_version,
1384 updated_at: format_time(value.updated_at)?,
1385 })
1386}
1387
1388fn admin_attempt(value: AttemptRecord) -> Result<admin::Attempt, RuntimeFailure> {
1389 if !required_bounded(&value.id, 1, 160)
1390 || !(1..=10).contains(&value.sequence)
1391 || !required_bounded(&value.function_run_id, 1, 160)
1392 || !optional_bounded(value.provider.as_deref(), 160)
1393 || !optional_bounded(value.remote_receipt_id.as_deref(), 320)
1394 || !optional_bounded(value.failure_code.as_deref(), 160)
1395 || !optional_bounded(value.failure_classification.as_deref(), 160)
1396 {
1397 return Err(invalid_admin_projection());
1398 }
1399 let status = match value.status.as_str() {
1400 "dispatching" => admin::AttemptStatus::Dispatching,
1401 "accepted" => admin::AttemptStatus::Accepted,
1402 "temporary_failure" => admin::AttemptStatus::TemporaryFailure,
1403 "permanent_failure" => admin::AttemptStatus::PermanentFailure,
1404 "delivery_unknown" => admin::AttemptStatus::DeliveryUnknown,
1405 _ => return Err(invalid_admin_projection()),
1406 };
1407 Ok(admin::Attempt {
1408 completed_at: value.completed_at.map(format_time).transpose()?,
1409 failure_classification: value.failure_classification,
1410 failure_code: value.failure_code,
1411 id: value.id,
1412 provider: value.provider,
1413 remote_receipt_id: value.remote_receipt_id,
1414 run_id: value.function_run_id,
1415 sequence: i64::from(value.sequence),
1416 started_at: format_time(value.started_at)?,
1417 status,
1418 })
1419}
1420
1421fn admin_receipt(value: ReceiptRecord) -> Result<admin::Receipt, RuntimeFailure> {
1422 if !required_bounded(&value.id, 1, 160)
1423 || !required_bounded(&value.attempt_id, 1, 160)
1424 || !required_bounded(&value.source, 1, 160)
1425 || !required_bounded(&value.remote_id, 1, 320)
1426 || !valid_digest(&value.digest)
1427 {
1428 return Err(invalid_admin_projection());
1429 }
1430 let kind = match value.kind.as_str() {
1431 "accepted" => admin::ReceiptKind::Accepted,
1432 "delivered" => admin::ReceiptKind::Delivered,
1433 "bounced" => admin::ReceiptKind::Bounced,
1434 "rejected" => admin::ReceiptKind::Rejected,
1435 _ => return Err(invalid_admin_projection()),
1436 };
1437 Ok(admin::Receipt {
1438 attempt_id: value.attempt_id,
1439 digest: value.digest,
1440 id: value.id,
1441 kind,
1442 observed_at: format_time(value.observed_at)?,
1443 remote_id: value.remote_id,
1444 source: value.source,
1445 })
1446}
1447
1448fn admin_retry_record(value: RetryRecord) -> Result<admin::RetryRecord, RuntimeFailure> {
1449 if !required_bounded(&value.id, 1, 160)
1450 || !optional_bounded(value.requested_by.as_deref(), 240)
1451 || !(1..=MAX_SAFE_WIRE_INTEGER).contains(&value.source_revision)
1452 || !optional_bounded(value.reason.as_deref(), 160)
1453 {
1454 return Err(invalid_admin_projection());
1455 }
1456 let kind = match value.kind.as_str() {
1457 "automatic" => admin::RetryRecordKind::Automatic,
1458 "manual" => admin::RetryRecordKind::Manual,
1459 _ => return Err(invalid_admin_projection()),
1460 };
1461 let decision = match value.decision.as_str() {
1462 "scheduled" => admin::RetryRecordDecision::Scheduled,
1463 "rejected" => admin::RetryRecordDecision::Rejected,
1464 _ => return Err(invalid_admin_projection()),
1465 };
1466 Ok(admin::RetryRecord {
1467 created_at: format_time(value.created_at)?,
1468 decision,
1469 id: value.id,
1470 kind,
1471 reason: value.reason,
1472 requested_by: value.requested_by,
1473 scheduled_at: value.scheduled_at.map(format_time).transpose()?,
1474 source_revision: value.source_revision,
1475 })
1476}
1477
1478fn admin_retry(value: RetryResult) -> Result<admin::RetryDeliveryResponse, RuntimeFailure> {
1479 if !required_bounded(&value.delivery_id, 1, 160)
1480 || !(1..=MAX_SAFE_WIRE_INTEGER).contains(&value.revision)
1481 || value.status != "retry_scheduled"
1482 {
1483 return Err(invalid_admin_projection());
1484 }
1485 Ok(admin::RetryDeliveryResponse {
1486 delivery_id: value.delivery_id,
1487 idempotent_replay: value.idempotent_replay,
1488 revision: value.revision,
1489 scheduled_at: format_time(value.scheduled_at)?,
1490 status: admin::RetryDeliveryResponseStatus::RetryScheduled,
1491 })
1492}
1493
1494fn invalid_admin_projection() -> RuntimeFailure {
1495 runtime("stored Notification Admin projection violates its bounded Capability Contract")
1496}
1497
1498fn admin_status(status: &admin::DeliveryStatus) -> &'static str {
1499 match status {
1500 admin::DeliveryStatus::Queued => "queued",
1501 admin::DeliveryStatus::Attempting => "attempting",
1502 admin::DeliveryStatus::Accepted => "accepted",
1503 admin::DeliveryStatus::RetryScheduled => "retry_scheduled",
1504 admin::DeliveryStatus::Delivered => "delivered",
1505 admin::DeliveryStatus::Failed => "failed",
1506 admin::DeliveryStatus::DeliveryUnknown => "delivery_unknown",
1507 }
1508}
1509
1510fn map_create_error(
1511 error: NotificationError,
1512) -> PluginError<transactional::CreateOrganizationInvitationError> {
1513 match error.code {
1514 ErrorCode::Validation => {
1515 PluginError::domain(transactional::CreateOrganizationInvitationError::InvalidIntent)
1516 }
1517 ErrorCode::Conflict => PluginError::domain(
1518 transactional::CreateOrganizationInvitationError::IdempotencyConflict,
1519 ),
1520 ErrorCode::NotFound | ErrorCode::EvidenceOverflow | ErrorCode::Internal => {
1521 PluginError::runtime(runtime(error))
1522 }
1523 }
1524}
1525
1526fn map_access_request_create_error(
1527 error: NotificationError,
1528) -> PluginError<transactional::CreateAccessRequestNotificationError> {
1529 match error.code {
1530 ErrorCode::Validation => {
1531 PluginError::domain(transactional::CreateAccessRequestNotificationError::InvalidIntent)
1532 }
1533 ErrorCode::Conflict => PluginError::domain(
1534 transactional::CreateAccessRequestNotificationError::IdempotencyConflict,
1535 ),
1536 ErrorCode::NotFound | ErrorCode::EvidenceOverflow | ErrorCode::Internal => {
1537 PluginError::runtime(runtime(error))
1538 }
1539 }
1540}
1541
1542fn map_lifecycle_error(
1543 error: NotificationError,
1544) -> PluginError<transactional::ObserveInvitationLifecycleError> {
1545 match error.code {
1546 ErrorCode::Validation => {
1547 PluginError::domain(transactional::ObserveInvitationLifecycleError::InvalidObservation)
1548 }
1549 ErrorCode::Conflict => {
1550 PluginError::domain(transactional::ObserveInvitationLifecycleError::ObservationConflict)
1551 }
1552 ErrorCode::NotFound | ErrorCode::EvidenceOverflow | ErrorCode::Internal => {
1553 PluginError::runtime(runtime(error))
1554 }
1555 }
1556}
1557
1558fn map_receipt_error(error: NotificationError) -> PluginError<delivery::ObserveReceiptError> {
1559 match error.code {
1560 ErrorCode::Validation => PluginError::domain(delivery::ObserveReceiptError::InvalidReceipt),
1561 ErrorCode::NotFound => PluginError::domain(delivery::ObserveReceiptError::DeliveryNotFound),
1562 ErrorCode::Conflict => PluginError::domain(delivery::ObserveReceiptError::ReceiptConflict),
1563 ErrorCode::EvidenceOverflow | ErrorCode::Internal => PluginError::runtime(runtime(error)),
1564 }
1565}
1566
1567fn map_get_error(error: NotificationError) -> PluginError<admin::GetDeliveryError> {
1568 match error.code {
1569 ErrorCode::EvidenceOverflow => {
1570 PluginError::domain(admin::GetDeliveryError::EvidenceOverflow)
1571 }
1572 ErrorCode::Validation | ErrorCode::NotFound | ErrorCode::Conflict | ErrorCode::Internal => {
1573 PluginError::runtime(runtime(error))
1574 }
1575 }
1576}
1577
1578fn map_retry_error(error: NotificationError) -> PluginError<admin::RetryDeliveryError> {
1579 match error.code {
1580 ErrorCode::NotFound => PluginError::domain(admin::RetryDeliveryError::DeliveryNotFound),
1581 ErrorCode::Conflict if error.message().contains("revision is stale") => {
1582 PluginError::domain(admin::RetryDeliveryError::StaleRevision)
1583 }
1584 ErrorCode::Conflict if error.message().contains("not eligible") => {
1585 PluginError::domain(admin::RetryDeliveryError::RetryNotAllowed)
1586 }
1587 ErrorCode::Conflict => PluginError::domain(admin::RetryDeliveryError::IdempotencyConflict),
1588 ErrorCode::Validation | ErrorCode::EvidenceOverflow | ErrorCode::Internal => {
1589 PluginError::runtime(runtime(error))
1590 }
1591 }
1592}
1593
1594async fn resolve_secret(
1595 secrets: &lenso_capability_secrets::SecretsClient,
1596 context: Ctx,
1597 reference: &str,
1598) -> Result<Zeroizing<String>, RuntimeFailure> {
1599 secrets
1600 .resolve_with_context(
1601 context,
1602 ResolveRequest {
1603 reference: reference.to_owned(),
1604 },
1605 )
1606 .await
1607 .map(|response| Zeroizing::new(response.value))
1608 .map_err(|error| match error {
1609 SecretsInvocationError::Domain(_) => RuntimeFailure::PluginFailure {
1610 detail: format!("secret `{reference}` was rejected"),
1611 },
1612 SecretsInvocationError::Runtime(error) => error,
1613 })
1614}
1615
1616fn valid_secret_reference(value: &str) -> bool {
1617 !value.is_empty()
1618 && value.len() <= 256
1619 && !value.starts_with('/')
1620 && !value.ends_with('/')
1621 && value.split('/').all(|segment| {
1622 !segment.is_empty()
1623 && segment != "."
1624 && segment != ".."
1625 && segment
1626 .bytes()
1627 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
1628 })
1629}
1630
1631fn valid_instance(value: &str) -> bool {
1632 !value.is_empty()
1633 && value.len() <= 160
1634 && value
1635 .bytes()
1636 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'/'))
1637}
1638
1639fn parse_time(value: &str) -> Result<DateTime<Utc>, chrono::ParseError> {
1640 DateTime::parse_from_rfc3339(value).map(|value| value.with_timezone(&Utc))
1641}
1642
1643fn valid_time(value: &str) -> bool {
1644 required_bounded(value, 1, 64) && parse_time(value).is_ok()
1645}
1646
1647pub(crate) fn format_time(value: DateTime<Utc>) -> Result<String, RuntimeFailure> {
1648 if !(0..=9_999).contains(&value.year()) {
1649 return Err(runtime(
1650 "stored Notification timestamp is outside the RFC 3339 four-digit year range",
1651 ));
1652 }
1653 let formatted = value.to_rfc3339_opts(SecondsFormat::Millis, true);
1654 if !valid_time(&formatted) {
1655 return Err(runtime(
1656 "stored Notification timestamp violates its bounded Capability Contract",
1657 ));
1658 }
1659 Ok(formatted)
1660}
1661
1662fn runtime(error: impl fmt::Display) -> RuntimeFailure {
1663 RuntimeFailure::PluginFailure {
1664 detail: error.to_string(),
1665 }
1666}
1667
1668#[cfg(test)]
1669mod tests {
1670 use std::{cell::RefCell, rc::Rc, time::Duration};
1671
1672 use lenso_app_plan::{
1673 AppComposition, CapabilityBinding, CapabilityEndpointPlan, CapabilityRequirementPlan,
1674 PluginInstancePlan, ResolvedAppPlan,
1675 };
1676 use lenso_kernel::{
1677 ActivateContext, CancellationToken, DeterministicDriver, InvocationContext, Kernel,
1678 NativeRequestEndpoint, NativeRequestFuture, PluginFuture, PluginLifecycle, RuntimeFailure,
1679 ShutdownOutcome,
1680 };
1681 use lenso_native_adapter::{
1682 NativePluginFactory, NativePluginFactoryContext, NativePluginInstance, NativePluginRegistry,
1683 };
1684
1685 use super::*;
1686 use crate::domain::DeliveryStatus as StoredDeliveryStatus;
1687
1688 const CONSUMER_PACKAGE_ID: &str = "test.notification-consumer";
1689 const FIXTURE_PROVIDER_PACKAGE_ID: &str = "test.notification-transactional-provider";
1690 const EMPTY_PACKAGE_ID: &str = "test.without-notification";
1691 const SECRETS_PACKAGE_ID: &str = "test.notification-secrets";
1692 const EMAIL_PACKAGE_ID: &str = "test.notification-email-dispatch";
1693 const TEMPLATE_PACKAGE_ID: &str = "test.notification-template";
1694
1695 #[derive(Clone, Copy, Debug)]
1696 enum FixtureOutcome {
1697 Success,
1698 Domain,
1699 Runtime,
1700 }
1701
1702 type InvocationResult = Result<
1703 transactional::CreateOrganizationInvitationResponse,
1704 transactional::TransactionalCreateOrganizationInvitationInvocationError,
1705 >;
1706
1707 #[derive(Clone, Debug)]
1708 struct FixtureTransactionalProvider {
1709 outcome: FixtureOutcome,
1710 }
1711
1712 impl transactional::TransactionalProvider for FixtureTransactionalProvider {
1713 fn create_access_request_notification(
1714 &self,
1715 _context: InvocationContext,
1716 _request: transactional::CreateAccessRequestNotificationRequest,
1717 ) -> NativeRequestFuture<transactional::TransactionalCreateAccessRequestNotification>
1718 {
1719 Box::pin(std::future::ready(Ok(Ok(
1720 transactional::CreateAccessRequestNotificationResponse {
1721 delivery_id: "ntf_dlv_access_fixture".to_owned(),
1722 idempotent_replay: false,
1723 intent_id: "ntf_int_access_fixture".to_owned(),
1724 status: transactional::CreateAccessRequestNotificationResponseStatus::Queued,
1725 },
1726 ))))
1727 }
1728
1729 fn create_organization_invitation(
1730 &self,
1731 _context: InvocationContext,
1732 _request: transactional::CreateOrganizationInvitationRequest,
1733 ) -> NativeRequestFuture<transactional::TransactionalCreateOrganizationInvitation> {
1734 let result = match self.outcome {
1735 FixtureOutcome::Success => {
1736 Ok(Ok(transactional::CreateOrganizationInvitationResponse {
1737 delivery_id: "ntf_dlv_fixture".to_owned(),
1738 idempotent_replay: false,
1739 intent_id: "ntf_int_fixture".to_owned(),
1740 status: transactional::CreateOrganizationInvitationResponseStatus::Queued,
1741 }))
1742 }
1743 FixtureOutcome::Domain => Ok(Err(
1744 transactional::CreateOrganizationInvitationError::Unauthorized,
1745 )),
1746 FixtureOutcome::Runtime => Err(RuntimeFailure::PluginFailure {
1747 detail: "fixture notification storage unavailable".to_owned(),
1748 }),
1749 };
1750 Box::pin(std::future::ready(result))
1751 }
1752
1753 fn observe_invitation_lifecycle(
1754 &self,
1755 _context: InvocationContext,
1756 _request: transactional::ObserveInvitationLifecycleRequest,
1757 ) -> NativeRequestFuture<transactional::TransactionalObserveInvitationLifecycle> {
1758 Box::pin(std::future::ready(Ok(Ok(
1759 transactional::ObserveInvitationLifecycleResponse { recorded: true },
1760 ))))
1761 }
1762 }
1763
1764 #[derive(Clone, Debug)]
1765 struct FixtureProviderFactory {
1766 outcome: FixtureOutcome,
1767 }
1768
1769 impl NativePluginFactory for FixtureProviderFactory {
1770 fn package_id(&self) -> &'static str {
1771 FIXTURE_PROVIDER_PACKAGE_ID
1772 }
1773
1774 fn instantiate(
1775 &self,
1776 _context: NativePluginFactoryContext<'_>,
1777 ) -> Result<NativePluginInstance, RuntimeFailure> {
1778 let endpoint = Rc::new(transactional::TransactionalEndpoint::new(
1779 FixtureTransactionalProvider {
1780 outcome: self.outcome,
1781 },
1782 )) as Rc<dyn NativeRequestEndpoint>;
1783 Ok(NativePluginInstance::new(vec![endpoint]))
1784 }
1785 }
1786
1787 #[derive(Clone, Debug)]
1788 struct ConsumerFactory {
1789 observed: Rc<RefCell<Option<InvocationResult>>>,
1790 }
1791
1792 impl NativePluginFactory for ConsumerFactory {
1793 fn package_id(&self) -> &'static str {
1794 CONSUMER_PACKAGE_ID
1795 }
1796
1797 fn instantiate(
1798 &self,
1799 _context: NativePluginFactoryContext<'_>,
1800 ) -> Result<NativePluginInstance, RuntimeFailure> {
1801 Ok(NativePluginInstance::with_lifecycle(
1802 Vec::new(),
1803 ConsumerLifecycle {
1804 observed: self.observed.clone(),
1805 },
1806 ))
1807 }
1808 }
1809
1810 #[derive(Clone, Debug)]
1811 struct ConsumerLifecycle {
1812 observed: Rc<RefCell<Option<InvocationResult>>>,
1813 }
1814
1815 impl PluginLifecycle for ConsumerLifecycle {
1816 fn activate(&self, context: ActivateContext) -> PluginFuture {
1817 let client =
1818 transactional::TransactionalClient::from_dependencies(context.dependencies());
1819 let observed = self.observed.clone();
1820 Box::pin(async move {
1821 let client = client?;
1822 observed.replace(Some(
1823 client
1824 .create_organization_invitation(generated_request())
1825 .await,
1826 ));
1827 Ok(())
1828 })
1829 }
1830 }
1831
1832 #[derive(Clone, Copy, Debug)]
1833 struct EmptyFactory(&'static str);
1834
1835 impl NativePluginFactory for EmptyFactory {
1836 fn package_id(&self) -> &'static str {
1837 self.0
1838 }
1839
1840 fn instantiate(
1841 &self,
1842 _context: NativePluginFactoryContext<'_>,
1843 ) -> Result<NativePluginInstance, RuntimeFailure> {
1844 Ok(NativePluginInstance::default())
1845 }
1846 }
1847
1848 #[test]
1849 fn generated_descriptor_declares_business_roles_and_exact_dependencies() {
1850 let descriptor: serde_json::Value = serde_json::from_str(PLUGIN_DESCRIPTOR_JSON)
1851 .expect("generated Plugin descriptor must be JSON");
1852 assert_eq!(PACKAGE_ID, "lenso.notification");
1853 assert_eq!(descriptor["plugin_id"], PACKAGE_ID);
1854 assert_eq!(descriptor["root_slot"], "notifications");
1855 let provided = descriptor["provided_capabilities"]
1856 .as_array()
1857 .expect("provided capabilities")
1858 .iter()
1859 .map(|entry| entry["capability_id"].as_str().expect("capability id"))
1860 .collect::<Vec<_>>();
1861 assert_eq!(
1862 provided,
1863 vec![
1864 transactional::CAPABILITY_ID,
1865 delivery::CAPABILITY_ID,
1866 admin::CAPABILITY_ID,
1867 ]
1868 );
1869 let required = descriptor["required_capabilities"]
1870 .as_array()
1871 .expect("required capabilities")
1872 .iter()
1873 .map(|entry| entry["capability_id"].as_str().expect("capability id"))
1874 .collect::<Vec<_>>();
1875 assert_eq!(
1876 required,
1877 vec![
1878 lenso_capability_secrets::CAPABILITY_ID,
1879 email::CAPABILITY_ID,
1880 notification_template::CAPABILITY_ID,
1881 ]
1882 );
1883 let linked = NativePluginRegistry::new()
1884 .with_linked_factories()
1885 .factories()
1886 .filter(|factory| factory.package_id() == PACKAGE_ID)
1887 .count();
1888 assert_eq!(linked, 1);
1889 }
1890
1891 #[test]
1892 fn template_requests_are_exact_versioned_and_event_typed() {
1893 let expires_at = parse_time("2026-09-01T00:00:00Z").expect("timestamp");
1894 let invitation = CreateTransactionalEmailIntent {
1895 source: IntentSource {
1896 module_id: "organization-blue".to_owned(),
1897 entity_type: "organization_invitation".to_owned(),
1898 entity_id: "invite_1".to_owned(),
1899 },
1900 recipient: EmailRecipient {
1901 address: "member@example.com".to_owned(),
1902 display_name: Some(" Member ".to_owned()),
1903 locale: "en-US".to_owned(),
1904 },
1905 template: OrganizationInvitationTemplateV1 {
1906 organization_id: "org_1".to_owned(),
1907 organization_name: " Acme ".to_owned(),
1908 invitation_id: "invite_1".to_owned(),
1909 invitation_url: "https://example.test/invite".to_owned(),
1910 inviter_display_name: None,
1911 role_name: Some(" Member ".to_owned()),
1912 expires_at,
1913 },
1914 idempotency_key: "organization-invitation:invite_1".to_owned(),
1915 correlation_id: "corr_1".to_owned(),
1916 causation_id: None,
1917 requested_by: None,
1918 };
1919 let request = organization_invitation_render_request(&invitation);
1920 assert_eq!(request.template_id, "organization-invitation");
1921 assert_eq!(request.version.as_deref(), Some("v1"));
1922 assert_eq!(request.locale, "en-US");
1923 let variables = request
1924 .variables
1925 .into_iter()
1926 .map(|item| (item.name, item.value))
1927 .collect::<std::collections::BTreeMap<_, _>>();
1928 assert_eq!(variables.len(), 7);
1929 assert_eq!(variables["organization_name"], "Acme");
1930 assert_eq!(variables["recipient_display_name"], "Member");
1931 assert_eq!(variables["inviter_display_name"], "");
1932 assert_eq!(variables["expires_at"], "2026-09-01T00:00:00Z");
1933
1934 let mut access = CreateAccessRequestNotificationIntent {
1935 source: IntentSource {
1936 module_id: "access-request-blue".to_owned(),
1937 entity_type: "access_request".to_owned(),
1938 entity_id: "ar_1".to_owned(),
1939 },
1940 recipient: EmailRecipient {
1941 address: "member@example.com".to_owned(),
1942 display_name: None,
1943 locale: "en".to_owned(),
1944 },
1945 template: AccessRequestNotificationTemplateV1 {
1946 request_id: "ar_1".to_owned(),
1947 organization_id: "org_1".to_owned(),
1948 event: AccessRequestNotificationEvent::Submitted,
1949 role: AccessRequestRoleV1 {
1950 role_id: "role_member".to_owned(),
1951 display_name: None,
1952 },
1953 scope: AccessRequestScopeV1 {
1954 kind: "organization".to_owned(),
1955 id: "org_1".to_owned(),
1956 display_name: None,
1957 },
1958 expires_at: Some(expires_at),
1959 },
1960 idempotency_key: "access-request:ar_1:submitted".to_owned(),
1961 correlation_id: "corr_2".to_owned(),
1962 causation_id: None,
1963 requested_by: None,
1964 };
1965 let submitted = access_request_render_request(&access);
1966 assert_eq!(submitted.template_id, "access-request-submitted");
1967 assert!(
1968 submitted
1969 .variables
1970 .iter()
1971 .any(|item| item.name == "expires_at")
1972 );
1973
1974 access.template.event = AccessRequestNotificationEvent::Denied;
1975 access.template.expires_at = None;
1976 let denied = access_request_render_request(&access);
1977 assert_eq!(denied.template_id, "access-request-denied");
1978 assert!(
1979 denied
1980 .variables
1981 .iter()
1982 .all(|item| item.name != "expires_at")
1983 );
1984 }
1985
1986 #[test]
1987 fn template_render_failures_preserve_runtime_and_fail_closed_on_domain_results() {
1988 let runtime = RuntimeFailure::Unavailable {
1989 capability: notification_template::CAPABILITY_ID,
1990 };
1991 assert!(matches!(
1992 map_template_render_error(
1993 notification_template::NotificationTemplateRenderInvocationError::Runtime(runtime)
1994 ),
1995 RuntimeFailure::Unavailable { capability }
1996 if capability == notification_template::CAPABILITY_ID
1997 ));
1998 assert!(matches!(
1999 map_template_render_error(
2000 notification_template::NotificationTemplateRenderInvocationError::Domain(
2001 notification_template::RenderError::NotFound,
2002 )
2003 ),
2004 RuntimeFailure::PluginFailure { .. }
2005 ));
2006 assert!(matches!(
2007 map_template_render_error(
2008 notification_template::NotificationTemplateRenderInvocationError::Domain(
2009 notification_template::RenderError::UnexpectedVariable,
2010 )
2011 ),
2012 RuntimeFailure::ProtocolViolation { capability }
2013 if capability == notification_template::CAPABILITY_ID
2014 ));
2015 }
2016
2017 #[test]
2018 fn generated_client_provider_and_plan_preserve_success_domain_and_runtime_lanes() {
2019 let success = run_generated_fixture(FixtureOutcome::Success);
2020 assert!(matches!(
2021 success,
2022 Ok(transactional::CreateOrganizationInvitationResponse {
2023 status: transactional::CreateOrganizationInvitationResponseStatus::Queued,
2024 ..
2025 })
2026 ));
2027 let domain = run_generated_fixture(FixtureOutcome::Domain);
2028 assert!(matches!(
2029 domain,
2030 Err(
2031 transactional::TransactionalCreateOrganizationInvitationInvocationError::Domain(
2032 transactional::CreateOrganizationInvitationError::Unauthorized
2033 )
2034 )
2035 ));
2036 let runtime = run_generated_fixture(FixtureOutcome::Runtime);
2037 assert!(matches!(
2038 runtime,
2039 Err(
2040 transactional::TransactionalCreateOrganizationInvitationInvocationError::Runtime(
2041 RuntimeFailure::PluginFailure { detail }
2042 )
2043 ) if detail == "fixture notification storage unavailable"
2044 ));
2045 }
2046
2047 #[test]
2048 fn caller_identity_is_derived_and_cannot_be_spoofed_by_payload() {
2049 let request = generated_request();
2050 let source = intent_source("organization-blue".to_owned(), request.source);
2051 assert_eq!(source.module_id, "organization-blue");
2052
2053 let transactional_schema: serde_json::Value =
2054 serde_json::from_str(transactional::CREATE_ORGANIZATION_INVITATION_REQUEST_SCHEMA_JSON)
2055 .expect("transactional request schema");
2056 assert!(
2057 transactional_schema["properties"]["source"]["properties"]
2058 .get("plugin_id")
2059 .is_none()
2060 );
2061
2062 let receipt_request = delivery::ObserveReceiptRequest {
2063 attempt_id: "attempt".to_owned(),
2064 delivery_id: "delivery".to_owned(),
2065 digest: format!("sha256:{}", "a".repeat(64)),
2066 kind: delivery::ObserveReceiptRequestKind::Delivered,
2067 observation_id: "observation".to_owned(),
2068 observed_at: "2026-08-30T00:00:00Z".to_owned(),
2069 remote_id: "remote".to_owned(),
2070 run_id: "run".to_owned(),
2071 };
2072 let receipt = receipt_observation(
2073 "email-provider-blue".to_owned(),
2074 &receipt_request,
2075 ReceiptKind::Delivered,
2076 Utc::now(),
2077 );
2078 assert_eq!(receipt.source, "email-provider-blue");
2079 let delivery_schema: serde_json::Value =
2080 serde_json::from_str(delivery::OBSERVE_RECEIPT_REQUEST_SCHEMA_JSON)
2081 .expect("receipt request schema");
2082 assert!(delivery_schema["properties"].get("source").is_none());
2083 }
2084
2085 #[test]
2086 fn observation_authorities_are_disjoint_per_operation() {
2087 let plugin = unprepared_plugin();
2088 assert!(matches!(
2089 futures::executor::block_on(plugin.dispatch_due(
2090 caller_context("email-provider-blue"),
2091 delivery::DispatchDueRequest {},
2092 )),
2093 Err(PluginError::Domain(
2094 delivery::DispatchDueError::Unauthorized
2095 ))
2096 ));
2097
2098 let request = delivery::ObserveReceiptRequest {
2099 attempt_id: "attempt".to_owned(),
2100 delivery_id: "delivery".to_owned(),
2101 digest: format!("sha256:{}", "a".repeat(64)),
2102 kind: delivery::ObserveReceiptRequestKind::Delivered,
2103 observation_id: "observation".to_owned(),
2104 observed_at: "2026-08-30T00:00:00Z".to_owned(),
2105 remote_id: "remote".to_owned(),
2106 run_id: "run".to_owned(),
2107 };
2108 assert!(matches!(
2109 futures::executor::block_on(
2110 plugin.observe_receipt(caller_context("notification-worker-blue"), request,)
2111 ),
2112 Err(PluginError::Domain(
2113 delivery::ObserveReceiptError::Unauthorized
2114 ))
2115 ));
2116
2117 let lifecycle = transactional::ObserveInvitationLifecycleRequest {
2118 invitation_id: "invite".to_owned(),
2119 lifecycle: transactional::ObserveInvitationLifecycleRequestLifecycle::Revoked,
2120 observation_id: "observation".to_owned(),
2121 observed_at: "2026-08-30T00:00:00Z".to_owned(),
2122 organization_id: "organization".to_owned(),
2123 };
2124 assert!(matches!(
2125 futures::executor::block_on(plugin.observe_invitation_lifecycle(
2126 caller_context("notification-worker-blue"),
2127 lifecycle,
2128 )),
2129 Err(PluginError::Domain(
2130 transactional::ObserveInvitationLifecycleError::Unauthorized
2131 ))
2132 ));
2133 }
2134
2135 #[test]
2136 fn native_typed_requests_revalidate_every_bound_before_state_or_dependencies() {
2137 let now = parse_time("2026-08-30T00:00:00Z").expect("fixed validation time");
2138 for request in invalid_create_requests() {
2139 assert!(!valid_create_request(&request, now));
2140 assert!(matches!(
2141 futures::executor::block_on(
2142 unprepared_plugin().create_organization_invitation(
2143 caller_context("organization-blue"),
2144 request,
2145 )
2146 ),
2147 Err(PluginError::Domain(
2148 transactional::CreateOrganizationInvitationError::InvalidIntent
2149 ))
2150 ));
2151 }
2152
2153 let mut invalid_access_request = generated_access_request();
2154 invalid_access_request.idempotency_key = "caller-selected".to_owned();
2155 assert!(!valid_access_request_notification_request(
2156 &invalid_access_request,
2157 now
2158 ));
2159 assert!(matches!(
2160 futures::executor::block_on(unprepared_plugin().create_access_request_notification(
2161 caller_context("organization-blue"),
2162 invalid_access_request,
2163 )),
2164 Err(PluginError::Domain(
2165 transactional::CreateAccessRequestNotificationError::InvalidIntent
2166 ))
2167 ));
2168
2169 let lifecycle = transactional::ObserveInvitationLifecycleRequest {
2170 invitation_id: "invite".to_owned(),
2171 lifecycle: transactional::ObserveInvitationLifecycleRequestLifecycle::Revoked,
2172 observation_id: "observation".to_owned(),
2173 observed_at: "2026-08-30T00:00:00Z".to_owned(),
2174 organization_id: "organization".to_owned(),
2175 };
2176 assert!(valid_lifecycle_request(&lifecycle));
2177 for request in [
2178 transactional::ObserveInvitationLifecycleRequest {
2179 observation_id: "x".repeat(241),
2180 ..lifecycle.clone()
2181 },
2182 transactional::ObserveInvitationLifecycleRequest {
2183 organization_id: "x".repeat(241),
2184 ..lifecycle.clone()
2185 },
2186 transactional::ObserveInvitationLifecycleRequest {
2187 invitation_id: "x".repeat(241),
2188 ..lifecycle.clone()
2189 },
2190 transactional::ObserveInvitationLifecycleRequest {
2191 observed_at: "not-a-time".to_owned(),
2192 ..lifecycle
2193 },
2194 ] {
2195 assert!(!valid_lifecycle_request(&request));
2196 assert!(matches!(
2197 futures::executor::block_on(
2198 unprepared_plugin().observe_invitation_lifecycle(
2199 caller_context("organization-blue"),
2200 request,
2201 )
2202 ),
2203 Err(PluginError::Domain(
2204 transactional::ObserveInvitationLifecycleError::InvalidObservation
2205 ))
2206 ));
2207 }
2208
2209 let receipt = delivery::ObserveReceiptRequest {
2210 attempt_id: "attempt".to_owned(),
2211 delivery_id: "delivery".to_owned(),
2212 digest: format!("sha256:{}", "a".repeat(64)),
2213 kind: delivery::ObserveReceiptRequestKind::Delivered,
2214 observation_id: "observation".to_owned(),
2215 observed_at: "2026-08-30T00:00:00Z".to_owned(),
2216 remote_id: "remote".to_owned(),
2217 run_id: "run".to_owned(),
2218 };
2219 assert!(valid_receipt_request(&receipt));
2220 for request in [
2221 delivery::ObserveReceiptRequest {
2222 observation_id: "x".repeat(241),
2223 ..receipt.clone()
2224 },
2225 delivery::ObserveReceiptRequest {
2226 delivery_id: "x".repeat(161),
2227 ..receipt.clone()
2228 },
2229 delivery::ObserveReceiptRequest {
2230 attempt_id: "x".repeat(161),
2231 ..receipt.clone()
2232 },
2233 delivery::ObserveReceiptRequest {
2234 run_id: "x".repeat(161),
2235 ..receipt.clone()
2236 },
2237 delivery::ObserveReceiptRequest {
2238 observed_at: "not-a-time".to_owned(),
2239 ..receipt.clone()
2240 },
2241 delivery::ObserveReceiptRequest {
2242 remote_id: "x".repeat(321),
2243 ..receipt.clone()
2244 },
2245 delivery::ObserveReceiptRequest {
2246 digest: "sha256:not-a-digest".to_owned(),
2247 ..receipt
2248 },
2249 ] {
2250 assert!(!valid_receipt_request(&request));
2251 assert!(matches!(
2252 futures::executor::block_on(
2253 unprepared_plugin()
2254 .observe_receipt(caller_context("email-provider-blue"), request,)
2255 ),
2256 Err(PluginError::Domain(
2257 delivery::ObserveReceiptError::InvalidReceipt
2258 ))
2259 ));
2260 }
2261
2262 for request in [
2263 admin::ListDeliveriesRequest {
2264 cursor: None,
2265 limit: Some(0),
2266 status: None,
2267 },
2268 admin::ListDeliveriesRequest {
2269 cursor: None,
2270 limit: Some(201),
2271 status: None,
2272 },
2273 admin::ListDeliveriesRequest {
2274 cursor: Some("x".repeat(161)),
2275 limit: None,
2276 status: None,
2277 },
2278 admin::ListDeliveriesRequest {
2279 cursor: Some(String::new()),
2280 limit: None,
2281 status: None,
2282 },
2283 ] {
2284 assert!(!valid_list_request(&request));
2285 assert!(matches!(
2286 futures::executor::block_on(
2287 unprepared_plugin().list_deliveries(caller_context("console-blue"), request,)
2288 ),
2289 Err(PluginError::Domain(
2290 admin::ListDeliveriesError::InvalidFilter
2291 ))
2292 ));
2293 }
2294
2295 for delivery_id in [String::new(), "x".repeat(161)] {
2296 assert!(!required_bounded(&delivery_id, 1, 160));
2297 assert!(matches!(
2298 futures::executor::block_on(unprepared_plugin().get_delivery(
2299 caller_context("console-blue"),
2300 admin::GetDeliveryRequest { delivery_id },
2301 )),
2302 Err(PluginError::Domain(admin::GetDeliveryError::InvalidRequest))
2303 ));
2304 }
2305
2306 for request in [
2307 admin::RetryDeliveryRequest {
2308 delivery_id: "x".repeat(161),
2309 idempotency_key: "retry".to_owned(),
2310 revision: 1,
2311 },
2312 admin::RetryDeliveryRequest {
2313 delivery_id: "delivery".to_owned(),
2314 idempotency_key: "x".repeat(241),
2315 revision: 1,
2316 },
2317 admin::RetryDeliveryRequest {
2318 delivery_id: "delivery".to_owned(),
2319 idempotency_key: "retry".to_owned(),
2320 revision: 0,
2321 },
2322 admin::RetryDeliveryRequest {
2323 delivery_id: "delivery".to_owned(),
2324 idempotency_key: "retry".to_owned(),
2325 revision: MAX_SAFE_WIRE_INTEGER + 1,
2326 },
2327 ] {
2328 assert!(!valid_retry_request(&request));
2329 assert!(matches!(
2330 futures::executor::block_on(
2331 unprepared_plugin().retry_delivery(caller_context("console-blue"), request,)
2332 ),
2333 Err(PluginError::Domain(
2334 admin::RetryDeliveryError::InvalidRequest
2335 ))
2336 ));
2337 }
2338
2339 let maximum_revision = admin::RetryDeliveryRequest {
2340 delivery_id: "delivery".to_owned(),
2341 idempotency_key: "retry".to_owned(),
2342 revision: MAX_SAFE_WIRE_INTEGER,
2343 };
2344 assert!(valid_retry_request(&maximum_revision));
2345 assert!(matches!(
2346 futures::executor::block_on(
2347 unprepared_plugin()
2348 .retry_delivery(caller_context("console-blue"), maximum_revision,)
2349 ),
2350 Err(PluginError::Domain(
2351 admin::RetryDeliveryError::RetryNotAllowed
2352 ))
2353 ));
2354 }
2355
2356 #[test]
2357 fn native_admin_outputs_reject_unbounded_or_nonportable_storage_values() {
2358 let now = Utc::now();
2359 let summary = DeliverySummary {
2360 id: "delivery".to_owned(),
2361 recipient_mask: "a***@example.test".to_owned(),
2362 template_id: "organization-invitation".to_owned(),
2363 template_version: "v1".to_owned(),
2364 locale: "en".to_owned(),
2365 status: "queued".to_owned(),
2366 revision: 1,
2367 attempt_count: 0,
2368 max_attempts: 4,
2369 redacted_preview: "Invitation".to_owned(),
2370 content_digest: format!("sha256:{}", "a".repeat(64)),
2371 correlation_id: "story".to_owned(),
2372 next_attempt_at: Some(now),
2373 final_reason: None,
2374 created_at: now,
2375 updated_at: now,
2376 };
2377 assert!(admin_delivery(summary.clone()).is_ok());
2378 assert!(
2379 admin_delivery(DeliverySummary {
2380 revision: MAX_SAFE_WIRE_INTEGER + 1,
2381 ..summary.clone()
2382 })
2383 .is_err()
2384 );
2385 assert!(
2386 admin_delivery(DeliverySummary {
2387 id: "x".repeat(161),
2388 ..summary.clone()
2389 })
2390 .is_err()
2391 );
2392
2393 let attempt = AttemptRecord {
2394 id: "attempt".to_owned(),
2395 sequence: 1,
2396 function_run_id: "run".to_owned(),
2397 status: "dispatching".to_owned(),
2398 provider: None,
2399 remote_receipt_id: None,
2400 failure_code: None,
2401 failure_classification: None,
2402 started_at: now,
2403 completed_at: None,
2404 };
2405 assert!(admin_attempt(attempt.clone()).is_ok());
2406 assert!(
2407 admin_attempt(AttemptRecord {
2408 function_run_id: "x".repeat(161),
2409 ..attempt.clone()
2410 })
2411 .is_err()
2412 );
2413
2414 let detail = DeliveryDetail {
2415 delivery: summary,
2416 attempts: vec![attempt; ADMIN_ATTEMPT_LIMIT + 1],
2417 receipts: Vec::new(),
2418 retry_requests: Vec::new(),
2419 };
2420 assert!(admin_detail(detail).is_err());
2421
2422 let mapped = map_get_error(NotificationError::new(
2423 ErrorCode::EvidenceOverflow,
2424 "bounded evidence overflow fixture",
2425 ));
2426 assert!(matches!(
2427 mapped,
2428 PluginError::Domain(admin::GetDeliveryError::EvidenceOverflow)
2429 ));
2430
2431 assert!(
2432 admin_retry(RetryResult {
2433 delivery_id: "delivery".to_owned(),
2434 revision: MAX_SAFE_WIRE_INTEGER + 1,
2435 status: "retry_scheduled".to_owned(),
2436 scheduled_at: now,
2437 idempotent_replay: false,
2438 })
2439 .is_err()
2440 );
2441 }
2442
2443 #[test]
2444 fn native_email_response_validation_rejects_invalid_bounds_and_cross_fields() {
2445 let work = dispatch_work();
2446 let email_provider_instance = "email-provider-blue";
2447 let accepted = email::DispatchResponse {
2448 failure: None,
2449 observed_at: "2026-08-30T00:00:00Z".to_owned(),
2450 outcome: DispatchResponseOutcome::Accepted,
2451 provider: "email-provider-blue".to_owned(),
2452 remote_receipt: Some(email::DispatchResponseRemoteReceipt {
2453 digest: format!("sha256:{}", "a".repeat(64)),
2454 remote_id: "remote".to_owned(),
2455 source: "email-provider-blue".to_owned(),
2456 }),
2457 };
2458 let accepted_observation =
2459 dispatch_observation(&work, email_provider_instance, accepted.clone())
2460 .expect("bound Provider response is valid");
2461 assert_eq!(accepted_observation.provider, email_provider_instance);
2462 assert_eq!(
2463 accepted_observation
2464 .remote_receipt
2465 .expect("accepted receipt")
2466 .source,
2467 email_provider_instance
2468 );
2469
2470 let temporary = email::DispatchResponse {
2471 failure: Some(email::DispatchResponseFailure {
2472 classification: "temporary_failure".to_owned(),
2473 code: "rate_limited".to_owned(),
2474 retry_after_ms: Some(1_000),
2475 }),
2476 observed_at: "2026-08-30T00:00:00Z".to_owned(),
2477 outcome: DispatchResponseOutcome::TemporaryFailure,
2478 provider: "email-provider-blue".to_owned(),
2479 remote_receipt: None,
2480 };
2481 assert!(dispatch_observation(&work, email_provider_instance, temporary.clone()).is_ok());
2482
2483 let invalid = [
2484 email::DispatchResponse {
2485 provider: String::new(),
2486 ..accepted.clone()
2487 },
2488 email::DispatchResponse {
2489 provider: "different-provider".to_owned(),
2490 ..accepted.clone()
2491 },
2492 email::DispatchResponse {
2493 observed_at: "not-a-time".to_owned(),
2494 ..accepted.clone()
2495 },
2496 email::DispatchResponse {
2497 observed_at: format!("2026-08-30T00:00:00.{}Z", "1".repeat(50)),
2498 ..accepted.clone()
2499 },
2500 email::DispatchResponse {
2501 failure: Some(email::DispatchResponseFailure {
2502 classification: "permanent_failure".to_owned(),
2503 code: "should-not-exist".to_owned(),
2504 retry_after_ms: None,
2505 }),
2506 ..accepted.clone()
2507 },
2508 email::DispatchResponse {
2509 outcome: DispatchResponseOutcome::PermanentFailure,
2510 remote_receipt: None,
2511 failure: None,
2512 ..accepted.clone()
2513 },
2514 email::DispatchResponse {
2515 outcome: DispatchResponseOutcome::DeliveryUnknown,
2516 remote_receipt: None,
2517 failure: None,
2518 ..accepted.clone()
2519 },
2520 email::DispatchResponse {
2521 remote_receipt: Some(email::DispatchResponseRemoteReceipt {
2522 remote_id: String::new(),
2523 ..accepted.remote_receipt.clone().expect("accepted receipt")
2524 }),
2525 ..accepted.clone()
2526 },
2527 email::DispatchResponse {
2528 remote_receipt: Some(email::DispatchResponseRemoteReceipt {
2529 remote_id: "x".repeat(321),
2530 ..accepted.remote_receipt.clone().expect("accepted receipt")
2531 }),
2532 ..accepted.clone()
2533 },
2534 email::DispatchResponse {
2535 remote_receipt: Some(email::DispatchResponseRemoteReceipt {
2536 source: "different-provider".to_owned(),
2537 ..accepted.remote_receipt.clone().expect("accepted receipt")
2538 }),
2539 ..accepted.clone()
2540 },
2541 email::DispatchResponse {
2542 remote_receipt: Some(email::DispatchResponseRemoteReceipt {
2543 digest: "sha256:not-a-digest".to_owned(),
2544 ..accepted.remote_receipt.clone().expect("accepted receipt")
2545 }),
2546 ..accepted.clone()
2547 },
2548 email::DispatchResponse {
2549 failure: Some(email::DispatchResponseFailure {
2550 retry_after_ms: Some(-1),
2551 ..temporary.failure.clone().expect("temporary failure")
2552 }),
2553 ..temporary.clone()
2554 },
2555 email::DispatchResponse {
2556 failure: Some(email::DispatchResponseFailure {
2557 retry_after_ms: Some(86_400_001),
2558 ..temporary.failure.clone().expect("temporary failure")
2559 }),
2560 ..temporary.clone()
2561 },
2562 email::DispatchResponse {
2563 failure: Some(email::DispatchResponseFailure {
2564 code: String::new(),
2565 ..temporary.failure.clone().expect("temporary failure")
2566 }),
2567 ..temporary.clone()
2568 },
2569 email::DispatchResponse {
2570 failure: Some(email::DispatchResponseFailure {
2571 code: "x".repeat(161),
2572 ..temporary.failure.clone().expect("temporary failure")
2573 }),
2574 ..temporary.clone()
2575 },
2576 email::DispatchResponse {
2577 failure: Some(email::DispatchResponseFailure {
2578 classification: String::new(),
2579 ..temporary.failure.clone().expect("temporary failure")
2580 }),
2581 ..temporary.clone()
2582 },
2583 email::DispatchResponse {
2584 failure: Some(email::DispatchResponseFailure {
2585 classification: "x".repeat(161),
2586 ..temporary.failure.clone().expect("temporary failure")
2587 }),
2588 ..temporary.clone()
2589 },
2590 email::DispatchResponse {
2591 failure: Some(email::DispatchResponseFailure {
2592 classification: "permanent_failure".to_owned(),
2593 ..temporary.failure.clone().expect("temporary failure")
2594 }),
2595 ..temporary.clone()
2596 },
2597 email::DispatchResponse {
2598 remote_receipt: accepted.remote_receipt,
2599 ..temporary
2600 },
2601 ];
2602 for response in invalid {
2603 assert!(matches!(
2604 dispatch_observation(&work, email_provider_instance, response),
2605 Err(RuntimeFailure::ProtocolViolation {
2606 capability: email::CAPABILITY_ID
2607 })
2608 ));
2609 }
2610 }
2611
2612 #[test]
2613 fn linked_factory_rejects_invalid_configuration_before_startup() {
2614 let driver = DeterministicDriver::new();
2615 let error = driver
2616 .run(Kernel::start_native(
2617 invalid_actual_plan(),
2618 driver.clone(),
2619 NativePluginRegistry::new()
2620 .with_linked_factories()
2621 .with_factory(EmptyFactory(SECRETS_PACKAGE_ID))
2622 .with_factory(EmptyFactory(EMAIL_PACKAGE_ID))
2623 .with_factory(EmptyFactory(TEMPLATE_PACKAGE_ID)),
2624 ))
2625 .expect_err("invalid Notification authority must reject startup");
2626 assert!(matches!(error, RuntimeFailure::InvalidResolvedPlan { .. }));
2627 }
2628
2629 #[test]
2630 fn removing_notification_selection_removes_runtime_behavior() {
2631 let plan = AppComposition::new(
2632 vec![PluginInstancePlan::new("empty", EMPTY_PACKAGE_ID)],
2633 Vec::new(),
2634 )
2635 .resolve()
2636 .expect("App without Notification must resolve");
2637 let driver = DeterministicDriver::new();
2638 let app = driver
2639 .run(Kernel::start_native(
2640 plan,
2641 driver.clone(),
2642 NativePluginRegistry::new()
2643 .with_linked_factories()
2644 .with_factory(EmptyFactory(EMPTY_PACKAGE_ID)),
2645 ))
2646 .expect("unselected Notification Plugin must be inert");
2647 assert_eq!(
2648 driver.run(app.shutdown(Duration::from_secs(1))),
2649 ShutdownOutcome::Clean
2650 );
2651 }
2652
2653 #[test]
2654 fn configuration_rejects_ambient_or_ambiguous_authority() {
2655 let config = NotificationConfig::new(
2656 "notification/database",
2657 "notification/snapshot-key",
2658 vec!["organization".to_owned()],
2659 vec!["notification-worker".to_owned()],
2660 vec!["email-provider".to_owned()],
2661 vec!["notification-console-http".to_owned()],
2662 )
2663 .expect("valid config");
2664 assert_eq!(config.schema, "notification");
2665
2666 let mut duplicate_secret = config.clone();
2667 duplicate_secret.snapshot_key_secret = duplicate_secret.database_url_secret.clone();
2668 assert_eq!(
2669 duplicate_secret.validate(),
2670 Err(NotificationConfigError::InvalidSecretReference)
2671 );
2672
2673 let mut ambient = config;
2674 ambient.admin_callers.clear();
2675 assert_eq!(
2676 ambient.validate(),
2677 Err(NotificationConfigError::InvalidCallers)
2678 );
2679 }
2680
2681 #[test]
2682 fn removing_notification_needs_no_kernel_branch() {
2683 let remaining = lenso_app_plan::AppComposition::new(
2684 vec![lenso_app_plan::PluginInstancePlan::new(
2685 "organization",
2686 "test.organization",
2687 )],
2688 vec![],
2689 )
2690 .resolve()
2691 .expect("App without Notification should resolve");
2692 assert_eq!(remaining.plugin_instances().len(), 1);
2693 assert!(remaining.capability_bindings().is_empty());
2694 }
2695
2696 #[test]
2697 fn unknown_dispatch_is_terminal_and_redaction_safe() {
2698 let work = DispatchWork {
2699 claim: crate::runtime::DispatchClaim {
2700 delivery_id: "delivery".to_owned(),
2701 attempt_id: "attempt".to_owned(),
2702 run_id: "run".to_owned(),
2703 },
2704 request: DispatchRequest {
2705 delivery_id: "delivery".to_owned(),
2706 attempt_id: "attempt".to_owned(),
2707 run_id: "run".to_owned(),
2708 idempotency_key: "attempt".to_owned(),
2709 recipient: email::DispatchRequestRecipient {
2710 address: "member@example.com".to_owned(),
2711 },
2712 message: email::DispatchRequestMessage {
2713 template_id: "organization-invitation".to_owned(),
2714 template_version: "v1".to_owned(),
2715 locale: "en".to_owned(),
2716 subject: "secret".to_owned(),
2717 text: "secret".to_owned(),
2718 html: "secret".to_owned(),
2719 content_digest: format!("sha256:{}", "a".repeat(64)),
2720 },
2721 correlation_id: "correlation".to_owned(),
2722 },
2723 };
2724 let observation =
2725 unknown_dispatch(&work, "email-provider-blue", Utc::now(), "runtime_failure");
2726 assert_eq!(observation.outcome, DispatchOutcome::DeliveryUnknown);
2727 assert!(observation.failure.is_some());
2728 assert!(!format!("{observation:?}").contains("member@example.com"));
2729 }
2730
2731 #[test]
2732 fn stored_delivery_unknown_never_maps_to_a_retry_state() {
2733 assert_eq!(
2734 delivery_status("delivery_unknown").unwrap(),
2735 delivery::DispatchDueResponseStatus::DeliveryUnknown
2736 );
2737 assert!(crate::domain::can_transition(
2738 StoredDeliveryStatus::Attempting,
2739 StoredDeliveryStatus::DeliveryUnknown
2740 ));
2741 assert!(!crate::domain::can_transition(
2742 StoredDeliveryStatus::DeliveryUnknown,
2743 StoredDeliveryStatus::RetryScheduled
2744 ));
2745 }
2746
2747 fn run_generated_fixture(outcome: FixtureOutcome) -> InvocationResult {
2748 let observed = Rc::new(RefCell::new(None));
2749 let driver = DeterministicDriver::new();
2750 let app = driver
2751 .run(Kernel::start_native(
2752 generated_fixture_plan(),
2753 driver.clone(),
2754 NativePluginRegistry::new()
2755 .with_factory(FixtureProviderFactory { outcome })
2756 .with_factory(ConsumerFactory {
2757 observed: observed.clone(),
2758 }),
2759 ))
2760 .expect("generated Notification fixture must start");
2761 let outcome = observed
2762 .borrow_mut()
2763 .take()
2764 .expect("generated Client consumer must observe an invocation");
2765 assert_eq!(
2766 driver.run(app.shutdown(Duration::from_secs(1))),
2767 ShutdownOutcome::Clean
2768 );
2769 outcome
2770 }
2771
2772 fn generated_fixture_plan() -> ResolvedAppPlan {
2773 let consumer = PluginInstancePlan::new("consumer", CONSUMER_PACKAGE_ID).with_requirement(
2774 CapabilityRequirementPlan::one(
2775 transactional::CAPABILITY_ID,
2776 transactional::DESCRIPTOR_VERSION,
2777 ),
2778 );
2779 let provider = PluginInstancePlan::new("notification", FIXTURE_PROVIDER_PACKAGE_ID)
2780 .with_capability(CapabilityEndpointPlan::new(
2781 transactional::CAPABILITY_ID,
2782 transactional::DESCRIPTOR_VERSION,
2783 [
2784 transactional::CREATE_ACCESS_REQUEST_NOTIFICATION_OPERATION,
2785 transactional::CREATE_ORGANIZATION_INVITATION_OPERATION,
2786 transactional::OBSERVE_INVITATION_LIFECYCLE_OPERATION,
2787 ],
2788 ));
2789 AppComposition::new(
2790 vec![consumer, provider],
2791 vec![CapabilityBinding::new(
2792 "consumer",
2793 transactional::CAPABILITY_ID,
2794 transactional::DESCRIPTOR_VERSION,
2795 "notification",
2796 )],
2797 )
2798 .resolve()
2799 .expect("generated Client/Provider Composition must resolve")
2800 }
2801
2802 fn invalid_actual_plan() -> ResolvedAppPlan {
2803 let invalid = NotificationConfig {
2804 schema: "notification".to_owned(),
2805 database_url_secret: "notification/database".to_owned(),
2806 snapshot_key_secret: "notification/snapshot-key".to_owned(),
2807 transactional_callers: Vec::new(),
2808 dispatch_callers: vec!["worker".to_owned()],
2809 receipt_callers: vec!["email-provider".to_owned()],
2810 admin_callers: vec!["admin".to_owned()],
2811 };
2812 let notification = PluginInstancePlan::new("notification", PACKAGE_ID)
2813 .with_configuration(serde_json::to_string(&invalid).expect("serialize invalid config"))
2814 .with_requirement(CapabilityRequirementPlan::one(
2815 lenso_capability_secrets::CAPABILITY_ID,
2816 lenso_capability_secrets::DESCRIPTOR_VERSION,
2817 ))
2818 .with_requirement(CapabilityRequirementPlan::one(
2819 email::CAPABILITY_ID,
2820 email::DESCRIPTOR_VERSION,
2821 ))
2822 .with_requirement(CapabilityRequirementPlan::one(
2823 notification_template::CAPABILITY_ID,
2824 notification_template::DESCRIPTOR_VERSION,
2825 ));
2826 let secrets = PluginInstancePlan::new("secrets", SECRETS_PACKAGE_ID).with_capability(
2827 CapabilityEndpointPlan::new(
2828 lenso_capability_secrets::CAPABILITY_ID,
2829 lenso_capability_secrets::DESCRIPTOR_VERSION,
2830 [lenso_capability_secrets::RESOLVE_OPERATION],
2831 ),
2832 );
2833 let email_provider = PluginInstancePlan::new("email", EMAIL_PACKAGE_ID).with_capability(
2834 CapabilityEndpointPlan::new(
2835 email::CAPABILITY_ID,
2836 email::DESCRIPTOR_VERSION,
2837 [email::DISPATCH_OPERATION],
2838 ),
2839 );
2840 let template_provider = PluginInstancePlan::new("templates", TEMPLATE_PACKAGE_ID)
2841 .with_capability(CapabilityEndpointPlan::new(
2842 notification_template::CAPABILITY_ID,
2843 notification_template::DESCRIPTOR_VERSION,
2844 [notification_template::RENDER_OPERATION],
2845 ));
2846 AppComposition::new(
2847 vec![notification, secrets, email_provider, template_provider],
2848 vec![
2849 CapabilityBinding::new(
2850 "notification",
2851 lenso_capability_secrets::CAPABILITY_ID,
2852 lenso_capability_secrets::DESCRIPTOR_VERSION,
2853 "secrets",
2854 ),
2855 CapabilityBinding::new(
2856 "notification",
2857 email::CAPABILITY_ID,
2858 email::DESCRIPTOR_VERSION,
2859 "email",
2860 ),
2861 CapabilityBinding::new(
2862 "notification",
2863 notification_template::CAPABILITY_ID,
2864 notification_template::DESCRIPTOR_VERSION,
2865 "templates",
2866 ),
2867 ],
2868 )
2869 .resolve()
2870 .expect("invalid configuration should pass structural Plan resolution")
2871 }
2872
2873 fn caller_context(caller: &str) -> InvocationContext {
2874 InvocationContext::new(1, None, CancellationToken::new())
2875 .with_caller_instance(caller.to_owned())
2876 }
2877
2878 fn unprepared_plugin() -> NotificationPlugin {
2879 NotificationPlugin {
2880 config: NotificationConfig::new(
2881 "notification/database",
2882 "notification/snapshot-key",
2883 vec!["organization-blue".to_owned()],
2884 vec!["notification-worker-blue".to_owned()],
2885 vec!["email-provider-blue".to_owned()],
2886 vec!["console-blue".to_owned()],
2887 )
2888 .expect("valid unprepared test Plugin"),
2889 secrets: Port::new(),
2890 email: Port::new(),
2891 templates: Port::new(),
2892 state: Rc::new(RefCell::new(None)),
2893 }
2894 }
2895
2896 fn invalid_create_requests() -> Vec<transactional::CreateOrganizationInvitationRequest> {
2897 let base = generated_request();
2898 vec![
2899 transactional::CreateOrganizationInvitationRequest {
2900 source: transactional::CreateOrganizationInvitationRequestSource {
2901 entity_type: "x".repeat(161),
2902 ..base.source.clone()
2903 },
2904 ..base.clone()
2905 },
2906 transactional::CreateOrganizationInvitationRequest {
2907 source: transactional::CreateOrganizationInvitationRequestSource {
2908 entity_id: "x".repeat(241),
2909 ..base.source.clone()
2910 },
2911 ..base.clone()
2912 },
2913 transactional::CreateOrganizationInvitationRequest {
2914 recipient: transactional::CreateOrganizationInvitationRequestRecipient {
2915 address: format!("a@{}", "x".repeat(319)),
2916 ..base.recipient.clone()
2917 },
2918 ..base.clone()
2919 },
2920 transactional::CreateOrganizationInvitationRequest {
2921 recipient: transactional::CreateOrganizationInvitationRequestRecipient {
2922 display_name: Some("x".repeat(241)),
2923 ..base.recipient.clone()
2924 },
2925 ..base.clone()
2926 },
2927 transactional::CreateOrganizationInvitationRequest {
2928 template: transactional::CreateOrganizationInvitationRequestTemplate {
2929 organization_id: "x".repeat(241),
2930 ..base.template.clone()
2931 },
2932 ..base.clone()
2933 },
2934 transactional::CreateOrganizationInvitationRequest {
2935 template: transactional::CreateOrganizationInvitationRequestTemplate {
2936 organization_name: "x".repeat(241),
2937 ..base.template.clone()
2938 },
2939 ..base.clone()
2940 },
2941 transactional::CreateOrganizationInvitationRequest {
2942 template: transactional::CreateOrganizationInvitationRequestTemplate {
2943 invitation_id: "x".repeat(241),
2944 ..base.template.clone()
2945 },
2946 ..base.clone()
2947 },
2948 transactional::CreateOrganizationInvitationRequest {
2949 template: transactional::CreateOrganizationInvitationRequestTemplate {
2950 invitation_url: format!("https://{}", "x".repeat(4_089)),
2951 ..base.template.clone()
2952 },
2953 ..base.clone()
2954 },
2955 transactional::CreateOrganizationInvitationRequest {
2956 template: transactional::CreateOrganizationInvitationRequestTemplate {
2957 inviter_display_name: Some("x".repeat(241)),
2958 ..base.template.clone()
2959 },
2960 ..base.clone()
2961 },
2962 transactional::CreateOrganizationInvitationRequest {
2963 template: transactional::CreateOrganizationInvitationRequestTemplate {
2964 role_name: Some("x".repeat(161)),
2965 ..base.template.clone()
2966 },
2967 ..base.clone()
2968 },
2969 transactional::CreateOrganizationInvitationRequest {
2970 template: transactional::CreateOrganizationInvitationRequestTemplate {
2971 expires_at: "not-a-time".to_owned(),
2972 ..base.template.clone()
2973 },
2974 ..base.clone()
2975 },
2976 transactional::CreateOrganizationInvitationRequest {
2977 idempotency_key: "x".repeat(241),
2978 ..base.clone()
2979 },
2980 transactional::CreateOrganizationInvitationRequest {
2981 correlation_id: "x".repeat(241),
2982 ..base.clone()
2983 },
2984 transactional::CreateOrganizationInvitationRequest {
2985 causation_id: Some("x".repeat(241)),
2986 ..base.clone()
2987 },
2988 transactional::CreateOrganizationInvitationRequest {
2989 requested_by: Some("x".repeat(241)),
2990 ..base
2991 },
2992 ]
2993 }
2994
2995 fn dispatch_work() -> DispatchWork {
2996 DispatchWork {
2997 claim: crate::runtime::DispatchClaim {
2998 delivery_id: "delivery".to_owned(),
2999 attempt_id: "attempt".to_owned(),
3000 run_id: "run".to_owned(),
3001 },
3002 request: DispatchRequest {
3003 delivery_id: "delivery".to_owned(),
3004 attempt_id: "attempt".to_owned(),
3005 run_id: "run".to_owned(),
3006 idempotency_key: "attempt".to_owned(),
3007 recipient: email::DispatchRequestRecipient {
3008 address: "member@example.com".to_owned(),
3009 },
3010 message: email::DispatchRequestMessage {
3011 template_id: "organization-invitation".to_owned(),
3012 template_version: "v1".to_owned(),
3013 locale: "en".to_owned(),
3014 subject: "secret".to_owned(),
3015 text: "secret".to_owned(),
3016 html: "secret".to_owned(),
3017 content_digest: format!("sha256:{}", "a".repeat(64)),
3018 },
3019 correlation_id: "correlation".to_owned(),
3020 },
3021 }
3022 }
3023
3024 fn generated_request() -> transactional::CreateOrganizationInvitationRequest {
3025 transactional::CreateOrganizationInvitationRequest {
3026 causation_id: Some("obs_invitation_fixture".to_owned()),
3027 correlation_id: "corr_notification_fixture".to_owned(),
3028 idempotency_key: "organization-invitation:fixture".to_owned(),
3029 recipient: transactional::CreateOrganizationInvitationRequestRecipient {
3030 address: "member@example.com".to_owned(),
3031 display_name: Some("Member".to_owned()),
3032 locale: transactional::CreateOrganizationInvitationRequestRecipientLocale::En,
3033 },
3034 requested_by: Some("usr_fixture".to_owned()),
3035 source: transactional::CreateOrganizationInvitationRequestSource {
3036 entity_id: "invite_fixture".to_owned(),
3037 entity_type: "organization_invitation".to_owned(),
3038 },
3039 template: transactional::CreateOrganizationInvitationRequestTemplate {
3040 expires_at: "2026-09-01T00:00:00Z".to_owned(),
3041 invitation_id: "invite_fixture".to_owned(),
3042 invitation_url: "https://example.test/invitations/secret".to_owned(),
3043 inviter_display_name: Some("Operator".to_owned()),
3044 organization_id: "org_fixture".to_owned(),
3045 organization_name: "Fixture Organization".to_owned(),
3046 role_name: Some("Member".to_owned()),
3047 },
3048 }
3049 }
3050
3051 fn generated_access_request() -> transactional::CreateAccessRequestNotificationRequest {
3052 transactional::CreateAccessRequestNotificationRequest {
3053 causation_id: Some("access_request_fixture:submitted".to_owned()),
3054 correlation_id: "corr_access_request_fixture".to_owned(),
3055 event: transactional::CreateAccessRequestNotificationRequestEvent::Submitted,
3056 expires_at: Some("2026-09-01T00:00:00Z".to_owned()),
3057 idempotency_key: "access-request:ar_fixture:submitted".to_owned(),
3058 organization_id: "org_fixture".to_owned(),
3059 recipient: transactional::CreateAccessRequestNotificationRequestRecipient {
3060 address: "requester@example.com".to_owned(),
3061 display_name: Some("Requester".to_owned()),
3062 locale: transactional::CreateAccessRequestNotificationRequestRecipientLocale::En,
3063 },
3064 request_id: "ar_fixture".to_owned(),
3065 requested_by: Some("subject_fixture".to_owned()),
3066 role: transactional::CreateAccessRequestNotificationRequestRole {
3067 display_name: Some("Member".to_owned()),
3068 role_id: "role_member".to_owned(),
3069 },
3070 scope: transactional::CreateAccessRequestNotificationRequestScope {
3071 display_name: Some("Fixture Organization".to_owned()),
3072 id: "org_fixture".to_owned(),
3073 kind: "organization".to_owned(),
3074 },
3075 }
3076 }
3077}