Skip to main content

coil_runtime/server/
request.rs

1use super::auth::authorize_live_request;
2use super::*;
3use axum::body::{Body, to_bytes};
4use axum::extract::{ConnectInfo, State};
5use axum::http::header::{CONTENT_LENGTH, CONTENT_TYPE, COOKIE, HOST};
6use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, Request, Response, StatusCode};
7use axum::response::IntoResponse;
8use coil_assets::{
9    AssetId, ContentFingerprint, FingerprintAlgorithm, ManagedAssetRevision, RevisionId,
10};
11use coil_config::StorageClass;
12use coil_customer_sdk::{
13    AssetWriteReceipt, AssetWriteRequest, AssetsFacade, AuditEntry, AuditFacade, AuthCheckRequest,
14    AuthCheckResult, AuthExplainRequest, AuthExplanation, AuthFacade, BackendError,
15    BackendErrorKind, CmsPageDraft, CmsPublishDecision, CommerceFacade, CommerceProduct,
16    CustomerAppContext as SdkCustomerAppContext, Headers, JobReceipt, JobsFacade, ManagedAsset,
17    MoneyAmount, OrderDraft, OrderLineDraft, OrderReviewDecision, OutboundHttpFacade,
18    OutboundHttpRequest, OutboundHttpResponse, PrincipalContext as SdkPrincipalContext,
19    RepositoryFacade, RepositoryQuery, RepositoryRecord, RepositoryRecordSet, RepositoryWrite,
20    RepositoryWriteReceipt, RequestContext as SdkRequestContext, TraceContext as SdkTraceContext,
21    VerifiedWebhook, WebhookHandlingResult,
22};
23use coil_jobs::JobInstant;
24use coil_storage::StoragePlanRequest;
25use hmac::{Hmac, Mac};
26use serde::{Deserialize, Serialize};
27use sha2::{Digest, Sha256};
28use std::borrow::Cow;
29use std::collections::BTreeMap;
30use std::future::Future;
31use std::net::SocketAddr;
32use std::sync::{Arc, Mutex};
33use std::time::{Instant, SystemTime, UNIX_EPOCH};
34use url::form_urlencoded;
35
36const STOREFRONT_ORDER_HISTORY_JSON_PATH: &str = "/account/orders.json";
37const STOREFRONT_FORM_CSRF_HEADERS: &[(&str, &str)] = &[
38    (
39        "/cart/items",
40        "x-coil-storefront-csrf-commerce-add-to-cart",
41    ),
42    ("/cart", "x-coil-storefront-csrf-commerce-cart-update"),
43    (
44        "/checkout/start",
45        "x-coil-storefront-csrf-commerce-checkout-start",
46    ),
47    (
48        "/checkout/complete",
49        "x-coil-storefront-csrf-commerce-checkout-complete",
50    ),
51    (
52        "/admin/catalog/products",
53        "x-coil-storefront-csrf-commerce-catalog-admin-update",
54    ),
55    (
56        "/admin/orders/refund",
57        "x-coil-storefront-csrf-commerce-order-refund",
58    ),
59    (
60        "/admin/orders/fulfill",
61        "x-coil-storefront-csrf-commerce-order-fulfill",
62    ),
63];
64const CMS_ADMIN_FORM_CSRF_HEADERS: &[(&str, &str)] = &[
65    (
66        "/admin/pages/draft",
67        "x-coil-cms-csrf-cms-pages-save-draft",
68    ),
69    (
70        "/admin/pages/publish",
71        "x-coil-cms-csrf-cms-pages-publish",
72    ),
73    (
74        "/admin/pages/unpublish",
75        "x-coil-cms-csrf-cms-pages-unpublish",
76    ),
77    (
78        "/admin/navigation/save",
79        "x-coil-cms-csrf-cms-navigation-save",
80    ),
81    (
82        "/admin/redirects/save",
83        "x-coil-cms-csrf-cms-redirects-save",
84    ),
85];
86const STOREFRONT_NATIVE_CAPABILITY_ROUTES: &[&str] = &[
87    "commerce.cart",
88    "commerce.add-to-cart",
89    "commerce.cart-update",
90    "commerce.checkout",
91    "commerce.checkout-start",
92    "commerce.checkout-complete",
93    "commerce.checkout-confirmation",
94    "commerce.catalog-admin-update",
95    "commerce.account-session-end",
96    "commerce.order-refund",
97    "commerce.order-fulfill",
98];
99const CMS_ADMIN_NATIVE_MUTATION_ROUTES: &[&str] = &[
100    "cms.pages.save-draft",
101    "cms.pages.publish",
102    "cms.pages.unpublish",
103    "cms.navigation.save",
104    "cms.redirects.save",
105];
106const STOREFRONT_CSRF_ACTIONS: &[&str] = &[
107    "commerce.add-to-cart",
108    "commerce.cart-update",
109    "commerce.checkout-start",
110    "commerce.checkout-complete",
111    "commerce.catalog-admin-update",
112    "commerce.account-session-end",
113    "commerce.order-refund",
114    "commerce.order-fulfill",
115];
116const CMS_ADMIN_CSRF_ACTIONS: &[(&str, &str)] = &[
117    (
118        "cms.pages.save-draft",
119        "x-coil-cms-csrf-cms-pages-save-draft",
120    ),
121    ("cms.pages.publish", "x-coil-cms-csrf-cms-pages-publish"),
122    (
123        "cms.pages.unpublish",
124        "x-coil-cms-csrf-cms-pages-unpublish",
125    ),
126    (
127        "cms.navigation.save",
128        "x-coil-cms-csrf-cms-navigation-save",
129    ),
130    (
131        "cms.redirects.save",
132        "x-coil-cms-csrf-cms-redirects-save",
133    ),
134];
135const STRIPE_WEBHOOK_MAX_AGE_SECS: u64 = 300;
136
137type HmacSha256 = Hmac<Sha256>;
138
139#[derive(Debug, Clone, PartialEq, Eq)]
140struct VerifiedIngressWebhook {
141    webhook: VerifiedWebhook,
142    payment_reference: Option<String>,
143    delivery_id: Option<String>,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147struct PersistedCustomerManagedAssetRecord {
148    logical_path: String,
149    storage_class: String,
150    revision_id: String,
151    content_type: String,
152    byte_length: u64,
153    fingerprint_algorithm: String,
154    fingerprint_digest: String,
155    published_current: bool,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
159enum CatalogAdminMutationInput {
160    Product(crate::storefront::StorefrontCatalogProductUpdate),
161    Collection(crate::storefront::StorefrontCatalogCollectionUpdate),
162}
163
164#[derive(Debug, Deserialize)]
165struct StripeCheckoutSessionResponse {
166    id: String,
167    url: String,
168}
169
170#[derive(Debug, Deserialize)]
171struct StripeCheckoutSessionLookupResponse {
172    id: String,
173    status: Option<String>,
174    payment_status: Option<String>,
175    client_reference_id: Option<String>,
176    metadata: Option<BTreeMap<String, String>>,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub(crate) struct HostedCheckoutSession {
181    pub(crate) id: String,
182    pub(crate) url: String,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub(crate) struct HostedCheckoutSessionStatus {
187    pub(crate) id: String,
188    pub(crate) status: Option<String>,
189    pub(crate) payment_status: Option<String>,
190    pub(crate) payment_reference: Option<String>,
191}
192
193pub(crate) trait HostedCheckoutClient: Send + Sync {
194    fn create_stripe_checkout_session(
195        &self,
196        api_key: &str,
197        request_body: &str,
198        idempotency_key: &str,
199    ) -> Result<HostedCheckoutSession, String>;
200
201    fn fetch_stripe_checkout_session(
202        &self,
203        api_key: &str,
204        session_id: &str,
205    ) -> Result<HostedCheckoutSessionStatus, String>;
206}
207
208#[derive(Debug, Default)]
209pub(crate) struct LiveStripeHostedCheckoutClient;
210
211impl HostedCheckoutClient for LiveStripeHostedCheckoutClient {
212    fn create_stripe_checkout_session(
213        &self,
214        api_key: &str,
215        request_body: &str,
216        idempotency_key: &str,
217    ) -> Result<HostedCheckoutSession, String> {
218        let agent = ureq::AgentBuilder::new()
219            .timeout_connect(std::time::Duration::from_secs(5))
220            .timeout_read(std::time::Duration::from_secs(10))
221            .build();
222        let request = agent
223            .post("https://api.stripe.com/v1/checkout/sessions")
224            .set("authorization", &format!("Bearer {api_key}"))
225            .set("content-type", "application/x-www-form-urlencoded")
226            .set("idempotency-key", idempotency_key);
227        match request.send_string(request_body) {
228            Ok(response) => {
229                let body = response
230                    .into_string()
231                    .map_err(|error| format!("failed to read Stripe Checkout response: {error}"))?;
232                let session = serde_json::from_str::<StripeCheckoutSessionResponse>(&body)
233                    .map_err(|error| {
234                        format!("failed to decode Stripe Checkout response: {error}")
235                    })?;
236                Ok(HostedCheckoutSession {
237                    id: session.id,
238                    url: session.url,
239                })
240            }
241            Err(ureq::Error::Status(code, response)) => {
242                let body = response.into_string().unwrap_or_default();
243                Err(format!(
244                    "Stripe Checkout session creation failed with HTTP {code}: {body}"
245                ))
246            }
247            Err(ureq::Error::Transport(error)) => {
248                Err(format!("Stripe Checkout handoff request failed: {error}"))
249            }
250        }
251    }
252
253    fn fetch_stripe_checkout_session(
254        &self,
255        api_key: &str,
256        session_id: &str,
257    ) -> Result<HostedCheckoutSessionStatus, String> {
258        let encoded_session_id =
259            url::form_urlencoded::byte_serialize(session_id.as_bytes()).collect::<String>();
260        let agent = ureq::AgentBuilder::new()
261            .timeout_connect(std::time::Duration::from_secs(5))
262            .timeout_read(std::time::Duration::from_secs(10))
263            .build();
264        let request = agent
265            .get(&format!(
266                "https://api.stripe.com/v1/checkout/sessions/{encoded_session_id}"
267            ))
268            .set("authorization", &format!("Bearer {api_key}"));
269        match request.call() {
270            Ok(response) => {
271                let body = response.into_string().map_err(|error| {
272                    format!("failed to read Stripe Checkout lookup response: {error}")
273                })?;
274                let session = serde_json::from_str::<StripeCheckoutSessionLookupResponse>(&body)
275                    .map_err(|error| {
276                        format!("failed to decode Stripe Checkout lookup response: {error}")
277                    })?;
278                Ok(HostedCheckoutSessionStatus {
279                    id: session.id,
280                    status: session.status,
281                    payment_status: session.payment_status,
282                    payment_reference: session.client_reference_id.or_else(|| {
283                        session
284                            .metadata
285                            .and_then(|metadata| metadata.get("payment_reference").cloned())
286                    }),
287                })
288            }
289            Err(ureq::Error::Status(code, response)) => {
290                let body = response.into_string().unwrap_or_default();
291                Err(format!(
292                    "Stripe Checkout session lookup failed with HTTP {code}: {body}"
293                ))
294            }
295            Err(ureq::Error::Transport(error)) => {
296                Err(format!("Stripe Checkout lookup request failed: {error}"))
297            }
298        }
299    }
300}
301
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub struct LiveHttpRequest {
304    pub method: HttpMethod,
305    pub host: String,
306    pub path: String,
307    pub headers: Headers,
308    pub query_params: RequestFieldMap,
309    pub form_fields: RequestFieldMap,
310    pub content_type: Option<String>,
311    pub raw_body: Vec<u8>,
312    pub scheme: String,
313    pub forwarded_proto: Option<String>,
314    pub request_id: Option<String>,
315    pub session_cookie: Option<String>,
316    pub flash_cookie: Option<String>,
317    pub csrf_token: Option<String>,
318    pub maintenance_bypass_token: Option<String>,
319}
320
321#[derive(Debug, Default)]
322struct ParsedRequestBody {
323    content_type: Option<String>,
324    raw_body: Vec<u8>,
325    form_fields: RequestFieldMap,
326}
327
328impl LiveHttpRequest {
329    pub fn from_request(
330        request: &Request<Body>,
331        browser: &BrowserSecurityServices,
332        server: &coil_config::ServerConfig,
333        remote_addr: Option<SocketAddr>,
334    ) -> Result<Self, RuntimeServerError> {
335        let headers = request.headers();
336        let host = header_value(headers, HOST)?.ok_or(RuntimeServerError::MissingHost)?;
337        let trusted_forwarded_headers = server.trusts_forwarded_headers(remote_addr.as_ref());
338        let forwarded_proto = if trusted_forwarded_headers {
339            header_value(headers, "x-forwarded-proto")?
340        } else {
341            None
342        };
343        let scheme = forwarded_proto
344            .clone()
345            .unwrap_or_else(|| "http".to_string());
346        let request_id = header_value(headers, "x-request-id")?;
347        let cookies = parse_cookie_header(headers)?;
348
349        Ok(Self {
350            method: map_http_method(request.method())?,
351            host,
352            path: request.uri().path().to_string(),
353            headers: normalized_request_headers(headers)?,
354            query_params: parse_request_fields(
355                request.uri().query().unwrap_or_default().as_bytes(),
356            ),
357            form_fields: RequestFieldMap::new(),
358            content_type: None,
359            raw_body: Vec::new(),
360            scheme,
361            forwarded_proto,
362            request_id,
363            session_cookie: cookies.get(&browser.sessions.session_cookie.name).cloned(),
364            flash_cookie: cookies.get(&browser.sessions.flash_cookie.name).cloned(),
365            csrf_token: header_value(headers, browser.csrf.header_name.as_str())?,
366            maintenance_bypass_token: header_value(headers, "x-coil-maintenance-bypass")?,
367        })
368    }
369
370    pub fn into_request_input(self) -> Result<RequestInput, RuntimeServerError> {
371        let mut request = RequestInput::new(self.method, self.host, self.path)?
372            .with_headers(self.headers)
373            .with_query_params(self.query_params)
374            .with_form_fields(self.form_fields)
375            .with_raw_body(self.raw_body)
376            .with_scheme(self.scheme);
377
378        if let Some(content_type) = self.content_type {
379            request = request.with_content_type(content_type);
380        }
381        if let Some(proto) = self.forwarded_proto {
382            request = request.with_forwarded_proto(proto);
383        }
384        if let Some(request_id) = self.request_id {
385            request = request.with_request_id(request_id);
386        }
387        if let Some(session_cookie) = self.session_cookie {
388            request = request.with_session_cookie(session_cookie);
389        }
390        if let Some(flash_cookie) = self.flash_cookie {
391            request = request.with_flash_cookie(flash_cookie);
392        }
393        if let Some(csrf_token) = self.csrf_token {
394            request = request.with_csrf_token(csrf_token);
395        }
396        if let Some(bypass) = self.maintenance_bypass_token {
397            request = request.with_maintenance_bypass_token(bypass);
398        }
399
400        Ok(request)
401    }
402}
403
404pub(crate) async fn serve_runtime_request(
405    State(state): State<Arc<RuntimeServerState>>,
406    ConnectInfo(remote_addr): ConnectInfo<SocketAddr>,
407    request: Request<Body>,
408) -> Response<Body> {
409    match execute_live_request(&state, request, Some(remote_addr)).await {
410        Ok(response) => response,
411        Err(error) => error_response(error),
412    }
413}
414
415pub(super) async fn execute_live_request(
416    state: &RuntimeServerState,
417    request: Request<Body>,
418    remote_addr: Option<SocketAddr>,
419) -> Result<Response<Body>, RuntimeServerError> {
420    let telemetry = &state.plan.observability.telemetry;
421    let started_at = Instant::now();
422    let request_method = request.method().as_str().to_string();
423    let request_path = request.uri().path().to_string();
424    let request_host = request
425        .headers()
426        .get(HOST)
427        .and_then(|value| value.to_str().ok())
428        .unwrap_or_default()
429        .to_string();
430    let request_id = request
431        .headers()
432        .get("x-request-id")
433        .and_then(|value| value.to_str().ok())
434        .map(ToOwned::to_owned)
435        .unwrap_or_else(|| format!("http:{}:{}", request_method, request_path));
436    let _ = telemetry.adjust_gauge("coil.http.requests.in_flight", 1);
437    let result = execute_live_request_inner(state, request, remote_addr).await;
438    let elapsed_ms = started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
439    let _ = telemetry.adjust_gauge("coil.http.requests.in_flight", -1);
440    let _ = telemetry.increment_counter("coil.http.requests.total", 1);
441    let _ = telemetry.record_histogram("coil.http.request.latency_ms", elapsed_ms);
442    let now = SystemTime::now()
443        .duration_since(UNIX_EPOCH)
444        .unwrap_or_default()
445        .as_secs();
446    let (outcome, status) = match &result {
447        Ok(response) => ("ok".to_string(), response.status().as_u16().to_string()),
448        Err(error) => (
449            live_request_error_outcome(error),
450            live_request_error_status(error).as_u16().to_string(),
451        ),
452    };
453    let _ = telemetry.record_trace(
454        coil_observability::TraceRecord::new(request_id, "http.request", outcome, now)
455            .with_field("method", request_method)
456            .with_field("host", request_host)
457            .with_field("path", request_path)
458            .with_field("status", status)
459            .with_field("duration_ms", elapsed_ms.to_string()),
460    );
461    result
462}
463
464async fn execute_live_request_inner(
465    state: &RuntimeServerState,
466    request: Request<Body>,
467    remote_addr: Option<SocketAddr>,
468) -> Result<Response<Body>, RuntimeServerError> {
469    let raw_request =
470        enforce_request_body_limit(request, state.plan.config.server.max_body_bytes).await?;
471    let mut live_request = LiveHttpRequest::from_request(
472        &raw_request,
473        &state.plan.browser,
474        &state.plan.config.server,
475        remote_addr,
476    )?;
477    let parsed_body = parse_request_body(live_request.method, raw_request).await?;
478    live_request.content_type = parsed_body.content_type;
479    live_request.raw_body = parsed_body.raw_body;
480    live_request.form_fields = parsed_body.form_fields;
481    let mut request = live_request.into_request_input()?;
482    if request.method == HttpMethod::Get
483        && state
484            .plan
485            .http
486            .resolve_match(
487                &state.plan.config,
488                request.method,
489                &request.host,
490                &request.path,
491            )
492            .is_none()
493    {
494        if let Some(response) = cms_admin_redirect_response(state, &request.path)? {
495            return Ok(response);
496        }
497    }
498    let now = BrowserInstant::from_unix_seconds(
499        SystemTime::now()
500            .duration_since(UNIX_EPOCH)
501            .unwrap_or_default()
502            .as_secs(),
503    );
504    let mut native_response_cookies = Vec::new();
505    let mut execution = if request.session_cookie.is_some() || request.flash_cookie.is_some() {
506        let resolved = {
507            let mut browser = state
508                .browser
509                .lock()
510                .expect("runtime browser mutex poisoned");
511            browser
512                .resolve_request(&request, &state.cookie_secret, now)
513                .map_err(RequestExecutionError::from_browser_error)?
514        };
515
516        request.session_id = resolved.session.session_id.clone();
517        request.session_cookie = None;
518        request.flash_cookie = None;
519
520        if request.principal_id.is_none() {
521            request.principal_id = resolved.principal_id.clone();
522        }
523
524        native_response_cookies.extend(resolved.response_cookies.clone());
525        prepare_native_storefront_request(state, &mut request, now, &mut native_response_cookies)?;
526        if request.path == STOREFRONT_ORDER_HISTORY_JSON_PATH && request.method == HttpMethod::Get {
527            return storefront_order_history_response(state, &request, native_response_cookies);
528        }
529        authorize_live_request(state, &mut request).await?;
530        let resolved_session = SessionContext {
531            session_id: request.session_id.clone(),
532            resolved_from_cookie: resolved.session.resolved_from_cookie,
533        };
534
535        let mut execution =
536            state
537                .plan
538                .execute_request(request, &state.cookie_secret, &state.csrf_secret)?;
539        execution.session = resolved_session;
540        if execution.principal.principal_id.is_none() {
541            execution.principal.principal_id = resolved.principal_id;
542        }
543        execution.flash_messages = resolved.flash_messages;
544        execution.response_cookies = native_response_cookies.clone();
545        execution
546    } else {
547        prepare_native_storefront_request(state, &mut request, now, &mut native_response_cookies)?;
548        if request.path == STOREFRONT_ORDER_HISTORY_JSON_PATH && request.method == HttpMethod::Get {
549            return storefront_order_history_response(state, &request, native_response_cookies);
550        }
551        authorize_live_request(state, &mut request).await?;
552        let mut execution =
553            state
554                .plan
555                .execute_request(request, &state.cookie_secret, &state.csrf_secret)?;
556        execution.response_cookies = native_response_cookies;
557        execution
558    };
559
560    let mut storefront_mutation_cookies = Vec::new();
561    if let Some(location) =
562        apply_native_cms_admin_mutations(state, &execution, now, &mut storefront_mutation_cookies)?
563    {
564        execution
565            .response_cookies
566            .extend(storefront_mutation_cookies);
567        return Ok(storefront_redirect_response(
568            &location,
569            &execution.response_cookies,
570        ));
571    }
572    if let Some(location) =
573        apply_native_storefront_mutations(state, &execution, now, &mut storefront_mutation_cookies)
574            .await?
575    {
576        execution
577            .response_cookies
578            .extend(storefront_mutation_cookies);
579        return Ok(storefront_redirect_response(
580            &location,
581            &execution.response_cookies,
582        ));
583    }
584    execution
585        .response_cookies
586        .extend(storefront_mutation_cookies);
587    let route_name = execution.route.route_name.clone();
588    let method = execution.method;
589    let session_id = execution.session.session_id.clone();
590    let principal_id = execution.principal.principal_id.clone();
591    let provider_result = execution_query_field(&execution, "provider_result").map(str::to_string);
592    let payment_reference =
593        execution_query_field(&execution, "payment_reference").map(str::to_string);
594    let checkout_session_id =
595        execution_query_field(&execution, "checkout_session_id").map(str::to_string);
596    if let Some(location) = redirect_failed_checkout_confirmation(
597        state,
598        route_name.as_str(),
599        method,
600        session_id.as_deref(),
601        principal_id.as_deref(),
602        provider_result.as_deref(),
603        payment_reference.as_deref(),
604        checkout_session_id.as_deref(),
605        now,
606        &mut execution.response_cookies,
607    )? {
608        return Ok(storefront_redirect_response(
609            &location,
610            &execution.response_cookies,
611        ));
612    }
613    let augmentation = storefront_response_augmentation(state, &execution)?;
614    let response = execution_response(&state.plan, &state.wasm_host, execution)?;
615    apply_storefront_response_augmentation(response, augmentation).await
616}
617
618fn execution_response(
619    plan: &RuntimePlan,
620    wasm_host: &WasmHost,
621    execution: RequestExecution,
622) -> Result<Response<Body>, RuntimeServerError> {
623    let receipts = LiveExecutionReceipts::collect(plan, wasm_host, &execution)?;
624    Ok(receipts.compose_response(plan, &execution)?.into_response())
625}
626
627pub(super) fn error_response(error: RuntimeServerError) -> Response<Body> {
628    match error {
629        RuntimeServerError::Storefront(
630            StorefrontStateError::UnknownSku { .. }
631            | StorefrontStateError::InvalidQuantity
632            | StorefrontStateError::MissingPaymentMethod
633            | StorefrontStateError::MissingCheckoutEmail
634            | StorefrontStateError::InvalidPaymentLast4
635            | StorefrontStateError::MissingPaymentIntent
636            | StorefrontStateError::PaymentIntentMismatch { .. }
637            | StorefrontStateError::CheckoutNotReady { .. }
638            | StorefrontStateError::EmptyCart { .. }
639            | StorefrontStateError::UnknownPaymentReference { .. }
640            | StorefrontStateError::UnknownPaymentWebhookEvent { .. }
641            | StorefrontStateError::UnexpectedPaymentWebhookProvider { .. }
642            | StorefrontStateError::MissingPaymentWebhookDeliveryId
643            | StorefrontStateError::InvalidPaymentWebhookSignature,
644        ) => (StatusCode::BAD_REQUEST, error.to_string()).into_response(),
645        RuntimeServerError::Storefront(StorefrontStateError::ReplayedPaymentWebhookDelivery {
646            ..
647        }) => (StatusCode::CONFLICT, error.to_string()).into_response(),
648        RuntimeServerError::Storefront(StorefrontStateError::MissingPaymentWebhookSecret) => {
649            (StatusCode::SERVICE_UNAVAILABLE, error.to_string()).into_response()
650        }
651        RuntimeServerError::Execution(RequestExecutionError::RouteNotFound { .. }) => {
652            (StatusCode::NOT_FOUND, "not found").into_response()
653        }
654        RuntimeServerError::Execution(RequestExecutionError::SessionRequired { .. }) => {
655            (StatusCode::UNAUTHORIZED, "session required").into_response()
656        }
657        RuntimeServerError::Execution(RequestExecutionError::CapabilityRequired { .. }) => {
658            (StatusCode::FORBIDDEN, "capability required").into_response()
659        }
660        RuntimeServerError::Execution(
661            RequestExecutionError::MissingCsrfToken { .. }
662            | RequestExecutionError::MissingSessionForCsrf { .. }
663            | RequestExecutionError::InvalidCsrfToken { .. },
664        ) => (StatusCode::FORBIDDEN, "csrf rejected").into_response(),
665        RuntimeServerError::Execution(RequestExecutionError::MaintenanceMode { .. }) => {
666            (StatusCode::SERVICE_UNAVAILABLE, "maintenance mode").into_response()
667        }
668        RuntimeServerError::Execution(RequestExecutionError::FeatureFlagDisabled { .. }) => {
669            (StatusCode::NOT_FOUND, "feature disabled").into_response()
670        }
671        RuntimeServerError::RequestBodyTooLarge { .. } => {
672            (StatusCode::PAYLOAD_TOO_LARGE, "request body too large").into_response()
673        }
674        RuntimeServerError::CustomerHookRejected { .. } => {
675            (StatusCode::CONFLICT, error.to_string()).into_response()
676        }
677        RuntimeServerError::CustomerHookFailed { .. } => {
678            (StatusCode::SERVICE_UNAVAILABLE, error.to_string()).into_response()
679        }
680        RuntimeServerError::MissingHost | RuntimeServerError::InvalidHeaderValue { .. } => {
681            (StatusCode::BAD_REQUEST, error.to_string()).into_response()
682        }
683        RuntimeServerError::Execution(_) => {
684            (StatusCode::BAD_REQUEST, error.to_string()).into_response()
685        }
686        _ => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(),
687    }
688}
689
690fn live_request_error_status(error: &RuntimeServerError) -> StatusCode {
691    match error {
692        RuntimeServerError::Storefront(
693            StorefrontStateError::UnknownSku { .. }
694            | StorefrontStateError::InvalidQuantity
695            | StorefrontStateError::MissingPaymentMethod
696            | StorefrontStateError::MissingCheckoutEmail
697            | StorefrontStateError::InvalidPaymentLast4
698            | StorefrontStateError::MissingPaymentIntent
699            | StorefrontStateError::PaymentIntentMismatch { .. }
700            | StorefrontStateError::CheckoutNotReady { .. }
701            | StorefrontStateError::EmptyCart { .. }
702            | StorefrontStateError::UnknownPaymentReference { .. }
703            | StorefrontStateError::UnknownPaymentWebhookEvent { .. }
704            | StorefrontStateError::UnexpectedPaymentWebhookProvider { .. }
705            | StorefrontStateError::MissingPaymentWebhookDeliveryId
706            | StorefrontStateError::InvalidPaymentWebhookSignature,
707        ) => StatusCode::BAD_REQUEST,
708        RuntimeServerError::Storefront(StorefrontStateError::ReplayedPaymentWebhookDelivery {
709            ..
710        }) => StatusCode::CONFLICT,
711        RuntimeServerError::Storefront(StorefrontStateError::MissingPaymentWebhookSecret) => {
712            StatusCode::SERVICE_UNAVAILABLE
713        }
714        RuntimeServerError::Execution(RequestExecutionError::RouteNotFound { .. }) => {
715            StatusCode::NOT_FOUND
716        }
717        RuntimeServerError::Execution(RequestExecutionError::SessionRequired { .. }) => {
718            StatusCode::UNAUTHORIZED
719        }
720        RuntimeServerError::Execution(RequestExecutionError::CapabilityRequired { .. }) => {
721            StatusCode::FORBIDDEN
722        }
723        RuntimeServerError::Execution(
724            RequestExecutionError::MissingCsrfToken { .. }
725            | RequestExecutionError::MissingSessionForCsrf { .. }
726            | RequestExecutionError::InvalidCsrfToken { .. },
727        ) => StatusCode::FORBIDDEN,
728        RuntimeServerError::Execution(RequestExecutionError::MaintenanceMode { .. }) => {
729            StatusCode::SERVICE_UNAVAILABLE
730        }
731        RuntimeServerError::Execution(RequestExecutionError::FeatureFlagDisabled { .. }) => {
732            StatusCode::NOT_FOUND
733        }
734        RuntimeServerError::RequestBodyTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
735        RuntimeServerError::CustomerHookRejected { .. } => StatusCode::CONFLICT,
736        RuntimeServerError::CustomerHookFailed { .. } => StatusCode::SERVICE_UNAVAILABLE,
737        RuntimeServerError::MissingHost | RuntimeServerError::InvalidHeaderValue { .. } => {
738            StatusCode::BAD_REQUEST
739        }
740        RuntimeServerError::Execution(_) => StatusCode::BAD_REQUEST,
741        _ => StatusCode::INTERNAL_SERVER_ERROR,
742    }
743}
744
745fn live_request_error_outcome(error: &RuntimeServerError) -> String {
746    match error {
747        RuntimeServerError::Execution(RequestExecutionError::RouteNotFound { .. }) => {
748            "route_not_found".to_string()
749        }
750        RuntimeServerError::Execution(RequestExecutionError::SessionRequired { .. }) => {
751            "session_required".to_string()
752        }
753        RuntimeServerError::Execution(RequestExecutionError::CapabilityRequired { .. }) => {
754            "capability_required".to_string()
755        }
756        RuntimeServerError::Execution(RequestExecutionError::MaintenanceMode { .. }) => {
757            "maintenance_mode".to_string()
758        }
759        RuntimeServerError::Execution(RequestExecutionError::FeatureFlagDisabled { .. }) => {
760            "feature_flag_disabled".to_string()
761        }
762        RuntimeServerError::Storefront(StorefrontStateError::ReplayedPaymentWebhookDelivery {
763            ..
764        }) => "storefront_conflict".to_string(),
765        RuntimeServerError::Storefront(_) => "storefront_error".to_string(),
766        RuntimeServerError::CustomerHookRejected { .. } => "customer_hook_rejected".to_string(),
767        RuntimeServerError::CustomerHookFailed { .. } => "customer_hook_failed".to_string(),
768        RuntimeServerError::RequestBodyTooLarge { .. } => "request_body_too_large".to_string(),
769        RuntimeServerError::Execution(_) => "request_error".to_string(),
770        RuntimeServerError::Authorization { .. } => "authorization_error".to_string(),
771        RuntimeServerError::Configuration { .. } => "configuration_error".to_string(),
772        RuntimeServerError::MissingHost => "missing_host".to_string(),
773        RuntimeServerError::InvalidHeaderValue { .. } => "invalid_header".to_string(),
774        _ => "internal_error".to_string(),
775    }
776}
777
778fn map_http_method(method: &Method) -> Result<HttpMethod, RuntimeServerError> {
779    match *method {
780        Method::GET => Ok(HttpMethod::Get),
781        Method::HEAD => Ok(HttpMethod::Head),
782        Method::POST => Ok(HttpMethod::Post),
783        Method::PUT => Ok(HttpMethod::Put),
784        Method::PATCH => Ok(HttpMethod::Patch),
785        Method::DELETE => Ok(HttpMethod::Delete),
786        _ => Err(RuntimeServerError::UnsupportedMethod {
787            method: method.to_string(),
788        }),
789    }
790}
791
792fn parse_cookie_header(
793    headers: &HeaderMap,
794) -> Result<BTreeMap<String, String>, RuntimeServerError> {
795    let Some(raw) = headers.get(COOKIE) else {
796        return Ok(BTreeMap::new());
797    };
798    let raw = raw
799        .to_str()
800        .map_err(|_| RuntimeServerError::InvalidHeaderValue { header: "cookie" })?;
801    let mut cookies = BTreeMap::new();
802    for segment in raw.split(';') {
803        let trimmed = segment.trim();
804        if trimmed.is_empty() {
805            continue;
806        }
807        if let Some((name, value)) = trimmed.split_once('=') {
808            cookies.insert(name.trim().to_string(), value.trim().to_string());
809        }
810    }
811    Ok(cookies)
812}
813
814fn header_value(
815    headers: &HeaderMap,
816    name: impl AsRef<str>,
817) -> Result<Option<String>, RuntimeServerError> {
818    let name = name.as_ref();
819    let Some(value) = headers.get(name) else {
820        return Ok(None);
821    };
822    Ok(Some(
823        value
824            .to_str()
825            .map_err(|_| RuntimeServerError::InvalidHeaderValue {
826                header: Box::leak(name.to_string().into_boxed_str()),
827            })?
828            .to_string(),
829    ))
830}
831
832fn normalized_request_headers(headers: &HeaderMap) -> Result<Headers, RuntimeServerError> {
833    let mut normalized = Headers::new();
834    for (name, value) in headers {
835        normalized.insert(
836            name.as_str().to_ascii_lowercase(),
837            value
838                .to_str()
839                .map_err(|_| RuntimeServerError::InvalidHeaderValue {
840                    header: Box::leak(name.as_str().to_string().into_boxed_str()),
841                })?
842                .to_string(),
843        );
844    }
845    Ok(normalized)
846}
847
848async fn parse_request_body(
849    request_method: HttpMethod,
850    request: Request<Body>,
851) -> Result<ParsedRequestBody, RuntimeServerError> {
852    if !matches!(
853        request_method,
854        HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete
855    ) {
856        return Ok(ParsedRequestBody::default());
857    }
858
859    let content_type = request
860        .headers()
861        .get(CONTENT_TYPE)
862        .map(|value| {
863            value
864                .to_str()
865                .map_err(|_| RuntimeServerError::InvalidHeaderValue {
866                    header: "content-type",
867                })
868                .map(str::to_string)
869        })
870        .transpose()?;
871    let is_form = content_type
872        .as_deref()
873        .and_then(|value| value.split(';').next().map(str::trim))
874        .is_some_and(|mime| mime.eq_ignore_ascii_case("application/x-www-form-urlencoded"));
875
876    let (_, body) = request.into_parts();
877    let bytes = to_bytes(body, usize::MAX)
878        .await
879        .map_err(|_| RuntimeServerError::RequestBodyTooLarge { limit: usize::MAX })?;
880    let form_fields = if is_form {
881        parse_request_fields(&bytes)
882    } else {
883        RequestFieldMap::new()
884    };
885    Ok(ParsedRequestBody {
886        content_type,
887        raw_body: bytes.to_vec(),
888        form_fields,
889    })
890}
891
892fn parse_request_fields(bytes: &[u8]) -> RequestFieldMap {
893    let mut fields = RequestFieldMap::new();
894    for (name, value) in form_urlencoded::parse(bytes) {
895        push_request_field(&mut fields, name.into_owned(), value.into_owned());
896    }
897    fields
898}
899
900fn prepare_native_storefront_request(
901    state: &RuntimeServerState,
902    request: &mut RequestInput,
903    now: BrowserInstant,
904    response_cookies: &mut Vec<String>,
905) -> Result<(), RuntimeServerError> {
906    let Some(matched) = state.plan.http.resolve_match(
907        &state.plan.config,
908        request.method,
909        &request.host,
910        &request.path,
911    ) else {
912        return Ok(());
913    };
914
915    let route_name = matched.resolved.route_name.as_str();
916    let is_storefront_page = request.method == HttpMethod::Get
917        && matched.route.module.as_deref() == Some("commerce")
918        && matched.route.area != RouteArea::Admin
919        && matched.route.area != RouteArea::Api
920        && matched.route.area != RouteArea::Fragment;
921    let is_account_page =
922        request.method == HttpMethod::Get && matched.route.area == RouteArea::Account;
923    let is_native_capability_route = STOREFRONT_NATIVE_CAPABILITY_ROUTES.contains(&route_name);
924    let is_native_mutation_route = matches!(
925        route_name,
926        "commerce.add-to-cart"
927            | "commerce.cart-update"
928            | "commerce.checkout-start"
929            | "commerce.checkout-complete"
930    );
931    if !is_storefront_page && !is_account_page && !is_native_mutation_route {
932        return Ok(());
933    }
934
935    ensure_storefront_session(state, request, now, response_cookies)?;
936    if is_native_capability_route {
937        request
938            .granted_capabilities
939            .insert(coil_auth::Capability::CheckoutSessionCreate);
940    }
941    Ok(())
942}
943
944fn ensure_storefront_session(
945    state: &RuntimeServerState,
946    request: &mut RequestInput,
947    now: BrowserInstant,
948    response_cookies: &mut Vec<String>,
949) -> Result<String, RuntimeServerError> {
950    if let Some(session_id) = request.session_id.clone() {
951        return Ok(session_id);
952    }
953
954    let issued = {
955        let mut browser = state
956            .browser
957            .lock()
958            .expect("runtime browser mutex poisoned");
959        browser
960            .issue_session(SessionIssueRequest::new(), &state.cookie_secret, now)
961            .map_err(RequestExecutionError::from_browser_error)?
962    };
963    request.session_id = Some(issued.record.session_id.clone());
964    response_cookies.push(issued.set_cookie_header);
965    Ok(issued.record.session_id)
966}
967
968fn push_storefront_flash(
969    state: &RuntimeServerState,
970    response_cookies: &mut Vec<String>,
971    level: FlashLevel,
972    text: impl Into<String>,
973) -> Result<(), RuntimeServerError> {
974    let message =
975        FlashMessage::new(level, text.into()).map_err(RequestExecutionError::from_browser_error)?;
976    let cookie = {
977        let browser = state
978            .browser
979            .lock()
980            .expect("runtime browser mutex poisoned");
981        browser
982            .issue_flash_cookie(&state.cookie_secret, &[message])
983            .map_err(RequestExecutionError::from_browser_error)?
984    };
985    response_cookies.push(cookie);
986    Ok(())
987}
988
989fn push_storefront_form_state(
990    state: &RuntimeServerState,
991    response_cookies: &mut Vec<String>,
992    form_state: &StorefrontFormState,
993) -> Result<(), RuntimeServerError> {
994    let message = FlashMessage::new(FlashLevel::Error, form_state.encode()?)
995        .map_err(RequestExecutionError::from_browser_error)?;
996    let cookie = {
997        let browser = state
998            .browser
999            .lock()
1000            .expect("runtime browser mutex poisoned");
1001        browser
1002            .issue_flash_cookie(&state.cookie_secret, &[message])
1003            .map_err(RequestExecutionError::from_browser_error)?
1004    };
1005    response_cookies.push(cookie);
1006    Ok(())
1007}
1008
1009fn storefront_redirect_response(location: &str, response_cookies: &[String]) -> Response<Body> {
1010    let mut response = Response::new(Body::empty());
1011    *response.status_mut() = StatusCode::SEE_OTHER;
1012    response.headers_mut().insert(
1013        HeaderName::from_static("location"),
1014        HeaderValue::from_str(location).expect("redirect location is a valid header value"),
1015    );
1016    for cookie in response_cookies {
1017        if let Ok(value) = HeaderValue::from_str(cookie) {
1018            response
1019                .headers_mut()
1020                .append(HeaderName::from_static("set-cookie"), value);
1021        }
1022    }
1023    response
1024}
1025
1026fn cms_admin_redirect_response(
1027    state: &RuntimeServerState,
1028    path: &str,
1029) -> Result<Option<Response<Body>>, RuntimeServerError> {
1030    if path.starts_with("/admin") {
1031        return Ok(None);
1032    }
1033    let workspace = CmsAdminWorkspace::load(&state.plan).map_err(|reason| {
1034        RuntimeServerError::Configuration {
1035            reason: format!("failed to load CMS admin workspace: {reason}"),
1036        }
1037    })?;
1038    let Some(redirect) = workspace.redirect_for_path(path) else {
1039        return Ok(None);
1040    };
1041    let mut response = Response::new(Body::empty());
1042    *response.status_mut() = if redirect.permanent {
1043        StatusCode::PERMANENT_REDIRECT
1044    } else {
1045        StatusCode::TEMPORARY_REDIRECT
1046    };
1047    response.headers_mut().insert(
1048        HeaderName::from_static("location"),
1049        HeaderValue::from_str(&redirect.to).expect("redirect target is a valid header value"),
1050    );
1051    Ok(Some(response))
1052}
1053
1054fn revoke_storefront_session(
1055    state: &RuntimeServerState,
1056    session_id: &str,
1057    now: BrowserInstant,
1058    response_cookies: &mut Vec<String>,
1059) -> Result<(), RuntimeServerError> {
1060    let clear_cookie = {
1061        let mut browser = state
1062            .browser
1063            .lock()
1064            .expect("runtime browser mutex poisoned");
1065        match browser.revoke_session(session_id, now) {
1066            Ok(()) => {}
1067            Err(
1068                RuntimeBrowserError::UnknownSession { .. }
1069                | RuntimeBrowserError::ExpiredSession { .. }
1070                | RuntimeBrowserError::RevokedSession { .. },
1071            ) => {}
1072            Err(error) => return Err(RequestExecutionError::from_browser_error(error).into()),
1073        }
1074        browser.clear_session_cookie_header()
1075    };
1076    response_cookies.push(clear_cookie);
1077    Ok(())
1078}
1079
1080fn parse_quantity_field(value: Option<&str>) -> Option<u32> {
1081    value.and_then(|raw| raw.trim().parse::<u32>().ok())
1082}
1083
1084fn storefront_quantity_from_execution(execution: &RequestExecution) -> u32 {
1085    parse_quantity_field(execution_form_field(execution, "quantity")).unwrap_or(1)
1086}
1087
1088fn storefront_form_field_value(execution: &RequestExecution, name: &str) -> String {
1089    execution_form_field(execution, name)
1090        .unwrap_or_default()
1091        .to_string()
1092}
1093
1094fn storefront_catalog_product_for_execution<'a>(
1095    state: &'a RuntimeServerState,
1096    execution: &RequestExecution,
1097    sku: &str,
1098) -> Option<&'a StorefrontProductDefinition> {
1099    state
1100        .plan
1101        .storefront_catalog
1102        .product_by_sku_or_handle_for_site(execution.site_id.as_deref(), sku)
1103}
1104
1105fn storefront_checkout_form_state_from_execution(
1106    execution: &RequestExecution,
1107    summary: impl Into<String>,
1108) -> StorefrontFormState {
1109    let mut state = StorefrontFormState::new("commerce.checkout", summary.into());
1110    for field in [
1111        "checkout_email",
1112        "delivery_name",
1113        "delivery_note",
1114        "payment_method",
1115        "payment_last4",
1116        "checkout_intent",
1117    ] {
1118        let value = storefront_form_field_value(execution, field);
1119        if !value.is_empty() {
1120            state = state.with_field_value(field, value);
1121        }
1122    }
1123    if execution_form_field(execution, "terms_accepted").is_some() {
1124        state = state.with_field_value("terms_accepted", "yes");
1125    }
1126    state
1127}
1128
1129#[derive(Debug)]
1130struct RuntimeCheckoutCommerceFacade<'a> {
1131    plan: &'a RuntimePlan,
1132    catalog: &'a StorefrontCatalog,
1133    site_id: Option<&'a str>,
1134    principal_id: Option<&'a str>,
1135    recorded_at_unix_seconds: u64,
1136}
1137
1138impl CommerceFacade for RuntimeCheckoutCommerceFacade<'_> {
1139    fn product(&self, sku: &str) -> Result<Option<CommerceProduct>, BackendError> {
1140        Ok(self
1141            .catalog
1142            .product_by_sku_or_handle_for_site(self.site_id, sku)
1143            .map(|product| CommerceProduct {
1144                sku: product.sku.clone(),
1145                handle: product.handle.clone(),
1146                title: product.title.clone(),
1147                current_price: MoneyAmount::new(product.currency.clone(), product.price_minor),
1148                collection_handle: Some(product.collection_handle.clone()),
1149                metadata: BTreeMap::new(),
1150            }))
1151    }
1152
1153    fn add_order_note(&self, order_id: &str, note: &str) -> Result<(), BackendError> {
1154        record_customer_order_note(
1155            self.plan,
1156            self.principal_id.unwrap_or("anonymous"),
1157            self.recorded_at_unix_seconds,
1158            order_id,
1159            note,
1160        )
1161    }
1162}
1163
1164#[derive(Debug)]
1165struct RuntimeCheckoutAuthFacade<'a> {
1166    state: &'a RuntimeServerState,
1167    execution: &'a RequestExecution,
1168}
1169
1170impl AuthFacade for RuntimeCheckoutAuthFacade<'_> {
1171    fn check_capability(
1172        &self,
1173        request: &AuthCheckRequest,
1174    ) -> Result<AuthCheckResult, BackendError> {
1175        let capability = parse_customer_capability(&request.capability)?;
1176        let object = parse_customer_auth_entity(&request.object)?;
1177        let Some(subject) = customer_hook_auth_subject(&self.execution.principal) else {
1178            return Ok(AuthCheckResult {
1179                allowed: false,
1180                explanation: Some(
1181                    "anonymous requests do not have an authenticated auth subject".to_string(),
1182                ),
1183            });
1184        };
1185        let authorizer = Arc::clone(&self.state.route_authorizer);
1186        let allowed = run_customer_hook_future(async move {
1187            authorizer
1188                .check_capability(&subject, capability, &object)
1189                .await
1190        })
1191        .map_err(customer_hook_auth_backend_error)?;
1192
1193        Ok(AuthCheckResult {
1194            allowed,
1195            explanation: (!allowed).then(|| {
1196                format!(
1197                    "live auth denied `{}` for `{}`",
1198                    capability.as_str(),
1199                    request.object
1200                )
1201            }),
1202        })
1203    }
1204
1205    fn explain_denial(
1206        &self,
1207        request: &AuthExplainRequest,
1208    ) -> Result<AuthExplanation, BackendError> {
1209        let capability = parse_customer_capability(&request.capability)?;
1210        let object = parse_customer_auth_entity(&request.object)?;
1211        let Some(explainer) = self.state.auth_explainer.clone() else {
1212            return Err(BackendError::new(
1213                BackendErrorKind::Unsupported,
1214                "auth.explain.unavailable",
1215                "Runtime auth explanations are disabled for this installation.",
1216            ));
1217        };
1218        let Some(subject) = customer_hook_auth_subject(&self.execution.principal) else {
1219            return Ok(AuthExplanation {
1220                summary: format!("deny `{}` on `{}`", capability.as_str(), request.object),
1221                traces: vec![
1222                    "anonymous requests do not have an authenticated auth subject".to_string(),
1223                ],
1224            });
1225        };
1226        let explain_request = coil_auth::LiveAuthExplainRequest {
1227            subject,
1228            capability,
1229            object,
1230            options: coil_auth::ExplainOptions::default(),
1231        };
1232        let explanation = run_customer_hook_future(async move {
1233            explainer.explain_capability(&explain_request).await
1234        })
1235        .map_err(customer_hook_auth_backend_error)?;
1236
1237        Ok(AuthExplanation {
1238            summary: format!(
1239                "{} `{}` on `{}`",
1240                if explanation.decision.is_allowed() {
1241                    "allow"
1242                } else {
1243                    "deny"
1244                },
1245                explanation.capability.as_str(),
1246                explanation.object
1247            ),
1248            traces: vec![format!("{:?}", explanation.trace)],
1249        })
1250    }
1251}
1252
1253fn cms_page_form_state_from_execution(
1254    execution: &RequestExecution,
1255    summary: impl Into<String>,
1256) -> StorefrontFormState {
1257    let mut state = StorefrontFormState::new("cms.pages.index", summary.into());
1258    for field in [
1259        "page_id",
1260        "page_title",
1261        "page_slug",
1262        "page_summary",
1263        "page_body_html",
1264    ] {
1265        let value = storefront_form_field_value(execution, field);
1266        if !value.is_empty() {
1267            state = state.with_field_value(field, value);
1268        }
1269    }
1270    state
1271}
1272
1273fn order_refund_form_state_from_execution(
1274    execution: &RequestExecution,
1275    summary: impl Into<String>,
1276) -> StorefrontFormState {
1277    let mut state = StorefrontFormState::new("commerce.order-detail", summary.into());
1278    for field in ["order_id", "reason"] {
1279        state = state.with_field_value(field, storefront_form_field_value(execution, field));
1280    }
1281    state
1282}
1283
1284fn record_operator_audit(
1285    state: &RuntimeServerState,
1286    execution: &RequestExecution,
1287    action: &str,
1288    resource_kind: &str,
1289    resource_id: &str,
1290    outcome: &str,
1291    detail: &str,
1292) -> Result<(), RuntimeServerError> {
1293    let capability = match execution.route.auth {
1294        RouteAuthGate::Capability(capability) => capability.to_string(),
1295        RouteAuthGate::Session => "session".to_string(),
1296        RouteAuthGate::Public => "public".to_string(),
1297    };
1298    let mut serializer = form_urlencoded::Serializer::new(String::new());
1299    serializer
1300        .append_pair("action", action)
1301        .append_pair("route", execution.route.route_name.as_str())
1302        .append_pair("capability", capability.as_str())
1303        .append_pair("outcome", outcome);
1304    if !resource_kind.trim().is_empty() {
1305        serializer.append_pair("resource_kind", resource_kind);
1306    }
1307    if !resource_id.trim().is_empty() {
1308        serializer.append_pair("resource_id", resource_id);
1309    }
1310    if !detail.trim().is_empty() {
1311        serializer.append_pair("detail", detail);
1312    }
1313    let kind = serializer.finish();
1314    match state
1315        .wasm_host
1316        .record_operator_audit(
1317            kind.clone(),
1318            execution.customer_app.as_str(),
1319            Some(execution.trace.request_id.as_str()),
1320            execution.principal.principal_id.as_deref(),
1321        )
1322    {
1323        Ok(()) => Ok(()),
1324        Err(_metadata_reason) => record_admin_audit_entry(
1325            &state.plan,
1326            SystemTime::now()
1327                .duration_since(UNIX_EPOCH)
1328                .unwrap_or_default()
1329                .as_secs() as i64,
1330            execution
1331                .principal
1332                .principal_id
1333                .clone()
1334                .unwrap_or_else(|| "anonymous".to_string()),
1335            kind,
1336        )
1337        .map_err(|fallback_reason| RuntimeServerError::Configuration {
1338            reason: format!(
1339                "failed to persist operator audit entry for `{action}` using the local fallback: {fallback_reason}"
1340            ),
1341        }),
1342    }
1343}
1344
1345fn cms_navigation_form_state_from_execution(
1346    execution: &RequestExecution,
1347    summary: impl Into<String>,
1348) -> StorefrontFormState {
1349    let mut state = StorefrontFormState::new("cms.navigation.index", summary.into());
1350    for (name, values) in &execution.form_fields {
1351        if (name.starts_with("nav_label_")
1352            || name.starts_with("nav_href_")
1353            || name == "new_nav_label"
1354            || name == "new_nav_href")
1355            && !values.is_empty()
1356        {
1357            state = state.with_field_value(name.clone(), values[0].clone());
1358        }
1359    }
1360    state
1361}
1362
1363fn cms_redirect_form_state_from_execution(
1364    execution: &RequestExecution,
1365    summary: impl Into<String>,
1366) -> StorefrontFormState {
1367    let mut state = StorefrontFormState::new("cms.redirects.index", summary.into());
1368    for (name, values) in &execution.form_fields {
1369        if (name.starts_with("redirect_from_")
1370            || name.starts_with("redirect_to_")
1371            || name.starts_with("redirect_permanent_")
1372            || name == "new_redirect_from"
1373            || name == "new_redirect_to"
1374            || name == "new_redirect_permanent")
1375            && !values.is_empty()
1376        {
1377            state = state.with_field_value(name.clone(), values[0].clone());
1378        }
1379    }
1380    if execution.form_fields.contains_key("new_redirect_permanent") {
1381        state = state.with_field_value("new_redirect_permanent", "yes");
1382    }
1383    state
1384}
1385
1386fn storefront_cart_form_state_from_execution(
1387    execution: &RequestExecution,
1388    summary: impl Into<String>,
1389) -> StorefrontFormState {
1390    let mut state = StorefrontFormState::new("commerce.cart", summary.into());
1391    for (name, values) in &execution.form_fields {
1392        if name.starts_with("quantity_") {
1393            if let Some(value) = values.first() {
1394                state = state.with_field_value(name.clone(), value.clone());
1395            }
1396        }
1397    }
1398    state
1399}
1400
1401fn runtime_customer_request_context(
1402    state: &RuntimeServerState,
1403    execution: &RequestExecution,
1404) -> SdkRequestContext {
1405    let environment = match state.plan.config.app.environment {
1406        coil_config::Environment::Development => "development",
1407        coil_config::Environment::Staging => "staging",
1408        coil_config::Environment::Production => "production",
1409    };
1410    let mut customer_app =
1411        SdkCustomerAppContext::new(execution.customer_app.clone(), environment.to_owned());
1412    if let Some(site_id) = execution.site_id.as_deref() {
1413        customer_app = customer_app.with_site_id(site_id.to_string());
1414    }
1415    if !execution.locale.trim().is_empty() {
1416        customer_app = customer_app.with_locale(execution.locale.clone());
1417    }
1418    let principal = match (
1419        execution.principal.principal_id.as_deref(),
1420        execution.principal.principal_kind,
1421    ) {
1422        (Some(principal_id), RequestPrincipalKind::ServiceAccount) => {
1423            SdkPrincipalContext::service_account(principal_id.to_string())
1424        }
1425        (Some(principal_id), _) => SdkPrincipalContext::user(principal_id.to_string()),
1426        (None, _) => SdkPrincipalContext::anonymous(),
1427    };
1428    let trace = SdkTraceContext::new(execution.trace.request_id.clone())
1429        .with_request_id(execution.trace.request_id.clone());
1430    SdkRequestContext::new(customer_app, principal, trace)
1431}
1432
1433#[derive(Debug)]
1434struct RuntimeRequestAuditFacade<'a> {
1435    plan: &'a RuntimePlan,
1436    principal_id: Option<&'a str>,
1437    recorded_at_unix_seconds: u64,
1438}
1439
1440impl AuditFacade for RuntimeRequestAuditFacade<'_> {
1441    fn record(&self, entry: AuditEntry) -> Result<(), BackendError> {
1442        let mut serializer = form_urlencoded::Serializer::new(String::new());
1443        serializer
1444            .append_pair("action", entry.action.as_str())
1445            .append_pair("resource_kind", entry.resource_kind.as_str())
1446            .append_pair("resource_id", entry.resource_id.as_str())
1447            .append_pair("outcome", entry.outcome.as_str());
1448        if let Some(detail) = entry.detail.as_deref() {
1449            serializer.append_pair("detail", detail);
1450        }
1451        for (key, value) in &entry.metadata {
1452            serializer.append_pair(&format!("meta.{key}"), value);
1453        }
1454
1455        record_admin_audit_entry(
1456            self.plan,
1457            self.recorded_at_unix_seconds.min(i64::MAX as u64) as i64,
1458            self.principal_id.unwrap_or("anonymous"),
1459            serializer.finish(),
1460        )
1461        .map_err(|reason| {
1462            BackendError::new(
1463                BackendErrorKind::Internal,
1464                "audit.record.failed",
1465                "Failed to persist the customer hook audit entry.",
1466            )
1467            .with_detail(reason)
1468        })
1469    }
1470}
1471
1472fn record_customer_order_note(
1473    plan: &RuntimePlan,
1474    principal_id: &str,
1475    recorded_at_unix_seconds: u64,
1476    order_id: &str,
1477    note: &str,
1478) -> Result<(), BackendError> {
1479    let mut serializer = form_urlencoded::Serializer::new(String::new());
1480    serializer
1481        .append_pair("action", "customer-plugin.order-note")
1482        .append_pair("resource_kind", "order")
1483        .append_pair("resource_id", order_id)
1484        .append_pair("outcome", "recorded")
1485        .append_pair("detail", note);
1486    record_admin_audit_entry(
1487        plan,
1488        recorded_at_unix_seconds.min(i64::MAX as u64) as i64,
1489        principal_id,
1490        serializer.finish(),
1491    )
1492    .map_err(|reason| {
1493        BackendError::new(
1494            BackendErrorKind::Internal,
1495            "commerce.order_note.failed",
1496            "Failed to persist the linked customer order note.",
1497        )
1498        .with_detail(reason)
1499    })
1500}
1501
1502#[derive(Debug)]
1503struct RuntimeCustomerRepositoryFacade<'a> {
1504    storefront: &'a StorefrontStateStore,
1505    workspace: Option<Arc<Mutex<CmsAdminWorkspace>>>,
1506    recorded_at_unix_seconds: u64,
1507    _marker: std::marker::PhantomData<&'a ()>,
1508}
1509
1510impl RepositoryFacade for RuntimeCustomerRepositoryFacade<'_> {
1511    fn read(&self, query: &RepositoryQuery) -> Result<RepositoryRecordSet, BackendError> {
1512        let records = match query.repository.as_str() {
1513            "cms.pages" => self
1514                .workspace
1515                .as_ref()
1516                .ok_or_else(|| {
1517                    BackendError::new(
1518                        BackendErrorKind::Unsupported,
1519                        "repository.read.unsupported",
1520                        "Runtime customer hooks did not expose a CMS workspace for this request.",
1521                    )
1522                })?
1523                .lock()
1524                .map_err(|_| {
1525                    BackendError::new(
1526                        BackendErrorKind::Internal,
1527                        "repository.workspace.lock_failed",
1528                        "Runtime could not acquire the CMS workspace lock.",
1529                    )
1530                })?
1531                .pages
1532                .iter()
1533                .filter(|page| {
1534                    query.key.as_deref().map_or(true, |key| {
1535                        page.id == key
1536                            || page.draft.slug == key
1537                            || page.live.as_ref().is_some_and(|live| live.slug == key)
1538                    })
1539                })
1540                .map(|page| {
1541                    let mut fields = BTreeMap::new();
1542                    fields.insert("title".to_string(), page.draft.title.clone());
1543                    fields.insert("slug".to_string(), page.draft.slug.clone());
1544                    fields.insert("summary".to_string(), page.draft.summary.clone());
1545                    fields.insert("body_html".to_string(), page.draft.body_html.clone());
1546                    fields.insert("status".to_string(), page.status_label().to_string());
1547                    if let Some(live_path) = page.live_path() {
1548                        fields.insert("live_path".to_string(), live_path);
1549                    }
1550                    RepositoryRecord {
1551                        id: page.id.clone(),
1552                        fields,
1553                    }
1554                })
1555                .collect(),
1556            "cms.navigation" => self
1557                .workspace
1558                .as_ref()
1559                .ok_or_else(|| {
1560                    BackendError::new(
1561                        BackendErrorKind::Unsupported,
1562                        "repository.read.unsupported",
1563                        "Runtime customer hooks did not expose a CMS workspace for this request.",
1564                    )
1565                })?
1566                .lock()
1567                .map_err(|_| {
1568                    BackendError::new(
1569                        BackendErrorKind::Internal,
1570                        "repository.workspace.lock_failed",
1571                        "Runtime could not acquire the CMS workspace lock.",
1572                    )
1573                })?
1574                .navigation
1575                .iter()
1576                .enumerate()
1577                .filter(|(index, item)| {
1578                    query
1579                        .key
1580                        .as_deref()
1581                        .map_or(true, |key| key == index.to_string() || key == item.href)
1582                })
1583                .map(|(index, item)| RepositoryRecord {
1584                    id: index.to_string(),
1585                    fields: BTreeMap::from([
1586                        ("label".to_string(), item.label.clone()),
1587                        ("href".to_string(), item.href.clone()),
1588                    ]),
1589                })
1590                .collect(),
1591            "cms.redirects" => self
1592                .workspace
1593                .as_ref()
1594                .ok_or_else(|| {
1595                    BackendError::new(
1596                        BackendErrorKind::Unsupported,
1597                        "repository.read.unsupported",
1598                        "Runtime customer hooks did not expose a CMS workspace for this request.",
1599                    )
1600                })?
1601                .lock()
1602                .map_err(|_| {
1603                    BackendError::new(
1604                        BackendErrorKind::Internal,
1605                        "repository.workspace.lock_failed",
1606                        "Runtime could not acquire the CMS workspace lock.",
1607                    )
1608                })?
1609                .redirects
1610                .iter()
1611                .enumerate()
1612                .filter(|(index, redirect)| {
1613                    query
1614                        .key
1615                        .as_deref()
1616                        .map_or(true, |key| key == index.to_string() || key == redirect.from)
1617                })
1618                .map(|(index, redirect)| RepositoryRecord {
1619                    id: index.to_string(),
1620                    fields: BTreeMap::from([
1621                        ("from".to_string(), redirect.from.clone()),
1622                        ("to".to_string(), redirect.to.clone()),
1623                        ("permanent".to_string(), redirect.permanent.to_string()),
1624                    ]),
1625                })
1626                .collect(),
1627            "commerce.catalog.products" => self
1628                .storefront
1629                .catalog()
1630                .map_err(|error| {
1631                    BackendError::new(
1632                        BackendErrorKind::Unavailable,
1633                        "repository.read.failed",
1634                        "Runtime could not load the effective storefront catalog products.",
1635                    )
1636                    .with_detail(error.to_string())
1637                })?
1638                .products
1639                .iter()
1640                .filter(|product| {
1641                    query
1642                        .key
1643                        .as_deref()
1644                        .map_or(true, |key| product.handle == key || product.sku == key)
1645                        && query
1646                            .filters
1647                            .get("collection_handle")
1648                            .map_or(true, |handle| product.collection_handle == *handle)
1649                })
1650                .map(|product| RepositoryRecord {
1651                    id: product.handle.clone(),
1652                    fields: BTreeMap::from([
1653                        ("handle".to_string(), product.handle.clone()),
1654                        ("sku".to_string(), product.sku.clone()),
1655                        ("title".to_string(), product.title.clone()),
1656                        ("summary".to_string(), product.summary.clone()),
1657                        ("price_minor".to_string(), product.price_minor.to_string()),
1658                        ("currency".to_string(), product.currency.clone()),
1659                        (
1660                            "collection_handle".to_string(),
1661                            product.collection_handle.clone(),
1662                        ),
1663                        ("is_visible".to_string(), product.is_visible.to_string()),
1664                        ("product_kind".to_string(), product.product_kind.clone()),
1665                        (
1666                            "entitlement_key".to_string(),
1667                            product.entitlement_key.clone().unwrap_or_default(),
1668                        ),
1669                    ]),
1670                })
1671                .collect(),
1672            "commerce.catalog.collections" => self
1673                .storefront
1674                .catalog()
1675                .map_err(|error| {
1676                    BackendError::new(
1677                        BackendErrorKind::Unavailable,
1678                        "repository.read.failed",
1679                        "Runtime could not load the effective storefront catalog collections.",
1680                    )
1681                    .with_detail(error.to_string())
1682                })?
1683                .collections
1684                .iter()
1685                .filter(|collection| {
1686                    query
1687                        .key
1688                        .as_deref()
1689                        .map_or(true, |key| collection.handle == key)
1690                })
1691                .map(|collection| RepositoryRecord {
1692                    id: collection.handle.clone(),
1693                    fields: BTreeMap::from([
1694                        ("handle".to_string(), collection.handle.clone()),
1695                        ("title".to_string(), collection.title.clone()),
1696                        ("label".to_string(), collection.label.clone()),
1697                        ("summary".to_string(), collection.summary.clone()),
1698                        ("is_visible".to_string(), collection.is_visible.to_string()),
1699                    ]),
1700                })
1701                .collect(),
1702            "commerce.orders" => {
1703                let order = if let Some(key) = query.key.as_deref() {
1704                    self.storefront.admin_order(key).map_err(|error| {
1705                        BackendError::new(
1706                            BackendErrorKind::Unavailable,
1707                            "repository.read.failed",
1708                            "Runtime could not load the requested commerce order.",
1709                        )
1710                        .with_detail(error.to_string())
1711                    })?
1712                } else if let Some(payment_reference) = query.filters.get("payment_reference") {
1713                    self.storefront
1714                        .order_by_payment_reference(payment_reference)
1715                        .map_err(|error| {
1716                            BackendError::new(
1717                                BackendErrorKind::Unavailable,
1718                                "repository.read.failed",
1719                                "Runtime could not load the requested commerce order.",
1720                            )
1721                            .with_detail(error.to_string())
1722                        })?
1723                } else {
1724                    None
1725                };
1726                order
1727                    .into_iter()
1728                    .map(|order| RepositoryRecord {
1729                        id: order.order_id.clone(),
1730                        fields: BTreeMap::from([
1731                            ("status".to_string(), order.status),
1732                            ("payment_status".to_string(), order.payment.status),
1733                            (
1734                                "payment_reference".to_string(),
1735                                order.payment.reference.unwrap_or_default(),
1736                            ),
1737                            (
1738                                "payment_method".to_string(),
1739                                order.payment.method.unwrap_or_default(),
1740                            ),
1741                            (
1742                                "checkout_email".to_string(),
1743                                order.payment.checkout_email.unwrap_or_default(),
1744                            ),
1745                            (
1746                                "principal_id".to_string(),
1747                                order.principal_id.unwrap_or_default(),
1748                            ),
1749                            ("currency".to_string(), order.currency),
1750                            ("total_minor".to_string(), order.total_minor.to_string()),
1751                            ("line_count".to_string(), order.line_count.to_string()),
1752                        ]),
1753                    })
1754                    .collect()
1755            }
1756            _ => {
1757                return Err(BackendError::new(
1758                    BackendErrorKind::Unsupported,
1759                    "repository.read.unsupported",
1760                    format!(
1761                        "Runtime customer hooks only expose `cms.pages`, `cms.navigation`, `cms.redirects`, `commerce.catalog.products`, `commerce.catalog.collections`, and `commerce.orders` reads; `{}` is not available.",
1762                        query.repository
1763                    ),
1764                ));
1765            }
1766        };
1767
1768        Ok(RepositoryRecordSet {
1769            repository: query.repository.clone(),
1770            records,
1771        })
1772    }
1773
1774    fn write(&self, change: RepositoryWrite) -> Result<RepositoryWriteReceipt, BackendError> {
1775        match change.repository.as_str() {
1776            "cms.pages" => {
1777                let mut workspace = self
1778                    .workspace
1779                    .as_ref()
1780                    .ok_or_else(|| {
1781                        BackendError::new(
1782                            BackendErrorKind::Unsupported,
1783                            "repository.write.unsupported",
1784                            "Runtime customer hooks did not expose a CMS workspace for this request.",
1785                        )
1786                    })?
1787                    .lock()
1788                    .map_err(|_| {
1789                        BackendError::new(
1790                            BackendErrorKind::Internal,
1791                            "repository.workspace.lock_failed",
1792                            "Runtime could not acquire the CMS workspace lock.",
1793                        )
1794                    })?;
1795                let existing = workspace
1796                    .selected_page(Some(change.record_id.as_str()))
1797                    .cloned()
1798                    .ok_or_else(|| {
1799                        BackendError::new(
1800                            BackendErrorKind::InvalidInput,
1801                            "repository.write.unknown_record",
1802                            format!(
1803                                "Customer CMS hook tried to write unknown page `{}`.",
1804                                change.record_id
1805                            ),
1806                        )
1807                    })?;
1808                let input = CmsAdminPageInput {
1809                    page_id: Some(change.record_id.clone()),
1810                    title: change
1811                        .fields
1812                        .get("title")
1813                        .cloned()
1814                        .unwrap_or(existing.draft.title),
1815                    slug: change
1816                        .fields
1817                        .get("slug")
1818                        .cloned()
1819                        .unwrap_or(existing.draft.slug),
1820                    summary: change
1821                        .fields
1822                        .get("summary")
1823                        .cloned()
1824                        .unwrap_or(existing.draft.summary),
1825                    body_html: change
1826                        .fields
1827                        .get("body_html")
1828                        .cloned()
1829                        .unwrap_or(existing.draft.body_html),
1830                };
1831                let page_id = workspace
1832                    .save_page_draft(input, self.recorded_at_unix_seconds)
1833                    .map_err(|reason| {
1834                        BackendError::new(
1835                            BackendErrorKind::InvalidInput,
1836                            "repository.write.invalid_page",
1837                            "Customer CMS hook submitted an invalid page draft update.",
1838                        )
1839                        .with_detail(reason)
1840                    })?;
1841
1842                Ok(RepositoryWriteReceipt {
1843                    repository: change.repository,
1844                    record_id: page_id,
1845                    version: Some(self.recorded_at_unix_seconds.to_string()),
1846                })
1847            }
1848            "cms.navigation" => {
1849                let mut workspace = self
1850                    .workspace
1851                    .as_ref()
1852                    .ok_or_else(|| {
1853                        BackendError::new(
1854                            BackendErrorKind::Unsupported,
1855                            "repository.write.unsupported",
1856                            "Runtime customer hooks did not expose a CMS workspace for this request.",
1857                        )
1858                    })?
1859                    .lock()
1860                    .map_err(|_| {
1861                        BackendError::new(
1862                            BackendErrorKind::Internal,
1863                            "repository.workspace.lock_failed",
1864                            "Runtime could not acquire the CMS workspace lock.",
1865                        )
1866                    })?;
1867                let mut items = workspace.navigation.clone();
1868                let record_id = if change.record_id == "append" || change.record_id == "new" {
1869                    let item = CmsAdminNavigationItem {
1870                        label: change.fields.get("label").cloned().ok_or_else(|| {
1871                            BackendError::new(
1872                                BackendErrorKind::InvalidInput,
1873                                "repository.write.invalid_navigation",
1874                                "Customer CMS hook must provide a label when appending navigation.",
1875                            )
1876                        })?,
1877                        href: change.fields.get("href").cloned().ok_or_else(|| {
1878                            BackendError::new(
1879                                BackendErrorKind::InvalidInput,
1880                                "repository.write.invalid_navigation",
1881                                "Customer CMS hook must provide an href when appending navigation.",
1882                            )
1883                        })?,
1884                    };
1885                    items.push(item);
1886                    (items.len() - 1).to_string()
1887                } else {
1888                    let index = change.record_id.parse::<usize>().map_err(|_| {
1889                        BackendError::new(
1890                            BackendErrorKind::InvalidInput,
1891                            "repository.write.invalid_navigation_record",
1892                            format!(
1893                                "Customer CMS hook must target a numeric navigation record id or `append`; `{}` is not valid.",
1894                                change.record_id
1895                            ),
1896                        )
1897                    })?;
1898                    let existing = items.get_mut(index).ok_or_else(|| {
1899                        BackendError::new(
1900                            BackendErrorKind::InvalidInput,
1901                            "repository.write.unknown_record",
1902                            format!(
1903                                "Customer CMS hook tried to write unknown navigation item `{}`.",
1904                                change.record_id
1905                            ),
1906                        )
1907                    })?;
1908                    if let Some(label) = change.fields.get("label") {
1909                        existing.label = label.clone();
1910                    }
1911                    if let Some(href) = change.fields.get("href") {
1912                        existing.href = href.clone();
1913                    }
1914                    change.record_id.clone()
1915                };
1916                workspace.save_navigation(items).map_err(|reason| {
1917                    BackendError::new(
1918                        BackendErrorKind::InvalidInput,
1919                        "repository.write.invalid_navigation",
1920                        "Customer CMS hook submitted an invalid navigation update.",
1921                    )
1922                    .with_detail(reason)
1923                })?;
1924                Ok(RepositoryWriteReceipt {
1925                    repository: change.repository,
1926                    record_id,
1927                    version: Some(self.recorded_at_unix_seconds.to_string()),
1928                })
1929            }
1930            "cms.redirects" => {
1931                let mut workspace = self
1932                    .workspace
1933                    .as_ref()
1934                    .ok_or_else(|| {
1935                        BackendError::new(
1936                            BackendErrorKind::Unsupported,
1937                            "repository.write.unsupported",
1938                            "Runtime customer hooks did not expose a CMS workspace for this request.",
1939                        )
1940                    })?
1941                    .lock()
1942                    .map_err(|_| {
1943                        BackendError::new(
1944                            BackendErrorKind::Internal,
1945                            "repository.workspace.lock_failed",
1946                            "Runtime could not acquire the CMS workspace lock.",
1947                        )
1948                    })?;
1949                let mut redirects = workspace.redirects.clone();
1950                let record_id = if change.record_id == "append" || change.record_id == "new" {
1951                    let redirect = CmsAdminRedirect {
1952                        from: change.fields.get("from").cloned().ok_or_else(|| {
1953                            BackendError::new(
1954                                BackendErrorKind::InvalidInput,
1955                                "repository.write.invalid_redirect",
1956                                "Customer CMS hook must provide a `from` path when appending a redirect.",
1957                            )
1958                        })?,
1959                        to: change.fields.get("to").cloned().ok_or_else(|| {
1960                            BackendError::new(
1961                                BackendErrorKind::InvalidInput,
1962                                "repository.write.invalid_redirect",
1963                                "Customer CMS hook must provide a `to` path when appending a redirect.",
1964                            )
1965                        })?,
1966                        permanent: change
1967                            .fields
1968                            .get("permanent")
1969                            .map(|value| {
1970                                matches!(
1971                                    value.trim().to_ascii_lowercase().as_str(),
1972                                    "1" | "true" | "yes" | "on"
1973                                )
1974                            })
1975                            .unwrap_or(false),
1976                    };
1977                    redirects.push(redirect);
1978                    (redirects.len() - 1).to_string()
1979                } else {
1980                    let index = change.record_id.parse::<usize>().map_err(|_| {
1981                        BackendError::new(
1982                            BackendErrorKind::InvalidInput,
1983                            "repository.write.invalid_redirect_record",
1984                            format!(
1985                                "Customer CMS hook must target a numeric redirect record id or `append`; `{}` is not valid.",
1986                                change.record_id
1987                            ),
1988                        )
1989                    })?;
1990                    let existing = redirects.get_mut(index).ok_or_else(|| {
1991                        BackendError::new(
1992                            BackendErrorKind::InvalidInput,
1993                            "repository.write.unknown_record",
1994                            format!(
1995                                "Customer CMS hook tried to write unknown redirect `{}`.",
1996                                change.record_id
1997                            ),
1998                        )
1999                    })?;
2000                    if let Some(from) = change.fields.get("from") {
2001                        existing.from = from.clone();
2002                    }
2003                    if let Some(to) = change.fields.get("to") {
2004                        existing.to = to.clone();
2005                    }
2006                    if let Some(permanent) = change.fields.get("permanent") {
2007                        existing.permanent = matches!(
2008                            permanent.trim().to_ascii_lowercase().as_str(),
2009                            "1" | "true" | "yes" | "on"
2010                        );
2011                    }
2012                    change.record_id.clone()
2013                };
2014                workspace.save_redirects(redirects).map_err(|reason| {
2015                    BackendError::new(
2016                        BackendErrorKind::InvalidInput,
2017                        "repository.write.invalid_redirect",
2018                        "Customer CMS hook submitted an invalid redirect update.",
2019                    )
2020                    .with_detail(reason)
2021                })?;
2022                Ok(RepositoryWriteReceipt {
2023                    repository: change.repository,
2024                    record_id,
2025                    version: Some(self.recorded_at_unix_seconds.to_string()),
2026                })
2027            }
2028            "commerce.catalog.products" => {
2029                let catalog = self.storefront.catalog().map_err(|error| {
2030                    BackendError::new(
2031                        BackendErrorKind::Unavailable,
2032                        "repository.write.failed",
2033                        "Runtime could not load the effective storefront catalog product.",
2034                    )
2035                    .with_detail(error.to_string())
2036                })?;
2037                let existing = catalog
2038                    .product_by_sku_or_handle(change.record_id.as_str())
2039                    .cloned()
2040                    .ok_or_else(|| {
2041                        BackendError::new(
2042                            BackendErrorKind::InvalidInput,
2043                            "repository.write.unknown_record",
2044                            format!(
2045                                "Customer hook tried to write unknown catalog product `{}`.",
2046                                change.record_id
2047                            ),
2048                        )
2049                    })?;
2050                let update = crate::storefront::StorefrontCatalogProductUpdate {
2051                    handle: existing.handle.clone(),
2052                    title: change
2053                        .fields
2054                        .get("title")
2055                        .cloned()
2056                        .unwrap_or(existing.title.clone()),
2057                    summary: change
2058                        .fields
2059                        .get("summary")
2060                        .cloned()
2061                        .unwrap_or(existing.summary.clone()),
2062                    price_minor: repository_i64_field(
2063                        &change,
2064                        "price_minor",
2065                        existing.price_minor,
2066                    )?,
2067                    collection_handle: change
2068                        .fields
2069                        .get("collection_handle")
2070                        .cloned()
2071                        .unwrap_or(existing.collection_handle.clone()),
2072                    is_visible: repository_bool_field(&change, "is_visible", existing.is_visible)?,
2073                };
2074                self.storefront
2075                    .update_catalog_product(&update, self.recorded_at_unix_seconds)
2076                    .map_err(|error| {
2077                        BackendError::new(
2078                            BackendErrorKind::Unavailable,
2079                            "repository.write.failed",
2080                            "Runtime could not persist the storefront catalog product override.",
2081                        )
2082                        .with_detail(error.to_string())
2083                    })?;
2084                Ok(RepositoryWriteReceipt {
2085                    repository: change.repository,
2086                    record_id: update.handle,
2087                    version: Some(self.recorded_at_unix_seconds.to_string()),
2088                })
2089            }
2090            "commerce.catalog.collections" => {
2091                let catalog = self.storefront.catalog().map_err(|error| {
2092                    BackendError::new(
2093                        BackendErrorKind::Unavailable,
2094                        "repository.write.failed",
2095                        "Runtime could not load the effective storefront catalog collection.",
2096                    )
2097                    .with_detail(error.to_string())
2098                })?;
2099                let existing = catalog
2100                    .collection(change.record_id.as_str())
2101                    .cloned()
2102                    .ok_or_else(|| {
2103                        BackendError::new(
2104                            BackendErrorKind::InvalidInput,
2105                            "repository.write.unknown_record",
2106                            format!(
2107                                "Customer hook tried to write unknown catalog collection `{}`.",
2108                                change.record_id
2109                            ),
2110                        )
2111                    })?;
2112                let update = crate::storefront::StorefrontCatalogCollectionUpdate {
2113                    handle: existing.handle.clone(),
2114                    title: change
2115                        .fields
2116                        .get("title")
2117                        .cloned()
2118                        .unwrap_or(existing.title.clone()),
2119                    label: change
2120                        .fields
2121                        .get("label")
2122                        .cloned()
2123                        .unwrap_or(existing.label.clone()),
2124                    summary: change
2125                        .fields
2126                        .get("summary")
2127                        .cloned()
2128                        .unwrap_or(existing.summary.clone()),
2129                    is_visible: repository_bool_field(&change, "is_visible", existing.is_visible)?,
2130                };
2131                self.storefront
2132                    .update_catalog_collection(&update, self.recorded_at_unix_seconds)
2133                    .map_err(|error| {
2134                        BackendError::new(
2135                            BackendErrorKind::Unavailable,
2136                            "repository.write.failed",
2137                            "Runtime could not persist the storefront catalog collection override.",
2138                        )
2139                        .with_detail(error.to_string())
2140                    })?;
2141                Ok(RepositoryWriteReceipt {
2142                    repository: change.repository,
2143                    record_id: update.handle,
2144                    version: Some(self.recorded_at_unix_seconds.to_string()),
2145                })
2146            }
2147            _ => Err(BackendError::new(
2148                BackendErrorKind::Unsupported,
2149                "repository.write.unsupported",
2150                format!(
2151                    "Runtime customer hooks only expose `cms.pages`, `cms.navigation`, `cms.redirects`, `commerce.catalog.products`, and `commerce.catalog.collections` writes; `{}` is not available.",
2152                    change.repository
2153                ),
2154            )),
2155        }
2156    }
2157}
2158
2159fn repository_i64_field(
2160    change: &RepositoryWrite,
2161    field: &str,
2162    default: i64,
2163) -> Result<i64, BackendError> {
2164    match change.fields.get(field) {
2165        Some(value) => value.parse::<i64>().map_err(|_| {
2166            BackendError::new(
2167                BackendErrorKind::InvalidInput,
2168                "repository.write.invalid_integer",
2169                format!(
2170                    "Customer hook field `{field}` for repository `{}` must be a valid integer.",
2171                    change.repository
2172                ),
2173            )
2174        }),
2175        None => Ok(default),
2176    }
2177}
2178
2179fn repository_bool_field(
2180    change: &RepositoryWrite,
2181    field: &str,
2182    default: bool,
2183) -> Result<bool, BackendError> {
2184    match change.fields.get(field) {
2185        Some(value) => match value.trim().to_ascii_lowercase().as_str() {
2186            "1" | "true" | "yes" | "on" => Ok(true),
2187            "0" | "false" | "no" | "off" => Ok(false),
2188            _ => Err(BackendError::new(
2189                BackendErrorKind::InvalidInput,
2190                "repository.write.invalid_bool",
2191                format!(
2192                    "Customer hook field `{field}` for repository `{}` must be a valid boolean.",
2193                    change.repository
2194                ),
2195            )),
2196        },
2197        None => Ok(default),
2198    }
2199}
2200
2201#[derive(Debug)]
2202struct RuntimeCustomerJobsFacade<'a> {
2203    plan: &'a RuntimePlan,
2204    trace_id: &'a str,
2205    now: BrowserInstant,
2206}
2207
2208impl JobsFacade for RuntimeCustomerJobsFacade<'_> {
2209    fn enqueue(
2210        &self,
2211        request: coil_customer_sdk::JobRequest,
2212    ) -> Result<JobReceipt, BackendError> {
2213        let mut host = self
2214            .plan
2215            .jobs_host(format!("customer-hooks-{}", self.trace_id))
2216            .map_err(|error| {
2217                BackendError::new(
2218                    BackendErrorKind::Unavailable,
2219                    "jobs.host.unavailable",
2220                    "Runtime jobs coordination is unavailable for the customer webhook hook.",
2221                )
2222                .with_detail(error.to_string())
2223            })?;
2224        let Some(definition) = host
2225            .registered_jobs
2226            .iter()
2227            .find(|definition| definition.contract.name == request.job_name)
2228            .cloned()
2229        else {
2230            return Err(BackendError::new(
2231                BackendErrorKind::InvalidInput,
2232                "jobs.name.unknown",
2233                format!(
2234                    "Customer webhook requested unknown runtime job `{}`.",
2235                    request.job_name
2236                ),
2237            ));
2238        };
2239        if definition.queue.as_str() != request.queue {
2240            return Err(BackendError::new(
2241                BackendErrorKind::InvalidInput,
2242                "jobs.queue.mismatch",
2243                format!(
2244                    "Customer webhook requested queue `{}` for `{}`, but the registered runtime job uses `{}`.",
2245                    request.queue, request.job_name, definition.queue
2246                ),
2247            ));
2248        }
2249
2250        let mut dispatch =
2251            JobDispatchRequest::new(request.job_name.clone(), request.payload_description)
2252                .map_err(|error| {
2253                    BackendError::new(
2254                        BackendErrorKind::InvalidInput,
2255                        "jobs.dispatch.invalid",
2256                        "Customer webhook requested an invalid runtime job dispatch.",
2257                    )
2258                    .with_detail(error.to_string())
2259                })?;
2260        if let Some(idempotency_key) = request.idempotency_key {
2261            dispatch = dispatch
2262                .with_idempotency_key(idempotency_key)
2263                .map_err(|error| {
2264                    BackendError::new(
2265                        BackendErrorKind::InvalidInput,
2266                        "jobs.idempotency.invalid",
2267                        "Customer webhook requested an invalid idempotency key.",
2268                    )
2269                    .with_detail(error.to_string())
2270                })?;
2271        }
2272        let enqueued = host
2273            .enqueue_job(
2274                dispatch,
2275                JobInstant::from_unix_seconds(self.now.as_unix_seconds()),
2276            )
2277            .map_err(|error| {
2278                BackendError::new(
2279                    BackendErrorKind::Unavailable,
2280                    "jobs.enqueue.failed",
2281                    "Runtime could not enqueue the customer webhook follow-up job.",
2282                )
2283                .with_detail(error.to_string())
2284            })?;
2285
2286        Ok(JobReceipt {
2287            queue: definition.queue.to_string(),
2288            job_id: enqueued.to_string(),
2289        })
2290    }
2291}
2292
2293#[derive(Debug)]
2294struct RuntimeCustomerOutboundHttpFacade<'a> {
2295    wasm_host: &'a WasmHost,
2296}
2297
2298impl OutboundHttpFacade for RuntimeCustomerOutboundHttpFacade<'_> {
2299    fn send(&self, request: OutboundHttpRequest) -> Result<OutboundHttpResponse, BackendError> {
2300        self.wasm_host
2301            .send_outbound_http(&request)
2302            .map_err(|reason| {
2303                BackendError::new(
2304                    BackendErrorKind::Unavailable,
2305                    "http.send.failed",
2306                    format!(
2307                        "Runtime could not execute approved outbound HTTP integration `{}`.",
2308                        request.integration
2309                    ),
2310                )
2311                .with_detail(reason)
2312            })
2313    }
2314}
2315
2316#[derive(Debug, Clone)]
2317struct RuntimeCustomerAssetsFacade {
2318    storage: StorageHost,
2319    wasm_host: WasmHost,
2320    recorded_at_unix_seconds: i64,
2321}
2322
2323impl AssetsFacade for RuntimeCustomerAssetsFacade {
2324    fn publish(&self, request: AssetWriteRequest) -> Result<AssetWriteReceipt, BackendError> {
2325        let storage_class = parse_customer_storage_class(request.storage_class.as_str())?;
2326        let content_type = request
2327            .content_type
2328            .clone()
2329            .unwrap_or_else(|| "application/octet-stream".to_string());
2330        let revision = plan_customer_hook_asset_revision(
2331            &self.storage,
2332            &request.logical_path,
2333            storage_class,
2334            &content_type,
2335            &request.bytes,
2336        )?;
2337        let receipt = self
2338            .storage
2339            .execute_write_with_content_type(
2340                revision.storage_plan(),
2341                &request.bytes,
2342                Some(&content_type),
2343            )
2344            .map_err(customer_hook_storage_backend_error)?;
2345        let mut asset = coil_assets::ManagedAsset::new(
2346            customer_hook_asset_id(&request.logical_path)?,
2347            request.logical_path.clone(),
2348            revision,
2349        )
2350        .map_err(customer_hook_asset_model_error)?;
2351        asset.publish_current();
2352        let persisted = persisted_customer_managed_asset_record(&asset)?;
2353        let record_json = serde_json::to_string(&persisted).map_err(|error| {
2354            customer_hook_asset_internal_error_with_detail(
2355                "failed to serialize the managed asset record",
2356                error.to_string(),
2357            )
2358        })?;
2359        self.wasm_host
2360            .upsert_customer_managed_asset(
2361                request.logical_path.as_str(),
2362                &record_json,
2363                self.recorded_at_unix_seconds,
2364            )
2365            .map_err(|reason| {
2366                customer_hook_asset_internal_error_with_detail(
2367                    "failed to persist the managed asset record",
2368                    reason,
2369                )
2370            })?;
2371        Ok(AssetWriteReceipt {
2372            logical_path: request.logical_path,
2373            storage_path: receipt.path.display().to_string(),
2374            bytes_written: receipt.bytes_written,
2375        })
2376    }
2377
2378    fn inspect(&self, logical_path: &str) -> Result<Option<ManagedAsset>, BackendError> {
2379        let Some(record_json) = self
2380            .wasm_host
2381            .customer_managed_asset(logical_path)
2382            .map_err(|reason| {
2383                customer_hook_asset_internal_error_with_detail(
2384                    "failed to inspect the persisted managed asset record",
2385                    reason,
2386                )
2387            })?
2388        else {
2389            return Ok(None);
2390        };
2391        let record = serde_json::from_str::<PersistedCustomerManagedAssetRecord>(&record_json)
2392            .map_err(|error| {
2393                customer_hook_asset_internal_error_with_detail(
2394                    "failed to decode the persisted managed asset record",
2395                    error.to_string(),
2396                )
2397            })?;
2398        let asset = runtime_asset_from_persisted_customer_managed_asset(&self.storage, &record)?;
2399        sdk_managed_asset_from_runtime_asset(&self.storage, &asset).map(Some)
2400    }
2401}
2402
2403fn checkout_order_draft(
2404    state: &RuntimeServerState,
2405    execution: &RequestExecution,
2406    session_id: &str,
2407    principal_id: Option<&str>,
2408    payment: &StorefrontPaymentInput,
2409) -> Result<OrderDraft, RuntimeServerError> {
2410    let snapshot = state.storefront.snapshot(session_id, principal_id)?;
2411    let lines = snapshot
2412        .cart
2413        .lines
2414        .iter()
2415        .map(|line| {
2416            let collection_handle = state
2417                .plan
2418                .storefront_catalog
2419                .product_by_sku_or_handle_for_site(execution.site_id.as_deref(), &line.sku)
2420                .map(|product| product.collection_handle.clone());
2421            OrderLineDraft {
2422                sku: line.sku.clone(),
2423                title: line.title.clone(),
2424                quantity: line.quantity,
2425                unit_price: MoneyAmount::new(line.currency.clone(), line.unit_price_minor),
2426                product_kind: line.product_kind.clone(),
2427                collection_handle,
2428                entitlement_key: line.entitlement_key.clone(),
2429                metadata: line.metadata.clone(),
2430            }
2431        })
2432        .collect::<Vec<_>>();
2433    let metadata = storefront_checkout_order_metadata(
2434        state,
2435        execution,
2436        &snapshot,
2437        session_id,
2438        principal_id,
2439        payment,
2440    );
2441    Ok(OrderDraft {
2442        order_id: format!("draft:{}", payment.intent_reference),
2443        currency_code: snapshot.cart.currency.clone(),
2444        subtotal: MoneyAmount::new(snapshot.cart.currency.clone(), snapshot.cart.subtotal_minor),
2445        total: MoneyAmount::new(snapshot.cart.currency.clone(), snapshot.cart.subtotal_minor),
2446        lines,
2447        metadata,
2448    })
2449}
2450
2451fn storefront_checkout_order_metadata(
2452    state: &RuntimeServerState,
2453    execution: &RequestExecution,
2454    snapshot: &StorefrontStateSnapshot,
2455    session_id: &str,
2456    principal_id: Option<&str>,
2457    payment: &StorefrontPaymentInput,
2458) -> BTreeMap<String, String> {
2459    let mut metadata = BTreeMap::new();
2460    metadata.insert("session_id".to_string(), session_id.to_string());
2461    metadata.insert(
2462        "payment_method".to_string(),
2463        payment.method.trim().to_ascii_lowercase(),
2464    );
2465    metadata.insert(
2466        "checkout_email".to_string(),
2467        payment.checkout_email.trim().to_string(),
2468    );
2469    if let Some(principal_id) = principal_id {
2470        metadata.insert("order_principal_id".to_string(), principal_id.to_string());
2471    }
2472    if let Some(shipping_country) = execution_form_field(execution, "shipping_country")
2473        .or_else(|| execution_form_field(execution, "country"))
2474        .map(str::trim)
2475        .filter(|value| !value.is_empty())
2476    {
2477        metadata.insert(
2478            "shipping_country".to_string(),
2479            shipping_country.to_ascii_uppercase(),
2480        );
2481    }
2482    if let Some(expedited_requested) = execution_form_field(execution, "expedited_requested")
2483        .or_else(|| execution_form_field(execution, "expedited"))
2484        .or_else(|| execution_form_field(execution, "priority_shipping"))
2485        .map(str::trim)
2486        .filter(|value| !value.is_empty())
2487    {
2488        metadata.insert(
2489            "expedited_requested".to_string(),
2490            normalize_checkout_flag(expedited_requested).to_string(),
2491        );
2492    }
2493    metadata.insert(
2494        "membership_tier".to_string(),
2495        storefront_membership_tier(state, execution, snapshot)
2496            .unwrap_or("guest")
2497            .to_string(),
2498    );
2499    metadata
2500}
2501
2502fn storefront_membership_tier<'a>(
2503    state: &'a RuntimeServerState,
2504    execution: &'a RequestExecution,
2505    snapshot: &'a StorefrontStateSnapshot,
2506) -> Option<&'static str> {
2507    snapshot.cart.lines.iter().find_map(|line| {
2508        let product = state
2509            .plan
2510            .storefront_catalog
2511            .product_by_sku_or_handle_for_site(execution.site_id.as_deref(), &line.sku)?;
2512        match product.entitlement_key.as_deref() {
2513            Some("membership.gold") => Some("gold"),
2514            Some(entitlement) if entitlement.starts_with("membership.") => Some("standard"),
2515            _ => None,
2516        }
2517    })
2518}
2519
2520fn normalize_checkout_flag(value: &str) -> &'static str {
2521    if matches!(
2522        value.trim().to_ascii_lowercase().as_str(),
2523        "1" | "true" | "yes" | "y" | "on"
2524    ) {
2525        "true"
2526    } else {
2527        "false"
2528    }
2529}
2530
2531fn customer_checkout_error_summary(error: &BackendError) -> Cow<'static, str> {
2532    match error.kind() {
2533        BackendErrorKind::InvalidInput
2534        | BackendErrorKind::Forbidden
2535        | BackendErrorKind::Conflict
2536        | BackendErrorKind::Unauthorized => Cow::Owned(error.message().to_string()),
2537        BackendErrorKind::Unsupported => Cow::Borrowed(
2538            "Checkout could not continue because a required customer backend feature is not available in this runtime yet.",
2539        ),
2540        BackendErrorKind::Unavailable | BackendErrorKind::Timeout | BackendErrorKind::Internal => {
2541            Cow::Borrowed("Checkout is temporarily unavailable. Review the basket and try again.")
2542        }
2543    }
2544}
2545
2546fn customer_cms_publish_error_summary(error: &BackendError) -> Cow<'static, str> {
2547    match error.kind() {
2548        BackendErrorKind::InvalidInput
2549        | BackendErrorKind::Forbidden
2550        | BackendErrorKind::Conflict
2551        | BackendErrorKind::Unauthorized => Cow::Owned(error.message().to_string()),
2552        BackendErrorKind::Unsupported => Cow::Borrowed(
2553            "Publishing could not continue because a required customer backend feature is not available in this runtime yet.",
2554        ),
2555        BackendErrorKind::Unavailable | BackendErrorKind::Timeout | BackendErrorKind::Internal => {
2556            Cow::Borrowed("Publishing is temporarily unavailable. Review the page and try again.")
2557        }
2558    }
2559}
2560
2561fn customer_hook_request_headers(execution: &RequestExecution) -> Headers {
2562    let mut headers = execution.headers.clone();
2563    headers.insert(
2564        "x-coil-customer-app".to_string(),
2565        execution.customer_app.clone(),
2566    );
2567    headers.insert(
2568        "x-coil-request-id".to_string(),
2569        execution.trace.request_id.clone(),
2570    );
2571    headers.insert(
2572        "x-coil-route".to_string(),
2573        execution.route.route_name.clone(),
2574    );
2575    headers
2576}
2577
2578fn cms_page_draft_from_workspace(page: &CmsAdminPage, locale: &str) -> CmsPageDraft {
2579    let mut metadata = BTreeMap::new();
2580    metadata.insert("status".to_string(), page.status_label().to_string());
2581    metadata.insert(
2582        "published_once".to_string(),
2583        if page.published_once { "true" } else { "false" }.to_string(),
2584    );
2585    if let Some(live_path) = page.live_path() {
2586        metadata.insert("live_path".to_string(), live_path);
2587    }
2588    CmsPageDraft {
2589        page_id: page.id.clone(),
2590        slug: page.draft.slug.clone(),
2591        title: page.draft.title.clone(),
2592        summary: page.draft.summary.clone(),
2593        body_html: page.draft.body_html.clone(),
2594        locale: (!locale.trim().is_empty()).then(|| locale.to_string()),
2595        metadata,
2596    }
2597}
2598
2599fn validate_cms_publish_with_customer_hooks(
2600    state: &RuntimeServerState,
2601    execution: &RequestExecution,
2602    workspace: &mut CmsAdminWorkspace,
2603    page_id: &str,
2604    now: BrowserInstant,
2605    response_cookies: &mut Vec<String>,
2606) -> Result<Option<String>, RuntimeServerError> {
2607    if state.plan.customer_hooks.cms.is_empty() {
2608        return Ok(None);
2609    }
2610
2611    let page = workspace.selected_page(Some(page_id)).ok_or_else(|| {
2612        RuntimeServerError::Configuration {
2613            reason: format!("CMS page `{page_id}` was not found during publish validation"),
2614        }
2615    })?;
2616    let context = runtime_customer_request_context(state, execution);
2617    let draft = cms_page_draft_from_workspace(page, execution.locale.as_str());
2618    let workspace_shadow = Arc::new(Mutex::new(workspace.clone()));
2619    let repositories = RuntimeCustomerRepositoryFacade {
2620        storefront: &state.storefront,
2621        workspace: Some(Arc::clone(&workspace_shadow)),
2622        recorded_at_unix_seconds: now.as_unix_seconds(),
2623        _marker: std::marker::PhantomData,
2624    };
2625    let audit = RuntimeRequestAuditFacade {
2626        plan: &state.plan,
2627        principal_id: execution.principal.principal_id.as_deref(),
2628        recorded_at_unix_seconds: now.as_unix_seconds(),
2629    };
2630
2631    for hook in &state.plan.customer_hooks.cms {
2632        match hook.validate_page_publish(&context, &draft, &repositories, &audit) {
2633            Ok(CmsPublishDecision::Allow) => {}
2634            Ok(CmsPublishDecision::Reject { message, .. }) => {
2635                let form_state = cms_page_form_state_from_execution(execution, message);
2636                push_storefront_form_state(state, response_cookies, &form_state)?;
2637                return Ok(Some(format!("/admin/pages?page={page_id}")));
2638            }
2639            Err(error) => {
2640                let form_state = cms_page_form_state_from_execution(
2641                    execution,
2642                    customer_cms_publish_error_summary(&error),
2643                );
2644                push_storefront_form_state(state, response_cookies, &form_state)?;
2645                return Ok(Some(format!("/admin/pages?page={page_id}")));
2646            }
2647        }
2648    }
2649
2650    *workspace = workspace_shadow
2651        .lock()
2652        .map_err(|_| RuntimeServerError::Configuration {
2653            reason: "failed to recover the mutated CMS workspace after customer hooks".to_string(),
2654        })?
2655        .clone();
2656
2657    Ok(None)
2658}
2659
2660fn execute_verified_webhook_customer_hooks(
2661    state: &RuntimeServerState,
2662    execution: &RequestExecution,
2663    webhook: &VerifiedWebhook,
2664    now: BrowserInstant,
2665) -> Result<(), RuntimeServerError> {
2666    if state.plan.customer_hooks.verified_webhooks.is_empty()
2667        && state.plan.customer_hooks.verified_webhook_assets.is_empty()
2668    {
2669        return Ok(());
2670    }
2671
2672    let context = runtime_customer_request_context(state, execution);
2673    let http = RuntimeCustomerOutboundHttpFacade {
2674        wasm_host: &state.wasm_host,
2675    };
2676    let jobs = RuntimeCustomerJobsFacade {
2677        plan: &state.plan,
2678        trace_id: execution.trace.request_id.as_str(),
2679        now,
2680    };
2681    let repositories = RuntimeCustomerRepositoryFacade {
2682        storefront: &state.storefront,
2683        workspace: None,
2684        recorded_at_unix_seconds: now.as_unix_seconds(),
2685        _marker: std::marker::PhantomData,
2686    };
2687    let assets = RuntimeCustomerAssetsFacade {
2688        storage: state.plan.storage_host_with_object_store(
2689            state
2690                .backends
2691                .object_store
2692                .as_ref()
2693                .and_then(|backend| backend.object_store_client_config()),
2694        ),
2695        wasm_host: state.wasm_host.clone(),
2696        recorded_at_unix_seconds: now.as_unix_seconds() as i64,
2697    };
2698    let audit = RuntimeRequestAuditFacade {
2699        plan: &state.plan,
2700        principal_id: execution.principal.principal_id.as_deref(),
2701        recorded_at_unix_seconds: now.as_unix_seconds(),
2702    };
2703
2704    for hook in &state.plan.customer_hooks.verified_webhooks {
2705        match hook.handle_verified_webhook(&context, webhook, &http, &jobs, &repositories, &audit) {
2706            Ok(WebhookHandlingResult::Accepted { detail }) => {
2707                audit
2708                    .record(
2709                        AuditEntry::new(
2710                            "customer-plugin.verified-webhook",
2711                            "webhook",
2712                            format!("{}:{}", webhook.source, webhook.event),
2713                            "accepted",
2714                        )
2715                        .with_detail(detail.unwrap_or_else(|| {
2716                            "customer hook accepted verified webhook".to_string()
2717                        })),
2718                    )
2719                    .map_err(|error| RuntimeServerError::CustomerHookFailed {
2720                        surface: "verified-webhook",
2721                        reason: error.to_string(),
2722                    })?;
2723            }
2724            Ok(WebhookHandlingResult::Rejected { code, message }) => {
2725                audit
2726                    .record(
2727                        AuditEntry::new(
2728                            "customer-plugin.verified-webhook",
2729                            "webhook",
2730                            format!("{}:{}", webhook.source, webhook.event),
2731                            "rejected",
2732                        )
2733                        .with_detail(format!("{code}: {message}")),
2734                    )
2735                    .map_err(|error| RuntimeServerError::CustomerHookFailed {
2736                        surface: "verified-webhook",
2737                        reason: error.to_string(),
2738                    })?;
2739                return Err(RuntimeServerError::CustomerHookRejected {
2740                    surface: "verified-webhook",
2741                    code,
2742                    message,
2743                });
2744            }
2745            Err(error) => {
2746                audit
2747                    .record(
2748                        AuditEntry::new(
2749                            "customer-plugin.verified-webhook",
2750                            "webhook",
2751                            format!("{}:{}", webhook.source, webhook.event),
2752                            "failed",
2753                        )
2754                        .with_detail(error.to_string()),
2755                    )
2756                    .map_err(|audit_error| RuntimeServerError::CustomerHookFailed {
2757                        surface: "verified-webhook",
2758                        reason: audit_error.to_string(),
2759                    })?;
2760                return Err(RuntimeServerError::CustomerHookFailed {
2761                    surface: "verified-webhook",
2762                    reason: error.to_string(),
2763                });
2764            }
2765        }
2766    }
2767
2768    for hook in &state.plan.customer_hooks.verified_webhook_assets {
2769        match hook.handle_verified_webhook(
2770            &context,
2771            webhook,
2772            &http,
2773            &jobs,
2774            &repositories,
2775            &audit,
2776            &assets,
2777        ) {
2778            Ok(WebhookHandlingResult::Accepted { detail }) => {
2779                audit
2780                    .record(
2781                        AuditEntry::new(
2782                            "customer-plugin.verified-webhook.assets",
2783                            "webhook",
2784                            format!("{}:{}", webhook.source, webhook.event),
2785                            "accepted",
2786                        )
2787                        .with_detail(detail.unwrap_or_else(|| {
2788                            "customer asset-aware hook accepted verified webhook".to_string()
2789                        })),
2790                    )
2791                    .map_err(|error| RuntimeServerError::CustomerHookFailed {
2792                        surface: "verified-webhook",
2793                        reason: error.to_string(),
2794                    })?;
2795            }
2796            Ok(WebhookHandlingResult::Rejected { code, message }) => {
2797                audit
2798                    .record(
2799                        AuditEntry::new(
2800                            "customer-plugin.verified-webhook.assets",
2801                            "webhook",
2802                            format!("{}:{}", webhook.source, webhook.event),
2803                            "rejected",
2804                        )
2805                        .with_detail(format!("{code}: {message}")),
2806                    )
2807                    .map_err(|error| RuntimeServerError::CustomerHookFailed {
2808                        surface: "verified-webhook",
2809                        reason: error.to_string(),
2810                    })?;
2811                return Err(RuntimeServerError::CustomerHookRejected {
2812                    surface: "verified-webhook",
2813                    code,
2814                    message,
2815                });
2816            }
2817            Err(error) => {
2818                return Err(RuntimeServerError::CustomerHookFailed {
2819                    surface: "verified-webhook",
2820                    reason: error.to_string(),
2821                });
2822            }
2823        }
2824    }
2825
2826    Ok(())
2827}
2828
2829fn review_checkout_with_customer_hooks(
2830    state: &RuntimeServerState,
2831    execution: &RequestExecution,
2832    session_id: &str,
2833    payment: &StorefrontPaymentInput,
2834    now: BrowserInstant,
2835    response_cookies: &mut Vec<String>,
2836) -> Result<Option<String>, RuntimeServerError> {
2837    if state.plan.customer_hooks.checkout.is_empty() {
2838        return Ok(None);
2839    }
2840
2841    let context = runtime_customer_request_context(state, execution);
2842    let order = checkout_order_draft(
2843        state,
2844        execution,
2845        session_id,
2846        execution.principal.principal_id.as_deref(),
2847        payment,
2848    )?;
2849    let commerce = RuntimeCheckoutCommerceFacade {
2850        plan: &state.plan,
2851        catalog: &state.plan.storefront_catalog,
2852        site_id: execution.site_id.as_deref(),
2853        principal_id: execution.principal.principal_id.as_deref(),
2854        recorded_at_unix_seconds: now.as_unix_seconds(),
2855    };
2856    let auth = RuntimeCheckoutAuthFacade { state, execution };
2857    let audit = RuntimeRequestAuditFacade {
2858        plan: &state.plan,
2859        principal_id: execution.principal.principal_id.as_deref(),
2860        recorded_at_unix_seconds: now.as_unix_seconds(),
2861    };
2862    let mut adjustment_messages = Vec::new();
2863
2864    for hook in &state.plan.customer_hooks.checkout {
2865        match hook.review_order(&context, &order, &commerce, &auth, &audit) {
2866            Ok(OrderReviewDecision::Approved) => {}
2867            Ok(OrderReviewDecision::Adjusted(adjustment)) => {
2868                adjustment_messages.push(adjustment.reason);
2869            }
2870            Ok(OrderReviewDecision::Rejected(rejection)) => {
2871                let form_state =
2872                    storefront_checkout_form_state_from_execution(execution, rejection.message);
2873                push_storefront_form_state(state, response_cookies, &form_state)?;
2874                return Ok(Some("/checkout".to_string()));
2875            }
2876            Err(error) => {
2877                let form_state = storefront_checkout_form_state_from_execution(
2878                    execution,
2879                    customer_checkout_error_summary(&error),
2880                );
2881                push_storefront_form_state(state, response_cookies, &form_state)?;
2882                return Ok(Some("/checkout".to_string()));
2883            }
2884        }
2885    }
2886
2887    for message in adjustment_messages {
2888        push_storefront_flash(state, response_cookies, FlashLevel::Info, message)?;
2889    }
2890
2891    Ok(None)
2892}
2893
2894fn catalog_admin_product_form_state_from_execution(
2895    execution: &RequestExecution,
2896    summary: impl Into<String>,
2897) -> StorefrontFormState {
2898    let mut state = StorefrontFormState::new("commerce.catalog-admin", summary.into());
2899    for field in [
2900        "catalog_entity",
2901        "product_handle",
2902        "product_title",
2903        "product_summary",
2904        "product_price",
2905        "product_collection_handle",
2906    ] {
2907        let value = storefront_form_field_value(execution, field);
2908        if !value.is_empty() {
2909            state = state.with_field_value(field, value);
2910        }
2911    }
2912    if storefront_form_field_value(execution, "product_visible") == "yes" {
2913        state = state.with_field_value("product_visible", "yes");
2914    }
2915    state
2916}
2917
2918fn catalog_admin_collection_form_state_from_execution(
2919    execution: &RequestExecution,
2920    summary: impl Into<String>,
2921) -> StorefrontFormState {
2922    let mut state = StorefrontFormState::new("commerce.catalog-admin", summary.into());
2923    for field in [
2924        "catalog_entity",
2925        "collection_handle",
2926        "collection_title",
2927        "collection_label",
2928        "collection_summary",
2929    ] {
2930        let value = storefront_form_field_value(execution, field);
2931        if !value.is_empty() {
2932            state = state.with_field_value(field, value);
2933        }
2934    }
2935    if storefront_form_field_value(execution, "collection_visible") == "yes" {
2936        state = state.with_field_value("collection_visible", "yes");
2937    }
2938    state
2939}
2940
2941fn parse_decimal_price_minor(value: &str) -> Option<i64> {
2942    let trimmed = value.trim().trim_start_matches('£');
2943    if trimmed.is_empty() || trimmed.starts_with('-') {
2944        return None;
2945    }
2946    let mut parts = trimmed.split('.');
2947    let pounds = parts.next()?;
2948    let pence = parts.next().unwrap_or("00");
2949    if parts.next().is_some()
2950        || pounds.is_empty()
2951        || !pounds.chars().all(|ch| ch.is_ascii_digit())
2952        || !pence.chars().all(|ch| ch.is_ascii_digit())
2953    {
2954        return None;
2955    }
2956    let pence = match pence.len() {
2957        0 => "00".to_string(),
2958        1 => format!("{pence}0"),
2959        2 => pence.to_string(),
2960        _ => return None,
2961    };
2962    let pounds = pounds.parse::<i64>().ok()?;
2963    let pence = pence.parse::<i64>().ok()?;
2964    let minor = pounds.checked_mul(100)?.checked_add(pence)?;
2965    (minor > 0).then_some(minor)
2966}
2967
2968fn validated_catalog_admin_update_from_execution(
2969    execution: &RequestExecution,
2970) -> Result<CatalogAdminMutationInput, StorefrontFormState> {
2971    match execution_form_field(execution, "catalog_entity").unwrap_or_default() {
2972        "product" => {
2973            let mut form_state = catalog_admin_product_form_state_from_execution(
2974                execution,
2975                "Fix the highlighted product fields and save again.",
2976            );
2977            let handle = storefront_form_field_value(execution, "product_handle");
2978            let title = storefront_form_field_value(execution, "product_title");
2979            let summary = storefront_form_field_value(execution, "product_summary");
2980            let price = storefront_form_field_value(execution, "product_price");
2981            let collection_handle =
2982                storefront_form_field_value(execution, "product_collection_handle");
2983            let is_visible = storefront_form_field_value(execution, "product_visible") == "yes";
2984            let mut has_errors = false;
2985            if handle.trim().is_empty() {
2986                has_errors = true;
2987                form_state = form_state.with_field_error(
2988                    "product_handle",
2989                    "Refresh the page and try again before saving this product.",
2990                );
2991            }
2992            if title.trim().is_empty() {
2993                has_errors = true;
2994                form_state = form_state.with_field_error("product_title", "Enter a product title.");
2995            }
2996            if summary.trim().is_empty() {
2997                has_errors = true;
2998                form_state =
2999                    form_state.with_field_error("product_summary", "Enter a product summary.");
3000            }
3001            if collection_handle.trim().is_empty() {
3002                has_errors = true;
3003                form_state = form_state.with_field_error(
3004                    "product_collection_handle",
3005                    "Choose a collection for this product.",
3006                );
3007            }
3008            let price_minor = match parse_decimal_price_minor(&price) {
3009                Some(price_minor) => price_minor,
3010                None => {
3011                    has_errors = true;
3012                    form_state = form_state.with_field_error(
3013                        "product_price",
3014                        "Enter a positive GBP price such as 29.00.",
3015                    );
3016                    0
3017                }
3018            };
3019            if has_errors {
3020                return Err(form_state);
3021            }
3022            Ok(CatalogAdminMutationInput::Product(
3023                crate::storefront::StorefrontCatalogProductUpdate {
3024                    handle,
3025                    title,
3026                    summary,
3027                    price_minor,
3028                    collection_handle,
3029                    is_visible,
3030                },
3031            ))
3032        }
3033        "collection" => {
3034            let mut form_state = catalog_admin_collection_form_state_from_execution(
3035                execution,
3036                "Fix the highlighted collection fields and save again.",
3037            );
3038            let handle = storefront_form_field_value(execution, "collection_handle");
3039            let title = storefront_form_field_value(execution, "collection_title");
3040            let label = storefront_form_field_value(execution, "collection_label");
3041            let summary = storefront_form_field_value(execution, "collection_summary");
3042            let is_visible = storefront_form_field_value(execution, "collection_visible") == "yes";
3043            let mut has_errors = false;
3044            if handle.trim().is_empty() {
3045                has_errors = true;
3046                form_state = form_state.with_field_error(
3047                    "collection_handle",
3048                    "Refresh the page and try again before saving this collection.",
3049                );
3050            }
3051            if title.trim().is_empty() {
3052                has_errors = true;
3053                form_state =
3054                    form_state.with_field_error("collection_title", "Enter a collection title.");
3055            }
3056            if label.trim().is_empty() {
3057                has_errors = true;
3058                form_state =
3059                    form_state.with_field_error("collection_label", "Enter a merchandising label.");
3060            }
3061            if summary.trim().is_empty() {
3062                has_errors = true;
3063                form_state = form_state
3064                    .with_field_error("collection_summary", "Enter a collection summary.");
3065            }
3066            if has_errors {
3067                return Err(form_state);
3068            }
3069            Ok(CatalogAdminMutationInput::Collection(
3070                crate::storefront::StorefrontCatalogCollectionUpdate {
3071                    handle,
3072                    title,
3073                    label,
3074                    summary,
3075                    is_visible,
3076                },
3077            ))
3078        }
3079        _ => Err(StorefrontFormState::new(
3080            "commerce.catalog-admin",
3081            "Refresh the catalog admin page and try the save action again.",
3082        )),
3083    }
3084}
3085
3086fn validated_cart_quantities_from_execution(
3087    execution: &RequestExecution,
3088) -> Result<BTreeMap<String, u32>, StorefrontFormState> {
3089    let mut quantities = BTreeMap::new();
3090    let mut form_state = storefront_cart_form_state_from_execution(
3091        execution,
3092        "Fix the highlighted cart quantities and try again.",
3093    );
3094    let mut has_errors = false;
3095    for (name, values) in &execution.form_fields {
3096        let Some(product_slug) = name.strip_prefix("quantity_") else {
3097            continue;
3098        };
3099        let raw = values.first().cloned().unwrap_or_default();
3100        match raw.trim().parse::<u32>() {
3101            Ok(quantity) => {
3102                quantities.insert(product_slug.to_string(), quantity);
3103            }
3104            Err(_) => {
3105                has_errors = true;
3106                form_state = form_state
3107                    .with_field_error(name.clone(), "Enter a whole-number quantity for this line.");
3108            }
3109        }
3110    }
3111    if has_errors {
3112        Err(form_state)
3113    } else {
3114        Ok(quantities)
3115    }
3116}
3117
3118fn validated_storefront_payment_input_from_execution(
3119    state: &RuntimeServerState,
3120    execution: &RequestExecution,
3121) -> Result<StorefrontPaymentInput, StorefrontFormState> {
3122    let hosted_checkout = configured_commerce_payment_provider(&state.plan.config)
3123        .map(|provider| provider.uses_hosted_checkout())
3124        .unwrap_or(false);
3125    let checkout_email = execution_form_field(execution, "checkout_email")
3126        .or_else(|| execution_form_field(execution, "checkoutEmail"))
3127        .or_else(|| execution_form_field(execution, "email"))
3128        .or_else(|| execution_form_field(execution, "billing_email"))
3129        .unwrap_or_default()
3130        .trim()
3131        .to_string();
3132    let last4 = execution_form_field(execution, "payment_last4")
3133        .or_else(|| execution_form_field(execution, "paymentLast4"))
3134        .or_else(|| execution_form_field(execution, "card_last4"))
3135        .unwrap_or_default()
3136        .trim()
3137        .to_string();
3138    let method = execution_form_field(execution, "payment_method")
3139        .or_else(|| execution_form_field(execution, "paymentMethod"))
3140        .map(str::trim)
3141        .filter(|value| !value.is_empty())
3142        .map(str::to_string)
3143        .or_else(|| (!last4.is_empty()).then(|| "card".to_string()))
3144        .unwrap_or_default();
3145    let mut form_state = storefront_checkout_form_state_from_execution(
3146        execution,
3147        "There is a problem with your checkout details.",
3148    );
3149    let mut has_errors = false;
3150    if checkout_email.is_empty() {
3151        has_errors = true;
3152        form_state = form_state.with_field_error(
3153            "checkout_email",
3154            "Enter the email address for order confirmation.",
3155        );
3156    }
3157    if method.is_empty() && !hosted_checkout {
3158        has_errors = true;
3159        form_state = form_state.with_field_error(
3160            "payment_method",
3161            "Choose or confirm a payment method before placing the order.",
3162        );
3163    }
3164    if method == "card"
3165        && !hosted_checkout
3166        && (last4.len() != 4 || !last4.chars().all(|character| character.is_ascii_digit()))
3167    {
3168        has_errors = true;
3169        form_state = form_state.with_field_error(
3170            "payment_last4",
3171            "Enter the final 4 digits for the payment card.",
3172        );
3173    }
3174    if execution_form_field(execution, "checkout_intent")
3175        .or_else(|| execution_form_field(execution, "payment_intent"))
3176        .or_else(|| execution_form_field(execution, "payment_reference"))
3177        .or_else(|| execution_form_field(execution, "paymentReference"))
3178        .is_none()
3179    {
3180        has_errors = true;
3181        form_state = form_state
3182            .with_summary("Refresh checkout before placing the order.")
3183            .with_field_error(
3184                "checkout_intent",
3185                "Refresh checkout and try again before placing the order.",
3186            );
3187    }
3188    if execution_form_field(execution, "terms_accepted").is_none() {
3189        has_errors = true;
3190        form_state = form_state.with_field_error(
3191            "terms_accepted",
3192            "Review the basket and confirm the final total before placing the order.",
3193        );
3194    }
3195    if has_errors {
3196        return Err(form_state);
3197    }
3198    let intent_reference = execution_form_field(execution, "checkout_intent")
3199        .or_else(|| execution_form_field(execution, "payment_intent"))
3200        .or_else(|| execution_form_field(execution, "payment_reference"))
3201        .or_else(|| execution_form_field(execution, "paymentReference"))
3202        .unwrap_or_default();
3203    let method = if method.is_empty() && hosted_checkout {
3204        "card".to_string()
3205    } else {
3206        method
3207    };
3208    let payment = if hosted_checkout && last4.is_empty() {
3209        StorefrontPaymentInput::hosted(method, checkout_email, intent_reference)
3210    } else {
3211        StorefrontPaymentInput::new(
3212            method,
3213            checkout_email,
3214            (!last4.is_empty()).then_some(last4),
3215            intent_reference,
3216        )
3217    };
3218    payment.map_err(|error| {
3219        let mut form_state = storefront_checkout_form_state_from_execution(
3220            execution,
3221            "There is a problem with your checkout details.",
3222        );
3223        let (field, message) = match error {
3224            StorefrontStateError::MissingPaymentMethod => (
3225                "payment_method",
3226                "Choose or confirm a payment method before placing the order.",
3227            ),
3228            StorefrontStateError::MissingCheckoutEmail => (
3229                "checkout_email",
3230                "Enter the email address for order confirmation.",
3231            ),
3232            StorefrontStateError::InvalidPaymentLast4 => (
3233                "payment_last4",
3234                "Enter the final 4 digits for the payment card.",
3235            ),
3236            StorefrontStateError::MissingPaymentIntent => (
3237                "checkout_intent",
3238                "Refresh checkout and try again before placing the order.",
3239            ),
3240            _ => (
3241                "checkout_email",
3242                "Update the checkout details and try again.",
3243            ),
3244        };
3245        form_state = form_state.with_field_error(field, message);
3246        form_state
3247    })
3248}
3249
3250fn verified_webhook_headers(execution: &RequestExecution, source: &str, event: &str) -> Headers {
3251    let mut headers = customer_hook_request_headers(execution);
3252    headers.insert(
3253        "x-coil-verified-webhook-source".to_string(),
3254        source.to_string(),
3255    );
3256    headers.insert(
3257        "x-coil-verified-webhook-event".to_string(),
3258        event.to_string(),
3259    );
3260    if let Some(delivery_id) = execution
3261        .headers
3262        .get("stripe-signature")
3263        .filter(|_| source == "stripe")
3264        .and_then(|_| stripe_event_delivery_id_from_request_body(execution).ok())
3265    {
3266        headers.insert(
3267            "x-coil-verified-webhook-delivery-id".to_string(),
3268            delivery_id,
3269        );
3270    }
3271    if let Some(content_type) = execution.content_type.clone() {
3272        headers.insert("content-type".to_string(), content_type);
3273    }
3274    headers
3275}
3276
3277fn verified_webhook_payload(execution: &RequestExecution) -> Result<Vec<u8>, RuntimeServerError> {
3278    if !execution.raw_body.is_empty() {
3279        return Ok(execution.raw_body.clone());
3280    }
3281    serde_json::to_vec(&execution.form_fields).map_err(|error| RuntimeServerError::Configuration {
3282        reason: format!("failed to encode verified webhook payload for customer hooks: {error}"),
3283    })
3284}
3285
3286fn validated_verified_payment_webhook_from_execution(
3287    state: &RuntimeServerState,
3288    execution: &RequestExecution,
3289) -> Result<VerifiedIngressWebhook, RuntimeServerError> {
3290    if configured_commerce_payment_provider(&state.plan.config)
3291        .as_ref()
3292        .is_some_and(|provider| provider.code == "stripe")
3293        && execution.headers.contains_key("stripe-signature")
3294    {
3295        return validated_stripe_payment_webhook_from_execution(state, execution);
3296    }
3297    validated_generic_verified_payment_webhook_from_execution(state, execution)
3298}
3299
3300fn validated_generic_verified_payment_webhook_from_execution(
3301    state: &RuntimeServerState,
3302    execution: &RequestExecution,
3303) -> Result<VerifiedIngressWebhook, RuntimeServerError> {
3304    let provider = execution_form_field(execution, "provider")
3305        .unwrap_or("generic")
3306        .trim()
3307        .to_ascii_lowercase();
3308    if let Some(configured_provider) = configured_commerce_payment_provider(&state.plan.config) {
3309        if provider != configured_provider.code {
3310            return Err(RuntimeServerError::Storefront(
3311                StorefrontStateError::UnexpectedPaymentWebhookProvider {
3312                    expected: configured_provider.code,
3313                    received: provider,
3314                },
3315            ));
3316        }
3317    }
3318    let event = execution_form_field(execution, "event")
3319        .or_else(|| execution_form_field(execution, "payment_event"))
3320        .map(str::trim)
3321        .filter(|value| !value.is_empty())
3322        .map(str::to_string)
3323        .ok_or_else(|| {
3324            RuntimeServerError::Storefront(StorefrontStateError::UnknownPaymentWebhookEvent {
3325                event: "<missing>".to_string(),
3326            })
3327        })?;
3328    let payment_reference = execution_form_field(execution, "payment_reference")
3329        .or_else(|| execution_form_field(execution, "paymentReference"))
3330        .map(str::trim)
3331        .filter(|value| !value.is_empty())
3332        .map(str::to_string)
3333        .ok_or_else(|| {
3334            RuntimeServerError::Storefront(StorefrontStateError::UnknownPaymentReference {
3335                payment_reference: "<missing>".to_string(),
3336            })
3337        })?;
3338    let signature = execution_form_field(execution, "signature")
3339        .or_else(|| execution_form_field(execution, "webhook_signature"))
3340        .map(str::trim)
3341        .filter(|value| !value.is_empty())
3342        .ok_or(RuntimeServerError::Storefront(
3343            StorefrontStateError::InvalidPaymentWebhookSignature,
3344        ))?;
3345    let secret = state
3346        .payment_webhook_secret
3347        .as_deref()
3348        .ok_or(RuntimeServerError::Storefront(
3349            StorefrontStateError::MissingPaymentWebhookSecret,
3350        ))?;
3351    let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|_| {
3352        RuntimeServerError::Storefront(StorefrontStateError::MissingPaymentWebhookSecret)
3353    })?;
3354    mac.update(provider.as_bytes());
3355    mac.update(b":");
3356    mac.update(event.as_bytes());
3357    mac.update(b":");
3358    mac.update(payment_reference.as_bytes());
3359    let provided_signature = decode_hex_signature(signature).ok_or(
3360        RuntimeServerError::Storefront(StorefrontStateError::InvalidPaymentWebhookSignature),
3361    )?;
3362    if mac.verify_slice(&provided_signature).is_err() {
3363        return Err(RuntimeServerError::Storefront(
3364            StorefrontStateError::InvalidPaymentWebhookSignature,
3365        ));
3366    }
3367    let payload = verified_webhook_payload(execution)?;
3368    let delivery_id =
3369        generic_verified_webhook_delivery_id(&provider, &event, &payment_reference, &payload);
3370    let mut headers = verified_webhook_headers(execution, &provider, &event);
3371    headers.insert(
3372        "x-coil-verified-webhook-delivery-id".to_string(),
3373        delivery_id.clone(),
3374    );
3375    Ok(VerifiedIngressWebhook {
3376        webhook: VerifiedWebhook {
3377            source: provider.clone(),
3378            event: event.clone(),
3379            headers,
3380            content_type: execution.content_type.clone(),
3381            payload,
3382        },
3383        payment_reference: Some(payment_reference),
3384        delivery_id: Some(delivery_id),
3385    })
3386}
3387
3388fn validated_stripe_payment_webhook_from_execution(
3389    state: &RuntimeServerState,
3390    execution: &RequestExecution,
3391) -> Result<VerifiedIngressWebhook, RuntimeServerError> {
3392    let secret = state
3393        .payment_webhook_secret
3394        .as_deref()
3395        .ok_or(RuntimeServerError::Storefront(
3396            StorefrontStateError::MissingPaymentWebhookSecret,
3397        ))?;
3398    let signature = execution
3399        .headers
3400        .get("stripe-signature")
3401        .map(String::as_str)
3402        .ok_or(RuntimeServerError::Storefront(
3403            StorefrontStateError::InvalidPaymentWebhookSignature,
3404        ))?;
3405    let payload = verified_webhook_payload(execution)?;
3406    verify_stripe_webhook_signature(secret, signature, &payload)?;
3407    let event = serde_json::from_slice::<serde_json::Value>(&payload).map_err(|error| {
3408        RuntimeServerError::Configuration {
3409            reason: format!("failed to decode Stripe webhook payload: {error}"),
3410        }
3411    })?;
3412    let event_name = event
3413        .get("type")
3414        .and_then(serde_json::Value::as_str)
3415        .map(str::trim)
3416        .filter(|value| !value.is_empty())
3417        .ok_or_else(|| {
3418            RuntimeServerError::Storefront(StorefrontStateError::UnknownPaymentWebhookEvent {
3419                event: "<missing>".to_string(),
3420            })
3421        })?
3422        .to_string();
3423    let delivery_id = stripe_event_delivery_id_from_event(&event)?;
3424    let payment_reference = stripe_payment_reference_from_event(&event)?;
3425    let mut headers = verified_webhook_headers(execution, "stripe", &event_name);
3426    headers.insert(
3427        "x-coil-verified-webhook-delivery-id".to_string(),
3428        delivery_id.clone(),
3429    );
3430    Ok(VerifiedIngressWebhook {
3431        webhook: VerifiedWebhook {
3432            source: "stripe".to_string(),
3433            event: event_name.clone(),
3434            headers,
3435            content_type: execution.content_type.clone(),
3436            payload,
3437        },
3438        payment_reference: Some(payment_reference),
3439        delivery_id: Some(delivery_id),
3440    })
3441}
3442
3443fn verify_stripe_webhook_signature(
3444    secret: &str,
3445    signature_header: &str,
3446    payload: &[u8],
3447) -> Result<(), RuntimeServerError> {
3448    let mut timestamp = None;
3449    let mut signatures = Vec::new();
3450    for segment in signature_header.split(',') {
3451        let Some((name, value)) = segment.trim().split_once('=') else {
3452            continue;
3453        };
3454        match name.trim() {
3455            "t" => timestamp = Some(value.trim().to_string()),
3456            "v1" => signatures.push(value.trim().to_string()),
3457            _ => {}
3458        }
3459    }
3460    let timestamp = timestamp.ok_or(RuntimeServerError::Storefront(
3461        StorefrontStateError::InvalidPaymentWebhookSignature,
3462    ))?;
3463    let timestamp = timestamp.parse::<u64>().map_err(|_| {
3464        RuntimeServerError::Storefront(StorefrontStateError::InvalidPaymentWebhookSignature)
3465    })?;
3466    if signatures.is_empty() {
3467        return Err(RuntimeServerError::Storefront(
3468            StorefrontStateError::InvalidPaymentWebhookSignature,
3469        ));
3470    }
3471    let now = SystemTime::now()
3472        .duration_since(UNIX_EPOCH)
3473        .unwrap_or_default()
3474        .as_secs();
3475    if now.abs_diff(timestamp) > STRIPE_WEBHOOK_MAX_AGE_SECS {
3476        return Err(RuntimeServerError::Storefront(
3477            StorefrontStateError::InvalidPaymentWebhookSignature,
3478        ));
3479    }
3480
3481    let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|_| {
3482        RuntimeServerError::Storefront(StorefrontStateError::MissingPaymentWebhookSecret)
3483    })?;
3484    mac.update(timestamp.to_string().as_bytes());
3485    mac.update(b".");
3486    mac.update(payload);
3487    let expected = mac.finalize().into_bytes();
3488    let matches = signatures.iter().any(|candidate| {
3489        decode_hex_signature(candidate)
3490            .map(|provided| provided == expected.as_slice())
3491            .unwrap_or(false)
3492    });
3493    if matches {
3494        Ok(())
3495    } else {
3496        Err(RuntimeServerError::Storefront(
3497            StorefrontStateError::InvalidPaymentWebhookSignature,
3498        ))
3499    }
3500}
3501
3502fn generic_verified_webhook_delivery_id(
3503    provider: &str,
3504    event: &str,
3505    payment_reference: &str,
3506    payload: &[u8],
3507) -> String {
3508    let mut hasher = Sha256::new();
3509    hasher.update(provider.as_bytes());
3510    hasher.update(b":");
3511    hasher.update(event.as_bytes());
3512    hasher.update(b":");
3513    hasher.update(payment_reference.as_bytes());
3514    hasher.update(b":");
3515    hasher.update(payload);
3516    format!("{:x}", hasher.finalize())
3517}
3518
3519fn stripe_payment_reference_from_event(
3520    event: &serde_json::Value,
3521) -> Result<String, RuntimeServerError> {
3522    let object = event
3523        .get("data")
3524        .and_then(|value| value.get("object"))
3525        .ok_or_else(|| {
3526            RuntimeServerError::Storefront(StorefrontStateError::UnknownPaymentReference {
3527                payment_reference: "<missing>".to_string(),
3528            })
3529        })?;
3530    let metadata = object
3531        .get("metadata")
3532        .and_then(serde_json::Value::as_object);
3533    let payment_reference = metadata
3534        .and_then(|metadata| {
3535            [
3536                "payment_reference",
3537                "paymentReference",
3538                "checkout_intent",
3539                "checkoutIntent",
3540            ]
3541            .iter()
3542            .find_map(|key| metadata.get(*key).and_then(serde_json::Value::as_str))
3543        })
3544        .or_else(|| {
3545            object
3546                .get("client_reference_id")
3547                .and_then(serde_json::Value::as_str)
3548        })
3549        .or_else(|| {
3550            object
3551                .get("payment_intent")
3552                .and_then(serde_json::Value::as_str)
3553        })
3554        .map(str::trim)
3555        .filter(|value| !value.is_empty())
3556        .map(str::to_string)
3557        .ok_or_else(|| {
3558            RuntimeServerError::Storefront(StorefrontStateError::UnknownPaymentReference {
3559                payment_reference: "<missing>".to_string(),
3560            })
3561        })?;
3562    Ok(payment_reference)
3563}
3564
3565fn stripe_event_delivery_id_from_request_body(
3566    execution: &RequestExecution,
3567) -> Result<String, RuntimeServerError> {
3568    let payload = verified_webhook_payload(execution)?;
3569    let event = serde_json::from_slice::<serde_json::Value>(&payload).map_err(|error| {
3570        RuntimeServerError::Configuration {
3571            reason: format!("failed to decode Stripe webhook payload: {error}"),
3572        }
3573    })?;
3574    stripe_event_delivery_id_from_event(&event)
3575}
3576
3577fn stripe_event_delivery_id_from_event(
3578    event: &serde_json::Value,
3579) -> Result<String, RuntimeServerError> {
3580    event
3581        .get("id")
3582        .and_then(serde_json::Value::as_str)
3583        .map(str::trim)
3584        .filter(|value| !value.is_empty())
3585        .map(str::to_string)
3586        .ok_or(RuntimeServerError::Storefront(
3587            StorefrontStateError::MissingPaymentWebhookDeliveryId,
3588        ))
3589}
3590
3591fn verified_webhook_from_execution(
3592    state: &RuntimeServerState,
3593    execution: &RequestExecution,
3594) -> Result<Option<VerifiedIngressWebhook>, RuntimeServerError> {
3595    match execution.route.route_name.as_str() {
3596        "commerce.payment-provider-webhook" => validated_verified_payment_webhook_from_execution(
3597            state, execution,
3598        )
3599        .and_then(|verified| {
3600            guard_verified_webhook_replay(state, execution, &verified)?;
3601            Ok(Some(verified))
3602        }),
3603        _ => Ok(None),
3604    }
3605}
3606
3607fn guard_verified_webhook_replay(
3608    state: &RuntimeServerState,
3609    execution: &RequestExecution,
3610    verified: &VerifiedIngressWebhook,
3611) -> Result<(), RuntimeServerError> {
3612    let Some(delivery_id) = verified.delivery_id.as_deref() else {
3613        return Ok(());
3614    };
3615    let recorded_at_unix_seconds = SystemTime::now()
3616        .duration_since(UNIX_EPOCH)
3617        .unwrap_or_default()
3618        .as_secs() as i64;
3619    let claimed = if should_fallback_to_local_verified_webhook_replay_store(state) {
3620        claim_local_verified_webhook_delivery(
3621            state,
3622            execution,
3623            verified.webhook.source.as_str(),
3624            delivery_id,
3625            recorded_at_unix_seconds,
3626        )?
3627    } else {
3628        state
3629            .wasm_host
3630            .claim_verified_webhook_delivery(
3631                execution.customer_app.as_str(),
3632                execution.route.route_name.as_str(),
3633                verified.webhook.source.as_str(),
3634                delivery_id,
3635                execution.trace.request_id.as_str(),
3636                recorded_at_unix_seconds,
3637            )
3638            .map_err(|reason| RuntimeServerError::Configuration {
3639                reason: format!("failed to persist verified webhook replay receipt: {reason}"),
3640            })?
3641    };
3642    if claimed {
3643        return Ok(());
3644    }
3645    record_verified_webhook_request_observation(
3646        state,
3647        execution,
3648        &verified.webhook,
3649        crate::wasm::WebhookObservationStatus::ReplayRejected,
3650        Some(format!(
3651            "verified webhook delivery `{delivery_id}` has already been processed"
3652        )),
3653    );
3654    Err(RuntimeServerError::Storefront(
3655        StorefrontStateError::ReplayedPaymentWebhookDelivery {
3656            delivery_id: delivery_id.to_string(),
3657        },
3658    ))
3659}
3660
3661fn should_fallback_to_local_verified_webhook_replay_store(state: &RuntimeServerState) -> bool {
3662    matches!(
3663        state.plan.metadata_audit_backend_selection(),
3664        crate::plan::MetadataAuditBackendSelection::SharedPostgres { .. }
3665    ) && std::env::var_os("DATABASE_URL").is_none()
3666}
3667
3668fn claim_local_verified_webhook_delivery(
3669    state: &RuntimeServerState,
3670    execution: &RequestExecution,
3671    source: &str,
3672    delivery_id: &str,
3673    recorded_at_unix_seconds: i64,
3674) -> Result<bool, RuntimeServerError> {
3675    let path = state
3676        .plan
3677        .shared_state_root()
3678        .join("server")
3679        .join("verified-webhook-deliveries.sqlite3");
3680    if let Some(parent) = path.parent() {
3681        std::fs::create_dir_all(parent).map_err(|error| RuntimeServerError::Configuration {
3682            reason: format!(
3683                "failed to create verified webhook replay directory `{}`: {error}",
3684                parent.display()
3685            ),
3686        })?;
3687    }
3688    let connection =
3689        rusqlite::Connection::open(&path).map_err(|error| RuntimeServerError::Configuration {
3690            reason: format!(
3691                "failed to open local verified webhook replay store `{}`: {error}",
3692                path.display()
3693            ),
3694        })?;
3695    connection
3696        .execute_batch(
3697            r#"
3698            PRAGMA journal_mode = WAL;
3699            PRAGMA synchronous = FULL;
3700            CREATE TABLE IF NOT EXISTS verified_webhook_deliveries (
3701                app_id TEXT NOT NULL,
3702                route_name TEXT NOT NULL,
3703                source TEXT NOT NULL,
3704                delivery_id TEXT NOT NULL,
3705                first_seen_request_id TEXT NOT NULL,
3706                first_seen_at_unix_seconds INTEGER NOT NULL,
3707                PRIMARY KEY (app_id, source, delivery_id)
3708            );
3709            "#,
3710        )
3711        .map_err(|error| RuntimeServerError::Configuration {
3712            reason: format!(
3713                "failed to initialize local verified webhook replay store `{}`: {error}",
3714                path.display()
3715            ),
3716        })?;
3717    let inserted = connection
3718        .execute(
3719            r#"
3720            INSERT OR IGNORE INTO verified_webhook_deliveries (
3721                app_id,
3722                route_name,
3723                source,
3724                delivery_id,
3725                first_seen_request_id,
3726                first_seen_at_unix_seconds
3727            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
3728            "#,
3729            rusqlite::params![
3730                execution.customer_app.as_str(),
3731                execution.route.route_name.as_str(),
3732                source,
3733                delivery_id,
3734                execution.trace.request_id.as_str(),
3735                recorded_at_unix_seconds,
3736            ],
3737        )
3738        .map_err(|error| RuntimeServerError::Configuration {
3739            reason: format!(
3740                "failed to persist local verified webhook replay receipt `{}`: {error}",
3741                path.display()
3742            ),
3743        })?;
3744    Ok(inserted > 0)
3745}
3746
3747fn record_verified_webhook_request_observation(
3748    state: &RuntimeServerState,
3749    execution: &RequestExecution,
3750    webhook: &VerifiedWebhook,
3751    status: crate::wasm::WebhookObservationStatus,
3752    detail: Option<String>,
3753) {
3754    let principal_kind = match execution.principal.principal_kind {
3755        RequestPrincipalKind::Anonymous => "anonymous",
3756        RequestPrincipalKind::User => "user",
3757        RequestPrincipalKind::ServiceAccount => "service_account",
3758    };
3759    let _ = state.wasm_host.record_webhook_request_observation(
3760        execution.customer_app.as_str(),
3761        webhook.source.as_str(),
3762        webhook.event.as_str(),
3763        status,
3764        execution.trace.request_id.as_str(),
3765        principal_kind,
3766        execution.principal.principal_id.as_deref(),
3767        detail,
3768    );
3769}
3770
3771fn storefront_payment_input_from_execution(
3772    execution: &RequestExecution,
3773) -> Result<StorefrontPaymentInput, RuntimeServerError> {
3774    let intent_reference = execution_form_field(execution, "checkout_intent")
3775        .or_else(|| execution_form_field(execution, "payment_intent"))
3776        .or_else(|| execution_form_field(execution, "payment_reference"))
3777        .or_else(|| execution_form_field(execution, "paymentReference"));
3778    let last4 = execution_form_field(execution, "payment_last4")
3779        .or_else(|| execution_form_field(execution, "paymentLast4"))
3780        .or_else(|| execution_form_field(execution, "card_last4"))
3781        .map(str::to_string);
3782    let method = execution_form_field(execution, "payment_method")
3783        .or_else(|| execution_form_field(execution, "paymentMethod"))
3784        .map(str::to_string)
3785        .or_else(|| last4.as_ref().map(|_| "card".to_string()));
3786    let checkout_email = execution_form_field(execution, "checkout_email")
3787        .or_else(|| execution_form_field(execution, "checkoutEmail"))
3788        .or_else(|| execution_form_field(execution, "email"))
3789        .or_else(|| execution_form_field(execution, "billing_email"));
3790    StorefrontPaymentInput::new(
3791        method.unwrap_or_default(),
3792        checkout_email.unwrap_or_default(),
3793        last4,
3794        intent_reference.unwrap_or_default(),
3795    )
3796    .map_err(RuntimeServerError::Storefront)
3797}
3798
3799fn apply_native_cms_admin_mutations(
3800    state: &RuntimeServerState,
3801    execution: &RequestExecution,
3802    now: BrowserInstant,
3803    response_cookies: &mut Vec<String>,
3804) -> Result<Option<String>, RuntimeServerError> {
3805    if !CMS_ADMIN_NATIVE_MUTATION_ROUTES.contains(&execution.route.route_name.as_str()) {
3806        return Ok(None);
3807    }
3808
3809    let mut workspace = CmsAdminWorkspace::load(&state.plan).map_err(|reason| {
3810        RuntimeServerError::Configuration {
3811            reason: format!("failed to load CMS admin workspace: {reason}"),
3812        }
3813    })?;
3814
3815    match execution.route.route_name.as_str() {
3816        "cms.pages.save-draft" => {
3817            let page_input = CmsAdminPageInput {
3818                page_id: execution_form_field(execution, "page_id").map(str::to_string),
3819                title: storefront_form_field_value(execution, "page_title"),
3820                slug: storefront_form_field_value(execution, "page_slug"),
3821                summary: storefront_form_field_value(execution, "page_summary"),
3822                body_html: storefront_form_field_value(execution, "page_body_html"),
3823            };
3824            let page_id = match workspace.save_page_draft(page_input, now.as_unix_seconds()) {
3825                Ok(page_id) => page_id,
3826                Err(reason) => {
3827                    let mut form_state =
3828                        cms_page_form_state_from_execution(execution, reason.clone());
3829                    for field in ["page_title", "page_slug", "page_summary", "page_body_html"] {
3830                        form_state = form_state.with_field_error(field, reason.clone());
3831                    }
3832                    push_storefront_form_state(state, response_cookies, &form_state)?;
3833                    return Ok(Some("/admin/pages".to_string()));
3834                }
3835            };
3836            workspace
3837                .save(&state.plan)
3838                .map_err(|reason| RuntimeServerError::Configuration {
3839                    reason: format!("failed to persist CMS page draft: {reason}"),
3840                })?;
3841            record_operator_audit(
3842                state,
3843                execution,
3844                "cms.pages.save-draft",
3845                "page",
3846                page_id.as_str(),
3847                "succeeded",
3848                &format!(
3849                    "Saved draft `{}` at slug `{}`.",
3850                    storefront_form_field_value(execution, "page_title"),
3851                    storefront_form_field_value(execution, "page_slug"),
3852                ),
3853            )?;
3854            push_storefront_flash(
3855                state,
3856                response_cookies,
3857                FlashLevel::Success,
3858                "Draft saved. Preview and publish when ready.",
3859            )?;
3860            return Ok(Some(format!("/admin/pages?page={page_id}")));
3861        }
3862        "cms.pages.publish" => {
3863            let page_id = if execution_form_field(execution, "page_title").is_some() {
3864                let page_input = CmsAdminPageInput {
3865                    page_id: execution_form_field(execution, "page_id").map(str::to_string),
3866                    title: storefront_form_field_value(execution, "page_title"),
3867                    slug: storefront_form_field_value(execution, "page_slug"),
3868                    summary: storefront_form_field_value(execution, "page_summary"),
3869                    body_html: storefront_form_field_value(execution, "page_body_html"),
3870                };
3871                match workspace.save_page_draft(page_input, now.as_unix_seconds()) {
3872                    Ok(page_id) => page_id,
3873                    Err(reason) => {
3874                        let mut form_state =
3875                            cms_page_form_state_from_execution(execution, reason.clone());
3876                        for field in ["page_title", "page_slug", "page_summary", "page_body_html"] {
3877                            form_state = form_state.with_field_error(field, reason.clone());
3878                        }
3879                        push_storefront_form_state(state, response_cookies, &form_state)?;
3880                        return Ok(Some("/admin/pages".to_string()));
3881                    }
3882                }
3883            } else {
3884                execution_form_field(execution, "page_id")
3885                    .map(str::to_string)
3886                    .ok_or_else(|| RuntimeServerError::Configuration {
3887                        reason: "missing page_id for publish".to_string(),
3888                    })?
3889            };
3890            if let Some(location) = validate_cms_publish_with_customer_hooks(
3891                state,
3892                execution,
3893                &mut workspace,
3894                &page_id,
3895                now,
3896                response_cookies,
3897            )? {
3898                return Ok(Some(location));
3899            }
3900            workspace
3901                .publish_page(&page_id, now.as_unix_seconds())
3902                .map_err(|reason| RuntimeServerError::Configuration { reason })?;
3903            workspace
3904                .save(&state.plan)
3905                .map_err(|reason| RuntimeServerError::Configuration {
3906                    reason: format!("failed to persist CMS publication: {reason}"),
3907                })?;
3908            let live_path = workspace
3909                .selected_page(Some(&page_id))
3910                .and_then(|page| page.live_path())
3911                .unwrap_or_else(|| "/pages/{slug}".to_string());
3912            record_operator_audit(
3913                state,
3914                execution,
3915                "cms.pages.publish",
3916                "page",
3917                page_id.as_str(),
3918                "succeeded",
3919                &format!("Published live route `{live_path}`."),
3920            )?;
3921            push_storefront_flash(
3922                state,
3923                response_cookies,
3924                FlashLevel::Success,
3925                "Page published to the live /pages/{slug} surface.",
3926            )?;
3927            return Ok(Some(format!("/admin/pages?page={page_id}")));
3928        }
3929        "cms.pages.unpublish" => {
3930            let page_id = execution_form_field(execution, "page_id").ok_or_else(|| {
3931                RuntimeServerError::Configuration {
3932                    reason: "missing page_id for unpublish".to_string(),
3933                }
3934            })?;
3935            workspace
3936                .unpublish_page(page_id, now.as_unix_seconds())
3937                .map_err(|reason| RuntimeServerError::Configuration { reason })?;
3938            workspace
3939                .save(&state.plan)
3940                .map_err(|reason| RuntimeServerError::Configuration {
3941                    reason: format!("failed to persist CMS unpublish: {reason}"),
3942                })?;
3943            record_operator_audit(
3944                state,
3945                execution,
3946                "cms.pages.unpublish",
3947                "page",
3948                page_id,
3949                "succeeded",
3950                "Removed the page from its live route while preserving the draft.",
3951            )?;
3952            push_storefront_flash(
3953                state,
3954                response_cookies,
3955                FlashLevel::Info,
3956                "Page removed from the live route but kept as a draft.",
3957            )?;
3958            return Ok(Some(format!("/admin/pages?page={page_id}")));
3959        }
3960        "cms.navigation.save" => {
3961            let items = match navigation_items_from_fields(&execution.form_fields) {
3962                Ok(items) => items,
3963                Err(reason) => {
3964                    let form_state =
3965                        cms_navigation_form_state_from_execution(execution, reason.clone())
3966                            .with_field_error("new_nav_label", reason);
3967                    push_storefront_form_state(state, response_cookies, &form_state)?;
3968                    return Ok(Some("/admin/navigation".to_string()));
3969                }
3970            };
3971            if let Err(reason) = workspace.save_navigation(items) {
3972                let form_state =
3973                    cms_navigation_form_state_from_execution(execution, reason.clone())
3974                        .with_field_error("new_nav_label", reason);
3975                push_storefront_form_state(state, response_cookies, &form_state)?;
3976                return Ok(Some("/admin/navigation".to_string()));
3977            }
3978            workspace
3979                .save(&state.plan)
3980                .map_err(|reason| RuntimeServerError::Configuration {
3981                    reason: format!("failed to persist CMS navigation: {reason}"),
3982                })?;
3983            record_operator_audit(
3984                state,
3985                execution,
3986                "cms.navigation.save",
3987                "navigation",
3988                "primary-navigation",
3989                "succeeded",
3990                &format!(
3991                    "Saved {} primary navigation items.",
3992                    workspace.navigation.len()
3993                ),
3994            )?;
3995            push_storefront_flash(
3996                state,
3997                response_cookies,
3998                FlashLevel::Success,
3999                "Primary navigation updated for the live storefront shell.",
4000            )?;
4001            return Ok(Some("/admin/navigation".to_string()));
4002        }
4003        "cms.redirects.save" => {
4004            let redirects = match redirects_from_fields(&execution.form_fields) {
4005                Ok(redirects) => redirects,
4006                Err(reason) => {
4007                    let form_state =
4008                        cms_redirect_form_state_from_execution(execution, reason.clone())
4009                            .with_field_error("new_redirect_from", reason);
4010                    push_storefront_form_state(state, response_cookies, &form_state)?;
4011                    return Ok(Some("/admin/redirects".to_string()));
4012                }
4013            };
4014            if let Err(reason) = workspace.save_redirects(redirects) {
4015                let form_state = cms_redirect_form_state_from_execution(execution, reason.clone())
4016                    .with_field_error("new_redirect_from", reason);
4017                push_storefront_form_state(state, response_cookies, &form_state)?;
4018                return Ok(Some("/admin/redirects".to_string()));
4019            }
4020            workspace
4021                .save(&state.plan)
4022                .map_err(|reason| RuntimeServerError::Configuration {
4023                    reason: format!("failed to persist CMS redirects: {reason}"),
4024                })?;
4025            record_operator_audit(
4026                state,
4027                execution,
4028                "cms.redirects.save",
4029                "redirects",
4030                "live-redirect-rules",
4031                "succeeded",
4032                &format!("Saved {} redirect rules.", workspace.redirects.len()),
4033            )?;
4034            push_storefront_flash(
4035                state,
4036                response_cookies,
4037                FlashLevel::Success,
4038                "Redirect rules saved for unmatched live requests.",
4039            )?;
4040            return Ok(Some("/admin/redirects".to_string()));
4041        }
4042        _ => {}
4043    }
4044
4045    Ok(None)
4046}
4047
4048fn normalized_payment_webhook_event<'a>(source: &str, event: &'a str) -> Cow<'a, str> {
4049    match (source, event) {
4050        ("stripe", "checkout.session.completed") => Cow::Borrowed("payment.captured"),
4051        ("stripe", "payment_intent.succeeded") => Cow::Borrowed("payment.captured"),
4052        ("stripe", "payment_intent.amount_capturable_updated") => {
4053            Cow::Borrowed("payment.authorized")
4054        }
4055        ("stripe", "checkout.session.expired") => Cow::Borrowed("payment.failed"),
4056        ("stripe", "payment_intent.payment_failed") => Cow::Borrowed("payment.failed"),
4057        _ => Cow::Borrowed(event),
4058    }
4059}
4060
4061async fn apply_native_storefront_mutations(
4062    state: &RuntimeServerState,
4063    execution: &RequestExecution,
4064    now: BrowserInstant,
4065    response_cookies: &mut Vec<String>,
4066) -> Result<Option<String>, RuntimeServerError> {
4067    if let Some(verified) = verified_webhook_from_execution(state, execution)? {
4068        if let Err(error) =
4069            execute_verified_webhook_customer_hooks(state, execution, &verified.webhook, now)
4070        {
4071            record_verified_webhook_request_observation(
4072                state,
4073                execution,
4074                &verified.webhook,
4075                crate::wasm::WebhookObservationStatus::ExecutionFailed,
4076                Some(error.to_string()),
4077            );
4078            return Err(error);
4079        }
4080        if execution.route.route_name.as_str() != "commerce.payment-provider-webhook" {
4081            return Ok(None);
4082        }
4083        let payment_reference = verified.payment_reference.as_deref().ok_or_else(|| {
4084            RuntimeServerError::Configuration {
4085                reason: format!(
4086                    "verified webhook route `{}` did not provide a required payment reference",
4087                    execution.route.route_name
4088                ),
4089            }
4090        })?;
4091        let receipt = match state.storefront.apply_payment_webhook(
4092            payment_reference,
4093            normalized_payment_webhook_event(
4094                verified.webhook.source.as_str(),
4095                verified.webhook.event.as_str(),
4096            )
4097            .as_ref(),
4098            now.as_unix_seconds(),
4099        ) {
4100            Ok(receipt) => receipt,
4101            Err(error) => {
4102                record_verified_webhook_request_observation(
4103                    state,
4104                    execution,
4105                    &verified.webhook,
4106                    crate::wasm::WebhookObservationStatus::ExecutionFailed,
4107                    Some(error.to_string()),
4108                );
4109                return Err(RuntimeServerError::Storefront(error));
4110            }
4111        };
4112        if receipt.needs_paid_event_dispatch {
4113            if let Err(error) = dispatch_paid_order_event(state, &receipt.order, now) {
4114                record_verified_webhook_request_observation(
4115                    state,
4116                    execution,
4117                    &verified.webhook,
4118                    crate::wasm::WebhookObservationStatus::ExecutionFailed,
4119                    Some(error.to_string()),
4120                );
4121                return Err(error);
4122            }
4123            if let Err(error) = state
4124                .storefront
4125                .mark_order_paid_event_dispatched(&receipt.order.order_id, now.as_unix_seconds())
4126            {
4127                record_verified_webhook_request_observation(
4128                    state,
4129                    execution,
4130                    &verified.webhook,
4131                    crate::wasm::WebhookObservationStatus::ExecutionFailed,
4132                    Some(error.to_string()),
4133                );
4134                return Err(RuntimeServerError::Storefront(error));
4135            }
4136        }
4137        record_verified_webhook_request_observation(
4138            state,
4139            execution,
4140            &verified.webhook,
4141            crate::wasm::WebhookObservationStatus::Accepted,
4142            None,
4143        );
4144        return Ok(None);
4145    }
4146
4147    let Some(session_id) = execution.session.session_id.as_deref() else {
4148        return Ok(None);
4149    };
4150    match execution.route.route_name.as_str() {
4151        "commerce.add-to-cart" => {
4152            let quantity = storefront_quantity_from_execution(execution);
4153            let sku = storefront_sku_from_execution(execution)?;
4154            if storefront_catalog_product_for_execution(state, execution, sku.as_ref()).is_none() {
4155                let form_state = StorefrontFormState::new(
4156                    "commerce.cart",
4157                    "That product is not available on this site right now.",
4158                );
4159                push_storefront_form_state(state, response_cookies, &form_state)?;
4160                return Ok(Some("/cart".to_string()));
4161            }
4162            let snapshot = state.storefront.add_to_cart(
4163                session_id,
4164                execution.principal.principal_id.as_deref(),
4165                sku.as_ref(),
4166                quantity,
4167                now.as_unix_seconds(),
4168            )?;
4169            push_storefront_flash(
4170                state,
4171                response_cookies,
4172                FlashLevel::Success,
4173                format!("Added {} to the cart ({})", sku, snapshot.cart.item_count),
4174            )?;
4175        }
4176        "commerce.cart-update" => {
4177            let quantities = match validated_cart_quantities_from_execution(execution) {
4178                Ok(quantities) => quantities,
4179                Err(form_state) => {
4180                    push_storefront_form_state(state, response_cookies, &form_state)?;
4181                    return Ok(Some("/cart".to_string()));
4182                }
4183            };
4184            let mut snapshot = state
4185                .storefront
4186                .snapshot(session_id, execution.principal.principal_id.as_deref())?;
4187            for (sku, quantity) in quantities {
4188                snapshot = state.storefront.update_cart(
4189                    session_id,
4190                    execution.principal.principal_id.as_deref(),
4191                    &sku,
4192                    quantity,
4193                    now.as_unix_seconds(),
4194                )?;
4195            }
4196            let message = if snapshot.cart.lines.is_empty() {
4197                "Your cart is now empty.".to_string()
4198            } else {
4199                format!("Updated cart with {} line(s).", snapshot.cart.item_count)
4200            };
4201            push_storefront_flash(state, response_cookies, FlashLevel::Info, message)?;
4202        }
4203        "commerce.checkout-start" => {
4204            if let Ok(sku) = storefront_sku_from_execution(execution) {
4205                let quantity = storefront_quantity_from_execution(execution);
4206                if storefront_catalog_product_for_execution(state, execution, sku.as_ref())
4207                    .is_none()
4208                {
4209                    let form_state = StorefrontFormState::new(
4210                        "commerce.cart",
4211                        "That product is not available on this site right now.",
4212                    );
4213                    push_storefront_form_state(state, response_cookies, &form_state)?;
4214                    return Ok(Some("/cart".to_string()));
4215                }
4216                let _ = state.storefront.add_to_cart(
4217                    session_id,
4218                    execution.principal.principal_id.as_deref(),
4219                    sku.as_ref(),
4220                    quantity,
4221                    now.as_unix_seconds(),
4222                )?;
4223            }
4224            match state.storefront.checkout_start(
4225                session_id,
4226                execution.principal.principal_id.as_deref(),
4227                now.as_unix_seconds(),
4228            ) {
4229                Ok(_) => {}
4230                Err(StorefrontStateError::EmptyCart { .. }) => {
4231                    let form_state = StorefrontFormState::new(
4232                        "commerce.cart",
4233                        "Add at least one item to the cart before starting checkout.",
4234                    );
4235                    push_storefront_form_state(state, response_cookies, &form_state)?;
4236                    return Ok(Some("/cart".to_string()));
4237                }
4238                Err(error) => return Err(RuntimeServerError::Storefront(error)),
4239            }
4240        }
4241        "commerce.checkout-complete" => {
4242            let payment = match validated_storefront_payment_input_from_execution(state, execution)
4243            {
4244                Ok(payment) => payment,
4245                Err(form_state) => {
4246                    push_storefront_form_state(state, response_cookies, &form_state)?;
4247                    return Ok(Some("/checkout".to_string()));
4248                }
4249            };
4250            if let Some(location) = review_checkout_with_customer_hooks(
4251                state,
4252                execution,
4253                session_id,
4254                &payment,
4255                now,
4256                response_cookies,
4257            )? {
4258                return Ok(Some(location));
4259            }
4260            let checkout_metadata = storefront_checkout_order_metadata(
4261                state,
4262                execution,
4263                &state
4264                    .storefront
4265                    .snapshot(session_id, execution.principal.principal_id.as_deref())?,
4266                session_id,
4267                execution.principal.principal_id.as_deref(),
4268                &payment,
4269            );
4270            let snapshot = match state.storefront.checkout_complete_with_metadata(
4271                session_id,
4272                execution.principal.principal_id.as_deref(),
4273                &payment,
4274                &checkout_metadata,
4275                now.as_unix_seconds(),
4276            ) {
4277                Ok(snapshot) => snapshot,
4278                Err(
4279                    error @ (StorefrontStateError::CheckoutNotReady { .. }
4280                    | StorefrontStateError::EmptyCart { .. }
4281                    | StorefrontStateError::MissingPaymentIntent
4282                    | StorefrontStateError::PaymentIntentMismatch { .. }),
4283                ) => {
4284                    let summary = match &error {
4285                        StorefrontStateError::CheckoutNotReady { .. } => {
4286                            "Refresh checkout and review the basket before placing the order."
4287                        }
4288                        StorefrontStateError::EmptyCart { .. } => {
4289                            "Add at least one item to the cart before placing the order."
4290                        }
4291                        StorefrontStateError::MissingPaymentIntent => {
4292                            "Refresh checkout before placing the order."
4293                        }
4294                        StorefrontStateError::PaymentIntentMismatch { .. } => {
4295                            "Refresh checkout before placing the order."
4296                        }
4297                        _ => "There is a problem with your checkout details.",
4298                    };
4299                    let mut form_state =
4300                        storefront_checkout_form_state_from_execution(execution, summary);
4301                    if matches!(
4302                        error,
4303                        StorefrontStateError::MissingPaymentIntent
4304                            | StorefrontStateError::PaymentIntentMismatch { .. }
4305                    ) {
4306                        form_state = form_state.with_field_error(
4307                            "checkout_intent",
4308                            "Refresh checkout and try again before placing the order.",
4309                        );
4310                    }
4311                    push_storefront_form_state(state, response_cookies, &form_state)?;
4312                    return Ok(Some("/checkout".to_string()));
4313                }
4314                Err(error) => return Err(RuntimeServerError::Storefront(error)),
4315            };
4316            if let Some(location) = finalize_storefront_checkout_completion(
4317                state,
4318                execution,
4319                &snapshot,
4320                now,
4321                response_cookies,
4322            )
4323            .await?
4324            {
4325                return Ok(Some(location));
4326            }
4327        }
4328        "commerce.catalog-admin-update" => {
4329            let update = match validated_catalog_admin_update_from_execution(execution) {
4330                Ok(update) => update,
4331                Err(form_state) => {
4332                    push_storefront_form_state(state, response_cookies, &form_state)?;
4333                    return Ok(Some("/admin/catalog/products".to_string()));
4334                }
4335            };
4336            let update_result = match &update {
4337                CatalogAdminMutationInput::Product(update) => state
4338                    .storefront
4339                    .update_catalog_product(update, now.as_unix_seconds()),
4340                CatalogAdminMutationInput::Collection(update) => state
4341                    .storefront
4342                    .update_catalog_collection(update, now.as_unix_seconds()),
4343            };
4344            match update_result {
4345                Ok(_) => {
4346                    let message = match &update {
4347                        CatalogAdminMutationInput::Product(update) => {
4348                            format!("Saved product changes for {}.", update.title)
4349                        }
4350                        CatalogAdminMutationInput::Collection(update) => {
4351                            format!("Saved collection changes for {}.", update.title)
4352                        }
4353                    };
4354                    match &update {
4355                        CatalogAdminMutationInput::Product(update) => {
4356                            record_operator_audit(
4357                                state,
4358                                execution,
4359                                "commerce.catalog-admin-update.product",
4360                                "product",
4361                                update.handle.as_str(),
4362                                "succeeded",
4363                                &format!(
4364                                    "Updated product `{}` at price_minor {} and marked it {}.",
4365                                    update.title,
4366                                    update.price_minor,
4367                                    if update.is_visible {
4368                                        "visible"
4369                                    } else {
4370                                        "hidden"
4371                                    }
4372                                ),
4373                            )?;
4374                        }
4375                        CatalogAdminMutationInput::Collection(update) => {
4376                            record_operator_audit(
4377                                state,
4378                                execution,
4379                                "commerce.catalog-admin-update.collection",
4380                                "collection",
4381                                update.handle.as_str(),
4382                                "succeeded",
4383                                &format!(
4384                                    "Updated collection `{}` and marked it {}.",
4385                                    update.title,
4386                                    if update.is_visible {
4387                                        "visible"
4388                                    } else {
4389                                        "hidden"
4390                                    }
4391                                ),
4392                            )?;
4393                        }
4394                    }
4395                    push_storefront_flash(state, response_cookies, FlashLevel::Success, message)?;
4396                    return Ok(Some("/admin/catalog/products".to_string()));
4397                }
4398                Err(
4399                    error @ (StorefrontStateError::MissingCatalogProduct { .. }
4400                    | StorefrontStateError::MissingCatalogCollection { .. }),
4401                ) => {
4402                    let mut form_state = match &update {
4403                        CatalogAdminMutationInput::Product(_) => {
4404                            catalog_admin_product_form_state_from_execution(
4405                                execution,
4406                                "Refresh the catalog admin page and try again.",
4407                            )
4408                        }
4409                        CatalogAdminMutationInput::Collection(_) => {
4410                            catalog_admin_collection_form_state_from_execution(
4411                                execution,
4412                                "Refresh the catalog admin page and try again.",
4413                            )
4414                        }
4415                    };
4416                    form_state = form_state.with_summary(error.to_string());
4417                    push_storefront_form_state(state, response_cookies, &form_state)?;
4418                    return Ok(Some("/admin/catalog/products".to_string()));
4419                }
4420                Err(error) => return Err(RuntimeServerError::Storefront(error)),
4421            }
4422        }
4423        "commerce.order-refund" => {
4424            let order_id = storefront_form_field_value(execution, "order_id");
4425            let reason = storefront_form_field_value(execution, "reason");
4426            let redirect_location = if order_id.trim().is_empty() {
4427                "/admin/orders".to_string()
4428            } else {
4429                format!("/admin/orders/{}", order_id.trim())
4430            };
4431            match state.storefront.refund_order(
4432                order_id.trim(),
4433                reason.as_str(),
4434                now.as_unix_seconds(),
4435            ) {
4436                Ok(order) => {
4437                    record_operator_audit(
4438                        state,
4439                        execution,
4440                        "commerce.order-refund",
4441                        "order",
4442                        order.order_id.as_str(),
4443                        "succeeded",
4444                        &format!(
4445                            "Refunded {} with reason `{}`.",
4446                            order.refunded_total, reason
4447                        ),
4448                    )?;
4449                    push_storefront_flash(
4450                        state,
4451                        response_cookies,
4452                        FlashLevel::Success,
4453                        format!(
4454                            "Refunded {} for order {}.",
4455                            order.refunded_total, order.order_id
4456                        ),
4457                    )?;
4458                    return Ok(Some(format!("/admin/orders/{}", order.order_id)));
4459                }
4460                Err(StorefrontStateError::MissingRefundReason) => {
4461                    record_operator_audit(
4462                        state,
4463                        execution,
4464                        "commerce.order-refund",
4465                        "order",
4466                        order_id.trim(),
4467                        "rejected",
4468                        "Refund reason was required but not provided.",
4469                    )?;
4470                    let form_state = order_refund_form_state_from_execution(
4471                        execution,
4472                        "Review the refund request and add a reason before trying again.",
4473                    )
4474                    .with_field_error("reason", "refund reason is required");
4475                    push_storefront_form_state(state, response_cookies, &form_state)?;
4476                    return Ok(Some(redirect_location));
4477                }
4478                Err(error @ StorefrontStateError::RefundNotAllowed { .. }) => {
4479                    record_operator_audit(
4480                        state,
4481                        execution,
4482                        "commerce.order-refund",
4483                        "order",
4484                        order_id.trim(),
4485                        "rejected",
4486                        &error.to_string(),
4487                    )?;
4488                    let form_state = order_refund_form_state_from_execution(
4489                        execution,
4490                        "This order cannot be refunded from the checked-in admin workflow right now.",
4491                    )
4492                    .with_field_error("reason", error.to_string());
4493                    push_storefront_form_state(state, response_cookies, &form_state)?;
4494                    return Ok(Some(redirect_location));
4495                }
4496                Err(error @ StorefrontStateError::UnknownOrder { .. }) => {
4497                    record_operator_audit(
4498                        state,
4499                        execution,
4500                        "commerce.order-refund",
4501                        "order",
4502                        order_id.trim(),
4503                        "rejected",
4504                        &error.to_string(),
4505                    )?;
4506                    if order_id.trim().is_empty() {
4507                        push_storefront_flash(
4508                            state,
4509                            response_cookies,
4510                            FlashLevel::Error,
4511                            error.to_string(),
4512                        )?;
4513                    } else {
4514                        let form_state = order_refund_form_state_from_execution(
4515                            execution,
4516                            "Refresh the order queue and reopen the detail view before retrying this refund.",
4517                        )
4518                        .with_field_error("order_id", error.to_string());
4519                        push_storefront_form_state(state, response_cookies, &form_state)?;
4520                    }
4521                    return Ok(Some(redirect_location));
4522                }
4523                Err(error) => return Err(RuntimeServerError::Storefront(error)),
4524            }
4525        }
4526        "commerce.order-fulfill" => {
4527            let order_id = storefront_form_field_value(execution, "order_id");
4528            let redirect_location = if order_id.trim().is_empty() {
4529                "/admin/orders".to_string()
4530            } else {
4531                format!("/admin/orders/{}", order_id.trim())
4532            };
4533            match state
4534                .storefront
4535                .fulfill_order(order_id.trim(), now.as_unix_seconds())
4536            {
4537                Ok(order) => {
4538                    record_operator_audit(
4539                        state,
4540                        execution,
4541                        "commerce.order-fulfill",
4542                        "order",
4543                        order.order_id.as_str(),
4544                        "succeeded",
4545                        &format!("Marked {} as fulfilled.", order.order_id),
4546                    )?;
4547                    push_storefront_flash(
4548                        state,
4549                        response_cookies,
4550                        FlashLevel::Success,
4551                        format!("Marked order {} as fulfilled.", order.order_id),
4552                    )?;
4553                    return Ok(Some(format!("/admin/orders/{}", order.order_id)));
4554                }
4555                Err(StorefrontStateError::FulfillmentNotAllowed { order_id, status }) => {
4556                    record_operator_audit(
4557                        state,
4558                        execution,
4559                        "commerce.order-fulfill",
4560                        "order",
4561                        order_id.as_str(),
4562                        "rejected",
4563                        &format!("Order cannot be fulfilled while it is `{status}`."),
4564                    )?;
4565                    push_storefront_flash(
4566                        state,
4567                        response_cookies,
4568                        FlashLevel::Error,
4569                        format!(
4570                            "Order {} cannot be marked fulfilled while it is {}.",
4571                            order_id, status
4572                        ),
4573                    )?;
4574                    return Ok(Some(redirect_location));
4575                }
4576                Err(error @ StorefrontStateError::UnknownOrder { .. }) => {
4577                    record_operator_audit(
4578                        state,
4579                        execution,
4580                        "commerce.order-fulfill",
4581                        "order",
4582                        order_id.trim(),
4583                        "rejected",
4584                        &error.to_string(),
4585                    )?;
4586                    if order_id.trim().is_empty() {
4587                        push_storefront_flash(
4588                            state,
4589                            response_cookies,
4590                            FlashLevel::Error,
4591                            error.to_string(),
4592                        )?;
4593                    } else {
4594                        let form_state = StorefrontFormState::new(
4595                            "commerce.orders",
4596                            "Refresh the order queue and reopen the detail view before retrying this fulfillment.",
4597                        );
4598                        push_storefront_form_state(state, response_cookies, &form_state)?;
4599                    }
4600                    return Ok(Some(redirect_location));
4601                }
4602                Err(error) => return Err(RuntimeServerError::Storefront(error)),
4603            }
4604        }
4605        "commerce.account-session-end" => {
4606            revoke_storefront_session(state, session_id, now, response_cookies)?;
4607            push_storefront_flash(
4608                state,
4609                response_cookies,
4610                FlashLevel::Success,
4611                "Account session ended. Start again from this browser when you are ready.",
4612            )?;
4613            return Ok(Some("/account".to_string()));
4614        }
4615        _ => {}
4616    }
4617    Ok(None)
4618}
4619
4620async fn finalize_storefront_checkout_completion(
4621    state: &RuntimeServerState,
4622    execution: &RequestExecution,
4623    snapshot: &StorefrontStateSnapshot,
4624    now: BrowserInstant,
4625    response_cookies: &mut Vec<String>,
4626) -> Result<Option<String>, RuntimeServerError> {
4627    let Some(order) = snapshot.latest_order.as_ref() else {
4628        push_storefront_flash(
4629            state,
4630            response_cookies,
4631            FlashLevel::Error,
4632            "Checkout could not complete because the cart is empty.",
4633        )?;
4634        return Ok(None);
4635    };
4636
4637    let Some(provider) = configured_commerce_payment_provider(&state.plan.config) else {
4638        push_storefront_flash(
4639            state,
4640            response_cookies,
4641            FlashLevel::Success,
4642            format!(
4643                "Order {} was received. Payment is still awaiting provider confirmation.",
4644                order.order_id
4645            ),
4646        )?;
4647        return Ok(None);
4648    };
4649
4650    if provider.code == "stripe" && provider.uses_hosted_checkout() {
4651        match launch_stripe_checkout_handoff(state, execution, order).await {
4652            Ok(handoff_url) => return Ok(Some(handoff_url)),
4653            Err(_) => {
4654                return restore_checkout_after_provider_handoff_failure(
4655                    state,
4656                    order,
4657                    now,
4658                    response_cookies,
4659                    "Stripe checkout could not start. Your basket has been restored so you can review it and try again.",
4660                )
4661                .map(Some);
4662            }
4663        }
4664    }
4665
4666    push_storefront_flash(
4667        state,
4668        response_cookies,
4669        FlashLevel::Success,
4670        provider.pending_confirmation_summary(&order.order_id),
4671    )?;
4672    Ok(None)
4673}
4674
4675async fn launch_stripe_checkout_handoff(
4676    state: &RuntimeServerState,
4677    execution: &RequestExecution,
4678    order: &StorefrontOrderSnapshot,
4679) -> Result<String, String> {
4680    let payment_reference = order
4681        .payment
4682        .reference
4683        .as_deref()
4684        .filter(|value| !value.trim().is_empty())
4685        .ok_or_else(|| format!("order {} is missing a payment reference", order.order_id))?;
4686    if state.uses_development_hosted_checkout_stub() {
4687        return Ok(provider_checkout_return_url(
4688            execution,
4689            payment_reference,
4690            "return",
4691        ));
4692    }
4693    let api_key = state
4694        .payment_provider_api_key
4695        .as_deref()
4696        .ok_or_else(|| "stripe hosted checkout api key is not configured".to_string())?
4697        .to_string();
4698    let request_body = stripe_checkout_session_request_body(execution, order)?;
4699    let idempotency_key = format!("coil-order-{}", order.order_id);
4700    let checkout_client = Arc::clone(&state.hosted_checkout_client);
4701    let response = tokio::task::spawn_blocking(move || {
4702        checkout_client.create_stripe_checkout_session(&api_key, &request_body, &idempotency_key)
4703    })
4704    .await
4705    .map_err(|error| format!("failed to join Stripe Checkout handoff task: {error}"))??;
4706
4707    if response.id.trim().is_empty() || response.url.trim().is_empty() {
4708        return Err("Stripe Checkout response was missing the hosted session URL".to_string());
4709    }
4710    Ok(response.url)
4711}
4712
4713fn stripe_checkout_session_request_body(
4714    execution: &RequestExecution,
4715    order: &StorefrontOrderSnapshot,
4716) -> Result<String, String> {
4717    let payment_reference = order
4718        .payment
4719        .reference
4720        .as_deref()
4721        .filter(|value| !value.trim().is_empty())
4722        .ok_or_else(|| format!("order {} is missing a payment reference", order.order_id))?;
4723    let mut serializer = form_urlencoded::Serializer::new(String::new());
4724    serializer.append_pair("mode", "payment");
4725    serializer.append_pair("success_url", &stripe_checkout_success_url(execution));
4726    serializer.append_pair(
4727        "cancel_url",
4728        &provider_checkout_cancel_url(execution, payment_reference),
4729    );
4730    serializer.append_pair("client_reference_id", payment_reference);
4731    if let Some(email) = order.payment.checkout_email.as_deref() {
4732        let trimmed = email.trim();
4733        if !trimmed.is_empty() {
4734            serializer.append_pair("customer_email", trimmed);
4735        }
4736    }
4737    serializer.append_pair("payment_intent_data[metadata][order_id]", &order.order_id);
4738    serializer.append_pair(
4739        "payment_intent_data[metadata][payment_reference]",
4740        payment_reference,
4741    );
4742    serializer.append_pair("metadata[order_id]", &order.order_id);
4743    serializer.append_pair("metadata[payment_reference]", payment_reference);
4744
4745    for (index, line) in order.lines.iter().enumerate() {
4746        if line.quantity == 0 {
4747            return Err(format!(
4748                "order {} contains a zero-quantity line for {}",
4749                order.order_id, line.sku
4750            ));
4751        }
4752        if line.unit_price_minor <= 0 {
4753            return Err(format!(
4754                "order {} contains a non-positive unit amount for {}",
4755                order.order_id, line.sku
4756            ));
4757        }
4758
4759        let prefix = format!("line_items[{index}]");
4760        serializer.append_pair(
4761            &format!("{prefix}[price_data][currency]"),
4762            &line.currency.to_ascii_lowercase(),
4763        );
4764        serializer.append_pair(
4765            &format!("{prefix}[price_data][unit_amount]"),
4766            &line.unit_price_minor.to_string(),
4767        );
4768        serializer.append_pair(
4769            &format!("{prefix}[price_data][product_data][name]"),
4770            stripe_line_item_name(line).as_str(),
4771        );
4772        serializer.append_pair(&format!("{prefix}[quantity]"), &line.quantity.to_string());
4773    }
4774
4775    Ok(serializer.finish())
4776}
4777
4778fn stripe_line_item_name(line: &StorefrontOrderLine) -> String {
4779    let variant = line.variant_title.trim();
4780    if variant.is_empty() || variant.eq_ignore_ascii_case("standard") {
4781        return line.title.clone();
4782    }
4783    format!("{} ({variant})", line.title)
4784}
4785
4786fn checkout_confirmation_base_url(execution: &RequestExecution) -> String {
4787    format!(
4788        "{}://{}/checkout/confirmation",
4789        execution.trace.transport_scheme, execution.host
4790    )
4791}
4792
4793fn stripe_checkout_success_url(execution: &RequestExecution) -> String {
4794    let mut serializer = form_urlencoded::Serializer::new(String::new());
4795    serializer.append_pair("checkout_session_id", "{CHECKOUT_SESSION_ID}");
4796    format!(
4797        "{}?{}",
4798        checkout_confirmation_base_url(execution),
4799        serializer.finish()
4800    )
4801}
4802
4803fn provider_checkout_return_url(
4804    execution: &RequestExecution,
4805    payment_reference: &str,
4806    provider_result: &str,
4807) -> String {
4808    let mut serializer = form_urlencoded::Serializer::new(String::new());
4809    serializer.append_pair("provider_result", provider_result);
4810    serializer.append_pair("payment_reference", payment_reference);
4811    format!(
4812        "{}?{}",
4813        checkout_confirmation_base_url(execution),
4814        serializer.finish()
4815    )
4816}
4817
4818fn provider_checkout_cancel_url(execution: &RequestExecution, payment_reference: &str) -> String {
4819    provider_checkout_return_url(execution, payment_reference, "cancel")
4820}
4821
4822fn reconcile_hosted_checkout_confirmation(
4823    state: &RuntimeServerState,
4824    checkout_session_id: &str,
4825    now: BrowserInstant,
4826) -> Result<(), RuntimeServerError> {
4827    if state.uses_development_hosted_checkout_stub() {
4828        return Ok(());
4829    }
4830    let Some(provider) = configured_commerce_payment_provider(&state.plan.config) else {
4831        return Ok(());
4832    };
4833    if provider.code != "stripe" || !provider.uses_hosted_checkout() {
4834        return Ok(());
4835    }
4836    let Some(api_key) = state.payment_provider_api_key.as_deref() else {
4837        return Ok(());
4838    };
4839    let session = match state
4840        .hosted_checkout_client
4841        .fetch_stripe_checkout_session(api_key, checkout_session_id)
4842    {
4843        Ok(session) => session,
4844        Err(_) => return Ok(()),
4845    };
4846    let Some(payment_reference) = session.payment_reference.as_deref() else {
4847        return Ok(());
4848    };
4849    let event = match (session.status.as_deref(), session.payment_status.as_deref()) {
4850        (Some("complete"), Some("paid" | "no_payment_required")) => Some("payment.captured"),
4851        (Some("expired"), _) => Some("payment.failed"),
4852        _ => None,
4853    };
4854    let Some(event) = event else {
4855        return Ok(());
4856    };
4857    let receipt =
4858        state
4859            .storefront
4860            .apply_payment_webhook(payment_reference, event, now.as_unix_seconds())?;
4861    if receipt.needs_paid_event_dispatch {
4862        dispatch_paid_order_event(state, &receipt.order, now)?;
4863        state
4864            .storefront
4865            .mark_order_paid_event_dispatched(&receipt.order.order_id, now.as_unix_seconds())?;
4866    }
4867    Ok(())
4868}
4869
4870fn restore_checkout_after_provider_handoff_failure(
4871    state: &RuntimeServerState,
4872    order: &StorefrontOrderSnapshot,
4873    now: BrowserInstant,
4874    response_cookies: &mut Vec<String>,
4875    message: &str,
4876) -> Result<String, RuntimeServerError> {
4877    if let Some(payment_reference) = order.payment.reference.as_deref() {
4878        let _ = state.storefront.apply_payment_webhook(
4879            payment_reference,
4880            "payment.failed",
4881            now.as_unix_seconds(),
4882        )?;
4883    }
4884    push_storefront_flash(state, response_cookies, FlashLevel::Error, message)?;
4885    Ok("/cart".to_string())
4886}
4887
4888fn redirect_failed_checkout_confirmation(
4889    state: &RuntimeServerState,
4890    route_name: &str,
4891    method: HttpMethod,
4892    session_id: Option<&str>,
4893    principal_id: Option<&str>,
4894    provider_result: Option<&str>,
4895    payment_reference: Option<&str>,
4896    checkout_session_id: Option<&str>,
4897    now: BrowserInstant,
4898    response_cookies: &mut Vec<String>,
4899) -> Result<Option<String>, RuntimeServerError> {
4900    if route_name != "commerce.checkout-confirmation" || method != HttpMethod::Get {
4901        return Ok(None);
4902    }
4903    if let Some(checkout_session_id) = checkout_session_id {
4904        reconcile_hosted_checkout_confirmation(state, checkout_session_id, now)?;
4905    }
4906    if provider_result == Some("return")
4907        && state.uses_development_hosted_checkout_stub()
4908        && let Some(payment_reference) = payment_reference
4909    {
4910        let receipt = state.storefront.apply_payment_webhook(
4911            payment_reference,
4912            "payment.succeeded",
4913            now.as_unix_seconds(),
4914        )?;
4915        dispatch_paid_order_event(state, &receipt.order, now)?;
4916        push_storefront_flash(
4917            state,
4918            response_cookies,
4919            FlashLevel::Success,
4920            format!(
4921                "Local checkout completed for order {} using the built-in development payment stub.",
4922                receipt.order.order_id
4923            ),
4924        )?;
4925        return Ok(None);
4926    }
4927    if provider_result == Some("cancel") {
4928        if let Some(payment_reference) = payment_reference {
4929            match state.storefront.apply_payment_webhook(
4930                payment_reference,
4931                "payment.failed",
4932                now.as_unix_seconds(),
4933            ) {
4934                Ok(receipt) => {
4935                    if receipt.order.payment.status == "failed" {
4936                        push_storefront_flash(
4937                            state,
4938                            response_cookies,
4939                            FlashLevel::Error,
4940                            "Stripe checkout was cancelled. Your basket has been restored so you can review it and start checkout again.",
4941                        )?;
4942                        return Ok(Some("/cart".to_string()));
4943                    }
4944                }
4945                Err(StorefrontStateError::UnknownPaymentReference { .. }) => {}
4946                Err(error) => return Err(RuntimeServerError::Storefront(error)),
4947            }
4948        }
4949    }
4950    let Some(session_id) = session_id else {
4951        return Ok(None);
4952    };
4953    let snapshot = state.storefront.snapshot(session_id, principal_id)?;
4954    let Some(order) = snapshot.latest_order.as_ref() else {
4955        return Ok(None);
4956    };
4957    if order.payment.status != "failed" {
4958        return Ok(None);
4959    }
4960    push_storefront_flash(
4961        state,
4962        response_cookies,
4963        FlashLevel::Error,
4964        format!(
4965            "Payment for order {} failed. Your basket has been restored so you can review it and start checkout again.",
4966            order.order_id
4967        ),
4968    )?;
4969    Ok(Some("/cart".to_string()))
4970}
4971
4972fn dispatch_paid_order_event(
4973    state: &RuntimeServerState,
4974    order: &StorefrontOrderSnapshot,
4975    now: BrowserInstant,
4976) -> Result<(), RuntimeServerError> {
4977    let mut jobs = state.plan.jobs_host("runtime-http")?;
4978    let payment_reference = order
4979        .payment
4980        .reference
4981        .as_deref()
4982        .unwrap_or(order.order_id.as_str());
4983    let _ = jobs.emit_domain_event(
4984        DomainEventDispatchRequest::new(
4985            "commerce.order.paid",
4986            "order",
4987            order.order_id.clone(),
4988            format!("payment provider confirmed {payment_reference}"),
4989        )?,
4990        JobInstant::from_unix_seconds(now.as_unix_seconds()),
4991    )?;
4992    Ok(())
4993}
4994
4995fn execution_form_field<'a>(execution: &'a RequestExecution, name: &str) -> Option<&'a str> {
4996    execution
4997        .form_fields
4998        .get(name)
4999        .and_then(|values| values.first().map(String::as_str))
5000}
5001
5002fn execution_query_field<'a>(execution: &'a RequestExecution, name: &str) -> Option<&'a str> {
5003    execution
5004        .query_params
5005        .get(name)
5006        .and_then(|values| values.first().map(String::as_str))
5007}
5008
5009fn run_customer_hook_future<T>(
5010    future: impl Future<Output = Result<T, RuntimeServerError>> + Send + 'static,
5011) -> Result<T, RuntimeServerError>
5012where
5013    T: Send + 'static,
5014{
5015    match tokio::runtime::Handle::try_current() {
5016        Ok(handle)
5017            if matches!(
5018                handle.runtime_flavor(),
5019                tokio::runtime::RuntimeFlavor::MultiThread
5020            ) =>
5021        {
5022            tokio::task::block_in_place(|| handle.block_on(future))
5023        }
5024        Ok(_) => std::thread::spawn(move || {
5025            tokio::runtime::Builder::new_current_thread()
5026                .enable_all()
5027                .build()
5028                .map_err(|error| RuntimeServerError::CustomerHookFailed {
5029                    surface: "auth",
5030                    reason: format!("failed to build runtime bridge for customer hooks: {error}"),
5031                })?
5032                .block_on(future)
5033        })
5034        .join()
5035        .map_err(|_| RuntimeServerError::CustomerHookFailed {
5036            surface: "auth",
5037            reason: "customer hook runtime bridge thread panicked".to_string(),
5038        })?,
5039        Err(_) => tokio::runtime::Builder::new_current_thread()
5040            .enable_all()
5041            .build()
5042            .map_err(|error| RuntimeServerError::CustomerHookFailed {
5043                surface: "auth",
5044                reason: format!("failed to build runtime bridge for customer hooks: {error}"),
5045            })?
5046            .block_on(future),
5047    }
5048}
5049
5050fn customer_hook_auth_backend_error(error: RuntimeServerError) -> BackendError {
5051    BackendError::new(
5052        BackendErrorKind::Unavailable,
5053        "auth.live_check.failed",
5054        "Runtime could not complete the linked customer auth check.",
5055    )
5056    .with_detail(error.to_string())
5057}
5058
5059fn customer_hook_asset_internal_error(message: &'static str) -> BackendError {
5060    BackendError::new(
5061        BackendErrorKind::Unavailable,
5062        "storage.asset.state_unavailable",
5063        message,
5064    )
5065}
5066
5067fn customer_hook_asset_internal_error_with_detail(
5068    message: &'static str,
5069    detail: impl Into<String>,
5070) -> BackendError {
5071    customer_hook_asset_internal_error(message).with_detail(detail.into())
5072}
5073
5074fn customer_hook_asset_model_error(error: coil_assets::AssetModelError) -> BackendError {
5075    BackendError::new(
5076        BackendErrorKind::InvalidInput,
5077        "storage.asset.invalid",
5078        "Customer webhook requested an invalid managed asset write.",
5079    )
5080    .with_detail(error.to_string())
5081}
5082
5083fn customer_hook_storage_backend_error(error: crate::storage::RuntimeStorageError) -> BackendError {
5084    BackendError::new(
5085        BackendErrorKind::Unavailable,
5086        "storage.asset.failed",
5087        "Runtime could not complete the linked customer asset operation.",
5088    )
5089    .with_detail(error.to_string())
5090}
5091
5092fn customer_hook_asset_id(logical_path: &str) -> Result<AssetId, BackendError> {
5093    AssetId::new(format!("customer-hook:{logical_path}")).map_err(customer_hook_asset_model_error)
5094}
5095
5096fn customer_hook_asset_revision_id(
5097    logical_path: &str,
5098    bytes: &[u8],
5099) -> Result<RevisionId, BackendError> {
5100    let mut hasher = Sha256::new();
5101    hasher.update(logical_path.as_bytes());
5102    hasher.update([0]);
5103    hasher.update(bytes);
5104    let digest = hasher.finalize();
5105    RevisionId::new(format!(
5106        "customer-hook:{}",
5107        digest
5108            .iter()
5109            .map(|byte| format!("{byte:02x}"))
5110            .collect::<String>()
5111    ))
5112    .map_err(customer_hook_asset_model_error)
5113}
5114
5115fn customer_hook_asset_fingerprint(bytes: &[u8]) -> Result<ContentFingerprint, BackendError> {
5116    let digest = Sha256::digest(bytes);
5117    ContentFingerprint::new(
5118        FingerprintAlgorithm::Sha256,
5119        digest
5120            .iter()
5121            .map(|byte| format!("{byte:02x}"))
5122            .collect::<String>(),
5123    )
5124    .map_err(customer_hook_asset_model_error)
5125}
5126
5127fn customer_hook_storage_plan(
5128    storage: &StorageHost,
5129    logical_path: &str,
5130    storage_class: StorageClass,
5131) -> Result<coil_storage::StoragePlan, crate::storage::RuntimeStorageError> {
5132    let request = StoragePlanRequest::new(logical_path).with_storage_class(storage_class);
5133    match storage_class {
5134        StorageClass::LocalOnlySensitive => storage.plan_single_node_escape_hatch_write(request),
5135        StorageClass::PublicAsset | StorageClass::PublicUpload | StorageClass::PrivateShared => {
5136            storage.plan_write(request)
5137        }
5138    }
5139}
5140
5141fn plan_customer_hook_asset_revision(
5142    storage: &StorageHost,
5143    logical_path: &str,
5144    storage_class: StorageClass,
5145    content_type: &str,
5146    bytes: &[u8],
5147) -> Result<ManagedAssetRevision, BackendError> {
5148    let revision_id = customer_hook_asset_revision_id(logical_path, bytes)?;
5149    let fingerprint = customer_hook_asset_fingerprint(bytes)?;
5150    let plan = customer_hook_storage_plan(storage, logical_path, storage_class)
5151        .map_err(customer_hook_storage_backend_error)?;
5152    ManagedAssetRevision::new(
5153        revision_id,
5154        plan,
5155        content_type,
5156        bytes.len() as u64,
5157        fingerprint,
5158    )
5159    .map_err(customer_hook_asset_model_error)
5160}
5161
5162fn sdk_managed_asset_from_runtime_asset(
5163    storage: &StorageHost,
5164    asset: &coil_assets::ManagedAsset,
5165) -> Result<ManagedAsset, BackendError> {
5166    let public_url = if asset.publication().is_published() {
5167        match storage
5168            .plan_public_asset_delivery(asset)
5169            .map_err(customer_hook_storage_backend_error)?
5170            .target()
5171        {
5172            coil_assets::AssetDeliveryTarget::Cdn { public_url, .. } => Some(public_url.clone()),
5173            _ => None,
5174        }
5175    } else {
5176        None
5177    };
5178
5179    Ok(ManagedAsset {
5180        logical_path: asset.current_revision().storage_plan().logical_path.clone(),
5181        storage_class: customer_storage_class_name(
5182            asset.current_revision().storage_plan().storage_class,
5183        )
5184        .to_string(),
5185        public_url,
5186    })
5187}
5188
5189fn persisted_customer_managed_asset_record(
5190    asset: &coil_assets::ManagedAsset,
5191) -> Result<PersistedCustomerManagedAssetRecord, BackendError> {
5192    Ok(PersistedCustomerManagedAssetRecord {
5193        logical_path: asset.current_revision().storage_plan().logical_path.clone(),
5194        storage_class: customer_storage_class_name(
5195            asset.current_revision().storage_plan().storage_class,
5196        )
5197        .to_string(),
5198        revision_id: asset.current_revision().id().as_str().to_string(),
5199        content_type: asset.current_revision().content_type().to_string(),
5200        byte_length: asset.current_revision().byte_length(),
5201        fingerprint_algorithm: asset
5202            .current_revision()
5203            .fingerprint()
5204            .algorithm()
5205            .to_string(),
5206        fingerprint_digest: asset.current_revision().fingerprint().digest().to_string(),
5207        published_current: asset.publication().is_published(),
5208    })
5209}
5210
5211fn runtime_asset_from_persisted_customer_managed_asset(
5212    storage: &StorageHost,
5213    record: &PersistedCustomerManagedAssetRecord,
5214) -> Result<coil_assets::ManagedAsset, BackendError> {
5215    let storage_class = parse_customer_storage_class(record.storage_class.as_str())?;
5216    let revision = ManagedAssetRevision::new(
5217        RevisionId::new(record.revision_id.clone()).map_err(customer_hook_asset_model_error)?,
5218        customer_hook_storage_plan(storage, &record.logical_path, storage_class)
5219            .map_err(customer_hook_storage_backend_error)?,
5220        record.content_type.clone(),
5221        record.byte_length,
5222        ContentFingerprint::new(
5223            parse_customer_hook_fingerprint_algorithm(record.fingerprint_algorithm.as_str())?,
5224            record.fingerprint_digest.clone(),
5225        )
5226        .map_err(customer_hook_asset_model_error)?,
5227    )
5228    .map_err(customer_hook_asset_model_error)?;
5229    let mut asset = coil_assets::ManagedAsset::new(
5230        customer_hook_asset_id(&record.logical_path)?,
5231        record.logical_path.clone(),
5232        revision,
5233    )
5234    .map_err(customer_hook_asset_model_error)?;
5235    if record.published_current {
5236        asset.publish_current();
5237    }
5238    Ok(asset)
5239}
5240
5241fn parse_customer_hook_fingerprint_algorithm(
5242    value: &str,
5243) -> Result<FingerprintAlgorithm, BackendError> {
5244    match value {
5245        "sha256" => Ok(FingerprintAlgorithm::Sha256),
5246        "sha384" => Ok(FingerprintAlgorithm::Sha384),
5247        "sha512" => Ok(FingerprintAlgorithm::Sha512),
5248        other => Err(BackendError::new(
5249            BackendErrorKind::InvalidInput,
5250            "storage.asset.invalid_fingerprint_algorithm",
5251            "Persisted customer managed asset record declares an unsupported fingerprint algorithm.",
5252        )
5253        .with_detail(format!("unsupported fingerprint algorithm `{other}`"))),
5254    }
5255}
5256
5257fn parse_customer_storage_class(value: &str) -> Result<StorageClass, BackendError> {
5258    match value {
5259        "public_asset" => Ok(StorageClass::PublicAsset),
5260        "public_upload" => Ok(StorageClass::PublicUpload),
5261        "private_shared" => Ok(StorageClass::PrivateShared),
5262        "local_only_sensitive" => Ok(StorageClass::LocalOnlySensitive),
5263        other => Err(BackendError::new(
5264            BackendErrorKind::InvalidInput,
5265            "storage.class.invalid",
5266            format!("Unknown storage class `{other}`."),
5267        )),
5268    }
5269}
5270
5271fn customer_storage_class_name(storage_class: StorageClass) -> &'static str {
5272    match storage_class {
5273        StorageClass::PublicAsset => "public_asset",
5274        StorageClass::PublicUpload => "public_upload",
5275        StorageClass::PrivateShared => "private_shared",
5276        StorageClass::LocalOnlySensitive => "local_only_sensitive",
5277    }
5278}
5279
5280fn parse_customer_capability(value: &str) -> Result<coil_auth::Capability, BackendError> {
5281    coil_auth::Capability::from_str(value).ok_or_else(|| {
5282        BackendError::new(
5283            BackendErrorKind::InvalidInput,
5284            "auth.capability.invalid",
5285            format!("Unknown capability `{value}`."),
5286        )
5287    })
5288}
5289
5290fn parse_customer_auth_entity(value: &str) -> Result<coil_auth::Entity, BackendError> {
5291    let Some((namespace, id)) = value.split_once(':') else {
5292        return Err(BackendError::new(
5293            BackendErrorKind::InvalidInput,
5294            "auth.object.invalid",
5295            format!("Invalid auth object `{value}`."),
5296        ));
5297    };
5298    if id.trim().is_empty() {
5299        return Err(BackendError::new(
5300            BackendErrorKind::InvalidInput,
5301            "auth.object.invalid",
5302            format!("Invalid auth object `{value}`."),
5303        ));
5304    }
5305    match namespace {
5306        "tenant" => Ok(coil_auth::Entity::tenant(id)),
5307        "site" => Ok(coil_auth::Entity::site(id)),
5308        "brand" => Ok(coil_auth::Entity::brand(id)),
5309        "storefront" => Ok(coil_auth::Entity::storefront(id)),
5310        "user" => Ok(coil_auth::Entity::user(id)),
5311        "group" => Ok(coil_auth::Entity::group(id)),
5312        "team" => Ok(coil_auth::Entity::team(id)),
5313        "service_account" => Ok(coil_auth::Entity::service_account(id)),
5314        "page" => Ok(coil_auth::Entity::page(id)),
5315        "navigation" => Ok(coil_auth::Entity::navigation(id)),
5316        "product" => Ok(coil_auth::Entity::product(id)),
5317        "collection" => Ok(coil_auth::Entity::collection(id)),
5318        "order" => Ok(coil_auth::Entity::order(id)),
5319        "subscription" => Ok(coil_auth::Entity::subscription(id)),
5320        "membership_tier" => Ok(coil_auth::Entity::membership_tier(id)),
5321        "event" => Ok(coil_auth::Entity::event(id)),
5322        "event_slot" => Ok(coil_auth::Entity::event_slot(id)),
5323        "booking" => Ok(coil_auth::Entity::booking(id)),
5324        "media" => Ok(coil_auth::Entity::media(id)),
5325        "media_library" => Ok(coil_auth::Entity::media_library(id)),
5326        "asset" => Ok(coil_auth::Entity::asset(id)),
5327        "asset_folder" => Ok(coil_auth::Entity::asset_folder(id)),
5328        "theme_asset_bundle" => Ok(coil_auth::Entity::theme_asset_bundle(id)),
5329        "admin_module" => Ok(coil_auth::Entity::admin_module(id)),
5330        _ => Err(BackendError::new(
5331            BackendErrorKind::InvalidInput,
5332            "auth.object.invalid",
5333            format!("Unknown auth object namespace `{namespace}`."),
5334        )),
5335    }
5336}
5337
5338fn customer_hook_auth_subject(
5339    principal: &PrincipalContext,
5340) -> Option<coil_auth::DefaultSubject> {
5341    match (principal.principal_id.as_deref(), principal.principal_kind) {
5342        (Some(principal_id), RequestPrincipalKind::ServiceAccount) => {
5343            Some(coil_auth::DefaultSubject::entity(
5344                coil_auth::Entity::service_account(principal_id.to_string()),
5345            ))
5346        }
5347        (Some(principal_id), _) => Some(coil_auth::DefaultSubject::entity(
5348            coil_auth::Entity::user(principal_id.to_string()),
5349        )),
5350        (None, _) => None,
5351    }
5352}
5353
5354fn decode_hex_signature(signature: &str) -> Option<Vec<u8>> {
5355    if !signature.len().is_multiple_of(2) {
5356        return None;
5357    }
5358    let mut bytes = Vec::with_capacity(signature.len() / 2);
5359    let mut chars = signature.as_bytes().chunks_exact(2);
5360    for chunk in &mut chars {
5361        let high = decode_hex_nibble(chunk[0])?;
5362        let low = decode_hex_nibble(chunk[1])?;
5363        bytes.push((high << 4) | low);
5364    }
5365    Some(bytes)
5366}
5367
5368fn decode_hex_nibble(byte: u8) -> Option<u8> {
5369    match byte {
5370        b'0'..=b'9' => Some(byte - b'0'),
5371        b'a'..=b'f' => Some(byte - b'a' + 10),
5372        b'A'..=b'F' => Some(byte - b'A' + 10),
5373        _ => None,
5374    }
5375}
5376
5377fn storefront_sku_from_execution(
5378    execution: &RequestExecution,
5379) -> Result<Cow<'_, str>, RuntimeServerError> {
5380    execution_form_field(execution, "sku")
5381        .or_else(|| execution_form_field(execution, "product_slug"))
5382        .or_else(|| execution_form_field(execution, "line_id"))
5383        .map(Cow::Borrowed)
5384        .ok_or_else(|| {
5385            RuntimeServerError::Storefront(StorefrontStateError::UnknownSku {
5386                sku: "<missing>".to_string(),
5387            })
5388        })
5389}
5390
5391fn cart_quantities_from_execution(execution: &RequestExecution) -> BTreeMap<String, u32> {
5392    let mut quantities = BTreeMap::new();
5393    if let Ok(sku) = storefront_sku_from_execution(execution) {
5394        quantities.insert(
5395            sku.into_owned(),
5396            parse_quantity_field(execution_form_field(execution, "quantity")).unwrap_or(1),
5397        );
5398    }
5399    for (name, values) in &execution.form_fields {
5400        let Some(product_slug) = name.strip_prefix("quantity_") else {
5401            continue;
5402        };
5403        let Some(quantity) = values
5404            .first()
5405            .and_then(|value| parse_quantity_field(Some(value.as_str())))
5406        else {
5407            continue;
5408        };
5409        quantities.insert(product_slug.to_string(), quantity);
5410    }
5411    quantities
5412}
5413
5414fn storefront_response_augmentation(
5415    state: &RuntimeServerState,
5416    execution: &RequestExecution,
5417) -> Result<Option<StorefrontResponseAugmentation>, RuntimeServerError> {
5418    let should_render_storefront = should_render_storefront_state(execution);
5419    let should_render_cms_admin_forms = should_render_cms_admin_forms(execution);
5420    if !should_render_storefront && !should_render_cms_admin_forms {
5421        return Ok(None);
5422    }
5423    let Some(session_id) = execution.session.session_id.as_deref() else {
5424        return Ok(None);
5425    };
5426    let mut augmentation = if should_render_storefront {
5427        let snapshot = state
5428            .storefront
5429            .snapshot(session_id, execution.principal.principal_id.as_deref())?;
5430        let tokens = issue_storefront_csrf_tokens(state, session_id)?;
5431        state.storefront.build_response_augmentation(
5432            execution.route.route_name.as_str(),
5433            &snapshot,
5434            tokens,
5435        )?
5436    } else {
5437        StorefrontResponseAugmentation {
5438            html_fragment: None,
5439            headers: BTreeMap::new(),
5440        }
5441    };
5442    if should_render_cms_admin_forms {
5443        augmentation
5444            .headers
5445            .extend(issue_cms_admin_csrf_tokens(state, session_id)?);
5446    }
5447    Ok(Some(augmentation))
5448}
5449
5450fn should_render_storefront_state(execution: &RequestExecution) -> bool {
5451    matches!(execution.response, HandlerResponse::Page(_))
5452        && (execution.route.route_name.starts_with("commerce.")
5453            || execution.route_area == RouteArea::Account)
5454}
5455
5456fn should_render_cms_admin_forms(execution: &RequestExecution) -> bool {
5457    matches!(execution.response, HandlerResponse::Page(_))
5458        && matches!(
5459            execution.route.route_name.as_str(),
5460            "cms.pages.index" | "cms.navigation.index" | "cms.redirects.index"
5461        )
5462}
5463
5464fn issue_storefront_csrf_tokens(
5465    state: &RuntimeServerState,
5466    session_id: &str,
5467) -> Result<BTreeMap<String, String>, RuntimeServerError> {
5468    let browser = state
5469        .browser
5470        .lock()
5471        .expect("runtime browser mutex poisoned");
5472    let mut tokens = BTreeMap::new();
5473    for action in STOREFRONT_CSRF_ACTIONS {
5474        let token = browser
5475            .issue_csrf_token(&state.csrf_secret, session_id, action)
5476            .map_err(RequestExecutionError::from_browser_error)?;
5477        tokens.insert((*action).to_string(), token);
5478    }
5479    Ok(tokens)
5480}
5481
5482fn issue_cms_admin_csrf_tokens(
5483    state: &RuntimeServerState,
5484    session_id: &str,
5485) -> Result<BTreeMap<String, String>, RuntimeServerError> {
5486    let browser = state
5487        .browser
5488        .lock()
5489        .expect("runtime browser mutex poisoned");
5490    let mut tokens = BTreeMap::new();
5491    for (action, header) in CMS_ADMIN_CSRF_ACTIONS {
5492        let token = browser
5493            .issue_csrf_token(&state.csrf_secret, session_id, action)
5494            .map_err(RequestExecutionError::from_browser_error)?;
5495        tokens.insert((*header).to_string(), token);
5496    }
5497    Ok(tokens)
5498}
5499
5500fn storefront_order_history_response(
5501    state: &RuntimeServerState,
5502    request: &RequestInput,
5503    response_cookies: Vec<String>,
5504) -> Result<Response<Body>, RuntimeServerError> {
5505    let Some(session_id) = request.session_id.as_deref() else {
5506        return Err(RuntimeServerError::Execution(
5507            RequestExecutionError::SessionRequired {
5508                route: "account.orders".to_string(),
5509            },
5510        ));
5511    };
5512    let history =
5513        state
5514            .storefront
5515            .order_history(session_id, request.principal_id.as_deref(), 50)?;
5516    let body = serde_json::to_string(&history).map_err(|error| {
5517        RuntimeServerError::Storefront(StorefrontStateError::Serialization {
5518            reason: error.to_string(),
5519        })
5520    })?;
5521    let mut response = Response::new(Body::from(body));
5522    *response.status_mut() = StatusCode::OK;
5523    response.headers_mut().insert(
5524        HeaderName::from_static("content-type"),
5525        HeaderValue::from_static("application/json"),
5526    );
5527    response.headers_mut().insert(
5528        HeaderName::from_static("x-coil-storefront-order-count"),
5529        HeaderValue::from_str(&history.orders.len().to_string())
5530            .expect("order count is a valid header value"),
5531    );
5532    if let Some(order) = history.orders.first() {
5533        response.headers_mut().insert(
5534            HeaderName::from_static("x-coil-storefront-latest-order"),
5535            HeaderValue::from_str(order.order_id.as_str())
5536                .expect("order id is a valid header value"),
5537        );
5538    }
5539    for cookie in response_cookies {
5540        if let Ok(value) = HeaderValue::from_str(&cookie) {
5541            response
5542                .headers_mut()
5543                .append(HeaderName::from_static("set-cookie"), value);
5544        }
5545    }
5546    Ok(response)
5547}
5548
5549async fn apply_storefront_response_augmentation(
5550    mut response: Response<Body>,
5551    augmentation: Option<StorefrontResponseAugmentation>,
5552) -> Result<Response<Body>, RuntimeServerError> {
5553    let Some(augmentation) = augmentation else {
5554        return Ok(response);
5555    };
5556    let form_tokens = storefront_form_tokens_from_headers(&augmentation.headers);
5557    for (name, value) in augmentation.headers {
5558        if let (Ok(name), Ok(value)) = (
5559            HeaderName::from_bytes(name.as_bytes()),
5560            HeaderValue::from_str(&value),
5561        ) {
5562            response.headers_mut().insert(name, value);
5563        }
5564    }
5565    let Some(markup) = augmentation.html_fragment else {
5566        return Ok(response);
5567    };
5568    let is_html = response
5569        .headers()
5570        .get("content-type")
5571        .and_then(|value| value.to_str().ok())
5572        .is_some_and(|value| value.starts_with("text/html"));
5573    if !is_html {
5574        return Ok(response);
5575    }
5576    let (parts, body) = response.into_parts();
5577    let bytes = to_bytes(body, usize::MAX)
5578        .await
5579        .map_err(|_| RuntimeServerError::RequestBodyTooLarge { limit: usize::MAX })?;
5580    let html = String::from_utf8(bytes.to_vec()).map_err(|error| {
5581        RuntimeServerError::Storefront(StorefrontStateError::Serialization {
5582            reason: error.to_string(),
5583        })
5584    })?;
5585    let html = inject_storefront_form_csrf_inputs(html, form_tokens.as_slice());
5586    Ok(Response::from_parts(
5587        parts,
5588        Body::from(inject_storefront_markup(html, markup.as_str())),
5589    ))
5590}
5591
5592fn inject_storefront_markup(document_html: String, markup: &str) -> String {
5593    if markup.is_empty() {
5594        return document_html;
5595    }
5596    if let Some(index) = document_html.find("</body>") {
5597        let mut html = document_html;
5598        html.insert_str(index, markup);
5599        return html;
5600    }
5601    format!("{document_html}{markup}")
5602}
5603
5604fn storefront_form_tokens_from_headers(
5605    headers: &BTreeMap<String, String>,
5606) -> Vec<(&'static str, String)> {
5607    STOREFRONT_FORM_CSRF_HEADERS
5608        .iter()
5609        .chain(CMS_ADMIN_FORM_CSRF_HEADERS.iter())
5610        .filter_map(|(path, header)| headers.get(*header).map(|token| (*path, token.clone())))
5611        .collect()
5612}
5613
5614fn inject_storefront_form_csrf_inputs(
5615    mut document_html: String,
5616    form_tokens: &[(&'static str, String)],
5617) -> String {
5618    for (action_path, token) in form_tokens {
5619        document_html = inject_hidden_csrf_input(document_html, action_path, token.as_str());
5620    }
5621    document_html
5622}
5623
5624fn inject_hidden_csrf_input(mut document_html: String, action_path: &str, token: &str) -> String {
5625    let action_attr = format!("action=\"{action_path}\"");
5626    let hidden_input = format!(r#"<input type="hidden" name="_csrf" value="{token}" />"#);
5627    let mut search_from = 0;
5628
5629    while let Some(relative) = document_html[search_from..].find(&action_attr) {
5630        let action_index = search_from + relative;
5631        let Some(form_start) = document_html[..action_index].rfind("<form") else {
5632            search_from = action_index + action_attr.len();
5633            continue;
5634        };
5635        let Some(open_end_relative) = document_html[action_index..].find('>') else {
5636            break;
5637        };
5638        let open_end = action_index + open_end_relative;
5639        let Some(close_relative) = document_html[open_end..].find("</form>") else {
5640            break;
5641        };
5642        let close_index = open_end + close_relative;
5643        if document_html[open_end..close_index].contains("name=\"_csrf\"") {
5644            search_from = close_index + "</form>".len();
5645            continue;
5646        }
5647        document_html.insert_str(open_end + 1, hidden_input.as_str());
5648        search_from = open_end + 1 + hidden_input.len();
5649        if search_from < form_start {
5650            search_from = form_start;
5651        }
5652    }
5653
5654    document_html
5655}
5656
5657async fn enforce_request_body_limit(
5658    request: Request<Body>,
5659    max_body_bytes: Option<usize>,
5660) -> Result<Request<Body>, RuntimeServerError> {
5661    let Some(limit) = max_body_bytes else {
5662        return Ok(request);
5663    };
5664
5665    let (parts, body) = request.into_parts();
5666    if let Some(content_length) = parts
5667        .headers
5668        .get(CONTENT_LENGTH)
5669        .and_then(|value| value.to_str().ok())
5670        .and_then(|value| value.parse::<usize>().ok())
5671        && content_length > limit
5672    {
5673        return Err(RuntimeServerError::RequestBodyTooLarge { limit });
5674    }
5675
5676    let bytes = to_bytes(body, limit)
5677        .await
5678        .map_err(|_| RuntimeServerError::RequestBodyTooLarge { limit })?;
5679    Ok(Request::from_parts(parts, Body::from(bytes)))
5680}
5681
5682#[cfg(test)]
5683mod security_tests {
5684    use super::*;
5685    use std::collections::HashSet;
5686
5687    #[test]
5688    fn customer_hook_auth_subject_is_absent_for_anonymous_requests() {
5689        let principal = PrincipalContext {
5690            principal_id: None,
5691            principal_kind: RequestPrincipalKind::Anonymous,
5692            granted_capabilities: HashSet::new(),
5693        };
5694
5695        assert!(customer_hook_auth_subject(&principal).is_none());
5696    }
5697
5698    #[test]
5699    fn customer_hook_auth_subject_preserves_service_accounts() {
5700        let principal = PrincipalContext {
5701            principal_id: Some("runtime.webhooks".to_string()),
5702            principal_kind: RequestPrincipalKind::ServiceAccount,
5703            granted_capabilities: HashSet::new(),
5704        };
5705
5706        assert_eq!(
5707            customer_hook_auth_subject(&principal),
5708            Some(coil_auth::DefaultSubject::entity(
5709                coil_auth::Entity::service_account("runtime.webhooks"),
5710            ))
5711        );
5712    }
5713}