use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use super::normalize::normalize_domain_name;
use super::store::{create_domain, get_default_domain, list_domains, set_default_domain};
use super::types::{DomainEntry, DomainStatus, DomainUrlScheme};
use crate::server::error::AppError;
use crate::server::store::Store;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SeedTier {
AlreadySeeded,
FromBootstrapDomains,
FromLegacyPublicUrl,
NoSeed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SeedOutcome {
pub tier: SeedTier,
pub final_count: usize,
pub default: Option<String>,
}
pub async fn seed_domains_first_boot(
store: &Store,
bootstrap_domains: &[String],
legacy_public_url: Option<&str>,
) -> Result<SeedOutcome, AppError> {
let existing = list_domains(store).await?;
if !existing.is_empty() {
let default = get_default_domain(store).await?;
return Ok(SeedOutcome {
tier: SeedTier::AlreadySeeded,
final_count: existing.len(),
default,
});
}
if !bootstrap_domains.is_empty() {
let first = &bootstrap_domains[0];
seed_entry(store, first, true).await?;
for d in &bootstrap_domains[1..] {
seed_entry(store, d, false).await?;
}
let final_count = list_domains(store).await?.len();
info!(
tier = "bootstrap_domains",
count = final_count,
default = %first,
"first-boot domain seed complete"
);
return Ok(SeedOutcome {
tier: SeedTier::FromBootstrapDomains,
final_count,
default: Some(normalize_domain_name(first)?),
});
}
if let Some(url) = legacy_public_url
&& let Some(host) = host_from_public_url(url)
{
warn!(
legacy_public_url = %url,
derived_host = %host,
"first-boot domain seed: bootstrap_domains empty — falling back to legacy public_url host. \
Consider moving '{host}' to config `[hosting] bootstrap_domains` for explicit intent."
);
seed_entry(store, &host, true).await?;
return Ok(SeedOutcome {
tier: SeedTier::FromLegacyPublicUrl,
final_count: 1,
default: Some(normalize_domain_name(&host)?),
});
}
warn!(
"first-boot domain seed: no domains configured — neither \
[hosting] bootstrap_domains nor a legacy public_url is set. \
The daemon will reject create-DID requests until an admin \
adds a domain. Add one via `did-hosting-control domain create` \
or the admin UI."
);
Ok(SeedOutcome {
tier: SeedTier::NoSeed,
final_count: 0,
default: None,
})
}
async fn seed_entry(store: &Store, name: &str, is_default: bool) -> Result<(), AppError> {
let canonical = normalize_domain_name(name)?;
let entry = DomainEntry {
name: canonical.clone(),
label: None,
scheme: DomainUrlScheme::Https,
status: DomainStatus::Active,
created_at: now_secs(),
default_domain: is_default,
branding: None,
witnesses: None,
watchers: None,
quota: None,
well_known_enabled: false,
disabled_at: None,
purge_at: None,
};
create_domain(store, &entry).await?;
if is_default {
set_default_domain(store, &canonical).await?;
}
Ok(())
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn host_from_public_url(url: &str) -> Option<String> {
let parsed = url::Url::parse(url).ok()?;
let host = parsed.host_str()?.to_lowercase();
Some(match parsed.port() {
Some(p) => format!("{host}:{p}"),
None => host,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::config::StoreConfig;
async fn fjall_store() -> Store {
let dir = tempfile::tempdir().expect("tempdir");
let cfg = StoreConfig {
data_dir: dir.path().to_path_buf(),
..StoreConfig::default()
};
std::mem::forget(dir);
Store::open(&cfg).await.expect("open fjall")
}
#[tokio::test]
async fn tier_1_seeds_first_as_default_and_rest_as_extra() {
let store = fjall_store().await;
let outcome = seed_domains_first_boot(
&store,
&[
"primary.example".to_string(),
"secondary.example".to_string(),
],
None,
)
.await
.unwrap();
assert_eq!(outcome.tier, SeedTier::FromBootstrapDomains);
assert_eq!(outcome.final_count, 2);
assert_eq!(outcome.default.as_deref(), Some("primary.example"));
let listed = list_domains(&store).await.unwrap();
let primary = listed.iter().find(|d| d.name == "primary.example").unwrap();
let secondary = listed
.iter()
.find(|d| d.name == "secondary.example")
.unwrap();
assert!(primary.default_domain);
assert!(!secondary.default_domain);
}
#[tokio::test]
async fn tier_1_single_entry() {
let store = fjall_store().await;
let outcome = seed_domains_first_boot(&store, &["only.example".to_string()], None)
.await
.unwrap();
assert_eq!(outcome.tier, SeedTier::FromBootstrapDomains);
assert_eq!(outcome.final_count, 1);
assert_eq!(outcome.default.as_deref(), Some("only.example"));
}
#[tokio::test]
async fn tier_2_seeds_from_legacy_public_url() {
let store = fjall_store().await;
let outcome =
seed_domains_first_boot(&store, &[], Some("https://old.example.com/some/path"))
.await
.unwrap();
assert_eq!(outcome.tier, SeedTier::FromLegacyPublicUrl);
assert_eq!(outcome.final_count, 1);
assert_eq!(outcome.default.as_deref(), Some("old.example.com"));
}
#[tokio::test]
async fn tier_2_lowercases_host() {
let store = fjall_store().await;
let outcome = seed_domains_first_boot(&store, &[], Some("https://OLD.example.com"))
.await
.unwrap();
assert_eq!(outcome.tier, SeedTier::FromLegacyPublicUrl);
assert_eq!(outcome.default.as_deref(), Some("old.example.com"));
}
#[tokio::test]
async fn tier_3_empty_seed_yields_no_seed() {
let store = fjall_store().await;
let outcome = seed_domains_first_boot(&store, &[], None).await.unwrap();
assert_eq!(outcome.tier, SeedTier::NoSeed);
assert_eq!(outcome.final_count, 0);
assert_eq!(outcome.default, None);
assert!(list_domains(&store).await.unwrap().is_empty());
}
#[tokio::test]
async fn already_seeded_short_circuits() {
let store = fjall_store().await;
seed_domains_first_boot(&store, &["a.example".to_string()], None)
.await
.unwrap();
let outcome = seed_domains_first_boot(
&store,
&["b.example".to_string()],
Some("https://c.example"),
)
.await
.unwrap();
assert_eq!(outcome.tier, SeedTier::AlreadySeeded);
assert_eq!(outcome.final_count, 1);
assert_eq!(outcome.default.as_deref(), Some("a.example"));
let listed = list_domains(&store).await.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].name, "a.example");
}
#[tokio::test]
async fn rejects_non_canonical_bootstrap_domain() {
let store = fjall_store().await;
let err = seed_domains_first_boot(&store, &["Example.com".to_string()], None)
.await
.expect_err("non-canonical must reject");
assert!(matches!(err, AppError::Validation(_)));
}
#[tokio::test]
async fn tier_1_preferred_over_tier_2_when_both_set() {
let store = fjall_store().await;
let outcome = seed_domains_first_boot(
&store,
&["preferred.example".to_string()],
Some("https://legacy.example.com"),
)
.await
.unwrap();
assert_eq!(outcome.tier, SeedTier::FromBootstrapDomains);
assert_eq!(outcome.default.as_deref(), Some("preferred.example"));
let listed = list_domains(&store).await.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].name, "preferred.example");
}
#[tokio::test]
async fn unparseable_legacy_url_falls_through_to_no_seed() {
let store = fjall_store().await;
let outcome = seed_domains_first_boot(&store, &[], Some("not-a-url"))
.await
.unwrap();
assert_eq!(outcome.tier, SeedTier::NoSeed);
}
#[test]
fn host_extraction() {
assert_eq!(
host_from_public_url("https://example.com:8080/path"),
Some("example.com:8080".to_string())
);
assert_eq!(
host_from_public_url("https://example.com:443"),
Some("example.com".to_string())
);
assert_eq!(
host_from_public_url("http://Example.COM"),
Some("example.com".to_string())
);
assert_eq!(
host_from_public_url("http://localhost:8534"),
Some("localhost:8534".to_string())
);
assert_eq!(host_from_public_url("not-a-url"), None);
}
}