1use std::path::Path;
29
30use anyhow::{Context, Result};
31use tracing::{debug, info};
32
33use crate::CloudflareClient;
34
35pub async fn ensure_r2_custom_domain(
50 workspace_root: &Path,
51 provider_id: &str,
52 bucket_name: &str,
53 domain: &str,
54) -> Result<()> {
55 let cf_provider = super::cf_creds::CfProvider::resolve(workspace_root, provider_id)?;
56 let account_id = cf_provider.account_id.clone();
57 let cf = CloudflareClient::new(cf_provider.api_token()?);
58 let existing = cf
59 .list_r2_custom_domains(&account_id, bucket_name)
60 .await
61 .with_context(|| format!("listing R2 custom domains on bucket {bucket_name:?}"))?;
62 if existing.iter().any(|d| d.domain == domain) {
63 debug!(
64 domain,
65 bucket_name, "R2 custom domain already bound — skipping"
66 );
67 return Ok(());
68 }
69 let zone_name = parent_zone_name(domain);
70 let zone_id = cf
71 .zone_id_for_name(zone_name)
72 .await
73 .with_context(|| format!("resolving zone id for {zone_name:?}"))?;
74 cf.add_r2_custom_domain(&account_id, bucket_name, domain, &zone_id)
75 .await
76 .with_context(|| format!("binding R2 custom domain {domain:?} → bucket {bucket_name:?}"))?;
77 info!(domain, bucket_name, zone_name, "R2 custom domain bound");
78 Ok(())
79}
80
81fn parent_zone_name(domain: &str) -> &str {
90 let last_dot = domain.rfind('.');
91 let Some(last_dot) = last_dot else {
92 return domain; };
94 if let Some(prev_dot) = domain[..last_dot].rfind('.') {
95 &domain[prev_dot + 1..]
96 } else {
97 domain }
99}
100
101use crate::config::{DomainConfig, DomainRoute, FrontDoor, RouteMode};
111use crate::provider::cloudflare::WorkerBinding;
112use crate::reconciler::mesofact_static::WORKER_SCRIPT;
113use std::collections::BTreeMap;
114
115#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct DomainWorkerPlan {
121 pub worker_name: String,
123 pub custom_domain: String,
125 pub asset_origin: String,
127 pub bindings: Vec<(String, String)>,
130}
131
132fn static_worker_bindings(asset_origin: &str, route_headers: String) -> Vec<(String, String)> {
139 vec![
140 ("ASSET_ORIGIN".to_string(), asset_origin.to_string()),
141 ("UPLOAD_ORIGIN".to_string(), String::new()),
142 ("WORKER_MODE".to_string(), "static".to_string()),
143 ("SSR_ORIGIN".to_string(), String::new()),
144 ("SSR_PREFIXES".to_string(), "[]".to_string()),
145 ("ROUTE_HEADERS".to_string(), route_headers),
146 ]
147}
148
149pub fn plan_domain_worker(
160 domain: &DomainConfig,
161 cdn_base: &str,
162 env: &str,
163) -> Result<DomainWorkerPlan> {
164 if domain.front_door != FrontDoor::Worker {
169 anyhow::bail!(
170 "domain {} ({}) declares front_door = \"{}\" — only \"worker\" \
171 domains get a generated Cloudflare Worker",
172 domain.name,
173 domain.domain,
174 domain.front_door.as_str()
175 );
176 }
177
178 let component = domain
180 .routes
181 .iter()
182 .find_map(|r| match &r.mode {
183 RouteMode::Static { component } => Some(component.as_str()),
184 _ => None,
185 })
186 .with_context(|| {
187 format!(
188 "domain {} ({}) has no `static` route — nothing for a static Worker to serve",
189 domain.name, domain.domain
190 )
191 })?;
192
193 let service = component
194 .split_once('/')
195 .map(|(svc, _)| svc)
196 .with_context(|| {
197 format!(
198 "domain {}: route component {component:?} — expected \"<service>/<component-id>\"",
199 domain.name
200 )
201 })?;
202
203 let asset_origin = format!("{}/{}/{}", cdn_base.trim_end_matches('/'), service, env);
204
205 Ok(DomainWorkerPlan {
206 worker_name: domain.name.clone(),
207 custom_domain: domain.domain.clone(),
208 bindings: static_worker_bindings(&asset_origin, domain.route_headers_json()),
209 asset_origin,
210 })
211}
212
213pub async fn deploy_domain_worker(
228 workspace_root: &Path,
229 provider_id: &str,
230 plan: &DomainWorkerPlan,
231) -> Result<()> {
232 let cf_provider = super::cf_creds::CfProvider::resolve(workspace_root, provider_id)?;
233 let account_id = cf_provider.account_id.clone();
234 let cf = CloudflareClient::new(cf_provider.api_token()?);
235
236 let worker_bindings: Vec<WorkerBinding<'_>> = plan
237 .bindings
238 .iter()
239 .map(|(k, v)| WorkerBinding::PlainText {
240 name: k.as_str(),
241 text: v.as_str(),
242 })
243 .collect();
244
245 cf.deploy_worker_script(
246 &account_id,
247 &plan.worker_name,
248 WORKER_SCRIPT,
249 &worker_bindings,
250 )
251 .await
252 .with_context(|| format!("deploying static Worker {}", plan.worker_name))?;
253 info!(worker = %plan.worker_name, "alias-tier Worker deployed");
254
255 let zone = parent_zone_name(&plan.custom_domain);
256 let zone_id = cf
257 .zone_id_for_name(zone)
258 .await
259 .with_context(|| format!("resolving zone id for {zone:?}"))?;
260 cf.upsert_worker_custom_domain(
261 &account_id,
262 &zone_id,
263 &plan.custom_domain,
264 &plan.worker_name,
265 )
266 .await
267 .with_context(|| {
268 format!(
269 "binding {} to Worker {}",
270 plan.custom_domain, plan.worker_name
271 )
272 })?;
273 info!(domain = %plan.custom_domain, worker = %plan.worker_name, "alias-tier custom domain bound");
274 Ok(())
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub enum AliasTier {
286 Com,
287 Net,
288}
289
290impl AliasTier {
291 pub fn zone(self) -> &'static str {
293 match self {
294 AliasTier::Com => "com.yah.dev",
295 AliasTier::Net => "net.yah.dev",
296 }
297 }
298
299 pub fn bucket(self) -> &'static str {
301 match self {
302 AliasTier::Com => "com-yah-dev",
303 AliasTier::Net => "net-yah-dev",
304 }
305 }
306
307 fn slug(self) -> &'static str {
309 match self {
310 AliasTier::Com => "com",
311 AliasTier::Net => "net",
312 }
313 }
314}
315
316pub fn valid_subdomain_label(name: &str) -> bool {
319 !name.is_empty()
320 && name.len() <= 63
321 && !name.starts_with('-')
322 && !name.ends_with('-')
323 && name
324 .bytes()
325 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
326}
327
328pub fn plan_alias_claim(
336 tier: AliasTier,
337 name: &str,
338 component: &str,
339 existing: &BTreeMap<String, DomainConfig>,
340) -> Result<DomainConfig> {
341 if !valid_subdomain_label(name) {
342 anyhow::bail!(
343 "invalid subdomain {name:?} — must be 1–63 chars, lowercase \
344 alphanumeric or hyphen, no leading/trailing hyphen"
345 );
346 }
347 if component.split_once('/').is_none() {
348 anyhow::bail!("component {component:?} — expected \"<service>/<component-id>\"");
349 }
350
351 let domain = format!("{name}.{}", tier.zone());
352 let stem = format!("{name}-{}-yah-dev", tier.slug());
353
354 if existing.values().any(|d| d.domain == domain) {
355 anyhow::bail!("{domain} is already claimed");
356 }
357 if existing.contains_key(&stem) {
358 anyhow::bail!("domain manifest {stem:?} already exists");
359 }
360
361 Ok(DomainConfig {
362 schema_version: 1,
363 name: stem,
364 domain,
365 front_door: FrontDoor::Worker,
368 cdn_bucket: tier.bucket().to_string(),
369 worker_bundle_path: None,
370 routes: vec![DomainRoute {
371 path: "/*".into(),
372 headers: BTreeMap::new(),
375 mode: RouteMode::Static {
376 component: component.to_string(),
377 },
378 }],
379 })
380}
381
382#[cfg(test)]
383mod tests {
384 use super::parent_zone_name;
385
386 #[test]
387 fn parent_zone_strips_one_label_off_subdomain() {
388 assert_eq!(parent_zone_name("cdn.yah.dev"), "yah.dev");
389 assert_eq!(parent_zone_name("app.yah.dev"), "yah.dev");
390 }
391
392 #[test]
393 fn parent_zone_returns_self_for_apex() {
394 assert_eq!(parent_zone_name("yah.dev"), "yah.dev");
395 }
396
397 #[test]
398 fn parent_zone_strips_only_first_label_for_deeper_subdomain() {
399 assert_eq!(parent_zone_name("a.b.yah.dev"), "yah.dev");
401 }
402
403 use super::{plan_domain_worker, DomainWorkerPlan};
404 use crate::config::{DomainConfig, DomainRoute, FrontDoor, RouteMode};
405
406 fn net_tier_manifest() -> DomainConfig {
407 DomainConfig {
409 schema_version: 1,
410 name: "scrabcake-net-yah-dev".into(),
411 domain: "scrabcake.net.yah.dev".into(),
412 front_door: FrontDoor::Worker,
413 cdn_bucket: "net-yah-dev".into(),
414 worker_bundle_path: None,
415 routes: vec![DomainRoute {
416 headers: Default::default(),
417 path: "/*".into(),
418 mode: RouteMode::Static {
419 component: "scrabcake/site".into(),
420 },
421 }],
422 }
423 }
424
425 #[test]
426 fn plan_resolves_worker_name_domain_and_asset_origin() {
427 let plan =
428 plan_domain_worker(&net_tier_manifest(), "https://cdn.net.yah.dev", "cloud").unwrap();
429 assert_eq!(
430 plan,
431 DomainWorkerPlan {
432 worker_name: "scrabcake-net-yah-dev".into(),
433 custom_domain: "scrabcake.net.yah.dev".into(),
434 asset_origin: "https://cdn.net.yah.dev/scrabcake/cloud".into(),
435 bindings: vec![
436 (
437 "ASSET_ORIGIN".into(),
438 "https://cdn.net.yah.dev/scrabcake/cloud".into()
439 ),
440 ("UPLOAD_ORIGIN".into(), String::new()),
441 ("WORKER_MODE".into(), "static".into()),
442 ("SSR_ORIGIN".into(), String::new()),
443 ("SSR_PREFIXES".into(), "[]".into()),
444 ("ROUTE_HEADERS".into(), "[]".into()),
445 ],
446 }
447 );
448 }
449
450 #[test]
455 fn plan_carries_declared_route_headers_into_the_bindings() {
456 let mut dom = net_tier_manifest();
457 dom.routes[0].headers = [
458 ("Cross-Origin-Opener-Policy".to_string(), "same-origin".to_string()),
459 (
460 "Cross-Origin-Embedder-Policy".to_string(),
461 "require-corp".to_string(),
462 ),
463 ]
464 .into_iter()
465 .collect();
466 let plan = plan_domain_worker(&dom, "https://cdn.net.yah.dev", "cloud").unwrap();
467 let binding = plan
468 .bindings
469 .iter()
470 .find(|(k, _)| k == "ROUTE_HEADERS")
471 .expect("ROUTE_HEADERS binding");
472 assert!(binding.1.contains("same-origin"), "{}", binding.1);
473 assert!(binding.1.contains("require-corp"), "{}", binding.1);
474 assert!(binding.1.contains("/*"), "{}", binding.1);
475 }
476
477 #[test]
478 fn plan_trims_trailing_slash_on_cdn_base() {
479 let plan =
480 plan_domain_worker(&net_tier_manifest(), "https://cdn.net.yah.dev/", "cloud").unwrap();
481 assert_eq!(plan.asset_origin, "https://cdn.net.yah.dev/scrabcake/cloud");
482 }
483
484 #[test]
488 fn plan_bails_when_front_door_is_not_worker() {
489 for door in [FrontDoor::BucketDirect, FrontDoor::Passway] {
490 let mut dom = net_tier_manifest();
491 dom.front_door = door;
492 let err = plan_domain_worker(&dom, "https://cdn.net.yah.dev", "cloud").unwrap_err();
493 let msg = format!("{err:#}");
494 assert!(msg.contains("front_door"), "{msg}");
495 assert!(msg.contains(door.as_str()), "{msg}");
496 }
497 }
498
499 #[test]
500 fn plan_bails_when_no_static_route() {
501 let mut dom = net_tier_manifest();
502 dom.routes = vec![DomainRoute {
503 headers: Default::default(),
504 path: "/old".into(),
505 mode: RouteMode::Redirect {
506 target: "https://elsewhere".into(),
507 status: 308,
508 },
509 }];
510 let err = plan_domain_worker(&dom, "https://cdn.net.yah.dev", "cloud").unwrap_err();
511 assert!(
512 format!("{err:#}").contains("no `static` route"),
513 "got: {err:#}"
514 );
515 }
516
517 use super::{plan_alias_claim, valid_subdomain_label, AliasTier};
519 use std::collections::BTreeMap;
520
521 #[test]
522 fn label_validation_rules() {
523 assert!(valid_subdomain_label("scrabcake"));
524 assert!(valid_subdomain_label("my-repo-1"));
525 assert!(!valid_subdomain_label("")); assert!(!valid_subdomain_label("-lead")); assert!(!valid_subdomain_label("trail-")); assert!(!valid_subdomain_label("Caps")); assert!(!valid_subdomain_label("under_score")); }
531
532 #[test]
533 fn claim_builds_net_tier_manifest() {
534 let dom = plan_alias_claim(
535 AliasTier::Net,
536 "scrabcake",
537 "scrabcake/site",
538 &BTreeMap::new(),
539 )
540 .unwrap();
541 assert_eq!(dom.name, "scrabcake-net-yah-dev");
542 assert_eq!(dom.domain, "scrabcake.net.yah.dev");
543 assert_eq!(dom.cdn_bucket, "net-yah-dev");
544 assert_eq!(dom.routes.len(), 1);
545 assert!(
546 matches!(&dom.routes[0].mode, RouteMode::Static { component } if component == "scrabcake/site")
547 );
548 }
549
550 #[test]
551 fn claim_uses_com_tier_zone_and_bucket() {
552 let dom = plan_alias_claim(AliasTier::Com, "acme", "acme/site", &BTreeMap::new()).unwrap();
553 assert_eq!(dom.domain, "acme.com.yah.dev");
554 assert_eq!(dom.cdn_bucket, "com-yah-dev");
555 }
556
557 #[test]
558 fn claim_bails_on_duplicate_host() {
559 let existing: BTreeMap<String, DomainConfig> =
560 [("scrabcake-net-yah-dev".to_string(), net_tier_manifest())]
561 .into_iter()
562 .collect();
563 let err =
564 plan_alias_claim(AliasTier::Net, "scrabcake", "scrabcake/site", &existing).unwrap_err();
565 assert!(format!("{err:#}").contains("already"), "got: {err:#}");
566 }
567
568 #[test]
569 fn claim_bails_on_invalid_label() {
570 let err =
571 plan_alias_claim(AliasTier::Net, "Bad_Name", "x/y", &BTreeMap::new()).unwrap_err();
572 assert!(
573 format!("{err:#}").contains("invalid subdomain"),
574 "got: {err:#}"
575 );
576 }
577}