use std::path::Path;
use serde_json::json;
use sqlx::{Pool, Sqlite};
use super::error::CliError;
use super::operator_session::OperatorSession;
use super::pds::PdsClient;
use crate::config::Config;
use crate::service_record::{RECORD_COLLECTION, RECORD_RKEY};
pub const AUDIT_ACTION_SERVICE_RECORD_UNPUBLISHED: &str = "service_record_unpublished";
pub const AUDIT_REASON_SERVICE_RECORD_UNPUBLISH: &str =
"service_record_unpublished: { cid, content_hash_hex, content_changed }";
#[derive(Debug)]
pub enum UnpublishOutcome {
NoChange,
Unpublished {
cid: String,
},
}
pub async fn unpublish(
pool: &Pool<Sqlite>,
config: &Config,
session_path: &Path,
) -> Result<UnpublishOutcome, CliError> {
let _labeler_cfg = config
.labeler
.as_ref()
.ok_or_else(|| CliError::Config("missing [labeler] section in config".into()))?;
let operator_cfg = config
.operator
.as_ref()
.ok_or_else(|| CliError::Config("missing [operator] section in config".into()))?;
let session = OperatorSession::load(session_path)
.map_err(|e| CliError::Config(format!("operator session: {e}")))?
.ok_or_else(|| {
CliError::Config(format!(
"no operator session at {}; run `cairn operator-login`",
session_path.display()
))
})?;
let prior = load_prior_state(pool).await?;
let Some(prior) = prior else {
record_noop_audit(pool, &session.operator_did).await?;
return Ok(UnpublishOutcome::NoChange);
};
let pds = PdsClient::new(&operator_cfg.pds_url)?;
pds.delete_record(
&session.access_jwt,
&session.operator_did,
RECORD_COLLECTION,
RECORD_RKEY,
Some(&prior.cid),
)
.await?;
commit_unpublish(pool, &session.operator_did, &prior).await?;
Ok(UnpublishOutcome::Unpublished { cid: prior.cid })
}
#[derive(Debug, Clone)]
struct PriorState {
cid: String,
content_hash_hex: String,
#[allow(dead_code)]
created_at: String,
}
async fn load_prior_state(pool: &Pool<Sqlite>) -> Result<Option<PriorState>, CliError> {
let cid = get_labeler_config(pool, "service_record_cid").await?;
let hash = get_labeler_config(pool, "service_record_content_hash").await?;
let created = get_labeler_config(pool, "service_record_created_at").await?;
match (cid, hash, created) {
(Some(cid), Some(content_hash_hex), Some(created_at)) => Ok(Some(PriorState {
cid,
content_hash_hex,
created_at,
})),
(None, None, None) => Ok(None),
_ => Ok(None),
}
}
async fn get_labeler_config(pool: &Pool<Sqlite>, key: &str) -> Result<Option<String>, CliError> {
sqlx::query_scalar!("SELECT value FROM labeler_config WHERE key = ?1", key)
.fetch_optional(pool)
.await
.map_err(|e| CliError::Startup(format!("read labeler_config: {e}")))
}
async fn commit_unpublish(
pool: &Pool<Sqlite>,
actor_did: &str,
prior: &PriorState,
) -> Result<(), CliError> {
let now_ms = crate::writer::epoch_ms_now();
let reason = audit_reason_json(Some(&prior.cid), Some(&prior.content_hash_hex), true);
let mut tx = pool
.begin()
.await
.map_err(|e| CliError::Startup(format!("begin tx: {e}")))?;
for key in [
"service_record_cid",
"service_record_content_hash",
"service_record_created_at",
] {
sqlx::query!("DELETE FROM labeler_config WHERE key = ?1", key)
.execute(&mut *tx)
.await
.map_err(|e| CliError::Startup(format!("delete labeler_config {key}: {e}")))?;
}
crate::audit::append::append_in_tx(
&mut tx,
&crate::audit::append::AuditRowForAppend {
created_at: now_ms,
action: AUDIT_ACTION_SERVICE_RECORD_UNPUBLISHED.into(),
actor_did: actor_did.into(),
target: None,
target_cid: Some(prior.cid.clone()),
outcome: "success".into(),
reason: Some(reason),
},
)
.await
.map_err(|e| CliError::Startup(format!("audit insert: {e}")))?;
tx.commit()
.await
.map_err(|e| CliError::Startup(format!("commit unpublish: {e}")))?;
Ok(())
}
async fn record_noop_audit(pool: &Pool<Sqlite>, actor_did: &str) -> Result<(), CliError> {
let now_ms = crate::writer::epoch_ms_now();
let reason = audit_reason_json(None, None, false);
crate::audit::append::append_via_pool(
pool,
&crate::audit::append::AuditRowForAppend {
created_at: now_ms,
action: AUDIT_ACTION_SERVICE_RECORD_UNPUBLISHED.into(),
actor_did: actor_did.into(),
target: None,
target_cid: None,
outcome: "success".into(),
reason: Some(reason),
},
)
.await
.map_err(|e| CliError::Startup(format!("audit insert: {e}")))?;
Ok(())
}
fn audit_reason_json(cid: Option<&str>, hash_hex: Option<&str>, content_changed: bool) -> String {
json!({
"cid": cid,
"content_hash_hex": hash_hex,
"content_changed": content_changed,
})
.to_string()
}