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) -> Vec<(String, String)> {
126 vec![
127 ("ASSET_ORIGIN".to_string(), asset_origin.to_string()),
128 ("UPLOAD_ORIGIN".to_string(), String::new()),
129 ("WORKER_MODE".to_string(), "static".to_string()),
130 ("SSR_ORIGIN".to_string(), String::new()),
131 ("SSR_PREFIXES".to_string(), "[]".to_string()),
132 ]
133}
134
135pub fn plan_domain_worker(
146 domain: &DomainConfig,
147 cdn_base: &str,
148 env: &str,
149) -> Result<DomainWorkerPlan> {
150 if domain.front_door != FrontDoor::Worker {
155 anyhow::bail!(
156 "domain {} ({}) declares front_door = \"{}\" — only \"worker\" \
157 domains get a generated Cloudflare Worker",
158 domain.name,
159 domain.domain,
160 domain.front_door.as_str()
161 );
162 }
163
164 let component = domain
166 .routes
167 .iter()
168 .find_map(|r| match &r.mode {
169 RouteMode::Static { component } => Some(component.as_str()),
170 _ => None,
171 })
172 .with_context(|| {
173 format!(
174 "domain {} ({}) has no `static` route — nothing for a static Worker to serve",
175 domain.name, domain.domain
176 )
177 })?;
178
179 let service = component
180 .split_once('/')
181 .map(|(svc, _)| svc)
182 .with_context(|| {
183 format!(
184 "domain {}: route component {component:?} — expected \"<service>/<component-id>\"",
185 domain.name
186 )
187 })?;
188
189 let asset_origin = format!("{}/{}/{}", cdn_base.trim_end_matches('/'), service, env);
190
191 Ok(DomainWorkerPlan {
192 worker_name: domain.name.clone(),
193 custom_domain: domain.domain.clone(),
194 bindings: static_worker_bindings(&asset_origin),
195 asset_origin,
196 })
197}
198
199pub async fn deploy_domain_worker(
214 workspace_root: &Path,
215 provider_id: &str,
216 plan: &DomainWorkerPlan,
217) -> Result<()> {
218 let cf_provider = super::cf_creds::CfProvider::resolve(workspace_root, provider_id)?;
219 let account_id = cf_provider.account_id.clone();
220 let cf = CloudflareClient::new(cf_provider.api_token()?);
221
222 let worker_bindings: Vec<WorkerBinding<'_>> = plan
223 .bindings
224 .iter()
225 .map(|(k, v)| WorkerBinding::PlainText {
226 name: k.as_str(),
227 text: v.as_str(),
228 })
229 .collect();
230
231 cf.deploy_worker_script(
232 &account_id,
233 &plan.worker_name,
234 WORKER_SCRIPT,
235 &worker_bindings,
236 )
237 .await
238 .with_context(|| format!("deploying static Worker {}", plan.worker_name))?;
239 info!(worker = %plan.worker_name, "alias-tier Worker deployed");
240
241 let zone = parent_zone_name(&plan.custom_domain);
242 let zone_id = cf
243 .zone_id_for_name(zone)
244 .await
245 .with_context(|| format!("resolving zone id for {zone:?}"))?;
246 cf.upsert_worker_custom_domain(
247 &account_id,
248 &zone_id,
249 &plan.custom_domain,
250 &plan.worker_name,
251 )
252 .await
253 .with_context(|| {
254 format!(
255 "binding {} to Worker {}",
256 plan.custom_domain, plan.worker_name
257 )
258 })?;
259 info!(domain = %plan.custom_domain, worker = %plan.worker_name, "alias-tier custom domain bound");
260 Ok(())
261}
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum AliasTier {
272 Com,
273 Net,
274}
275
276impl AliasTier {
277 pub fn zone(self) -> &'static str {
279 match self {
280 AliasTier::Com => "com.yah.dev",
281 AliasTier::Net => "net.yah.dev",
282 }
283 }
284
285 pub fn bucket(self) -> &'static str {
287 match self {
288 AliasTier::Com => "com-yah-dev",
289 AliasTier::Net => "net-yah-dev",
290 }
291 }
292
293 fn slug(self) -> &'static str {
295 match self {
296 AliasTier::Com => "com",
297 AliasTier::Net => "net",
298 }
299 }
300}
301
302pub fn valid_subdomain_label(name: &str) -> bool {
305 !name.is_empty()
306 && name.len() <= 63
307 && !name.starts_with('-')
308 && !name.ends_with('-')
309 && name
310 .bytes()
311 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
312}
313
314pub fn plan_alias_claim(
322 tier: AliasTier,
323 name: &str,
324 component: &str,
325 existing: &BTreeMap<String, DomainConfig>,
326) -> Result<DomainConfig> {
327 if !valid_subdomain_label(name) {
328 anyhow::bail!(
329 "invalid subdomain {name:?} — must be 1–63 chars, lowercase \
330 alphanumeric or hyphen, no leading/trailing hyphen"
331 );
332 }
333 if component.split_once('/').is_none() {
334 anyhow::bail!("component {component:?} — expected \"<service>/<component-id>\"");
335 }
336
337 let domain = format!("{name}.{}", tier.zone());
338 let stem = format!("{name}-{}-yah-dev", tier.slug());
339
340 if existing.values().any(|d| d.domain == domain) {
341 anyhow::bail!("{domain} is already claimed");
342 }
343 if existing.contains_key(&stem) {
344 anyhow::bail!("domain manifest {stem:?} already exists");
345 }
346
347 Ok(DomainConfig {
348 schema_version: 1,
349 name: stem,
350 domain,
351 front_door: FrontDoor::Worker,
354 cdn_bucket: tier.bucket().to_string(),
355 worker_bundle_path: None,
356 routes: vec![DomainRoute {
357 path: "/*".into(),
358 mode: RouteMode::Static {
359 component: component.to_string(),
360 },
361 }],
362 })
363}
364
365#[cfg(test)]
366mod tests {
367 use super::parent_zone_name;
368
369 #[test]
370 fn parent_zone_strips_one_label_off_subdomain() {
371 assert_eq!(parent_zone_name("cdn.yah.dev"), "yah.dev");
372 assert_eq!(parent_zone_name("app.yah.dev"), "yah.dev");
373 }
374
375 #[test]
376 fn parent_zone_returns_self_for_apex() {
377 assert_eq!(parent_zone_name("yah.dev"), "yah.dev");
378 }
379
380 #[test]
381 fn parent_zone_strips_only_first_label_for_deeper_subdomain() {
382 assert_eq!(parent_zone_name("a.b.yah.dev"), "yah.dev");
384 }
385
386 use super::{plan_domain_worker, DomainWorkerPlan};
387 use crate::config::{DomainConfig, DomainRoute, FrontDoor, RouteMode};
388
389 fn net_tier_manifest() -> DomainConfig {
390 DomainConfig {
392 schema_version: 1,
393 name: "scrabcake-net-yah-dev".into(),
394 domain: "scrabcake.net.yah.dev".into(),
395 front_door: FrontDoor::Worker,
396 cdn_bucket: "net-yah-dev".into(),
397 worker_bundle_path: None,
398 routes: vec![DomainRoute {
399 path: "/*".into(),
400 mode: RouteMode::Static {
401 component: "scrabcake/site".into(),
402 },
403 }],
404 }
405 }
406
407 #[test]
408 fn plan_resolves_worker_name_domain_and_asset_origin() {
409 let plan =
410 plan_domain_worker(&net_tier_manifest(), "https://cdn.net.yah.dev", "cloud").unwrap();
411 assert_eq!(
412 plan,
413 DomainWorkerPlan {
414 worker_name: "scrabcake-net-yah-dev".into(),
415 custom_domain: "scrabcake.net.yah.dev".into(),
416 asset_origin: "https://cdn.net.yah.dev/scrabcake/cloud".into(),
417 bindings: vec![
418 (
419 "ASSET_ORIGIN".into(),
420 "https://cdn.net.yah.dev/scrabcake/cloud".into()
421 ),
422 ("UPLOAD_ORIGIN".into(), String::new()),
423 ("WORKER_MODE".into(), "static".into()),
424 ("SSR_ORIGIN".into(), String::new()),
425 ("SSR_PREFIXES".into(), "[]".into()),
426 ],
427 }
428 );
429 }
430
431 #[test]
432 fn plan_trims_trailing_slash_on_cdn_base() {
433 let plan =
434 plan_domain_worker(&net_tier_manifest(), "https://cdn.net.yah.dev/", "cloud").unwrap();
435 assert_eq!(plan.asset_origin, "https://cdn.net.yah.dev/scrabcake/cloud");
436 }
437
438 #[test]
442 fn plan_bails_when_front_door_is_not_worker() {
443 for door in [FrontDoor::BucketDirect, FrontDoor::Passway] {
444 let mut dom = net_tier_manifest();
445 dom.front_door = door;
446 let err = plan_domain_worker(&dom, "https://cdn.net.yah.dev", "cloud").unwrap_err();
447 let msg = format!("{err:#}");
448 assert!(msg.contains("front_door"), "{msg}");
449 assert!(msg.contains(door.as_str()), "{msg}");
450 }
451 }
452
453 #[test]
454 fn plan_bails_when_no_static_route() {
455 let mut dom = net_tier_manifest();
456 dom.routes = vec![DomainRoute {
457 path: "/old".into(),
458 mode: RouteMode::Redirect {
459 target: "https://elsewhere".into(),
460 status: 308,
461 },
462 }];
463 let err = plan_domain_worker(&dom, "https://cdn.net.yah.dev", "cloud").unwrap_err();
464 assert!(
465 format!("{err:#}").contains("no `static` route"),
466 "got: {err:#}"
467 );
468 }
469
470 use super::{plan_alias_claim, valid_subdomain_label, AliasTier};
472 use std::collections::BTreeMap;
473
474 #[test]
475 fn label_validation_rules() {
476 assert!(valid_subdomain_label("scrabcake"));
477 assert!(valid_subdomain_label("my-repo-1"));
478 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")); }
484
485 #[test]
486 fn claim_builds_net_tier_manifest() {
487 let dom = plan_alias_claim(
488 AliasTier::Net,
489 "scrabcake",
490 "scrabcake/site",
491 &BTreeMap::new(),
492 )
493 .unwrap();
494 assert_eq!(dom.name, "scrabcake-net-yah-dev");
495 assert_eq!(dom.domain, "scrabcake.net.yah.dev");
496 assert_eq!(dom.cdn_bucket, "net-yah-dev");
497 assert_eq!(dom.routes.len(), 1);
498 assert!(
499 matches!(&dom.routes[0].mode, RouteMode::Static { component } if component == "scrabcake/site")
500 );
501 }
502
503 #[test]
504 fn claim_uses_com_tier_zone_and_bucket() {
505 let dom = plan_alias_claim(AliasTier::Com, "acme", "acme/site", &BTreeMap::new()).unwrap();
506 assert_eq!(dom.domain, "acme.com.yah.dev");
507 assert_eq!(dom.cdn_bucket, "com-yah-dev");
508 }
509
510 #[test]
511 fn claim_bails_on_duplicate_host() {
512 let existing: BTreeMap<String, DomainConfig> =
513 [("scrabcake-net-yah-dev".to_string(), net_tier_manifest())]
514 .into_iter()
515 .collect();
516 let err =
517 plan_alias_claim(AliasTier::Net, "scrabcake", "scrabcake/site", &existing).unwrap_err();
518 assert!(format!("{err:#}").contains("already"), "got: {err:#}");
519 }
520
521 #[test]
522 fn claim_bails_on_invalid_label() {
523 let err =
524 plan_alias_claim(AliasTier::Net, "Bad_Name", "x/y", &BTreeMap::new()).unwrap_err();
525 assert!(
526 format!("{err:#}").contains("invalid subdomain"),
527 "got: {err:#}"
528 );
529 }
530}