use std::path::Path;
use serde::Serialize;
use sqlx::{Pool, Sqlite};
use crate::config::Config;
use super::error::CliError;
use super::moderator_action::{
RecordActionInput, RecordResponse, RevokeActionInput, RevokeResponse, record, revoke,
};
use super::session::SessionFile;
pub const PDS_ADMIN_DEFAULT_REASON_CODE: &str = "pds-admin-cli";
#[derive(Debug, Clone, Serialize)]
pub struct PdsAdminTakedownOutcome {
pub record: RecordResponse,
pub bridge: Option<PdsAdminAuditView>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PdsAdminRestoreOutcome {
pub revoke: RevokeResponse,
pub bridge: Option<PdsAdminAuditView>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PdsAdminAuditView {
pub id: i64,
pub precipitating_action_id: i64,
pub backend_method: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub backend_action_id: Option<String>,
pub outcome: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_after_seconds: Option<i64>,
}
pub fn verify_pds_admin_enabled(config: &Config) -> Result<(), CliError> {
let policy = crate::pds_admin::PdsAdminPolicy::from_config(config)
.map_err(|e| CliError::Config(format!("[pds_admin]: {e}")))?;
if !policy.enabled {
return Err(CliError::Config(
"[pds_admin] bridge is disabled in this config; \
set [pds_admin].enabled = true (and configure a backend) \
before using `cairn pds-admin`"
.into(),
));
}
Ok(())
}
pub async fn find_active_suspension_action_id(
pool: &Pool<Sqlite>,
subject_did: &str,
) -> sqlx::Result<Option<i64>> {
let row = sqlx::query!(
r#"SELECT id as "id!: i64"
FROM subject_actions
WHERE subject_did = ?1
AND action_type IN ('takedown', 'temp_suspension', 'indef_suspension')
AND revoked_at IS NULL
ORDER BY id DESC
LIMIT 1"#,
subject_did,
)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| r.id))
}
pub async fn find_pds_admin_audit_for_action(
pool: &Pool<Sqlite>,
precipitating_action_id: i64,
) -> sqlx::Result<Option<PdsAdminAuditView>> {
let row = sqlx::query!(
r#"SELECT
id AS "id!: i64",
precipitating_action_id AS "precipitating_action_id!: i64",
backend_method AS "backend_method!: String",
backend_action_id,
outcome AS "outcome!: String",
error_code,
error_message,
retry_after_seconds
FROM pds_admin_audit
WHERE precipitating_action_id = ?1
ORDER BY id DESC
LIMIT 1"#,
precipitating_action_id,
)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| PdsAdminAuditView {
id: r.id,
precipitating_action_id: r.precipitating_action_id,
backend_method: r.backend_method,
backend_action_id: r.backend_action_id,
outcome: r.outcome,
error_code: r.error_code,
error_message: r.error_message,
retry_after_seconds: r.retry_after_seconds,
}))
}
#[allow(clippy::too_many_arguments)]
pub async fn takedown(
pool: &Pool<Sqlite>,
session: &mut SessionFile,
session_path: &Path,
subject_did: &str,
reason: &str,
notes: Option<String>,
cairn_server_override: Option<String>,
) -> Result<PdsAdminTakedownOutcome, CliError> {
let record_resp = record(
session,
session_path,
RecordActionInput {
subject: subject_did.to_string(),
action_type: "takedown".to_string(),
reasons: vec![reason.to_string()],
duration: None,
note: notes,
report_ids: Vec::new(),
cairn_server_override,
},
)
.await?;
let bridge = find_pds_admin_audit_for_action(pool, record_resp.action_id)
.await
.map_err(|e| CliError::Startup(format!("pds_admin_audit lookup: {e}")))?;
Ok(PdsAdminTakedownOutcome {
record: record_resp,
bridge,
})
}
#[allow(clippy::too_many_arguments)]
pub async fn suspend(
pool: &Pool<Sqlite>,
session: &mut SessionFile,
session_path: &Path,
subject_did: &str,
reason: &str,
duration: Option<String>,
notes: Option<String>,
cairn_server_override: Option<String>,
) -> Result<PdsAdminTakedownOutcome, CliError> {
let action_type = if duration.is_some() {
"temp_suspension"
} else {
"indef_suspension"
};
let record_resp = record(
session,
session_path,
RecordActionInput {
subject: subject_did.to_string(),
action_type: action_type.to_string(),
reasons: vec![reason.to_string()],
duration,
note: notes,
report_ids: Vec::new(),
cairn_server_override,
},
)
.await?;
let bridge = find_pds_admin_audit_for_action(pool, record_resp.action_id)
.await
.map_err(|e| CliError::Startup(format!("pds_admin_audit lookup: {e}")))?;
Ok(PdsAdminTakedownOutcome {
record: record_resp,
bridge,
})
}
pub async fn restore(
pool: &Pool<Sqlite>,
session: &mut SessionFile,
session_path: &Path,
subject_did: &str,
reason: Option<String>,
cairn_server_override: Option<String>,
) -> Result<PdsAdminRestoreOutcome, CliError> {
let action_id = find_active_suspension_action_id(pool, subject_did)
.await
.map_err(|e| CliError::Startup(format!("subject_actions lookup: {e}")))?
.ok_or_else(|| {
CliError::Config(format!(
"no active takedown / suspension to restore for subject {subject_did}"
))
})?;
let revoke_resp = revoke(
session,
session_path,
RevokeActionInput {
action_id,
reason,
cairn_server_override,
},
)
.await?;
let bridge = find_pds_admin_audit_for_action(pool, revoke_resp.action_id)
.await
.map_err(|e| CliError::Startup(format!("pds_admin_audit lookup: {e}")))?;
Ok(PdsAdminRestoreOutcome {
revoke: revoke_resp,
bridge,
})
}
pub fn format_takedown_human(out: &PdsAdminTakedownOutcome) -> String {
let mut s = format!(
"Recorded action {} (subject taken down)",
out.record.action_id
);
if let Some(b) = &out.bridge {
s.push_str(&format!(
"\nbridge: {} via {} (id={})",
b.outcome,
b.backend_method,
b.backend_action_id.as_deref().unwrap_or("-")
));
if let Some(err) = &b.error_message {
s.push_str(&format!("\nerror: {err}"));
}
} else {
s.push_str("\nbridge: dispatch pending — check `cairn moderator events` shortly");
}
s
}
pub fn format_takedown_json(out: &PdsAdminTakedownOutcome) -> String {
serde_json::to_string(out).expect("PdsAdminTakedownOutcome serializes")
}
pub fn format_restore_human(out: &PdsAdminRestoreOutcome) -> String {
let mut s = format!(
"Revoked action {} at {}",
out.revoke.action_id, out.revoke.revoked_at
);
if let Some(b) = &out.bridge {
s.push_str(&format!(
"\nbridge: {} via {} (id={})",
b.outcome,
b.backend_method,
b.backend_action_id.as_deref().unwrap_or("-")
));
if let Some(err) = &b.error_message {
s.push_str(&format!("\nerror: {err}"));
}
} else {
s.push_str("\nbridge: dispatch pending — check `cairn moderator events` shortly");
}
s
}
pub fn format_restore_json(out: &PdsAdminRestoreOutcome) -> String {
serde_json::to_string(out).expect("PdsAdminRestoreOutcome serializes")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pds_admin_default_reason_code_is_hyphenated() {
assert_eq!(PDS_ADMIN_DEFAULT_REASON_CODE, "pds-admin-cli");
}
#[test]
fn format_takedown_human_no_bridge_marks_pending() {
let out = PdsAdminTakedownOutcome {
record: RecordResponse {
action_id: 7,
strike_value_base: 0,
strike_value_applied: 0,
was_dampened: false,
strikes_at_time_of_action: 0,
},
bridge: None,
};
let s = format_takedown_human(&out);
assert!(s.contains("Recorded action 7"));
assert!(s.contains("dispatch pending"));
}
#[test]
fn format_takedown_human_with_bridge_includes_outcome() {
let out = PdsAdminTakedownOutcome {
record: RecordResponse {
action_id: 9,
strike_value_base: 0,
strike_value_applied: 0,
was_dampened: false,
strikes_at_time_of_action: 0,
},
bridge: Some(PdsAdminAuditView {
id: 11,
precipitating_action_id: 9,
backend_method: "takedown_account".into(),
backend_action_id: Some("backend-id-42".into()),
outcome: "success".into(),
error_code: None,
error_message: None,
retry_after_seconds: None,
}),
};
let s = format_takedown_human(&out);
assert!(s.contains("Recorded action 9"));
assert!(s.contains("bridge: success via takedown_account"));
assert!(s.contains("backend-id-42"));
}
#[test]
fn format_takedown_human_with_failed_bridge_includes_error() {
let out = PdsAdminTakedownOutcome {
record: RecordResponse {
action_id: 1,
strike_value_base: 0,
strike_value_applied: 0,
was_dampened: false,
strikes_at_time_of_action: 0,
},
bridge: Some(PdsAdminAuditView {
id: 2,
precipitating_action_id: 1,
backend_method: "takedown_account".into(),
backend_action_id: None,
outcome: "auth".into(),
error_code: Some("AuthRequired".into()),
error_message: Some("invalid app password".into()),
retry_after_seconds: None,
}),
};
let s = format_takedown_human(&out);
assert!(s.contains("bridge: auth"));
assert!(s.contains("error: invalid app password"));
}
#[test]
fn format_restore_human_with_bridge_includes_outcome() {
let out = PdsAdminRestoreOutcome {
revoke: RevokeResponse {
action_id: 3,
revoked_at: "2026-04-29T00:00:00.000Z".into(),
},
bridge: Some(PdsAdminAuditView {
id: 4,
precipitating_action_id: 3,
backend_method: "restore_account".into(),
backend_action_id: None,
outcome: "success".into(),
error_code: None,
error_message: None,
retry_after_seconds: None,
}),
};
let s = format_restore_human(&out);
assert!(s.contains("Revoked action 3"));
assert!(s.contains("bridge: success via restore_account"));
}
#[test]
fn format_takedown_json_round_trips() {
let out = PdsAdminTakedownOutcome {
record: RecordResponse {
action_id: 1,
strike_value_base: 0,
strike_value_applied: 0,
was_dampened: false,
strikes_at_time_of_action: 0,
},
bridge: None,
};
let s = format_takedown_json(&out);
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(v["record"]["actionId"].as_i64(), Some(1));
assert!(v["bridge"].is_null());
}
}