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