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, "email"))
3127        .or_else(|| execution_form_field(execution, "billing_email"))
3128        .unwrap_or_default()
3129        .trim()
3130        .to_string();
3131    let last4 = execution_form_field(execution, "payment_last4")
3132        .or_else(|| execution_form_field(execution, "card_last4"))
3133        .unwrap_or_default()
3134        .trim()
3135        .to_string();
3136    let method = execution_form_field(execution, "payment_method")
3137        .map(str::trim)
3138        .filter(|value| !value.is_empty())
3139        .map(str::to_string)
3140        .or_else(|| (!last4.is_empty()).then(|| "card".to_string()))
3141        .unwrap_or_default();
3142    let mut form_state = storefront_checkout_form_state_from_execution(
3143        execution,
3144        "There is a problem with your checkout details.",
3145    );
3146    let mut has_errors = false;
3147    if checkout_email.is_empty() {
3148        has_errors = true;
3149        form_state = form_state.with_field_error(
3150            "checkout_email",
3151            "Enter the email address for order confirmation.",
3152        );
3153    }
3154    if method.is_empty() && !hosted_checkout {
3155        has_errors = true;
3156        form_state = form_state.with_field_error(
3157            "payment_method",
3158            "Choose or confirm a payment method before placing the order.",
3159        );
3160    }
3161    if method == "card"
3162        && !hosted_checkout
3163        && (last4.len() != 4 || !last4.chars().all(|character| character.is_ascii_digit()))
3164    {
3165        has_errors = true;
3166        form_state = form_state.with_field_error(
3167            "payment_last4",
3168            "Enter the final 4 digits for the payment card.",
3169        );
3170    }
3171    if execution_form_field(execution, "checkout_intent")
3172        .or_else(|| execution_form_field(execution, "payment_intent"))
3173        .or_else(|| execution_form_field(execution, "payment_reference"))
3174        .is_none()
3175    {
3176        has_errors = true;
3177        form_state = form_state
3178            .with_summary("Refresh checkout before placing the order.")
3179            .with_field_error(
3180                "checkout_intent",
3181                "Refresh checkout and try again before placing the order.",
3182            );
3183    }
3184    if execution_form_field(execution, "terms_accepted").is_none() {
3185        has_errors = true;
3186        form_state = form_state.with_field_error(
3187            "terms_accepted",
3188            "Review the basket and confirm the final total before placing the order.",
3189        );
3190    }
3191    if has_errors {
3192        return Err(form_state);
3193    }
3194    let intent_reference = execution_form_field(execution, "checkout_intent")
3195        .or_else(|| execution_form_field(execution, "payment_intent"))
3196        .or_else(|| execution_form_field(execution, "payment_reference"))
3197        .unwrap_or_default();
3198    let method = if method.is_empty() && hosted_checkout {
3199        "card".to_string()
3200    } else {
3201        method
3202    };
3203    let payment = if hosted_checkout && last4.is_empty() {
3204        StorefrontPaymentInput::hosted(method, checkout_email, intent_reference)
3205    } else {
3206        StorefrontPaymentInput::new(
3207            method,
3208            checkout_email,
3209            (!last4.is_empty()).then_some(last4),
3210            intent_reference,
3211        )
3212    };
3213    payment.map_err(|error| {
3214        let mut form_state = storefront_checkout_form_state_from_execution(
3215            execution,
3216            "There is a problem with your checkout details.",
3217        );
3218        let (field, message) = match error {
3219            StorefrontStateError::MissingPaymentMethod => (
3220                "payment_method",
3221                "Choose or confirm a payment method before placing the order.",
3222            ),
3223            StorefrontStateError::MissingCheckoutEmail => (
3224                "checkout_email",
3225                "Enter the email address for order confirmation.",
3226            ),
3227            StorefrontStateError::InvalidPaymentLast4 => (
3228                "payment_last4",
3229                "Enter the final 4 digits for the payment card.",
3230            ),
3231            StorefrontStateError::MissingPaymentIntent => (
3232                "checkout_intent",
3233                "Refresh checkout and try again before placing the order.",
3234            ),
3235            _ => (
3236                "checkout_email",
3237                "Update the checkout details and try again.",
3238            ),
3239        };
3240        form_state = form_state.with_field_error(field, message);
3241        form_state
3242    })
3243}
3244
3245fn verified_webhook_headers(execution: &RequestExecution, source: &str, event: &str) -> Headers {
3246    let mut headers = customer_hook_request_headers(execution);
3247    headers.insert(
3248        "x-coil-verified-webhook-source".to_string(),
3249        source.to_string(),
3250    );
3251    headers.insert(
3252        "x-coil-verified-webhook-event".to_string(),
3253        event.to_string(),
3254    );
3255    if let Some(delivery_id) = execution
3256        .headers
3257        .get("stripe-signature")
3258        .filter(|_| source == "stripe")
3259        .and_then(|_| stripe_event_delivery_id_from_request_body(execution).ok())
3260    {
3261        headers.insert(
3262            "x-coil-verified-webhook-delivery-id".to_string(),
3263            delivery_id,
3264        );
3265    }
3266    if let Some(content_type) = execution.content_type.clone() {
3267        headers.insert("content-type".to_string(), content_type);
3268    }
3269    headers
3270}
3271
3272fn verified_webhook_payload(execution: &RequestExecution) -> Result<Vec<u8>, RuntimeServerError> {
3273    if !execution.raw_body.is_empty() {
3274        return Ok(execution.raw_body.clone());
3275    }
3276    serde_json::to_vec(&execution.form_fields).map_err(|error| RuntimeServerError::Configuration {
3277        reason: format!("failed to encode verified webhook payload for customer hooks: {error}"),
3278    })
3279}
3280
3281fn validated_verified_payment_webhook_from_execution(
3282    state: &RuntimeServerState,
3283    execution: &RequestExecution,
3284) -> Result<VerifiedIngressWebhook, RuntimeServerError> {
3285    if configured_commerce_payment_provider(&state.plan.config)
3286        .as_ref()
3287        .is_some_and(|provider| provider.code == "stripe")
3288        && execution.headers.contains_key("stripe-signature")
3289    {
3290        return validated_stripe_payment_webhook_from_execution(state, execution);
3291    }
3292    validated_generic_verified_payment_webhook_from_execution(state, execution)
3293}
3294
3295fn validated_generic_verified_payment_webhook_from_execution(
3296    state: &RuntimeServerState,
3297    execution: &RequestExecution,
3298) -> Result<VerifiedIngressWebhook, RuntimeServerError> {
3299    let provider = execution_form_field(execution, "provider")
3300        .unwrap_or("generic")
3301        .trim()
3302        .to_ascii_lowercase();
3303    if let Some(configured_provider) = configured_commerce_payment_provider(&state.plan.config) {
3304        if provider != configured_provider.code {
3305            return Err(RuntimeServerError::Storefront(
3306                StorefrontStateError::UnexpectedPaymentWebhookProvider {
3307                    expected: configured_provider.code,
3308                    received: provider,
3309                },
3310            ));
3311        }
3312    }
3313    let event = execution_form_field(execution, "event")
3314        .or_else(|| execution_form_field(execution, "payment_event"))
3315        .map(str::trim)
3316        .filter(|value| !value.is_empty())
3317        .map(str::to_string)
3318        .ok_or_else(|| {
3319            RuntimeServerError::Storefront(StorefrontStateError::UnknownPaymentWebhookEvent {
3320                event: "<missing>".to_string(),
3321            })
3322        })?;
3323    let payment_reference = execution_form_field(execution, "payment_reference")
3324        .or_else(|| execution_form_field(execution, "payment_reference"))
3325        .map(str::trim)
3326        .filter(|value| !value.is_empty())
3327        .map(str::to_string)
3328        .ok_or_else(|| {
3329            RuntimeServerError::Storefront(StorefrontStateError::UnknownPaymentReference {
3330                payment_reference: "<missing>".to_string(),
3331            })
3332        })?;
3333    let signature = execution_form_field(execution, "signature")
3334        .or_else(|| execution_form_field(execution, "webhook_signature"))
3335        .map(str::trim)
3336        .filter(|value| !value.is_empty())
3337        .ok_or(RuntimeServerError::Storefront(
3338            StorefrontStateError::InvalidPaymentWebhookSignature,
3339        ))?;
3340    let secret = state
3341        .payment_webhook_secret
3342        .as_deref()
3343        .ok_or(RuntimeServerError::Storefront(
3344            StorefrontStateError::MissingPaymentWebhookSecret,
3345        ))?;
3346    let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|_| {
3347        RuntimeServerError::Storefront(StorefrontStateError::MissingPaymentWebhookSecret)
3348    })?;
3349    mac.update(provider.as_bytes());
3350    mac.update(b":");
3351    mac.update(event.as_bytes());
3352    mac.update(b":");
3353    mac.update(payment_reference.as_bytes());
3354    let provided_signature = decode_hex_signature(signature).ok_or(
3355        RuntimeServerError::Storefront(StorefrontStateError::InvalidPaymentWebhookSignature),
3356    )?;
3357    if mac.verify_slice(&provided_signature).is_err() {
3358        return Err(RuntimeServerError::Storefront(
3359            StorefrontStateError::InvalidPaymentWebhookSignature,
3360        ));
3361    }
3362    let payload = verified_webhook_payload(execution)?;
3363    let delivery_id =
3364        generic_verified_webhook_delivery_id(&provider, &event, &payment_reference, &payload);
3365    let mut headers = verified_webhook_headers(execution, &provider, &event);
3366    headers.insert(
3367        "x-coil-verified-webhook-delivery-id".to_string(),
3368        delivery_id.clone(),
3369    );
3370    Ok(VerifiedIngressWebhook {
3371        webhook: VerifiedWebhook {
3372            source: provider.clone(),
3373            event: event.clone(),
3374            headers,
3375            content_type: execution.content_type.clone(),
3376            payload,
3377        },
3378        payment_reference: Some(payment_reference),
3379        delivery_id: Some(delivery_id),
3380    })
3381}
3382
3383fn validated_stripe_payment_webhook_from_execution(
3384    state: &RuntimeServerState,
3385    execution: &RequestExecution,
3386) -> Result<VerifiedIngressWebhook, RuntimeServerError> {
3387    let secret = state
3388        .payment_webhook_secret
3389        .as_deref()
3390        .ok_or(RuntimeServerError::Storefront(
3391            StorefrontStateError::MissingPaymentWebhookSecret,
3392        ))?;
3393    let signature = execution
3394        .headers
3395        .get("stripe-signature")
3396        .map(String::as_str)
3397        .ok_or(RuntimeServerError::Storefront(
3398            StorefrontStateError::InvalidPaymentWebhookSignature,
3399        ))?;
3400    let payload = verified_webhook_payload(execution)?;
3401    verify_stripe_webhook_signature(secret, signature, &payload)?;
3402    let event = serde_json::from_slice::<serde_json::Value>(&payload).map_err(|error| {
3403        RuntimeServerError::Configuration {
3404            reason: format!("failed to decode Stripe webhook payload: {error}"),
3405        }
3406    })?;
3407    let event_name = event
3408        .get("type")
3409        .and_then(serde_json::Value::as_str)
3410        .map(str::trim)
3411        .filter(|value| !value.is_empty())
3412        .ok_or_else(|| {
3413            RuntimeServerError::Storefront(StorefrontStateError::UnknownPaymentWebhookEvent {
3414                event: "<missing>".to_string(),
3415            })
3416        })?
3417        .to_string();
3418    let delivery_id = stripe_event_delivery_id_from_event(&event)?;
3419    let payment_reference = stripe_payment_reference_from_event(&event)?;
3420    let mut headers = verified_webhook_headers(execution, "stripe", &event_name);
3421    headers.insert(
3422        "x-coil-verified-webhook-delivery-id".to_string(),
3423        delivery_id.clone(),
3424    );
3425    Ok(VerifiedIngressWebhook {
3426        webhook: VerifiedWebhook {
3427            source: "stripe".to_string(),
3428            event: event_name.clone(),
3429            headers,
3430            content_type: execution.content_type.clone(),
3431            payload,
3432        },
3433        payment_reference: Some(payment_reference),
3434        delivery_id: Some(delivery_id),
3435    })
3436}
3437
3438fn verify_stripe_webhook_signature(
3439    secret: &str,
3440    signature_header: &str,
3441    payload: &[u8],
3442) -> Result<(), RuntimeServerError> {
3443    let mut timestamp = None;
3444    let mut signatures = Vec::new();
3445    for segment in signature_header.split(',') {
3446        let Some((name, value)) = segment.trim().split_once('=') else {
3447            continue;
3448        };
3449        match name.trim() {
3450            "t" => timestamp = Some(value.trim().to_string()),
3451            "v1" => signatures.push(value.trim().to_string()),
3452            _ => {}
3453        }
3454    }
3455    let timestamp = timestamp.ok_or(RuntimeServerError::Storefront(
3456        StorefrontStateError::InvalidPaymentWebhookSignature,
3457    ))?;
3458    let timestamp = timestamp.parse::<u64>().map_err(|_| {
3459        RuntimeServerError::Storefront(StorefrontStateError::InvalidPaymentWebhookSignature)
3460    })?;
3461    if signatures.is_empty() {
3462        return Err(RuntimeServerError::Storefront(
3463            StorefrontStateError::InvalidPaymentWebhookSignature,
3464        ));
3465    }
3466    let now = SystemTime::now()
3467        .duration_since(UNIX_EPOCH)
3468        .unwrap_or_default()
3469        .as_secs();
3470    if now.abs_diff(timestamp) > STRIPE_WEBHOOK_MAX_AGE_SECS {
3471        return Err(RuntimeServerError::Storefront(
3472            StorefrontStateError::InvalidPaymentWebhookSignature,
3473        ));
3474    }
3475
3476    let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|_| {
3477        RuntimeServerError::Storefront(StorefrontStateError::MissingPaymentWebhookSecret)
3478    })?;
3479    mac.update(timestamp.to_string().as_bytes());
3480    mac.update(b".");
3481    mac.update(payload);
3482    let expected = mac.finalize().into_bytes();
3483    let matches = signatures.iter().any(|candidate| {
3484        decode_hex_signature(candidate)
3485            .map(|provided| provided == expected.as_slice())
3486            .unwrap_or(false)
3487    });
3488    if matches {
3489        Ok(())
3490    } else {
3491        Err(RuntimeServerError::Storefront(
3492            StorefrontStateError::InvalidPaymentWebhookSignature,
3493        ))
3494    }
3495}
3496
3497fn generic_verified_webhook_delivery_id(
3498    provider: &str,
3499    event: &str,
3500    payment_reference: &str,
3501    payload: &[u8],
3502) -> String {
3503    let mut hasher = Sha256::new();
3504    hasher.update(provider.as_bytes());
3505    hasher.update(b":");
3506    hasher.update(event.as_bytes());
3507    hasher.update(b":");
3508    hasher.update(payment_reference.as_bytes());
3509    hasher.update(b":");
3510    hasher.update(payload);
3511    format!("{:x}", hasher.finalize())
3512}
3513
3514fn stripe_payment_reference_from_event(
3515    event: &serde_json::Value,
3516) -> Result<String, RuntimeServerError> {
3517    let object = event
3518        .get("data")
3519        .and_then(|value| value.get("object"))
3520        .ok_or_else(|| {
3521            RuntimeServerError::Storefront(StorefrontStateError::UnknownPaymentReference {
3522                payment_reference: "<missing>".to_string(),
3523            })
3524        })?;
3525    let metadata = object
3526        .get("metadata")
3527        .and_then(serde_json::Value::as_object);
3528    let payment_reference = metadata
3529        .and_then(|metadata| {
3530            ["payment_reference", "checkout_intent"]
3531            .iter()
3532            .find_map(|key| metadata.get(*key).and_then(serde_json::Value::as_str))
3533        })
3534        .or_else(|| {
3535            object
3536                .get("client_reference_id")
3537                .and_then(serde_json::Value::as_str)
3538        })
3539        .or_else(|| {
3540            object
3541                .get("payment_intent")
3542                .and_then(serde_json::Value::as_str)
3543        })
3544        .map(str::trim)
3545        .filter(|value| !value.is_empty())
3546        .map(str::to_string)
3547        .ok_or_else(|| {
3548            RuntimeServerError::Storefront(StorefrontStateError::UnknownPaymentReference {
3549                payment_reference: "<missing>".to_string(),
3550            })
3551        })?;
3552    Ok(payment_reference)
3553}
3554
3555fn stripe_event_delivery_id_from_request_body(
3556    execution: &RequestExecution,
3557) -> Result<String, RuntimeServerError> {
3558    let payload = verified_webhook_payload(execution)?;
3559    let event = serde_json::from_slice::<serde_json::Value>(&payload).map_err(|error| {
3560        RuntimeServerError::Configuration {
3561            reason: format!("failed to decode Stripe webhook payload: {error}"),
3562        }
3563    })?;
3564    stripe_event_delivery_id_from_event(&event)
3565}
3566
3567fn stripe_event_delivery_id_from_event(
3568    event: &serde_json::Value,
3569) -> Result<String, RuntimeServerError> {
3570    event
3571        .get("id")
3572        .and_then(serde_json::Value::as_str)
3573        .map(str::trim)
3574        .filter(|value| !value.is_empty())
3575        .map(str::to_string)
3576        .ok_or(RuntimeServerError::Storefront(
3577            StorefrontStateError::MissingPaymentWebhookDeliveryId,
3578        ))
3579}
3580
3581fn verified_webhook_from_execution(
3582    state: &RuntimeServerState,
3583    execution: &RequestExecution,
3584) -> Result<Option<VerifiedIngressWebhook>, RuntimeServerError> {
3585    match execution.route.route_name.as_str() {
3586        "commerce.payment-provider-webhook" => validated_verified_payment_webhook_from_execution(
3587            state, execution,
3588        )
3589        .and_then(|verified| {
3590            guard_verified_webhook_replay(state, execution, &verified)?;
3591            Ok(Some(verified))
3592        }),
3593        _ => Ok(None),
3594    }
3595}
3596
3597fn guard_verified_webhook_replay(
3598    state: &RuntimeServerState,
3599    execution: &RequestExecution,
3600    verified: &VerifiedIngressWebhook,
3601) -> Result<(), RuntimeServerError> {
3602    let Some(delivery_id) = verified.delivery_id.as_deref() else {
3603        return Ok(());
3604    };
3605    let recorded_at_unix_seconds = SystemTime::now()
3606        .duration_since(UNIX_EPOCH)
3607        .unwrap_or_default()
3608        .as_secs() as i64;
3609    let claimed = if should_fallback_to_local_verified_webhook_replay_store(state) {
3610        claim_local_verified_webhook_delivery(
3611            state,
3612            execution,
3613            verified.webhook.source.as_str(),
3614            delivery_id,
3615            recorded_at_unix_seconds,
3616        )?
3617    } else {
3618        state
3619            .wasm_host
3620            .claim_verified_webhook_delivery(
3621                execution.customer_app.as_str(),
3622                execution.route.route_name.as_str(),
3623                verified.webhook.source.as_str(),
3624                delivery_id,
3625                execution.trace.request_id.as_str(),
3626                recorded_at_unix_seconds,
3627            )
3628            .map_err(|reason| RuntimeServerError::Configuration {
3629                reason: format!("failed to persist verified webhook replay receipt: {reason}"),
3630            })?
3631    };
3632    if claimed {
3633        return Ok(());
3634    }
3635    record_verified_webhook_request_observation(
3636        state,
3637        execution,
3638        &verified.webhook,
3639        crate::wasm::WebhookObservationStatus::ReplayRejected,
3640        Some(format!(
3641            "verified webhook delivery `{delivery_id}` has already been processed"
3642        )),
3643    );
3644    Err(RuntimeServerError::Storefront(
3645        StorefrontStateError::ReplayedPaymentWebhookDelivery {
3646            delivery_id: delivery_id.to_string(),
3647        },
3648    ))
3649}
3650
3651fn should_fallback_to_local_verified_webhook_replay_store(state: &RuntimeServerState) -> bool {
3652    matches!(
3653        state.plan.metadata_audit_backend_selection(),
3654        crate::plan::MetadataAuditBackendSelection::SharedPostgres { .. }
3655    ) && std::env::var_os("DATABASE_URL").is_none()
3656}
3657
3658fn claim_local_verified_webhook_delivery(
3659    state: &RuntimeServerState,
3660    execution: &RequestExecution,
3661    source: &str,
3662    delivery_id: &str,
3663    recorded_at_unix_seconds: i64,
3664) -> Result<bool, RuntimeServerError> {
3665    let path = state
3666        .plan
3667        .shared_state_root()
3668        .join("server")
3669        .join("verified-webhook-deliveries.sqlite3");
3670    if let Some(parent) = path.parent() {
3671        std::fs::create_dir_all(parent).map_err(|error| RuntimeServerError::Configuration {
3672            reason: format!(
3673                "failed to create verified webhook replay directory `{}`: {error}",
3674                parent.display()
3675            ),
3676        })?;
3677    }
3678    let connection =
3679        rusqlite::Connection::open(&path).map_err(|error| RuntimeServerError::Configuration {
3680            reason: format!(
3681                "failed to open local verified webhook replay store `{}`: {error}",
3682                path.display()
3683            ),
3684        })?;
3685    connection
3686        .execute_batch(
3687            r#"
3688            PRAGMA journal_mode = WAL;
3689            PRAGMA synchronous = FULL;
3690            CREATE TABLE IF NOT EXISTS verified_webhook_deliveries (
3691                app_id TEXT NOT NULL,
3692                route_name TEXT NOT NULL,
3693                source TEXT NOT NULL,
3694                delivery_id TEXT NOT NULL,
3695                first_seen_request_id TEXT NOT NULL,
3696                first_seen_at_unix_seconds INTEGER NOT NULL,
3697                PRIMARY KEY (app_id, source, delivery_id)
3698            );
3699            "#,
3700        )
3701        .map_err(|error| RuntimeServerError::Configuration {
3702            reason: format!(
3703                "failed to initialize local verified webhook replay store `{}`: {error}",
3704                path.display()
3705            ),
3706        })?;
3707    let inserted = connection
3708        .execute(
3709            r#"
3710            INSERT OR IGNORE INTO verified_webhook_deliveries (
3711                app_id,
3712                route_name,
3713                source,
3714                delivery_id,
3715                first_seen_request_id,
3716                first_seen_at_unix_seconds
3717            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
3718            "#,
3719            rusqlite::params![
3720                execution.customer_app.as_str(),
3721                execution.route.route_name.as_str(),
3722                source,
3723                delivery_id,
3724                execution.trace.request_id.as_str(),
3725                recorded_at_unix_seconds,
3726            ],
3727        )
3728        .map_err(|error| RuntimeServerError::Configuration {
3729            reason: format!(
3730                "failed to persist local verified webhook replay receipt `{}`: {error}",
3731                path.display()
3732            ),
3733        })?;
3734    Ok(inserted > 0)
3735}
3736
3737fn record_verified_webhook_request_observation(
3738    state: &RuntimeServerState,
3739    execution: &RequestExecution,
3740    webhook: &VerifiedWebhook,
3741    status: crate::wasm::WebhookObservationStatus,
3742    detail: Option<String>,
3743) {
3744    let principal_kind = match execution.principal.principal_kind {
3745        RequestPrincipalKind::Anonymous => "anonymous",
3746        RequestPrincipalKind::User => "user",
3747        RequestPrincipalKind::ServiceAccount => "service_account",
3748    };
3749    let _ = state.wasm_host.record_webhook_request_observation(
3750        execution.customer_app.as_str(),
3751        webhook.source.as_str(),
3752        webhook.event.as_str(),
3753        status,
3754        execution.trace.request_id.as_str(),
3755        principal_kind,
3756        execution.principal.principal_id.as_deref(),
3757        detail,
3758    );
3759}
3760
3761fn storefront_payment_input_from_execution(
3762    execution: &RequestExecution,
3763) -> Result<StorefrontPaymentInput, RuntimeServerError> {
3764    let intent_reference = execution_form_field(execution, "checkout_intent")
3765        .or_else(|| execution_form_field(execution, "payment_intent"))
3766        .or_else(|| execution_form_field(execution, "payment_reference"));
3767    let last4 = execution_form_field(execution, "payment_last4")
3768        .or_else(|| execution_form_field(execution, "card_last4"))
3769        .map(str::to_string);
3770    let method = execution_form_field(execution, "payment_method")
3771        .map(str::to_string)
3772        .or_else(|| last4.as_ref().map(|_| "card".to_string()));
3773    let checkout_email = execution_form_field(execution, "checkout_email")
3774        .or_else(|| execution_form_field(execution, "email"))
3775        .or_else(|| execution_form_field(execution, "billing_email"));
3776    StorefrontPaymentInput::new(
3777        method.unwrap_or_default(),
3778        checkout_email.unwrap_or_default(),
3779        last4,
3780        intent_reference.unwrap_or_default(),
3781    )
3782    .map_err(RuntimeServerError::Storefront)
3783}
3784
3785fn apply_native_cms_admin_mutations(
3786    state: &RuntimeServerState,
3787    execution: &RequestExecution,
3788    now: BrowserInstant,
3789    response_cookies: &mut Vec<String>,
3790) -> Result<Option<String>, RuntimeServerError> {
3791    if !CMS_ADMIN_NATIVE_MUTATION_ROUTES.contains(&execution.route.route_name.as_str()) {
3792        return Ok(None);
3793    }
3794
3795    let mut workspace = CmsAdminWorkspace::load(&state.plan).map_err(|reason| {
3796        RuntimeServerError::Configuration {
3797            reason: format!("failed to load CMS admin workspace: {reason}"),
3798        }
3799    })?;
3800
3801    match execution.route.route_name.as_str() {
3802        "cms.pages.save-draft" => {
3803            let page_input = CmsAdminPageInput {
3804                page_id: execution_form_field(execution, "page_id").map(str::to_string),
3805                title: storefront_form_field_value(execution, "page_title"),
3806                slug: storefront_form_field_value(execution, "page_slug"),
3807                summary: storefront_form_field_value(execution, "page_summary"),
3808                body_html: storefront_form_field_value(execution, "page_body_html"),
3809            };
3810            let page_id = match workspace.save_page_draft(page_input, now.as_unix_seconds()) {
3811                Ok(page_id) => page_id,
3812                Err(reason) => {
3813                    let mut form_state =
3814                        cms_page_form_state_from_execution(execution, reason.clone());
3815                    for field in ["page_title", "page_slug", "page_summary", "page_body_html"] {
3816                        form_state = form_state.with_field_error(field, reason.clone());
3817                    }
3818                    push_storefront_form_state(state, response_cookies, &form_state)?;
3819                    return Ok(Some("/admin/pages".to_string()));
3820                }
3821            };
3822            workspace
3823                .save(&state.plan)
3824                .map_err(|reason| RuntimeServerError::Configuration {
3825                    reason: format!("failed to persist CMS page draft: {reason}"),
3826                })?;
3827            record_operator_audit(
3828                state,
3829                execution,
3830                "cms.pages.save-draft",
3831                "page",
3832                page_id.as_str(),
3833                "succeeded",
3834                &format!(
3835                    "Saved draft `{}` at slug `{}`.",
3836                    storefront_form_field_value(execution, "page_title"),
3837                    storefront_form_field_value(execution, "page_slug"),
3838                ),
3839            )?;
3840            push_storefront_flash(
3841                state,
3842                response_cookies,
3843                FlashLevel::Success,
3844                "Draft saved. Preview and publish when ready.",
3845            )?;
3846            return Ok(Some(format!("/admin/pages?page={page_id}")));
3847        }
3848        "cms.pages.publish" => {
3849            let page_id = if execution_form_field(execution, "page_title").is_some() {
3850                let page_input = CmsAdminPageInput {
3851                    page_id: execution_form_field(execution, "page_id").map(str::to_string),
3852                    title: storefront_form_field_value(execution, "page_title"),
3853                    slug: storefront_form_field_value(execution, "page_slug"),
3854                    summary: storefront_form_field_value(execution, "page_summary"),
3855                    body_html: storefront_form_field_value(execution, "page_body_html"),
3856                };
3857                match workspace.save_page_draft(page_input, now.as_unix_seconds()) {
3858                    Ok(page_id) => page_id,
3859                    Err(reason) => {
3860                        let mut form_state =
3861                            cms_page_form_state_from_execution(execution, reason.clone());
3862                        for field in ["page_title", "page_slug", "page_summary", "page_body_html"] {
3863                            form_state = form_state.with_field_error(field, reason.clone());
3864                        }
3865                        push_storefront_form_state(state, response_cookies, &form_state)?;
3866                        return Ok(Some("/admin/pages".to_string()));
3867                    }
3868                }
3869            } else {
3870                execution_form_field(execution, "page_id")
3871                    .map(str::to_string)
3872                    .ok_or_else(|| RuntimeServerError::Configuration {
3873                        reason: "missing page_id for publish".to_string(),
3874                    })?
3875            };
3876            if let Some(location) = validate_cms_publish_with_customer_hooks(
3877                state,
3878                execution,
3879                &mut workspace,
3880                &page_id,
3881                now,
3882                response_cookies,
3883            )? {
3884                return Ok(Some(location));
3885            }
3886            workspace
3887                .publish_page(&page_id, now.as_unix_seconds())
3888                .map_err(|reason| RuntimeServerError::Configuration { reason })?;
3889            workspace
3890                .save(&state.plan)
3891                .map_err(|reason| RuntimeServerError::Configuration {
3892                    reason: format!("failed to persist CMS publication: {reason}"),
3893                })?;
3894            let live_path = workspace
3895                .selected_page(Some(&page_id))
3896                .and_then(|page| page.live_path())
3897                .unwrap_or_else(|| "/pages/{slug}".to_string());
3898            record_operator_audit(
3899                state,
3900                execution,
3901                "cms.pages.publish",
3902                "page",
3903                page_id.as_str(),
3904                "succeeded",
3905                &format!("Published live route `{live_path}`."),
3906            )?;
3907            push_storefront_flash(
3908                state,
3909                response_cookies,
3910                FlashLevel::Success,
3911                "Page published to the live /pages/{slug} surface.",
3912            )?;
3913            return Ok(Some(format!("/admin/pages?page={page_id}")));
3914        }
3915        "cms.pages.unpublish" => {
3916            let page_id = execution_form_field(execution, "page_id").ok_or_else(|| {
3917                RuntimeServerError::Configuration {
3918                    reason: "missing page_id for unpublish".to_string(),
3919                }
3920            })?;
3921            workspace
3922                .unpublish_page(page_id, now.as_unix_seconds())
3923                .map_err(|reason| RuntimeServerError::Configuration { reason })?;
3924            workspace
3925                .save(&state.plan)
3926                .map_err(|reason| RuntimeServerError::Configuration {
3927                    reason: format!("failed to persist CMS unpublish: {reason}"),
3928                })?;
3929            record_operator_audit(
3930                state,
3931                execution,
3932                "cms.pages.unpublish",
3933                "page",
3934                page_id,
3935                "succeeded",
3936                "Removed the page from its live route while preserving the draft.",
3937            )?;
3938            push_storefront_flash(
3939                state,
3940                response_cookies,
3941                FlashLevel::Info,
3942                "Page removed from the live route but kept as a draft.",
3943            )?;
3944            return Ok(Some(format!("/admin/pages?page={page_id}")));
3945        }
3946        "cms.navigation.save" => {
3947            let items = match navigation_items_from_fields(&execution.form_fields) {
3948                Ok(items) => items,
3949                Err(reason) => {
3950                    let form_state =
3951                        cms_navigation_form_state_from_execution(execution, reason.clone())
3952                            .with_field_error("new_nav_label", reason);
3953                    push_storefront_form_state(state, response_cookies, &form_state)?;
3954                    return Ok(Some("/admin/navigation".to_string()));
3955                }
3956            };
3957            if let Err(reason) = workspace.save_navigation(items) {
3958                let form_state =
3959                    cms_navigation_form_state_from_execution(execution, reason.clone())
3960                        .with_field_error("new_nav_label", reason);
3961                push_storefront_form_state(state, response_cookies, &form_state)?;
3962                return Ok(Some("/admin/navigation".to_string()));
3963            }
3964            workspace
3965                .save(&state.plan)
3966                .map_err(|reason| RuntimeServerError::Configuration {
3967                    reason: format!("failed to persist CMS navigation: {reason}"),
3968                })?;
3969            record_operator_audit(
3970                state,
3971                execution,
3972                "cms.navigation.save",
3973                "navigation",
3974                "primary-navigation",
3975                "succeeded",
3976                &format!(
3977                    "Saved {} primary navigation items.",
3978                    workspace.navigation.len()
3979                ),
3980            )?;
3981            push_storefront_flash(
3982                state,
3983                response_cookies,
3984                FlashLevel::Success,
3985                "Primary navigation updated for the live storefront shell.",
3986            )?;
3987            return Ok(Some("/admin/navigation".to_string()));
3988        }
3989        "cms.redirects.save" => {
3990            let redirects = match redirects_from_fields(&execution.form_fields) {
3991                Ok(redirects) => redirects,
3992                Err(reason) => {
3993                    let form_state =
3994                        cms_redirect_form_state_from_execution(execution, reason.clone())
3995                            .with_field_error("new_redirect_from", reason);
3996                    push_storefront_form_state(state, response_cookies, &form_state)?;
3997                    return Ok(Some("/admin/redirects".to_string()));
3998                }
3999            };
4000            if let Err(reason) = workspace.save_redirects(redirects) {
4001                let form_state = cms_redirect_form_state_from_execution(execution, reason.clone())
4002                    .with_field_error("new_redirect_from", reason);
4003                push_storefront_form_state(state, response_cookies, &form_state)?;
4004                return Ok(Some("/admin/redirects".to_string()));
4005            }
4006            workspace
4007                .save(&state.plan)
4008                .map_err(|reason| RuntimeServerError::Configuration {
4009                    reason: format!("failed to persist CMS redirects: {reason}"),
4010                })?;
4011            record_operator_audit(
4012                state,
4013                execution,
4014                "cms.redirects.save",
4015                "redirects",
4016                "live-redirect-rules",
4017                "succeeded",
4018                &format!("Saved {} redirect rules.", workspace.redirects.len()),
4019            )?;
4020            push_storefront_flash(
4021                state,
4022                response_cookies,
4023                FlashLevel::Success,
4024                "Redirect rules saved for unmatched live requests.",
4025            )?;
4026            return Ok(Some("/admin/redirects".to_string()));
4027        }
4028        _ => {}
4029    }
4030
4031    Ok(None)
4032}
4033
4034fn normalized_payment_webhook_event<'a>(source: &str, event: &'a str) -> Cow<'a, str> {
4035    match (source, event) {
4036        ("stripe", "checkout.session.completed") => Cow::Borrowed("payment.captured"),
4037        ("stripe", "payment_intent.succeeded") => Cow::Borrowed("payment.captured"),
4038        ("stripe", "payment_intent.amount_capturable_updated") => {
4039            Cow::Borrowed("payment.authorized")
4040        }
4041        ("stripe", "checkout.session.expired") => Cow::Borrowed("payment.failed"),
4042        ("stripe", "payment_intent.payment_failed") => Cow::Borrowed("payment.failed"),
4043        _ => Cow::Borrowed(event),
4044    }
4045}
4046
4047async fn apply_native_storefront_mutations(
4048    state: &RuntimeServerState,
4049    execution: &RequestExecution,
4050    now: BrowserInstant,
4051    response_cookies: &mut Vec<String>,
4052) -> Result<Option<String>, RuntimeServerError> {
4053    if let Some(verified) = verified_webhook_from_execution(state, execution)? {
4054        if let Err(error) =
4055            execute_verified_webhook_customer_hooks(state, execution, &verified.webhook, now)
4056        {
4057            record_verified_webhook_request_observation(
4058                state,
4059                execution,
4060                &verified.webhook,
4061                crate::wasm::WebhookObservationStatus::ExecutionFailed,
4062                Some(error.to_string()),
4063            );
4064            return Err(error);
4065        }
4066        if execution.route.route_name.as_str() != "commerce.payment-provider-webhook" {
4067            return Ok(None);
4068        }
4069        let payment_reference = verified.payment_reference.as_deref().ok_or_else(|| {
4070            RuntimeServerError::Configuration {
4071                reason: format!(
4072                    "verified webhook route `{}` did not provide a required payment reference",
4073                    execution.route.route_name
4074                ),
4075            }
4076        })?;
4077        let receipt = match state.storefront.apply_payment_webhook(
4078            payment_reference,
4079            normalized_payment_webhook_event(
4080                verified.webhook.source.as_str(),
4081                verified.webhook.event.as_str(),
4082            )
4083            .as_ref(),
4084            now.as_unix_seconds(),
4085        ) {
4086            Ok(receipt) => receipt,
4087            Err(error) => {
4088                record_verified_webhook_request_observation(
4089                    state,
4090                    execution,
4091                    &verified.webhook,
4092                    crate::wasm::WebhookObservationStatus::ExecutionFailed,
4093                    Some(error.to_string()),
4094                );
4095                return Err(RuntimeServerError::Storefront(error));
4096            }
4097        };
4098        if receipt.needs_paid_event_dispatch {
4099            if let Err(error) = dispatch_paid_order_event(state, &receipt.order, now) {
4100                record_verified_webhook_request_observation(
4101                    state,
4102                    execution,
4103                    &verified.webhook,
4104                    crate::wasm::WebhookObservationStatus::ExecutionFailed,
4105                    Some(error.to_string()),
4106                );
4107                return Err(error);
4108            }
4109            if let Err(error) = state
4110                .storefront
4111                .mark_order_paid_event_dispatched(&receipt.order.order_id, now.as_unix_seconds())
4112            {
4113                record_verified_webhook_request_observation(
4114                    state,
4115                    execution,
4116                    &verified.webhook,
4117                    crate::wasm::WebhookObservationStatus::ExecutionFailed,
4118                    Some(error.to_string()),
4119                );
4120                return Err(RuntimeServerError::Storefront(error));
4121            }
4122        }
4123        record_verified_webhook_request_observation(
4124            state,
4125            execution,
4126            &verified.webhook,
4127            crate::wasm::WebhookObservationStatus::Accepted,
4128            None,
4129        );
4130        return Ok(None);
4131    }
4132
4133    let Some(session_id) = execution.session.session_id.as_deref() else {
4134        return Ok(None);
4135    };
4136    match execution.route.route_name.as_str() {
4137        "commerce.add-to-cart" => {
4138            let quantity = storefront_quantity_from_execution(execution);
4139            let sku = storefront_sku_from_execution(execution)?;
4140            if storefront_catalog_product_for_execution(state, execution, sku.as_ref()).is_none() {
4141                let form_state = StorefrontFormState::new(
4142                    "commerce.cart",
4143                    "That product is not available on this site right now.",
4144                );
4145                push_storefront_form_state(state, response_cookies, &form_state)?;
4146                return Ok(Some("/cart".to_string()));
4147            }
4148            let snapshot = state.storefront.add_to_cart(
4149                session_id,
4150                execution.principal.principal_id.as_deref(),
4151                sku.as_ref(),
4152                quantity,
4153                now.as_unix_seconds(),
4154            )?;
4155            push_storefront_flash(
4156                state,
4157                response_cookies,
4158                FlashLevel::Success,
4159                format!("Added {} to the cart ({})", sku, snapshot.cart.item_count),
4160            )?;
4161        }
4162        "commerce.cart-update" => {
4163            let quantities = match validated_cart_quantities_from_execution(execution) {
4164                Ok(quantities) => quantities,
4165                Err(form_state) => {
4166                    push_storefront_form_state(state, response_cookies, &form_state)?;
4167                    return Ok(Some("/cart".to_string()));
4168                }
4169            };
4170            let mut snapshot = state
4171                .storefront
4172                .snapshot(session_id, execution.principal.principal_id.as_deref())?;
4173            for (sku, quantity) in quantities {
4174                snapshot = state.storefront.update_cart(
4175                    session_id,
4176                    execution.principal.principal_id.as_deref(),
4177                    &sku,
4178                    quantity,
4179                    now.as_unix_seconds(),
4180                )?;
4181            }
4182            let message = if snapshot.cart.lines.is_empty() {
4183                "Your cart is now empty.".to_string()
4184            } else {
4185                format!("Updated cart with {} line(s).", snapshot.cart.item_count)
4186            };
4187            push_storefront_flash(state, response_cookies, FlashLevel::Info, message)?;
4188        }
4189        "commerce.checkout-start" => {
4190            if let Ok(sku) = storefront_sku_from_execution(execution) {
4191                let quantity = storefront_quantity_from_execution(execution);
4192                if storefront_catalog_product_for_execution(state, execution, sku.as_ref())
4193                    .is_none()
4194                {
4195                    let form_state = StorefrontFormState::new(
4196                        "commerce.cart",
4197                        "That product is not available on this site right now.",
4198                    );
4199                    push_storefront_form_state(state, response_cookies, &form_state)?;
4200                    return Ok(Some("/cart".to_string()));
4201                }
4202                let _ = state.storefront.add_to_cart(
4203                    session_id,
4204                    execution.principal.principal_id.as_deref(),
4205                    sku.as_ref(),
4206                    quantity,
4207                    now.as_unix_seconds(),
4208                )?;
4209            }
4210            match state.storefront.checkout_start(
4211                session_id,
4212                execution.principal.principal_id.as_deref(),
4213                now.as_unix_seconds(),
4214            ) {
4215                Ok(_) => {}
4216                Err(StorefrontStateError::EmptyCart { .. }) => {
4217                    let form_state = StorefrontFormState::new(
4218                        "commerce.cart",
4219                        "Add at least one item to the cart before starting checkout.",
4220                    );
4221                    push_storefront_form_state(state, response_cookies, &form_state)?;
4222                    return Ok(Some("/cart".to_string()));
4223                }
4224                Err(error) => return Err(RuntimeServerError::Storefront(error)),
4225            }
4226        }
4227        "commerce.checkout-complete" => {
4228            let payment = match validated_storefront_payment_input_from_execution(state, execution)
4229            {
4230                Ok(payment) => payment,
4231                Err(form_state) => {
4232                    push_storefront_form_state(state, response_cookies, &form_state)?;
4233                    return Ok(Some("/checkout".to_string()));
4234                }
4235            };
4236            if let Some(location) = review_checkout_with_customer_hooks(
4237                state,
4238                execution,
4239                session_id,
4240                &payment,
4241                now,
4242                response_cookies,
4243            )? {
4244                return Ok(Some(location));
4245            }
4246            let checkout_metadata = storefront_checkout_order_metadata(
4247                state,
4248                execution,
4249                &state
4250                    .storefront
4251                    .snapshot(session_id, execution.principal.principal_id.as_deref())?,
4252                session_id,
4253                execution.principal.principal_id.as_deref(),
4254                &payment,
4255            );
4256            let snapshot = match state.storefront.checkout_complete_with_metadata(
4257                session_id,
4258                execution.principal.principal_id.as_deref(),
4259                &payment,
4260                &checkout_metadata,
4261                now.as_unix_seconds(),
4262            ) {
4263                Ok(snapshot) => snapshot,
4264                Err(
4265                    error @ (StorefrontStateError::CheckoutNotReady { .. }
4266                    | StorefrontStateError::EmptyCart { .. }
4267                    | StorefrontStateError::MissingPaymentIntent
4268                    | StorefrontStateError::PaymentIntentMismatch { .. }),
4269                ) => {
4270                    let summary = match &error {
4271                        StorefrontStateError::CheckoutNotReady { .. } => {
4272                            "Refresh checkout and review the basket before placing the order."
4273                        }
4274                        StorefrontStateError::EmptyCart { .. } => {
4275                            "Add at least one item to the cart before placing the order."
4276                        }
4277                        StorefrontStateError::MissingPaymentIntent => {
4278                            "Refresh checkout before placing the order."
4279                        }
4280                        StorefrontStateError::PaymentIntentMismatch { .. } => {
4281                            "Refresh checkout before placing the order."
4282                        }
4283                        _ => "There is a problem with your checkout details.",
4284                    };
4285                    let mut form_state =
4286                        storefront_checkout_form_state_from_execution(execution, summary);
4287                    if matches!(
4288                        error,
4289                        StorefrontStateError::MissingPaymentIntent
4290                            | StorefrontStateError::PaymentIntentMismatch { .. }
4291                    ) {
4292                        form_state = form_state.with_field_error(
4293                            "checkout_intent",
4294                            "Refresh checkout and try again before placing the order.",
4295                        );
4296                    }
4297                    push_storefront_form_state(state, response_cookies, &form_state)?;
4298                    return Ok(Some("/checkout".to_string()));
4299                }
4300                Err(error) => return Err(RuntimeServerError::Storefront(error)),
4301            };
4302            if let Some(location) = finalize_storefront_checkout_completion(
4303                state,
4304                execution,
4305                &snapshot,
4306                now,
4307                response_cookies,
4308            )
4309            .await?
4310            {
4311                return Ok(Some(location));
4312            }
4313        }
4314        "commerce.catalog-admin-update" => {
4315            let update = match validated_catalog_admin_update_from_execution(execution) {
4316                Ok(update) => update,
4317                Err(form_state) => {
4318                    push_storefront_form_state(state, response_cookies, &form_state)?;
4319                    return Ok(Some("/admin/catalog/products".to_string()));
4320                }
4321            };
4322            let update_result = match &update {
4323                CatalogAdminMutationInput::Product(update) => state
4324                    .storefront
4325                    .update_catalog_product(update, now.as_unix_seconds()),
4326                CatalogAdminMutationInput::Collection(update) => state
4327                    .storefront
4328                    .update_catalog_collection(update, now.as_unix_seconds()),
4329            };
4330            match update_result {
4331                Ok(_) => {
4332                    let message = match &update {
4333                        CatalogAdminMutationInput::Product(update) => {
4334                            format!("Saved product changes for {}.", update.title)
4335                        }
4336                        CatalogAdminMutationInput::Collection(update) => {
4337                            format!("Saved collection changes for {}.", update.title)
4338                        }
4339                    };
4340                    match &update {
4341                        CatalogAdminMutationInput::Product(update) => {
4342                            record_operator_audit(
4343                                state,
4344                                execution,
4345                                "commerce.catalog-admin-update.product",
4346                                "product",
4347                                update.handle.as_str(),
4348                                "succeeded",
4349                                &format!(
4350                                    "Updated product `{}` at price_minor {} and marked it {}.",
4351                                    update.title,
4352                                    update.price_minor,
4353                                    if update.is_visible {
4354                                        "visible"
4355                                    } else {
4356                                        "hidden"
4357                                    }
4358                                ),
4359                            )?;
4360                        }
4361                        CatalogAdminMutationInput::Collection(update) => {
4362                            record_operator_audit(
4363                                state,
4364                                execution,
4365                                "commerce.catalog-admin-update.collection",
4366                                "collection",
4367                                update.handle.as_str(),
4368                                "succeeded",
4369                                &format!(
4370                                    "Updated collection `{}` and marked it {}.",
4371                                    update.title,
4372                                    if update.is_visible {
4373                                        "visible"
4374                                    } else {
4375                                        "hidden"
4376                                    }
4377                                ),
4378                            )?;
4379                        }
4380                    }
4381                    push_storefront_flash(state, response_cookies, FlashLevel::Success, message)?;
4382                    return Ok(Some("/admin/catalog/products".to_string()));
4383                }
4384                Err(
4385                    error @ (StorefrontStateError::MissingCatalogProduct { .. }
4386                    | StorefrontStateError::MissingCatalogCollection { .. }),
4387                ) => {
4388                    let mut form_state = match &update {
4389                        CatalogAdminMutationInput::Product(_) => {
4390                            catalog_admin_product_form_state_from_execution(
4391                                execution,
4392                                "Refresh the catalog admin page and try again.",
4393                            )
4394                        }
4395                        CatalogAdminMutationInput::Collection(_) => {
4396                            catalog_admin_collection_form_state_from_execution(
4397                                execution,
4398                                "Refresh the catalog admin page and try again.",
4399                            )
4400                        }
4401                    };
4402                    form_state = form_state.with_summary(error.to_string());
4403                    push_storefront_form_state(state, response_cookies, &form_state)?;
4404                    return Ok(Some("/admin/catalog/products".to_string()));
4405                }
4406                Err(error) => return Err(RuntimeServerError::Storefront(error)),
4407            }
4408        }
4409        "commerce.order-refund" => {
4410            let order_id = storefront_form_field_value(execution, "order_id");
4411            let reason = storefront_form_field_value(execution, "reason");
4412            let redirect_location = if order_id.trim().is_empty() {
4413                "/admin/orders".to_string()
4414            } else {
4415                format!("/admin/orders/{}", order_id.trim())
4416            };
4417            match state.storefront.refund_order(
4418                order_id.trim(),
4419                reason.as_str(),
4420                now.as_unix_seconds(),
4421            ) {
4422                Ok(order) => {
4423                    record_operator_audit(
4424                        state,
4425                        execution,
4426                        "commerce.order-refund",
4427                        "order",
4428                        order.order_id.as_str(),
4429                        "succeeded",
4430                        &format!(
4431                            "Refunded {} with reason `{}`.",
4432                            order.refunded_total, reason
4433                        ),
4434                    )?;
4435                    push_storefront_flash(
4436                        state,
4437                        response_cookies,
4438                        FlashLevel::Success,
4439                        format!(
4440                            "Refunded {} for order {}.",
4441                            order.refunded_total, order.order_id
4442                        ),
4443                    )?;
4444                    return Ok(Some(format!("/admin/orders/{}", order.order_id)));
4445                }
4446                Err(StorefrontStateError::MissingRefundReason) => {
4447                    record_operator_audit(
4448                        state,
4449                        execution,
4450                        "commerce.order-refund",
4451                        "order",
4452                        order_id.trim(),
4453                        "rejected",
4454                        "Refund reason was required but not provided.",
4455                    )?;
4456                    let form_state = order_refund_form_state_from_execution(
4457                        execution,
4458                        "Review the refund request and add a reason before trying again.",
4459                    )
4460                    .with_field_error("reason", "refund reason is required");
4461                    push_storefront_form_state(state, response_cookies, &form_state)?;
4462                    return Ok(Some(redirect_location));
4463                }
4464                Err(error @ StorefrontStateError::RefundNotAllowed { .. }) => {
4465                    record_operator_audit(
4466                        state,
4467                        execution,
4468                        "commerce.order-refund",
4469                        "order",
4470                        order_id.trim(),
4471                        "rejected",
4472                        &error.to_string(),
4473                    )?;
4474                    let form_state = order_refund_form_state_from_execution(
4475                        execution,
4476                        "This order cannot be refunded from the checked-in admin workflow right now.",
4477                    )
4478                    .with_field_error("reason", error.to_string());
4479                    push_storefront_form_state(state, response_cookies, &form_state)?;
4480                    return Ok(Some(redirect_location));
4481                }
4482                Err(error @ StorefrontStateError::UnknownOrder { .. }) => {
4483                    record_operator_audit(
4484                        state,
4485                        execution,
4486                        "commerce.order-refund",
4487                        "order",
4488                        order_id.trim(),
4489                        "rejected",
4490                        &error.to_string(),
4491                    )?;
4492                    if order_id.trim().is_empty() {
4493                        push_storefront_flash(
4494                            state,
4495                            response_cookies,
4496                            FlashLevel::Error,
4497                            error.to_string(),
4498                        )?;
4499                    } else {
4500                        let form_state = order_refund_form_state_from_execution(
4501                            execution,
4502                            "Refresh the order queue and reopen the detail view before retrying this refund.",
4503                        )
4504                        .with_field_error("order_id", error.to_string());
4505                        push_storefront_form_state(state, response_cookies, &form_state)?;
4506                    }
4507                    return Ok(Some(redirect_location));
4508                }
4509                Err(error) => return Err(RuntimeServerError::Storefront(error)),
4510            }
4511        }
4512        "commerce.order-fulfill" => {
4513            let order_id = storefront_form_field_value(execution, "order_id");
4514            let redirect_location = if order_id.trim().is_empty() {
4515                "/admin/orders".to_string()
4516            } else {
4517                format!("/admin/orders/{}", order_id.trim())
4518            };
4519            match state
4520                .storefront
4521                .fulfill_order(order_id.trim(), now.as_unix_seconds())
4522            {
4523                Ok(order) => {
4524                    record_operator_audit(
4525                        state,
4526                        execution,
4527                        "commerce.order-fulfill",
4528                        "order",
4529                        order.order_id.as_str(),
4530                        "succeeded",
4531                        &format!("Marked {} as fulfilled.", order.order_id),
4532                    )?;
4533                    push_storefront_flash(
4534                        state,
4535                        response_cookies,
4536                        FlashLevel::Success,
4537                        format!("Marked order {} as fulfilled.", order.order_id),
4538                    )?;
4539                    return Ok(Some(format!("/admin/orders/{}", order.order_id)));
4540                }
4541                Err(StorefrontStateError::FulfillmentNotAllowed { order_id, status }) => {
4542                    record_operator_audit(
4543                        state,
4544                        execution,
4545                        "commerce.order-fulfill",
4546                        "order",
4547                        order_id.as_str(),
4548                        "rejected",
4549                        &format!("Order cannot be fulfilled while it is `{status}`."),
4550                    )?;
4551                    push_storefront_flash(
4552                        state,
4553                        response_cookies,
4554                        FlashLevel::Error,
4555                        format!(
4556                            "Order {} cannot be marked fulfilled while it is {}.",
4557                            order_id, status
4558                        ),
4559                    )?;
4560                    return Ok(Some(redirect_location));
4561                }
4562                Err(error @ StorefrontStateError::UnknownOrder { .. }) => {
4563                    record_operator_audit(
4564                        state,
4565                        execution,
4566                        "commerce.order-fulfill",
4567                        "order",
4568                        order_id.trim(),
4569                        "rejected",
4570                        &error.to_string(),
4571                    )?;
4572                    if order_id.trim().is_empty() {
4573                        push_storefront_flash(
4574                            state,
4575                            response_cookies,
4576                            FlashLevel::Error,
4577                            error.to_string(),
4578                        )?;
4579                    } else {
4580                        let form_state = StorefrontFormState::new(
4581                            "commerce.orders",
4582                            "Refresh the order queue and reopen the detail view before retrying this fulfillment.",
4583                        );
4584                        push_storefront_form_state(state, response_cookies, &form_state)?;
4585                    }
4586                    return Ok(Some(redirect_location));
4587                }
4588                Err(error) => return Err(RuntimeServerError::Storefront(error)),
4589            }
4590        }
4591        "commerce.account-session-end" => {
4592            revoke_storefront_session(state, session_id, now, response_cookies)?;
4593            push_storefront_flash(
4594                state,
4595                response_cookies,
4596                FlashLevel::Success,
4597                "Account session ended. Start again from this browser when you are ready.",
4598            )?;
4599            return Ok(Some("/account".to_string()));
4600        }
4601        _ => {}
4602    }
4603    Ok(None)
4604}
4605
4606async fn finalize_storefront_checkout_completion(
4607    state: &RuntimeServerState,
4608    execution: &RequestExecution,
4609    snapshot: &StorefrontStateSnapshot,
4610    now: BrowserInstant,
4611    response_cookies: &mut Vec<String>,
4612) -> Result<Option<String>, RuntimeServerError> {
4613    let Some(order) = snapshot.latest_order.as_ref() else {
4614        push_storefront_flash(
4615            state,
4616            response_cookies,
4617            FlashLevel::Error,
4618            "Checkout could not complete because the cart is empty.",
4619        )?;
4620        return Ok(None);
4621    };
4622
4623    let Some(provider) = configured_commerce_payment_provider(&state.plan.config) else {
4624        push_storefront_flash(
4625            state,
4626            response_cookies,
4627            FlashLevel::Success,
4628            format!(
4629                "Order {} was received. Payment is still awaiting provider confirmation.",
4630                order.order_id
4631            ),
4632        )?;
4633        return Ok(None);
4634    };
4635
4636    if provider.code == "stripe" && provider.uses_hosted_checkout() {
4637        match launch_stripe_checkout_handoff(state, execution, order).await {
4638            Ok(handoff_url) => return Ok(Some(handoff_url)),
4639            Err(_) => {
4640                return restore_checkout_after_provider_handoff_failure(
4641                    state,
4642                    order,
4643                    now,
4644                    response_cookies,
4645                    "Stripe checkout could not start. Your basket has been restored so you can review it and try again.",
4646                )
4647                .map(Some);
4648            }
4649        }
4650    }
4651
4652    push_storefront_flash(
4653        state,
4654        response_cookies,
4655        FlashLevel::Success,
4656        provider.pending_confirmation_summary(&order.order_id),
4657    )?;
4658    Ok(None)
4659}
4660
4661async fn launch_stripe_checkout_handoff(
4662    state: &RuntimeServerState,
4663    execution: &RequestExecution,
4664    order: &StorefrontOrderSnapshot,
4665) -> Result<String, String> {
4666    let payment_reference = order
4667        .payment
4668        .reference
4669        .as_deref()
4670        .filter(|value| !value.trim().is_empty())
4671        .ok_or_else(|| format!("order {} is missing a payment reference", order.order_id))?;
4672    if state.uses_development_hosted_checkout_stub() {
4673        return Ok(provider_checkout_return_url(
4674            execution,
4675            payment_reference,
4676            "return",
4677        ));
4678    }
4679    let api_key = state
4680        .payment_provider_api_key
4681        .as_deref()
4682        .ok_or_else(|| "stripe hosted checkout api key is not configured".to_string())?
4683        .to_string();
4684    let request_body = stripe_checkout_session_request_body(execution, order)?;
4685    let idempotency_key = format!("coil-order-{}", order.order_id);
4686    let checkout_client = Arc::clone(&state.hosted_checkout_client);
4687    let response = tokio::task::spawn_blocking(move || {
4688        checkout_client.create_stripe_checkout_session(&api_key, &request_body, &idempotency_key)
4689    })
4690    .await
4691    .map_err(|error| format!("failed to join Stripe Checkout handoff task: {error}"))??;
4692
4693    if response.id.trim().is_empty() || response.url.trim().is_empty() {
4694        return Err("Stripe Checkout response was missing the hosted session URL".to_string());
4695    }
4696    Ok(response.url)
4697}
4698
4699fn stripe_checkout_session_request_body(
4700    execution: &RequestExecution,
4701    order: &StorefrontOrderSnapshot,
4702) -> Result<String, String> {
4703    let payment_reference = order
4704        .payment
4705        .reference
4706        .as_deref()
4707        .filter(|value| !value.trim().is_empty())
4708        .ok_or_else(|| format!("order {} is missing a payment reference", order.order_id))?;
4709    let mut serializer = form_urlencoded::Serializer::new(String::new());
4710    serializer.append_pair("mode", "payment");
4711    serializer.append_pair("success_url", &stripe_checkout_success_url(execution));
4712    serializer.append_pair(
4713        "cancel_url",
4714        &provider_checkout_cancel_url(execution, payment_reference),
4715    );
4716    serializer.append_pair("client_reference_id", payment_reference);
4717    if let Some(email) = order.payment.checkout_email.as_deref() {
4718        let trimmed = email.trim();
4719        if !trimmed.is_empty() {
4720            serializer.append_pair("customer_email", trimmed);
4721        }
4722    }
4723    serializer.append_pair("payment_intent_data[metadata][order_id]", &order.order_id);
4724    serializer.append_pair(
4725        "payment_intent_data[metadata][payment_reference]",
4726        payment_reference,
4727    );
4728    serializer.append_pair("metadata[order_id]", &order.order_id);
4729    serializer.append_pair("metadata[payment_reference]", payment_reference);
4730
4731    for (index, line) in order.lines.iter().enumerate() {
4732        if line.quantity == 0 {
4733            return Err(format!(
4734                "order {} contains a zero-quantity line for {}",
4735                order.order_id, line.sku
4736            ));
4737        }
4738        if line.unit_price_minor <= 0 {
4739            return Err(format!(
4740                "order {} contains a non-positive unit amount for {}",
4741                order.order_id, line.sku
4742            ));
4743        }
4744
4745        let prefix = format!("line_items[{index}]");
4746        serializer.append_pair(
4747            &format!("{prefix}[price_data][currency]"),
4748            &line.currency.to_ascii_lowercase(),
4749        );
4750        serializer.append_pair(
4751            &format!("{prefix}[price_data][unit_amount]"),
4752            &line.unit_price_minor.to_string(),
4753        );
4754        serializer.append_pair(
4755            &format!("{prefix}[price_data][product_data][name]"),
4756            stripe_line_item_name(line).as_str(),
4757        );
4758        serializer.append_pair(&format!("{prefix}[quantity]"), &line.quantity.to_string());
4759    }
4760
4761    Ok(serializer.finish())
4762}
4763
4764fn stripe_line_item_name(line: &StorefrontOrderLine) -> String {
4765    let variant = line.variant_title.trim();
4766    if variant.is_empty() || variant.eq_ignore_ascii_case("standard") {
4767        return line.title.clone();
4768    }
4769    format!("{} ({variant})", line.title)
4770}
4771
4772fn checkout_confirmation_base_url(execution: &RequestExecution) -> String {
4773    format!(
4774        "{}://{}/checkout/confirmation",
4775        execution.trace.transport_scheme, execution.host
4776    )
4777}
4778
4779fn stripe_checkout_success_url(execution: &RequestExecution) -> String {
4780    let mut serializer = form_urlencoded::Serializer::new(String::new());
4781    serializer.append_pair("checkout_session_id", "{CHECKOUT_SESSION_ID}");
4782    format!(
4783        "{}?{}",
4784        checkout_confirmation_base_url(execution),
4785        serializer.finish()
4786    )
4787}
4788
4789fn provider_checkout_return_url(
4790    execution: &RequestExecution,
4791    payment_reference: &str,
4792    provider_result: &str,
4793) -> String {
4794    let mut serializer = form_urlencoded::Serializer::new(String::new());
4795    serializer.append_pair("provider_result", provider_result);
4796    serializer.append_pair("payment_reference", payment_reference);
4797    format!(
4798        "{}?{}",
4799        checkout_confirmation_base_url(execution),
4800        serializer.finish()
4801    )
4802}
4803
4804fn provider_checkout_cancel_url(execution: &RequestExecution, payment_reference: &str) -> String {
4805    provider_checkout_return_url(execution, payment_reference, "cancel")
4806}
4807
4808fn reconcile_hosted_checkout_confirmation(
4809    state: &RuntimeServerState,
4810    checkout_session_id: &str,
4811    now: BrowserInstant,
4812) -> Result<(), RuntimeServerError> {
4813    if state.uses_development_hosted_checkout_stub() {
4814        return Ok(());
4815    }
4816    let Some(provider) = configured_commerce_payment_provider(&state.plan.config) else {
4817        return Ok(());
4818    };
4819    if provider.code != "stripe" || !provider.uses_hosted_checkout() {
4820        return Ok(());
4821    }
4822    let Some(api_key) = state.payment_provider_api_key.as_deref() else {
4823        return Ok(());
4824    };
4825    let session = match state
4826        .hosted_checkout_client
4827        .fetch_stripe_checkout_session(api_key, checkout_session_id)
4828    {
4829        Ok(session) => session,
4830        Err(_) => return Ok(()),
4831    };
4832    let Some(payment_reference) = session.payment_reference.as_deref() else {
4833        return Ok(());
4834    };
4835    let event = match (session.status.as_deref(), session.payment_status.as_deref()) {
4836        (Some("complete"), Some("paid" | "no_payment_required")) => Some("payment.captured"),
4837        (Some("expired"), _) => Some("payment.failed"),
4838        _ => None,
4839    };
4840    let Some(event) = event else {
4841        return Ok(());
4842    };
4843    let receipt =
4844        state
4845            .storefront
4846            .apply_payment_webhook(payment_reference, event, now.as_unix_seconds())?;
4847    if receipt.needs_paid_event_dispatch {
4848        dispatch_paid_order_event(state, &receipt.order, now)?;
4849        state
4850            .storefront
4851            .mark_order_paid_event_dispatched(&receipt.order.order_id, now.as_unix_seconds())?;
4852    }
4853    Ok(())
4854}
4855
4856fn restore_checkout_after_provider_handoff_failure(
4857    state: &RuntimeServerState,
4858    order: &StorefrontOrderSnapshot,
4859    now: BrowserInstant,
4860    response_cookies: &mut Vec<String>,
4861    message: &str,
4862) -> Result<String, RuntimeServerError> {
4863    if let Some(payment_reference) = order.payment.reference.as_deref() {
4864        let _ = state.storefront.apply_payment_webhook(
4865            payment_reference,
4866            "payment.failed",
4867            now.as_unix_seconds(),
4868        )?;
4869    }
4870    push_storefront_flash(state, response_cookies, FlashLevel::Error, message)?;
4871    Ok("/cart".to_string())
4872}
4873
4874fn redirect_failed_checkout_confirmation(
4875    state: &RuntimeServerState,
4876    route_name: &str,
4877    method: HttpMethod,
4878    session_id: Option<&str>,
4879    principal_id: Option<&str>,
4880    provider_result: Option<&str>,
4881    payment_reference: Option<&str>,
4882    checkout_session_id: Option<&str>,
4883    now: BrowserInstant,
4884    response_cookies: &mut Vec<String>,
4885) -> Result<Option<String>, RuntimeServerError> {
4886    if route_name != "commerce.checkout-confirmation" || method != HttpMethod::Get {
4887        return Ok(None);
4888    }
4889    if let Some(checkout_session_id) = checkout_session_id {
4890        reconcile_hosted_checkout_confirmation(state, checkout_session_id, now)?;
4891    }
4892    if provider_result == Some("return")
4893        && state.uses_development_hosted_checkout_stub()
4894        && let Some(payment_reference) = payment_reference
4895    {
4896        let receipt = state.storefront.apply_payment_webhook(
4897            payment_reference,
4898            "payment.succeeded",
4899            now.as_unix_seconds(),
4900        )?;
4901        dispatch_paid_order_event(state, &receipt.order, now)?;
4902        push_storefront_flash(
4903            state,
4904            response_cookies,
4905            FlashLevel::Success,
4906            format!(
4907                "Local checkout completed for order {} using the built-in development payment stub.",
4908                receipt.order.order_id
4909            ),
4910        )?;
4911        return Ok(None);
4912    }
4913    if provider_result == Some("cancel") {
4914        if let Some(payment_reference) = payment_reference {
4915            match state.storefront.apply_payment_webhook(
4916                payment_reference,
4917                "payment.failed",
4918                now.as_unix_seconds(),
4919            ) {
4920                Ok(receipt) => {
4921                    if receipt.order.payment.status == "failed" {
4922                        push_storefront_flash(
4923                            state,
4924                            response_cookies,
4925                            FlashLevel::Error,
4926                            "Stripe checkout was cancelled. Your basket has been restored so you can review it and start checkout again.",
4927                        )?;
4928                        return Ok(Some("/cart".to_string()));
4929                    }
4930                }
4931                Err(StorefrontStateError::UnknownPaymentReference { .. }) => {}
4932                Err(error) => return Err(RuntimeServerError::Storefront(error)),
4933            }
4934        }
4935    }
4936    let Some(session_id) = session_id else {
4937        return Ok(None);
4938    };
4939    let snapshot = state.storefront.snapshot(session_id, principal_id)?;
4940    let Some(order) = snapshot.latest_order.as_ref() else {
4941        return Ok(None);
4942    };
4943    if order.payment.status != "failed" {
4944        return Ok(None);
4945    }
4946    push_storefront_flash(
4947        state,
4948        response_cookies,
4949        FlashLevel::Error,
4950        format!(
4951            "Payment for order {} failed. Your basket has been restored so you can review it and start checkout again.",
4952            order.order_id
4953        ),
4954    )?;
4955    Ok(Some("/cart".to_string()))
4956}
4957
4958fn dispatch_paid_order_event(
4959    state: &RuntimeServerState,
4960    order: &StorefrontOrderSnapshot,
4961    now: BrowserInstant,
4962) -> Result<(), RuntimeServerError> {
4963    let mut jobs = state.plan.jobs_host("runtime-http")?;
4964    let payment_reference = order
4965        .payment
4966        .reference
4967        .as_deref()
4968        .unwrap_or(order.order_id.as_str());
4969    let _ = jobs.emit_domain_event(
4970        DomainEventDispatchRequest::new(
4971            "commerce.order.paid",
4972            "order",
4973            order.order_id.clone(),
4974            format!("payment provider confirmed {payment_reference}"),
4975        )?,
4976        JobInstant::from_unix_seconds(now.as_unix_seconds()),
4977    )?;
4978    Ok(())
4979}
4980
4981fn execution_form_field<'a>(execution: &'a RequestExecution, name: &str) -> Option<&'a str> {
4982    execution
4983        .form_fields
4984        .get(name)
4985        .and_then(|values| values.first().map(String::as_str))
4986}
4987
4988fn execution_query_field<'a>(execution: &'a RequestExecution, name: &str) -> Option<&'a str> {
4989    execution
4990        .query_params
4991        .get(name)
4992        .and_then(|values| values.first().map(String::as_str))
4993}
4994
4995fn run_customer_hook_future<T>(
4996    future: impl Future<Output = Result<T, RuntimeServerError>> + Send + 'static,
4997) -> Result<T, RuntimeServerError>
4998where
4999    T: Send + 'static,
5000{
5001    match tokio::runtime::Handle::try_current() {
5002        Ok(handle)
5003            if matches!(
5004                handle.runtime_flavor(),
5005                tokio::runtime::RuntimeFlavor::MultiThread
5006            ) =>
5007        {
5008            tokio::task::block_in_place(|| handle.block_on(future))
5009        }
5010        Ok(_) => std::thread::spawn(move || {
5011            tokio::runtime::Builder::new_current_thread()
5012                .enable_all()
5013                .build()
5014                .map_err(|error| RuntimeServerError::CustomerHookFailed {
5015                    surface: "auth",
5016                    reason: format!("failed to build runtime bridge for customer hooks: {error}"),
5017                })?
5018                .block_on(future)
5019        })
5020        .join()
5021        .map_err(|_| RuntimeServerError::CustomerHookFailed {
5022            surface: "auth",
5023            reason: "customer hook runtime bridge thread panicked".to_string(),
5024        })?,
5025        Err(_) => 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}
5035
5036fn customer_hook_auth_backend_error(error: RuntimeServerError) -> BackendError {
5037    BackendError::new(
5038        BackendErrorKind::Unavailable,
5039        "auth.live_check.failed",
5040        "Runtime could not complete the linked customer auth check.",
5041    )
5042    .with_detail(error.to_string())
5043}
5044
5045fn customer_hook_asset_internal_error(message: &'static str) -> BackendError {
5046    BackendError::new(
5047        BackendErrorKind::Unavailable,
5048        "storage.asset.state_unavailable",
5049        message,
5050    )
5051}
5052
5053fn customer_hook_asset_internal_error_with_detail(
5054    message: &'static str,
5055    detail: impl Into<String>,
5056) -> BackendError {
5057    customer_hook_asset_internal_error(message).with_detail(detail.into())
5058}
5059
5060fn customer_hook_asset_model_error(error: coil_assets::AssetModelError) -> BackendError {
5061    BackendError::new(
5062        BackendErrorKind::InvalidInput,
5063        "storage.asset.invalid",
5064        "Customer webhook requested an invalid managed asset write.",
5065    )
5066    .with_detail(error.to_string())
5067}
5068
5069fn customer_hook_storage_backend_error(error: crate::storage::RuntimeStorageError) -> BackendError {
5070    BackendError::new(
5071        BackendErrorKind::Unavailable,
5072        "storage.asset.failed",
5073        "Runtime could not complete the linked customer asset operation.",
5074    )
5075    .with_detail(error.to_string())
5076}
5077
5078fn customer_hook_asset_id(logical_path: &str) -> Result<AssetId, BackendError> {
5079    AssetId::new(format!("customer-hook:{logical_path}")).map_err(customer_hook_asset_model_error)
5080}
5081
5082fn customer_hook_asset_revision_id(
5083    logical_path: &str,
5084    bytes: &[u8],
5085) -> Result<RevisionId, BackendError> {
5086    let mut hasher = Sha256::new();
5087    hasher.update(logical_path.as_bytes());
5088    hasher.update([0]);
5089    hasher.update(bytes);
5090    let digest = hasher.finalize();
5091    RevisionId::new(format!(
5092        "customer-hook:{}",
5093        digest
5094            .iter()
5095            .map(|byte| format!("{byte:02x}"))
5096            .collect::<String>()
5097    ))
5098    .map_err(customer_hook_asset_model_error)
5099}
5100
5101fn customer_hook_asset_fingerprint(bytes: &[u8]) -> Result<ContentFingerprint, BackendError> {
5102    let digest = Sha256::digest(bytes);
5103    ContentFingerprint::new(
5104        FingerprintAlgorithm::Sha256,
5105        digest
5106            .iter()
5107            .map(|byte| format!("{byte:02x}"))
5108            .collect::<String>(),
5109    )
5110    .map_err(customer_hook_asset_model_error)
5111}
5112
5113fn customer_hook_storage_plan(
5114    storage: &StorageHost,
5115    logical_path: &str,
5116    storage_class: StorageClass,
5117) -> Result<coil_storage::StoragePlan, crate::storage::RuntimeStorageError> {
5118    let request = StoragePlanRequest::new(logical_path).with_storage_class(storage_class);
5119    match storage_class {
5120        StorageClass::LocalOnlySensitive => storage.plan_single_node_escape_hatch_write(request),
5121        StorageClass::PublicAsset | StorageClass::PublicUpload | StorageClass::PrivateShared => {
5122            storage.plan_write(request)
5123        }
5124    }
5125}
5126
5127fn plan_customer_hook_asset_revision(
5128    storage: &StorageHost,
5129    logical_path: &str,
5130    storage_class: StorageClass,
5131    content_type: &str,
5132    bytes: &[u8],
5133) -> Result<ManagedAssetRevision, BackendError> {
5134    let revision_id = customer_hook_asset_revision_id(logical_path, bytes)?;
5135    let fingerprint = customer_hook_asset_fingerprint(bytes)?;
5136    let plan = customer_hook_storage_plan(storage, logical_path, storage_class)
5137        .map_err(customer_hook_storage_backend_error)?;
5138    ManagedAssetRevision::new(
5139        revision_id,
5140        plan,
5141        content_type,
5142        bytes.len() as u64,
5143        fingerprint,
5144    )
5145    .map_err(customer_hook_asset_model_error)
5146}
5147
5148fn sdk_managed_asset_from_runtime_asset(
5149    storage: &StorageHost,
5150    asset: &coil_assets::ManagedAsset,
5151) -> Result<ManagedAsset, BackendError> {
5152    let public_url = if asset.publication().is_published() {
5153        match storage
5154            .plan_public_asset_delivery(asset)
5155            .map_err(customer_hook_storage_backend_error)?
5156            .target()
5157        {
5158            coil_assets::AssetDeliveryTarget::Cdn { public_url, .. } => Some(public_url.clone()),
5159            _ => None,
5160        }
5161    } else {
5162        None
5163    };
5164
5165    Ok(ManagedAsset {
5166        logical_path: asset.current_revision().storage_plan().logical_path.clone(),
5167        storage_class: customer_storage_class_name(
5168            asset.current_revision().storage_plan().storage_class,
5169        )
5170        .to_string(),
5171        public_url,
5172    })
5173}
5174
5175fn persisted_customer_managed_asset_record(
5176    asset: &coil_assets::ManagedAsset,
5177) -> Result<PersistedCustomerManagedAssetRecord, BackendError> {
5178    Ok(PersistedCustomerManagedAssetRecord {
5179        logical_path: asset.current_revision().storage_plan().logical_path.clone(),
5180        storage_class: customer_storage_class_name(
5181            asset.current_revision().storage_plan().storage_class,
5182        )
5183        .to_string(),
5184        revision_id: asset.current_revision().id().as_str().to_string(),
5185        content_type: asset.current_revision().content_type().to_string(),
5186        byte_length: asset.current_revision().byte_length(),
5187        fingerprint_algorithm: asset
5188            .current_revision()
5189            .fingerprint()
5190            .algorithm()
5191            .to_string(),
5192        fingerprint_digest: asset.current_revision().fingerprint().digest().to_string(),
5193        published_current: asset.publication().is_published(),
5194    })
5195}
5196
5197fn runtime_asset_from_persisted_customer_managed_asset(
5198    storage: &StorageHost,
5199    record: &PersistedCustomerManagedAssetRecord,
5200) -> Result<coil_assets::ManagedAsset, BackendError> {
5201    let storage_class = parse_customer_storage_class(record.storage_class.as_str())?;
5202    let revision = ManagedAssetRevision::new(
5203        RevisionId::new(record.revision_id.clone()).map_err(customer_hook_asset_model_error)?,
5204        customer_hook_storage_plan(storage, &record.logical_path, storage_class)
5205            .map_err(customer_hook_storage_backend_error)?,
5206        record.content_type.clone(),
5207        record.byte_length,
5208        ContentFingerprint::new(
5209            parse_customer_hook_fingerprint_algorithm(record.fingerprint_algorithm.as_str())?,
5210            record.fingerprint_digest.clone(),
5211        )
5212        .map_err(customer_hook_asset_model_error)?,
5213    )
5214    .map_err(customer_hook_asset_model_error)?;
5215    let mut asset = coil_assets::ManagedAsset::new(
5216        customer_hook_asset_id(&record.logical_path)?,
5217        record.logical_path.clone(),
5218        revision,
5219    )
5220    .map_err(customer_hook_asset_model_error)?;
5221    if record.published_current {
5222        asset.publish_current();
5223    }
5224    Ok(asset)
5225}
5226
5227fn parse_customer_hook_fingerprint_algorithm(
5228    value: &str,
5229) -> Result<FingerprintAlgorithm, BackendError> {
5230    match value {
5231        "sha256" => Ok(FingerprintAlgorithm::Sha256),
5232        "sha384" => Ok(FingerprintAlgorithm::Sha384),
5233        "sha512" => Ok(FingerprintAlgorithm::Sha512),
5234        other => Err(BackendError::new(
5235            BackendErrorKind::InvalidInput,
5236            "storage.asset.invalid_fingerprint_algorithm",
5237            "Persisted customer managed asset record declares an unsupported fingerprint algorithm.",
5238        )
5239        .with_detail(format!("unsupported fingerprint algorithm `{other}`"))),
5240    }
5241}
5242
5243fn parse_customer_storage_class(value: &str) -> Result<StorageClass, BackendError> {
5244    match value {
5245        "public_asset" => Ok(StorageClass::PublicAsset),
5246        "public_upload" => Ok(StorageClass::PublicUpload),
5247        "private_shared" => Ok(StorageClass::PrivateShared),
5248        "local_only_sensitive" => Ok(StorageClass::LocalOnlySensitive),
5249        other => Err(BackendError::new(
5250            BackendErrorKind::InvalidInput,
5251            "storage.class.invalid",
5252            format!("Unknown storage class `{other}`."),
5253        )),
5254    }
5255}
5256
5257fn customer_storage_class_name(storage_class: StorageClass) -> &'static str {
5258    match storage_class {
5259        StorageClass::PublicAsset => "public_asset",
5260        StorageClass::PublicUpload => "public_upload",
5261        StorageClass::PrivateShared => "private_shared",
5262        StorageClass::LocalOnlySensitive => "local_only_sensitive",
5263    }
5264}
5265
5266fn parse_customer_capability(value: &str) -> Result<coil_auth::Capability, BackendError> {
5267    coil_auth::Capability::from_str(value).ok_or_else(|| {
5268        BackendError::new(
5269            BackendErrorKind::InvalidInput,
5270            "auth.capability.invalid",
5271            format!("Unknown capability `{value}`."),
5272        )
5273    })
5274}
5275
5276fn parse_customer_auth_entity(value: &str) -> Result<coil_auth::Entity, BackendError> {
5277    let Some((namespace, id)) = value.split_once(':') else {
5278        return Err(BackendError::new(
5279            BackendErrorKind::InvalidInput,
5280            "auth.object.invalid",
5281            format!("Invalid auth object `{value}`."),
5282        ));
5283    };
5284    if id.trim().is_empty() {
5285        return Err(BackendError::new(
5286            BackendErrorKind::InvalidInput,
5287            "auth.object.invalid",
5288            format!("Invalid auth object `{value}`."),
5289        ));
5290    }
5291    match namespace {
5292        "tenant" => Ok(coil_auth::Entity::tenant(id)),
5293        "site" => Ok(coil_auth::Entity::site(id)),
5294        "brand" => Ok(coil_auth::Entity::brand(id)),
5295        "storefront" => Ok(coil_auth::Entity::storefront(id)),
5296        "user" => Ok(coil_auth::Entity::user(id)),
5297        "group" => Ok(coil_auth::Entity::group(id)),
5298        "team" => Ok(coil_auth::Entity::team(id)),
5299        "service_account" => Ok(coil_auth::Entity::service_account(id)),
5300        "page" => Ok(coil_auth::Entity::page(id)),
5301        "navigation" => Ok(coil_auth::Entity::navigation(id)),
5302        "product" => Ok(coil_auth::Entity::product(id)),
5303        "collection" => Ok(coil_auth::Entity::collection(id)),
5304        "order" => Ok(coil_auth::Entity::order(id)),
5305        "subscription" => Ok(coil_auth::Entity::subscription(id)),
5306        "membership_tier" => Ok(coil_auth::Entity::membership_tier(id)),
5307        "event" => Ok(coil_auth::Entity::event(id)),
5308        "event_slot" => Ok(coil_auth::Entity::event_slot(id)),
5309        "booking" => Ok(coil_auth::Entity::booking(id)),
5310        "media" => Ok(coil_auth::Entity::media(id)),
5311        "media_library" => Ok(coil_auth::Entity::media_library(id)),
5312        "asset" => Ok(coil_auth::Entity::asset(id)),
5313        "asset_folder" => Ok(coil_auth::Entity::asset_folder(id)),
5314        "theme_asset_bundle" => Ok(coil_auth::Entity::theme_asset_bundle(id)),
5315        "admin_module" => Ok(coil_auth::Entity::admin_module(id)),
5316        _ => Err(BackendError::new(
5317            BackendErrorKind::InvalidInput,
5318            "auth.object.invalid",
5319            format!("Unknown auth object namespace `{namespace}`."),
5320        )),
5321    }
5322}
5323
5324fn customer_hook_auth_subject(
5325    principal: &PrincipalContext,
5326) -> Option<coil_auth::DefaultSubject> {
5327    match (principal.principal_id.as_deref(), principal.principal_kind) {
5328        (Some(principal_id), RequestPrincipalKind::ServiceAccount) => {
5329            Some(coil_auth::DefaultSubject::entity(
5330                coil_auth::Entity::service_account(principal_id.to_string()),
5331            ))
5332        }
5333        (Some(principal_id), _) => Some(coil_auth::DefaultSubject::entity(
5334            coil_auth::Entity::user(principal_id.to_string()),
5335        )),
5336        (None, _) => None,
5337    }
5338}
5339
5340fn decode_hex_signature(signature: &str) -> Option<Vec<u8>> {
5341    if !signature.len().is_multiple_of(2) {
5342        return None;
5343    }
5344    let mut bytes = Vec::with_capacity(signature.len() / 2);
5345    let mut chars = signature.as_bytes().chunks_exact(2);
5346    for chunk in &mut chars {
5347        let high = decode_hex_nibble(chunk[0])?;
5348        let low = decode_hex_nibble(chunk[1])?;
5349        bytes.push((high << 4) | low);
5350    }
5351    Some(bytes)
5352}
5353
5354fn decode_hex_nibble(byte: u8) -> Option<u8> {
5355    match byte {
5356        b'0'..=b'9' => Some(byte - b'0'),
5357        b'a'..=b'f' => Some(byte - b'a' + 10),
5358        b'A'..=b'F' => Some(byte - b'A' + 10),
5359        _ => None,
5360    }
5361}
5362
5363fn storefront_sku_from_execution(
5364    execution: &RequestExecution,
5365) -> Result<Cow<'_, str>, RuntimeServerError> {
5366    execution_form_field(execution, "sku")
5367        .or_else(|| execution_form_field(execution, "product_slug"))
5368        .or_else(|| execution_form_field(execution, "line_id"))
5369        .map(Cow::Borrowed)
5370        .ok_or_else(|| {
5371            RuntimeServerError::Storefront(StorefrontStateError::UnknownSku {
5372                sku: "<missing>".to_string(),
5373            })
5374        })
5375}
5376
5377fn cart_quantities_from_execution(execution: &RequestExecution) -> BTreeMap<String, u32> {
5378    let mut quantities = BTreeMap::new();
5379    if let Ok(sku) = storefront_sku_from_execution(execution) {
5380        quantities.insert(
5381            sku.into_owned(),
5382            parse_quantity_field(execution_form_field(execution, "quantity")).unwrap_or(1),
5383        );
5384    }
5385    for (name, values) in &execution.form_fields {
5386        let Some(product_slug) = name.strip_prefix("quantity_") else {
5387            continue;
5388        };
5389        let Some(quantity) = values
5390            .first()
5391            .and_then(|value| parse_quantity_field(Some(value.as_str())))
5392        else {
5393            continue;
5394        };
5395        quantities.insert(product_slug.to_string(), quantity);
5396    }
5397    quantities
5398}
5399
5400fn storefront_response_augmentation(
5401    state: &RuntimeServerState,
5402    execution: &RequestExecution,
5403) -> Result<Option<StorefrontResponseAugmentation>, RuntimeServerError> {
5404    let should_render_storefront = should_render_storefront_state(execution);
5405    let should_render_cms_admin_forms = should_render_cms_admin_forms(execution);
5406    if !should_render_storefront && !should_render_cms_admin_forms {
5407        return Ok(None);
5408    }
5409    let Some(session_id) = execution.session.session_id.as_deref() else {
5410        return Ok(None);
5411    };
5412    let mut augmentation = if should_render_storefront {
5413        let snapshot = state
5414            .storefront
5415            .snapshot(session_id, execution.principal.principal_id.as_deref())?;
5416        let tokens = issue_storefront_csrf_tokens(state, session_id)?;
5417        state.storefront.build_response_augmentation(
5418            execution.route.route_name.as_str(),
5419            &snapshot,
5420            tokens,
5421        )?
5422    } else {
5423        StorefrontResponseAugmentation {
5424            html_fragment: None,
5425            headers: BTreeMap::new(),
5426        }
5427    };
5428    if should_render_cms_admin_forms {
5429        augmentation
5430            .headers
5431            .extend(issue_cms_admin_csrf_tokens(state, session_id)?);
5432    }
5433    Ok(Some(augmentation))
5434}
5435
5436fn should_render_storefront_state(execution: &RequestExecution) -> bool {
5437    matches!(execution.response, HandlerResponse::Page(_))
5438        && (execution.route.route_name.starts_with("commerce.")
5439            || execution.route_area == RouteArea::Account)
5440}
5441
5442fn should_render_cms_admin_forms(execution: &RequestExecution) -> bool {
5443    matches!(execution.response, HandlerResponse::Page(_))
5444        && matches!(
5445            execution.route.route_name.as_str(),
5446            "cms.pages.index" | "cms.navigation.index" | "cms.redirects.index"
5447        )
5448}
5449
5450fn issue_storefront_csrf_tokens(
5451    state: &RuntimeServerState,
5452    session_id: &str,
5453) -> Result<BTreeMap<String, String>, RuntimeServerError> {
5454    let browser = state
5455        .browser
5456        .lock()
5457        .expect("runtime browser mutex poisoned");
5458    let mut tokens = BTreeMap::new();
5459    for action in STOREFRONT_CSRF_ACTIONS {
5460        let token = browser
5461            .issue_csrf_token(&state.csrf_secret, session_id, action)
5462            .map_err(RequestExecutionError::from_browser_error)?;
5463        tokens.insert((*action).to_string(), token);
5464    }
5465    Ok(tokens)
5466}
5467
5468fn issue_cms_admin_csrf_tokens(
5469    state: &RuntimeServerState,
5470    session_id: &str,
5471) -> Result<BTreeMap<String, String>, RuntimeServerError> {
5472    let browser = state
5473        .browser
5474        .lock()
5475        .expect("runtime browser mutex poisoned");
5476    let mut tokens = BTreeMap::new();
5477    for (action, header) in CMS_ADMIN_CSRF_ACTIONS {
5478        let token = browser
5479            .issue_csrf_token(&state.csrf_secret, session_id, action)
5480            .map_err(RequestExecutionError::from_browser_error)?;
5481        tokens.insert((*header).to_string(), token);
5482    }
5483    Ok(tokens)
5484}
5485
5486fn storefront_order_history_response(
5487    state: &RuntimeServerState,
5488    request: &RequestInput,
5489    response_cookies: Vec<String>,
5490) -> Result<Response<Body>, RuntimeServerError> {
5491    let Some(session_id) = request.session_id.as_deref() else {
5492        return Err(RuntimeServerError::Execution(
5493            RequestExecutionError::SessionRequired {
5494                route: "account.orders".to_string(),
5495            },
5496        ));
5497    };
5498    let history =
5499        state
5500            .storefront
5501            .order_history(session_id, request.principal_id.as_deref(), 50)?;
5502    let body = serde_json::to_string(&history).map_err(|error| {
5503        RuntimeServerError::Storefront(StorefrontStateError::Serialization {
5504            reason: error.to_string(),
5505        })
5506    })?;
5507    let mut response = Response::new(Body::from(body));
5508    *response.status_mut() = StatusCode::OK;
5509    response.headers_mut().insert(
5510        HeaderName::from_static("content-type"),
5511        HeaderValue::from_static("application/json"),
5512    );
5513    response.headers_mut().insert(
5514        HeaderName::from_static("x-coil-storefront-order-count"),
5515        HeaderValue::from_str(&history.orders.len().to_string())
5516            .expect("order count is a valid header value"),
5517    );
5518    if let Some(order) = history.orders.first() {
5519        response.headers_mut().insert(
5520            HeaderName::from_static("x-coil-storefront-latest-order"),
5521            HeaderValue::from_str(order.order_id.as_str())
5522                .expect("order id is a valid header value"),
5523        );
5524    }
5525    for cookie in response_cookies {
5526        if let Ok(value) = HeaderValue::from_str(&cookie) {
5527            response
5528                .headers_mut()
5529                .append(HeaderName::from_static("set-cookie"), value);
5530        }
5531    }
5532    Ok(response)
5533}
5534
5535async fn apply_storefront_response_augmentation(
5536    mut response: Response<Body>,
5537    augmentation: Option<StorefrontResponseAugmentation>,
5538) -> Result<Response<Body>, RuntimeServerError> {
5539    let Some(augmentation) = augmentation else {
5540        return Ok(response);
5541    };
5542    let form_tokens = storefront_form_tokens_from_headers(&augmentation.headers);
5543    for (name, value) in augmentation.headers {
5544        if let (Ok(name), Ok(value)) = (
5545            HeaderName::from_bytes(name.as_bytes()),
5546            HeaderValue::from_str(&value),
5547        ) {
5548            response.headers_mut().insert(name, value);
5549        }
5550    }
5551    let Some(markup) = augmentation.html_fragment else {
5552        return Ok(response);
5553    };
5554    let is_html = response
5555        .headers()
5556        .get("content-type")
5557        .and_then(|value| value.to_str().ok())
5558        .is_some_and(|value| value.starts_with("text/html"));
5559    if !is_html {
5560        return Ok(response);
5561    }
5562    let (parts, body) = response.into_parts();
5563    let bytes = to_bytes(body, usize::MAX)
5564        .await
5565        .map_err(|_| RuntimeServerError::RequestBodyTooLarge { limit: usize::MAX })?;
5566    let html = String::from_utf8(bytes.to_vec()).map_err(|error| {
5567        RuntimeServerError::Storefront(StorefrontStateError::Serialization {
5568            reason: error.to_string(),
5569        })
5570    })?;
5571    let html = inject_storefront_form_csrf_inputs(html, form_tokens.as_slice());
5572    Ok(Response::from_parts(
5573        parts,
5574        Body::from(inject_storefront_markup(html, markup.as_str())),
5575    ))
5576}
5577
5578fn inject_storefront_markup(document_html: String, markup: &str) -> String {
5579    if markup.is_empty() {
5580        return document_html;
5581    }
5582    if let Some(index) = document_html.find("</body>") {
5583        let mut html = document_html;
5584        html.insert_str(index, markup);
5585        return html;
5586    }
5587    format!("{document_html}{markup}")
5588}
5589
5590fn storefront_form_tokens_from_headers(
5591    headers: &BTreeMap<String, String>,
5592) -> Vec<(&'static str, String)> {
5593    STOREFRONT_FORM_CSRF_HEADERS
5594        .iter()
5595        .chain(CMS_ADMIN_FORM_CSRF_HEADERS.iter())
5596        .filter_map(|(path, header)| headers.get(*header).map(|token| (*path, token.clone())))
5597        .collect()
5598}
5599
5600fn inject_storefront_form_csrf_inputs(
5601    mut document_html: String,
5602    form_tokens: &[(&'static str, String)],
5603) -> String {
5604    for (action_path, token) in form_tokens {
5605        document_html = inject_hidden_csrf_input(document_html, action_path, token.as_str());
5606    }
5607    document_html
5608}
5609
5610fn inject_hidden_csrf_input(mut document_html: String, action_path: &str, token: &str) -> String {
5611    let action_attr = format!("action=\"{action_path}\"");
5612    let hidden_input = format!(r#"<input type="hidden" name="_csrf" value="{token}" />"#);
5613    let mut search_from = 0;
5614
5615    while let Some(relative) = document_html[search_from..].find(&action_attr) {
5616        let action_index = search_from + relative;
5617        let Some(form_start) = document_html[..action_index].rfind("<form") else {
5618            search_from = action_index + action_attr.len();
5619            continue;
5620        };
5621        let Some(open_end_relative) = document_html[action_index..].find('>') else {
5622            break;
5623        };
5624        let open_end = action_index + open_end_relative;
5625        let Some(close_relative) = document_html[open_end..].find("</form>") else {
5626            break;
5627        };
5628        let close_index = open_end + close_relative;
5629        if document_html[open_end..close_index].contains("name=\"_csrf\"") {
5630            search_from = close_index + "</form>".len();
5631            continue;
5632        }
5633        document_html.insert_str(open_end + 1, hidden_input.as_str());
5634        search_from = open_end + 1 + hidden_input.len();
5635        if search_from < form_start {
5636            search_from = form_start;
5637        }
5638    }
5639
5640    document_html
5641}
5642
5643async fn enforce_request_body_limit(
5644    request: Request<Body>,
5645    max_body_bytes: Option<usize>,
5646) -> Result<Request<Body>, RuntimeServerError> {
5647    let Some(limit) = max_body_bytes else {
5648        return Ok(request);
5649    };
5650
5651    let (parts, body) = request.into_parts();
5652    if let Some(content_length) = parts
5653        .headers
5654        .get(CONTENT_LENGTH)
5655        .and_then(|value| value.to_str().ok())
5656        .and_then(|value| value.parse::<usize>().ok())
5657        && content_length > limit
5658    {
5659        return Err(RuntimeServerError::RequestBodyTooLarge { limit });
5660    }
5661
5662    let bytes = to_bytes(body, limit)
5663        .await
5664        .map_err(|_| RuntimeServerError::RequestBodyTooLarge { limit })?;
5665    Ok(Request::from_parts(parts, Body::from(bytes)))
5666}
5667
5668#[cfg(test)]
5669mod security_tests {
5670    use super::*;
5671    use std::collections::HashSet;
5672
5673    #[test]
5674    fn customer_hook_auth_subject_is_absent_for_anonymous_requests() {
5675        let principal = PrincipalContext {
5676            principal_id: None,
5677            principal_kind: RequestPrincipalKind::Anonymous,
5678            granted_capabilities: HashSet::new(),
5679        };
5680
5681        assert!(customer_hook_auth_subject(&principal).is_none());
5682    }
5683
5684    #[test]
5685    fn customer_hook_auth_subject_preserves_service_accounts() {
5686        let principal = PrincipalContext {
5687            principal_id: Some("runtime.webhooks".to_string()),
5688            principal_kind: RequestPrincipalKind::ServiceAccount,
5689            granted_capabilities: HashSet::new(),
5690        };
5691
5692        assert_eq!(
5693            customer_hook_auth_subject(&principal),
5694            Some(coil_auth::DefaultSubject::entity(
5695                coil_auth::Entity::service_account("runtime.webhooks"),
5696            ))
5697        );
5698    }
5699}