1use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use pharos_runtime_core::application_ir::{CONTRACT_VERSION, ContractId};
8use serde_json::{Map, Value};
9use sha2::{Digest, Sha256};
10
11const MAX_FEATURE_FILES: usize = 128;
12const MAX_TEMPLATE_FILES: usize = 4_096;
13const MAX_TEMPLATE_DEPTH: usize = 32;
14const INTERACTION_CLIENT: &str = include_str!("../assets/interaction-v1.js");
15
16#[derive(Debug, Clone, Eq, PartialEq)]
17pub enum FeatureCompileError {
37 InvalidDocument(String),
38 DuplicateId(String),
39 MissingReference(String),
40 UnsafePath(String),
41 Io(String),
42}
43
44fn declaration_id(value: &str) -> Result<String, FeatureCompileError> {
46 ContractId::parse(value)
47 .map(|id| id.as_str().to_owned())
48 .map_err(|_| FeatureCompileError::InvalidDocument(value.to_owned()))
49}
50
51fn safe_artifact_path(root: &Path, relative: &str) -> Result<PathBuf, FeatureCompileError> {
53 let path = Path::new(relative);
54 if relative.is_empty()
55 || path.is_absolute()
56 || path
57 .components()
58 .any(|component| !matches!(component, std::path::Component::Normal(_)))
59 {
60 return Err(FeatureCompileError::UnsafePath(relative.to_owned()));
61 }
62 Ok(root.join(path))
63}
64
65pub fn safe_feature_template_path(root: &Path, relative: &str) -> Option<PathBuf> {
86 let path = safe_artifact_path(root, relative).ok()?;
87 (relative.starts_with("templates/") && path.is_file()).then_some(path)
88}
89
90fn load_json_object(path: &Path) -> Result<Value, FeatureCompileError> {
92 let text = fs::read_to_string(path)
93 .map_err(|_| FeatureCompileError::Io(path.to_string_lossy().into_owned()))?;
94 let value: Value = serde_json::from_str(&text)
95 .map_err(|_| FeatureCompileError::InvalidDocument(path.to_string_lossy().into_owned()))?;
96 if !value.is_object() {
97 return Err(FeatureCompileError::InvalidDocument(
98 path.to_string_lossy().into_owned(),
99 ));
100 }
101 Ok(value)
102}
103
104fn load_contract_or_empty(
106 artifact_root: &Path,
107 name: &str,
108 array_key: &str,
109) -> Result<Value, FeatureCompileError> {
110 let path = artifact_root.join(format!("data/contracts/{name}.json"));
111 if path.is_file() {
112 return load_json_object(&path);
113 }
114 Ok(serde_json::json!({
115 "contract_version": CONTRACT_VERSION,
116 array_key: [],
117 }))
118}
119
120fn write_json(
122 artifact_root: &Path,
123 relative: &str,
124 value: &Value,
125) -> Result<(), FeatureCompileError> {
126 let path = safe_artifact_path(artifact_root, relative)?;
127 let parent = path
128 .parent()
129 .ok_or_else(|| FeatureCompileError::UnsafePath(relative.to_owned()))?;
130 fs::create_dir_all(parent)
131 .map_err(|_| FeatureCompileError::Io(parent.to_string_lossy().into_owned()))?;
132 let encoded = serde_json::to_string_pretty(value)
133 .map_err(|_| FeatureCompileError::InvalidDocument(relative.to_owned()))?;
134 pharos_runtime_core::fs::write_file_within(artifact_root, &path, encoded.as_bytes(), 0o644)
135 .map_err(|_| FeatureCompileError::Io(relative.to_owned()))
136}
137
138fn feature_paths(artifact_root: &Path) -> Result<Vec<PathBuf>, FeatureCompileError> {
140 let directory = artifact_root.join("data/features");
141 let mut paths = Vec::new();
142 if directory.is_dir() {
143 for entry in fs::read_dir(&directory)
144 .map_err(|_| FeatureCompileError::Io(directory.to_string_lossy().into_owned()))?
145 {
146 let entry = entry
147 .map_err(|_| FeatureCompileError::Io(directory.to_string_lossy().into_owned()))?;
148 let path = entry.path();
149 let metadata = fs::symlink_metadata(&path)
150 .map_err(|_| FeatureCompileError::Io(path.to_string_lossy().into_owned()))?;
151 if metadata.file_type().is_symlink() || metadata.is_dir() {
152 return Err(FeatureCompileError::UnsafePath(
153 path.to_string_lossy().into_owned(),
154 ));
155 }
156 if metadata.is_file()
157 && path.extension().and_then(|value| value.to_str()) == Some("json")
158 {
159 paths.push(path);
160 }
161 if paths.len() > MAX_FEATURE_FILES {
162 return Err(FeatureCompileError::InvalidDocument(
163 "too many feature files".to_owned(),
164 ));
165 }
166 }
167 }
168 if paths.is_empty() {
169 let contract_path = artifact_root.join("data/contracts/features.json");
170 if contract_path.is_file() {
171 let metadata = fs::symlink_metadata(&contract_path).map_err(|_| {
172 FeatureCompileError::Io(contract_path.to_string_lossy().into_owned())
173 })?;
174 if metadata.file_type().is_symlink() || !metadata.is_file() {
175 return Err(FeatureCompileError::UnsafePath(
176 contract_path.to_string_lossy().into_owned(),
177 ));
178 }
179 paths.push(contract_path);
180 }
181 }
182 paths.sort();
183 Ok(paths)
184}
185
186fn feature_source_documents(
187 path: &Path,
188 document: Value,
189) -> Result<Vec<Value>, FeatureCompileError> {
190 if document.get("feature_id").and_then(Value::as_str).is_some() {
191 return Ok(vec![document]);
192 }
193 if let Some(features) = document.get("features").and_then(Value::as_array) {
194 if features.len() > MAX_FEATURE_FILES {
195 return Err(FeatureCompileError::InvalidDocument(
196 path.to_string_lossy().into_owned(),
197 ));
198 }
199 return Ok(features.clone());
200 }
201 Err(FeatureCompileError::InvalidDocument(
202 path.to_string_lossy().into_owned(),
203 ))
204}
205
206fn route_path(value: &str) -> Result<String, FeatureCompileError> {
208 if !value.starts_with('/')
209 || value.len() > 512
210 || value.contains(['?', '#', '\r', '\n', '\0'])
211 || value.split('/').any(|segment| segment == "..")
212 {
213 return Err(FeatureCompileError::UnsafePath(value.to_owned()));
214 }
215 Ok(value.to_owned())
216}
217
218fn declaration_map<'a>(
220 feature: &'a Value,
221 key: &str,
222) -> Result<&'a Map<String, Value>, FeatureCompileError> {
223 match feature.get(key) {
224 None => {
225 static EMPTY: std::sync::OnceLock<Map<String, Value>> = std::sync::OnceLock::new();
226 Ok(EMPTY.get_or_init(Map::new))
227 }
228 Some(value) => value
229 .as_object()
230 .ok_or_else(|| FeatureCompileError::InvalidDocument(key.to_owned())),
231 }
232}
233
234fn bundle_map(feature: &Value) -> Result<&Map<String, Value>, FeatureCompileError> {
236 declaration_map(feature, "bundles")
237}
238
239fn bundle_object<'a>(
241 bundle_id: &str,
242 bundle: &'a Value,
243) -> Result<&'a Map<String, Value>, FeatureCompileError> {
244 bundle
245 .as_object()
246 .ok_or_else(|| FeatureCompileError::InvalidDocument(format!("bundles.{bundle_id}")))
247}
248
249fn bundle_section<'a>(
251 bundle_id: &str,
252 bundle: &'a Map<String, Value>,
253 section: &str,
254) -> Result<&'a Map<String, Value>, FeatureCompileError> {
255 bundle
256 .get(section)
257 .and_then(Value::as_object)
258 .ok_or_else(|| {
259 FeatureCompileError::InvalidDocument(format!("bundles.{bundle_id}.{section}"))
260 })
261}
262
263fn insert_default_field(object: &mut Map<String, Value>, key: &str, value: Option<Value>) {
265 if !object.contains_key(key)
266 && let Some(value) = value
267 {
268 object.insert(key.to_owned(), value);
269 }
270}
271
272fn insert_default_response_field(object: &mut Map<String, Value>, key: &str, value: Option<Value>) {
274 if value.is_none() {
275 return;
276 }
277 if !object.contains_key("response") {
278 object.insert("response".to_owned(), Value::Object(Map::new()));
279 }
280 if let Some(response) = object.get_mut("response").and_then(Value::as_object_mut)
281 && !response.contains_key(key)
282 && let Some(value) = value
283 {
284 response.insert(key.to_owned(), value);
285 }
286}
287
288fn expand_bundle_page(
290 bundle_id: &str,
291 role: &str,
292 bundle: &Map<String, Value>,
293) -> Result<(String, Value, Value), FeatureCompileError> {
294 let mut page = bundle_section(bundle_id, bundle, role)?.clone();
295 let page_id = declaration_id(
296 page.get("page_id")
297 .and_then(Value::as_str)
298 .unwrap_or(&format!("{bundle_id}_{role}")),
299 )?;
300 insert_default_field(&mut page, "layout", bundle.get("layout").cloned());
301 insert_default_field(&mut page, "policy", bundle.get("policy").cloned());
302 insert_default_field(&mut page, "auth", bundle.get("auth").cloned());
303 let page_queries = page
304 .get("queries")
305 .cloned()
306 .or_else(|| Some(Value::Array(Vec::new())));
307 insert_default_response_field(&mut page, "target", bundle.get("target").cloned());
308 insert_default_response_field(&mut page, "history", bundle.get("history").cloned());
309 insert_default_response_field(&mut page, "queries", page_queries);
310 let summary = serde_json::json!({
311 "role": role,
312 "page_id": page_id,
313 "route_id": page.get("route_id").and_then(Value::as_str).map(ToOwned::to_owned),
314 "path": page.get("path").and_then(Value::as_str).map(ToOwned::to_owned),
315 "target": page.get("response").and_then(|value| value.get("target")).and_then(Value::as_str).map(ToOwned::to_owned),
316 "queries": page.get("response").and_then(|value| value.get("queries")).cloned().unwrap_or_else(|| Value::Array(Vec::new())),
317 });
318 Ok((page_id, Value::Object(page), summary))
319}
320
321fn expand_bundle_action_response(
323 bundle_id: &str,
324 role: &str,
325 bundle: &Map<String, Value>,
326 action: &Map<String, Value>,
327) -> Result<Value, FeatureCompileError> {
328 if let Some(response) = action.get("response") {
329 let mut response = response
330 .as_object()
331 .ok_or_else(|| {
332 FeatureCompileError::InvalidDocument(format!("bundles.{bundle_id}.{role}.response"))
333 })?
334 .clone();
335 insert_default_field(&mut response, "target", bundle.get("target").cloned());
336 insert_default_field(
337 &mut response,
338 "fallback",
339 bundle
340 .get("fallback_redirect")
341 .cloned()
342 .map(|redirect| serde_json::json!({"redirect": redirect})),
343 );
344 return Ok(Value::Object(response));
345 }
346 let render = declaration_id(action.get("render").and_then(Value::as_str).ok_or_else(
347 || FeatureCompileError::InvalidDocument(format!("bundles.{bundle_id}.{role}.render")),
348 )?)?;
349 let target = declaration_id(
350 action
351 .get("target")
352 .and_then(Value::as_str)
353 .or_else(|| bundle.get("target").and_then(Value::as_str))
354 .ok_or_else(|| {
355 FeatureCompileError::InvalidDocument(format!("bundles.{bundle_id}.{role}.target"))
356 })?,
357 )?;
358 let fallback = route_path(
359 action
360 .get("fallback")
361 .and_then(Value::as_str)
362 .or_else(|| bundle.get("fallback_redirect").and_then(Value::as_str))
363 .ok_or_else(|| {
364 FeatureCompileError::InvalidDocument(format!("bundles.{bundle_id}.{role}.fallback"))
365 })?,
366 )?;
367 let status = action
368 .get("status")
369 .cloned()
370 .unwrap_or_else(|| Value::from(200));
371 Ok(serde_json::json!({
372 "render": render,
373 "target": target,
374 "queries": action.get("queries").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
375 "fallback": {"redirect": fallback},
376 "status": status,
377 }))
378}
379
380fn expand_bundle_action(
382 bundle_id: &str,
383 role: &str,
384 bundle: &Map<String, Value>,
385) -> Result<(String, Value, Value), FeatureCompileError> {
386 let section = bundle_section(bundle_id, bundle, role)?;
387 let mut action = section.clone();
388 let action_id = declaration_id(
389 action
390 .get("action_id")
391 .and_then(Value::as_str)
392 .unwrap_or(&format!("{bundle_id}_{role}")),
393 )?;
394 insert_default_field(
395 &mut action,
396 "method",
397 Some(Value::String("POST".to_owned())),
398 );
399 insert_default_field(&mut action, "policy", bundle.get("policy").cloned());
400 if !action.contains_key("response") {
401 action.insert(
402 "response".to_owned(),
403 expand_bundle_action_response(bundle_id, role, bundle, section)?,
404 );
405 } else if let Some(response) = action.get_mut("response").and_then(Value::as_object_mut) {
406 insert_default_field(response, "target", bundle.get("target").cloned());
407 insert_default_field(
408 response,
409 "fallback",
410 bundle
411 .get("fallback_redirect")
412 .cloned()
413 .map(|redirect| serde_json::json!({"redirect": redirect})),
414 );
415 }
416 let summary = serde_json::json!({
417 "role": role,
418 "action_id": action_id,
419 "route_id": action.get("route_id").and_then(Value::as_str).map(ToOwned::to_owned),
420 "path": action.get("path").and_then(Value::as_str).map(ToOwned::to_owned),
421 "method": action.get("method").and_then(Value::as_str).map(ToOwned::to_owned),
422 "target": action.get("response").and_then(|value| value.get("target")).and_then(Value::as_str).map(ToOwned::to_owned),
423 "queries": action.get("response").and_then(|value| value.get("queries")).cloned().unwrap_or_else(|| Value::Array(Vec::new())),
424 "render": action.get("response").and_then(|value| value.get("render")).and_then(Value::as_str).map(ToOwned::to_owned),
425 "fallback": action.get("response").and_then(|value| value.get("fallback")).and_then(|value| value.get("redirect")).and_then(Value::as_str).map(ToOwned::to_owned),
426 });
427 Ok((action_id, Value::Object(action), summary))
428}
429
430type ExpandedFeatureBundles = (Map<String, Value>, Map<String, Value>, Vec<Value>);
432
433pub type ExpandedFeatureDeclarations = (Map<String, Value>, Map<String, Value>, Vec<Value>);
435
436pub fn feature_pages_and_actions(
438 feature_id: &str,
439 feature: &Value,
440) -> Result<ExpandedFeatureDeclarations, FeatureCompileError> {
441 declaration_id(feature_id)?;
442 let (bundle_pages, bundle_actions, summaries) = expand_feature_bundles(feature_id, feature)?;
443 let mut pages = declaration_map(feature, "pages")?.clone();
444 let mut actions = declaration_map(feature, "actions")?.clone();
445 for (id, page) in bundle_pages {
446 if pages.insert(id, page).is_some() {
447 return Err(FeatureCompileError::DuplicateId(format!(
448 "{feature_id}.pages"
449 )));
450 }
451 }
452 for (id, action) in bundle_actions {
453 if actions.insert(id, action).is_some() {
454 return Err(FeatureCompileError::DuplicateId(format!(
455 "{feature_id}.actions"
456 )));
457 }
458 }
459 Ok((pages, actions, summaries))
460}
461
462fn expand_feature_bundles(
464 feature_id: &str,
465 feature: &Value,
466) -> Result<ExpandedFeatureBundles, FeatureCompileError> {
467 let mut pages = Map::new();
468 let mut actions = Map::new();
469 let mut bundles = Vec::new();
470 for (bundle_id, bundle_value) in bundle_map(feature)? {
471 let bundle_id = declaration_id(bundle_id)?;
472 let bundle = bundle_object(&bundle_id, bundle_value)?;
473 let kind = bundle.get("kind").and_then(Value::as_str).ok_or_else(|| {
474 FeatureCompileError::InvalidDocument(format!("bundles.{bundle_id}.kind"))
475 })?;
476 let (page_roles, action_roles): (&[&str], &[&str]) = match kind {
477 "list_detail" => (&["list", "detail"], &[]),
478 "list_create" => (&["list"], &["create"]),
479 "detail_edit" => (&["detail", "edit"], &["update"]),
480 "crud" => (&["list", "detail", "edit"], &["create", "update"]),
481 _ => {
482 return Err(FeatureCompileError::InvalidDocument(format!(
483 "bundles.{bundle_id}.kind"
484 )));
485 }
486 };
487 let mut bundle_pages = Vec::new();
488 let mut bundle_actions = Vec::new();
489 for role in page_roles {
490 let (page_id, page, summary) = expand_bundle_page(&bundle_id, role, bundle)?;
491 if pages.insert(page_id.clone(), page).is_some() {
492 return Err(FeatureCompileError::DuplicateId(page_id));
493 }
494 bundle_pages.push(summary);
495 }
496 for role in action_roles {
497 let (action_id, action, summary) = expand_bundle_action(&bundle_id, role, bundle)?;
498 if actions.insert(action_id.clone(), action).is_some() {
499 return Err(FeatureCompileError::DuplicateId(action_id));
500 }
501 bundle_actions.push(summary);
502 }
503 bundles.push(serde_json::json!({
504 "feature_id": feature_id,
505 "bundle_id": bundle_id,
506 "bundle_kind": kind,
507 "target": bundle.get("target"),
508 "history": bundle.get("history"),
509 "fallback_redirect": bundle.get("fallback_redirect"),
510 "pages": bundle_pages,
511 "actions": bundle_actions,
512 }));
513 }
514 Ok((pages, actions, bundles))
515}
516
517fn validate_response_queries(
519 feature_id: &str,
520 queries: &Map<String, Value>,
521 response: &Value,
522) -> Result<(), FeatureCompileError> {
523 if let Some(variants) = response.get("variants").and_then(Value::as_object) {
524 if variants.is_empty() || variants.len() > 16 {
525 return Err(FeatureCompileError::InvalidDocument(format!(
526 "{feature_id}.response.variants"
527 )));
528 }
529 for (variant_id, variant) in variants {
530 declaration_id(variant_id)?;
531 validate_response_queries(feature_id, queries, variant)?;
532 }
533 return Ok(());
534 }
535 for query in response
536 .get("queries")
537 .and_then(Value::as_array)
538 .into_iter()
539 .flatten()
540 {
541 let query_id = query
542 .as_str()
543 .ok_or_else(|| FeatureCompileError::InvalidDocument(feature_id.to_owned()))?;
544 declaration_id(query_id)?;
545 if !queries.contains_key(query_id) {
546 return Err(FeatureCompileError::MissingReference(format!(
547 "{feature_id}:{query_id}"
548 )));
549 }
550 }
551 Ok(())
552}
553
554fn find_template_by_stem(artifact_root: &Path, stem: &str) -> Result<PathBuf, FeatureCompileError> {
556 declaration_id(stem)?;
557 let templates = artifact_root.join("templates");
558 let mut pending = vec![(templates, 0usize)];
559 let mut visited = 0usize;
560 let mut found = None;
561 while let Some((directory, depth)) = pending.pop() {
562 if depth > MAX_TEMPLATE_DEPTH {
563 return Err(FeatureCompileError::InvalidDocument(
564 "template depth exceeded".to_owned(),
565 ));
566 }
567 let mut entries = fs::read_dir(&directory)
568 .map_err(|_| FeatureCompileError::Io(directory.to_string_lossy().into_owned()))?
569 .collect::<Result<Vec<_>, _>>()
570 .map_err(|_| FeatureCompileError::Io(directory.to_string_lossy().into_owned()))?;
571 entries.sort_by_key(|entry| entry.file_name());
572 for entry in entries {
573 let path = entry.path();
574 let metadata = fs::symlink_metadata(&path)
575 .map_err(|_| FeatureCompileError::Io(path.to_string_lossy().into_owned()))?;
576 if metadata.file_type().is_symlink() {
577 return Err(FeatureCompileError::UnsafePath(
578 path.to_string_lossy().into_owned(),
579 ));
580 }
581 if metadata.is_dir() {
582 pending.push((path, depth + 1));
583 continue;
584 }
585 if !metadata.is_file() {
586 continue;
587 }
588 visited += 1;
589 if visited > MAX_TEMPLATE_FILES {
590 return Err(FeatureCompileError::InvalidDocument(
591 "too many template files".to_owned(),
592 ));
593 }
594 if path.extension().and_then(|value| value.to_str()) == Some("html")
595 && path.file_stem().and_then(|value| value.to_str()) == Some(stem)
596 && found.replace(path).is_some()
597 {
598 return Err(FeatureCompileError::DuplicateId(stem.to_owned()));
599 }
600 }
601 }
602 found.ok_or_else(|| FeatureCompileError::MissingReference(stem.to_owned()))
603}
604
605fn collect_transaction_effect(
607 operation: &str,
608 value: &Value,
609 reads: &mut BTreeSet<String>,
610 writes: &mut BTreeSet<String>,
611) -> Result<(), FeatureCompileError> {
612 let entity = value
613 .get("entity")
614 .and_then(Value::as_str)
615 .map(declaration_id)
616 .transpose()?;
617 match operation {
618 "require" | "select" | "bind_inserted" => {
619 if let Some(entity) = entity {
620 reads.insert(format!("entity:{entity}"));
621 } else if value.get("query").and_then(Value::as_str).is_none() {
622 return Err(FeatureCompileError::InvalidDocument(operation.to_owned()));
623 }
624 }
625 "insert" | "insert_from" | "update" | "delete" | "upsert" | "allocate_sequence"
626 | "reserve" | "confirm" | "release" => {
627 let entity =
628 entity.ok_or_else(|| FeatureCompileError::InvalidDocument(operation.to_owned()))?;
629 reads.insert(format!("entity:{entity}"));
630 writes.insert(format!("entity:{entity}"));
631 if operation == "allocate_sequence" {
632 let counter = value
633 .get("counter")
634 .and_then(Value::as_object)
635 .ok_or_else(|| {
636 FeatureCompileError::InvalidDocument("allocate_sequence.counter".to_owned())
637 })?;
638 let counter_entity =
639 counter
640 .get("entity")
641 .and_then(Value::as_str)
642 .ok_or_else(|| {
643 FeatureCompileError::InvalidDocument("counter.entity".to_owned())
644 })?;
645 let counter_entity = declaration_id(counter_entity)?;
646 reads.insert(format!("entity:{counter_entity}"));
647 writes.insert(format!("entity:{counter_entity}"));
648 if let Some(seed) = counter.get("seed") {
649 let seed_entity =
650 seed.get("entity").and_then(Value::as_str).ok_or_else(|| {
651 FeatureCompileError::InvalidDocument("counter.seed.entity".to_owned())
652 })?;
653 reads.insert(format!("entity:{}", declaration_id(seed_entity)?));
654 }
655 }
656 }
657 "assert" | "values" | "idempotency" => {}
658 _ => {
659 return Err(FeatureCompileError::InvalidDocument(format!(
660 "transaction.{operation}"
661 )));
662 }
663 }
664 Ok(())
665}
666
667fn transaction_effects(transaction: &Value) -> Result<Value, FeatureCompileError> {
669 if let Some(variants) = transaction.get("variants").and_then(Value::as_object) {
670 let selector = transaction
671 .get("select")
672 .and_then(Value::as_str)
673 .ok_or_else(|| FeatureCompileError::InvalidDocument("transaction.select".to_owned()))?;
674 let selector_field = selector
675 .strip_prefix("input.")
676 .ok_or_else(|| FeatureCompileError::InvalidDocument("transaction.select".to_owned()))?;
677 declaration_id(selector_field)?;
678 if variants.is_empty() || variants.len() > 16 {
679 return Err(FeatureCompileError::InvalidDocument(
680 "transaction.variants".to_owned(),
681 ));
682 }
683 let mut reads = BTreeSet::new();
684 let mut writes = BTreeSet::new();
685 for (variant_id, variant) in variants {
686 declaration_id(variant_id)?;
687 let effects = transaction_effects(variant)?;
688 for (key, output) in [("reads", &mut reads), ("writes", &mut writes)] {
689 for effect in effects
690 .get(key)
691 .and_then(Value::as_array)
692 .into_iter()
693 .flatten()
694 {
695 output.insert(
696 effect
697 .as_str()
698 .ok_or_else(|| {
699 FeatureCompileError::InvalidDocument(
700 "transaction.effects".to_owned(),
701 )
702 })?
703 .to_owned(),
704 );
705 }
706 }
707 }
708 return Ok(serde_json::json!({"reads": reads, "writes": writes}));
709 }
710 let mut reads = BTreeSet::new();
711 let mut writes = BTreeSet::new();
712 let object = transaction
713 .as_object()
714 .ok_or_else(|| FeatureCompileError::InvalidDocument("transaction".to_owned()))?;
715 if let Some(steps) = object.get("steps").and_then(Value::as_array) {
716 if steps.is_empty() || steps.len() > 64 {
717 return Err(FeatureCompileError::InvalidDocument(
718 "transaction.steps".to_owned(),
719 ));
720 }
721 for step in steps {
722 let step = step.as_object().ok_or_else(|| {
723 FeatureCompileError::InvalidDocument("transaction.step".to_owned())
724 })?;
725 if step.len() != 1 {
726 return Err(FeatureCompileError::InvalidDocument(
727 "transaction.step".to_owned(),
728 ));
729 }
730 let (operation, value) = step.iter().next().expect("one transaction step");
731 collect_transaction_effect(operation, value, &mut reads, &mut writes)?;
732 }
733 } else {
734 for (operation, value) in object {
735 collect_transaction_effect(operation, value, &mut reads, &mut writes)?;
736 }
737 }
738 Ok(serde_json::json!({
739 "reads": reads,
740 "writes": writes,
741 }))
742}
743
744fn compile_response_variant(
746 artifact_root: &Path,
747 feature_id: &str,
748 action_id: &str,
749 response: &Value,
750 queries: &Map<String, Value>,
751 inherited_target: Option<&str>,
752) -> Result<Value, FeatureCompileError> {
753 validate_response_queries(feature_id, queries, response)?;
754 let status = response
755 .get("status")
756 .and_then(Value::as_u64)
757 .unwrap_or(200);
758 if !(200..=599).contains(&status) {
759 return Err(FeatureCompileError::InvalidDocument(format!(
760 "{action_id}.response.status"
761 )));
762 }
763 if response.get("response_kind").and_then(Value::as_str) == Some("redirect") {
764 let redirect = route_path(
765 response
766 .get("fallback")
767 .and_then(|value| value.get("redirect"))
768 .and_then(Value::as_str)
769 .ok_or_else(|| FeatureCompileError::InvalidDocument(action_id.to_owned()))?,
770 )?;
771 return Ok(serde_json::json!({
772 "response_kind": "redirect",
773 "fallback": {"redirect": redirect},
774 "status": status,
775 }));
776 }
777 if response.get("response_kind").and_then(Value::as_str) == Some("json") {
778 let surface = declaration_id(
779 response
780 .get("surface")
781 .and_then(Value::as_str)
782 .ok_or_else(|| FeatureCompileError::InvalidDocument(action_id.to_owned()))?,
783 )?;
784 let payload_query = declaration_id(
785 response
786 .get("payload_query")
787 .and_then(Value::as_str)
788 .ok_or_else(|| FeatureCompileError::InvalidDocument(action_id.to_owned()))?,
789 )?;
790 if !queries.contains_key(&payload_query) {
791 return Err(FeatureCompileError::MissingReference(format!(
792 "{feature_id}:{payload_query}"
793 )));
794 }
795 let mut compiled = serde_json::json!({
796 "response_kind": "json",
797 "surface": surface,
798 "payload_query": payload_query,
799 "status": status,
800 });
801 if let Some(payload_query_before) =
802 response.get("payload_query_before").and_then(Value::as_str)
803 {
804 let payload_query_before = declaration_id(payload_query_before)?;
805 if !queries.contains_key(&payload_query_before) {
806 return Err(FeatureCompileError::MissingReference(format!(
807 "{feature_id}:{payload_query_before}"
808 )));
809 }
810 compiled["payload_query_before"] = Value::String(payload_query_before);
811 }
812 return Ok(compiled);
813 }
814 let render = declaration_id(
815 response
816 .get("render")
817 .and_then(Value::as_str)
818 .ok_or_else(|| FeatureCompileError::InvalidDocument(action_id.to_owned()))?,
819 )?;
820 let fragment_path = find_template_by_stem(artifact_root, &render)?;
821 let fragment_relative = fragment_path
822 .strip_prefix(artifact_root)
823 .map_err(|_| FeatureCompileError::UnsafePath(render.clone()))?
824 .to_string_lossy()
825 .into_owned();
826 let target = declaration_id(
827 response
828 .get("target")
829 .and_then(Value::as_str)
830 .or(inherited_target)
831 .ok_or_else(|| FeatureCompileError::InvalidDocument(action_id.to_owned()))?,
832 )?;
833 let fallback = route_path(
834 response
835 .get("fallback")
836 .and_then(|value| value.get("redirect"))
837 .and_then(Value::as_str)
838 .ok_or_else(|| FeatureCompileError::InvalidDocument(action_id.to_owned()))?,
839 )?;
840 Ok(serde_json::json!({
841 "response_kind": "fragment",
842 "render": render,
843 "template": fragment_relative,
844 "target": target,
845 "queries": response.get("queries").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
846 "fallback": {"redirect": fallback},
847 "status": status,
848 }))
849}
850
851fn compile_action_response(
853 artifact_root: &Path,
854 feature_id: &str,
855 action_id: &str,
856 response: &Value,
857 queries: &Map<String, Value>,
858) -> Result<Value, FeatureCompileError> {
859 let Some(variants) = response.get("variants").and_then(Value::as_object) else {
860 return compile_response_variant(
861 artifact_root,
862 feature_id,
863 action_id,
864 response,
865 queries,
866 None,
867 );
868 };
869 let selector = response
870 .get("select")
871 .and_then(Value::as_str)
872 .ok_or_else(|| FeatureCompileError::InvalidDocument(format!("{action_id}.response")))?;
873 let selector_field = selector
874 .strip_prefix("input.")
875 .ok_or_else(|| FeatureCompileError::InvalidDocument(format!("{action_id}.response")))?;
876 declaration_id(selector_field)?;
877 let target = declaration_id(
878 response
879 .get("target")
880 .and_then(Value::as_str)
881 .ok_or_else(|| FeatureCompileError::InvalidDocument(format!("{action_id}.response")))?,
882 )?;
883 if variants.is_empty() || variants.len() > 16 {
884 return Err(FeatureCompileError::InvalidDocument(format!(
885 "{action_id}.response.variants"
886 )));
887 }
888 let mut compiled = Map::new();
889 for (variant_id, variant) in variants {
890 declaration_id(variant_id)?;
891 compiled.insert(
892 variant_id.clone(),
893 compile_response_variant(
894 artifact_root,
895 feature_id,
896 action_id,
897 variant,
898 queries,
899 Some(&target),
900 )?,
901 );
902 }
903 Ok(serde_json::json!({
904 "select": selector,
905 "target": target,
906 "variants": compiled,
907 }))
908}
909
910fn validate_action_variants(
912 action_id: &str,
913 action: &Value,
914 contract: &Value,
915) -> Result<(), FeatureCompileError> {
916 let Some(variants) = contract.get("variants").and_then(Value::as_object) else {
917 return Ok(());
918 };
919 let selector = contract
920 .get("select")
921 .and_then(Value::as_str)
922 .and_then(|value| value.strip_prefix("input."))
923 .ok_or_else(|| FeatureCompileError::InvalidDocument(format!("{action_id}.select")))?;
924 declaration_id(selector)?;
925 let rules = action
926 .get("input")
927 .and_then(Value::as_object)
928 .and_then(|fields| fields.get(selector))
929 .ok_or_else(|| FeatureCompileError::MissingReference(format!("{action_id}:{selector}")))?;
930 if rules.get("type").and_then(Value::as_str) != Some("enum") {
931 return Err(FeatureCompileError::InvalidDocument(format!(
932 "{action_id}.{selector}"
933 )));
934 }
935 let values = rules
936 .get("values")
937 .and_then(Value::as_array)
938 .ok_or_else(|| FeatureCompileError::InvalidDocument(format!("{action_id}.{selector}")))?;
939 let declared = values
940 .iter()
941 .map(|value| {
942 value
943 .as_str()
944 .ok_or_else(|| {
945 FeatureCompileError::InvalidDocument(format!("{action_id}.{selector}"))
946 })
947 .and_then(declaration_id)
948 })
949 .collect::<Result<BTreeSet<_>, _>>()?;
950 let variant_ids = variants.keys().cloned().collect::<BTreeSet<_>>();
951 if declared != variant_ids {
952 return Err(FeatureCompileError::InvalidDocument(format!(
953 "{action_id}.{selector}.variants"
954 )));
955 }
956 Ok(())
957}
958
959fn compile_page(
961 artifact_root: &Path,
962 feature_id: &str,
963 page_id: &str,
964 page: &Value,
965 queries: &Map<String, Value>,
966) -> Result<(Value, Value), FeatureCompileError> {
967 let page_id = declaration_id(page_id)?;
968 let path = route_path(
969 page.get("path")
970 .and_then(Value::as_str)
971 .ok_or_else(|| FeatureCompileError::InvalidDocument(page_id.clone()))?,
972 )?;
973 let template = page
974 .get("template")
975 .and_then(Value::as_str)
976 .ok_or_else(|| FeatureCompileError::InvalidDocument(page_id.clone()))?;
977 let template_path = safe_artifact_path(artifact_root, template)?;
978 if !template.starts_with("templates/") || !template_path.is_file() {
979 return Err(FeatureCompileError::MissingReference(template.to_owned()));
980 }
981 let policy = declaration_id(
982 page.get("policy")
983 .and_then(Value::as_str)
984 .unwrap_or("public"),
985 )?;
986 let auth = page
987 .get("auth")
988 .and_then(Value::as_str)
989 .unwrap_or(if policy == "public" {
990 "anonymous_or_session"
991 } else {
992 "session_required"
993 });
994 let response = page
995 .get("response")
996 .cloned()
997 .unwrap_or_else(|| serde_json::json!({}));
998 validate_response_queries(feature_id, queries, &response)?;
999 if let Some(target) = response.get("target").and_then(Value::as_str) {
1000 declaration_id(target)?;
1001 }
1002 let default_route_id = format!("{feature_id}_{page_id}");
1003 let route_id = declaration_id(
1004 page.get("route_id")
1005 .and_then(Value::as_str)
1006 .unwrap_or(&default_route_id),
1007 )?;
1008 let page_spec = serde_json::json!({
1009 "page_id": page_id,
1010 "title": page.get("title").and_then(Value::as_str).unwrap_or(&page_id),
1011 "layout_id": page.get("layout").and_then(Value::as_str).unwrap_or("default_shell"),
1012 "template_file": template,
1013 "template_role": "page_body",
1014 "feature_id": feature_id,
1015 });
1016 let route = serde_json::json!({
1017 "route_id": route_id,
1018 "path": path,
1019 "kind": "page",
1020 "method": "GET",
1021 "page_id": page_id,
1022 "layout_id": page_spec.get("layout_id").cloned().unwrap_or(Value::Null),
1023 "auth_gate": auth,
1024 "policy_gate": policy,
1025 "feature_id": feature_id,
1026 "feature_page": page_id,
1027 "queries": page.get("queries").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
1028 "response_contract": response,
1029 });
1030 Ok((route, page_spec))
1031}
1032
1033fn compile_action(
1035 artifact_root: &Path,
1036 feature_id: &str,
1037 action_id: &str,
1038 action: &Value,
1039 queries: &Map<String, Value>,
1040) -> Result<(Value, Value), FeatureCompileError> {
1041 let action_id = declaration_id(action_id)?;
1042 let path = route_path(
1043 action
1044 .get("path")
1045 .and_then(Value::as_str)
1046 .ok_or_else(|| FeatureCompileError::InvalidDocument(action_id.clone()))?,
1047 )?;
1048 let method = action
1049 .get("method")
1050 .and_then(Value::as_str)
1051 .unwrap_or("POST")
1052 .to_ascii_uppercase();
1053 if !matches!(method.as_str(), "POST" | "PUT" | "PATCH" | "DELETE") {
1054 return Err(FeatureCompileError::InvalidDocument(method));
1055 }
1056 let policy = declaration_id(
1057 action
1058 .get("policy")
1059 .and_then(Value::as_str)
1060 .unwrap_or("public"),
1061 )?;
1062 let response = action
1063 .get("response")
1064 .ok_or_else(|| FeatureCompileError::InvalidDocument(action_id.clone()))?;
1065 let transaction = action
1066 .get("transaction")
1067 .ok_or_else(|| FeatureCompileError::InvalidDocument(action_id.clone()))?;
1068 validate_action_variants(&action_id, action, transaction)?;
1069 validate_action_variants(&action_id, action, response)?;
1070 let compiled_response =
1071 compile_action_response(artifact_root, feature_id, &action_id, response, queries)?;
1072 let effects = transaction_effects(transaction)?;
1073 let default_route_id = format!("{feature_id}_{action_id}");
1074 let route_id = declaration_id(
1075 action
1076 .get("route_id")
1077 .and_then(Value::as_str)
1078 .unwrap_or(&default_route_id),
1079 )?;
1080 let route = serde_json::json!({
1081 "route_id": route_id,
1082 "path": path,
1083 "kind": "action",
1084 "method": method,
1085 "action_id": action_id,
1086 "auth_gate": if policy == "public" { "anonymous_or_session" } else { "session_required" },
1087 "policy_gate": policy,
1088 "feature_id": feature_id,
1089 "request_hooks": action
1090 .get("request_hooks")
1091 .cloned()
1092 .unwrap_or_else(|| Value::Array(Vec::new())),
1093 });
1094 let contract = serde_json::json!({
1095 "action_id": action_id,
1096 "policy_gate": policy,
1097 "feature_id": feature_id,
1098 "input_contract": {
1099 "inline_fields": action.get("input").cloned().unwrap_or_else(|| serde_json::json!({})),
1100 "reject_unknown": true,
1101 },
1102 "transaction_contract": transaction,
1103 "response_contract": compiled_response,
1104 "effect_contract": effects,
1105 "guard_contract": {
1106 "guards": action
1107 .get("guards")
1108 .cloned()
1109 .unwrap_or_else(|| Value::Array(Vec::new()))
1110 },
1111 "hook_contract": action
1112 .get("hook_contract")
1113 .cloned()
1114 .unwrap_or_else(|| serde_json::json!({})),
1115 });
1116 Ok((route, contract))
1117}
1118
1119fn compile_form_attributes(
1121 html: &str,
1122 actions: &BTreeMap<String, (String, String, String)>,
1123 base_path: &str,
1124) -> Result<String, FeatureCompileError> {
1125 let mut output = html.to_owned();
1126 for (action_id, (method, path, _target)) in actions {
1127 let action_attribute = format!(" pharos-action=\"{action_id}\"");
1128 let method_lower = method.to_ascii_lowercase();
1129 let scoped_path = if base_path.is_empty() || base_path == "/" {
1130 path.clone()
1131 } else {
1132 format!("{}{}", base_path.trim_end_matches('/'), path)
1133 };
1134 let replacement = format!(
1135 " method=\"{method_lower}\" action=\"{scoped_path}\" data-pharos-action=\"{scoped_path}\""
1136 );
1137 output = output.replace(&action_attribute, &replacement);
1138 }
1139 output = compile_form_target_attributes(&output)?;
1140 if output.contains(" pharos-action=") || output.contains(" pharos-target=") {
1141 return Err(FeatureCompileError::MissingReference(
1142 "unresolved Pharos form attribute".to_owned(),
1143 ));
1144 }
1145 Ok(output)
1146}
1147
1148fn compile_form_target_attributes(html: &str) -> Result<String, FeatureCompileError> {
1150 const MARKER: &str = " pharos-target=\"";
1151 let mut remaining = html;
1152 let mut output = String::with_capacity(html.len());
1153 while let Some(position) = remaining.find(MARKER) {
1154 output.push_str(&remaining[..position]);
1155 remaining = &remaining[position + MARKER.len()..];
1156 let Some(end) = remaining.find('"') else {
1157 return Err(FeatureCompileError::MissingReference(
1158 "unresolved Pharos form attribute".to_owned(),
1159 ));
1160 };
1161 let target = &remaining[..end];
1162 let target_id = declaration_id(target)?;
1163 output.push_str(" data-pharos-target=\"");
1164 output.push_str(&target_id);
1165 output.push('"');
1166 remaining = &remaining[end + 1..];
1167 }
1168 output.push_str(remaining);
1169 Ok(output)
1170}
1171
1172fn compile_navigation_attributes(html: &str) -> Result<String, FeatureCompileError> {
1174 let mut output = html.to_owned();
1175 for (source, compiled) in [
1176 (" pharos-nav=\"", " data-pharos-nav=\""),
1177 (" pharos-get=\"", " data-pharos-get=\""),
1178 ] {
1179 output = output.replace(source, compiled);
1180 }
1181 for marker in ["data-pharos-nav=\"", "data-pharos-get=\""] {
1182 for suffix in output.split(marker).skip(1) {
1183 let target = suffix.split('"').next().unwrap_or_default();
1184 declaration_id(target)?;
1185 }
1186 }
1187 Ok(output)
1188}
1189
1190fn compile_template_tree(
1192 artifact_root: &Path,
1193 actions: &BTreeMap<String, (String, String, String)>,
1194) -> Result<(), FeatureCompileError> {
1195 let base_path = pharos_runtime_core::config::app_local_conf_path(artifact_root)
1196 .and_then(|path| pharos_runtime_core::config::dynamic_config_lookup_raw(&path, "base_path"))
1197 .filter(|value| value.starts_with('/') && !value.contains(['\r', '\n']))
1198 .unwrap_or_default();
1199 write_json_asset(
1200 artifact_root,
1201 "static/pharos/interaction-v1.js",
1202 INTERACTION_CLIENT.as_bytes(),
1203 )?;
1204 let interaction_version = Sha256::digest(INTERACTION_CLIENT.as_bytes())
1205 .iter()
1206 .take(8)
1207 .map(|byte| format!("{byte:02x}"))
1208 .collect::<String>();
1209 let templates = artifact_root.join("templates");
1210 let mut pending = vec![(templates, 0usize)];
1211 let mut visited = 0usize;
1212 while let Some((directory, depth)) = pending.pop() {
1213 if depth > MAX_TEMPLATE_DEPTH {
1214 return Err(FeatureCompileError::InvalidDocument(
1215 "template depth exceeded".to_owned(),
1216 ));
1217 }
1218 for entry in fs::read_dir(&directory)
1219 .map_err(|_| FeatureCompileError::Io(directory.to_string_lossy().into_owned()))?
1220 {
1221 let entry = entry
1222 .map_err(|_| FeatureCompileError::Io(directory.to_string_lossy().into_owned()))?;
1223 let path = entry.path();
1224 let metadata = fs::symlink_metadata(&path)
1225 .map_err(|_| FeatureCompileError::Io(path.to_string_lossy().into_owned()))?;
1226 if metadata.file_type().is_symlink() {
1227 return Err(FeatureCompileError::UnsafePath(
1228 path.to_string_lossy().into_owned(),
1229 ));
1230 }
1231 if metadata.is_dir() {
1232 pending.push((path, depth + 1));
1233 continue;
1234 }
1235 if !metadata.is_file()
1236 || path.extension().and_then(|value| value.to_str()) != Some("html")
1237 {
1238 continue;
1239 }
1240 visited += 1;
1241 if visited > MAX_TEMPLATE_FILES {
1242 return Err(FeatureCompileError::InvalidDocument(
1243 "too many template files".to_owned(),
1244 ));
1245 }
1246 let source = fs::read_to_string(&path)
1247 .map_err(|_| FeatureCompileError::Io(path.to_string_lossy().into_owned()))?;
1248 let mut compiled = compile_form_attributes(&source, actions, &base_path)?;
1249 compiled = compile_navigation_attributes(&compiled)?;
1250 if path
1251 .components()
1252 .any(|component| component.as_os_str() == "layout")
1253 && !compiled.contains("/static/pharos/interaction-v1.js")
1254 && let Some(position) = compiled.rfind("</body>")
1255 {
1256 let source = format!(
1257 "{}/static/pharos/interaction-v1.js?v={interaction_version}",
1258 base_path.trim_end_matches('/'),
1259 );
1260 compiled.insert_str(
1261 position,
1262 &format!(" <script src=\"{source}\" defer></script>\n"),
1263 );
1264 }
1265 if compiled != source {
1266 pharos_runtime_core::fs::write_file_within(
1267 artifact_root,
1268 &path,
1269 compiled.as_bytes(),
1270 0o644,
1271 )
1272 .map_err(|_| FeatureCompileError::Io(path.to_string_lossy().into_owned()))?;
1273 }
1274 }
1275 }
1276 Ok(())
1277}
1278
1279fn write_json_asset(
1281 artifact_root: &Path,
1282 relative: &str,
1283 bytes: &[u8],
1284) -> Result<(), FeatureCompileError> {
1285 let path = safe_artifact_path(artifact_root, relative)?;
1286 let parent = path
1287 .parent()
1288 .ok_or_else(|| FeatureCompileError::UnsafePath(relative.to_owned()))?;
1289 fs::create_dir_all(parent)
1290 .map_err(|_| FeatureCompileError::Io(parent.to_string_lossy().into_owned()))?;
1291 pharos_runtime_core::fs::write_file_within(artifact_root, &path, bytes, 0o644)
1292 .map_err(|_| FeatureCompileError::Io(relative.to_owned()))
1293}
1294
1295pub fn compile_feature_sources(artifact_root: &Path) -> Result<usize, FeatureCompileError> {
1323 let paths = feature_paths(artifact_root)?;
1324 write_json_asset(
1325 artifact_root,
1326 "static/pharos/editable-grouping-v1.css",
1327 include_bytes!("../assets/editable-grouping-v1.css"),
1328 )?;
1329 if paths.is_empty() {
1330 return Ok(0);
1331 }
1332 let mut routes = load_contract_or_empty(artifact_root, "routes", "routes")?;
1333 let mut actions = load_contract_or_empty(artifact_root, "actions", "actions")?;
1334 let route_rows = routes
1335 .get_mut("routes")
1336 .and_then(Value::as_array_mut)
1337 .ok_or_else(|| FeatureCompileError::InvalidDocument("routes".to_owned()))?;
1338 let action_rows = actions
1339 .get_mut("actions")
1340 .and_then(Value::as_array_mut)
1341 .ok_or_else(|| FeatureCompileError::InvalidDocument("actions".to_owned()))?;
1342 let mut route_ids = route_rows
1343 .iter()
1344 .filter_map(|row| row.get("route_id").and_then(Value::as_str))
1345 .map(ToOwned::to_owned)
1346 .collect::<BTreeSet<_>>();
1347 let mut action_ids = action_rows
1348 .iter()
1349 .filter_map(|row| row.get("action_id").and_then(Value::as_str))
1350 .map(ToOwned::to_owned)
1351 .collect::<BTreeSet<_>>();
1352 let mut feature_ids = BTreeSet::new();
1353 let mut compiled_features = Vec::new();
1354 let mut compiled_bundles = Vec::new();
1355 let mut form_actions = BTreeMap::new();
1356 let mut protected_assets = Vec::new();
1357
1358 for route in route_rows.iter() {
1359 let Some(action_id) = route.get("action_id").and_then(Value::as_str) else {
1360 continue;
1361 };
1362 let method = route
1363 .get("method")
1364 .and_then(Value::as_str)
1365 .unwrap_or("POST")
1366 .to_owned();
1367 let path = route
1368 .get("path")
1369 .and_then(Value::as_str)
1370 .unwrap_or_default()
1371 .to_owned();
1372 form_actions.insert(action_id.to_owned(), (method, path, String::new()));
1373 }
1374
1375 for path in paths {
1376 let feature_documents = feature_source_documents(&path, load_json_object(&path)?)?;
1377 for feature in feature_documents {
1378 if feature.get("contract_version").and_then(Value::as_str) != Some(CONTRACT_VERSION) {
1379 return Err(FeatureCompileError::InvalidDocument(
1380 path.to_string_lossy().into_owned(),
1381 ));
1382 }
1383 let feature_id = declaration_id(
1384 feature
1385 .get("feature_id")
1386 .and_then(Value::as_str)
1387 .ok_or_else(|| {
1388 FeatureCompileError::InvalidDocument(path.to_string_lossy().into_owned())
1389 })?,
1390 )?;
1391 if !feature_ids.insert(feature_id.clone()) {
1392 return Err(FeatureCompileError::DuplicateId(feature_id));
1393 }
1394 let queries = declaration_map(&feature, "queries")?;
1395 for query_id in queries.keys() {
1396 declaration_id(query_id)?;
1397 }
1398 let (pages, feature_actions, bundle_summaries) =
1399 feature_pages_and_actions(&feature_id, &feature)?;
1400 compiled_bundles.extend(bundle_summaries);
1401 for (page_id, page) in &pages {
1402 let (route, page_spec) =
1403 compile_page(artifact_root, &feature_id, page_id, page, queries)?;
1404 let route_id = route
1405 .get("route_id")
1406 .and_then(Value::as_str)
1407 .unwrap_or_default();
1408 if !route_ids.insert(route_id.to_owned()) {
1409 return Err(FeatureCompileError::DuplicateId(route_id.to_owned()));
1410 }
1411 let page_id = page_spec
1412 .get("page_id")
1413 .and_then(Value::as_str)
1414 .unwrap_or_default();
1415 write_json(
1416 artifact_root,
1417 &format!("templates/.pharos-generated/{page_id}.page.json"),
1418 &page_spec,
1419 )?;
1420 route_rows.push(route);
1421 }
1422 for (action_id, action) in &feature_actions {
1423 let (route, contract) =
1424 compile_action(artifact_root, &feature_id, action_id, action, queries)?;
1425 if !action_ids.insert(action_id.to_owned()) {
1426 return Err(FeatureCompileError::DuplicateId(action_id.to_owned()));
1427 }
1428 let route_enabled = action.get("route").and_then(Value::as_bool).unwrap_or(true);
1429 if route_enabled {
1430 let route_id = route
1431 .get("route_id")
1432 .and_then(Value::as_str)
1433 .unwrap_or_default();
1434 if !route_ids.insert(route_id.to_owned()) {
1435 return Err(FeatureCompileError::DuplicateId(route_id.to_owned()));
1436 }
1437 let method = route
1438 .get("method")
1439 .and_then(Value::as_str)
1440 .unwrap_or("POST")
1441 .to_owned();
1442 let path = route
1443 .get("path")
1444 .and_then(Value::as_str)
1445 .unwrap_or_default()
1446 .to_owned();
1447 let target = contract
1448 .get("response_contract")
1449 .and_then(|value| value.get("target"))
1450 .and_then(Value::as_str)
1451 .unwrap_or_default()
1452 .to_owned();
1453 form_actions.insert(action_id.to_owned(), (method, path, target));
1454 route_rows.push(route);
1455 }
1456 action_rows.push(contract);
1457 }
1458 for (asset_id, declaration) in declaration_map(&feature, "protected_assets")? {
1459 let asset_id = declaration_id(asset_id)?;
1460 let path = route_path(
1461 declaration
1462 .get("path")
1463 .and_then(Value::as_str)
1464 .ok_or_else(|| FeatureCompileError::InvalidDocument(asset_id.clone()))?,
1465 )?;
1466 for field in ["entity", "key_field", "owner_field", "asset_field"] {
1467 declaration_id(
1468 declaration
1469 .get(field)
1470 .and_then(Value::as_str)
1471 .ok_or_else(|| {
1472 FeatureCompileError::InvalidDocument(asset_id.clone())
1473 })?,
1474 )?;
1475 }
1476 let root = declaration
1477 .get("root")
1478 .and_then(Value::as_str)
1479 .ok_or_else(|| FeatureCompileError::InvalidDocument(asset_id.clone()))?;
1480 let root_path = safe_artifact_path(artifact_root, root)?;
1481 if !root.starts_with("private/") || !root_path.is_dir() {
1482 return Err(FeatureCompileError::UnsafePath(root.to_owned()));
1483 }
1484 if let Some(on_authorized) = declaration.get("on_authorized") {
1485 transaction_effects(on_authorized)?;
1486 }
1487 protected_assets.push(serde_json::json!({
1488 "asset_id": asset_id,
1489 "feature_id": feature_id,
1490 "path": path,
1491 "entity": declaration.get("entity"),
1492 "key_field": declaration.get("key_field"),
1493 "owner_field": declaration.get("owner_field"),
1494 "asset_field": declaration.get("asset_field"),
1495 "root": root,
1496 "on_authorized": declaration.get("on_authorized"),
1497 }));
1498 }
1499 compiled_features.push(feature);
1500 }
1501 }
1502
1503 if !action_rows.is_empty() {
1504 actions["effect_policy"] = serde_json::json!({"deny_undeclared": true});
1505 }
1506 write_json(artifact_root, "data/contracts/routes.json", &routes)?;
1507 write_json(artifact_root, "data/contracts/actions.json", &actions)?;
1508 write_json(
1509 artifact_root,
1510 "data/contracts/features.json",
1511 &serde_json::json!({
1512 "contract_version": CONTRACT_VERSION,
1513 "features": compiled_features,
1514 }),
1515 )?;
1516 if !compiled_bundles.is_empty() {
1517 write_json(
1518 artifact_root,
1519 "data/contracts/feature-bundles.json",
1520 &serde_json::json!({
1521 "contract_version": CONTRACT_VERSION,
1522 "bundles": compiled_bundles,
1523 }),
1524 )?;
1525 }
1526 if !protected_assets.is_empty() {
1527 write_json(
1528 artifact_root,
1529 "data/contracts/protected-assets.json",
1530 &serde_json::json!({
1531 "contract_version": CONTRACT_VERSION,
1532 "assets": protected_assets,
1533 }),
1534 )?;
1535 }
1536 compile_template_tree(artifact_root, &form_actions)?;
1537 Ok(compiled_features.len())
1538}
1539
1540#[cfg(test)]
1541mod tests {
1542 use std::time::{SystemTime, UNIX_EPOCH};
1543
1544 use super::{compile_feature_sources, compile_form_attributes};
1545
1546 fn test_root(label: &str) -> std::path::PathBuf {
1548 let nonce = SystemTime::now()
1549 .duration_since(UNIX_EPOCH)
1550 .expect("clock")
1551 .as_nanos();
1552 std::env::temp_dir().join(format!(
1553 "pharos-feature-{label}-{}-{nonce}",
1554 std::process::id()
1555 ))
1556 }
1557
1558 fn write(root: &std::path::Path, relative: &str, content: &str) {
1560 let path = root.join(relative);
1561 std::fs::create_dir_all(path.parent().expect("parent")).expect("directories");
1562 std::fs::write(path, content).expect("fixture");
1563 }
1564
1565 #[test]
1566 fn compiles_one_feature_into_routes_actions_pages_and_forms() {
1568 let root = test_root("compile");
1569 write(
1570 &root,
1571 "data/contracts/routes.json",
1572 r#"{"contract_version":"1.0","security_defaults":{"csrf_mode":"mutation_required","trusted_origin_policy":"mutation_required","tenant_scope":"policy_bound","rate_limit_policy":"runtime_default","capability_policy":"policy_gate"},"routes":[{"route_id":"account_login_submit","path":"/accounts/login/","kind":"action","method":"POST","action_id":"login","auth_gate":"anonymous_or_session","policy_gate":"account_login"}]}"#,
1573 );
1574 write(
1575 &root,
1576 "data/contracts/actions.json",
1577 r#"{"contract_version":"1.0","actions":[{"action_id":"login","policy_gate":"account_login"}]}"#,
1578 );
1579 write(
1580 &root,
1581 "templates/storefront.body.html",
1582 r#"<a href="/cart" pharos-nav="app-main">Cart</a><form method="get" action="/" pharos-get="app-main"><button>Filter</button></form><form pharos-action="login"><button>Log in</button></form><form pharos-action="add_item" pharos-target="cart-panel"><button>Add</button></form>"#,
1583 );
1584 write(
1585 &root,
1586 "templates/fragments/cart_summary.html",
1587 r#"<aside id="cart-panel">{cart_total}</aside>"#,
1588 );
1589 write(
1590 &root,
1591 "data/features/cart.json",
1592 r#"{
1593 "contract_version":"1.0",
1594 "feature_id":"cart",
1595 "queries":{"current_cart":{"entity":"shopping_cart","result":"one"}},
1596 "bundles":{
1597 "cart_flow":{
1598 "kind":"list_create",
1599 "policy":"buy",
1600 "target":"cart-panel",
1601 "fallback_redirect":"/",
1602 "list":{
1603 "page_id":"storefront",
1604 "path":"/",
1605 "template":"templates/storefront.body.html",
1606 "title":"Store",
1607 "queries":["current_cart"],
1608 "response":{"target":"app-main","history":"push","queries":["current_cart"]}
1609 },
1610 "create":{
1611 "action_id":"add_item",
1612 "path":"/cart/items",
1613 "method":"POST",
1614 "input":{"product_id":{"type":"integer","required":true}},
1615 "transaction":{"upsert":{"entity":"shopping_cart_item","unique_by":["cart_id","product_id"],"values":{"product_id":"input.product_id"}}},
1616 "render":"cart_summary",
1617 "queries":["current_cart"]
1618 }
1619 }
1620 }
1621 }"#,
1622 );
1623
1624 assert_eq!(compile_feature_sources(&root), Ok(1));
1625 let routes =
1626 std::fs::read_to_string(root.join("data/contracts/routes.json")).expect("routes");
1627 let actions =
1628 std::fs::read_to_string(root.join("data/contracts/actions.json")).expect("actions");
1629 let bundles = std::fs::read_to_string(root.join("data/contracts/feature-bundles.json"))
1630 .expect("bundle ledger");
1631 let template =
1632 std::fs::read_to_string(root.join("templates/storefront.body.html")).expect("template");
1633 assert!(routes.contains("cart_add_item"));
1634 assert!(actions.contains("transaction_contract"));
1635 assert!(bundles.contains("\"bundle_id\": \"cart_flow\""));
1636 assert!(bundles.contains("\"action_id\": \"add_item\""));
1637 assert!(bundles.contains("\"target\": \"cart-panel\""));
1638 assert!(template.contains("action=\"/accounts/login/\""));
1639 assert!(template.contains("data-pharos-action=\"/cart/items\""));
1640 assert!(template.contains("data-pharos-target=\"cart-panel\""));
1641 assert!(template.contains("data-pharos-nav=\"app-main\""));
1642 assert!(template.contains("data-pharos-get=\"app-main\""));
1643 let interaction_client =
1644 std::fs::read_to_string(root.join("static/pharos/interaction-v1.js"))
1645 .expect("interaction client");
1646 assert!(interaction_client.contains("response.headers.get(\"HX-Redirect\")"));
1647 assert!(interaction_client.contains("location.assign(hxRedirect)"));
1648 assert!(
1649 root.join("templates/.pharos-generated/storefront.page.json")
1650 .is_file()
1651 );
1652 std::fs::remove_dir_all(root).expect("cleanup");
1653 }
1654
1655 #[test]
1656 fn rewrites_safe_form_targets_independently_from_action_targets() {
1658 let mut actions = std::collections::BTreeMap::new();
1659 actions.insert(
1660 "add_item".to_owned(),
1661 ("POST".to_owned(), "/items".to_owned(), String::new()),
1662 );
1663 let template = compile_form_attributes(
1664 r#"<form pharos-action="add_item" pharos-target="item-panel"><button>Add</button></form>"#,
1665 &actions,
1666 "",
1667 )
1668 .expect("compiled form");
1669
1670 assert!(template.contains(r#"data-pharos-action="/items""#));
1671 assert!(template.contains(r#"data-pharos-target="item-panel""#));
1672 assert!(!template.contains(" pharos-target="));
1673 }
1674
1675 #[test]
1676 fn rejects_unresolved_fragment_targets() {
1678 let root = test_root("missing-fragment");
1679 write(
1680 &root,
1681 "data/contracts/routes.json",
1682 r#"{"contract_version":"1.0","routes":[]}"#,
1683 );
1684 write(&root, "templates/storefront.body.html", "<main></main>");
1685 write(
1686 &root,
1687 "data/features/cart.json",
1688 r#"{"contract_version":"1.0","feature_id":"cart","queries":{},"actions":{"add_item":{"path":"/cart/items","method":"POST","transaction":{"insert":{"entity":"item","values":{}}},"response":{"render":"missing","target":"cart","queries":[],"fallback":{"redirect":"/"}}}}}"#,
1689 );
1690 assert!(compile_feature_sources(&root).is_err());
1691 std::fs::remove_dir_all(root).expect("cleanup");
1692 }
1693
1694 #[test]
1695 fn compiles_redirect_action_response() {
1697 let compiled = super::compile_action_response(
1698 std::path::Path::new("."),
1699 "business",
1700 "create_service",
1701 &serde_json::json!({
1702 "response_kind": "redirect",
1703 "fallback": {"redirect": "/businesses/"}
1704 }),
1705 &serde_json::Map::new(),
1706 )
1707 .expect("compile app-owned redirect");
1708 assert_eq!(compiled["response_kind"], "redirect");
1709 assert_eq!(compiled["fallback"]["redirect"], "/businesses/");
1710 }
1711
1712 #[test]
1713 fn expands_crud_bundle_with_deterministic_ids_and_overrides() {
1715 let root = test_root("crud-bundle");
1716 write(
1717 &root,
1718 "data/contracts/routes.json",
1719 r#"{"contract_version":"1.0","routes":[]}"#,
1720 );
1721 write(&root, "templates/tasks.body.html", "<main></main>");
1722 write(&root, "templates/task.body.html", "<main></main>");
1723 write(&root, "templates/task-edit.body.html", "<main></main>");
1724 write(
1725 &root,
1726 "templates/fragments/task_list.html",
1727 r#"<section id="task-panel"></section>"#,
1728 );
1729 write(
1730 &root,
1731 "data/features/tasks.json",
1732 r#"{
1733 "contract_version":"1.0",
1734 "feature_id":"tasks",
1735 "queries":{"task_list":{"entity":"task","result":"many"}},
1736 "bundles":{
1737 "task_flow":{
1738 "kind":"crud",
1739 "layout":"default_shell",
1740 "policy":"public",
1741 "target":"task-panel",
1742 "fallback_redirect":"/tasks",
1743 "list":{"path":"/tasks","template":"templates/tasks.body.html","title":"Tasks","queries":["task_list"]},
1744 "detail":{"path":"/tasks/:task_id","template":"templates/task.body.html","title":"Task detail"},
1745 "edit":{"path":"/tasks/:task_id/edit","template":"templates/task-edit.body.html","title":"Edit task"},
1746 "create":{
1747 "path":"/tasks",
1748 "input":{"title":{"type":"string","required":true}},
1749 "transaction":{"insert":{"entity":"task","values":{"title":"input.title"}}},
1750 "render":"task_list",
1751 "queries":["task_list"]
1752 },
1753 "update":{
1754 "action_id":"save_task",
1755 "path":"/tasks/:task_id",
1756 "method":"PATCH",
1757 "input":{"title":{"type":"string","required":true}},
1758 "transaction":{"update":{"entity":"task","filters":{"id":"path.task_id"},"values":{"title":"input.title"}}},
1759 "response":{"render":"task_list","target":"task-panel","queries":["task_list"],"fallback":{"redirect":"/tasks"}}
1760 }
1761 }
1762 }
1763 }"#,
1764 );
1765 assert_eq!(compile_feature_sources(&root), Ok(1));
1766 let routes =
1767 std::fs::read_to_string(root.join("data/contracts/routes.json")).expect("routes");
1768 let actions =
1769 std::fs::read_to_string(root.join("data/contracts/actions.json")).expect("actions");
1770 let bundles = std::fs::read_to_string(root.join("data/contracts/feature-bundles.json"))
1771 .expect("bundles");
1772 assert!(routes.contains("\"route_id\": \"tasks_task_flow_list\""));
1773 assert!(routes.contains("\"route_id\": \"tasks_task_flow_detail\""));
1774 assert!(routes.contains("\"route_id\": \"tasks_task_flow_edit\""));
1775 assert!(routes.contains("\"route_id\": \"tasks_save_task\""));
1776 assert!(actions.contains("\"action_id\": \"task_flow_create\""));
1777 assert!(actions.contains("\"action_id\": \"save_task\""));
1778 assert!(bundles.contains("\"bundle_kind\": \"crud\""));
1779 assert!(bundles.contains("\"page_id\": \"task_flow_list\""));
1780 assert!(bundles.contains("\"action_id\": \"save_task\""));
1781 std::fs::remove_dir_all(root).expect("cleanup");
1782 }
1783}