1use std::collections::BTreeMap;
2use std::fmt;
3use std::net::SocketAddr;
4use std::sync::{Arc, Mutex};
5
6use super::*;
7use crate::backends::RuntimeBackendMaterializer;
8use crate::wasm::RuntimeWasmHostServices;
9use axum::body::Body;
10use axum::http::Request;
11use axum::response::Response;
12use axum::routing::any;
13use axum::{Router, serve};
14use coil_config::{PlatformConfig, SecretRef};
15
16mod auth;
17mod backend;
18mod devtools;
19mod diagnostics;
20mod observability;
21mod request;
22
23use auth::DeferredPostgresRouteCapabilityAuthorizer;
24pub(crate) use auth::LiveRouteCapabilityAuthorizer;
25use auth::auth_explain_router;
26pub use backend::{
27 DatabaseClientTarget, DistributedCacheClientTarget, EnvironmentSecretResolver,
28 JobsClientTarget, ObjectStoreClientTarget, SecretResolutionError, SecretResolver,
29 SessionStoreClientTarget, SharedBackendClients, StaticSecretResolver,
30};
31use devtools::development_router;
32use diagnostics::privileged_router as diagnostics_router;
33use observability::public_router as observability_router;
34pub(crate) use request::HostedCheckoutClient;
35#[cfg(test)]
36pub(crate) type HostedCheckoutSession = request::HostedCheckoutSession;
37#[cfg(test)]
38pub(crate) type HostedCheckoutSessionStatus = request::HostedCheckoutSessionStatus;
39pub use request::LiveHttpRequest;
40use request::{
41 LiveStripeHostedCheckoutClient, error_response, execute_live_request, serve_runtime_request,
42};
43
44#[cfg(test)]
45#[allow(unused_imports)]
46pub(crate) use auth::{
47 LiveAuthorizationCheck, StaticLiveAuthExplainer, StaticLiveRouteCapabilityAuthorizer,
48};
49
50#[derive(Debug, Error)]
51pub enum RuntimeServerError {
52 #[error("HTTP request uses unsupported method `{method}`")]
53 UnsupportedMethod { method: String },
54 #[error("HTTP request did not include a host header")]
55 MissingHost,
56 #[error("header `{header}` is not valid UTF-8")]
57 InvalidHeaderValue { header: &'static str },
58 #[error(transparent)]
59 Route(#[from] RouteBuildError),
60 #[error(transparent)]
61 Execution(#[from] RequestExecutionError),
62 #[error(transparent)]
63 Secret(#[from] SecretResolutionError),
64 #[error(transparent)]
65 Render(#[from] RuntimeRenderError),
66 #[error(transparent)]
67 WasmExecution(#[from] LiveWasmExecutionError),
68 #[error(transparent)]
69 BrowserHostBuild(#[from] BrowserHostBuildError),
70 #[error(transparent)]
71 Storefront(#[from] StorefrontStateError),
72 #[error(transparent)]
73 Jobs(#[from] RuntimeJobsError),
74 #[error("server configuration is invalid: {reason}")]
75 Configuration { reason: String },
76 #[error("request body exceeds configured maximum of {limit} bytes")]
77 RequestBodyTooLarge { limit: usize },
78 #[error("live request authorization failed: {reason}")]
79 Authorization { reason: String },
80 #[error("auth explain failed: {reason}")]
81 Explain { reason: String },
82 #[error("linked customer hook rejected `{surface}`: {code}: {message}")]
83 CustomerHookRejected {
84 surface: &'static str,
85 code: String,
86 message: String,
87 },
88 #[error("linked customer hook failed during `{surface}`: {reason}")]
89 CustomerHookFailed {
90 surface: &'static str,
91 reason: String,
92 },
93}
94
95pub(crate) struct RuntimeServerState {
96 plan: RuntimePlan,
97 browser: Mutex<BrowserHost>,
98 wasm_host: WasmHost,
99 storefront: StorefrontStateStore,
100 cookie_secret: Vec<u8>,
101 csrf_secret: Vec<u8>,
102 payment_webhook_secret: Option<String>,
103 payment_provider_api_key: Option<String>,
104 hosted_checkout_client: Arc<dyn HostedCheckoutClient>,
105 backends: SharedBackendClients,
106 route_authorizer: Arc<dyn LiveRouteCapabilityAuthorizer>,
107 auth_explainer: Option<Arc<dyn auth::LiveAuthExplainer>>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub(crate) struct CommercePaymentProviderConfig {
112 pub code: String,
113 pub checkout_mode: String,
114}
115
116impl fmt::Debug for RuntimeServerState {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 f.debug_struct("RuntimeServerState")
119 .field("plan", &self.plan)
120 .field("browser", &self.browser)
121 .field("backends", &self.backends)
122 .finish_non_exhaustive()
123 }
124}
125
126#[derive(Debug, Clone)]
127pub struct HttpServerHost {
128 state: Arc<RuntimeServerState>,
129 router: Router,
130}
131
132impl HttpServerHost {
133 pub(crate) fn new(
134 plan: RuntimePlan,
135 backends: SharedBackendClients,
136 wasm_secrets: BTreeMap<String, String>,
137 payment_webhook_secret: Option<String>,
138 cookie_secret: Vec<u8>,
139 csrf_secret: Vec<u8>,
140 ) -> Result<Self, RuntimeServerError> {
141 let materializer = RuntimeBackendMaterializer::new(
142 plan.shared_backend_namespace(),
143 backends.clone(),
144 plan.shared_state_root().clone(),
145 );
146 let payment_provider_api_key = configured_commerce_payment_provider(&plan.config)
147 .and_then(|provider| provider.api_key_from_runtime_secrets(&wasm_secrets));
148 let route_authorizer: Arc<dyn LiveRouteCapabilityAuthorizer> =
149 Arc::new(DeferredPostgresRouteCapabilityAuthorizer::new(
150 plan.data.clone(),
151 plan.tenant_id(),
152 backends.database.url.clone(),
153 plan.auth_package.clone(),
154 ));
155 let auth_explainer = build_auth_explainer(&plan)?;
156 let hosted_checkout_client: Arc<dyn HostedCheckoutClient> =
157 Arc::new(LiveStripeHostedCheckoutClient);
158 let browser =
159 materializer.browser_host(plan.config.app.name.clone(), plan.browser.clone())?;
160 let storage_host = plan.storage_host_with_object_store(
161 backends
162 .object_store
163 .as_ref()
164 .and_then(|backend| backend.object_store_client_config()),
165 );
166 let wasm_host = WasmHost::with_host_services(
167 plan.clone(),
168 plan.config.app.name.clone(),
169 plan.wasm.clone(),
170 plan.extension_registry.clone(),
171 plan.config.i18n.default_locale.clone(),
172 plan.registered_runtime_jobs.clone(),
173 RuntimeWasmHostServices::with_runtime_secrets(plan.clone(), storage_host, wasm_secrets),
174 );
175 let storefront = StorefrontStateStore::open_for_plan(&plan)?;
176 Ok(Self::new_with_browser_and_authorizer(
177 plan,
178 browser,
179 wasm_host,
180 storefront,
181 backends,
182 payment_webhook_secret,
183 payment_provider_api_key,
184 hosted_checkout_client,
185 cookie_secret,
186 csrf_secret,
187 route_authorizer,
188 auth_explainer,
189 ))
190 }
191
192 pub fn new_with_browser_host(
193 plan: RuntimePlan,
194 browser: BrowserHost,
195 backends: SharedBackendClients,
196 cookie_secret: Vec<u8>,
197 csrf_secret: Vec<u8>,
198 ) -> Result<Self, RuntimeServerError> {
199 let route_authorizer: Arc<dyn LiveRouteCapabilityAuthorizer> =
200 Arc::new(DeferredPostgresRouteCapabilityAuthorizer::new(
201 plan.data.clone(),
202 plan.tenant_id(),
203 backends.database.url.clone(),
204 plan.auth_package.clone(),
205 ));
206 let auth_explainer = build_auth_explainer(&plan)?;
207 let wasm_host = plan.wasm_host();
208 let storefront = StorefrontStateStore::open_for_plan(&plan)?;
209 let hosted_checkout_client: Arc<dyn HostedCheckoutClient> =
210 Arc::new(LiveStripeHostedCheckoutClient);
211 Ok(Self::new_with_browser_and_authorizer(
212 plan,
213 browser,
214 wasm_host,
215 storefront,
216 backends,
217 None,
218 None,
219 hosted_checkout_client,
220 cookie_secret,
221 csrf_secret,
222 route_authorizer,
223 auth_explainer,
224 ))
225 }
226
227 #[cfg(test)]
228 pub(crate) fn new_with_authorizer(
229 plan: RuntimePlan,
230 backends: SharedBackendClients,
231 cookie_secret: Vec<u8>,
232 csrf_secret: Vec<u8>,
233 route_authorizer: Arc<dyn LiveRouteCapabilityAuthorizer>,
234 ) -> Result<Self, RuntimeServerError> {
235 let browser = plan.browser_host()?;
236 let wasm_host = plan.wasm_host();
237 let auth_explainer = build_auth_explainer(&plan)?;
238 let storefront = StorefrontStateStore::open_for_plan(&plan)?;
239 let hosted_checkout_client: Arc<dyn HostedCheckoutClient> =
240 Arc::new(LiveStripeHostedCheckoutClient);
241 Ok(Self::new_with_browser_and_authorizer(
242 plan,
243 browser,
244 wasm_host,
245 storefront,
246 backends,
247 None,
248 None,
249 hosted_checkout_client,
250 cookie_secret,
251 csrf_secret,
252 route_authorizer,
253 auth_explainer,
254 ))
255 }
256
257 #[cfg(test)]
258 pub(crate) fn new_with_authorizer_and_explainer(
259 plan: RuntimePlan,
260 backends: SharedBackendClients,
261 cookie_secret: Vec<u8>,
262 csrf_secret: Vec<u8>,
263 route_authorizer: Arc<dyn LiveRouteCapabilityAuthorizer>,
264 auth_explainer: Arc<dyn auth::LiveAuthExplainer>,
265 ) -> Result<Self, RuntimeServerError> {
266 let browser = plan.browser_host()?;
267 let wasm_host = plan.wasm_host();
268 let storefront = StorefrontStateStore::open_for_plan(&plan)?;
269 let hosted_checkout_client: Arc<dyn HostedCheckoutClient> =
270 Arc::new(LiveStripeHostedCheckoutClient);
271 Ok(Self::new_with_browser_and_authorizer(
272 plan,
273 browser,
274 wasm_host,
275 storefront,
276 backends,
277 None,
278 None,
279 hosted_checkout_client,
280 cookie_secret,
281 csrf_secret,
282 route_authorizer,
283 Some(auth_explainer),
284 ))
285 }
286
287 #[cfg(test)]
288 pub(crate) fn new_with_checkout_client(
289 plan: RuntimePlan,
290 backends: SharedBackendClients,
291 wasm_secrets: BTreeMap<String, String>,
292 payment_webhook_secret: Option<String>,
293 cookie_secret: Vec<u8>,
294 csrf_secret: Vec<u8>,
295 hosted_checkout_client: Arc<dyn HostedCheckoutClient>,
296 ) -> Result<Self, RuntimeServerError> {
297 let materializer = RuntimeBackendMaterializer::new(
298 plan.shared_backend_namespace(),
299 backends.clone(),
300 plan.shared_state_root().clone(),
301 );
302 let payment_provider_api_key = configured_commerce_payment_provider(&plan.config)
303 .and_then(|provider| provider.api_key_from_runtime_secrets(&wasm_secrets));
304 let route_authorizer: Arc<dyn LiveRouteCapabilityAuthorizer> =
305 Arc::new(DeferredPostgresRouteCapabilityAuthorizer::new(
306 plan.data.clone(),
307 plan.tenant_id(),
308 backends.database.url.clone(),
309 plan.auth_package.clone(),
310 ));
311 let auth_explainer = build_auth_explainer(&plan)?;
312 let browser =
313 materializer.browser_host(plan.config.app.name.clone(), plan.browser.clone())?;
314 let storage_host = plan.storage_host_with_object_store(
315 backends
316 .object_store
317 .as_ref()
318 .and_then(|backend| backend.object_store_client_config()),
319 );
320 let wasm_host = WasmHost::with_host_services(
321 plan.clone(),
322 plan.config.app.name.clone(),
323 plan.wasm.clone(),
324 plan.extension_registry.clone(),
325 plan.config.i18n.default_locale.clone(),
326 plan.registered_runtime_jobs.clone(),
327 RuntimeWasmHostServices::with_runtime_secrets(plan.clone(), storage_host, wasm_secrets),
328 );
329 let storefront = StorefrontStateStore::open_for_plan(&plan)?;
330 Ok(Self::new_with_browser_and_authorizer(
331 plan,
332 browser,
333 wasm_host,
334 storefront,
335 backends,
336 payment_webhook_secret,
337 payment_provider_api_key,
338 hosted_checkout_client,
339 cookie_secret,
340 csrf_secret,
341 route_authorizer,
342 auth_explainer,
343 ))
344 }
345
346 #[cfg(test)]
347 pub(crate) fn new_with_authorizer_and_checkout_client(
348 plan: RuntimePlan,
349 backends: SharedBackendClients,
350 cookie_secret: Vec<u8>,
351 csrf_secret: Vec<u8>,
352 route_authorizer: Arc<dyn LiveRouteCapabilityAuthorizer>,
353 hosted_checkout_client: Arc<dyn HostedCheckoutClient>,
354 ) -> Result<Self, RuntimeServerError> {
355 let browser = plan.browser_host()?;
356 let wasm_host = plan.wasm_host();
357 let auth_explainer = build_auth_explainer(&plan)?;
358 let storefront = StorefrontStateStore::open_for_plan(&plan)?;
359 Ok(Self::new_with_browser_and_authorizer(
360 plan,
361 browser,
362 wasm_host,
363 storefront,
364 backends,
365 None,
366 None,
367 hosted_checkout_client,
368 cookie_secret,
369 csrf_secret,
370 route_authorizer,
371 auth_explainer,
372 ))
373 }
374
375 fn new_with_browser_and_authorizer(
376 plan: RuntimePlan,
377 browser: BrowserHost,
378 wasm_host: WasmHost,
379 storefront: StorefrontStateStore,
380 backends: SharedBackendClients,
381 payment_webhook_secret: Option<String>,
382 payment_provider_api_key: Option<String>,
383 hosted_checkout_client: Arc<dyn HostedCheckoutClient>,
384 cookie_secret: Vec<u8>,
385 csrf_secret: Vec<u8>,
386 route_authorizer: Arc<dyn LiveRouteCapabilityAuthorizer>,
387 auth_explainer: Option<Arc<dyn auth::LiveAuthExplainer>>,
388 ) -> Self {
389 let state = Arc::new(RuntimeServerState {
390 browser: Mutex::new(browser),
391 wasm_host,
392 storefront,
393 plan,
394 cookie_secret,
395 csrf_secret,
396 payment_webhook_secret,
397 payment_provider_api_key,
398 hosted_checkout_client,
399 backends,
400 route_authorizer,
401 auth_explainer,
402 });
403 let public_router = observability_router();
404 let privileged_router =
405 diagnostics_router(state.clone()).merge(auth_explain_router(state.clone()));
406 let mut router = Router::new()
407 .merge(public_router)
408 .merge(privileged_router)
409 .route("/", any(serve_runtime_request))
410 .fallback(any(serve_runtime_request));
411 if state.is_development() {
412 router = router.merge(development_router());
413 }
414 let router = router.with_state(state.clone());
415
416 Self { state, router }
417 }
418
419 pub fn shared_backends(&self) -> &SharedBackendClients {
420 &self.state.backends
421 }
422
423 pub fn router(&self) -> Router {
424 self.router.clone()
425 }
426
427 #[cfg(test)]
428 pub(crate) fn public_router(&self) -> Router {
429 observability_router().with_state(self.state.clone())
430 }
431
432 #[cfg(test)]
433 pub(crate) fn privileged_router(&self) -> Router {
434 diagnostics_router(self.state.clone())
435 .merge(auth_explain_router(self.state.clone()))
436 .with_state(self.state.clone())
437 }
438
439 #[cfg(test)]
440 pub(crate) fn wasm_host(&self) -> WasmHost {
441 self.state.wasm_host.clone()
442 }
443
444 pub fn issue_session(
445 &self,
446 request: SessionIssueRequest,
447 now: BrowserInstant,
448 ) -> Result<IssuedBrowserSession, RuntimeBrowserError> {
449 let mut browser = self
450 .state
451 .browser
452 .lock()
453 .expect("runtime browser mutex poisoned");
454 browser.issue_session(request, &self.state.cookie_secret, now)
455 }
456
457 pub async fn respond(
458 &self,
459 request: Request<Body>,
460 ) -> Result<Response<Body>, RuntimeServerError> {
461 Ok(
462 match execute_live_request(&self.state, request, None).await {
463 Ok(response) => response,
464 Err(error) => error_response(error),
465 },
466 )
467 }
468
469 pub async fn serve(self, listener: tokio::net::TcpListener) -> std::io::Result<()> {
470 serve(
471 listener,
472 self.router
473 .into_make_service_with_connect_info::<SocketAddr>(),
474 )
475 .await
476 .map_err(std::io::Error::other)
477 }
478}
479
480impl RuntimeServerState {
481 pub(crate) fn is_development(&self) -> bool {
482 matches!(
483 self.plan.config.app.environment,
484 coil_config::Environment::Development
485 )
486 }
487
488 pub(crate) fn uses_development_hosted_checkout_stub(&self) -> bool {
489 self.is_development()
490 && self
491 .payment_provider_api_key
492 .as_deref()
493 .is_none_or(is_placeholder_stripe_secret_key)
494 }
495}
496
497fn is_placeholder_stripe_secret_key(api_key: &str) -> bool {
498 let normalized = api_key.trim();
499 normalized.is_empty()
500 || matches!(
501 normalized,
502 "sk_test_replace_me" | "replace-me" | "changeme" | "change-me"
503 )
504 || normalized.contains("replace_me")
505}
506
507pub(crate) fn resolve_commerce_payment_webhook_secret<R: SecretResolver>(
508 config: &PlatformConfig,
509 resolver: &R,
510) -> Result<Option<String>, RuntimeServerError> {
511 if let Some(provider) = configured_commerce_payment_provider(config) {
512 if let Some(secret) =
513 resolve_module_payment_webhook_secret(config, &provider.module_config_key(), resolver)?
514 {
515 return Ok(Some(secret));
516 }
517 }
518
519 resolve_module_payment_webhook_secret(config, "commerce", resolver)
520}
521
522pub(crate) fn configured_commerce_payment_provider(
523 config: &PlatformConfig,
524) -> Option<CommercePaymentProviderConfig> {
525 let Some(stripe_settings) = config.modules.settings.get("commerce-payments-stripe") else {
526 return None;
527 };
528 let table = stripe_settings.as_table()?;
529 let code = table
530 .get("provider")
531 .and_then(|value| value.as_str())
532 .map(str::trim)
533 .filter(|value| !value.is_empty())
534 .unwrap_or("stripe")
535 .to_ascii_lowercase();
536 let checkout_mode = table
537 .get("checkout_mode")
538 .and_then(|value| value.as_str())
539 .map(str::trim)
540 .filter(|value| !value.is_empty())
541 .unwrap_or("webhook-confirmation")
542 .to_ascii_lowercase();
543 Some(CommercePaymentProviderConfig {
544 code,
545 checkout_mode,
546 })
547}
548
549fn resolve_module_payment_webhook_secret<R: SecretResolver>(
550 config: &PlatformConfig,
551 module_key: &str,
552 resolver: &R,
553) -> Result<Option<String>, RuntimeServerError> {
554 let Some(module_settings) = config.modules.settings.get(module_key) else {
555 return Ok(None);
556 };
557 let Some(table) = module_settings.as_table() else {
558 return Err(RuntimeServerError::Configuration {
559 reason: format!("modules.{module_key} must be a table when provided"),
560 });
561 };
562 let secret_field = if table.contains_key("webhook_secret") {
563 "webhook_secret"
564 } else if table.contains_key("payment_webhook_secret") {
565 "payment_webhook_secret"
566 } else {
567 return Ok(None);
568 };
569 let Some(secret_value) = table.get(secret_field) else {
570 return Ok(None);
571 };
572 let secret_ref: SecretRef =
573 secret_value
574 .clone()
575 .try_into()
576 .map_err(|error| RuntimeServerError::Configuration {
577 reason: format!("modules.{module_key}.{secret_field} is invalid: {error}"),
578 })?;
579 Ok(Some(resolver.resolve(&secret_ref)?))
580}
581
582impl CommercePaymentProviderConfig {
583 pub(crate) fn uses_hosted_checkout(&self) -> bool {
584 matches!(
585 self.checkout_mode.as_str(),
586 "hosted-checkout" | "hosted_checkout" | "stripe-hosted-checkout"
587 )
588 }
589
590 pub(crate) fn api_key_from_runtime_secrets(
591 &self,
592 runtime_secrets: &BTreeMap<String, String>,
593 ) -> Option<String> {
594 self.api_key_secret_candidates()
595 .iter()
596 .find_map(|candidate| runtime_secrets.get(*candidate))
597 .map(String::as_str)
598 .map(str::trim)
599 .filter(|value| !value.is_empty())
600 .map(str::to_string)
601 }
602
603 pub(crate) fn module_config_key(&self) -> String {
604 match self.code.as_str() {
605 "stripe" => "commerce-payments-stripe".to_string(),
606 other => format!("commerce-payments-{other}"),
607 }
608 }
609
610 pub(crate) fn label(&self) -> String {
611 match (self.code.as_str(), self.checkout_mode.as_str()) {
612 ("stripe", _) if self.uses_hosted_checkout() => "Stripe hosted checkout".to_string(),
613 ("stripe", "webhook-confirmation") => "Stripe webhook confirmation".to_string(),
614 ("stripe", _) => "Stripe payment provider".to_string(),
615 (other, _) if self.uses_hosted_checkout() => {
616 format!("{} hosted checkout", display_payment_provider_name(other))
617 }
618 (other, "webhook-confirmation") => {
619 format!(
620 "{} webhook confirmation",
621 display_payment_provider_name(other)
622 )
623 }
624 (other, _) => format!("{} payment provider", display_payment_provider_name(other)),
625 }
626 }
627
628 pub(crate) fn summary(&self) -> String {
629 match (self.code.as_str(), self.checkout_mode.as_str()) {
630 ("stripe", _) if self.uses_hosted_checkout() => "This checkout reserves the order in Coil, then redirects the customer to Stripe Checkout for payment collection. Coil still waits for the signed Stripe webhook before treating the order as paid.".to_string(),
631 ("stripe", "webhook-confirmation") => "This checkout uses the installed Stripe payment provider. The order stays visible until the signed Stripe webhook confirms the payment result.".to_string(),
632 ("stripe", _) => "This checkout uses the installed Stripe payment provider for payment confirmation.".to_string(),
633 (other, _) if self.uses_hosted_checkout() => format!(
634 "This checkout reserves the order in Coil, then redirects the customer to the installed {} hosted checkout. Coil still waits for the signed webhook before treating the order as paid.",
635 display_payment_provider_name(other)
636 ),
637 (other, "webhook-confirmation") => format!(
638 "This checkout uses the installed {} payment provider. The order stays visible until its signed webhook confirms the payment result.",
639 display_payment_provider_name(other)
640 ),
641 (other, _) => format!(
642 "This checkout uses the installed {} payment provider for payment confirmation.",
643 display_payment_provider_name(other)
644 ),
645 }
646 }
647
648 pub(crate) fn submit_label(&self) -> String {
649 match (self.code.as_str(), self.checkout_mode.as_str()) {
650 ("stripe", _) if self.uses_hosted_checkout() => "Continue to Stripe".to_string(),
651 ("stripe", "webhook-confirmation") => {
652 "Place order and wait for Stripe confirmation".to_string()
653 }
654 (other, _) if self.uses_hosted_checkout() => {
655 format!("Continue to {}", display_payment_provider_name(other))
656 }
657 (_, "webhook-confirmation") => {
658 format!(
659 "Place order and wait for {} confirmation",
660 display_payment_provider_name(&self.code)
661 )
662 }
663 _ => "Place order".to_string(),
664 }
665 }
666
667 pub(crate) fn pending_confirmation_summary(&self, order_id: &str) -> String {
668 match (self.code.as_str(), self.checkout_mode.as_str()) {
669 ("stripe", _) if self.uses_hosted_checkout() => format!(
670 "Order {order_id} was reserved and handed off to Stripe Checkout. Stripe still needs to confirm payment."
671 ),
672 ("stripe", "webhook-confirmation") => {
673 format!("Order {order_id} was received. Stripe still needs to confirm payment.")
674 }
675 (_, _) if self.uses_hosted_checkout() => format!(
676 "Order {order_id} was reserved and handed off to {}. The provider still needs to confirm payment.",
677 display_payment_provider_name(&self.code)
678 ),
679 (_, "webhook-confirmation") => format!(
680 "Order {order_id} was received. {} still needs to confirm payment.",
681 display_payment_provider_name(&self.code)
682 ),
683 _ => format!("Order {order_id} was received and is awaiting payment confirmation."),
684 }
685 }
686
687 pub(crate) fn pending_next_step(&self) -> String {
688 match (self.code.as_str(), self.checkout_mode.as_str()) {
689 ("stripe", _) if self.uses_hosted_checkout() => {
690 "Stripe Checkout has not confirmed this payment yet. The order will move forward after the hosted Stripe session finishes and the signed Stripe webhook arrives.".to_string()
691 }
692 ("stripe", "webhook-confirmation") => {
693 "Stripe has not confirmed this payment yet. The order will move forward after the signed Stripe webhook arrives.".to_string()
694 }
695 (_, _) if self.uses_hosted_checkout() => format!(
696 "{} has not confirmed this payment yet. The order will move forward after the hosted checkout finishes and the provider webhook arrives.",
697 display_payment_provider_name(&self.code)
698 ),
699 (_, "webhook-confirmation") => format!(
700 "{} has not confirmed this payment yet. The order will move forward after its signed webhook arrives.",
701 display_payment_provider_name(&self.code)
702 ),
703 _ => "Payment confirmation is pending. The order will move forward after the provider callback arrives.".to_string(),
704 }
705 }
706
707 fn api_key_secret_candidates(&self) -> &'static [&'static str] {
708 match self.code.as_str() {
709 "stripe" => &[
710 "commerce_payments_stripe_secret_key",
711 "commerce_payments_stripe_api_key",
712 "stripe_secret_key",
713 "stripe_api_key",
714 ],
715 _ => &[],
716 }
717 }
718}
719
720fn display_payment_provider_name(code: &str) -> String {
721 match code {
722 "stripe" => "Stripe".to_string(),
723 other => {
724 let mut chars = other.chars();
725 match chars.next() {
726 Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
727 None => "Payment provider".to_string(),
728 }
729 }
730 }
731}
732
733fn build_auth_explainer(
734 plan: &RuntimePlan,
735) -> Result<Option<Arc<dyn auth::LiveAuthExplainer>>, RuntimeServerError> {
736 if !plan.config.auth.explain_api {
737 return Ok(None);
738 }
739
740 let explainer = coil_auth::LiveAuthExplainHost::from_runtime(
741 &plan.config,
742 plan.data.clone(),
743 plan.auth_package.clone(),
744 )
745 .map_err(|error| RuntimeServerError::Explain {
746 reason: error.to_string(),
747 })?;
748
749 let explainer: Arc<dyn auth::LiveAuthExplainer> = Arc::new(explainer);
750 Ok(Some(explainer))
751}