An encrypted personal cloud built on the AT Protocol.

Add purge command to delete all Opake data from PDS

Enumerates and deletes all records across all 7 app.opake.* collections.
Supports --dry-run for preview, --force to skip passphrase confirmation
and auto-remove local identity. [CL-196]

+200
+1
CHANGELOG.md
··· 12 12 - Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html)s 13 13 14 14 ### Added 15 + - Add purge command to delete all Opake data from PDS [#196](https://issues.opake.app/issues/196.html) 15 16 - Add metadata CLI command for rename, tag, and description management [#190](https://issues.opake.app/issues/190.html) 16 17 - Consolidate DNS and transport into opake-core, unify handle resolution [#185](https://issues.opake.app/issues/185.html) 17 18 - Build web login, callback, setup, and recover routes [#173](https://issues.opake.app/issues/173.html)
+9
README.md
··· 138 138 opake pair request # on the NEW device (polls for approval) 139 139 opake pair approve # on the EXISTING device (select + approve) 140 140 141 + # delete all Opake data from PDS (see what would go) 142 + opake purge --dry-run 143 + 144 + # delete everything (prompts for confirmation phrase) 145 + opake purge 146 + 147 + # skip confirmation and also remove local identity 148 + opake purge --force 149 + 141 150 # remove an account (defaults to only account if just one) 142 151 opake logout 143 152 opake logout bob.other.com
+1
crates/opake-cli/src/commands/mod.rs
··· 10 10 pub mod mkdir; 11 11 pub mod move_cmd; 12 12 pub mod pair; 13 + pub mod purge; 13 14 pub mod resolve; 14 15 pub mod revoke; 15 16 pub mod rm;
+186
crates/opake-cli/src/commands/purge.rs
··· 1 + use anyhow::{Context, Result}; 2 + use clap::Args; 3 + use opake_core::atproto; 4 + use opake_core::client::{Session, Transport, XrpcClient}; 5 + use opake_core::directories::DIRECTORY_COLLECTION; 6 + use opake_core::documents::DOCUMENT_COLLECTION; 7 + use opake_core::keyrings::KEYRING_COLLECTION; 8 + use opake_core::records::{ 9 + PAIR_REQUEST_COLLECTION, PAIR_RESPONSE_COLLECTION, PUBLIC_KEY_COLLECTION, 10 + }; 11 + use opake_core::sharing::GRANT_COLLECTION; 12 + 13 + use crate::commands::Execute; 14 + use crate::session::{self, CommandContext}; 15 + 16 + const CONFIRMATION_PHRASE: &str = "I want to delete all my Opake data"; 17 + 18 + /// All Opake collections in deletion order — dependents before parents. 19 + const COLLECTIONS: &[&str] = &[ 20 + GRANT_COLLECTION, 21 + PAIR_RESPONSE_COLLECTION, 22 + PAIR_REQUEST_COLLECTION, 23 + KEYRING_COLLECTION, 24 + DOCUMENT_COLLECTION, 25 + DIRECTORY_COLLECTION, 26 + PUBLIC_KEY_COLLECTION, 27 + ]; 28 + 29 + /// Delete all Opake data from the PDS 30 + #[derive(Args)] 31 + pub struct PurgeCommand { 32 + /// Show what would be deleted without deleting anything 33 + #[arg(long)] 34 + dry_run: bool, 35 + 36 + /// Skip confirmation prompt 37 + #[arg(long)] 38 + force: bool, 39 + } 40 + 41 + /// Paginate through a collection and collect all record keys. 42 + /// 43 + /// Unlike `list_collection`, this doesn't parse or version-check records — 44 + /// purge wants to delete everything regardless of schema version. 45 + async fn collect_rkeys( 46 + client: &mut XrpcClient<impl Transport>, 47 + collection: &str, 48 + ) -> Result<Vec<String>> { 49 + let mut rkeys = Vec::new(); 50 + let mut cursor: Option<String> = None; 51 + 52 + loop { 53 + let page = client 54 + .list_records(collection, Some(100), cursor.as_deref()) 55 + .await?; 56 + 57 + for record in &page.records { 58 + let at_uri = atproto::parse_at_uri(&record.uri)?; 59 + rkeys.push(at_uri.rkey); 60 + } 61 + 62 + match page.cursor { 63 + Some(c) => cursor = Some(c), 64 + None => break, 65 + } 66 + } 67 + 68 + Ok(rkeys) 69 + } 70 + 71 + /// Prompt the user to type the exact confirmation phrase. 72 + fn require_confirmation() -> Result<()> { 73 + println!(); 74 + println!("WARNING: This will permanently delete ALL Opake data from your PDS."); 75 + println!("All encrypted files, keys, grants, and directories will be gone."); 76 + println!("This action is irreversible."); 77 + println!(); 78 + println!("Type exactly: {CONFIRMATION_PHRASE}"); 79 + print!("> "); 80 + std::io::Write::flush(&mut std::io::stdout())?; 81 + 82 + let mut input = String::new(); 83 + std::io::stdin().read_line(&mut input)?; 84 + 85 + if input.trim() != CONFIRMATION_PHRASE { 86 + anyhow::bail!("Purge cancelled."); 87 + } 88 + 89 + Ok(()) 90 + } 91 + 92 + /// Ask whether to delete local identity and session files. 93 + fn confirm_local_cleanup(force: bool) -> Result<bool> { 94 + if force { 95 + return Ok(true); 96 + } 97 + 98 + eprint!("Delete local identity and session? [y/N] "); 99 + let mut answer = String::new(); 100 + std::io::stdin() 101 + .read_line(&mut answer) 102 + .context("failed to read confirmation")?; 103 + 104 + Ok(answer.trim().eq_ignore_ascii_case("y")) 105 + } 106 + 107 + impl Execute for PurgeCommand { 108 + async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 109 + let mut client = session::load_client(&ctx.storage, &ctx.did)?; 110 + 111 + // Enumerate all records across all collections. 112 + let mut inventory: Vec<(&str, Vec<String>)> = Vec::new(); 113 + let mut total = 0usize; 114 + 115 + for &collection in COLLECTIONS { 116 + let rkeys = collect_rkeys(&mut client, collection).await?; 117 + total += rkeys.len(); 118 + inventory.push((collection, rkeys)); 119 + } 120 + 121 + // Nothing to do. 122 + if total == 0 { 123 + println!("No Opake records found on PDS."); 124 + return Ok(session::refreshed_session(&client)); 125 + } 126 + 127 + // Print inventory (always, not just dry-run — useful context before confirmation). 128 + let max_name_len = inventory 129 + .iter() 130 + .map(|(name, _)| name.len()) 131 + .max() 132 + .unwrap_or(0); 133 + 134 + for &(collection, ref rkeys) in &inventory { 135 + let noun = if rkeys.len() == 1 { 136 + "record" 137 + } else { 138 + "records" 139 + }; 140 + println!( 141 + " {:<width$} {:>3} {noun}", 142 + collection, 143 + rkeys.len(), 144 + width = max_name_len, 145 + ); 146 + } 147 + 148 + println!(); 149 + 150 + if self.dry_run { 151 + let noun = if total == 1 { "record" } else { "records" }; 152 + println!("Total: {total} {noun} would be deleted."); 153 + return Ok(session::refreshed_session(&client)); 154 + } 155 + 156 + // Confirmation gate. 157 + if !self.force { 158 + require_confirmation()?; 159 + } 160 + 161 + // Delete everything. 162 + for (collection, rkeys) in &inventory { 163 + for rkey in rkeys { 164 + client.delete_record(collection, rkey).await?; 165 + } 166 + if !rkeys.is_empty() { 167 + println!("deleted {} {collection}", rkeys.len()); 168 + } 169 + } 170 + 171 + println!(); 172 + println!("Purged {total} records from PDS."); 173 + 174 + // Local cleanup. 175 + if confirm_local_cleanup(self.force)? { 176 + // Session will be invalid after remove_account, so grab any refresh first. 177 + let refreshed = session::refreshed_session(&client); 178 + ctx.storage.remove_account(&ctx.did)?; 179 + println!("Removed local identity and session for {}.", ctx.did); 180 + // Don't persist session — the account dir is gone. 181 + return Ok(refreshed.and(None)); 182 + } 183 + 184 + Ok(session::refreshed_session(&client)) 185 + } 186 + }
+3
crates/opake-cli/src/main.rs
··· 53 53 Revoke(commands::revoke::RevokeCommand), 54 54 Keyring(commands::keyring::KeyringCommand), 55 55 Pair(commands::pair::PairCommand), 56 + /// Delete all Opake data from the PDS 57 + Purge(commands::purge::PurgeCommand), 56 58 Tree(commands::tree::TreeCommand), 57 59 } 58 60 ··· 121 123 Command::Revoke(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 122 124 Command::Keyring(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 123 125 Command::Pair(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 126 + Command::Purge(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 124 127 Command::Tree(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 125 128 } 126 129