1use std::path::Path;
60
61use serde::Serialize;
62use sqlx::{Pool, Sqlite};
63
64use crate::config::Config;
65
66use super::error::CliError;
67use super::moderator_action::{
68 RecordActionInput, RecordResponse, RevokeActionInput, RevokeResponse, record, revoke,
69};
70use super::session::SessionFile;
71
72pub const PDS_ADMIN_DEFAULT_REASON_CODE: &str = "pds-admin-cli";
82
83#[derive(Debug, Clone, Serialize)]
87pub struct PdsAdminTakedownOutcome {
88 pub record: RecordResponse,
91 pub bridge: Option<PdsAdminAuditView>,
97}
98
99#[derive(Debug, Clone, Serialize)]
103pub struct PdsAdminRestoreOutcome {
104 pub revoke: RevokeResponse,
106 pub bridge: Option<PdsAdminAuditView>,
109}
110
111#[derive(Debug, Clone, Serialize)]
115pub struct PdsAdminAuditView {
116 pub id: i64,
118 pub precipitating_action_id: i64,
120 pub backend_method: String,
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub backend_action_id: Option<String>,
125 pub outcome: String,
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub error_code: Option<String>,
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub error_message: Option<String>,
133 #[serde(skip_serializing_if = "Option::is_none")]
135 pub retry_after_seconds: Option<i64>,
136}
137
138pub fn verify_pds_admin_enabled(config: &Config) -> Result<(), CliError> {
148 let policy = crate::pds_admin::PdsAdminPolicy::from_config(config)
149 .map_err(|e| CliError::Config(format!("[pds_admin]: {e}")))?;
150 if !policy.enabled {
151 return Err(CliError::Config(
152 "[pds_admin] bridge is disabled in this config; \
153 set [pds_admin].enabled = true (and configure a backend) \
154 before using `cairn pds-admin`"
155 .into(),
156 ));
157 }
158 Ok(())
159}
160
161pub async fn find_active_suspension_action_id(
173 pool: &Pool<Sqlite>,
174 subject_did: &str,
175) -> sqlx::Result<Option<i64>> {
176 let row = sqlx::query!(
177 r#"SELECT id as "id!: i64"
178 FROM subject_actions
179 WHERE subject_did = ?1
180 AND action_type IN ('takedown', 'temp_suspension', 'indef_suspension')
181 AND revoked_at IS NULL
182 ORDER BY id DESC
183 LIMIT 1"#,
184 subject_did,
185 )
186 .fetch_optional(pool)
187 .await?;
188 Ok(row.map(|r| r.id))
189}
190
191pub async fn find_pds_admin_audit_for_action(
198 pool: &Pool<Sqlite>,
199 precipitating_action_id: i64,
200) -> sqlx::Result<Option<PdsAdminAuditView>> {
201 let row = sqlx::query!(
202 r#"SELECT
203 id AS "id!: i64",
204 precipitating_action_id AS "precipitating_action_id!: i64",
205 backend_method AS "backend_method!: String",
206 backend_action_id,
207 outcome AS "outcome!: String",
208 error_code,
209 error_message,
210 retry_after_seconds
211 FROM pds_admin_audit
212 WHERE precipitating_action_id = ?1
213 ORDER BY id DESC
214 LIMIT 1"#,
215 precipitating_action_id,
216 )
217 .fetch_optional(pool)
218 .await?;
219 Ok(row.map(|r| PdsAdminAuditView {
220 id: r.id,
221 precipitating_action_id: r.precipitating_action_id,
222 backend_method: r.backend_method,
223 backend_action_id: r.backend_action_id,
224 outcome: r.outcome,
225 error_code: r.error_code,
226 error_message: r.error_message,
227 retry_after_seconds: r.retry_after_seconds,
228 }))
229}
230
231#[allow(clippy::too_many_arguments)]
238pub async fn takedown(
239 pool: &Pool<Sqlite>,
240 session: &mut SessionFile,
241 session_path: &Path,
242 subject_did: &str,
243 reason: &str,
244 notes: Option<String>,
245 cairn_server_override: Option<String>,
246) -> Result<PdsAdminTakedownOutcome, CliError> {
247 let record_resp = record(
248 session,
249 session_path,
250 RecordActionInput {
251 subject: subject_did.to_string(),
252 action_type: "takedown".to_string(),
253 reasons: vec![reason.to_string()],
254 duration: None,
255 note: notes,
256 report_ids: Vec::new(),
257 cairn_server_override,
258 },
259 )
260 .await?;
261
262 let bridge = find_pds_admin_audit_for_action(pool, record_resp.action_id)
268 .await
269 .map_err(|e| CliError::Startup(format!("pds_admin_audit lookup: {e}")))?;
270
271 Ok(PdsAdminTakedownOutcome {
272 record: record_resp,
273 bridge,
274 })
275}
276
277#[allow(clippy::too_many_arguments)]
281pub async fn suspend(
282 pool: &Pool<Sqlite>,
283 session: &mut SessionFile,
284 session_path: &Path,
285 subject_did: &str,
286 reason: &str,
287 duration: Option<String>,
288 notes: Option<String>,
289 cairn_server_override: Option<String>,
290) -> Result<PdsAdminTakedownOutcome, CliError> {
291 let action_type = if duration.is_some() {
292 "temp_suspension"
293 } else {
294 "indef_suspension"
295 };
296 let record_resp = record(
297 session,
298 session_path,
299 RecordActionInput {
300 subject: subject_did.to_string(),
301 action_type: action_type.to_string(),
302 reasons: vec![reason.to_string()],
303 duration,
304 note: notes,
305 report_ids: Vec::new(),
306 cairn_server_override,
307 },
308 )
309 .await?;
310
311 let bridge = find_pds_admin_audit_for_action(pool, record_resp.action_id)
312 .await
313 .map_err(|e| CliError::Startup(format!("pds_admin_audit lookup: {e}")))?;
314
315 Ok(PdsAdminTakedownOutcome {
316 record: record_resp,
317 bridge,
318 })
319}
320
321pub async fn restore(
325 pool: &Pool<Sqlite>,
326 session: &mut SessionFile,
327 session_path: &Path,
328 subject_did: &str,
329 reason: Option<String>,
330 cairn_server_override: Option<String>,
331) -> Result<PdsAdminRestoreOutcome, CliError> {
332 let action_id = find_active_suspension_action_id(pool, subject_did)
333 .await
334 .map_err(|e| CliError::Startup(format!("subject_actions lookup: {e}")))?
335 .ok_or_else(|| {
336 CliError::Config(format!(
337 "no active takedown / suspension to restore for subject {subject_did}"
338 ))
339 })?;
340
341 let revoke_resp = revoke(
342 session,
343 session_path,
344 RevokeActionInput {
345 action_id,
346 reason,
347 cairn_server_override,
348 },
349 )
350 .await?;
351
352 let bridge = find_pds_admin_audit_for_action(pool, revoke_resp.action_id)
353 .await
354 .map_err(|e| CliError::Startup(format!("pds_admin_audit lookup: {e}")))?;
355
356 Ok(PdsAdminRestoreOutcome {
357 revoke: revoke_resp,
358 bridge,
359 })
360}
361
362pub fn format_takedown_human(out: &PdsAdminTakedownOutcome) -> String {
368 let mut s = format!(
369 "Recorded action {} (subject taken down)",
370 out.record.action_id
371 );
372 if let Some(b) = &out.bridge {
373 s.push_str(&format!(
374 "\nbridge: {} via {} (id={})",
375 b.outcome,
376 b.backend_method,
377 b.backend_action_id.as_deref().unwrap_or("-")
378 ));
379 if let Some(err) = &b.error_message {
380 s.push_str(&format!("\nerror: {err}"));
381 }
382 } else {
383 s.push_str("\nbridge: dispatch pending — check `cairn moderator events` shortly");
384 }
385 s
386}
387
388pub fn format_takedown_json(out: &PdsAdminTakedownOutcome) -> String {
390 serde_json::to_string(out).expect("PdsAdminTakedownOutcome serializes")
391}
392
393pub fn format_restore_human(out: &PdsAdminRestoreOutcome) -> String {
395 let mut s = format!(
396 "Revoked action {} at {}",
397 out.revoke.action_id, out.revoke.revoked_at
398 );
399 if let Some(b) = &out.bridge {
400 s.push_str(&format!(
401 "\nbridge: {} via {} (id={})",
402 b.outcome,
403 b.backend_method,
404 b.backend_action_id.as_deref().unwrap_or("-")
405 ));
406 if let Some(err) = &b.error_message {
407 s.push_str(&format!("\nerror: {err}"));
408 }
409 } else {
410 s.push_str("\nbridge: dispatch pending — check `cairn moderator events` shortly");
411 }
412 s
413}
414
415pub fn format_restore_json(out: &PdsAdminRestoreOutcome) -> String {
417 serde_json::to_string(out).expect("PdsAdminRestoreOutcome serializes")
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn pds_admin_default_reason_code_is_hyphenated() {
426 assert_eq!(PDS_ADMIN_DEFAULT_REASON_CODE, "pds-admin-cli");
431 }
432
433 #[test]
434 fn format_takedown_human_no_bridge_marks_pending() {
435 let out = PdsAdminTakedownOutcome {
436 record: RecordResponse {
437 action_id: 7,
438 strike_value_base: 0,
439 strike_value_applied: 0,
440 was_dampened: false,
441 strikes_at_time_of_action: 0,
442 },
443 bridge: None,
444 };
445 let s = format_takedown_human(&out);
446 assert!(s.contains("Recorded action 7"));
447 assert!(s.contains("dispatch pending"));
448 }
449
450 #[test]
451 fn format_takedown_human_with_bridge_includes_outcome() {
452 let out = PdsAdminTakedownOutcome {
453 record: RecordResponse {
454 action_id: 9,
455 strike_value_base: 0,
456 strike_value_applied: 0,
457 was_dampened: false,
458 strikes_at_time_of_action: 0,
459 },
460 bridge: Some(PdsAdminAuditView {
461 id: 11,
462 precipitating_action_id: 9,
463 backend_method: "takedown_account".into(),
464 backend_action_id: Some("backend-id-42".into()),
465 outcome: "success".into(),
466 error_code: None,
467 error_message: None,
468 retry_after_seconds: None,
469 }),
470 };
471 let s = format_takedown_human(&out);
472 assert!(s.contains("Recorded action 9"));
473 assert!(s.contains("bridge: success via takedown_account"));
474 assert!(s.contains("backend-id-42"));
475 }
476
477 #[test]
478 fn format_takedown_human_with_failed_bridge_includes_error() {
479 let out = PdsAdminTakedownOutcome {
480 record: RecordResponse {
481 action_id: 1,
482 strike_value_base: 0,
483 strike_value_applied: 0,
484 was_dampened: false,
485 strikes_at_time_of_action: 0,
486 },
487 bridge: Some(PdsAdminAuditView {
488 id: 2,
489 precipitating_action_id: 1,
490 backend_method: "takedown_account".into(),
491 backend_action_id: None,
492 outcome: "auth".into(),
493 error_code: Some("AuthRequired".into()),
494 error_message: Some("invalid app password".into()),
495 retry_after_seconds: None,
496 }),
497 };
498 let s = format_takedown_human(&out);
499 assert!(s.contains("bridge: auth"));
500 assert!(s.contains("error: invalid app password"));
501 }
502
503 #[test]
504 fn format_restore_human_with_bridge_includes_outcome() {
505 let out = PdsAdminRestoreOutcome {
506 revoke: RevokeResponse {
507 action_id: 3,
508 revoked_at: "2026-04-29T00:00:00.000Z".into(),
509 },
510 bridge: Some(PdsAdminAuditView {
511 id: 4,
512 precipitating_action_id: 3,
513 backend_method: "restore_account".into(),
514 backend_action_id: None,
515 outcome: "success".into(),
516 error_code: None,
517 error_message: None,
518 retry_after_seconds: None,
519 }),
520 };
521 let s = format_restore_human(&out);
522 assert!(s.contains("Revoked action 3"));
523 assert!(s.contains("bridge: success via restore_account"));
524 }
525
526 #[test]
527 fn format_takedown_json_round_trips() {
528 let out = PdsAdminTakedownOutcome {
529 record: RecordResponse {
530 action_id: 1,
531 strike_value_base: 0,
532 strike_value_applied: 0,
533 was_dampened: false,
534 strikes_at_time_of_action: 0,
535 },
536 bridge: None,
537 };
538 let s = format_takedown_json(&out);
539 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
540 assert_eq!(v["record"]["actionId"].as_i64(), Some(1));
541 assert!(v["bridge"].is_null());
542 }
543}