use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpListener;
use crate::auth::{AuthConfig, AuthContext};
use crate::cli::error::CliError;
use crate::config::Config;
use crate::error::Error;
use crate::signing_key::SigningKey;
use crate::{
admin_router, create_report_router, did_document_router, health_router, public_router, storage,
subscribe_router, wellknown_router,
};
const DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
pub async fn run<F>(config: Config, shutdown: F) -> Result<(), CliError>
where
F: std::future::Future<Output = ()> + Send + 'static,
{
config
.validate()
.map_err(|e| CliError::Config(e.to_string()))?;
let key = SigningKey::load_from_file(&config.signing_key_path)?;
let pool = storage::open(&config.db_path)
.await
.map_err(|e| CliError::MigrationFailed(e.to_string()))?;
let reason_vocabulary = crate::moderation::reasons::ReasonVocabulary::from_config(&config)
.map_err(|e| CliError::Startup(format!("moderation reasons: {e}")))?;
let strike_policy = crate::moderation::policy::StrikePolicy::from_config(&config)
.map_err(|e| CliError::Startup(format!("strike policy: {e}")))?;
let label_emission_policy = crate::labels::policy::LabelEmissionPolicy::from_config(&config)
.map_err(|e| CliError::Startup(format!("label emission policy: {e}")))?;
let policy_automation_policy =
crate::policy::automation::PolicyAutomationPolicy::from_config(&config)
.map_err(|e| CliError::Startup(format!("policy automation: {e}")))?;
policy_automation_policy
.validate_reason_codes_against(&reason_vocabulary)
.map_err(|e| CliError::Startup(format!("policy automation: {e}")))?;
let pds_admin_policy = crate::pds_admin::PdsAdminPolicy::from_config(&config)
.map_err(|e| CliError::Startup(format!("pds_admin: {e}")))?;
let pds_admin_bridge = build_pds_admin_bridge(&pds_admin_policy)?;
if let Some(bridge) = pds_admin_bridge.as_ref() {
run_pds_admin_startup_probe(bridge).await;
}
let writer = crate::writer::spawn_with_pds_admin(
pool.clone(),
key,
config.service_did.clone(),
crate::SubscribeConfig::default().retention_days,
config.retention.clone().into(),
reason_vocabulary,
strike_policy.clone(),
label_emission_policy,
policy_automation_policy,
pds_admin_bridge,
)
.await
.map_err(map_spawn_writer_error)?;
if let Err(verify_err) = verify::verify_service_record(&config).await {
if let Err(e) = writer.shutdown().await {
tracing::warn!(error = %e, "writer shutdown failed during verify-induced exit");
}
return Err(verify_err);
}
let auth_config = AuthConfig {
service_did: config.service_did.clone(),
..AuthConfig::default()
};
let did_resolver: Arc<dyn crate::auth::did::DidResolver> =
Arc::new(crate::auth::did::HttpDidResolver::new(
auth_config.plc_directory_url.clone(),
auth_config.resolver_timeout,
));
let auth = Arc::new(AuthContext::with_resolver(
auth_config,
did_resolver.clone(),
));
let admin_cfg = {
let mut c: crate::AdminConfig = config.admin.clone().into();
c.service_did = config.service_did.clone();
c.service_endpoint = config.service_endpoint.clone();
c.declared_label_values = config.labeler.as_ref().map(|l| l.label_values.clone());
c
};
let mut router = admin_router(
pool.clone(),
writer.clone(),
auth.clone(),
admin_cfg,
strike_policy.clone(),
)
.merge(create_report_router(
pool.clone(),
auth.clone(),
crate::CreateReportConfig {
db_path: config.db_path.clone(),
..crate::CreateReportConfig::default()
},
))
.merge(subscribe_router(
pool.clone(),
writer.clone(),
crate::SubscribeConfig::default(),
))
.merge(public_router(
pool.clone(),
auth.clone(),
strike_policy.clone(),
config.service_did.clone(),
))
.merge(wellknown_router())
.merge(did_document_router(pool.clone(), config.clone()))
.merge(health_router(pool.clone(), writer.clone()));
if let Some(gateway_cfg) = crate::xrpc_gateway::XrpcGatewayConfig::from_config(&config)
.map_err(|e| CliError::Startup(format!("xrpc_gateway: {e}")))?
{
let xrpc_auth = Arc::new(crate::xrpc_gateway::XrpcAuthService::new(
gateway_cfg.clone(),
did_resolver.clone(),
));
let xrpc_replay_cache = Arc::new(crate::xrpc_gateway::XrpcReplayCache::new(
gateway_cfg.replay_cache_ttl,
));
tracing::info!(
service_did = %gateway_cfg.service_did,
clock_skew_tolerance_seconds = gateway_cfg.clock_skew_tolerance.as_secs(),
replay_cache_ttl_seconds = gateway_cfg.replay_cache_ttl.as_secs(),
"xrpc_gateway enabled: routes mounted at /xrpc/* (auth + membership + replay all wired; handler bodies pending #95-#98)"
);
let xrpc_handler_state = crate::xrpc_gateway::XrpcGatewayState {
writer: writer.clone(),
pool: pool.clone(),
service_did: gateway_cfg.service_did.clone(),
};
router = router.merge(crate::xrpc_gateway::build_router(
gateway_cfg,
xrpc_auth,
pool.clone(),
xrpc_replay_cache,
xrpc_handler_state,
));
}
let listener = TcpListener::bind(config.bind_addr)
.await
.map_err(|source| CliError::BindFailed {
addr: config.bind_addr,
source,
})?;
let local_addr = listener.local_addr().unwrap_or(config.bind_addr);
tracing::info!(
bind_addr = %local_addr,
service_did = %config.service_did,
"cairn listening; lease acquired"
);
let (drain_start_tx, drain_start_rx) = tokio::sync::oneshot::channel::<()>();
let shutdown_wrapper = async move {
shutdown.await;
let _ = drain_start_tx.send(());
};
let serve_fut = axum::serve(
listener,
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(shutdown_wrapper);
let drain_timer = async move {
match drain_start_rx.await {
Ok(()) => tokio::time::sleep(DRAIN_TIMEOUT).await,
Err(_) => std::future::pending::<()>().await,
}
};
enum Outcome {
Clean,
AxumError(std::io::Error),
DrainTimeout,
}
let outcome = tokio::select! {
res = serve_fut => match res {
Ok(()) => Outcome::Clean,
Err(e) => Outcome::AxumError(e),
},
_ = drain_timer => Outcome::DrainTimeout,
};
if let Err(e) = writer.shutdown().await {
tracing::warn!(error = %e, "writer shutdown failed during serve exit");
}
match outcome {
Outcome::Clean => Ok(()),
Outcome::AxumError(e) => Err(CliError::Startup(format!("axum serve error: {e}"))),
Outcome::DrainTimeout => {
tracing::warn!(
drain_timeout_secs = DRAIN_TIMEOUT.as_secs(),
"drain timeout exceeded after shutdown signal; forcing exit"
);
Ok(())
}
}
}
fn map_spawn_writer_error(e: Error) -> CliError {
match e {
Error::LeaseHeld {
instance_id,
age_secs,
} => CliError::LeaseConflict {
instance_id,
age_secs,
},
other => CliError::Startup(format!("writer spawn: {other}")),
}
}
fn build_pds_admin_bridge(
policy: &crate::pds_admin::PdsAdminPolicy,
) -> Result<Option<crate::pds_admin::PdsAdminBridge>, CliError> {
if !policy.enabled {
return Ok(None);
}
let backend_config = policy.backend.as_ref().ok_or_else(|| {
CliError::Startup(
"pds_admin: enabled but no backend resolved (config-resolver invariant violated)"
.into(),
)
})?;
let backend: Arc<dyn crate::pds_admin::PdsAdminBackend> = match backend_config {
crate::pds_admin::PdsAdminBackendConfig::Ozone(ozone_cfg) => {
let backend = crate::pds_admin::OzoneBackend::new(ozone_cfg)
.map_err(|e| CliError::Startup(format!("pds_admin ozone backend: {e}")))?;
tracing::info!(
pds_url = %ozone_cfg.pds_url,
"pds_admin: Ozone backend (bsky-PDS) initialized"
);
Arc::new(backend)
}
};
Ok(Some(crate::pds_admin::PdsAdminBridge {
policy: policy.clone(),
backend,
}))
}
async fn run_pds_admin_startup_probe(bridge: &crate::pds_admin::PdsAdminBridge) {
use crate::pds_admin::BackendError;
match bridge.backend.probe().await {
Ok(report) => {
tracing::info!(
backend = report.backend_name,
pds_url = %report.pds_url,
detected_version = ?report.detected_version,
capabilities = ?report.capabilities,
"pds_admin probe successful"
);
}
Err(BackendError::Network(e)) => {
tracing::warn!(
error = %e,
"pds_admin probe failed at the network layer; cairn-mod will continue starting (first real call will retry)"
);
}
Err(BackendError::RateLimited {
message,
retry_after_seconds,
}) => {
tracing::warn!(
error = %message,
retry_after_seconds = ?retry_after_seconds,
"pds_admin probe rate-limited; cairn-mod will continue starting"
);
}
Err(e) => {
tracing::error!(
error = %e,
"pds_admin probe failed: operator-actionable misconfiguration. \
cairn-mod will continue starting; first real call will retry, \
but moderation actions will fail until the underlying issue is fixed."
);
}
}
}
mod verify {
use crate::cli::error::CliError;
use crate::cli::pds::{PdsClient, PdsError};
use crate::config::Config;
use crate::service_record::{self, RECORD_COLLECTION, RECORD_RKEY};
use serde_json::Value;
pub(super) async fn verify_service_record(config: &Config) -> Result<(), CliError> {
let Some(labeler_cfg) = config.labeler.as_ref() else {
tracing::info!(
"no [labeler] config block — skipping service record verify (#8 narrow scope)"
);
return Ok(());
};
let operator_cfg = config.operator.as_ref().ok_or_else(|| {
CliError::Config(
"[labeler] is configured but [operator] is missing — verify needs operator.pds_url"
.into(),
)
})?;
let local_record = service_record::render(labeler_cfg, "1970-01-01T00:00:00.000Z")
.map_err(|e| CliError::Config(format!("could not render local service record: {e}")))?;
let local_hash = service_record::content_hash(&local_record);
let pds = PdsClient::new(&operator_cfg.pds_url).map_err(|e| {
CliError::ServiceRecordUnreachable {
pds_url: operator_cfg.pds_url.clone(),
cause: e.to_string(),
}
})?;
let fetched = match pds
.get_record(&config.service_did, RECORD_COLLECTION, RECORD_RKEY)
.await
{
Ok(Some(r)) => r,
Ok(None) => {
return Err(CliError::ServiceRecordAbsent {
pds_url: operator_cfg.pds_url.clone(),
service_did: config.service_did.clone(),
});
}
Err(PdsError::Network { source, .. }) => {
return Err(CliError::ServiceRecordUnreachable {
pds_url: operator_cfg.pds_url.clone(),
cause: source.to_string(),
});
}
Err(other) => {
return Err(CliError::ServiceRecordUnreachable {
pds_url: operator_cfg.pds_url.clone(),
cause: other.to_string(),
});
}
};
let pds_hash = service_record::content_hash_value(fetched.value.clone());
if local_hash == pds_hash {
tracing::info!(
cid = ?fetched.cid,
"service record verified: local config matches PDS"
);
return Ok(());
}
let summary = drift_summary(&local_record, &fetched.value);
tracing::error!(
pds_url = %operator_cfg.pds_url,
"service record drift detected — see error for details"
);
Err(CliError::ServiceRecordDrift {
pds_url: operator_cfg.pds_url.clone(),
service_did: config.service_did.clone(),
summary,
})
}
fn drift_summary(local: &service_record::ServiceRecord, pds: &Value) -> String {
use std::fmt::Write;
let mut out = String::new();
let local_lv = &local.policies.label_values;
let pds_lv = pds_label_values(pds);
if local_lv != &pds_lv {
let _ = writeln!(out, " - label values:");
let _ = writeln!(out, " local: {local_lv:?}");
let _ = writeln!(out, " published: {pds_lv:?}");
}
let local_defs = local.policies.label_value_definitions.len();
let pds_defs = pds_definition_count(pds);
if local_defs != pds_defs {
let _ = writeln!(out, " - label value definitions:");
let _ = writeln!(out, " local: {local_defs} entries");
let _ = writeln!(out, " published: {pds_defs} entries");
}
let local_rt = &local.reason_types;
let pds_rt = pds_string_array(pds, "reasonTypes");
if local_rt != &pds_rt {
let _ = writeln!(out, " - reason types:");
let _ = writeln!(out, " local: {local_rt:?}");
let _ = writeln!(out, " published: {pds_rt:?}");
}
let local_st = &local.subject_types;
let pds_st = pds_string_array(pds, "subjectTypes");
if local_st != &pds_st {
let _ = writeln!(out, " - subject types:");
let _ = writeln!(out, " local: {local_st:?}");
let _ = writeln!(out, " published: {pds_st:?}");
}
if out.is_empty() {
out.push_str(
" - per-label definition contents (severity / blurs / locales) differ; \
inspect the published record alongside the local config to identify which.\n",
);
}
if out.ends_with('\n') {
out.pop();
}
out
}
fn pds_label_values(v: &Value) -> Vec<String> {
v.get("policies")
.and_then(|p| p.get("labelValues"))
.and_then(|x| x.as_array())
.map(|a| {
a.iter()
.filter_map(|s| s.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
fn pds_definition_count(v: &Value) -> usize {
v.get("policies")
.and_then(|p| p.get("labelValueDefinitions"))
.and_then(|x| x.as_array())
.map(|a| a.len())
.unwrap_or(0)
}
fn pds_string_array(v: &Value, key: &str) -> Vec<String> {
v.get(key)
.and_then(|x| x.as_array())
.map(|a| {
a.iter()
.filter_map(|s| s.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{
BlursToml, LabelValueDefinitionToml, LabelerConfigToml, LocaleToml, SeverityToml,
};
fn sample_cfg() -> LabelerConfigToml {
LabelerConfigToml {
label_values: vec!["spam".into()],
label_value_definitions: vec![LabelValueDefinitionToml {
identifier: "spam".into(),
severity: SeverityToml::Alert,
blurs: BlursToml::None,
default_setting: None,
adult_only: None,
locales: vec![LocaleToml {
lang: "en".into(),
name: "Spam".into(),
description: "x".into(),
}],
}],
reason_types: vec![],
subject_types: vec!["account".into()],
subject_collections: vec![],
}
}
#[test]
fn drift_summary_label_values_differ() {
let local = service_record::render(&sample_cfg(), "1970-01-01T00:00:00.000Z").unwrap();
let pds_value = serde_json::json!({
"policies": { "labelValues": ["other"] },
});
let s = drift_summary(&local, &pds_value);
assert!(s.contains("label values"));
assert!(s.contains("\"spam\""));
assert!(s.contains("\"other\""));
assert!(!s.contains("reason types"), "no drift on reasonTypes here");
}
#[test]
fn drift_summary_definition_count_differs() {
let local = service_record::render(&sample_cfg(), "1970-01-01T00:00:00.000Z").unwrap();
let pds_value = serde_json::json!({
"policies": {
"labelValues": ["spam"],
"labelValueDefinitions": [],
},
"subjectTypes": ["account"],
});
let s = drift_summary(&local, &pds_value);
assert!(s.contains("label value definitions"));
assert!(s.contains("local: 1 entries"));
assert!(s.contains("published: 0 entries"));
}
#[test]
fn drift_summary_falls_back_when_no_top_level_field_differs() {
let local = service_record::render(&sample_cfg(), "1970-01-01T00:00:00.000Z").unwrap();
let pds_value = serde_json::json!({
"policies": {
"labelValues": ["spam"],
"labelValueDefinitions": [{
"identifier": "spam",
"severity": "inform",
"blurs": "content",
"locales": [{ "lang": "fr", "name": "Spam", "description": "y" }],
}],
},
"subjectTypes": ["account"],
});
let s = drift_summary(&local, &pds_value);
assert!(
s.contains("per-label definition contents"),
"fallback message expected; got: {s}"
);
}
}
}