1use std::path::Path;
20
21use anyhow::{Context, Result};
22use tracing::{debug, info};
23
24use crate::CloudflareClient;
25
26pub async fn ensure_r2_custom_domain(
41 workspace_root: &Path,
42 provider_id: &str,
43 bucket_name: &str,
44 domain: &str,
45) -> Result<()> {
46 let cf_provider = super::cf_creds::CfProvider::resolve(workspace_root, provider_id)?;
47 let account_id = cf_provider.account_id.clone();
48 let cf = CloudflareClient::new(cf_provider.api_token()?);
49 let existing = cf
50 .list_r2_custom_domains(&account_id, bucket_name)
51 .await
52 .with_context(|| format!("listing R2 custom domains on bucket {bucket_name:?}"))?;
53 if existing.iter().any(|d| d.domain == domain) {
54 debug!(
55 domain,
56 bucket_name, "R2 custom domain already bound — skipping"
57 );
58 return Ok(());
59 }
60 let zone_name = parent_zone_name(domain);
61 let zone_id = cf
62 .zone_id_for_name(zone_name)
63 .await
64 .with_context(|| format!("resolving zone id for {zone_name:?}"))?;
65 cf.add_r2_custom_domain(&account_id, bucket_name, domain, &zone_id)
66 .await
67 .with_context(|| format!("binding R2 custom domain {domain:?} → bucket {bucket_name:?}"))?;
68 info!(domain, bucket_name, zone_name, "R2 custom domain bound");
69 Ok(())
70}
71
72fn parent_zone_name(domain: &str) -> &str {
81 let last_dot = domain.rfind('.');
82 let Some(last_dot) = last_dot else {
83 return domain; };
85 if let Some(prev_dot) = domain[..last_dot].rfind('.') {
86 &domain[prev_dot + 1..]
87 } else {
88 domain }
90}
91
92use crate::config::{DomainConfig, DomainRoute, FrontDoor, RouteMode};
102use crate::provider::cloudflare::WorkerBinding;
103use crate::reconciler::mesofact_static::WORKER_SCRIPT;
104use std::collections::BTreeMap;
105
106#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct DomainWorkerPlan {
112 pub worker_name: String,
114 pub custom_domain: String,
116 pub asset_origin: String,
118 pub bindings: Vec<(String, String)>,
121}
122
123fn static_worker_bindings(asset_origin: &str, route_headers: String) -> Vec<(String, String)> {
130 vec![
131 ("ASSET_ORIGIN".to_string(), asset_origin.to_string()),
132 ("UPLOAD_ORIGIN".to_string(), String::new()),
133 ("WORKER_MODE".to_string(), "static".to_string()),
134 ("SSR_ORIGIN".to_string(), String::new()),
135 ("SSR_PREFIXES".to_string(), "[]".to_string()),
136 ("ROUTE_HEADERS".to_string(), route_headers),
137 ]
138}
139
140pub fn plan_domain_worker(
151 domain: &DomainConfig,
152 cdn_base: &str,
153 env: &str,
154) -> Result<DomainWorkerPlan> {
155 if domain.front_door != FrontDoor::Worker {
160 anyhow::bail!(
161 "domain {} ({}) declares front_door = \"{}\" — only \"worker\" \
162 domains get a generated Cloudflare Worker",
163 domain.name,
164 domain.domain,
165 domain.front_door.as_str()
166 );
167 }
168
169 let component = domain
171 .routes
172 .iter()
173 .find_map(|r| match &r.mode {
174 RouteMode::Static { component } => Some(component.as_str()),
175 _ => None,
176 })
177 .with_context(|| {
178 format!(
179 "domain {} ({}) has no `static` route — nothing for a static Worker to serve",
180 domain.name, domain.domain
181 )
182 })?;
183
184 let service = component
185 .split_once('/')
186 .map(|(svc, _)| svc)
187 .with_context(|| {
188 format!(
189 "domain {}: route component {component:?} — expected \"<service>/<component-id>\"",
190 domain.name
191 )
192 })?;
193
194 let asset_origin = format!("{}/{}/{}", cdn_base.trim_end_matches('/'), service, env);
195
196 Ok(DomainWorkerPlan {
197 worker_name: domain.name.clone(),
198 custom_domain: domain.domain.clone(),
199 bindings: static_worker_bindings(&asset_origin, domain.route_headers_json()),
200 asset_origin,
201 })
202}
203
204pub async fn deploy_domain_worker(
219 workspace_root: &Path,
220 provider_id: &str,
221 plan: &DomainWorkerPlan,
222) -> Result<()> {
223 let cf_provider = super::cf_creds::CfProvider::resolve(workspace_root, provider_id)?;
224 let account_id = cf_provider.account_id.clone();
225 let cf = CloudflareClient::new(cf_provider.api_token()?);
226
227 let worker_bindings: Vec<WorkerBinding<'_>> = plan
228 .bindings
229 .iter()
230 .map(|(k, v)| WorkerBinding::PlainText {
231 name: k.as_str(),
232 text: v.as_str(),
233 })
234 .collect();
235
236 cf.deploy_worker_script(
237 &account_id,
238 &plan.worker_name,
239 WORKER_SCRIPT,
240 &worker_bindings,
241 )
242 .await
243 .with_context(|| format!("deploying static Worker {}", plan.worker_name))?;
244 info!(worker = %plan.worker_name, "alias-tier Worker deployed");
245
246 let zone = parent_zone_name(&plan.custom_domain);
247 let zone_id = cf
248 .zone_id_for_name(zone)
249 .await
250 .with_context(|| format!("resolving zone id for {zone:?}"))?;
251 cf.upsert_worker_custom_domain(
252 &account_id,
253 &zone_id,
254 &plan.custom_domain,
255 &plan.worker_name,
256 )
257 .await
258 .with_context(|| {
259 format!(
260 "binding {} to Worker {}",
261 plan.custom_domain, plan.worker_name
262 )
263 })?;
264 info!(domain = %plan.custom_domain, worker = %plan.worker_name, "alias-tier custom domain bound");
265 Ok(())
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub enum AliasTier {
277 Com,
278 Net,
279}
280
281impl AliasTier {
282 pub fn zone(self) -> &'static str {
284 match self {
285 AliasTier::Com => "com.yah.dev",
286 AliasTier::Net => "net.yah.dev",
287 }
288 }
289
290 pub fn bucket(self) -> &'static str {
292 match self {
293 AliasTier::Com => "com-yah-dev",
294 AliasTier::Net => "net-yah-dev",
295 }
296 }
297
298 fn slug(self) -> &'static str {
300 match self {
301 AliasTier::Com => "com",
302 AliasTier::Net => "net",
303 }
304 }
305}
306
307pub fn valid_subdomain_label(name: &str) -> bool {
310 !name.is_empty()
311 && name.len() <= 63
312 && !name.starts_with('-')
313 && !name.ends_with('-')
314 && name
315 .bytes()
316 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
317}
318
319pub fn plan_alias_claim(
327 tier: AliasTier,
328 name: &str,
329 component: &str,
330 existing: &BTreeMap<String, DomainConfig>,
331) -> Result<DomainConfig> {
332 if !valid_subdomain_label(name) {
333 anyhow::bail!(
334 "invalid subdomain {name:?} — must be 1–63 chars, lowercase \
335 alphanumeric or hyphen, no leading/trailing hyphen"
336 );
337 }
338 if component.split_once('/').is_none() {
339 anyhow::bail!("component {component:?} — expected \"<service>/<component-id>\"");
340 }
341
342 let domain = format!("{name}.{}", tier.zone());
343 let stem = format!("{name}-{}-yah-dev", tier.slug());
344
345 if existing.values().any(|d| d.domain == domain) {
346 anyhow::bail!("{domain} is already claimed");
347 }
348 if existing.contains_key(&stem) {
349 anyhow::bail!("domain manifest {stem:?} already exists");
350 }
351
352 Ok(DomainConfig {
353 schema_version: 1,
354 name: stem,
355 domain,
356 front_door: FrontDoor::Worker,
359 cdn_bucket: tier.bucket().to_string(),
360 worker_bundle_path: None,
361 routes: vec![DomainRoute {
362 path: "/*".into(),
363 headers: BTreeMap::new(),
366 mode: RouteMode::Static {
367 component: component.to_string(),
368 },
369 }],
370 })
371}
372
373#[cfg(test)]
374mod tests {
375 use super::parent_zone_name;
376
377 #[test]
378 fn parent_zone_strips_one_label_off_subdomain() {
379 assert_eq!(parent_zone_name("cdn.yah.dev"), "yah.dev");
380 assert_eq!(parent_zone_name("app.yah.dev"), "yah.dev");
381 }
382
383 #[test]
384 fn parent_zone_returns_self_for_apex() {
385 assert_eq!(parent_zone_name("yah.dev"), "yah.dev");
386 }
387
388 #[test]
389 fn parent_zone_strips_only_first_label_for_deeper_subdomain() {
390 assert_eq!(parent_zone_name("a.b.yah.dev"), "yah.dev");
392 }
393
394 use super::{plan_domain_worker, DomainWorkerPlan};
395 use crate::config::{DomainConfig, DomainRoute, FrontDoor, RouteMode};
396
397 fn net_tier_manifest() -> DomainConfig {
398 DomainConfig {
400 schema_version: 1,
401 name: "scrabcake-net-yah-dev".into(),
402 domain: "scrabcake.net.yah.dev".into(),
403 front_door: FrontDoor::Worker,
404 cdn_bucket: "net-yah-dev".into(),
405 worker_bundle_path: None,
406 routes: vec![DomainRoute {
407 headers: Default::default(),
408 path: "/*".into(),
409 mode: RouteMode::Static {
410 component: "scrabcake/site".into(),
411 },
412 }],
413 }
414 }
415
416 #[test]
417 fn plan_resolves_worker_name_domain_and_asset_origin() {
418 let plan =
419 plan_domain_worker(&net_tier_manifest(), "https://cdn.net.yah.dev", "cloud").unwrap();
420 assert_eq!(
421 plan,
422 DomainWorkerPlan {
423 worker_name: "scrabcake-net-yah-dev".into(),
424 custom_domain: "scrabcake.net.yah.dev".into(),
425 asset_origin: "https://cdn.net.yah.dev/scrabcake/cloud".into(),
426 bindings: vec![
427 (
428 "ASSET_ORIGIN".into(),
429 "https://cdn.net.yah.dev/scrabcake/cloud".into()
430 ),
431 ("UPLOAD_ORIGIN".into(), String::new()),
432 ("WORKER_MODE".into(), "static".into()),
433 ("SSR_ORIGIN".into(), String::new()),
434 ("SSR_PREFIXES".into(), "[]".into()),
435 ("ROUTE_HEADERS".into(), "[]".into()),
436 ],
437 }
438 );
439 }
440
441 #[test]
446 fn plan_carries_declared_route_headers_into_the_bindings() {
447 let mut dom = net_tier_manifest();
448 dom.routes[0].headers = [
449 ("Cross-Origin-Opener-Policy".to_string(), "same-origin".to_string()),
450 (
451 "Cross-Origin-Embedder-Policy".to_string(),
452 "require-corp".to_string(),
453 ),
454 ]
455 .into_iter()
456 .collect();
457 let plan = plan_domain_worker(&dom, "https://cdn.net.yah.dev", "cloud").unwrap();
458 let binding = plan
459 .bindings
460 .iter()
461 .find(|(k, _)| k == "ROUTE_HEADERS")
462 .expect("ROUTE_HEADERS binding");
463 assert!(binding.1.contains("same-origin"), "{}", binding.1);
464 assert!(binding.1.contains("require-corp"), "{}", binding.1);
465 assert!(binding.1.contains("/*"), "{}", binding.1);
466 }
467
468 #[test]
469 fn plan_trims_trailing_slash_on_cdn_base() {
470 let plan =
471 plan_domain_worker(&net_tier_manifest(), "https://cdn.net.yah.dev/", "cloud").unwrap();
472 assert_eq!(plan.asset_origin, "https://cdn.net.yah.dev/scrabcake/cloud");
473 }
474
475 #[test]
479 fn plan_bails_when_front_door_is_not_worker() {
480 for door in [FrontDoor::BucketDirect, FrontDoor::Passway] {
481 let mut dom = net_tier_manifest();
482 dom.front_door = door;
483 let err = plan_domain_worker(&dom, "https://cdn.net.yah.dev", "cloud").unwrap_err();
484 let msg = format!("{err:#}");
485 assert!(msg.contains("front_door"), "{msg}");
486 assert!(msg.contains(door.as_str()), "{msg}");
487 }
488 }
489
490 #[test]
491 fn plan_bails_when_no_static_route() {
492 let mut dom = net_tier_manifest();
493 dom.routes = vec![DomainRoute {
494 headers: Default::default(),
495 path: "/old".into(),
496 mode: RouteMode::Redirect {
497 target: "https://elsewhere".into(),
498 status: 308,
499 },
500 }];
501 let err = plan_domain_worker(&dom, "https://cdn.net.yah.dev", "cloud").unwrap_err();
502 assert!(
503 format!("{err:#}").contains("no `static` route"),
504 "got: {err:#}"
505 );
506 }
507
508 use super::{plan_alias_claim, valid_subdomain_label, AliasTier};
510 use std::collections::BTreeMap;
511
512 #[test]
513 fn label_validation_rules() {
514 assert!(valid_subdomain_label("scrabcake"));
515 assert!(valid_subdomain_label("my-repo-1"));
516 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")); }
522
523 #[test]
524 fn claim_builds_net_tier_manifest() {
525 let dom = plan_alias_claim(
526 AliasTier::Net,
527 "scrabcake",
528 "scrabcake/site",
529 &BTreeMap::new(),
530 )
531 .unwrap();
532 assert_eq!(dom.name, "scrabcake-net-yah-dev");
533 assert_eq!(dom.domain, "scrabcake.net.yah.dev");
534 assert_eq!(dom.cdn_bucket, "net-yah-dev");
535 assert_eq!(dom.routes.len(), 1);
536 assert!(
537 matches!(&dom.routes[0].mode, RouteMode::Static { component } if component == "scrabcake/site")
538 );
539 }
540
541 #[test]
542 fn claim_uses_com_tier_zone_and_bucket() {
543 let dom = plan_alias_claim(AliasTier::Com, "acme", "acme/site", &BTreeMap::new()).unwrap();
544 assert_eq!(dom.domain, "acme.com.yah.dev");
545 assert_eq!(dom.cdn_bucket, "com-yah-dev");
546 }
547
548 #[test]
549 fn claim_bails_on_duplicate_host() {
550 let existing: BTreeMap<String, DomainConfig> =
551 [("scrabcake-net-yah-dev".to_string(), net_tier_manifest())]
552 .into_iter()
553 .collect();
554 let err =
555 plan_alias_claim(AliasTier::Net, "scrabcake", "scrabcake/site", &existing).unwrap_err();
556 assert!(format!("{err:#}").contains("already"), "got: {err:#}");
557 }
558
559 #[test]
560 fn claim_bails_on_invalid_label() {
561 let err =
562 plan_alias_claim(AliasTier::Net, "Bad_Name", "x/y", &BTreeMap::new()).unwrap_err();
563 assert!(
564 format!("{err:#}").contains("invalid subdomain"),
565 "got: {err:#}"
566 );
567 }
568}