1pub(super) mod logical;
4mod media_input;
5use std::collections::BTreeMap;
6#[cfg(not(target_os = "wasi"))]
7use std::collections::BTreeSet;
8use std::fs;
9use std::path::{Path, PathBuf};
10#[cfg(not(target_os = "wasi"))]
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use serde_json::{Map, Value};
14
15use crate::config::AppRuntime;
16
17#[cfg(not(target_os = "wasi"))]
18const MAX_QUERY_FILTERS: usize = 32;
19const MAX_QUERY_ROWS: usize = 100;
20
21#[derive(Debug, Clone, Eq, PartialEq)]
22pub enum FeatureRuntimeError {
44 InvalidContract(String),
45 InvalidInput(String),
46 ValidationReport(String),
47 MissingRecord(String),
48 Conflict(String),
49 DatabaseUnavailable,
50 Database(pharos_runtime_database::operations::DatabaseError),
52 RenderFailed(String),
53}
54
55#[derive(Debug, Clone, Default)]
56pub struct FeatureRequestContext {
80 pub request_target: String,
81 pub actor_id: i64,
82 pub input: Map<String, Value>,
83 pub path_params: Map<String, Value>,
84 pub query_params: Map<String, Value>,
85 pub bindings: Map<String, Value>,
86 pub row: Map<String, Value>,
87 pub generated: Map<String, Value>,
88 pub protected: Map<String, Value>,
89}
90
91#[derive(Debug, Clone, Eq, PartialEq)]
92pub struct FeatureActionOutcome {
110 pub status: u16,
111 pub fragment_html: String,
112 pub redirect: String,
113 pub redirect_only: bool,
114}
115
116fn action_redirect_path(
118 template: &str,
119 path_params: &Map<String, Value>,
120) -> Result<String, FeatureRuntimeError> {
121 if !template.starts_with('/')
122 || template.len() > 512
123 || template.contains(['?', '#', '\r', '\n', '\0'])
124 {
125 return Err(FeatureRuntimeError::InvalidContract(
126 "action redirect".to_owned(),
127 ));
128 }
129 let mut result = String::with_capacity(template.len());
130 for (index, segment) in template.split('/').enumerate() {
131 if index > 0 {
132 result.push('/');
133 }
134 let Some(parameter) = segment.strip_prefix(':') else {
135 if segment == ".." || segment.contains(['%', '\\']) {
136 return Err(FeatureRuntimeError::InvalidContract(
137 "action redirect".to_owned(),
138 ));
139 }
140 result.push_str(segment);
141 continue;
142 };
143 let value = path_params
144 .get(parameter)
145 .ok_or_else(|| FeatureRuntimeError::InvalidContract(parameter.to_owned()))?;
146 let value = match value {
147 Value::String(value) => value.clone(),
148 Value::Number(value) => value.to_string(),
149 _ => return Err(FeatureRuntimeError::InvalidInput(parameter.to_owned())),
150 };
151 if value.is_empty() || value.len() > 256 || matches!(value.as_str(), "." | "..") {
152 return Err(FeatureRuntimeError::InvalidInput(parameter.to_owned()));
153 }
154 for byte in value.bytes() {
155 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
156 result.push(char::from(byte));
157 } else {
158 result.push_str(&format!("%{byte:02X}"));
159 }
160 }
161 }
162 Ok(result)
163}
164
165#[derive(Debug, Clone, Eq, PartialEq)]
166pub struct AuthorizedPrivateAsset {
183 pub private_root: String,
184 pub asset_key: String,
185}
186
187fn payment_provider_render_state_root(
188 app: &AppRuntime,
189 request_path: &str,
190) -> Result<Option<Value>, FeatureRuntimeError> {
191 let Some(app_root) = app.app_root.as_deref().map(Path::new) else {
192 return Ok(None);
193 };
194 let Some(management) = pharos_runtime_payment::payment_provider_manifest_config(app_root)
195 else {
196 return Ok(None);
197 };
198 let Some(provider_id) = pharos_runtime_payment::payment_provider_primary_provider(app_root)
199 else {
200 return Ok(None);
201 };
202 if !super::app_uses_file_backed_runtime_state(app) {
203 let store =
204 super::database::runtime_logical_app_store(app).map_err(logical::map_database_error)?;
205 let route_value =
206 pharos_runtime_payment::payment_provider_owner_route_value(app_root, request_path);
207 return pharos_runtime_database::payment::payment_render_state(
208 store.adapter.as_ref(),
209 &store.config,
210 &store.schema,
211 &management,
212 route_value.as_deref(),
213 &provider_id,
214 )
215 .map(Some)
216 .map_err(logical::map_database_error);
217 }
218 Ok(app
219 .state_path
220 .as_deref()
221 .and_then(|path| super::json_commands::json_doc_load_file(Path::new(path))))
222}
223
224pub fn actor_roles(
245 app: &AppRuntime,
246 actor_id: i64,
247) -> Result<Vec<String>, pharos_runtime_database::operations::DatabaseError> {
248 #[cfg(not(target_os = "wasi"))]
249 {
250 use pharos_runtime_database::operations::{Predicate, Query};
251
252 if actor_id <= 0 {
253 return Ok(Vec::new());
254 }
255 let store = super::database::runtime_logical_app_store(app)?;
256 let read_roles =
257 || -> Result<Vec<String>, pharos_runtime_database::operations::DatabaseError> {
258 let account = store
259 .schema
260 .entities
261 .iter()
262 .find(|entity| entity.name == "user_account")
263 .ok_or(pharos_runtime_database::operations::DatabaseError::InvalidContract)?;
264 let admission = if account.fields.iter().any(|field| {
265 field.name == "is_active"
266 && field.kind == pharos_runtime_database::operations::FieldType::Boolean
267 }) {
268 Predicate::Equal("is_active".to_owned(), Value::Bool(true))
269 } else if account.fields.iter().any(|field| {
270 field.name == "account_status"
271 && field.kind == pharos_runtime_database::operations::FieldType::Text
272 }) {
273 Predicate::Equal(
274 "account_status".to_owned(),
275 Value::String("active".to_owned()),
276 )
277 } else {
278 return Err(
279 pharos_runtime_database::operations::DatabaseError::InvalidContract,
280 );
281 };
282 let active = store.adapter.query(
283 &store.config,
284 &store.schema,
285 &Query {
286 entity: "user_account".to_owned(),
287 fields: vec!["id".to_owned()],
288 filter: Some(Predicate::All(vec![
289 Predicate::Equal("id".to_owned(), Value::from(actor_id)),
290 admission,
291 ])),
292 order: Vec::new(),
293 limit: 1,
294 offset: 0,
295 },
296 )?;
297 if active.is_empty() {
298 return Ok(Vec::new());
299 }
300 let mut roles = std::collections::BTreeSet::new();
301 for (entity, actor_field, role_field) in [
302 ("user_profile", "user_id", "account_type"),
303 ("store_membership", "user_id", "role"),
304 ] {
305 if !store
306 .schema
307 .entities
308 .iter()
309 .any(|declaration| declaration.name == entity)
310 {
311 continue;
312 }
313 let rows = store.adapter.query(
314 &store.config,
315 &store.schema,
316 &Query {
317 entity: entity.to_owned(),
318 fields: vec![role_field.to_owned()],
319 filter: Some(Predicate::Equal(
320 actor_field.to_owned(),
321 Value::from(actor_id),
322 )),
323 order: Vec::new(),
324 limit: 65,
325 offset: 0,
326 },
327 )?;
328 if rows.len() > 64 {
329 return Err(
330 pharos_runtime_database::operations::DatabaseError::CapacityExceeded,
331 );
332 }
333 for row in rows {
334 let role = row.get(role_field).and_then(Value::as_str).ok_or(
335 pharos_runtime_database::operations::DatabaseError::StorageFailure,
336 )?;
337 if sql_identifier(role).is_err() {
338 return Err(
339 pharos_runtime_database::operations::DatabaseError::InvalidInput,
340 );
341 }
342 roles.insert(role.to_owned());
343 }
344 }
345 Ok(roles.into_iter().collect())
346 };
347 read_roles()
348 }
349 #[cfg(target_os = "wasi")]
350 {
351 let _ = (app, actor_id);
352 Err(pharos_runtime_database::operations::DatabaseError::Unsupported)
353 }
354}
355
356pub fn authorize_private_asset(
382 app: &AppRuntime,
383 request_path: &str,
384 actor_id: i64,
385) -> Result<Option<AuthorizedPrivateAsset>, FeatureRuntimeError> {
386 #[cfg(not(target_os = "wasi"))]
387 {
388 use pharos_runtime_database::operations::{Predicate, Query};
389
390 if actor_id <= 0 {
391 return Ok(None);
392 }
393 let root = artifact_root(app)?;
394 let contract_path = root.join("data/contracts/protected-assets.json");
395 if !contract_path.is_file() {
396 return Ok(None);
397 }
398 let contract = load_json(&contract_path)?;
399 let declarations = contract
400 .get("assets")
401 .and_then(Value::as_array)
402 .ok_or_else(|| FeatureRuntimeError::InvalidContract("protected assets".to_owned()))?;
403 let mut matched = None;
404 for declaration in declarations {
405 let pattern = declaration
406 .get("path")
407 .and_then(Value::as_str)
408 .ok_or_else(|| FeatureRuntimeError::InvalidContract("protected path".to_owned()))?;
409 if let Ok(parameters) = path_parameters(pattern, request_path)
410 && matched.replace((declaration, parameters)).is_some()
411 {
412 return Err(FeatureRuntimeError::InvalidContract(
413 "ambiguous protected route".to_owned(),
414 ));
415 }
416 }
417 let Some((declaration, parameters)) = matched else {
418 return Ok(None);
419 };
420 let entity_id = declaration
421 .get("entity")
422 .and_then(Value::as_str)
423 .ok_or_else(|| FeatureRuntimeError::InvalidContract("protected entity".to_owned()))?;
424 let store =
425 super::database::runtime_logical_app_store(app).map_err(logical::map_database_error)?;
426 let entity = store
427 .schema
428 .entities
429 .iter()
430 .find(|entity| entity.name == entity_id)
431 .ok_or_else(|| FeatureRuntimeError::InvalidContract(entity_id.to_owned()))?;
432 let identity_fields = entity.identity.fields();
433 let identity_field = match identity_fields.as_slice() {
434 [field] => *field,
435 _ => {
436 return Err(FeatureRuntimeError::InvalidContract(
437 "protected asset identity".to_owned(),
438 ));
439 }
440 };
441 let key_field = sql_identifier(
442 declaration
443 .get("key_field")
444 .and_then(Value::as_str)
445 .unwrap_or_default(),
446 )?;
447 let owner_field = sql_identifier(
448 declaration
449 .get("owner_field")
450 .and_then(Value::as_str)
451 .unwrap_or_default(),
452 )?;
453 let asset_field = sql_identifier(
454 declaration
455 .get("asset_field")
456 .and_then(Value::as_str)
457 .unwrap_or_default(),
458 )?;
459 if ![
460 identity_field,
461 key_field.as_str(),
462 owner_field.as_str(),
463 asset_field.as_str(),
464 ]
465 .iter()
466 .all(|name| entity.fields.iter().any(|field| field.name == *name))
467 {
468 return Err(FeatureRuntimeError::InvalidContract(entity_id.to_owned()));
469 }
470 let key = parameters
471 .values()
472 .next()
473 .and_then(Value::as_str)
474 .ok_or_else(|| FeatureRuntimeError::InvalidInput("protected key".to_owned()))?;
475 let query = Query {
476 entity: entity.name.clone(),
477 fields: vec![identity_field.to_owned(), asset_field.clone()],
478 filter: Some(Predicate::All(vec![
479 Predicate::Equal(key_field, Value::String(key.to_owned())),
480 Predicate::Equal(owner_field, Value::from(actor_id)),
481 ])),
482 order: Vec::new(),
483 limit: 1,
484 offset: 0,
485 };
486 let rows = store
487 .adapter
488 .query(&store.config, &store.schema, &query)
489 .map_err(|_| FeatureRuntimeError::DatabaseUnavailable)?;
490 let Some(row) = rows.first() else {
491 return Ok(None);
492 };
493 let protected_id = row
494 .get(identity_field)
495 .and_then(Value::as_i64)
496 .ok_or(FeatureRuntimeError::DatabaseUnavailable)?;
497 let asset_key = row
498 .get(&asset_field)
499 .and_then(Value::as_str)
500 .ok_or(FeatureRuntimeError::DatabaseUnavailable)?
501 .to_owned();
502 record_private_asset_authorization(app, declaration, actor_id, protected_id)?;
503 Ok(Some(AuthorizedPrivateAsset {
504 private_root: declaration
505 .get("root")
506 .and_then(Value::as_str)
507 .unwrap_or_default()
508 .to_owned(),
509 asset_key,
510 }))
511 }
512 #[cfg(target_os = "wasi")]
513 {
514 let _ = (app, request_path, actor_id);
515 Err(FeatureRuntimeError::DatabaseUnavailable)
516 }
517}
518
519fn sql_identifier(value: &str) -> Result<String, FeatureRuntimeError> {
521 if value.is_empty()
522 || value.len() > 128
523 || !value.bytes().enumerate().all(|(index, byte)| match byte {
524 b'a'..=b'z' | b'A'..=b'Z' | b'_' => true,
525 b'0'..=b'9' => index > 0,
526 _ => false,
527 })
528 {
529 return Err(FeatureRuntimeError::InvalidContract(value.to_owned()));
530 }
531 Ok(value.to_owned())
532}
533
534fn load_json(path: &Path) -> Result<Value, FeatureRuntimeError> {
536 let text = std::fs::read_to_string(path)
537 .map_err(|_| FeatureRuntimeError::InvalidContract(path.to_string_lossy().into_owned()))?;
538 serde_json::from_str(&text)
539 .map_err(|_| FeatureRuntimeError::InvalidContract(path.to_string_lossy().into_owned()))
540}
541
542fn manifest_declared_template_params(
543 app: &AppRuntime,
544) -> Result<Map<String, Value>, FeatureRuntimeError> {
545 let mut params = Map::new();
546 let app_root = artifact_root(app)?;
547 let manifest_path = app_root.join("pharos.app.json");
548 if !manifest_path.is_file() {
549 return Ok(params);
550 }
551 let manifest = load_json(&manifest_path)?;
552 let Some(payments) = manifest.get("payments").and_then(Value::as_object) else {
553 return Ok(params);
554 };
555 if let Some(mode) = payments.get("checkout_mode").and_then(Value::as_str) {
556 if mode.len() > 64 {
557 return Err(FeatureRuntimeError::InvalidContract(
558 "payments.checkout_mode".to_owned(),
559 ));
560 }
561 let normalized_mode = match mode.trim().to_ascii_lowercase().as_str() {
562 "live" => "live",
563 _ => "demo",
564 };
565 let display_mode = if normalized_mode == "live" {
566 "Live"
567 } else {
568 "Demo"
569 };
570 let mode_class = if normalized_mode == "live" {
571 "payment-mode-live"
572 } else {
573 "payment-mode-demo"
574 };
575 params.insert(
576 "payment_checkout_mode".to_owned(),
577 Value::String(display_mode.to_owned()),
578 );
579 params.insert(
580 "payment_checkout_mode_value".to_owned(),
581 Value::String(normalized_mode.to_owned()),
582 );
583 params.insert(
584 "payment_checkout_mode_class".to_owned(),
585 Value::String(mode_class.to_owned()),
586 );
587 params.insert(
588 "payment_checkout_demo_checked".to_owned(),
589 Value::String(
590 if normalized_mode == "demo" {
591 "checked"
592 } else {
593 ""
594 }
595 .to_owned(),
596 ),
597 );
598 params.insert(
599 "payment_checkout_live_checked".to_owned(),
600 Value::String(
601 if normalized_mode == "live" {
602 "checked"
603 } else {
604 ""
605 }
606 .to_owned(),
607 ),
608 );
609 params.insert(
610 "payment_checkout_notice_class".to_owned(),
611 Value::String(
612 if normalized_mode == "live" {
613 "checkout-notice-hidden"
614 } else {
615 "demo-notice"
616 }
617 .to_owned(),
618 ),
619 );
620 params.insert(
621 "payment_checkout_decline_class".to_owned(),
622 Value::String(
623 if normalized_mode == "live" {
624 "checkout-action-hidden"
625 } else {
626 ""
627 }
628 .to_owned(),
629 ),
630 );
631 if normalized_mode == "live" {
632 params.insert(
633 "checkout_notice_title".to_owned(),
634 Value::String(String::new()),
635 );
636 params.insert(
637 "checkout_notice_body".to_owned(),
638 Value::String(String::new()),
639 );
640 params.insert(
641 "checkout_primary_action".to_owned(),
642 Value::String("Purchase".to_owned()),
643 );
644 }
645 }
646 params.insert(
647 "payment_provider_credentials_state".to_owned(),
648 Value::String("not_saved".to_owned()),
649 );
650 params.insert(
651 "payment_provider_credentials_state_display".to_owned(),
652 Value::String("Not saved".to_owned()),
653 );
654 params.insert(
655 "payment_provider_feedback_message".to_owned(),
656 Value::String(String::new()),
657 );
658 if let Some(provider) = payments
659 .get("supported_providers")
660 .and_then(Value::as_array)
661 .into_iter()
662 .flatten()
663 .find_map(Value::as_str)
664 {
665 if provider.len() > 64 {
666 return Err(FeatureRuntimeError::InvalidContract(
667 "payments.supported_providers".to_owned(),
668 ));
669 }
670 params.insert(
671 "payment_primary_provider".to_owned(),
672 Value::String(provider.to_owned()),
673 );
674 }
675 if let Some(copy) = payments.get("copy").and_then(Value::as_object) {
676 if copy.len() > 64 {
677 return Err(FeatureRuntimeError::InvalidContract(
678 "payments.copy".to_owned(),
679 ));
680 }
681 for (key, value) in copy {
682 let key = sql_identifier(key)?;
683 let scalar = match value {
684 Value::String(text) if text.len() <= 4096 => Value::String(text.clone()),
685 Value::Bool(flag) => Value::Bool(*flag),
686 Value::Number(number) => Value::Number(number.clone()),
687 Value::Null => Value::Null,
688 _ => {
689 return Err(FeatureRuntimeError::InvalidContract(format!(
690 "payments.copy.{key}"
691 )));
692 }
693 };
694 params.insert(key, scalar);
695 }
696 }
697 if let Some(copy) = super::payment_provider::payment_provider_copy_entries(&app_root) {
698 if copy.len() > 64 {
699 return Err(FeatureRuntimeError::InvalidContract(
700 "payments.provider_src".to_owned(),
701 ));
702 }
703 for (key, value) in copy {
704 params.insert(sql_identifier(&key)?, value);
705 }
706 }
707 Ok(params)
708}
709
710fn artifact_root(app: &AppRuntime) -> Result<PathBuf, FeatureRuntimeError> {
712 app.artifact_dir
713 .as_deref()
714 .filter(|value| !value.is_empty())
715 .map(PathBuf::from)
716 .ok_or(FeatureRuntimeError::DatabaseUnavailable)
717}
718
719fn dotted_value<'a>(root: &'a Value, path: &str) -> Option<&'a Value> {
722 path.split('.').try_fold(root, |current, segment| {
723 (!segment.is_empty()).then_some(())?;
724 current.as_object()?.get(segment)
725 })
726}
727
728fn resolve_value(
730 declaration: &Value,
731 context: &FeatureRequestContext,
732) -> Result<Option<Value>, FeatureRuntimeError> {
733 resolve_value_with_budget(declaration, context, 32)
734}
735
736fn resolve_value_with_budget(
738 declaration: &Value,
739 context: &FeatureRequestContext,
740 remaining: usize,
741) -> Result<Option<Value>, FeatureRuntimeError> {
742 if remaining == 0 {
743 return Err(FeatureRuntimeError::InvalidContract(
744 "value nesting limit".into(),
745 ));
746 }
747 if !declaration.is_object() && !declaration.is_string() {
748 return Ok(Some(declaration.clone()));
749 }
750 let roots = serde_json::json!({
751 "input": context.input,
752 "path": context.path_params,
753 "query_param": context.query_params,
754 "query": context.bindings,
755 "row": context.row,
756 "generated": context.generated,
757 "protected": context.protected,
758 "subject": {"user_id": context.actor_id},
759 });
760 if let Some(text) = declaration.as_str() {
761 if text == "server.now" {
762 return Ok(Some(Value::String(super::current_timestamp_utc())));
763 }
764 if text == "server.date" {
765 return Ok(Some(Value::String(
766 super::current_timestamp_utc()[..10].to_owned(),
767 )));
768 }
769 for prefix in [
770 "input.",
771 "path.",
772 "query_param.",
773 "query.",
774 "row.",
775 "generated.",
776 "protected.",
777 "subject.",
778 ] {
779 if text.starts_with(prefix) {
780 return Ok(dotted_value(&roots, text).cloned());
781 }
782 }
783 return Ok(Some(Value::String(text.to_owned())));
784 }
785 let object = declaration
786 .as_object()
787 .ok_or_else(|| FeatureRuntimeError::InvalidContract("value source".to_owned()))?;
788 if let Some(offset) = object.get("server_time_offset_seconds") {
789 let seconds = offset
790 .as_i64()
791 .filter(|seconds| (-31_622_400..=31_622_400).contains(seconds))
792 .filter(|_| object.len() == 1)
793 .ok_or_else(|| {
794 FeatureRuntimeError::InvalidContract("server_time_offset_seconds".into())
795 })?;
796 let now = super::runtime_timestamp_seconds(&super::current_timestamp_utc())
797 .and_then(|now| now.checked_add_signed(seconds))
798 .ok_or_else(|| {
799 FeatureRuntimeError::InvalidContract("server_time_offset_seconds".into())
800 })?;
801 return Ok(Some(Value::String(
802 pharos_runtime_core::time::timestamp_from_epoch_seconds(now),
803 )));
804 }
805 if let Some(spec) = object.get("pluck").and_then(Value::as_object) {
806 let field = spec
807 .get("field")
808 .and_then(Value::as_str)
809 .ok_or_else(|| FeatureRuntimeError::InvalidContract("pluck.field".into()))?;
810 sql_identifier(field)?;
811 let rows = resolve_value_with_budget(
812 spec.get("source")
813 .ok_or_else(|| FeatureRuntimeError::InvalidContract("pluck.source".into()))?,
814 context,
815 remaining - 1,
816 )?
817 .ok_or_else(|| FeatureRuntimeError::InvalidInput("pluck.source".into()))?;
818 let rows = rows
819 .as_array()
820 .filter(|rows| rows.len() <= 64)
821 .ok_or_else(|| FeatureRuntimeError::InvalidInput("pluck.source".into()))?;
822 let values = rows
823 .iter()
824 .map(|row| {
825 row.as_object()
826 .and_then(|row| row.get(field))
827 .cloned()
828 .ok_or_else(|| FeatureRuntimeError::InvalidInput("pluck.field".into()))
829 })
830 .collect::<Result<Vec<_>, _>>()?;
831 return Ok(Some(Value::Array(values)));
832 }
833 if let Some(spec) = object.get("lookup").and_then(Value::as_object) {
834 let values = spec
835 .get("values")
836 .and_then(Value::as_object)
837 .filter(|values| !values.is_empty() && values.len() <= 64)
838 .ok_or_else(|| FeatureRuntimeError::InvalidContract("lookup.values".into()))?;
839 let key = resolve_value_with_budget(
840 spec.get("key")
841 .ok_or_else(|| FeatureRuntimeError::InvalidContract("lookup.key".into()))?,
842 context,
843 remaining - 1,
844 )?;
845 let Some(key) = key else {
846 return Ok(None);
847 };
848 let key = key
849 .as_str()
850 .filter(|key| key.len() <= 128)
851 .ok_or_else(|| FeatureRuntimeError::InvalidInput("lookup.key".into()))?;
852 if key.is_empty() {
853 return Ok(None);
854 }
855 let value = values
856 .get(key)
857 .ok_or_else(|| FeatureRuntimeError::InvalidInput("lookup.key".into()))?;
858 return if value.is_null() {
859 Ok(None)
860 } else {
861 resolve_value_with_budget(value, context, remaining - 1)
862 };
863 }
864 if let Some(values) = object.get("coalesce").and_then(Value::as_array) {
865 if values.is_empty() || values.len() > 8 {
866 return Err(FeatureRuntimeError::InvalidContract("coalesce".into()));
867 }
868 for value in values {
869 if let Some(value) = resolve_value_with_budget(value, context, remaining - 1)?
870 .filter(|value| !value.is_null())
871 {
872 return Ok(Some(value));
873 }
874 }
875 return Ok(None);
876 }
877 if let Some(values) = object.get("add").and_then(Value::as_array) {
878 if values.is_empty() || values.len() > 8 {
879 return Err(FeatureRuntimeError::InvalidContract("add".into()));
880 }
881 let mut total = 0_i64;
882 for value in values {
883 let value = resolve_value_with_budget(value, context, remaining - 1)?
884 .and_then(|value| value.as_i64())
885 .ok_or_else(|| FeatureRuntimeError::InvalidInput("add".into()))?;
886 total = total
887 .checked_add(value)
888 .ok_or_else(|| FeatureRuntimeError::Conflict("checked add".into()))?;
889 }
890 return Ok(Some(Value::from(total)));
891 }
892 if let Some(spec) = object.get("format_minor").and_then(Value::as_object) {
893 let amount = resolve_value_with_budget(
894 spec.get("amount").ok_or_else(|| {
895 FeatureRuntimeError::InvalidContract("format_minor.amount".into())
896 })?,
897 context,
898 remaining - 1,
899 )?
900 .and_then(|value| value.as_i64())
901 .filter(|amount| *amount >= 0)
902 .ok_or_else(|| FeatureRuntimeError::InvalidInput("format_minor.amount".into()))?;
903 let currency = resolve_value_with_budget(
904 spec.get("currency").ok_or_else(|| {
905 FeatureRuntimeError::InvalidContract("format_minor.currency".into())
906 })?,
907 context,
908 remaining - 1,
909 )?
910 .and_then(|value| value.as_str().map(str::to_owned))
911 .ok_or_else(|| FeatureRuntimeError::InvalidInput("format_minor.currency".into()))?;
912 let value = super::json_commands::currency_minor_text(amount, ¤cy)
913 .ok_or_else(|| FeatureRuntimeError::InvalidInput("format_minor.currency".into()))?;
914 return Ok(Some(Value::from(value)));
915 }
916 if let Some(value) = object.get("literal") {
917 return Ok(Some(value.clone()));
918 }
919 for (key, root_name) in [
920 ("input", "input"),
921 ("path", "path"),
922 ("query_param", "query_param"),
923 ("binding", "query"),
924 ("row", "row"),
925 ("generated", "generated"),
926 ("protected", "protected"),
927 ("subject", "subject"),
928 ] {
929 if let Some(field) = object.get(key).and_then(Value::as_str) {
930 return Ok(dotted_value(&roots, &format!("{root_name}.{field}")).cloned());
931 }
932 }
933 if object.get("server").and_then(Value::as_str) == Some("now") {
934 return Ok(Some(Value::String(super::current_timestamp_utc())));
935 }
936 if let Some(seconds) = object
937 .get("server_now_minus_seconds")
938 .and_then(Value::as_u64)
939 {
940 if seconds > 604_800 {
941 return Err(FeatureRuntimeError::InvalidContract(
942 "server_now_minus_seconds".to_owned(),
943 ));
944 }
945 return Ok(Some(Value::String(timestamp_before_seconds_utc(seconds))));
946 }
947 if let Some(spec) = object.get("pad_integer").and_then(Value::as_object) {
948 let width = spec
949 .get("width")
950 .and_then(Value::as_u64)
951 .filter(|width| (1..=19).contains(width))
952 .ok_or_else(|| FeatureRuntimeError::InvalidContract("pad_integer.width".to_owned()))?
953 as usize;
954 let value = resolve_value_with_budget(
955 spec.get("value").ok_or_else(|| {
956 FeatureRuntimeError::InvalidContract("pad_integer.value".to_owned())
957 })?,
958 context,
959 remaining - 1,
960 )?
961 .and_then(|value| value.as_i64())
962 .filter(|value| *value >= 0)
963 .ok_or_else(|| FeatureRuntimeError::InvalidInput("pad_integer.value".to_owned()))?;
964 let output = format!("{value:0width$}");
965 if output.len() > width {
966 return Err(FeatureRuntimeError::InvalidInput(
967 "pad_integer.width".to_owned(),
968 ));
969 }
970 return Ok(Some(Value::String(output)));
971 }
972 if let Some(parts) = object.get("concat").and_then(Value::as_array) {
973 if parts.is_empty() || parts.len() > 16 {
974 return Err(FeatureRuntimeError::InvalidContract("concat".to_owned()));
975 }
976 let mut output = String::new();
977 for part in parts {
978 let value = resolve_value_with_budget(part, context, remaining - 1)?
979 .ok_or_else(|| FeatureRuntimeError::InvalidInput("concat".to_owned()))?;
980 let text = value
981 .as_str()
982 .map(ToOwned::to_owned)
983 .or_else(|| value.as_i64().map(|number| number.to_string()))
984 .ok_or_else(|| FeatureRuntimeError::InvalidInput("concat".to_owned()))?;
985 if output.len().saturating_add(text.len()) > 512 {
986 return Err(FeatureRuntimeError::InvalidInput("concat".to_owned()));
987 }
988 output.push_str(&text);
989 }
990 return Ok(Some(Value::String(output)));
991 }
992 if let Some(sum) = object.get("sum").and_then(Value::as_object) {
993 let query = sum
994 .get("query")
995 .and_then(Value::as_str)
996 .ok_or_else(|| FeatureRuntimeError::InvalidContract("sum.query".to_owned()))?;
997 let field = sum
998 .get("field")
999 .and_then(Value::as_str)
1000 .ok_or_else(|| FeatureRuntimeError::InvalidContract("sum.field".to_owned()))?;
1001 sql_identifier(query)?;
1002 sql_identifier(field)?;
1003 let max_items = sum
1004 .get("max_items")
1005 .and_then(Value::as_u64)
1006 .unwrap_or(20)
1007 .min(MAX_QUERY_ROWS as u64) as usize;
1008 let rows = context
1009 .bindings
1010 .get(query)
1011 .and_then(Value::as_array)
1012 .ok_or_else(|| FeatureRuntimeError::MissingRecord(query.to_owned()))?;
1013 if rows.is_empty() || rows.len() > max_items {
1014 return Err(FeatureRuntimeError::Conflict("bounded sum".to_owned()));
1015 }
1016 let total = rows.iter().try_fold(0i64, |total, row| {
1017 row.get(field)
1018 .and_then(Value::as_i64)
1019 .and_then(|value| total.checked_add(value))
1020 });
1021 return total
1022 .map(Value::from)
1023 .map(Some)
1024 .ok_or_else(|| FeatureRuntimeError::Conflict("checked sum".to_owned()));
1025 }
1026 Err(FeatureRuntimeError::InvalidContract(
1027 "value source".to_owned(),
1028 ))
1029}
1030
1031fn selected_contract_variant<'a>(
1033 contract: &'a Value,
1034 context: &FeatureRequestContext,
1035) -> Result<&'a Value, FeatureRuntimeError> {
1036 let Some(variants) = contract.get("variants").and_then(Value::as_object) else {
1037 return Ok(contract);
1038 };
1039 if variants.is_empty() || variants.len() > 16 {
1040 return Err(FeatureRuntimeError::InvalidContract(
1041 "contract variants".to_owned(),
1042 ));
1043 }
1044 let selector = contract
1045 .get("select")
1046 .and_then(Value::as_str)
1047 .ok_or_else(|| FeatureRuntimeError::InvalidContract("contract select".to_owned()))?;
1048 let selected = resolve_value(&Value::String(selector.to_owned()), context)?
1049 .and_then(|value| value.as_str().map(ToOwned::to_owned))
1050 .ok_or_else(|| FeatureRuntimeError::InvalidInput("contract select".to_owned()))?;
1051 sql_identifier(&selected)?;
1052 variants
1053 .get(&selected)
1054 .ok_or_else(|| FeatureRuntimeError::InvalidInput("contract variant".to_owned()))
1055}
1056
1057#[cfg(not(target_os = "wasi"))]
1059fn referenced_queries(value: &Value, output: &mut BTreeSet<String>) {
1061 match value {
1062 Value::Array(items) => {
1063 for item in items {
1064 referenced_queries(item, output);
1065 }
1066 }
1067 Value::Object(fields) => {
1068 if let Some(query) = fields.get("query").and_then(Value::as_str) {
1069 output.insert(query.to_owned());
1070 }
1071 if let Some(binding) = fields.get("binding").and_then(Value::as_str)
1072 && let Some(query) = binding.split('.').next()
1073 && !query.is_empty()
1074 {
1075 output.insert(query.to_owned());
1076 }
1077 for item in fields.values() {
1078 referenced_queries(item, output);
1079 }
1080 }
1081 Value::String(text) => {
1082 if let Some(binding) = text.strip_prefix("query.")
1083 && let Some(query) = binding.split('.').next()
1084 && !query.is_empty()
1085 {
1086 output.insert(query.to_owned());
1087 }
1088 }
1089 Value::Null | Value::Bool(_) | Value::Number(_) => {}
1090 }
1091}
1092
1093#[cfg(not(target_os = "wasi"))]
1095fn generated_values(value: &Value, output: &mut BTreeSet<String>) {
1097 match value {
1098 Value::Array(items) => {
1099 for item in items {
1100 generated_values(item, output);
1101 }
1102 }
1103 Value::Object(fields) => {
1104 if let Some(name) = fields.get("generated").and_then(Value::as_str) {
1105 output.insert(name.to_owned());
1106 }
1107 for item in fields.values() {
1108 generated_values(item, output);
1109 }
1110 }
1111 Value::String(text) => {
1112 if let Some(name) = text.strip_prefix("generated.")
1113 && !name.is_empty()
1114 {
1115 output.insert(name.to_owned());
1116 }
1117 }
1118 Value::Null | Value::Bool(_) | Value::Number(_) => {}
1119 }
1120}
1121
1122#[cfg(not(target_os = "wasi"))]
1124fn populate_generated_values(
1126 transaction: &Value,
1127 context: &mut FeatureRequestContext,
1128) -> Result<(), FeatureRuntimeError> {
1129 let mut names = BTreeSet::new();
1130 generated_values(transaction, &mut names);
1131 for name in names {
1132 sql_identifier(&name)?;
1133 let value = super::dynamic::random_secret_hex(16)
1134 .map_err(|_| FeatureRuntimeError::DatabaseUnavailable)?;
1135 context.generated.insert(name, Value::String(value));
1136 }
1137 Ok(())
1138}
1139
1140fn validation_report(
1142 action_id: &str,
1143 errors: Vec<String>,
1144 field_error: Option<(String, String, String)>,
1145 form_error: Option<(String, String, String)>,
1146) -> String {
1147 let mut root = Map::new();
1148 root.insert(
1149 "schema".to_owned(),
1150 Value::String("pharos.validation.report.v1".to_owned()),
1151 );
1152 root.insert(
1153 "report_id".to_owned(),
1154 Value::String("pharos.validation.report.v1".to_owned()),
1155 );
1156 root.insert("action_id".to_owned(), Value::String(action_id.to_owned()));
1157 root.insert(
1158 "errors".to_owned(),
1159 Value::Array(errors.into_iter().take(8).map(Value::String).collect()),
1160 );
1161 let mut field_errors = Vec::new();
1162 if let Some((field_id, code, message)) = field_error {
1163 let mut entry = Map::new();
1164 entry.insert("field_id".to_owned(), Value::String(field_id));
1165 entry.insert("rule_id".to_owned(), Value::String(code.clone()));
1166 entry.insert("code".to_owned(), Value::String(code));
1167 entry.insert("message".to_owned(), Value::String(message));
1168 field_errors.push(Value::Object(entry));
1169 }
1170 let mut form_errors = Vec::new();
1171 if let Some((rule_id, code, message)) = form_error {
1172 let mut entry = Map::new();
1173 entry.insert("rule_id".to_owned(), Value::String(rule_id));
1174 entry.insert("code".to_owned(), Value::String(code));
1175 entry.insert("message".to_owned(), Value::String(message));
1176 form_errors.push(Value::Object(entry));
1177 }
1178 root.insert("field_errors".to_owned(), Value::Array(field_errors));
1179 root.insert("form_errors".to_owned(), Value::Array(form_errors));
1180 Value::Object(root).to_string()
1181}
1182
1183fn validation_report_with_field_errors(
1184 action_id: &str,
1185 field_errors: Vec<(String, String, String)>,
1186) -> String {
1187 let errors = field_errors
1188 .iter()
1189 .take(8)
1190 .map(|(_, code, _)| Value::String(code.clone()))
1191 .collect::<Vec<_>>();
1192 let field_errors = field_errors
1193 .into_iter()
1194 .take(8)
1195 .map(|(field_id, code, message)| {
1196 let mut entry = Map::new();
1197 entry.insert("field_id".to_owned(), Value::String(field_id));
1198 entry.insert("rule_id".to_owned(), Value::String(code.clone()));
1199 entry.insert("code".to_owned(), Value::String(code));
1200 entry.insert("message".to_owned(), Value::String(message));
1201 Value::Object(entry)
1202 })
1203 .collect::<Vec<_>>();
1204 serde_json::json!({
1205 "schema": "pharos.validation.report.v1",
1206 "report_id": "pharos.validation.report.v1",
1207 "action_id": action_id,
1208 "errors": errors,
1209 "field_errors": field_errors,
1210 "form_errors": []
1211 })
1212 .to_string()
1213}
1214
1215fn feature_field_validation_code(rules: &Value, default_code: &str) -> String {
1216 rules
1217 .get("validation_code")
1218 .and_then(Value::as_str)
1219 .filter(|value| !value.is_empty())
1220 .filter(|value| value.len() <= 80)
1221 .filter(|value| {
1222 value
1223 .chars()
1224 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
1225 })
1226 .unwrap_or(default_code)
1227 .to_owned()
1228}
1229
1230fn json_scalar_input(value: &Value) -> Option<String> {
1232 match value {
1233 Value::String(value) => Some(value.clone()),
1234 Value::Number(value) => Some(value.to_string()),
1235 Value::Bool(value) => Some(value.to_string()),
1236 Value::Null => None,
1237 _ => None,
1238 }
1239}
1240
1241fn request_header_value(request: &super::http::HttpRequest, header_name: &str) -> Option<String> {
1243 request.headers.lines().find_map(|line| {
1244 let (name, value) = line.split_once(':')?;
1245 name.trim()
1246 .eq_ignore_ascii_case(header_name)
1247 .then(|| value.trim().to_owned())
1248 })
1249}
1250
1251fn scalar_value_to_text(value: &Value) -> String {
1253 match value {
1254 Value::Null => String::new(),
1255 Value::Bool(value) => value.to_string(),
1256 Value::Number(value) => value.to_string(),
1257 Value::String(value) => value.clone(),
1258 Value::Array(_) | Value::Object(_) => String::new(),
1259 }
1260}
1261
1262fn apply_derived_input_hooks(
1264 mut raw: BTreeMap<String, String>,
1265 request: &super::http::HttpRequest,
1266 declared: &Map<String, Value>,
1267 hook_contract: Option<&Value>,
1268) -> Result<BTreeMap<String, String>, FeatureRuntimeError> {
1269 let query_params = query_parameters(&request.target)?;
1270 let Some(rows) = hook_contract
1271 .and_then(|value| value.get("derived_inputs"))
1272 .and_then(Value::as_array)
1273 else {
1274 return Ok(raw);
1275 };
1276 if rows.len() > MAX_QUERY_FILTERS {
1277 return Err(FeatureRuntimeError::InvalidContract(
1278 "hook_contract.derived_inputs".to_owned(),
1279 ));
1280 }
1281 for row in rows {
1282 let field = row
1283 .get("field")
1284 .and_then(Value::as_str)
1285 .filter(|value| !value.is_empty() && value.len() <= 128)
1286 .ok_or_else(|| {
1287 FeatureRuntimeError::InvalidContract(
1288 "hook_contract.derived_inputs.field".to_owned(),
1289 )
1290 })?;
1291 if !declared.contains_key(field) {
1292 return Err(FeatureRuntimeError::InvalidContract(
1293 "hook_contract.derived_inputs.field".to_owned(),
1294 ));
1295 }
1296 if row
1297 .get("preserve_existing")
1298 .and_then(Value::as_bool)
1299 .unwrap_or(false)
1300 && raw.get(field).is_some_and(|value| !value.is_empty())
1301 {
1302 continue;
1303 }
1304 let source = row
1305 .get("source")
1306 .and_then(Value::as_str)
1307 .unwrap_or_default();
1308 let derived = match source {
1309 "literal" => row
1310 .get("value")
1311 .map(scalar_value_to_text)
1312 .filter(|value| !value.is_empty()),
1313 "header" => row
1314 .get("header")
1315 .and_then(Value::as_str)
1316 .and_then(|header| request_header_value(request, header))
1317 .filter(|value| !value.is_empty()),
1318 "query" => row
1319 .get("param")
1320 .or_else(|| row.get("query_param"))
1321 .and_then(Value::as_str)
1322 .and_then(|field| query_params.get(field).cloned())
1323 .and_then(|value| value.as_str().map(ToOwned::to_owned))
1324 .filter(|value| !value.is_empty()),
1325 "form" => row
1326 .get("from_field")
1327 .or_else(|| row.get("input_field"))
1328 .and_then(Value::as_str)
1329 .and_then(|field| raw.get(field).cloned())
1330 .filter(|value| !value.is_empty()),
1331 "context_method" => (!request.method.is_empty()).then(|| request.method.clone()),
1332 "context_path" => request
1333 .target
1334 .split('?')
1335 .next()
1336 .filter(|value| !value.is_empty())
1337 .map(ToOwned::to_owned),
1338 _ => {
1339 return Err(FeatureRuntimeError::InvalidContract(
1340 "hook_contract.derived_inputs.source".to_owned(),
1341 ));
1342 }
1343 };
1344 match derived {
1345 Some(value) => {
1346 raw.insert(field.to_owned(), value);
1347 }
1348 None => {
1349 raw.remove(field);
1350 }
1351 }
1352 }
1353 Ok(raw)
1354}
1355
1356fn apply_default_input_hooks(
1358 mut raw: BTreeMap<String, String>,
1359 request: &super::http::HttpRequest,
1360 declared: &Map<String, Value>,
1361 hook_contract: Option<&Value>,
1362) -> Result<BTreeMap<String, String>, FeatureRuntimeError> {
1363 let query_params = query_parameters(&request.target)?;
1364 let Some(rows) = hook_contract
1365 .and_then(|value| value.get("default_inputs"))
1366 .and_then(Value::as_array)
1367 else {
1368 return Ok(raw);
1369 };
1370 if rows.len() > MAX_QUERY_FILTERS {
1371 return Err(FeatureRuntimeError::InvalidContract(
1372 "hook_contract.default_inputs".to_owned(),
1373 ));
1374 }
1375 for row in rows {
1376 let field = row
1377 .get("field")
1378 .and_then(Value::as_str)
1379 .filter(|value| !value.is_empty() && value.len() <= 128)
1380 .ok_or_else(|| {
1381 FeatureRuntimeError::InvalidContract(
1382 "hook_contract.default_inputs.field".to_owned(),
1383 )
1384 })?;
1385 if !declared.contains_key(field) {
1386 return Err(FeatureRuntimeError::InvalidContract(
1387 "hook_contract.default_inputs.field".to_owned(),
1388 ));
1389 }
1390 if raw.get(field).is_some_and(|value| !value.is_empty()) {
1391 continue;
1392 }
1393 let source = row
1394 .get("source")
1395 .and_then(Value::as_str)
1396 .unwrap_or_default();
1397 let derived = match source {
1398 "literal" => row.get("value").map(scalar_value_to_text),
1399 "header" => row
1400 .get("header")
1401 .and_then(Value::as_str)
1402 .and_then(|header| request_header_value(request, header))
1403 .filter(|value| !value.is_empty()),
1404 "query" => row
1405 .get("param")
1406 .or_else(|| row.get("query_param"))
1407 .and_then(Value::as_str)
1408 .and_then(|field| query_params.get(field).cloned())
1409 .and_then(|value| value.as_str().map(ToOwned::to_owned))
1410 .filter(|value| !value.is_empty()),
1411 "form" => row
1412 .get("from_field")
1413 .or_else(|| row.get("input_field"))
1414 .and_then(Value::as_str)
1415 .and_then(|field| raw.get(field).cloned())
1416 .filter(|value| !value.is_empty()),
1417 "context_method" => (!request.method.is_empty()).then(|| request.method.clone()),
1418 "context_path" => request
1419 .target
1420 .split('?')
1421 .next()
1422 .filter(|value| !value.is_empty())
1423 .map(ToOwned::to_owned),
1424 _ => {
1425 return Err(FeatureRuntimeError::InvalidContract(
1426 "hook_contract.default_inputs.source".to_owned(),
1427 ));
1428 }
1429 };
1430 match derived {
1431 Some(value) => {
1432 raw.insert(field.to_owned(), value);
1433 }
1434 None => {
1435 raw.remove(field);
1436 }
1437 }
1438 }
1439 Ok(raw)
1440}
1441
1442fn apply_input_normalization_hooks(
1444 mut input: Map<String, Value>,
1445 hook_contract: Option<&Value>,
1446) -> Result<Map<String, Value>, FeatureRuntimeError> {
1447 let Some(hooks) = hook_contract
1448 .and_then(|value| value.get("normalize_inputs"))
1449 .and_then(Value::as_array)
1450 else {
1451 return Ok(input);
1452 };
1453 for hook in hooks {
1454 let kind = hook
1455 .get("kind")
1456 .and_then(Value::as_str)
1457 .ok_or_else(|| FeatureRuntimeError::InvalidContract("hook_contract.kind".to_owned()))?;
1458 let fields = hook
1459 .get("fields")
1460 .and_then(Value::as_array)
1461 .ok_or_else(|| {
1462 FeatureRuntimeError::InvalidContract("hook_contract.fields".to_owned())
1463 })?;
1464 for field in fields {
1465 let field = field.as_str().ok_or_else(|| {
1466 FeatureRuntimeError::InvalidContract("hook_contract.fields".to_owned())
1467 })?;
1468 if field.is_empty() || field.len() > 128 {
1469 return Err(FeatureRuntimeError::InvalidContract(
1470 "hook_contract.fields".to_owned(),
1471 ));
1472 }
1473 let Some(current) = input.get(field).cloned() else {
1474 continue;
1475 };
1476 let Some(text) = current.as_str() else {
1477 continue;
1478 };
1479 let next = match kind {
1480 "trim" => Some(text.trim().to_owned()),
1481 "collapse_whitespace" => Some(
1482 text.split_whitespace()
1483 .filter(|segment| !segment.is_empty())
1484 .collect::<Vec<_>>()
1485 .join(" "),
1486 ),
1487 "lowercase" => Some(text.to_ascii_lowercase()),
1488 "uppercase" => Some(text.to_ascii_uppercase()),
1489 "empty_to_null" => (!text.trim().is_empty()).then_some(text.to_owned()),
1490 _ => {
1491 return Err(FeatureRuntimeError::InvalidContract(
1492 "hook_contract.kind".to_owned(),
1493 ));
1494 }
1495 };
1496 match next {
1497 Some(value) => {
1498 input.insert(field.to_owned(), Value::String(value));
1499 }
1500 None => {
1501 input.remove(field);
1502 }
1503 }
1504 }
1505 }
1506 Ok(input)
1507}
1508
1509fn declared_input_preflight_errors(
1511 declared: &Map<String, Value>,
1512 raw: &BTreeMap<String, String>,
1513) -> Vec<(String, String, String)> {
1514 let mut errors = Vec::new();
1515 for (field, rules) in declared {
1516 let Some(value) = raw.get(field) else {
1517 continue;
1518 };
1519 if rules.get("type").and_then(Value::as_str) == Some("enum")
1520 && !rules
1521 .get("values")
1522 .and_then(Value::as_array)
1523 .is_some_and(|values| values.iter().any(|item| item.as_str() == Some(value)))
1524 {
1525 errors.push((
1526 field.clone(),
1527 feature_field_validation_code(rules, "field_invalid"),
1528 "The supplied value is not one of the declared enum choices.".to_owned(),
1529 ));
1530 }
1531 if let Some(other_field) = rules.get("after_field").and_then(Value::as_str)
1532 && let Some(other_value) = raw.get(other_field)
1533 && !value.is_empty()
1534 && !other_value.is_empty()
1535 && value <= other_value
1536 {
1537 errors.push((
1538 field.clone(),
1539 feature_field_validation_code(rules, "field_invalid"),
1540 "The supplied value must be after the referenced field.".to_owned(),
1541 ));
1542 }
1543 }
1544 errors
1545}
1546
1547fn validated_action_input(
1548 request: &super::http::HttpRequest,
1549 contract: &Value,
1550 hook_contract: Option<&Value>,
1551 action_id: &str,
1552) -> Result<Map<String, Value>, FeatureRuntimeError> {
1553 let large_text = contract
1554 .get("inline_fields")
1555 .and_then(Value::as_object)
1556 .is_some_and(|fields| {
1557 fields
1558 .values()
1559 .take(33)
1560 .any(|field| field.get("large_text") == Some(&Value::Bool(true)))
1561 });
1562 let body_limit = if large_text { 128 * 1024 } else { 64 * 1024 };
1563 if request.body.len() > body_limit || request.body_file_path.is_some() {
1564 return Err(FeatureRuntimeError::ValidationReport(validation_report(
1565 action_id,
1566 vec!["body_invalid".to_owned()],
1567 None,
1568 Some((
1569 "body_invalid".to_owned(),
1570 "body_invalid".to_owned(),
1571 "Request body must fit within the supported bounds.".to_owned(),
1572 )),
1573 )));
1574 }
1575 let declared = contract
1576 .get("inline_fields")
1577 .and_then(Value::as_object)
1578 .ok_or_else(|| FeatureRuntimeError::InvalidContract("input_contract".to_owned()))?;
1579 if declared.len() > MAX_QUERY_FILTERS {
1580 return Err(FeatureRuntimeError::InvalidContract(
1581 "input field count".to_owned(),
1582 ));
1583 }
1584 let mut raw = BTreeMap::new();
1585 if request
1586 .content_type
1587 .as_deref()
1588 .is_some_and(|value| value.starts_with("application/json"))
1589 {
1590 let body = std::str::from_utf8(&request.body).map_err(|_| {
1591 FeatureRuntimeError::ValidationReport(validation_report(
1592 action_id,
1593 vec!["json_object_required".to_owned()],
1594 None,
1595 Some((
1596 "json_object_required".to_owned(),
1597 "json_object_required".to_owned(),
1598 "JSON request bodies must be valid top-level objects.".to_owned(),
1599 )),
1600 ))
1601 })?;
1602 let object = serde_json::from_str::<Value>(body)
1603 .ok()
1604 .and_then(|value| value.as_object().cloned())
1605 .ok_or_else(|| {
1606 FeatureRuntimeError::ValidationReport(validation_report(
1607 action_id,
1608 vec!["json_object_required".to_owned()],
1609 None,
1610 Some((
1611 "json_object_required".to_owned(),
1612 "json_object_required".to_owned(),
1613 "JSON request bodies must be valid top-level objects.".to_owned(),
1614 )),
1615 ))
1616 })?;
1617 for (key, value) in object {
1618 if !declared.contains_key(&key) {
1619 return Err(FeatureRuntimeError::ValidationReport(validation_report(
1620 action_id,
1621 vec!["undeclared_field".to_owned()],
1622 Some((
1623 key,
1624 "undeclared_field".to_owned(),
1625 "The request includes a field that is not part of the action contract."
1626 .to_owned(),
1627 )),
1628 None,
1629 )));
1630 }
1631 let Some(value) = json_scalar_input(&value) else {
1632 return Err(FeatureRuntimeError::ValidationReport(validation_report(
1633 action_id,
1634 vec!["field_invalid".to_owned()],
1635 Some((
1636 key,
1637 "field_invalid".to_owned(),
1638 "Only scalar JSON values are accepted for declared input fields."
1639 .to_owned(),
1640 )),
1641 None,
1642 )));
1643 };
1644 raw.insert(key, value);
1645 }
1646 } else {
1647 let body = std::str::from_utf8(&request.body).map_err(|_| {
1648 FeatureRuntimeError::ValidationReport(validation_report(
1649 action_id,
1650 vec!["body_invalid".to_owned()],
1651 None,
1652 Some((
1653 "body_invalid".to_owned(),
1654 "body_invalid".to_owned(),
1655 "The request body could not be decoded.".to_owned(),
1656 )),
1657 ))
1658 })?;
1659 for pair in body.split('&').filter(|pair| !pair.is_empty()) {
1660 let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
1661 let key = super::json_commands::url_decode(key).ok_or_else(|| {
1662 FeatureRuntimeError::ValidationReport(validation_report(
1663 action_id,
1664 vec!["field_invalid".to_owned()],
1665 None,
1666 Some((
1667 "field_invalid".to_owned(),
1668 "field_invalid".to_owned(),
1669 "The request contained an invalid field encoding.".to_owned(),
1670 )),
1671 ))
1672 })?;
1673 let value = super::json_commands::url_decode(value).ok_or_else(|| {
1674 FeatureRuntimeError::ValidationReport(validation_report(
1675 action_id,
1676 vec!["field_invalid".to_owned()],
1677 Some((
1678 key.clone(),
1679 "field_invalid".to_owned(),
1680 "The request contained an invalid field encoding.".to_owned(),
1681 )),
1682 None,
1683 ))
1684 })?;
1685 if key == "csrf_token" {
1686 continue;
1687 }
1688 if !declared.contains_key(&key) || raw.insert(key.clone(), value).is_some() {
1689 return Err(FeatureRuntimeError::ValidationReport(validation_report(
1690 action_id,
1691 vec!["undeclared_field".to_owned()],
1692 Some((
1693 key,
1694 "undeclared_field".to_owned(),
1695 "The request includes a field that is not part of the action contract."
1696 .to_owned(),
1697 )),
1698 None,
1699 )));
1700 }
1701 }
1702 }
1703 raw = apply_derived_input_hooks(raw, request, declared, hook_contract)?;
1704 raw = apply_default_input_hooks(raw, request, declared, hook_contract)?;
1705 let preflight_errors = declared_input_preflight_errors(declared, &raw);
1706 if !preflight_errors.is_empty() {
1707 return Err(FeatureRuntimeError::ValidationReport(
1708 validation_report_with_field_errors(action_id, preflight_errors),
1709 ));
1710 }
1711 let mut input = Map::new();
1712 for (field, rules) in declared {
1713 sql_identifier(field)?;
1714 let required = rules
1715 .get("required")
1716 .and_then(Value::as_bool)
1717 .unwrap_or(false);
1718 let Some(mut value) = raw.remove(field) else {
1719 if required {
1720 let code = feature_field_validation_code(rules, "missing_required_field");
1721 return Err(FeatureRuntimeError::ValidationReport(validation_report(
1722 action_id,
1723 vec![code.clone()],
1724 Some((
1725 field.clone(),
1726 code,
1727 "A required field was not provided.".to_owned(),
1728 )),
1729 None,
1730 )));
1731 }
1732 continue;
1733 };
1734 let field_ceiling = match rules.get("large_text") {
1735 None | Some(Value::Bool(false)) => 8 * 1024,
1736 Some(Value::Bool(true))
1737 if rules.get("type").and_then(Value::as_str) == Some("string") =>
1738 {
1739 32 * 1024
1740 }
1741 _ => {
1742 return Err(FeatureRuntimeError::InvalidContract(
1743 "input.large_text".to_owned(),
1744 ));
1745 }
1746 };
1747 let max_length = rules
1748 .get("max_length")
1749 .and_then(Value::as_u64)
1750 .unwrap_or(256)
1751 .min(field_ceiling) as usize;
1752 let min_length = rules
1753 .get("min_length")
1754 .and_then(Value::as_u64)
1755 .unwrap_or(0)
1756 .min(max_length as u64) as usize;
1757 if value.len() > max_length
1758 || value.len() < min_length
1759 || (required && value.trim().is_empty())
1760 {
1761 let code = feature_field_validation_code(rules, "field_invalid");
1762 return Err(FeatureRuntimeError::ValidationReport(validation_report(
1763 action_id,
1764 vec![code.clone()],
1765 Some((
1766 field.clone(),
1767 code,
1768 "The supplied value does not satisfy the declared bounds.".to_owned(),
1769 )),
1770 None,
1771 )));
1772 }
1773 if !declared_input_format_valid(rules.get("format").and_then(Value::as_str), &value)? {
1774 return Err(FeatureRuntimeError::InvalidInput(field.clone()));
1775 }
1776 if rules.get("format").and_then(Value::as_str) == Some("date_or_timestamp")
1777 && value.len() == 10
1778 {
1779 value.push_str("T00:00:00Z");
1780 }
1781 if matches!(
1782 rules.get("format").and_then(Value::as_str),
1783 Some("timestamp" | "date_or_timestamp")
1784 ) && !value.is_empty()
1785 {
1786 value = pharos_runtime_database::operations::canonical_stored_timestamp(&value)
1787 .map_err(|_| FeatureRuntimeError::InvalidInput(field.clone()))?;
1788 }
1789 if matches!(
1790 rules.get("format").and_then(Value::as_str),
1791 Some("https_url" | "web_url")
1792 ) && !value.is_empty()
1793 {
1794 value = super::outbound_url::web_url(&value)
1795 .ok_or_else(|| FeatureRuntimeError::InvalidInput(field.clone()))?
1796 .to_string();
1797 }
1798 let typed = match rules
1799 .get("type")
1800 .and_then(Value::as_str)
1801 .unwrap_or("string")
1802 {
1803 "string" => Value::String(value),
1804 "integer" => Value::from(
1805 value
1806 .parse::<i64>()
1807 .ok()
1808 .filter(|number| {
1809 rules
1810 .get("minimum")
1811 .and_then(Value::as_i64)
1812 .is_none_or(|minimum| *number >= minimum)
1813 && rules
1814 .get("maximum")
1815 .and_then(Value::as_i64)
1816 .is_none_or(|maximum| *number <= maximum)
1817 })
1818 .ok_or_else(|| {
1819 FeatureRuntimeError::ValidationReport(validation_report(
1820 action_id,
1821 vec!["field_invalid".to_owned()],
1822 Some((
1823 field.clone(),
1824 "field_invalid".to_owned(),
1825 "The supplied integer value is outside the declared bounds."
1826 .to_owned(),
1827 )),
1828 None,
1829 ))
1830 })?,
1831 ),
1832 "boolean" => {
1833 let normalized = value.trim().to_ascii_lowercase();
1834 match normalized.as_str() {
1835 "true" | "1" | "yes" | "on" => Value::Bool(true),
1836 "false" | "0" | "no" | "off" => Value::Bool(false),
1837 _ => {
1838 return Err(FeatureRuntimeError::ValidationReport(validation_report(
1839 action_id,
1840 vec!["field_invalid".to_owned()],
1841 Some((
1842 field.clone(),
1843 "field_invalid".to_owned(),
1844 "The supplied boolean value is outside the declared bounds."
1845 .to_owned(),
1846 )),
1847 None,
1848 )));
1849 }
1850 }
1851 }
1852 "enum" => {
1853 if !rules
1854 .get("values")
1855 .and_then(Value::as_array)
1856 .is_some_and(|values| values.iter().any(|item| item.as_str() == Some(&value)))
1857 {
1858 let code = feature_field_validation_code(rules, "field_invalid");
1859 return Err(FeatureRuntimeError::ValidationReport(validation_report(
1860 action_id,
1861 vec![code.clone()],
1862 Some((
1863 field.clone(),
1864 code,
1865 "The supplied value is not one of the declared enum choices."
1866 .to_owned(),
1867 )),
1868 None,
1869 )));
1870 }
1871 Value::String(value)
1872 }
1873 _ => {
1874 return Err(FeatureRuntimeError::InvalidContract(format!(
1875 "input.{field}.type"
1876 )));
1877 }
1878 };
1879 input.insert(field.clone(), typed);
1880 }
1881 for (field, rules) in declared {
1882 if let Some(earlier) = rules.get("not_before").and_then(Value::as_str) {
1883 if !matches!(
1884 rules.get("format").and_then(Value::as_str),
1885 Some("timestamp" | "date_or_timestamp")
1886 ) || !matches!(
1887 declared
1888 .get(earlier)
1889 .and_then(|rules| rules.get("format"))
1890 .and_then(Value::as_str),
1891 Some("timestamp" | "date_or_timestamp")
1892 ) {
1893 return Err(FeatureRuntimeError::InvalidContract(
1894 "input.not_before".into(),
1895 ));
1896 }
1897 if let (Some(end), Some(start)) = (
1898 input.get(field).and_then(Value::as_str),
1899 input.get(earlier).and_then(Value::as_str),
1900 ) && !end.is_empty()
1901 && !start.is_empty()
1902 && end < start
1903 {
1904 return Err(FeatureRuntimeError::InvalidInput(field.clone()));
1905 }
1906 }
1907 }
1908 if !raw.is_empty() {
1909 return Err(FeatureRuntimeError::ValidationReport(validation_report(
1910 action_id,
1911 vec!["undeclared_field".to_owned()],
1912 None,
1913 Some((
1914 "undeclared_field".to_owned(),
1915 "undeclared_field".to_owned(),
1916 "The request includes a field that is not part of the action contract.".to_owned(),
1917 )),
1918 )));
1919 }
1920 apply_input_normalization_hooks(input, hook_contract)
1921}
1922
1923fn declared_input_format_valid(
1925 format: Option<&str>,
1926 value: &str,
1927) -> Result<bool, FeatureRuntimeError> {
1928 match format {
1929 None => Ok(true),
1930 Some("email") => {
1931 Ok(value.is_empty() || super::json_commands::generated_surface_text_is_email(value))
1932 }
1933 Some("date") => Ok(value.is_empty()
1934 || value.len() == 10
1935 && pharos_runtime_database::operations::canonical_stored_timestamp(&format!(
1936 "{value}T00:00:00Z"
1937 ))
1938 .is_ok()),
1939 Some("date_or_timestamp") => Ok(declared_input_format_valid(Some("date"), value)?
1940 || declared_input_format_valid(Some("timestamp"), value)?),
1941 Some("timestamp") => Ok(value.is_empty()
1942 || value
1943 .as_bytes()
1944 .get(10)
1945 .is_some_and(|ch| matches!(ch, b'T' | b't'))
1946 && (value.ends_with(['Z', 'z'])
1947 || value
1948 .get(19..)
1949 .is_some_and(|tail| tail.contains(['+', '-'])))
1950 && pharos_runtime_database::operations::canonical_stored_timestamp(value).is_ok()),
1951 Some("utc_offset") => {
1952 let bytes = value.as_bytes();
1953 Ok(value.is_empty()
1954 || value == "UTC"
1955 || bytes.len() == 6
1956 && matches!(bytes[0], b'+' | b'-')
1957 && bytes[3] == b':'
1958 && [1, 2, 4, 5].into_iter().all(|i| bytes[i].is_ascii_digit())
1959 && value[1..3].parse::<u8>().is_ok_and(|hour| hour <= 23)
1960 && value[4..6].parse::<u8>().is_ok_and(|minute| minute <= 59))
1961 }
1962 Some("https_url") => {
1963 Ok(value.is_empty() || super::outbound_url::https_url(value).is_some())
1964 }
1965 Some("web_url") => Ok(value.is_empty() || super::outbound_url::web_url(value).is_some()),
1966 Some(_) => Err(FeatureRuntimeError::InvalidContract(
1967 "input.format".to_owned(),
1968 )),
1969 }
1970}
1971
1972#[cfg(not(target_os = "wasi"))]
1974fn guard_binding_rows<'a>(
1976 query_id: &str,
1977 value: &'a Value,
1978) -> Result<Vec<&'a Map<String, Value>>, FeatureRuntimeError> {
1979 match value {
1980 Value::Array(rows) => rows
1981 .iter()
1982 .map(|row| {
1983 row.as_object()
1984 .ok_or_else(|| FeatureRuntimeError::InvalidContract(query_id.to_owned()))
1985 })
1986 .collect(),
1987 Value::Object(row) => Ok(vec![row]),
1988 Value::Null => Ok(Vec::new()),
1989 _ => Err(FeatureRuntimeError::InvalidContract(query_id.to_owned())),
1990 }
1991}
1992
1993#[cfg(not(target_os = "wasi"))]
1995fn guard_bounds_satisfied(actual: i64, guard: &Value) -> bool {
1997 guard
1998 .get("minimum")
1999 .and_then(Value::as_i64)
2000 .is_none_or(|minimum| actual >= minimum)
2001 && guard
2002 .get("maximum")
2003 .and_then(Value::as_i64)
2004 .is_none_or(|maximum| actual <= maximum)
2005 && guard
2006 .get("equals")
2007 .and_then(Value::as_i64)
2008 .is_none_or(|expected| actual == expected)
2009}
2010
2011#[cfg(not(target_os = "wasi"))]
2013fn evaluate_guard_contract(
2015 action_id: &str,
2016 guard_contract: Option<&Value>,
2017 context: &FeatureRequestContext,
2018) -> Result<(), FeatureRuntimeError> {
2019 let Some(guards) = guard_contract
2020 .and_then(|contract| contract.get("guards"))
2021 .and_then(Value::as_array)
2022 else {
2023 return Ok(());
2024 };
2025 if guards.len() > 16 {
2026 return Err(FeatureRuntimeError::InvalidContract(format!(
2027 "{action_id}.guards"
2028 )));
2029 }
2030 for guard in guards {
2031 let guard_id = guard
2032 .get("guard_id")
2033 .and_then(Value::as_str)
2034 .unwrap_or("guard_failed");
2035 let kind = guard.get("kind").and_then(Value::as_str).ok_or_else(|| {
2036 FeatureRuntimeError::InvalidContract(format!("{action_id}.{guard_id}"))
2037 })?;
2038 let query_id = guard.get("query").and_then(Value::as_str).ok_or_else(|| {
2039 FeatureRuntimeError::InvalidContract(format!("{action_id}.{guard_id}"))
2040 })?;
2041 let binding = context
2042 .bindings
2043 .get(query_id)
2044 .ok_or_else(|| FeatureRuntimeError::MissingRecord(query_id.to_owned()))?;
2045 let rows = guard_binding_rows(query_id, binding)?;
2046 let add = guard
2047 .get("add")
2048 .map(|source| {
2049 resolve_value(source, context)?
2050 .and_then(|value| value.as_i64())
2051 .ok_or_else(|| {
2052 FeatureRuntimeError::InvalidContract(format!("{action_id}.{guard_id}.add"))
2053 })
2054 })
2055 .transpose()?
2056 .unwrap_or(0);
2057 let actual = match kind {
2058 "count" => i64::try_from(rows.len())
2059 .map_err(|_| {
2060 FeatureRuntimeError::InvalidContract(format!("{action_id}.{guard_id}"))
2061 })?
2062 .checked_add(add)
2063 .ok_or_else(|| FeatureRuntimeError::Conflict(format!("{action_id}.{guard_id}")))?,
2064 "sum" => {
2065 let field = sql_identifier(
2066 guard.get("field").and_then(Value::as_str).ok_or_else(|| {
2067 FeatureRuntimeError::InvalidContract(format!(
2068 "{action_id}.{guard_id}.field"
2069 ))
2070 })?,
2071 )?;
2072 rows.iter()
2073 .try_fold(add, |total, row| {
2074 row.get(&field)
2075 .and_then(Value::as_i64)
2076 .and_then(|value| total.checked_add(value))
2077 })
2078 .ok_or_else(|| {
2079 FeatureRuntimeError::Conflict(format!("{action_id}.{guard_id}"))
2080 })?
2081 }
2082 "exists" => i64::from(!rows.is_empty()),
2083 _ => {
2084 return Err(FeatureRuntimeError::InvalidContract(format!(
2085 "{action_id}.{guard_id}"
2086 )));
2087 }
2088 };
2089 let satisfied = if kind == "exists"
2090 && guard.get("minimum").is_none()
2091 && guard.get("maximum").is_none()
2092 && guard.get("equals").is_none()
2093 {
2094 actual == 1
2095 } else {
2096 guard_bounds_satisfied(actual, guard)
2097 };
2098 if !satisfied {
2099 let message = guard
2100 .get("message")
2101 .and_then(Value::as_str)
2102 .unwrap_or("The action was rejected by a declared business-rule guard.");
2103 return Err(FeatureRuntimeError::ValidationReport(validation_report(
2104 action_id,
2105 vec![guard_id.to_owned()],
2106 None,
2107 Some((guard_id.to_owned(), guard_id.to_owned(), message.to_owned())),
2108 )));
2109 }
2110 }
2111 Ok(())
2112}
2113
2114fn query_binding_value(
2116 query_id: &str,
2117 result_kind: &str,
2118 rows: &[Map<String, Value>],
2119) -> Result<Value, FeatureRuntimeError> {
2120 match result_kind {
2121 "many" => Ok(Value::Array(
2122 rows.iter().cloned().map(Value::Object).collect(),
2123 )),
2124 "optional" => Ok(rows
2125 .first()
2126 .cloned()
2127 .map(Value::Object)
2128 .unwrap_or(Value::Null)),
2129 _ => rows
2130 .first()
2131 .cloned()
2132 .map(Value::Object)
2133 .ok_or_else(|| FeatureRuntimeError::MissingRecord(query_id.to_owned())),
2134 }
2135}
2136
2137#[cfg(not(target_os = "wasi"))]
2138fn timestamp_before_seconds_utc(seconds_before_now: u64) -> String {
2139 let seconds = SystemTime::now()
2140 .duration_since(UNIX_EPOCH)
2141 .unwrap_or_default()
2142 .as_secs()
2143 .saturating_sub(seconds_before_now);
2144 super::chrono_like_timestamp(seconds)
2145}
2146
2147fn transaction_operations(
2150 transaction: &Value,
2151) -> Result<Vec<(String, Value)>, FeatureRuntimeError> {
2152 let object = transaction
2153 .as_object()
2154 .ok_or_else(|| FeatureRuntimeError::InvalidContract("transaction".to_owned()))?;
2155 if let Some(steps) = object.get("steps").and_then(Value::as_array) {
2156 if steps.is_empty() || steps.len() > 64 {
2157 return Err(FeatureRuntimeError::InvalidContract(
2158 "transaction.steps".to_owned(),
2159 ));
2160 }
2161 return steps
2162 .iter()
2163 .map(|step| {
2164 let operation = step.as_object().ok_or_else(|| {
2165 FeatureRuntimeError::InvalidContract("transaction.step".to_owned())
2166 })?;
2167 if operation.len() != 1 {
2168 return Err(FeatureRuntimeError::InvalidContract(
2169 "transaction.step".to_owned(),
2170 ));
2171 }
2172 let (kind, value) = operation.iter().next().expect("one operation");
2173 Ok((kind.clone(), value.clone()))
2174 })
2175 .collect();
2176 }
2177 Ok(object
2178 .iter()
2179 .filter(|(kind, _)| kind.as_str() != "idempotency")
2180 .map(|(kind, value)| (kind.clone(), value.clone()))
2181 .collect::<Vec<_>>())
2182}
2183
2184fn execute_feature_transaction(
2186 app: &AppRuntime,
2187 feature: &Value,
2188 transaction: &Value,
2189 guard_contract: Option<&Value>,
2190 context: FeatureRequestContext,
2191 action_id: &str,
2192) -> Result<(), FeatureRuntimeError> {
2193 logical::execute_logical_transaction(
2194 app,
2195 feature,
2196 transaction,
2197 guard_contract,
2198 context,
2199 action_id,
2200 )
2201}
2202
2203#[cfg(not(target_os = "wasi"))]
2205fn record_private_asset_authorization(
2207 app: &AppRuntime,
2208 declaration: &Value,
2209 actor_id: i64,
2210 protected_id: i64,
2211) -> Result<(), FeatureRuntimeError> {
2212 let Some(transaction) = declaration
2213 .get("on_authorized")
2214 .filter(|value| !value.is_null())
2215 else {
2216 return Ok(());
2217 };
2218 if actor_id <= 0 || protected_id <= 0 {
2219 return Err(FeatureRuntimeError::InvalidInput(
2220 "protected authorization".to_owned(),
2221 ));
2222 }
2223 let feature_id = declaration
2224 .get("feature_id")
2225 .and_then(Value::as_str)
2226 .ok_or_else(|| FeatureRuntimeError::InvalidContract("protected feature".to_owned()))?;
2227 let feature = load_feature(app, feature_id)?;
2228 let mut protected = Map::new();
2229 protected.insert("id".to_owned(), Value::from(protected_id));
2230 execute_feature_transaction(
2231 app,
2232 &feature,
2233 transaction,
2234 None,
2235 FeatureRequestContext {
2236 actor_id,
2237 protected,
2238 ..FeatureRequestContext::default()
2239 },
2240 "protected_asset_authorization",
2241 )
2242}
2243
2244fn execute_feature_queries(
2246 app: &AppRuntime,
2247 feature: &Value,
2248 query_ids: &[String],
2249 context: &FeatureRequestContext,
2250) -> Result<BTreeMap<String, Vec<Map<String, Value>>>, FeatureRuntimeError> {
2251 logical::execute_logical_queries(app, feature, query_ids, context)
2252}
2253
2254pub fn load_feature(app: &AppRuntime, feature_id: &str) -> Result<Value, FeatureRuntimeError> {
2283 let root = artifact_root(app)?;
2284 let document = load_json(&root.join("data/contracts/features.json"))?;
2285 document
2286 .get("features")
2287 .and_then(Value::as_array)
2288 .and_then(|features| {
2289 features.iter().find(|feature| {
2290 feature.get("feature_id").and_then(Value::as_str) == Some(feature_id)
2291 })
2292 })
2293 .cloned()
2294 .ok_or_else(|| FeatureRuntimeError::InvalidContract(feature_id.to_owned()))
2295}
2296
2297fn load_feature_action(app: &AppRuntime, action_id: &str) -> Result<Value, FeatureRuntimeError> {
2299 let root = artifact_root(app)?;
2300 let document = load_json(&root.join("data/contracts/actions.json"))?;
2301 document
2302 .get("actions")
2303 .and_then(Value::as_array)
2304 .and_then(|actions| {
2305 actions.iter().find(|action| {
2306 action.get("action_id").and_then(Value::as_str) == Some(action_id)
2307 && action.get("feature_id").and_then(Value::as_str).is_some()
2308 })
2309 })
2310 .cloned()
2311 .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))
2312}
2313
2314pub fn action_fallback_redirect(
2339 app: &AppRuntime,
2340 action_id: &str,
2341) -> Result<String, FeatureRuntimeError> {
2342 load_feature_action(app, action_id)?
2343 .get("response_contract")
2344 .and_then(|value| value.get("fallback"))
2345 .and_then(|value| value.get("redirect"))
2346 .and_then(Value::as_str)
2347 .map(ToOwned::to_owned)
2348 .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))
2349}
2350
2351pub fn execute_feature_action(
2382 app: &AppRuntime,
2383 route: &Value,
2384 request: &super::http::HttpRequest,
2385 actor_id: i64,
2386) -> Result<FeatureActionOutcome, FeatureRuntimeError> {
2387 let feature_id = route
2388 .get("feature_id")
2389 .and_then(Value::as_str)
2390 .ok_or_else(|| FeatureRuntimeError::InvalidContract("route.feature_id".to_owned()))?;
2391 let action_id = route
2392 .get("action_id")
2393 .and_then(Value::as_str)
2394 .ok_or_else(|| FeatureRuntimeError::InvalidContract("route.action_id".to_owned()))?;
2395 let feature = load_feature(app, feature_id)?;
2396 let action = load_feature_action(app, action_id)?;
2397 let mut media = media_input::prepare(
2398 app,
2399 request,
2400 action
2401 .get("input_contract")
2402 .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?,
2403 )?;
2404 let input = validated_action_input(
2405 &media.request,
2406 action
2407 .get("input_contract")
2408 .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?,
2409 action.get("hook_contract"),
2410 action_id,
2411 )?;
2412 let request_path = request.target.split('?').next().unwrap_or("/");
2413 let context = FeatureRequestContext {
2414 request_target: request.target.clone(),
2415 actor_id,
2416 input,
2417 path_params: path_parameters(
2418 route.get("path").and_then(Value::as_str).unwrap_or("/"),
2419 request_path,
2420 )?,
2421 query_params: query_parameters(&request.target)?,
2422 ..FeatureRequestContext::default()
2423 };
2424 let transaction = selected_contract_variant(
2425 action
2426 .get("transaction_contract")
2427 .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?,
2428 &context,
2429 )?;
2430 let response = selected_contract_variant(
2431 action
2432 .get("response_contract")
2433 .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?,
2434 &context,
2435 )?;
2436 let redirect_only_path =
2437 if response.get("response_kind").and_then(Value::as_str) == Some("redirect") {
2438 let redirect = response
2439 .get("fallback")
2440 .and_then(|value| value.get("redirect"))
2441 .and_then(Value::as_str)
2442 .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?;
2443 Some(action_redirect_path(redirect, &context.path_params)?)
2444 } else {
2445 None
2446 };
2447 execute_feature_transaction(
2448 app,
2449 &feature,
2450 transaction,
2451 action.get("guard_contract"),
2452 context.clone(),
2453 action_id,
2454 )?;
2455 media.commit();
2456 if let Some(redirect) = redirect_only_path {
2457 return Ok(FeatureActionOutcome {
2458 status: 200,
2459 fragment_html: String::new(),
2460 redirect,
2461 redirect_only: true,
2462 });
2463 }
2464 let query_ids = response
2465 .get("queries")
2466 .and_then(Value::as_array)
2467 .into_iter()
2468 .flatten()
2469 .map(|value| {
2470 value
2471 .as_str()
2472 .map(ToOwned::to_owned)
2473 .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))
2474 })
2475 .collect::<Result<Vec<_>, _>>()?;
2476 let params = render_query_params(app, &feature, &query_ids, &context)?;
2477 let template = response
2478 .get("template")
2479 .and_then(Value::as_str)
2480 .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?;
2481 let root = artifact_root(app)?;
2482 let template_path = super::features::safe_feature_template_path(&root, template)
2483 .ok_or_else(|| FeatureRuntimeError::InvalidContract(template.to_owned()))?;
2484 let fragment_html =
2485 super::json_commands::template_render_file(&template_path, &Value::Object(params))
2486 .ok_or_else(|| FeatureRuntimeError::RenderFailed(template.to_owned()))?;
2487 let redirect = response
2488 .get("fallback")
2489 .and_then(|value| value.get("redirect"))
2490 .and_then(Value::as_str)
2491 .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?
2492 .to_owned();
2493 Ok(FeatureActionOutcome {
2494 status: response
2495 .get("status")
2496 .and_then(Value::as_u64)
2497 .and_then(|status| u16::try_from(status).ok())
2498 .filter(|status| (200..=599).contains(status))
2499 .unwrap_or(200),
2500 fragment_html,
2501 redirect,
2502 redirect_only: false,
2503 })
2504}
2505
2506fn append_query_with_dependencies(
2508 queries: &Map<String, Value>,
2509 query_id: &str,
2510 output: &mut Vec<String>,
2511 visiting: &mut BTreeSet<String>,
2512) -> Result<(), FeatureRuntimeError> {
2513 if output.iter().any(|existing| existing == query_id) {
2514 return Ok(());
2515 }
2516 if !visiting.insert(query_id.to_owned()) {
2517 return Err(FeatureRuntimeError::InvalidContract(format!(
2518 "query dependency cycle: {query_id}"
2519 )));
2520 }
2521 let query = queries
2522 .get(query_id)
2523 .ok_or_else(|| FeatureRuntimeError::InvalidContract(query_id.to_owned()))?;
2524 let mut dependencies = BTreeSet::new();
2525 referenced_queries(query, &mut dependencies);
2526 for dependency in dependencies {
2527 if dependency != query_id {
2528 append_query_with_dependencies(queries, &dependency, output, visiting)?;
2529 }
2530 }
2531 visiting.remove(query_id);
2532 if !output.iter().any(|existing| existing == query_id) {
2533 output.push(query_id.to_owned());
2534 }
2535 Ok(())
2536}
2537
2538fn feature_json_payload_for_query(
2540 app: &AppRuntime,
2541 feature: &Value,
2542 payload_query: &str,
2543 context: &FeatureRequestContext,
2544) -> Result<Value, FeatureRuntimeError> {
2545 let queries = feature
2546 .get("queries")
2547 .and_then(Value::as_object)
2548 .ok_or_else(|| FeatureRuntimeError::InvalidContract("feature.queries".to_owned()))?;
2549 let mut query_ids = Vec::new();
2550 append_query_with_dependencies(queries, payload_query, &mut query_ids, &mut BTreeSet::new())?;
2551 let query_results = execute_feature_queries(app, feature, &query_ids, context)?;
2552 Ok(query_results
2553 .get(payload_query)
2554 .and_then(|rows| rows.first())
2555 .cloned()
2556 .map(Value::Object)
2557 .unwrap_or(Value::Null))
2558}
2559
2560pub fn execute_feature_json_action(
2561 app: &AppRuntime,
2562 route_path: &str,
2563 request: &super::http::HttpRequest,
2564 actor_id: i64,
2565 action_id: &str,
2566) -> Result<(u16, Value), FeatureRuntimeError> {
2567 let action = load_feature_action(app, action_id)?;
2568 let feature_id = action
2569 .get("feature_id")
2570 .and_then(Value::as_str)
2571 .ok_or_else(|| FeatureRuntimeError::InvalidContract(format!("{action_id}.feature_id")))?;
2572 let feature = load_feature(app, feature_id)?;
2573 let mut media = media_input::prepare(
2574 app,
2575 request,
2576 action
2577 .get("input_contract")
2578 .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?,
2579 )?;
2580 let input = validated_action_input(
2581 &media.request,
2582 action
2583 .get("input_contract")
2584 .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?,
2585 action.get("hook_contract"),
2586 action_id,
2587 )?;
2588 let request_path = request.target.split('?').next().unwrap_or("/");
2589 let context = FeatureRequestContext {
2590 request_target: request.target.clone(),
2591 actor_id,
2592 input,
2593 path_params: path_parameters(route_path, request_path)?,
2594 query_params: query_parameters(&request.target)?,
2595 ..FeatureRequestContext::default()
2596 };
2597 let transaction = selected_contract_variant(
2598 action
2599 .get("transaction_contract")
2600 .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?,
2601 &context,
2602 )?;
2603 let response = selected_contract_variant(
2604 action
2605 .get("response_contract")
2606 .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?,
2607 &context,
2608 )?;
2609 let payload_before = response
2610 .get("payload_query_before")
2611 .and_then(Value::as_str)
2612 .map(|query| feature_json_payload_for_query(app, &feature, query, &context))
2613 .transpose()?;
2614 execute_feature_transaction(
2615 app,
2616 &feature,
2617 transaction,
2618 action.get("guard_contract"),
2619 context.clone(),
2620 action_id,
2621 )?;
2622 media.commit();
2623 let status = response
2624 .get("status")
2625 .and_then(Value::as_u64)
2626 .and_then(|status| u16::try_from(status).ok())
2627 .filter(|status| (200..=599).contains(status))
2628 .unwrap_or(200);
2629 let surface = response
2630 .get("surface")
2631 .and_then(Value::as_str)
2632 .ok_or_else(|| FeatureRuntimeError::InvalidContract(format!("{action_id}.surface")))?;
2633 let payload = if let Some(payload) = payload_before {
2634 payload
2635 } else {
2636 let payload_query = response
2637 .get("payload_query")
2638 .and_then(Value::as_str)
2639 .ok_or_else(|| {
2640 FeatureRuntimeError::InvalidContract(format!("{action_id}.payload_query"))
2641 })?;
2642 feature_json_payload_for_query(app, &feature, payload_query, &context)?
2643 };
2644 Ok((
2645 status,
2646 serde_json::json!({
2647 "schema": "pharos.generated.api.item.v1",
2648 "surface": surface,
2649 "payload": payload
2650 }),
2651 ))
2652}
2653
2654pub fn query_parameters(target: &str) -> Result<Map<String, Value>, FeatureRuntimeError> {
2682 let query = target.split_once('?').map(|(_, query)| query).unwrap_or("");
2683 if query.len() > 8 * 1024 {
2684 return Err(FeatureRuntimeError::InvalidInput("query".to_owned()));
2685 }
2686 let mut values = Map::new();
2687 for pair in query.split('&').filter(|pair| !pair.is_empty()) {
2688 let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
2689 let key = super::json_commands::url_decode(key)
2690 .ok_or_else(|| FeatureRuntimeError::InvalidInput("query".to_owned()))?;
2691 let value = super::json_commands::url_decode(value)
2692 .ok_or_else(|| FeatureRuntimeError::InvalidInput(key.clone()))?;
2693 sql_identifier(&key)?;
2694 if values.len() >= MAX_QUERY_FILTERS
2695 || values.insert(key.clone(), Value::String(value)).is_some()
2696 {
2697 return Err(FeatureRuntimeError::InvalidInput(key));
2698 }
2699 }
2700 Ok(values)
2701}
2702
2703fn path_parameter_value(parameter: &str, decoded: &str) -> Value {
2732 if (parameter == "id" || parameter.ends_with("_id"))
2733 && decoded.len() <= 18
2734 && decoded.chars().all(|character| character.is_ascii_digit())
2735 && let Ok(value) = decoded.parse::<i64>()
2736 {
2737 return Value::from(value);
2738 }
2739 Value::String(decoded.to_owned())
2740}
2741
2742pub fn path_parameters(
2743 pattern: &str,
2744 path: &str,
2745) -> Result<Map<String, Value>, FeatureRuntimeError> {
2746 let pattern_parts = pattern.trim_matches('/').split('/').collect::<Vec<_>>();
2747 let path_parts = path.trim_matches('/').split('/').collect::<Vec<_>>();
2748 if pattern_parts.len() != path_parts.len() {
2749 return Err(FeatureRuntimeError::InvalidInput("path".to_owned()));
2750 }
2751 let mut values = Map::new();
2752 for (declared, actual) in pattern_parts.into_iter().zip(path_parts) {
2753 let parameter = declared.strip_prefix(':').or_else(|| {
2754 declared
2755 .strip_prefix('{')
2756 .and_then(|value| value.strip_suffix('}'))
2757 });
2758 if let Some(parameter) = parameter {
2759 sql_identifier(parameter)?;
2760 let decoded = super::json_commands::url_decode(actual)
2761 .ok_or_else(|| FeatureRuntimeError::InvalidInput(parameter.to_owned()))?;
2762 if decoded.is_empty() || decoded.len() > 256 || decoded.contains('/') {
2763 return Err(FeatureRuntimeError::InvalidInput(parameter.to_owned()));
2764 }
2765 values.insert(
2766 parameter.to_owned(),
2767 path_parameter_value(parameter, &decoded),
2768 );
2769 } else if declared != actual {
2770 return Err(FeatureRuntimeError::InvalidInput("path".to_owned()));
2771 }
2772 }
2773 Ok(values)
2774}
2775
2776fn missing_template_keys(template_path: &Path, params: &Map<String, Value>) -> Vec<String> {
2777 let Ok(template_text) = fs::read_to_string(template_path) else {
2778 return vec!["<template unreadable>".to_owned()];
2779 };
2780 let mut missing = Vec::new();
2781 let mut cursor = 0usize;
2782 while let Some(offset) = template_text[cursor..].find('{') {
2783 let open = cursor + offset;
2784 let Some(close_offset) = template_text[open + 1..].find('}') else {
2785 missing.push("<unclosed placeholder>".to_owned());
2786 break;
2787 };
2788 let close = open + 1 + close_offset;
2789 let raw = &template_text[open + 1..close];
2790 let key = raw.split_once(':').map_or(raw, |(_, key)| key);
2791 if !params.contains_key(key) {
2792 missing.push(key.to_owned());
2793 }
2794 cursor = close + 1;
2795 }
2796 missing.sort();
2797 missing.dedup();
2798 missing
2799}
2800
2801fn query_render_params(
2803 artifact_root: &Path,
2804 feature: &Value,
2805 results: &BTreeMap<String, Vec<Map<String, Value>>>,
2806 base_params: &Map<String, Value>,
2807) -> Result<Map<String, Value>, FeatureRuntimeError> {
2808 let mut rendered_results = results.clone();
2811 for rows in rendered_results.values_mut() {
2812 for row in rows {
2813 for value in row.values_mut() {
2814 if let Some(flag) = value.as_bool() {
2815 *value = Value::from(i64::from(flag));
2816 }
2817 }
2818 }
2819 }
2820 let results = &rendered_results;
2821 let queries = feature
2822 .get("queries")
2823 .and_then(Value::as_object)
2824 .ok_or_else(|| FeatureRuntimeError::InvalidContract("feature.queries".to_owned()))?;
2825 let mut params = base_params.clone();
2826 for (query_id, rows) in results {
2827 let query = queries
2828 .get(query_id)
2829 .ok_or_else(|| FeatureRuntimeError::InvalidContract(query_id.clone()))?;
2830 params.insert(format!("{query_id}_count"), Value::from(rows.len()));
2831 if let Some(aggregates) = query.get("aggregates").and_then(Value::as_object) {
2832 if aggregates.len() > 16 {
2833 return Err(FeatureRuntimeError::InvalidContract(format!(
2834 "{query_id}.aggregates"
2835 )));
2836 }
2837 for (output, aggregate) in aggregates {
2838 sql_identifier(output)?;
2839 let operation = aggregate
2840 .get("operation")
2841 .and_then(Value::as_str)
2842 .ok_or_else(|| {
2843 FeatureRuntimeError::InvalidContract(format!(
2844 "{query_id}.aggregates.{output}"
2845 ))
2846 })?;
2847 let value = match operation {
2848 "count" => i64::try_from(rows.len()).map_err(|_| {
2849 FeatureRuntimeError::InvalidContract(format!(
2850 "{query_id}.aggregates.{output}"
2851 ))
2852 })?,
2853 "sum" => {
2854 let field = sql_identifier(
2855 aggregate
2856 .get("field")
2857 .and_then(Value::as_str)
2858 .unwrap_or_default(),
2859 )?;
2860 rows.iter()
2861 .try_fold(0i64, |total, row| {
2862 row.get(&field)
2863 .and_then(Value::as_i64)
2864 .and_then(|value| total.checked_add(value))
2865 })
2866 .ok_or_else(|| {
2867 FeatureRuntimeError::Conflict(format!(
2868 "{query_id}.aggregates.{output}"
2869 ))
2870 })?
2871 }
2872 _ => {
2873 return Err(FeatureRuntimeError::InvalidContract(format!(
2874 "{query_id}.aggregates.{output}"
2875 )));
2876 }
2877 };
2878 params.insert(output.clone(), Value::from(value));
2879 }
2880 }
2881 if let Some(outputs) = query.get("outputs").and_then(Value::as_object) {
2882 let first = rows.first();
2883 for (output, field) in outputs {
2884 sql_identifier(output)?;
2885 let field = field.as_str().ok_or_else(|| {
2886 FeatureRuntimeError::InvalidContract(format!("{query_id}.outputs"))
2887 })?;
2888 params.insert(
2889 output.clone(),
2890 first
2891 .and_then(|row| row.get(field))
2892 .cloned()
2893 .unwrap_or(Value::Null),
2894 );
2895 }
2896 }
2897 }
2898 let scalar_params = params.clone();
2900 for (query_id, rows) in results {
2901 let query = queries
2902 .get(query_id)
2903 .ok_or_else(|| FeatureRuntimeError::InvalidContract(query_id.clone()))?;
2904 let Some(render) = query.get("render") else {
2905 continue;
2906 };
2907 let template = render
2908 .get("template")
2909 .and_then(Value::as_str)
2910 .ok_or_else(|| FeatureRuntimeError::InvalidContract(format!("{query_id}.render")))?;
2911 let output = sql_identifier(render.get("output").and_then(Value::as_str).ok_or_else(
2912 || FeatureRuntimeError::InvalidContract(format!("{query_id}.render.output")),
2913 )?)?;
2914 let template_path = super::features::safe_feature_template_path(artifact_root, template)
2915 .ok_or_else(|| FeatureRuntimeError::InvalidContract(template.to_owned()))?;
2916 let mut html = String::new();
2917 for row in rows {
2918 let mut row_params = scalar_params.clone();
2919 row_params.extend(row.clone());
2920 html.push_str(
2921 &super::json_commands::template_render_file(
2922 &template_path,
2923 &Value::Object(row_params.clone()),
2924 )
2925 .ok_or_else(|| {
2926 eprintln!(
2927 "feature row template render failed: template={} missing_keys={:?}",
2928 template,
2929 missing_template_keys(&template_path, &row_params)
2930 );
2931 FeatureRuntimeError::RenderFailed(template.to_owned())
2932 })?,
2933 );
2934 }
2935 if rows.is_empty()
2936 && let Some(empty_template) = render.get("empty_template").and_then(Value::as_str)
2937 {
2938 let empty_path =
2939 super::features::safe_feature_template_path(artifact_root, empty_template)
2940 .ok_or_else(|| {
2941 FeatureRuntimeError::InvalidContract(empty_template.to_owned())
2942 })?;
2943 html = super::json_commands::template_render_file(
2944 &empty_path,
2945 &Value::Object(scalar_params.clone()),
2946 )
2947 .ok_or_else(|| {
2948 eprintln!(
2949 "feature empty template render failed: template={} missing_keys={:?}",
2950 empty_template,
2951 missing_template_keys(&empty_path, &scalar_params)
2952 );
2953 FeatureRuntimeError::RenderFailed(empty_template.to_owned())
2954 })?;
2955 }
2956 params.insert(output, Value::String(html));
2957 }
2958 Ok(params)
2959}
2960
2961pub fn execute_feature_query_rows_json(
2988 app: &AppRuntime,
2989 feature_id: &str,
2990 query_ids: &[String],
2991 context: &FeatureRequestContext,
2992) -> Result<Value, FeatureRuntimeError> {
2993 if query_ids.len() > MAX_QUERY_FILTERS {
2994 return Err(FeatureRuntimeError::InvalidContract(
2995 "query count".to_owned(),
2996 ));
2997 }
2998 let feature = load_feature(app, feature_id)?;
2999 let mut results = None;
3000 for attempt in 0..40 {
3001 match execute_feature_queries(app, &feature, query_ids, context) {
3002 Ok(query_results) => {
3003 results = Some(query_results);
3004 break;
3005 }
3006 Err(FeatureRuntimeError::DatabaseUnavailable) if attempt < 39 => {
3007 std::thread::sleep(std::time::Duration::from_millis(50));
3008 }
3009 Err(error) => return Err(error),
3010 }
3011 }
3012 let results = results.ok_or(FeatureRuntimeError::DatabaseUnavailable)?;
3013 let mut root = Map::new();
3014 root.insert(
3015 "schema".to_owned(),
3016 Value::String("pharos.feature-query-result.v1".to_owned()),
3017 );
3018 root.insert(
3019 "feature_id".to_owned(),
3020 Value::String(feature_id.to_owned()),
3021 );
3022 let mut query_root = Map::new();
3023 for (query_id, rows) in results {
3024 query_root.insert(
3025 query_id,
3026 Value::Array(rows.into_iter().map(Value::Object).collect()),
3027 );
3028 }
3029 root.insert("queries".to_owned(), Value::Object(query_root));
3030 Ok(Value::Object(root))
3031}
3032
3033pub fn render_query_params(
3034 app: &AppRuntime,
3035 feature: &Value,
3036 query_ids: &[String],
3037 context: &FeatureRequestContext,
3038) -> Result<Map<String, Value>, FeatureRuntimeError> {
3039 if query_ids.len() > MAX_QUERY_FILTERS {
3040 return Err(FeatureRuntimeError::InvalidContract(
3041 "query count".to_owned(),
3042 ));
3043 }
3044 let mut results = None;
3045 for attempt in 0..40 {
3046 match execute_feature_queries(app, feature, query_ids, context) {
3047 Ok(query_results) => {
3048 results = Some(query_results);
3049 break;
3050 }
3051 Err(FeatureRuntimeError::DatabaseUnavailable) if attempt < 39 => {
3052 std::thread::sleep(std::time::Duration::from_millis(50));
3053 }
3054 Err(error) => return Err(error),
3055 }
3056 }
3057 let results = results.ok_or(FeatureRuntimeError::DatabaseUnavailable)?;
3058 let base_params = manifest_declared_template_params(app)?;
3059 query_render_params(&artifact_root(app)?, feature, &results, &base_params)
3060}
3061
3062pub fn render_feature_page_body_with_error(
3095 app: &AppRuntime,
3096 route: &Value,
3097 target: &str,
3098 actor_id: i64,
3099 csrf_token: &str,
3100 error_message: Option<&str>,
3101) -> Result<String, FeatureRuntimeError> {
3102 render_feature_page_body_with_error_and_params(
3103 app,
3104 route,
3105 target,
3106 actor_id,
3107 csrf_token,
3108 error_message,
3109 Map::new(),
3110 )
3111}
3112
3113pub fn render_feature_page_body_with_error_and_params(
3115 app: &AppRuntime,
3116 route: &Value,
3117 target: &str,
3118 actor_id: i64,
3119 csrf_token: &str,
3120 error_message: Option<&str>,
3121 extra_params: Map<String, Value>,
3122) -> Result<String, FeatureRuntimeError> {
3123 let feature_id = route
3124 .get("feature_id")
3125 .and_then(Value::as_str)
3126 .ok_or_else(|| FeatureRuntimeError::InvalidContract("route.feature_id".to_owned()))?;
3127 let page_id = route
3128 .get("page_id")
3129 .and_then(Value::as_str)
3130 .ok_or_else(|| FeatureRuntimeError::InvalidContract("route.page_id".to_owned()))?;
3131 let feature = load_feature(app, feature_id)?;
3132 let query_ids = route
3133 .get("queries")
3134 .and_then(Value::as_array)
3135 .into_iter()
3136 .flatten()
3137 .map(|value| {
3138 value
3139 .as_str()
3140 .map(ToOwned::to_owned)
3141 .ok_or_else(|| FeatureRuntimeError::InvalidContract("route.queries".to_owned()))
3142 })
3143 .collect::<Result<Vec<_>, _>>()?;
3144 let request_path = target.split('?').next().unwrap_or("/");
3145 let context = FeatureRequestContext {
3146 actor_id,
3147 path_params: path_parameters(
3148 route.get("path").and_then(Value::as_str).unwrap_or("/"),
3149 request_path,
3150 )?,
3151 request_target: target.to_owned(),
3152 query_params: query_parameters(target)?,
3153 ..FeatureRequestContext::default()
3154 };
3155 let root = artifact_root(app)?;
3156 let mut params = render_query_params(app, &feature, &query_ids, &context)?;
3157 params.insert(
3158 "pharos_idempotency_key".to_owned(),
3159 Value::String(
3160 super::dynamic::random_secret_hex(24)
3161 .map_err(|_| FeatureRuntimeError::RenderFailed(page_id.to_owned()))?,
3162 ),
3163 );
3164 if page_id == "login" {
3165 let next_path = context
3166 .query_params
3167 .get("next")
3168 .and_then(Value::as_str)
3169 .unwrap_or_default();
3170 let auth_params =
3171 super::json_commands::template_login_page_json(&root, error_message, Some(next_path))
3172 .and_then(|json| serde_json::from_str::<Value>(&json).ok())
3173 .and_then(|value| value.as_object().cloned())
3174 .ok_or_else(|| FeatureRuntimeError::RenderFailed(page_id.to_owned()))?;
3175 for (key, value) in auth_params {
3176 params.insert(key, value);
3177 }
3178 }
3179 if let Some(state_root) = payment_provider_render_state_root(app, request_path)? {
3180 for (key, value) in super::payment_provider::payment_provider_render_params(
3181 &state_root,
3182 &root,
3183 request_path,
3184 ) {
3185 params.insert(key, value);
3186 }
3187 }
3188 for (key, value) in extra_params {
3189 params.insert(key, value);
3190 }
3191 let page_relative = super::json_commands::declared_page_relative_path(&root, page_id)
3192 .ok_or_else(|| FeatureRuntimeError::InvalidContract(page_id.to_owned()))?;
3193 let page_spec = load_json(&root.join(page_relative))?;
3194 let template = page_spec
3195 .get("template_file")
3196 .and_then(Value::as_str)
3197 .ok_or_else(|| FeatureRuntimeError::InvalidContract(page_id.to_owned()))?;
3198 let template_path = super::features::safe_feature_template_path(&root, template)
3199 .ok_or_else(|| FeatureRuntimeError::InvalidContract(template.to_owned()))?;
3200 let body =
3201 super::json_commands::template_render_file(&template_path, &Value::Object(params.clone()))
3202 .ok_or_else(|| {
3203 eprintln!(
3204 "feature page template render failed: page={} template={} missing_keys={:?}",
3205 page_id,
3206 template,
3207 missing_template_keys(&template_path, ¶ms)
3208 );
3209 FeatureRuntimeError::RenderFailed(template.to_owned())
3210 })?;
3211 let body = super::json_commands::template_apply_base_path(&body, app.base_path.as_deref())
3212 .ok_or_else(|| FeatureRuntimeError::RenderFailed(page_id.to_owned()))?;
3213 Ok(super::dynamic::csrf_inject_hidden_inputs(
3214 &root, &body, csrf_token,
3215 ))
3216}
3217
3218pub fn render_feature_page_body(
3246 app: &AppRuntime,
3247 route: &Value,
3248 target: &str,
3249 actor_id: i64,
3250 csrf_token: &str,
3251) -> Result<String, FeatureRuntimeError> {
3252 render_feature_page_body_with_error(app, route, target, actor_id, csrf_token, None)
3253}
3254
3255fn role_fragment_key(role: &str) -> Option<String> {
3256 if role.is_empty()
3257 || role.len() > 64
3258 || !role
3259 .chars()
3260 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
3261 {
3262 return None;
3263 }
3264 Some(format!("{role}_nav_html"))
3265}
3266
3267fn role_nav_fragment_html(root: &Path, role: &str) -> String {
3268 let Some(key) = role_fragment_key(role) else {
3269 return String::new();
3270 };
3271 let Some(role_stem) = key.strip_suffix("_html") else {
3272 return String::new();
3273 };
3274 let fragment_path = root
3275 .join("templates/layout/fragments")
3276 .join(format!("{role_stem}.html"));
3277 if !fragment_path.is_file() {
3278 return String::new();
3279 }
3280 super::json_commands::template_render_file(&fragment_path, &serde_json::json!({}))
3281 .unwrap_or_default()
3282}
3283
3284fn layout_role_nav_params(layout_path: &Path) -> serde_json::Map<String, Value> {
3285 let mut params = serde_json::Map::new();
3286 let Ok(layout) = fs::read_to_string(layout_path) else {
3287 return params;
3288 };
3289 for segment in layout.split("{html:").skip(1) {
3290 let Some((key, _)) = segment.split_once('}') else {
3291 continue;
3292 };
3293 if key.ends_with("_nav_html")
3294 && key
3295 .chars()
3296 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
3297 {
3298 params.insert(key.to_owned(), Value::String(String::new()));
3299 }
3300 }
3301 params
3302}
3303
3304fn insert_actor_role_nav_params(
3305 app: &AppRuntime,
3306 root: &Path,
3307 actor_id: i64,
3308 params: &mut serde_json::Map<String, Value>,
3309) -> Result<(), FeatureRuntimeError> {
3310 if actor_id <= 0 {
3311 return Ok(());
3312 }
3313 if params.contains_key("authenticated_user_nav_html") {
3314 params.insert(
3315 "authenticated_user_nav_html".to_owned(),
3316 Value::String(role_nav_fragment_html(root, "authenticated_user")),
3317 );
3318 }
3319 for role in actor_roles(app, actor_id).map_err(logical::map_database_error)? {
3320 if let Some(key) = role_fragment_key(&role)
3321 && params.contains_key(&key)
3322 {
3323 params.insert(key, Value::String(role_nav_fragment_html(root, &role)));
3324 }
3325 }
3326 Ok(())
3327}
3328
3329pub fn render_feature_page_html_with_error(
3359 app: &AppRuntime,
3360 route: &Value,
3361 target: &str,
3362 actor_id: i64,
3363 has_user: bool,
3364 csrf_token: &str,
3365 error_message: Option<&str>,
3366) -> Result<String, FeatureRuntimeError> {
3367 let feature_id = route
3368 .get("feature_id")
3369 .and_then(Value::as_str)
3370 .ok_or_else(|| FeatureRuntimeError::InvalidContract("route.feature_id".to_owned()))?;
3371 let page_id = route
3372 .get("page_id")
3373 .and_then(Value::as_str)
3374 .ok_or_else(|| FeatureRuntimeError::InvalidContract("route.page_id".to_owned()))?;
3375 let body = render_feature_page_body_with_error(
3376 app,
3377 route,
3378 target,
3379 actor_id,
3380 csrf_token,
3381 error_message,
3382 )?;
3383 let root = artifact_root(app)?;
3384 let page_relative = super::json_commands::declared_page_relative_path(&root, page_id)
3385 .ok_or_else(|| FeatureRuntimeError::InvalidContract(page_id.to_owned()))?;
3386 let page_spec = load_json(&root.join(page_relative))?;
3387 let title = page_spec
3388 .get("title")
3389 .and_then(Value::as_str)
3390 .unwrap_or(page_id);
3391 let layout_id = route
3392 .get("layout_id")
3393 .and_then(Value::as_str)
3394 .or_else(|| page_spec.get("layout_id").and_then(Value::as_str));
3395 if layout_id == Some("architect_shell") {
3396 let html = super::json_commands::template_layout_page_html(
3397 &root,
3398 app.base_path.as_deref(),
3399 layout_id,
3400 Some("en"),
3401 title,
3402 &body,
3403 Some(target),
3404 has_user,
3405 if super::actor_has_route_policy(app, actor_id, "manage_llm_vendors")
3406 .map_err(logical::map_database_error)?
3407 {
3408 1
3409 } else {
3410 0
3411 },
3412 )
3413 .ok_or_else(|| FeatureRuntimeError::RenderFailed(page_id.to_owned()))?;
3414 return Ok(super::dynamic::csrf_inject_hidden_inputs(
3415 &root, &html, csrf_token,
3416 ));
3417 }
3418 let _ = (feature_id, layout_id, target);
3419 let layout_path = root.join("templates/layout/base.html");
3420 let mut layout_params = serde_json::Map::from_iter([
3421 ("current_lang".to_owned(), Value::String("en".to_owned())),
3422 (
3423 "title".to_owned(),
3424 Value::String(super::json_commands::layout_browser_title(&root, title)),
3425 ),
3426 ("body".to_owned(), Value::String(body)),
3427 ("has_user".to_owned(), Value::Bool(has_user)),
3428 ]);
3429 for (key, value) in layout_role_nav_params(&layout_path) {
3430 layout_params.insert(key, value);
3431 }
3432 insert_actor_role_nav_params(app, &root, actor_id, &mut layout_params)?;
3433 let rendered =
3434 super::json_commands::template_render_file(&layout_path, &Value::Object(layout_params))
3435 .ok_or_else(|| FeatureRuntimeError::RenderFailed(page_id.to_owned()))?;
3436 let html = super::json_commands::template_apply_base_path(&rendered, app.base_path.as_deref())
3437 .ok_or_else(|| FeatureRuntimeError::RenderFailed(page_id.to_owned()))?;
3438 Ok(super::dynamic::csrf_inject_hidden_inputs(
3439 &root, &html, csrf_token,
3440 ))
3441}
3442
3443pub fn render_feature_page_html(
3472 app: &AppRuntime,
3473 route: &Value,
3474 target: &str,
3475 actor_id: i64,
3476 has_user: bool,
3477 csrf_token: &str,
3478) -> Result<String, FeatureRuntimeError> {
3479 render_feature_page_html_with_error(app, route, target, actor_id, has_user, csrf_token, None)
3480}
3481
3482#[cfg(test)]
3483mod tests {
3484 #[test]
3486 fn relation_projection_and_membership_are_bounded_and_typed() {
3487 use serde_json::json;
3488 let mut context = super::FeatureRequestContext::default();
3489 context.bindings.insert(
3490 "links".into(),
3491 json!([{"id":1,"private":"not selected"},{"id":2,"private":"not selected"}]),
3492 );
3493 let before =
3494 crate::runtime::runtime_timestamp_seconds(&crate::runtime::current_timestamp_utc())
3495 .unwrap()
3496 .saturating_sub(30 * 86400);
3497 let cutoff =
3498 super::resolve_value(&json!({"server_time_offset_seconds":-2592000}), &context)
3499 .unwrap()
3500 .unwrap();
3501 let after =
3502 crate::runtime::runtime_timestamp_seconds(&crate::runtime::current_timestamp_utc())
3503 .unwrap()
3504 .saturating_sub(30 * 86400);
3505 let actual = crate::runtime::runtime_timestamp_seconds(cutoff.as_str().unwrap()).unwrap();
3506 assert!((before..=after).contains(&actual));
3507 for invalid in [
3508 json!({"server_time_offset_seconds":"-2592000"}),
3509 json!({"server_time_offset_seconds":i64::MIN}),
3510 json!({"server_time_offset_seconds":0,"extra":true}),
3511 ] {
3512 assert!(super::resolve_value(&invalid, &context).is_err());
3513 }
3514 let expression = json!({"pluck":{"source":"query.links","field":"id"}});
3515 assert_eq!(
3516 super::resolve_value(&expression, &context).unwrap(),
3517 Some(json!([1, 2]))
3518 );
3519 let schema=serde_json::from_value(json!({"app_id":"relation_test","entities":[{"name":"record","identity":{"auto_integer":"id"},"fields":[{"name":"id","kind":"integer","nullable":false,"constraints":[]}],"unique":[],"indexes":[],"conditional_indexes":[]}]})).unwrap();
3520 let filters = json!({"id":{"operator":"in","value":expression}});
3521 assert!(
3522 super::logical::predicate_for_filters(filters.as_object(), &context, &schema, "record")
3523 .unwrap()
3524 .is_some()
3525 );
3526 context.bindings.insert("links".into(), json!([]));
3527 assert_eq!(
3528 super::resolve_value(&expression, &context).unwrap(),
3529 Some(json!([]))
3530 );
3531 assert!(
3532 super::logical::predicate_for_filters(filters.as_object(), &context, &schema, "record")
3533 .unwrap()
3534 .is_some()
3535 );
3536 context
3537 .bindings
3538 .insert("links".into(), json!([{"id":"1 OR 1=1"}]));
3539 assert!(
3540 super::logical::predicate_for_filters(filters.as_object(), &context, &schema, "record")
3541 .is_err()
3542 );
3543 context
3544 .bindings
3545 .insert("links".into(), json!([{"unrelated":1}]));
3546 assert!(super::resolve_value(&expression, &context).is_err());
3547 context
3548 .bindings
3549 .insert("links".into(), json!(vec![json!({"id":1}); 65]));
3550 assert!(super::resolve_value(&expression, &context).is_err());
3551 for offset in ["UTC", "+14:00", "-05:30"] {
3552 assert!(super::declared_input_format_valid(Some("utc_offset"), offset).unwrap());
3553 }
3554 let choice =
3555 json!({"lookup":{"key":"query_param.type","values":{"DJ":["DJ","dj"],"all":null}}});
3556 context.query_params.insert("type".into(), json!("DJ"));
3557 assert_eq!(
3558 super::resolve_value(&choice, &context).unwrap(),
3559 Some(json!(["DJ", "dj"]))
3560 );
3561 context.query_params.insert("type".into(), json!("all"));
3562 assert_eq!(super::resolve_value(&choice, &context).unwrap(), None);
3563 context
3564 .query_params
3565 .insert("type".into(), json!("unrecognized"));
3566 assert!(super::resolve_value(&choice, &context).is_err());
3567 for offset in ["+24:00", "+01:99", "Europe/Unknown", "+é:10"] {
3568 assert!(!super::declared_input_format_valid(Some("utc_offset"), offset).unwrap());
3569 }
3570 }
3571 #[test]
3573 fn date_or_timestamp_retains_day_precision() {
3574 use serde_json::json;
3575 let contract = json!({"inline_fields":{"start":{"type":"string","format":"date_or_timestamp","required":true},"end":{"type":"string","format":"date_or_timestamp","not_before":"start"}}});
3576 let mut request = crate::runtime::http::HttpRequest {
3577 method: "POST".into(),
3578 content_type: Some("application/json".into()),
3579 ..Default::default()
3580 };
3581 for value in ["2026-10-17", "2026-10-17T20:00:00Z"] {
3582 request.body = serde_json::to_vec(&json!({"start":value,"end":""})).unwrap();
3583 let parsed = super::validated_action_input(&request, &contract, None, "save").unwrap();
3584 assert!(
3585 parsed["start"]
3586 .as_str()
3587 .unwrap()
3588 .starts_with(&value[..value.len().min(19)])
3589 );
3590 }
3591 for value in ["2026-02-30", "2026-10-17T20:00:00", "not a date"] {
3592 assert!(!super::declared_input_format_valid(Some("date_or_timestamp"), value).unwrap());
3593 }
3594 request.body =
3595 serde_json::to_vec(&json!({"start":"2026-10-17","end":"2026-10-16"})).unwrap();
3596 assert!(super::validated_action_input(&request, &contract, None, "save").is_err());
3597 }
3598
3599 #[test]
3601 fn timestamp_inputs_require_zones_and_ordered_end_times() {
3602 for invalid in ["2027-01-01 20:00:00", "1800000000", "2027-02-30T20:00:00Z"] {
3603 assert!(!super::declared_input_format_valid(Some("timestamp"), invalid).unwrap());
3604 }
3605 let mut request = crate::runtime::http::HttpRequest {
3606 method: "POST".into(),
3607 target: "/save".into(),
3608 content_type: Some("application/json".into()),
3609 ..Default::default()
3610 };
3611 let contract = serde_json::json!({"inline_fields":{"start":{"type":"string","format":"timestamp","required":true},"end":{"type":"string","format":"timestamp","not_before":"start"}}});
3612 request.body = serde_json::to_vec(
3613 &serde_json::json!({"start":"2027-01-01T20:00:00+01:00","end":"2027-01-01T20:00:00Z"}),
3614 )
3615 .unwrap();
3616 let parsed = super::validated_action_input(&request, &contract, None, "save").unwrap();
3617 assert!(
3618 parsed["start"]
3619 .as_str()
3620 .unwrap()
3621 .starts_with("2027-01-01T19:00:00")
3622 );
3623 request.body = serde_json::to_vec(
3624 &serde_json::json!({"start":"2027-01-01T20:00:00Z","end":"2027-01-01T19:00:00Z"}),
3625 )
3626 .unwrap();
3627 assert!(super::validated_action_input(&request, &contract, None, "save").is_err());
3628 request.body =
3629 serde_json::to_vec(&serde_json::json!({"start":"2027-01-01T20:00:00","end":""}))
3630 .unwrap();
3631 assert!(super::validated_action_input(&request, &contract, None, "save").is_err());
3632 }
3633 #[test]
3635 fn declared_large_text_admits_biographies_with_fixed_request_and_field_bounds() {
3636 let mut request = crate::runtime::http::HttpRequest {
3637 method: "POST".into(),
3638 target: "/save".into(),
3639 content_type: Some("application/json".into()),
3640 ..Default::default()
3641 };
3642 request.body =
3643 serde_json::to_vec(&serde_json::json!({"biography":"a".repeat(9000)})).unwrap();
3644 let mut contract =
3645 serde_json::json!({"inline_fields":{"biography":{"type":"string","max_length":32768}}});
3646 assert!(super::validated_action_input(&request, &contract, None, "save").is_err());
3647 contract["inline_fields"]["biography"]["large_text"] = serde_json::json!(true);
3648 assert_eq!(
3649 super::validated_action_input(&request, &contract, None, "save").unwrap()["biography"]
3650 .as_str()
3651 .unwrap()
3652 .len(),
3653 9000
3654 );
3655 request.body =
3656 serde_json::to_vec(&serde_json::json!({"biography":"a".repeat(32769)})).unwrap();
3657 assert!(super::validated_action_input(&request, &contract, None, "save").is_err());
3658 }
3659 #[test]
3661 fn declared_contact_formats_reject_unsafe_links_and_bad_contacts() {
3662 assert!(super::declared_input_format_valid(Some("email"), "person@example.org").unwrap());
3663 assert!(!super::declared_input_format_valid(Some("email"), "not-an-email").unwrap());
3664 assert!(super::declared_input_format_valid(Some("email"), "").unwrap());
3665 assert!(
3666 super::declared_input_format_valid(Some("https_url"), "https://events.example/path")
3667 .unwrap()
3668 );
3669 assert!(
3670 !super::declared_input_format_valid(Some("https_url"), "javascript:alert(1)").unwrap()
3671 );
3672 assert!(
3673 !super::declared_input_format_valid(
3674 Some("https_url"),
3675 "https://secret@example.org/path"
3676 )
3677 .unwrap()
3678 );
3679 assert!(super::declared_input_format_valid(Some("unknown"), "").is_err());
3680 }
3681
3682 use serde_json::Value;
3683
3684 use super::{
3685 FeatureRequestContext, FeatureRuntimeError, action_redirect_path, execute_feature_action,
3686 manifest_declared_template_params, path_parameters, query_parameters,
3687 };
3688
3689 #[test]
3691 fn bounded_site_value_sources_preserve_typed_defaults_and_money() {
3692 let mut context = FeatureRequestContext {
3693 request_target: "/".into(),
3694 actor_id: 1,
3695 input: Default::default(),
3696 path_params: Default::default(),
3697 query_params: Default::default(),
3698 bindings: Default::default(),
3699 row: Default::default(),
3700 generated: Default::default(),
3701 protected: Default::default(),
3702 };
3703 let source = serde_json::json!({"coalesce":["path.table_id",1]});
3704 assert_eq!(
3705 super::resolve_value(&source, &context).unwrap(),
3706 Some(Value::from(1))
3707 );
3708 context
3709 .path_params
3710 .insert("table_id".into(), Value::from(2));
3711 assert_eq!(
3712 super::resolve_value(&source, &context).unwrap(),
3713 Some(Value::from(2))
3714 );
3715 context
3716 .path_params
3717 .insert("table_id".into(), Value::from("invalid"));
3718 assert_eq!(
3719 super::resolve_value(&source, &context).unwrap(),
3720 Some(Value::from("invalid"))
3721 );
3722 assert_eq!(
3723 super::resolve_value(&serde_json::json!({"add":[40,-1]}), &context).unwrap(),
3724 Some(Value::from(39))
3725 );
3726 assert!(super::resolve_value(&serde_json::json!({"add":[i64::MAX,1]}), &context).is_err());
3727 assert!(
3728 super::resolve_value(&serde_json::json!({"add":["untrusted",1]}), &context).is_err()
3729 );
3730 for (currency, expected) in [("USD", "$12.50"), ("JPY", "Â¥1250")] {
3731 assert_eq!(
3732 super::resolve_value(
3733 &serde_json::json!({"format_minor":{"amount":1250,"currency":currency}}),
3734 &context
3735 )
3736 .unwrap(),
3737 Some(Value::from(expected))
3738 );
3739 assert_eq!(
3740 crate::runtime::json_commands::render_template_text_with_default_kind(
3741 "{currency_minor:amount}",
3742 &serde_json::json!({"amount":1250,"currency":currency}),
3743 "text"
3744 )
3745 .as_deref(),
3746 Some(expected)
3747 );
3748 }
3749 assert!(
3750 super::resolve_value(
3751 &serde_json::json!({"format_minor":{"amount":-1,"currency":"USD"}}),
3752 &context
3753 )
3754 .is_err()
3755 );
3756 let mut nested = Value::from(1);
3757 for _ in 0..33 {
3758 nested = serde_json::json!({"coalesce":[nested]});
3759 }
3760 assert!(super::resolve_value(&nested, &context).is_err());
3761 }
3762
3763 #[test]
3765 fn feature_rows_receive_cross_query_scalar_outputs() {
3766 let root = test_root("query-output-alias");
3767 std::fs::create_dir_all(root.join("templates")).unwrap();
3768 std::fs::write(
3769 root.join("templates/alias_row.html"),
3770 "{current_table_id}:{title}",
3771 )
3772 .unwrap();
3773 std::fs::write(
3774 root.join("templates/alias_empty.html"),
3775 "{current_table_id}",
3776 )
3777 .unwrap();
3778 let feature = serde_json::json!({"queries": {
3779 "a_items":{"render":{"template":"templates/alias_row.html","empty_template":"templates/alias_empty.html","output":"item_rows"}},
3780 "z_table":{"outputs":{"current_table_id":"id"}}
3781 }});
3782 let mut results = std::collections::BTreeMap::from([
3783 (
3784 "a_items".into(),
3785 vec![serde_json::Map::from_iter([(
3786 "title".into(),
3787 Value::from("Pizza"),
3788 )])],
3789 ),
3790 (
3791 "z_table".into(),
3792 vec![serde_json::Map::from_iter([("id".into(), Value::from(2))])],
3793 ),
3794 ]);
3795 let params =
3796 super::query_render_params(&root, &feature, &results, &Default::default()).unwrap();
3797 assert_eq!(params["item_rows"], "2:Pizza");
3798 results.insert("a_items".into(), vec![]);
3799 let params =
3800 super::query_render_params(&root, &feature, &results, &Default::default()).unwrap();
3801 assert_eq!(params["item_rows"], "2");
3802 std::fs::remove_dir_all(root).unwrap();
3803 }
3804
3805 #[test]
3806 fn declared_redirect_keeps_path_parameter_in_one_segment() {
3808 let parameters = serde_json::Map::from_iter([(
3809 "business_slug".to_owned(),
3810 Value::String("one?two".to_owned()),
3811 )]);
3812 assert_eq!(
3813 action_redirect_path("/businesses/:business_slug/services/", ¶meters)
3814 .expect("encoded redirect"),
3815 "/businesses/one%3Ftwo/services/"
3816 );
3817 assert!(action_redirect_path("/businesses/:missing/", ¶meters).is_err());
3818 let traversal = serde_json::Map::from_iter([(
3819 "business_slug".to_owned(),
3820 Value::String("..".to_owned()),
3821 )]);
3822 assert!(action_redirect_path("/businesses/:business_slug/", &traversal).is_err());
3823 }
3824
3825 fn test_root(label: &str) -> std::path::PathBuf {
3827 let nonce = std::time::SystemTime::now()
3828 .duration_since(std::time::UNIX_EPOCH)
3829 .expect("clock")
3830 .as_nanos();
3831 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3832 .join("tmp")
3833 .join(format!(
3834 "pharos-feature-runtime-{label}-{}-{nonce}",
3835 std::process::id()
3836 ))
3837 }
3838
3839 fn write(root: &std::path::Path, relative: &str, content: &str) {
3841 let path = root.join(relative);
3842 std::fs::create_dir_all(path.parent().expect("parent")).expect("directories");
3843 std::fs::write(path, content).expect("fixture");
3844 if relative == "data/contracts/entities.json" {
3845 write_logical_test_contract(root, content);
3846 }
3847 }
3848
3849 fn initialize_logical_test_records(root: &std::path::Path, records: &Value) -> i32 {
3851 let contract = pharos_runtime_database::contract::load_contract(
3852 &root.join("data/contracts/database.json"),
3853 "feature_test",
3854 )
3855 .expect("logical feature test contract");
3856 let store = super::super::database::logical_app_store(root, None, "feature_test")
3857 .expect("feature test operation store");
3858 store
3859 .adapter
3860 .apply_contract(&store.config, &contract)
3861 .expect("apply feature schema");
3862 let records: Vec<pharos_runtime_database::operations::Record> =
3863 serde_json::from_value(records.clone()).expect("logical feature seeds");
3864 if !records.is_empty() {
3865 store
3866 .adapter
3867 .import_batch(&store.config, &store.schema, &records)
3868 .expect("feature seed records");
3869 }
3870 0
3871 }
3872
3873 fn logical_test_value(
3875 root: &std::path::Path,
3876 entity: &str,
3877 field: &str,
3878 filter: Option<pharos_runtime_database::operations::Predicate>,
3879 ) -> Option<String> {
3880 use pharos_runtime_database::operations::{Aggregate, AggregateQuery, Query};
3881 let store = super::super::database::logical_app_store(root, None, "feature_test")
3882 .expect("feature test operation store");
3883 let value = if field == "COUNT(*)" {
3884 store
3885 .adapter
3886 .aggregate(
3887 &store.config,
3888 &store.schema,
3889 &AggregateQuery {
3890 entity: entity.to_owned(),
3891 filter,
3892 aggregate: Aggregate::Count,
3893 },
3894 )
3895 .ok()?
3896 } else {
3897 store
3898 .adapter
3899 .query(
3900 &store.config,
3901 &store.schema,
3902 &Query {
3903 entity: entity.to_owned(),
3904 fields: vec![field.to_owned()],
3905 filter,
3906 order: Vec::new(),
3907 limit: 1,
3908 offset: 0,
3909 },
3910 )
3911 .ok()?
3912 .first()?
3913 .get(field)?
3914 .clone()
3915 };
3916 Some(
3917 value
3918 .as_str()
3919 .map(ToOwned::to_owned)
3920 .unwrap_or_else(|| value.to_string()),
3921 )
3922 }
3923
3924 fn write_logical_test_contract(root: &std::path::Path, content: &str) {
3926 use pharos_runtime_database::contract::{
3927 AppDataContract, migration_checksum, schema_from_entity_catalog,
3928 };
3929 use pharos_runtime_database::operations::{Migration, SchemaChange};
3930 let mut document: Value = serde_json::from_str(content).expect("entity catalog");
3931 for entity in document["entities"].as_array_mut().expect("entities") {
3932 for field in entity["fields"].as_array_mut().expect("fields") {
3933 let name = field["field_id"].as_str().expect("field name").to_owned();
3934 let kind = match name.as_str() {
3935 "id" | "user_id" | "cart_id" | "product_id" | "price_minor"
3936 | "available_quantity" => "integer",
3937 "added_at" | "reserved_at" | "released_at" | "confirmed_at" => "timestamp",
3938 "title" | "slug" | "status" | "reservation_status" | "release_reason" => "text",
3939 _ => panic!("missing logical test field declaration: {name}"),
3940 };
3941 let nullable = matches!(name.as_str(), "released_at" | "confirmed_at");
3942 field["field_type"] = Value::String(kind.to_owned());
3943 field["nullable"] = Value::Bool(nullable);
3944 if name == "release_reason" {
3945 field["field_default"] = serde_json::json!({"literal":""});
3946 }
3947 }
3948 entity["unique_keys"] = match entity["entity_id"].as_str().expect("entity name") {
3949 "cart" => serde_json::json!([["user_id"]]),
3950 "cart_item" => serde_json::json!([["cart_id", "product_id"]]),
3951 _ => serde_json::json!([]),
3952 };
3953 }
3954 let names = document["entities"]
3955 .as_array()
3956 .expect("entities")
3957 .iter()
3958 .map(|entity| entity["entity_id"].as_str().expect("entity name"))
3959 .collect::<Vec<_>>();
3960 let schema =
3961 schema_from_entity_catalog(&document, "feature_test", &names).expect("logical schema");
3962 let changes = schema
3963 .entities
3964 .iter()
3965 .cloned()
3966 .map(SchemaChange::CreateEntity)
3967 .collect::<Vec<_>>();
3968 let migration = Migration {
3969 app_id: "feature_test".to_owned(),
3970 id: "feature_schema".to_owned(),
3971 checksum: migration_checksum("feature_test", "feature_schema", &changes)
3972 .expect("checksum"),
3973 changes,
3974 };
3975 let contract = AppDataContract {
3976 schema,
3977 migrations: vec![migration],
3978 legacy_adoption: None,
3979 };
3980 std::fs::write(
3981 root.join("data/contracts/database.json"),
3982 serde_json::to_vec(&contract).expect("contract JSON"),
3983 )
3984 .expect("logical contract");
3985 }
3986
3987 #[test]
3988 fn query_values_are_bound_and_never_interpolated() {
3990 use pharos_runtime_database::operations::{
3991 AppSchema, Entity, Field, FieldType, Identity, Predicate,
3992 };
3993 let fields = ["id", "title", "status"]
3994 .into_iter()
3995 .map(|name| Field {
3996 name: name.to_owned(),
3997 kind: if name == "id" {
3998 FieldType::Integer
3999 } else {
4000 FieldType::Text
4001 },
4002 nullable: false,
4003 max_length: None,
4004 default: None,
4005 references: None,
4006 on_delete: None,
4007 constraints: Vec::new(),
4008 })
4009 .collect();
4010 let schema = AppSchema {
4011 app_id: "feature_test".to_owned(),
4012 entities: vec![Entity {
4013 name: "item".to_owned(),
4014 identity: Identity::AutoInteger("id".to_owned()),
4015 fields,
4016 unique: Vec::new(),
4017 indexes: Vec::new(),
4018 conditional_indexes: Vec::new(),
4019 }],
4020 };
4021 let context = FeatureRequestContext {
4022 query_params: serde_json::Map::from_iter([(
4023 "q".to_owned(),
4024 Value::String("%' OR 1=1 --".to_owned()),
4025 )]),
4026 ..FeatureRequestContext::default()
4027 };
4028 let filters =
4029 serde_json::json!({"title": {"value": {"query_param": "q"}, "operator": "contains"}});
4030 let predicate =
4031 super::logical::predicate_for_filters(filters.as_object(), &context, &schema, "item")
4032 .expect("typed predicate");
4033 assert_eq!(
4034 predicate,
4035 Some(Predicate::Contains(
4036 "title".to_owned(),
4037 "%' OR 1=1 --".to_owned()
4038 ))
4039 );
4040 let filters = serde_json::json!({"status": {"operator": "is_null"}});
4041 let predicate =
4042 super::logical::predicate_for_filters(filters.as_object(), &context, &schema, "item")
4043 .expect("typed null eligibility");
4044 assert_eq!(predicate, Some(Predicate::IsNull("status".to_owned())));
4045 let filters = serde_json::json!({"unknown": {"operator": "is_null"}});
4046 assert!(
4047 super::logical::predicate_for_filters(filters.as_object(), &context, &schema, "item")
4048 .is_err()
4049 );
4050 }
4051
4052 #[test]
4053 fn query_and_path_inputs_are_bounded_and_duplicate_free() {
4055 let query = query_parameters("/?genre=house&q=kick+loop").expect("query");
4056 assert_eq!(query.get("q").and_then(Value::as_str), Some("kick loop"));
4057 assert!(query_parameters("/?q=one&q=two").is_err());
4058 let path = path_parameters("/packs/:slug", "/packs/neon-house").expect("path");
4059 assert_eq!(path.get("slug").and_then(Value::as_str), Some("neon-house"));
4060 assert!(path_parameters("/packs/:slug", "/packs/a/b").is_err());
4061 }
4062
4063 #[test]
4064 fn manifest_declared_payment_copy_exposes_template_params() {
4066 let root = test_root("payment-copy");
4067 std::fs::create_dir_all(&root).expect("root");
4068 std::fs::create_dir_all(root.join("data")).expect("data");
4069 write(
4070 &root,
4071 "pharos.app.json",
4072 r#"{"app_id":"feature_test","payments":{"checkout_mode":"demo","supported_providers":["stripe"],"provider_src":"data/payment_provider.yml","copy":{"checkout_headline":"Demo checkout","owner_gross_label":"Gross demo sales"}}}"#,
4073 );
4074 write(
4075 &root,
4076 "data/payment_provider.yml",
4077 "owner_resource_type: merchant\nsupported_providers:\n - provider: stripe\ncopy:\n owner_payment_provider_setup_headline: Connect Payment Provider\n",
4078 );
4079 let app = crate::config::AppRuntime {
4080 app_id: Some("feature_test".to_owned()),
4081 app_root: Some(root.to_string_lossy().into_owned()),
4082 artifact_dir: Some(root.to_string_lossy().into_owned()),
4083 ..crate::config::AppRuntime::default()
4084 };
4085 let params = manifest_declared_template_params(&app).expect("manifest params");
4086 assert_eq!(
4087 params.get("payment_checkout_mode").and_then(Value::as_str),
4088 Some("Demo")
4089 );
4090 assert_eq!(
4091 params
4092 .get("payment_primary_provider")
4093 .and_then(Value::as_str),
4094 Some("stripe")
4095 );
4096 assert_eq!(
4097 params.get("owner_gross_label").and_then(Value::as_str),
4098 Some("Gross demo sales")
4099 );
4100 assert_eq!(
4101 params
4102 .get("owner_payment_provider_setup_headline")
4103 .and_then(Value::as_str),
4104 Some("Connect Payment Provider")
4105 );
4106 std::fs::remove_dir_all(root).expect("cleanup");
4107 }
4108
4109 #[cfg(all(feature = "sqlite", not(target_os = "wasi")))]
4110 #[test]
4111 fn feature_action_commits_once_and_renders_refreshed_fragment() {
4113 let root = test_root("action");
4114 let database_path = root.join("feature.sqlite3");
4115 std::fs::create_dir_all(&root).expect("root");
4116 write(
4117 &root,
4118 "pharos.app.yml",
4119 "app_id: feature_test\nname: Feature Test\nversion: 1.0.0\ndatabase:\n migration_contract: data/contracts/database.json\n",
4120 );
4121 write(
4122 &root,
4123 "app.conf",
4124 &format!("db_backend=sqlite\ndb_path={}\n", database_path.display()),
4125 );
4126 write(
4127 &root,
4128 "data/contracts/migrations.json",
4129 r#"{"state_cutover_mode":"relational_runtime_authority"}"#,
4130 );
4131 write(
4132 &root,
4133 "data/contracts/entities.json",
4134 r#"{"entities":[
4135 {"entity_id":"product","table":"product","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"status"}]},
4136 {"entity_id":"cart","table":"cart","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"user_id"}]},
4137 {"entity_id":"cart_item","table":"cart_item","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"cart_id"},{"field_id":"user_id"},{"field_id":"product_id"}]}
4138 ]}"#,
4139 );
4140 write(
4141 &root,
4142 "data/contracts/features.json",
4143 r#"{"features":[{"feature_id":"cart","queries":{
4144 "published_product":{"entity":"product","fields":["id","status"],"filters":{"id":"input.product_id","status":"published"},"result":"one"},
4145 "current_cart":{"entity":"cart","fields":["id","user_id"],"filters":{"user_id":"subject.user_id"},"result":"one"},
4146 "current_cart_items":{"entity":"cart_item","fields":["id","user_id","product_id"],"filters":{"user_id":"subject.user_id"},"result":"many","limit":20}
4147 }}]}"#,
4148 );
4149 write(
4150 &root,
4151 "data/contracts/actions.json",
4152 r#"{"actions":[{"action_id":"add_item","feature_id":"cart","input_contract":{"inline_fields":{"product_id":{"type":"integer","required":true,"minimum":1}},"reject_unknown":true},"transaction_contract":{"require":{"query":"published_product"},"upsert":{"entity":"cart_item","unique_by":["cart_id","product_id"],"values":{"cart_id":"query.current_cart.id","user_id":"subject.user_id","product_id":"input.product_id"}}},"response_contract":{"response_kind":"fragment","template":"templates/fragments/cart_summary.html","target":"cart-panel","queries":["current_cart_items"],"fallback":{"redirect":"/"}}}]}"#,
4153 );
4154 write(
4155 &root,
4156 "templates/fragments/cart_summary.html",
4157 r#"<aside id="cart-panel">{current_cart_items_count}</aside>"#,
4158 );
4159 assert_eq!(
4160 initialize_logical_test_records(
4161 &root,
4162 &serde_json::json!([{"entity":"product","fields":{"id":7,"status":"published"}},{"entity":"cart","fields":{"id":3,"user_id":42}}])
4163 ),
4164 0
4165 );
4166 let app = crate::config::AppRuntime {
4167 app_id: Some("feature_test".to_owned()),
4168 app_root: Some(root.to_string_lossy().into_owned()),
4169 artifact_dir: Some(root.to_string_lossy().into_owned()),
4170 ..crate::config::AppRuntime::default()
4171 };
4172 let route = serde_json::json!({
4173 "path":"/cart/items",
4174 "feature_id":"cart",
4175 "action_id":"add_item"
4176 });
4177 let request = super::super::http::HttpRequest {
4178 method: "POST".to_owned(),
4179 target: "/cart/items".to_owned(),
4180 content_type: Some("application/x-www-form-urlencoded".to_owned()),
4181 body: b"product_id=7&csrf_token=test".to_vec(),
4182 ..super::super::http::HttpRequest::default()
4183 };
4184 let first = execute_feature_action(&app, &route, &request, 42).expect("first action");
4185 let second = execute_feature_action(&app, &route, &request, 42).expect("idempotent action");
4186 assert!(first.fragment_html.contains(">1</aside>"));
4187 assert_eq!(second.fragment_html, first.fragment_html);
4188 assert_eq!(
4189 logical_test_value(&root, "cart_item", "COUNT(*)", None).as_deref(),
4190 Some("1")
4191 );
4192 std::fs::remove_dir_all(root).expect("cleanup");
4193 }
4194
4195 #[cfg(all(feature = "sqlite", not(target_os = "wasi")))]
4196 #[test]
4197 fn feature_action_accepts_json_input_for_declared_fields() {
4199 let root = test_root("json-action");
4200 let database_path = root.join("feature.sqlite3");
4201 std::fs::create_dir_all(&root).expect("root");
4202 write(
4203 &root,
4204 "pharos.app.yml",
4205 "app_id: feature_test\nname: Feature Test\nversion: 1.0.0\ndatabase:\n migration_contract: data/contracts/database.json\n",
4206 );
4207 write(
4208 &root,
4209 "app.conf",
4210 &format!("db_backend=sqlite\ndb_path={}\n", database_path.display()),
4211 );
4212 write(
4213 &root,
4214 "data/contracts/migrations.json",
4215 r#"{"state_cutover_mode":"relational_runtime_authority"}"#,
4216 );
4217 write(
4218 &root,
4219 "data/contracts/entities.json",
4220 r#"{"entities":[
4221 {"entity_id":"product","table":"product","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"status"}]},
4222 {"entity_id":"cart","table":"cart","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"user_id"}]},
4223 {"entity_id":"cart_item","table":"cart_item","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"cart_id"},{"field_id":"user_id"},{"field_id":"product_id"}]}
4224 ]}"#,
4225 );
4226 write(
4227 &root,
4228 "data/contracts/features.json",
4229 r#"{"features":[{"feature_id":"cart","queries":{
4230 "published_product":{"entity":"product","fields":["id","status"],"filters":{"id":"input.product_id","status":"published"},"result":"one"},
4231 "current_cart":{"entity":"cart","fields":["id","user_id"],"filters":{"user_id":"subject.user_id"},"result":"one"},
4232 "current_cart_items":{"entity":"cart_item","fields":["id","user_id","product_id"],"filters":{"user_id":"subject.user_id"},"result":"many","limit":20}
4233 }}]}"#,
4234 );
4235 write(
4236 &root,
4237 "data/contracts/actions.json",
4238 r#"{"actions":[{"action_id":"add_item","feature_id":"cart","input_contract":{"inline_fields":{"product_id":{"type":"integer","required":true,"minimum":1}},"reject_unknown":true},"transaction_contract":{"require":{"query":"published_product"},"upsert":{"entity":"cart_item","unique_by":["cart_id","product_id"],"values":{"cart_id":"query.current_cart.id","user_id":"subject.user_id","product_id":"input.product_id"}}},"response_contract":{"response_kind":"fragment","template":"templates/fragments/cart_summary.html","target":"cart-panel","queries":["current_cart_items"],"fallback":{"redirect":"/"}},"guard_contract":{"guards":[]}}]}"#,
4239 );
4240 write(
4241 &root,
4242 "templates/fragments/cart_summary.html",
4243 r#"<aside id="cart-panel">{current_cart_items_count}</aside>"#,
4244 );
4245 assert_eq!(
4246 initialize_logical_test_records(
4247 &root,
4248 &serde_json::json!([{"entity":"product","fields":{"id":7,"status":"published"}},{"entity":"cart","fields":{"id":3,"user_id":42}}])
4249 ),
4250 0
4251 );
4252 let app = crate::config::AppRuntime {
4253 app_id: Some("feature_test".to_owned()),
4254 app_root: Some(root.to_string_lossy().into_owned()),
4255 artifact_dir: Some(root.to_string_lossy().into_owned()),
4256 ..crate::config::AppRuntime::default()
4257 };
4258 let route = serde_json::json!({
4259 "path":"/cart/items",
4260 "feature_id":"cart",
4261 "action_id":"add_item"
4262 });
4263 let request = super::super::http::HttpRequest {
4264 method: "POST".to_owned(),
4265 target: "/cart/items".to_owned(),
4266 content_type: Some("application/json".to_owned()),
4267 body: br#"{"product_id":7}"#.to_vec(),
4268 ..super::super::http::HttpRequest::default()
4269 };
4270 let outcome = execute_feature_action(&app, &route, &request, 42).expect("json action");
4271 assert!(outcome.fragment_html.contains(">1</aside>"));
4272 write(
4273 &root,
4274 "templates/connections.page.json",
4275 r#"{"page_id":"connections","template_file":"templates/fragments/connections.html"}"#,
4276 );
4277 write(
4278 &root,
4279 "templates/fragments/connections.html",
4280 r#"<div hx-get="/__pharos/social/youtube/status"></div><a href="/feature-test/owner">Owner</a>"#,
4281 );
4282 let mut mounted = app.clone();
4283 mounted.base_path = Some("/feature-test".into());
4284 let page = serde_json::json!({"path":"/connections","feature_id":"cart","page_id":"connections","queries":[]});
4285 let fragment =
4286 super::render_feature_page_body(&mounted, &page, "/connections", 42, "csrf").unwrap();
4287 assert!(fragment.contains("hx-get=\"/feature-test/__pharos/social/youtube/status\""));
4288 assert!(fragment.contains("href=\"/feature-test/owner\""));
4289 assert!(!fragment.contains("/feature-test/feature-test/"));
4290 std::fs::remove_dir_all(root).expect("cleanup");
4291 }
4292
4293 #[cfg(all(feature = "sqlite", not(target_os = "wasi")))]
4294 #[test]
4295 fn feature_action_normalizes_declared_string_input() {
4297 let root = test_root("normalized-action");
4298 let database_path = root.join("feature.sqlite3");
4299 std::fs::create_dir_all(&root).expect("root");
4300 write(
4301 &root,
4302 "pharos.app.yml",
4303 "app_id: feature_test\nname: Feature Test\nversion: 1.0.0\ndatabase:\n migration_contract: data/contracts/database.json\n",
4304 );
4305 write(
4306 &root,
4307 "app.conf",
4308 &format!("db_backend=sqlite\ndb_path={}\n", database_path.display()),
4309 );
4310 write(
4311 &root,
4312 "data/contracts/migrations.json",
4313 r#"{"state_cutover_mode":"relational_runtime_authority"}"#,
4314 );
4315 write(
4316 &root,
4317 "data/contracts/entities.json",
4318 r#"{"entities":[
4319 {"entity_id":"note","table":"note","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"title"},{"field_id":"user_id"}]}
4320 ]}"#,
4321 );
4322 write(
4323 &root,
4324 "data/contracts/features.json",
4325 r#"{"features":[{"feature_id":"notes","queries":{
4326 "my_notes":{"entity":"note","fields":["id","title"],"filters":{"user_id":"subject.user_id"},"result":"many","limit":20}
4327 }}]}"#,
4328 );
4329 write(
4330 &root,
4331 "data/contracts/actions.json",
4332 r#"{"actions":[{"action_id":"add_note","feature_id":"notes","input_contract":{"inline_fields":{"title":{"type":"string","required":true,"min_length":1,"max_length":80}},"reject_unknown":true},"hook_contract":{"normalize_inputs":[{"kind":"trim","fields":["title"]},{"kind":"collapse_whitespace","fields":["title"]}]},"transaction_contract":{"insert":{"entity":"note","values":{"title":"input.title","user_id":"subject.user_id"}}},"response_contract":{"response_kind":"fragment","template":"templates/fragments/note_summary.html","target":"note-panel","queries":["my_notes"],"fallback":{"redirect":"/"}}}]}"#,
4333 );
4334 write(
4335 &root,
4336 "templates/fragments/note_summary.html",
4337 r#"<aside id="note-panel">{my_notes_count}</aside>"#,
4338 );
4339 assert_eq!(
4340 initialize_logical_test_records(&root, &serde_json::json!([])),
4341 0
4342 );
4343 let app = crate::config::AppRuntime {
4344 app_id: Some("feature_test".to_owned()),
4345 app_root: Some(root.to_string_lossy().into_owned()),
4346 artifact_dir: Some(root.to_string_lossy().into_owned()),
4347 ..crate::config::AppRuntime::default()
4348 };
4349 let route = serde_json::json!({
4350 "path":"/notes",
4351 "feature_id":"notes",
4352 "action_id":"add_note"
4353 });
4354 let request = super::super::http::HttpRequest {
4355 method: "POST".to_owned(),
4356 target: "/notes".to_owned(),
4357 content_type: Some("application/x-www-form-urlencoded".to_owned()),
4358 body: b"title=++Alpha+++Beta++&csrf_token=test".to_vec(),
4359 ..super::super::http::HttpRequest::default()
4360 };
4361 let outcome = execute_feature_action(&app, &route, &request, 42).expect("action");
4362 assert!(outcome.fragment_html.contains(">1</aside>"));
4363 assert_eq!(
4364 logical_test_value(
4365 &root,
4366 "note",
4367 "title",
4368 Some(pharos_runtime_database::operations::Predicate::Equal(
4369 "user_id".to_owned(),
4370 serde_json::json!(42)
4371 ))
4372 )
4373 .as_deref(),
4374 Some("Alpha Beta")
4375 );
4376 std::fs::remove_dir_all(root).expect("cleanup");
4377 }
4378
4379 #[test]
4380 fn validated_action_input_applies_default_inputs_before_required_checks() {
4382 let request = super::super::http::HttpRequest {
4383 method: "POST".to_owned(),
4384 target: "/snapshots/create".to_owned(),
4385 content_type: Some("application/x-www-form-urlencoded".to_owned()),
4386 body: b"csrf_token=test".to_vec(),
4387 ..super::super::http::HttpRequest::default()
4388 };
4389 let contract = serde_json::json!({
4390 "inline_fields": {
4391 "snapshot_label": {"type":"string","required":true,"min_length":1,"max_length":80}
4392 },
4393 "reject_unknown": true
4394 });
4395 let hook_contract = serde_json::json!({
4396 "default_inputs": [
4397 {"field":"snapshot_label","source":"literal","value":"initial-proof"}
4398 ]
4399 });
4400 let input = super::validated_action_input(
4401 &request,
4402 &contract,
4403 Some(&hook_contract),
4404 "snapshot_board_tasks",
4405 )
4406 .expect("default input");
4407 assert_eq!(
4408 input.get("snapshot_label").and_then(Value::as_str),
4409 Some("initial-proof")
4410 );
4411 }
4412
4413 #[test]
4414 fn validated_action_input_preserves_empty_literal_default_inputs() {
4416 let request = super::super::http::HttpRequest {
4417 method: "POST".to_owned(),
4418 target: "/snapshots/create".to_owned(),
4419 content_type: Some("application/x-www-form-urlencoded".to_owned()),
4420 body: b"title=Proof&csrf_token=test".to_vec(),
4421 ..super::super::http::HttpRequest::default()
4422 };
4423 let contract = serde_json::json!({
4424 "inline_fields": {
4425 "title": {"type":"string","required":true,"min_length":1,"max_length":80},
4426 "amount": {"type":"string","required":false,"max_length":120}
4427 },
4428 "reject_unknown": true
4429 });
4430 let hook_contract = serde_json::json!({
4431 "default_inputs": [
4432 {"field":"amount","source":"literal","value":""}
4433 ]
4434 });
4435 let input = super::validated_action_input(
4436 &request,
4437 &contract,
4438 Some(&hook_contract),
4439 "snapshot_board_tasks",
4440 )
4441 .expect("empty default input");
4442 assert_eq!(input.get("amount").and_then(Value::as_str), Some(""));
4443 }
4444
4445 #[test]
4446 fn validated_action_input_accepts_derived_header_and_query_values() {
4448 let request = super::super::http::HttpRequest {
4449 method: "POST".to_owned(),
4450 target: "/notes/create?source=release-page".to_owned(),
4451 headers: "X-Feature-Title: Header Title \r\n".to_owned(),
4452 content_type: Some("application/x-www-form-urlencoded".to_owned()),
4453 body: b"csrf_token=test".to_vec(),
4454 ..super::super::http::HttpRequest::default()
4455 };
4456 let contract = serde_json::json!({
4457 "inline_fields": {
4458 "title": {"type":"string","required":true,"min_length":1,"max_length":80},
4459 "source_label": {"type":"string","required":true,"min_length":1,"max_length":80}
4460 },
4461 "reject_unknown": true
4462 });
4463 let hook_contract = serde_json::json!({
4464 "derived_inputs": [
4465 {"field":"title","source":"header","header":"X-Feature-Title"},
4466 {"field":"source_label","source":"query","param":"source"}
4467 ],
4468 "normalize_inputs": [
4469 {"kind":"trim","fields":["title"]},
4470 {"kind":"collapse_whitespace","fields":["title"]}
4471 ]
4472 });
4473 let input =
4474 super::validated_action_input(&request, &contract, Some(&hook_contract), "add_note")
4475 .expect("derived input");
4476 assert_eq!(
4477 input.get("title").and_then(Value::as_str),
4478 Some("Header Title")
4479 );
4480 assert_eq!(
4481 input.get("source_label").and_then(Value::as_str),
4482 Some("release-page")
4483 );
4484 }
4485
4486 #[cfg(all(feature = "sqlite", not(target_os = "wasi")))]
4487 #[test]
4488 fn feature_action_guard_returns_structured_validation_report() {
4490 let root = test_root("guard-action");
4491 let database_path = root.join("feature.sqlite3");
4492 std::fs::create_dir_all(&root).expect("root");
4493 write(
4494 &root,
4495 "pharos.app.yml",
4496 "app_id: feature_test\nname: Feature Test\nversion: 1.0.0\ndatabase:\n migration_contract: data/contracts/database.json\n",
4497 );
4498 write(
4499 &root,
4500 "app.conf",
4501 &format!("db_backend=sqlite\ndb_path={}\n", database_path.display()),
4502 );
4503 write(
4504 &root,
4505 "data/contracts/migrations.json",
4506 r#"{"state_cutover_mode":"relational_runtime_authority"}"#,
4507 );
4508 write(
4509 &root,
4510 "data/contracts/entities.json",
4511 r#"{"entities":[
4512 {"entity_id":"product","table":"product","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"status"},{"field_id":"price_minor"}]},
4513 {"entity_id":"cart","table":"cart","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"user_id"}]},
4514 {"entity_id":"cart_item","table":"cart_item","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"cart_id"},{"field_id":"user_id"},{"field_id":"product_id"},{"field_id":"price_minor"}]}
4515 ]}"#,
4516 );
4517 write(
4518 &root,
4519 "data/contracts/features.json",
4520 r#"{"features":[{"feature_id":"cart","queries":{
4521 "published_product":{"entity":"product","fields":["id","status","price_minor"],"filters":{"id":"input.product_id","status":"published"},"result":"one"},
4522 "current_cart":{"entity":"cart","fields":["id","user_id"],"filters":{"user_id":"subject.user_id"},"result":"one"},
4523 "current_cart_items":{"entity":"cart_item","fields":["id","user_id","product_id","price_minor"],"filters":{"user_id":"subject.user_id"},"result":"many","limit":20}
4524 }}]}"#,
4525 );
4526 write(
4527 &root,
4528 "data/contracts/actions.json",
4529 r#"{"actions":[{"action_id":"add_item","feature_id":"cart","input_contract":{"inline_fields":{"product_id":{"type":"integer","required":true,"minimum":1}},"reject_unknown":true},"transaction_contract":{"steps":[{"require":{"query":"published_product"}},{"require":{"query":"current_cart"}},{"upsert":{"entity":"cart_item","unique_by":["cart_id","product_id"],"values":{"cart_id":"query.current_cart.id","user_id":"subject.user_id","product_id":"input.product_id","price_minor":"query.published_product.price_minor"}}}]},"response_contract":{"response_kind":"fragment","template":"templates/fragments/cart_summary.html","target":"cart-panel","queries":["current_cart_items"],"fallback":{"redirect":"/"}},"guard_contract":{"guards":[{"guard_id":"demo_cart_total_minor_cap","kind":"sum","query":"current_cart_items","field":"price_minor","add":"query.published_product.price_minor","maximum":6000,"message":"Demo cart total is capped at $60 before checkout."}]}}]}"#,
4530 );
4531 write(
4532 &root,
4533 "templates/fragments/cart_summary.html",
4534 r#"<aside id="cart-panel">{current_cart_items_count}</aside>"#,
4535 );
4536 assert_eq!(
4537 initialize_logical_test_records(
4538 &root,
4539 &serde_json::json!([{"entity":"product","fields":{"id":1,"status":"published","price_minor":2900}},{"entity":"product","fields":{"id":2,"status":"published","price_minor":3400}},{"entity":"cart","fields":{"id":3,"user_id":42}},{"entity":"cart_item","fields":{"id":9,"cart_id":3,"user_id":42,"product_id":1,"price_minor":2900}}])
4540 ),
4541 0
4542 );
4543 let app = crate::config::AppRuntime {
4544 app_id: Some("feature_test".to_owned()),
4545 app_root: Some(root.to_string_lossy().into_owned()),
4546 artifact_dir: Some(root.to_string_lossy().into_owned()),
4547 ..crate::config::AppRuntime::default()
4548 };
4549 let route = serde_json::json!({
4550 "path":"/cart/items",
4551 "feature_id":"cart",
4552 "action_id":"add_item"
4553 });
4554 let request = super::super::http::HttpRequest {
4555 method: "POST".to_owned(),
4556 target: "/cart/items".to_owned(),
4557 content_type: Some("application/json".to_owned()),
4558 body: br#"{"product_id":2}"#.to_vec(),
4559 ..super::super::http::HttpRequest::default()
4560 };
4561 let error =
4562 execute_feature_action(&app, &route, &request, 42).expect_err("guard rejection");
4563 match error {
4564 FeatureRuntimeError::ValidationReport(report) => {
4565 assert!(report.contains("demo_cart_total_minor_cap"));
4566 assert!(report.contains("Demo cart total is capped at $60 before checkout."));
4567 }
4568 other => panic!("unexpected error: {other:?}"),
4569 }
4570 std::fs::remove_dir_all(root).expect("cleanup");
4571 }
4572
4573 #[cfg(all(feature = "sqlite", not(target_os = "wasi")))]
4574 #[test]
4575 fn feature_action_reserve_releases_expired_rows_before_capacity_check() {
4577 let root = test_root("reserve-expiry-action");
4578 let database_path = root.join("feature.sqlite3");
4579 std::fs::create_dir_all(&root).expect("root");
4580 write(
4581 &root,
4582 "pharos.app.yml",
4583 "app_id: feature_test\nname: Feature Test\nversion: 1.0.0\ndatabase:\n migration_contract: data/contracts/database.json\n",
4584 );
4585 write(
4586 &root,
4587 "app.conf",
4588 &format!("db_backend=sqlite\ndb_path={}\n", database_path.display()),
4589 );
4590 write(
4591 &root,
4592 "data/contracts/migrations.json",
4593 r#"{"state_cutover_mode":"relational_runtime_authority"}"#,
4594 );
4595 write(
4596 &root,
4597 "data/contracts/entities.json",
4598 r#"{"entities":[
4599 {"entity_id":"product","table":"product","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"status"},{"field_id":"slug"},{"field_id":"title"},{"field_id":"price_minor"}]},
4600 {"entity_id":"cart","table":"cart","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"user_id"}]},
4601 {"entity_id":"cart_item","table":"cart_item","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"cart_id"},{"field_id":"user_id"},{"field_id":"product_id"},{"field_id":"price_minor"},{"field_id":"added_at"}]},
4602 {"entity_id":"product_inventory","table":"product_inventory","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"product_id"},{"field_id":"available_quantity"}]},
4603 {"entity_id":"inventory_reservation","table":"inventory_reservation","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"cart_id"},{"field_id":"user_id"},{"field_id":"product_id"},{"field_id":"reservation_status"},{"field_id":"reserved_at"},{"field_id":"released_at"},{"field_id":"release_reason"}]}
4604 ]}"#,
4605 );
4606 write(
4607 &root,
4608 "data/contracts/features.json",
4609 r#"{"features":[{"feature_id":"cart","queries":{
4610 "published_product":{"entity":"product","fields":["id","status","slug","title","price_minor"],"filters":{"id":"input.product_id","status":"published"},"result":"one"},
4611 "current_cart":{"entity":"cart","fields":["id","user_id"],"filters":{"user_id":"subject.user_id"},"result":"one"},
4612 "current_cart_items":{"entity":"cart_item","fields":["id","user_id","product_id","price_minor","added_at"],"filters":{"user_id":"subject.user_id","added_at":{"value":{"server_now_minus_seconds":3600},"operator":"gt"}},"result":"many","limit":20},
4613 "product_inventory":{"entity":"product_inventory","fields":["id","product_id","available_quantity"],"filters":{"product_id":"input.product_id"},"result":"one"}
4614 }}]}"#,
4615 );
4616 write(
4617 &root,
4618 "data/contracts/actions.json",
4619 r#"{"actions":[{"action_id":"add_item","feature_id":"cart","input_contract":{"inline_fields":{"product_id":{"type":"integer","required":true,"minimum":1}},"reject_unknown":true},"transaction_contract":{"steps":[{"require":{"query":"published_product"}},{"require":{"query":"current_cart"}},{"require":{"query":"product_inventory"}},{"reserve":{"entity":"inventory_reservation","status_field":"reservation_status","active_statuses":["reserved","confirmed"],"expires_after_seconds":3600,"owner_filters":{"cart_id":"query.current_cart.id","product_id":"query.published_product.id"},"scope_filters":{"product_id":"query.published_product.id"},"capacity":"query.product_inventory.available_quantity","on_conflict":{"code":"inventory_reserved_out","message":"Inventory is fully reserved."},"values":{"cart_id":"query.current_cart.id","user_id":"subject.user_id","product_id":"query.published_product.id","reservation_status":"reserved","reserved_at":"server.now"}}},{"upsert":{"entity":"cart_item","unique_by":["cart_id","product_id"],"update_on_conflict":true,"values":{"cart_id":"query.current_cart.id","user_id":"subject.user_id","product_id":"query.published_product.id","price_minor":"query.published_product.price_minor","added_at":"server.now"}}}]},"response_contract":{"response_kind":"fragment","template":"templates/fragments/cart_summary.html","target":"cart-panel","queries":["current_cart_items"],"fallback":{"redirect":"/"}}}]}"#,
4620 );
4621 write(
4622 &root,
4623 "templates/fragments/cart_summary.html",
4624 r#"<aside id="cart-panel">{current_cart_items_count}</aside>"#,
4625 );
4626 assert_eq!(
4627 initialize_logical_test_records(
4628 &root,
4629 &serde_json::json!([{"entity":"product","fields":{"id":1,"status":"published","slug":"neon","title":"Neon","price_minor":2900}},{"entity":"cart","fields":{"id":3,"user_id":42}},{"entity":"cart","fields":{"id":5,"user_id":7}},{"entity":"cart_item","fields":{"id":4,"cart_id":3,"user_id":42,"product_id":1,"price_minor":2900,"added_at":"2026-08-30T00:00:00Z"}},{"entity":"product_inventory","fields":{"id":1,"product_id":1,"available_quantity":1}},{"entity":"inventory_reservation","fields":{"id":9,"cart_id":5,"user_id":7,"product_id":1,"reservation_status":"reserved","reserved_at":"2026-08-30T00:00:00Z","release_reason":""}},{"entity":"inventory_reservation","fields":{"id":10,"cart_id":3,"user_id":42,"product_id":1,"reservation_status":"reserved","reserved_at":"2026-08-30T00:00:00Z","release_reason":""}}])
4630 ),
4631 0
4632 );
4633 let app = crate::config::AppRuntime {
4634 app_id: Some("feature_test".to_owned()),
4635 app_root: Some(root.to_string_lossy().into_owned()),
4636 artifact_dir: Some(root.to_string_lossy().into_owned()),
4637 ..crate::config::AppRuntime::default()
4638 };
4639 let route = serde_json::json!({
4640 "path":"/cart/items",
4641 "feature_id":"cart",
4642 "action_id":"add_item"
4643 });
4644 let request = super::super::http::HttpRequest {
4645 method: "POST".to_owned(),
4646 target: "/cart/items".to_owned(),
4647 content_type: Some("application/json".to_owned()),
4648 body: br#"{"product_id":1}"#.to_vec(),
4649 ..super::super::http::HttpRequest::default()
4650 };
4651 let outcome = execute_feature_action(&app, &route, &request, 42).expect("reserve action");
4652 assert!(outcome.fragment_html.contains(r#"id="cart-panel""#));
4653 assert_eq!(
4654 logical_test_value(
4655 &root,
4656 "inventory_reservation",
4657 "reservation_status",
4658 Some(pharos_runtime_database::operations::Predicate::Equal(
4659 "id".to_owned(),
4660 serde_json::json!(9)
4661 ))
4662 )
4663 .as_deref(),
4664 Some("released")
4665 );
4666 assert_eq!(
4667 logical_test_value(
4668 &root,
4669 "inventory_reservation",
4670 "reservation_status",
4671 Some(pharos_runtime_database::operations::Predicate::Equal(
4672 "id".to_owned(),
4673 serde_json::json!(10)
4674 ))
4675 )
4676 .as_deref(),
4677 Some("released")
4678 );
4679 assert_eq!(
4680 logical_test_value(
4681 &root,
4682 "inventory_reservation",
4683 "release_reason",
4684 Some(pharos_runtime_database::operations::Predicate::Equal(
4685 "id".to_owned(),
4686 serde_json::json!(9)
4687 ))
4688 )
4689 .as_deref(),
4690 Some("reservation_expired")
4691 );
4692 assert_eq!(
4693 logical_test_value(
4694 &root,
4695 "inventory_reservation",
4696 "COUNT(*)",
4697 Some(pharos_runtime_database::operations::Predicate::Equal(
4698 "reservation_status".to_owned(),
4699 serde_json::json!("reserved")
4700 ))
4701 )
4702 .as_deref(),
4703 Some("1")
4704 );
4705 assert_ne!(
4706 logical_test_value(
4707 &root,
4708 "cart_item",
4709 "added_at",
4710 Some(pharos_runtime_database::operations::Predicate::Equal(
4711 "id".to_owned(),
4712 serde_json::json!(4)
4713 ))
4714 )
4715 .as_deref(),
4716 Some("2026-08-30T00:00:00Z")
4717 );
4718 std::fs::remove_dir_all(root).expect("cleanup");
4719 }
4720
4721 #[cfg(all(feature = "sqlite", not(target_os = "wasi")))]
4722 #[test]
4723 fn feature_action_reserve_returns_structured_conflict_report() {
4725 let root = test_root("reserve-action");
4726 let database_path = root.join("feature.sqlite3");
4727 std::fs::create_dir_all(&root).expect("root");
4728 write(
4729 &root,
4730 "pharos.app.yml",
4731 "app_id: feature_test\nname: Feature Test\nversion: 1.0.0\ndatabase:\n migration_contract: data/contracts/database.json\n",
4732 );
4733 write(
4734 &root,
4735 "app.conf",
4736 &format!("db_backend=sqlite\ndb_path={}\n", database_path.display()),
4737 );
4738 write(
4739 &root,
4740 "data/contracts/migrations.json",
4741 r#"{"state_cutover_mode":"relational_runtime_authority"}"#,
4742 );
4743 write(
4744 &root,
4745 "data/contracts/entities.json",
4746 r#"{"entities":[
4747 {"entity_id":"product","table":"product","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"status"},{"field_id":"slug"},{"field_id":"title"},{"field_id":"price_minor"}]},
4748 {"entity_id":"cart","table":"cart","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"user_id"}]},
4749 {"entity_id":"cart_item","table":"cart_item","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"cart_id"},{"field_id":"user_id"},{"field_id":"product_id"},{"field_id":"price_minor"}]},
4750 {"entity_id":"product_inventory","table":"product_inventory","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"product_id"},{"field_id":"available_quantity"}]},
4751 {"entity_id":"inventory_reservation","table":"inventory_reservation","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"cart_id"},{"field_id":"user_id"},{"field_id":"product_id"},{"field_id":"reservation_status"},{"field_id":"reserved_at"},{"field_id":"released_at"},{"field_id":"release_reason"}]}
4752 ]}"#,
4753 );
4754 write(
4755 &root,
4756 "data/contracts/features.json",
4757 r#"{"features":[{"feature_id":"cart","queries":{
4758 "published_product":{"entity":"product","fields":["id","status","slug","title","price_minor"],"filters":{"id":"input.product_id","status":"published"},"result":"one"},
4759 "current_cart":{"entity":"cart","fields":["id","user_id"],"filters":{"user_id":"subject.user_id"},"result":"one"},
4760 "current_cart_items":{"entity":"cart_item","fields":["id","user_id","product_id","price_minor"],"filters":{"user_id":"subject.user_id"},"result":"many","limit":20},
4761 "product_inventory":{"entity":"product_inventory","fields":["id","product_id","available_quantity"],"filters":{"product_id":"input.product_id"},"result":"one"}
4762 }}]}"#,
4763 );
4764 write(
4765 &root,
4766 "data/contracts/actions.json",
4767 r#"{"actions":[{"action_id":"add_item","feature_id":"cart","input_contract":{"inline_fields":{"product_id":{"type":"integer","required":true,"minimum":1}},"reject_unknown":true},"transaction_contract":{"steps":[{"require":{"query":"published_product"}},{"require":{"query":"current_cart"}},{"require":{"query":"product_inventory"}},{"reserve":{"entity":"inventory_reservation","status_field":"reservation_status","active_statuses":["reserved","confirmed"],"owner_filters":{"cart_id":"query.current_cart.id","product_id":"query.published_product.id"},"scope_filters":{"product_id":"query.published_product.id"},"capacity":"query.product_inventory.available_quantity","on_conflict":{"code":"inventory_reserved_out","message":"Inventory is fully reserved."},"values":{"cart_id":"query.current_cart.id","user_id":"subject.user_id","product_id":"query.published_product.id","reservation_status":"reserved","reserved_at":"server.now"}}},{"insert":{"entity":"cart_item","values":{"cart_id":"query.current_cart.id","user_id":"subject.user_id","product_id":"query.published_product.id","price_minor":"query.published_product.price_minor"}}}]},"response_contract":{"response_kind":"fragment","template":"templates/fragments/cart_summary.html","target":"cart-panel","queries":["current_cart_items"],"fallback":{"redirect":"/"}}}]}"#,
4768 );
4769 write(
4770 &root,
4771 "templates/fragments/cart_summary.html",
4772 r#"<aside id="cart-panel">{current_cart_items_count}</aside>"#,
4773 );
4774 assert_eq!(
4775 initialize_logical_test_records(
4776 &root,
4777 &serde_json::json!([{"entity":"product","fields":{"id":1,"status":"published","slug":"neon","title":"Neon","price_minor":2900}},{"entity":"cart","fields":{"id":3,"user_id":42}},{"entity":"cart","fields":{"id":5,"user_id":7}},{"entity":"product_inventory","fields":{"id":1,"product_id":1,"available_quantity":1}},{"entity":"inventory_reservation","fields":{"id":9,"cart_id":5,"user_id":7,"product_id":1,"reservation_status":"reserved","reserved_at":"2026-08-30T00:00:00Z","release_reason":""}}])
4778 ),
4779 0
4780 );
4781 let app = crate::config::AppRuntime {
4782 app_id: Some("feature_test".to_owned()),
4783 app_root: Some(root.to_string_lossy().into_owned()),
4784 artifact_dir: Some(root.to_string_lossy().into_owned()),
4785 ..crate::config::AppRuntime::default()
4786 };
4787 let route = serde_json::json!({
4788 "path":"/cart/items",
4789 "feature_id":"cart",
4790 "action_id":"add_item"
4791 });
4792 let request = super::super::http::HttpRequest {
4793 method: "POST".to_owned(),
4794 target: "/cart/items".to_owned(),
4795 content_type: Some("application/json".to_owned()),
4796 body: br#"{"product_id":1}"#.to_vec(),
4797 ..super::super::http::HttpRequest::default()
4798 };
4799 let error =
4800 execute_feature_action(&app, &route, &request, 42).expect_err("reservation rejection");
4801 match error {
4802 FeatureRuntimeError::ValidationReport(report) => {
4803 assert!(report.contains("inventory_reserved_out"));
4804 assert!(report.contains("Inventory is fully reserved."));
4805 }
4806 other => panic!("unexpected error: {other:?}"),
4807 }
4808 std::fs::remove_dir_all(root).expect("cleanup");
4809 }
4810
4811 #[cfg(all(feature = "sqlite", not(target_os = "wasi")))]
4812 #[test]
4813 fn feature_action_confirm_transitions_reserved_row() {
4815 let root = test_root("confirm-action");
4816 let database_path = root.join("feature.sqlite3");
4817 std::fs::create_dir_all(&root).expect("root");
4818 write(
4819 &root,
4820 "pharos.app.yml",
4821 "app_id: feature_test\nname: Feature Test\nversion: 1.0.0\ndatabase:\n migration_contract: data/contracts/database.json\n",
4822 );
4823 write(
4824 &root,
4825 "app.conf",
4826 &format!("db_backend=sqlite\ndb_path={}\n", database_path.display()),
4827 );
4828 write(
4829 &root,
4830 "data/contracts/migrations.json",
4831 r#"{"state_cutover_mode":"relational_runtime_authority"}"#,
4832 );
4833 write(
4834 &root,
4835 "data/contracts/entities.json",
4836 r#"{"entities":[
4837 {"entity_id":"cart","table":"cart","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"user_id"}]},
4838 {"entity_id":"inventory_reservation","table":"inventory_reservation","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"cart_id"},{"field_id":"user_id"},{"field_id":"product_id"},{"field_id":"reservation_status"},{"field_id":"reserved_at"},{"field_id":"confirmed_at"},{"field_id":"released_at"},{"field_id":"release_reason"}]}
4839 ]}"#,
4840 );
4841 write(
4842 &root,
4843 "data/contracts/features.json",
4844 r#"{"features":[{"feature_id":"cart","queries":{
4845 "current_cart":{"entity":"cart","fields":["id","user_id"],"filters":{"user_id":"subject.user_id"},"result":"one"}
4846 }}]}"#,
4847 );
4848 write(
4849 &root,
4850 "data/contracts/actions.json",
4851 r#"{"actions":[{"action_id":"confirm_item","feature_id":"cart","input_contract":{"inline_fields":{"product_id":{"type":"integer","required":true,"minimum":1}},"reject_unknown":true},"transaction_contract":{"steps":[{"require":{"query":"current_cart"}},{"confirm":{"entity":"inventory_reservation","status_field":"reservation_status","active_statuses":["reserved"],"owner_filters":{"cart_id":"query.current_cart.id","product_id":"input.product_id","user_id":"subject.user_id"},"scope_filters":{"product_id":"input.product_id"},"capacity":1,"to_status":"confirmed","values":{"confirmed_at":"server.now","release_reason":""}}}]},"response_contract":{"response_kind":"fragment","template":"templates/fragments/cart_summary.html","target":"cart-panel","queries":[],"fallback":{"redirect":"/"}}}]}"#,
4852 );
4853 write(
4854 &root,
4855 "templates/fragments/cart_summary.html",
4856 r#"<aside id="cart-panel">ok</aside>"#,
4857 );
4858 assert_eq!(
4859 initialize_logical_test_records(
4860 &root,
4861 &serde_json::json!([{"entity":"cart","fields":{"id":3,"user_id":42}},{"entity":"inventory_reservation","fields":{"id":9,"cart_id":3,"user_id":42,"product_id":1,"reservation_status":"reserved","reserved_at":"2026-08-30T00:00:00Z","release_reason":""}}])
4862 ),
4863 0
4864 );
4865 let app = crate::config::AppRuntime {
4866 app_id: Some("feature_test".to_owned()),
4867 app_root: Some(root.to_string_lossy().into_owned()),
4868 artifact_dir: Some(root.to_string_lossy().into_owned()),
4869 ..crate::config::AppRuntime::default()
4870 };
4871 let route = serde_json::json!({
4872 "path":"/cart/confirm",
4873 "feature_id":"cart",
4874 "action_id":"confirm_item"
4875 });
4876 let request = super::super::http::HttpRequest {
4877 method: "POST".to_owned(),
4878 target: "/cart/confirm".to_owned(),
4879 content_type: Some("application/json".to_owned()),
4880 body: br#"{"product_id":1}"#.to_vec(),
4881 ..super::super::http::HttpRequest::default()
4882 };
4883 let outcome = execute_feature_action(&app, &route, &request, 42).expect("confirm action");
4884 assert!(outcome.fragment_html.contains("ok"));
4885 assert_eq!(
4886 logical_test_value(
4887 &root,
4888 "inventory_reservation",
4889 "reservation_status",
4890 Some(pharos_runtime_database::operations::Predicate::Equal(
4891 "id".to_owned(),
4892 serde_json::json!(9)
4893 ))
4894 )
4895 .as_deref(),
4896 Some("confirmed")
4897 );
4898 std::fs::remove_dir_all(root).expect("cleanup");
4899 }
4900}