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