1use std::net::IpAddr;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result, anyhow, bail};
7use dialoguer::{Confirm, Input, Select};
8use serde::{Deserialize, Serialize};
9use url::Url;
10
11use crate::deployment_targets::DeploymentTargetRecord;
12
13const STATIC_ROUTES_VERSION: u32 = 1;
14const PACK_DECLARED_POLICY: &str = "pack_declared";
15const SURFACE_ENABLED: &str = "enabled";
16const SURFACE_DISABLED: &str = "disabled";
17
18#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
19pub struct PlatformSetupAnswers {
20 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub static_routes: Option<StaticRoutesAnswers>,
22 #[serde(default, skip_serializing_if = "Vec::is_empty")]
23 pub deployment_targets: Vec<DeploymentTargetRecord>,
24}
25
26#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
27pub struct StaticRoutesAnswers {
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub public_web_enabled: Option<bool>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub public_base_url: Option<String>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub public_surface_policy: Option<String>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub default_route_prefix_policy: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub tenant_path_policy: Option<String>,
38}
39
40#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
41pub struct StaticRoutesPolicy {
42 pub version: u32,
43 pub public_web_enabled: bool,
44 pub public_base_url: Option<String>,
45 pub public_surface_policy: String,
46 pub default_route_prefix_policy: String,
47 pub tenant_path_policy: String,
48}
49
50impl Default for StaticRoutesPolicy {
51 fn default() -> Self {
52 Self::disabled()
53 }
54}
55
56impl StaticRoutesPolicy {
57 pub fn disabled() -> Self {
58 Self {
59 version: STATIC_ROUTES_VERSION,
60 public_web_enabled: false,
61 public_base_url: None,
62 public_surface_policy: SURFACE_DISABLED.to_string(),
63 default_route_prefix_policy: PACK_DECLARED_POLICY.to_string(),
64 tenant_path_policy: PACK_DECLARED_POLICY.to_string(),
65 }
66 }
67
68 pub fn to_answers(&self) -> StaticRoutesAnswers {
69 StaticRoutesAnswers {
70 public_web_enabled: Some(self.public_web_enabled),
71 public_base_url: self.public_base_url.clone(),
72 public_surface_policy: Some(self.public_surface_policy.clone()),
73 default_route_prefix_policy: Some(self.default_route_prefix_policy.clone()),
74 tenant_path_policy: Some(self.tenant_path_policy.clone()),
75 }
76 }
77
78 pub fn normalize(input: Option<&StaticRoutesAnswers>, env: &str) -> Result<Self> {
79 let Some(input) = input else {
80 return Ok(Self::disabled());
81 };
82
83 let public_web_enabled = input.public_web_enabled.unwrap_or(false);
84 let public_surface_policy = input
85 .public_surface_policy
86 .as_deref()
87 .map(str::trim)
88 .filter(|v| !v.is_empty())
89 .map(str::to_string)
90 .unwrap_or_else(|| {
91 if public_web_enabled {
92 SURFACE_ENABLED.to_string()
93 } else {
94 SURFACE_DISABLED.to_string()
95 }
96 });
97
98 if public_surface_policy != SURFACE_ENABLED && public_surface_policy != SURFACE_DISABLED {
99 bail!(
100 "public_surface_policy must be one of: {}, {}",
101 SURFACE_ENABLED,
102 SURFACE_DISABLED
103 );
104 }
105
106 let default_route_prefix_policy = normalize_pack_declared_policy(
107 "default_route_prefix_policy",
108 input.default_route_prefix_policy.as_deref(),
109 )?;
110 let tenant_path_policy = normalize_pack_declared_policy(
111 "tenant_path_policy",
112 input.tenant_path_policy.as_deref(),
113 )?;
114
115 let public_base_url = match input.public_base_url.as_deref().map(str::trim) {
116 Some("") | None => None,
117 Some(url) => Some(normalize_public_base_url(url, env)?),
118 };
119
120 if public_web_enabled && public_base_url.is_none() {
121 bail!("public_base_url is required when public_web_enabled=true");
122 }
123
124 if public_web_enabled && public_surface_policy == SURFACE_DISABLED {
125 bail!("public_surface_policy=disabled is incompatible with public_web_enabled=true");
126 }
127
128 Ok(Self {
129 version: STATIC_ROUTES_VERSION,
130 public_web_enabled,
131 public_base_url,
132 public_surface_policy,
133 default_route_prefix_policy,
134 tenant_path_policy,
135 })
136 }
137}
138
139pub fn prompt_static_routes_policy(
140 env: &str,
141 current: Option<&StaticRoutesPolicy>,
142) -> Result<StaticRoutesPolicy> {
143 let current = current.cloned().unwrap_or_default();
144 let public_web_enabled = Confirm::new()
145 .with_prompt("Enable public web/static hosting for this bundle?")
146 .default(current.public_web_enabled)
147 .interact()?;
148
149 if !public_web_enabled {
150 return Ok(StaticRoutesPolicy::disabled());
151 }
152
153 let base_default = current.public_base_url.unwrap_or_default();
154 let public_base_url: String = Input::new()
155 .with_prompt("Public base URL")
156 .with_initial_text(base_default)
157 .interact_text()?;
158
159 let policies = [SURFACE_ENABLED, SURFACE_DISABLED];
160 let surface_index = policies
161 .iter()
162 .position(|value| *value == current.public_surface_policy)
163 .unwrap_or(0);
164 let public_surface_policy = policies[Select::new()
165 .with_prompt("Public surface policy")
166 .items(policies)
167 .default(surface_index)
168 .interact()?]
169 .to_string();
170
171 StaticRoutesPolicy::normalize(
172 Some(&StaticRoutesAnswers {
173 public_web_enabled: Some(public_web_enabled),
174 public_base_url: Some(public_base_url),
175 public_surface_policy: Some(public_surface_policy),
176 default_route_prefix_policy: Some(current.default_route_prefix_policy),
177 tenant_path_policy: Some(current.tenant_path_policy),
178 }),
179 env,
180 )
181}
182
183pub fn static_routes_artifact_path(bundle_root: &Path) -> PathBuf {
184 bundle_root
185 .join("state")
186 .join("config")
187 .join("platform")
188 .join("static-routes.json")
189}
190
191pub fn load_static_routes_artifact(bundle_root: &Path) -> Result<Option<StaticRoutesPolicy>> {
192 let path = static_routes_artifact_path(bundle_root);
193 if !path.exists() {
194 return Ok(None);
195 }
196 let raw = std::fs::read_to_string(&path)
197 .with_context(|| format!("failed to read {}", path.display()))?;
198 let policy = serde_json::from_str(&raw)
199 .or_else(|_| serde_yaml_bw::from_str(&raw))
200 .with_context(|| format!("failed to parse {}", path.display()))?;
201 Ok(Some(policy))
202}
203
204#[derive(Debug, Deserialize)]
205struct RuntimeEndpoints {
206 #[allow(dead_code)]
207 tenant: Option<String>,
208 #[allow(dead_code)]
209 team: Option<String>,
210 public_base_url: Option<String>,
211}
212
213pub fn load_runtime_public_base_url(
214 bundle_root: &Path,
215 tenant: &str,
216 team: Option<&str>,
217) -> Result<Option<String>> {
218 let team = team.unwrap_or("default");
219 let path = bundle_root
220 .join("state")
221 .join("runtime")
222 .join(format!("{tenant}.{team}"))
223 .join("endpoints.json");
224 if !path.exists() {
225 return Ok(None);
226 }
227 let raw = std::fs::read_to_string(&path)
228 .with_context(|| format!("failed to read {}", path.display()))?;
229 let endpoints: RuntimeEndpoints = serde_json::from_str(&raw)
230 .with_context(|| format!("failed to parse {}", path.display()))?;
231 Ok(endpoints
232 .public_base_url
233 .as_deref()
234 .map(str::trim)
235 .filter(|value| !value.is_empty())
236 .map(ToString::to_string))
237}
238
239pub fn load_effective_static_routes_defaults(
240 bundle_root: &Path,
241 tenant: &str,
242 team: Option<&str>,
243) -> Result<Option<StaticRoutesPolicy>> {
244 let mut policy = load_static_routes_artifact(bundle_root)?.unwrap_or_default();
245 if policy.public_base_url.is_none()
246 && let Some(runtime_url) = load_runtime_public_base_url(bundle_root, tenant, team)?
247 {
248 policy.public_base_url = Some(runtime_url);
249 }
250 if policy == StaticRoutesPolicy::disabled() {
251 return Ok(None);
252 }
253 Ok(Some(policy))
254}
255
256pub fn persist_static_routes_artifact(
257 bundle_root: &Path,
258 policy: &StaticRoutesPolicy,
259) -> Result<PathBuf> {
260 let path = static_routes_artifact_path(bundle_root);
261 if let Some(parent) = path.parent() {
262 std::fs::create_dir_all(parent)?;
263 }
264 let payload = serde_json::to_string_pretty(policy).context("serialize static routes policy")?;
265 std::fs::write(&path, payload)
266 .with_context(|| format!("failed to write {}", path.display()))?;
267 Ok(path)
268}
269
270fn normalize_pack_declared_policy(field: &str, value: Option<&str>) -> Result<String> {
271 let value = value
272 .map(str::trim)
273 .filter(|v| !v.is_empty())
274 .unwrap_or(PACK_DECLARED_POLICY);
275 if value != PACK_DECLARED_POLICY {
276 bail!("{field} must be '{}'", PACK_DECLARED_POLICY);
277 }
278 Ok(value.to_string())
279}
280
281fn normalize_public_base_url(value: &str, env: &str) -> Result<String> {
282 let url = Url::parse(value).map_err(|err| anyhow!("invalid public_base_url: {err}"))?;
283 match url.scheme() {
284 "https" => {}
285 "http" if is_local_http_origin(&url) => {}
286 "http" => bail!("public_base_url must use https unless it targets localhost/loopback"),
287 _ => bail!("public_base_url must use http or https"),
288 }
289
290 if url.host_str().is_none() {
291 bail!("public_base_url must include a host");
292 }
293 if url.query().is_some() {
294 bail!("public_base_url must not include a query string");
295 }
296 if url.fragment().is_some() {
297 bail!("public_base_url must not include a fragment");
298 }
299 if env != "dev" && url.scheme() == "http" {
300 bail!("public_base_url may only use http for localhost/loopback origins in dev");
301 }
302
303 let mut normalized = url.to_string();
304 while normalized.ends_with('/') && normalized.len() > scheme_host_floor(&url) {
305 normalized.pop();
306 }
307 if normalized.ends_with('/') && url.path() == "/" {
308 normalized.pop();
309 }
310 Ok(normalized)
311}
312
313fn scheme_host_floor(url: &Url) -> usize {
314 let host = url.host_str().unwrap_or_default();
315 let mut floor = url.scheme().len() + 3 + host.len();
316 if let Some(port) = url.port() {
317 floor += 1 + port.to_string().len();
318 }
319 floor
320}
321
322fn is_local_http_origin(url: &Url) -> bool {
323 let Some(host) = url.host_str() else {
324 return false;
325 };
326 if host.eq_ignore_ascii_case("localhost") {
327 return true;
328 }
329 host.parse::<IpAddr>()
330 .map(|addr| addr.is_loopback())
331 .unwrap_or(false)
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn disabled_is_default() {
340 let policy = StaticRoutesPolicy::normalize(None, "dev").unwrap();
341 assert_eq!(policy, StaticRoutesPolicy::disabled());
342 }
343
344 #[test]
345 fn enabled_requires_base_url() {
346 let err = StaticRoutesPolicy::normalize(
347 Some(&StaticRoutesAnswers {
348 public_web_enabled: Some(true),
349 ..Default::default()
350 }),
351 "dev",
352 )
353 .unwrap_err();
354 assert!(err.to_string().contains("public_base_url is required"));
355 }
356
357 #[test]
358 fn normalizes_public_base_url() {
359 let policy = StaticRoutesPolicy::normalize(
360 Some(&StaticRoutesAnswers {
361 public_web_enabled: Some(true),
362 public_base_url: Some("https://example.com/base/".into()),
363 ..Default::default()
364 }),
365 "prod",
366 )
367 .unwrap();
368 assert_eq!(
369 policy.public_base_url.as_deref(),
370 Some("https://example.com/base")
371 );
372 assert_eq!(policy.public_surface_policy, SURFACE_ENABLED);
373 assert_eq!(policy.default_route_prefix_policy, PACK_DECLARED_POLICY);
374 assert_eq!(policy.tenant_path_policy, PACK_DECLARED_POLICY);
375 }
376
377 #[test]
378 fn rejects_query_and_fragment() {
379 let err = StaticRoutesPolicy::normalize(
380 Some(&StaticRoutesAnswers {
381 public_web_enabled: Some(true),
382 public_base_url: Some("https://example.com?x=1".into()),
383 ..Default::default()
384 }),
385 "prod",
386 )
387 .unwrap_err();
388 assert!(err.to_string().contains("query string"));
389
390 let err = StaticRoutesPolicy::normalize(
391 Some(&StaticRoutesAnswers {
392 public_web_enabled: Some(true),
393 public_base_url: Some("https://example.com#frag".into()),
394 ..Default::default()
395 }),
396 "prod",
397 )
398 .unwrap_err();
399 assert!(err.to_string().contains("fragment"));
400 }
401
402 #[test]
403 fn allows_http_loopback_in_dev_only() {
404 let policy = StaticRoutesPolicy::normalize(
405 Some(&StaticRoutesAnswers {
406 public_web_enabled: Some(true),
407 public_base_url: Some("http://127.0.0.1:3000/".into()),
408 ..Default::default()
409 }),
410 "dev",
411 )
412 .unwrap();
413 assert_eq!(
414 policy.public_base_url.as_deref(),
415 Some("http://127.0.0.1:3000")
416 );
417
418 let err = StaticRoutesPolicy::normalize(
419 Some(&StaticRoutesAnswers {
420 public_web_enabled: Some(true),
421 public_base_url: Some("http://127.0.0.1:3000".into()),
422 ..Default::default()
423 }),
424 "prod",
425 )
426 .unwrap_err();
427 assert!(err.to_string().contains("dev"));
428 }
429
430 #[test]
431 fn rejects_enabled_with_disabled_surface_policy() {
432 let err = StaticRoutesPolicy::normalize(
433 Some(&StaticRoutesAnswers {
434 public_web_enabled: Some(true),
435 public_base_url: Some("https://example.com".into()),
436 public_surface_policy: Some("disabled".into()),
437 ..Default::default()
438 }),
439 "prod",
440 )
441 .unwrap_err();
442 assert!(err.to_string().contains("incompatible"));
443 }
444
445 #[test]
446 fn persists_and_loads_artifact() {
447 let temp = tempfile::tempdir().unwrap();
448 let policy = StaticRoutesPolicy::normalize(
449 Some(&StaticRoutesAnswers {
450 public_web_enabled: Some(true),
451 public_base_url: Some("https://example.com".into()),
452 ..Default::default()
453 }),
454 "prod",
455 )
456 .unwrap();
457 let path = persist_static_routes_artifact(temp.path(), &policy).unwrap();
458 assert!(path.exists());
459 let loaded = load_static_routes_artifact(temp.path()).unwrap().unwrap();
460 assert_eq!(loaded, policy);
461 }
462
463 #[test]
464 fn effective_defaults_fall_back_to_runtime_endpoint() {
465 let temp = tempfile::tempdir().unwrap();
466 let runtime_dir = temp
467 .path()
468 .join("state")
469 .join("runtime")
470 .join("demo.default");
471 std::fs::create_dir_all(&runtime_dir).unwrap();
472 std::fs::write(
473 runtime_dir.join("endpoints.json"),
474 r#"{"tenant":"demo","team":"default","public_base_url":"https://runtime.example.com"}"#,
475 )
476 .unwrap();
477
478 let loaded =
479 load_effective_static_routes_defaults(temp.path(), "demo", Some("default")).unwrap();
480 assert_eq!(
481 loaded.and_then(|policy| policy.public_base_url),
482 Some("https://runtime.example.com".to_string())
483 );
484 }
485}