Skip to main content

cloud/reconciler/
domain.rs

1//! Domain-level reconciler — `.yah/domains/*.toml` → live Cloudflare state.
2//!
3//! Today's only shape is the **R2 custom-domain** binding: a domain that
4//! declares `front_door = "bucket-direct"` (R594-F12) and names an R2 bucket
5//! in `cdn_bucket`. Cloudflare's R2 Custom Domains API binds the hostname to
6//! the bucket and writes the CNAME into the parent zone automatically when
7//! the zone lives on the same account — no DNS-side call is needed here.
8//!
9//! Domains with `front_door = "worker"` (e.g. `yah-dev.toml`,
10//! `app-yah-dev.toml`) are out of scope for this shape; they get DNS + route
11//! management through the Worker reconciler below. `front_door = "passway"`
12//! is the sovereign-ingress path (W267) and has no reconciler here yet.
13//!
14//! Before R594-F12 the shape was *inferred* from the absence of `[[routes]]`,
15//! which meant a route-carrying manifest bound straight to R2 was accepted
16//! and silently skipped by this pass. The discriminator is now declared and
17//! validated at load.
18
19use std::path::Path;
20
21use anyhow::{Context, Result};
22use tracing::{debug, info};
23
24use crate::CloudflareClient;
25
26/// Bind `domain` as a custom domain on `bucket_name`. Idempotent — does
27/// nothing when the binding is already present, regardless of `enabled`
28/// state (CF reflects newly-added bindings as `enabled: true` immediately;
29/// disabling is an explicit dashboard action we don't undo here).
30///
31/// Resolves account_id + the Cloudflare API token from the named provider
32/// (`.yah/infra/providers/<provider_id>.toml`, see
33/// [`super::cf_creds::CfProvider`]). Mirrors the list-first pattern of
34/// `static_asset::ensure_r2_bucket` so the apply loop is safe to re-run.
35///
36/// Required token scopes: `Workers R2 Storage: Edit` and `Zone: Read`
37/// (the latter because the API requires the zone id of the parent zone —
38/// CF writes the CNAME there). The caller does NOT need `DNS: Edit`; CF
39/// provisions the record itself when the binding is created.
40pub 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
72/// Heuristic: the parent CF zone of `domain` is its last two labels.
73///
74/// `cdn.yah.dev` → `yah.dev`; `yah.dev` → `yah.dev` (already apex). This is
75/// correct for every yah-owned zone today (all are two-label apexes). If a
76/// future workspace registers a three-label zone (e.g. `staging.yah.dev`
77/// as its own CF zone) and binds a subdomain under it, this will resolve
78/// to the wrong zone — swap in a longest-suffix match against
79/// `list_zones()` when that day arrives.
80fn parent_zone_name(domain: &str) -> &str {
81    let last_dot = domain.rfind('.');
82    let Some(last_dot) = last_dot else {
83        return domain; // single-label — let CF surface the bad input
84    };
85    if let Some(prev_dot) = domain[..last_dot].rfind('.') {
86        &domain[prev_dot + 1..]
87    } else {
88        domain // already a two-label apex
89    }
90}
91
92// ─── R561-F3: domain-manifest-driven static Worker ──────────────────────────
93//
94// A per-tenant alias-tier manifest (e.g. scrabcake.net.yah.dev) carries a
95// `static` route → `<service>/<component>`. Serving it means deploying the
96// shared router bundle (`WORKER_SCRIPT`) configured to fetch the tenant's
97// assets from its R2 prefix, then binding the subdomain to that Worker via the
98// Workers Custom Domains API. This is the routed-domain shape the apply loop
99// currently Skips.
100
101use crate::config::{DomainConfig, DomainRoute, FrontDoor, RouteMode};
102use crate::provider::cloudflare::WorkerBinding;
103use crate::reconciler::mesofact_static::WORKER_SCRIPT;
104use std::collections::BTreeMap;
105
106/// The plan for deploying one subdomain's static Worker — everything decided
107/// before any Cloudflare call. Pure output of [`plan_domain_worker`], so the
108/// decision logic is unit-testable offline; [`deploy_domain_worker`] performs
109/// the I/O.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct DomainWorkerPlan {
112    /// Worker script name (the domain manifest's file stem / `name`).
113    pub worker_name: String,
114    /// Hostname to bind via Workers Custom Domains, e.g. `scrabcake.net.yah.dev`.
115    pub custom_domain: String,
116    /// `ASSET_ORIGIN` the Worker fetches from: `<cdn_base>/<service>/<env>`.
117    pub asset_origin: String,
118    /// `plain_text` Worker bindings (mirrors the Static-mode shape of
119    /// mesofact_static's `worker_config_bindings`).
120    pub bindings: Vec<(String, String)>,
121}
122
123/// Static-mode Worker bindings for an alias-tier subdomain. Kept in lockstep
124/// with `mesofact_static::worker_config_bindings(WorkerMode::Static, …)`.
125///
126/// `route_headers` is the manifest's own `DomainConfig::route_headers_json` —
127/// this planner already holds the domain, so unlike the mirror-driven path it
128/// needs no lookup to find it (R746).
129fn 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
140/// Build the [`DomainWorkerPlan`] for a per-tenant alias-tier manifest.
141///
142/// `cdn_base` is the tier's CDN origin (an R2 custom domain bound to the
143/// tier bucket, e.g. `https://cdn.net.yah.dev`); `env` is the mirror env the
144/// publisher wrote under (e.g. `cloud`). The publisher lays assets down at
145/// `<bucket>/<service>/<env>/<key>` and the Worker fetches
146/// `${ASSET_ORIGIN}/<key>`, so `ASSET_ORIGIN = <cdn_base>/<service>/<env>`.
147///
148/// Fails fast (before any network call) when the manifest has no `static`
149/// route or its component ref is malformed.
150pub fn plan_domain_worker(
151    domain: &DomainConfig,
152    cdn_base: &str,
153    env: &str,
154) -> Result<DomainWorkerPlan> {
155    // R594-F12: refuse to synthesize a Worker for a domain that declared a
156    // different front door. Without this the plan succeeds and deploys a
157    // Worker that never receives traffic (bucket-direct) or that duplicates
158    // the sovereign ingress (passway).
159    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    // First static route wins (v1 alias tier serves one site per subdomain).
170    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
204/// Deploy the planned Worker and bind the subdomain (R561-F3, live I/O).
205///
206/// Reuses the shared `WORKER_SCRIPT` bundle + the existing CloudflareClient
207/// deploy/custom-domain methods. Gated on `cloudflare-api-token`. The decision
208/// logic is covered by `plan_domain_worker`'s tests; this I/O path has NOT been
209/// exercised against a live account yet (no creds in CI) — treat as untested
210/// until a live `yah cloud apply` confirms it.
211///
212/// CAVEAT (zone resolution): `parent_zone_name` uses a two-label heuristic, so
213/// for `scrabcake.net.yah.dev` it returns `yah.dev`. But the alias tiers
214/// `net.yah.dev` / `com.yah.dev` are their own Cloudflare zones — the binding
215/// must target `net.yah.dev`, not `yah.dev`. Before this goes live, swap in a
216/// longest-suffix match against the account's zones (the upgrade path
217/// `parent_zone_name`'s doc already names). Tracked on R561-F3.
218pub 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// ─── R561-F4: alias-tier registration ───────────────────────────────────────
269//
270// "Claim <name>.{com,net}.yah.dev": validate the label, check it's free, and
271// produce the per-tenant DomainConfig (the F2 manifest) the registration flow
272// writes. Pure + offline — the SaaS "sign up, get a subdomain" moment.
273
274/// The two wildcard alias tiers. `Com` = managed/commercial, `Net` = community.
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub enum AliasTier {
277    Com,
278    Net,
279}
280
281impl AliasTier {
282    /// The tier's Cloudflare zone, e.g. `net.yah.dev`.
283    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    /// The tier's shared R2 bucket (per-tenant `<svc>/<env>` prefixes within).
291    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    /// Manifest-name infix, e.g. `net` → `<name>-net-yah-dev` file stem.
299    fn slug(self) -> &'static str {
300        match self {
301            AliasTier::Com => "com",
302            AliasTier::Net => "net",
303        }
304    }
305}
306
307/// A valid DNS label: 1–63 chars, lowercase alphanumeric or hyphen, no
308/// leading/trailing hyphen. (Subdomain names a tenant can claim.)
309pub 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
319/// Validate and build the per-tenant alias manifest for a claim.
320///
321/// `name` is the requested subdomain label (e.g. `scrabcake`); `component` is
322/// the tenant's static component ref (`"<service>/<component-id>"`). `existing`
323/// is the current domain map (from `CloudConfig`) — the claim fails if the
324/// resulting host or manifest name is already taken. Returns the
325/// [`DomainConfig`] to write at `.yah/domains/<stem>.toml`.
326pub 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        // R594-F12: every tenant manifest is Worker-served (R561-F3 binds the
357        // subdomain as a custom domain on the generated Worker).
358        front_door: FrontDoor::Worker,
359        cdn_bucket: tier.bucket().to_string(),
360        worker_bundle_path: None,
361        routes: vec![DomainRoute {
362            path: "/*".into(),
363            // A claimed alias serves one bundle at the root with no special
364            // header needs; a tenant that wants some edits its own manifest.
365            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        // Two-label heuristic: yah-side zones are all two-label apexes today.
391        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        // Mirrors .yah/domains/scrabcake-net-yah-dev.toml.
399        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    /// R746: headers declared on a route reach the deployed Worker as the
442    /// ROUTE_HEADERS binding. Without this the manifest could declare them and
443    /// the Worker would serve without them — the exact silent gap the primitive
444    /// exists to close.
445    #[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    /// R594-F12: a manifest that declares a different front door must not
476    /// get a Cloudflare Worker synthesized behind its back — the Worker
477    /// would deploy and then never receive traffic.
478    #[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    // ── R561-F4: alias-tier registration ──
509    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("")); // empty
517        assert!(!valid_subdomain_label("-lead")); // leading hyphen
518        assert!(!valid_subdomain_label("trail-")); // trailing hyphen
519        assert!(!valid_subdomain_label("Caps")); // uppercase
520        assert!(!valid_subdomain_label("under_score")); // underscore
521    }
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}