Something went wrong. Try again.
Client side atproto account migrator in your web browser, along with services for backups and adversarial migrations. pdsmoover.com
pds atproto migrations moo cow
Something went wrong. Try again.
10 kB · 297 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298use anyhow::{Result, anyhow};use chrono::{DateTime, Utc};use std::time::Duration;
#[derive(Debug, Clone, serde::Deserialize)]pub struct PlcService { pub endpoint: String,}
#[derive(Debug, Clone, serde::Deserialize)]pub struct PlcServices { pub atproto_pds: Option<PlcService>,}
/// Permissive view over every PLC op shape: modern `plc_operation` carries/// `services.atproto_pds`, legacy genesis `create` carries a flat `service`,/// and `plc_tombstone` carries neither.#[derive(Debug, Clone, serde::Deserialize)]pub struct PlcOp { pub services: Option<PlcServices>, pub service: Option<String>,}
impl PlcOp { fn pds_endpoint(&self) -> Option<&str> { self.services .as_ref() .and_then(|services| services.atproto_pds.as_ref()) .map(|service| service.endpoint.as_str()) .or(self.service.as_deref()) }}
#[derive(Debug, Clone, serde::Deserialize)]pub struct AuditLogEntry { pub cid: String, #[serde(default)] pub nullified: bool, #[serde(rename = "createdAt")] pub created_at: DateTime<Utc>, pub operation: PlcOp,}
#[derive(Debug, Clone, PartialEq, Eq)]pub struct DetectedMigration { pub plc_op_cid: String, pub endpoint: String, pub migrated_at: DateTime<Utc>,}
pub struct PlcClient { http: reqwest::Client, base_url: String,}
impl PlcClient { pub fn new(base_url: String) -> Result<Self> { let http = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build()?;
Ok(Self { http, base_url: base_url.trim_end_matches('/').to_string(), }) }
pub async fn fetch_audit_log(&self, did: &str) -> Result<Vec<AuditLogEntry>> { let url = format!("{}/{}/log/audit", self.base_url, did); let response = self.http.get(&url).send().await?;
if !response.status().is_success() { return Err(anyhow!( "plc directory returned {} for {}", response.status(), did )); }
Ok(response.json::<Vec<AuditLogEntry>>().await?) }}
/// Walks the audit log newest to oldest and returns the most recent op that/// changed the DID's PDS endpoint, regardless of how long ago it happened.pub fn find_latest_endpoint_change(entries: &[AuditLogEntry]) -> Option<DetectedMigration> { let mut entries: Vec<&AuditLogEntry> = entries.iter().filter(|entry| !entry.nullified).collect(); entries.sort_by_key(|entry| entry.created_at);
for i in (1..entries.len()).rev() { let Some(endpoint) = entries[i].operation.pds_endpoint() else { continue; };
if Some(endpoint) != entries[i - 1].operation.pds_endpoint() { return Some(DetectedMigration { plc_op_cid: entries[i].cid.clone(), endpoint: endpoint.to_string(), migrated_at: entries[i].created_at, }); } }
None}
#[cfg(test)]mod tests { use super::*;
fn parse(json: &str) -> Vec<AuditLogEntry> { serde_json::from_str(json).expect("valid audit log") }
#[test] fn detects_modern_endpoint_change() { let entries = parse( r#"[ { "cid": "genesis", "createdAt": "2024-01-01T00:00:00.000Z", "operation": { "type": "plc_operation", "services": { "atproto_pds": { "type": "AtprotoPersonalDataServer", "endpoint": "https://old.example.com" } } } }, { "cid": "moved", "createdAt": "2024-06-01T00:00:00.000Z", "operation": { "type": "plc_operation", "services": { "atproto_pds": { "type": "AtprotoPersonalDataServer", "endpoint": "https://new.example.com" } } } } ]"#, );
let found = find_latest_endpoint_change(&entries).expect("change detected"); assert_eq!(found.plc_op_cid, "moved"); assert_eq!(found.endpoint, "https://new.example.com"); }
#[test] fn detects_change_from_legacy_create() { let entries = parse( r#"[ { "cid": "genesis", "createdAt": "2022-01-01T00:00:00.000Z", "operation": { "type": "create", "service": "https://bsky.social" } }, { "cid": "moved", "createdAt": "2024-06-01T00:00:00.000Z", "operation": { "type": "plc_operation", "services": { "atproto_pds": { "type": "AtprotoPersonalDataServer", "endpoint": "https://new.example.com" } } } } ]"#, );
let found = find_latest_endpoint_change(&entries).expect("change detected"); assert_eq!(found.plc_op_cid, "moved"); assert_eq!(found.endpoint, "https://new.example.com"); }
#[test] fn skips_rotation_key_only_op() { let entries = parse( r#"[ { "cid": "genesis", "createdAt": "2024-01-01T00:00:00.000Z", "operation": { "type": "plc_operation", "services": { "atproto_pds": { "type": "AtprotoPersonalDataServer", "endpoint": "https://old.example.com" } } } }, { "cid": "moved", "createdAt": "2024-06-01T00:00:00.000Z", "operation": { "type": "plc_operation", "services": { "atproto_pds": { "type": "AtprotoPersonalDataServer", "endpoint": "https://new.example.com" } } } }, { "cid": "rotated", "createdAt": "2024-07-01T00:00:00.000Z", "operation": { "type": "plc_operation", "services": { "atproto_pds": { "type": "AtprotoPersonalDataServer", "endpoint": "https://new.example.com" } } } } ]"#, );
let found = find_latest_endpoint_change(&entries).expect("change detected"); assert_eq!(found.plc_op_cid, "moved"); assert_eq!( found.migrated_at, "2024-06-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap() ); }
#[test] fn skips_trailing_tombstone() { let entries = parse( r#"[ { "cid": "genesis", "createdAt": "2024-01-01T00:00:00.000Z", "operation": { "type": "plc_operation", "services": { "atproto_pds": { "type": "AtprotoPersonalDataServer", "endpoint": "https://old.example.com" } } } }, { "cid": "moved", "createdAt": "2024-06-01T00:00:00.000Z", "operation": { "type": "plc_operation", "services": { "atproto_pds": { "type": "AtprotoPersonalDataServer", "endpoint": "https://new.example.com" } } } }, { "cid": "dead", "createdAt": "2024-07-01T00:00:00.000Z", "operation": { "type": "plc_tombstone" } } ]"#, );
let found = find_latest_endpoint_change(&entries).expect("change detected"); assert_eq!(found.plc_op_cid, "moved"); }
#[test] fn ignores_nullified_entries() { let entries = parse( r#"[ { "cid": "genesis", "createdAt": "2024-01-01T00:00:00.000Z", "operation": { "type": "plc_operation", "services": { "atproto_pds": { "type": "AtprotoPersonalDataServer", "endpoint": "https://old.example.com" } } } }, { "cid": "forked", "nullified": true, "createdAt": "2024-06-01T00:00:00.000Z", "operation": { "type": "plc_operation", "services": { "atproto_pds": { "type": "AtprotoPersonalDataServer", "endpoint": "https://wrong.example.com" } } } }, { "cid": "moved", "createdAt": "2024-06-02T00:00:00.000Z", "operation": { "type": "plc_operation", "services": { "atproto_pds": { "type": "AtprotoPersonalDataServer", "endpoint": "https://new.example.com" } } } } ]"#, );
let found = find_latest_endpoint_change(&entries).expect("change detected"); assert_eq!(found.plc_op_cid, "moved"); assert_eq!(found.endpoint, "https://new.example.com"); }
#[test] fn single_entry_log_is_not_a_migration() { let entries = parse( r#"[ { "cid": "genesis", "createdAt": "2024-01-01T00:00:00.000Z", "operation": { "type": "plc_operation", "services": { "atproto_pds": { "type": "AtprotoPersonalDataServer", "endpoint": "https://old.example.com" } } } } ]"#, );
assert!(find_latest_endpoint_change(&entries).is_none()); }}