Skip to main content

coil_runtime/server/
mod.rs

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}