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, …)`.
125fn 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
135/// Build the [`DomainWorkerPlan`] for a per-tenant alias-tier manifest.
136///
137/// `cdn_base` is the tier's CDN origin (an R2 custom domain bound to the
138/// tier bucket, e.g. `https://cdn.net.yah.dev`); `env` is the mirror env the
139/// publisher wrote under (e.g. `cloud`). The publisher lays assets down at
140/// `<bucket>/<service>/<env>/<key>` and the Worker fetches
141/// `${ASSET_ORIGIN}/<key>`, so `ASSET_ORIGIN = <cdn_base>/<service>/<env>`.
142///
143/// Fails fast (before any network call) when the manifest has no `static`
144/// route or its component ref is malformed.
145pub fn plan_domain_worker(
146    domain: &DomainConfig,
147    cdn_base: &str,
148    env: &str,
149) -> Result<DomainWorkerPlan> {
150    // R594-F12: refuse to synthesize a Worker for a domain that declared a
151    // different front door. Without this the plan succeeds and deploys a
152    // Worker that never receives traffic (bucket-direct) or that duplicates
153    // the sovereign ingress (passway).
154    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    // First static route wins (v1 alias tier serves one site per subdomain).
165    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
199/// Deploy the planned Worker and bind the subdomain (R561-F3, live I/O).
200///
201/// Reuses the shared `WORKER_SCRIPT` bundle + the existing CloudflareClient
202/// deploy/custom-domain methods. Gated on `cloudflare-api-token`. The decision
203/// logic is covered by `plan_domain_worker`'s tests; this I/O path has NOT been
204/// exercised against a live account yet (no creds in CI) — treat as untested
205/// until a live `yah cloud apply` confirms it.
206///
207/// CAVEAT (zone resolution): `parent_zone_name` uses a two-label heuristic, so
208/// for `scrabcake.net.yah.dev` it returns `yah.dev`. But the alias tiers
209/// `net.yah.dev` / `com.yah.dev` are their own Cloudflare zones — the binding
210/// must target `net.yah.dev`, not `yah.dev`. Before this goes live, swap in a
211/// longest-suffix match against the account's zones (the upgrade path
212/// `parent_zone_name`'s doc already names). Tracked on R561-F3.
213pub 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// ─── R561-F4: alias-tier registration ───────────────────────────────────────
264//
265// "Claim <name>.{com,net}.yah.dev": validate the label, check it's free, and
266// produce the per-tenant DomainConfig (the F2 manifest) the registration flow
267// writes. Pure + offline — the SaaS "sign up, get a subdomain" moment.
268
269/// The two wildcard alias tiers. `Com` = managed/commercial, `Net` = community.
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum AliasTier {
272    Com,
273    Net,
274}
275
276impl AliasTier {
277    /// The tier's Cloudflare zone, e.g. `net.yah.dev`.
278    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    /// The tier's shared R2 bucket (per-tenant `<svc>/<env>` prefixes within).
286    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    /// Manifest-name infix, e.g. `net` → `<name>-net-yah-dev` file stem.
294    fn slug(self) -> &'static str {
295        match self {
296            AliasTier::Com => "com",
297            AliasTier::Net => "net",
298        }
299    }
300}
301
302/// A valid DNS label: 1–63 chars, lowercase alphanumeric or hyphen, no
303/// leading/trailing hyphen. (Subdomain names a tenant can claim.)
304pub 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
314/// Validate and build the per-tenant alias manifest for a claim.
315///
316/// `name` is the requested subdomain label (e.g. `scrabcake`); `component` is
317/// the tenant's static component ref (`"<service>/<component-id>"`). `existing`
318/// is the current domain map (from `CloudConfig`) — the claim fails if the
319/// resulting host or manifest name is already taken. Returns the
320/// [`DomainConfig`] to write at `.yah/domains/<stem>.toml`.
321pub 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        // R594-F12: every tenant manifest is Worker-served (R561-F3 binds the
352        // subdomain as a custom domain on the generated Worker).
353        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        // Two-label heuristic: yah-side zones are all two-label apexes today.
383        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        // Mirrors .yah/domains/scrabcake-net-yah-dev.toml.
391        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    /// R594-F12: a manifest that declares a different front door must not
439    /// get a Cloudflare Worker synthesized behind its back — the Worker
440    /// would deploy and then never receive traffic.
441    #[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    // ── R561-F4: alias-tier registration ──
471    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("")); // empty
479        assert!(!valid_subdomain_label("-lead")); // leading hyphen
480        assert!(!valid_subdomain_label("trail-")); // trailing hyphen
481        assert!(!valid_subdomain_label("Caps")); // uppercase
482        assert!(!valid_subdomain_label("under_score")); // underscore
483    }
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}