Our Personal Data Server from scratch!
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(auth): verification-gate override, inbound-migration bypass, store deleter improvement

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
committer
Tangled
date (May 23, 2026, 11:02 PM +0300) commit 4d2c7d47 parent f24a9f8b change-id yxwopztp
+460 -86
-14
.sqlx/query-23201d4e26bc650939e30f69fb0bca00d351d057098afebc1017f70a84b4bd22.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE users SET deactivated_at = NULL WHERE did = $1", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text" 9 - ] 10 - }, 11 - "nullable": [] 12 - }, 13 - "hash": "23201d4e26bc650939e30f69fb0bca00d351d057098afebc1017f70a84b4bd22" 14 - }
+14
.sqlx/query-51e029e572777e6a103fd7fd5550494de9d4cac7e3ff84e27ddec1a6aaefc047.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET deactivated_at = NULL, inbound_migration = FALSE WHERE did = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "51e029e572777e6a103fd7fd5550494de9d4cac7e3ff84e27ddec1a6aaefc047" 14 + }
+8 -2
.sqlx/query-584bceda60d8b6a02e7dc44d833e3fba13151f36ba9f75c64e33d6cb534cc939.json .sqlx/query-18bbda5582db1b32d02ab8a3eee970c9508b9bd67239c2f936639a9f863b30ff.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin\n FROM users WHERE handle = $1", 3 + "query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin, inbound_migration\n FROM users WHERE handle = $1", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 42 42 "ordinal": 7, 43 43 "name": "is_admin", 44 44 "type_info": "Bool" 45 + }, 46 + { 47 + "ordinal": 8, 48 + "name": "inbound_migration", 49 + "type_info": "Bool" 45 50 } 46 51 ], 47 52 "parameters": { ··· 57 62 false, 58 63 true, 59 64 true, 65 + false, 60 66 false 61 67 ] 62 68 }, 63 - "hash": "584bceda60d8b6a02e7dc44d833e3fba13151f36ba9f75c64e33d6cb534cc939" 69 + "hash": "18bbda5582db1b32d02ab8a3eee970c9508b9bd67239c2f936639a9f863b30ff" 64 70 }
+8 -2
.sqlx/query-6b51995c40519a63f85c70f29ca8bd6ec1963c8562d78215d980785dc46a6384.json .sqlx/query-f1c4ec28b02d09ffce35aa8249c1747a70c12a3ecfc1ff6ca1847840f770db2f.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin\n FROM users WHERE did = $1", 3 + "query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin, inbound_migration\n FROM users WHERE did = $1", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 42 42 "ordinal": 7, 43 43 "name": "is_admin", 44 44 "type_info": "Bool" 45 + }, 46 + { 47 + "ordinal": 8, 48 + "name": "inbound_migration", 49 + "type_info": "Bool" 45 50 } 46 51 ], 47 52 "parameters": { ··· 57 62 false, 58 63 true, 59 64 true, 65 + false, 60 66 false 61 67 ] 62 68 }, 63 - "hash": "6b51995c40519a63f85c70f29ca8bd6ec1963c8562d78215d980785dc46a6384" 69 + "hash": "f1c4ec28b02d09ffce35aa8249c1747a70c12a3ecfc1ff6ca1847840f770db2f" 64 70 }
+1
crates/tranquil-api/src/identity/account.rs
··· 510 510 telegram_username: comms.telegram, 511 511 signal_username: comms.signal, 512 512 deactivated_at, 513 + inbound_migration: is_migration || is_did_web_byod, 513 514 encrypted_key_bytes: repo.encrypted_key_bytes, 514 515 encryption_version: tranquil_pds::config::ENCRYPTION_VERSION, 515 516 reserved_key_id,
+1 -1
crates/tranquil-api/src/repo/import.rs
··· 106 106 .map(|c| c.import.skip_verification) 107 107 .unwrap_or(false) 108 108 }); 109 - let is_migration = user.deactivated_at.is_some(); 109 + let is_migration = user.inbound_migration && user.deactivated_at.is_some(); 110 110 if skip_verification { 111 111 warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)"); 112 112 } else if is_migration {
+1
crates/tranquil-api/src/server/mod.rs
··· 51 51 auto_resend_verification, confirm_signup, create_session, delete_session, 52 52 get_legacy_login_preference, get_session, list_sessions, refresh_session, resend_verification, 53 53 revoke_all_sessions, revoke_session, update_legacy_login_preference, update_locale, 54 + verification_blocks_login, 54 55 }; 55 56 pub use signing_key::reserve_signing_key; 56 57 pub use totp::{
+9 -3
crates/tranquil-api/src/server/session.rs
··· 8 8 use serde::{Deserialize, Serialize}; 9 9 use serde_json::json; 10 10 use tracing::{error, info, warn}; 11 - use tranquil_db_traits::{SessionId, TokenFamilyId}; 11 + use tranquil_db_traits::{ChannelVerificationStatus, SessionId, TokenFamilyId}; 12 12 use tranquil_pds::api::error::{ApiError, DbResultExt}; 13 13 use tranquil_pds::api::{EmptyResponse, PreferredLocaleOutput, SuccessResponse}; 14 14 use tranquil_pds::auth::{ ··· 19 19 use tranquil_pds::state::AppState; 20 20 use tranquil_pds::types::{AccountState, Did, Handle, PlainPassword}; 21 21 use tranquil_types::TokenId; 22 + 23 + pub fn verification_blocks_login(channel_verification: &ChannelVerificationStatus) -> bool { 24 + !tranquil_config::get() 25 + .server 26 + .disable_account_verification_gate 27 + && !channel_verification.has_any_verified() 28 + } 22 29 23 30 #[derive(Deserialize)] 24 31 #[serde(rename_all = "camelCase")] ··· 129 136 warn!("Login attempt for takendown account: {}", row.did); 130 137 return Err(ApiError::AccountTakedown); 131 138 } 132 - let is_verified = row.channel_verification.has_any_verified(); 133 139 let is_delegated = state 134 140 .repos 135 141 .delegation 136 142 .is_delegated_account(&row.did) 137 143 .await 138 144 .unwrap_or(false); 139 - if !is_verified && !is_delegated { 145 + if verification_blocks_login(&row.channel_verification) && !is_delegated { 140 146 warn!("Login attempt for unverified account: {}", row.did); 141 147 let resend_info = auto_resend_verification(&state, &row.did).await; 142 148 let handle = resend_info
+5
crates/tranquil-config/src/lib.rs
··· 456 456 #[config(env = "DISABLE_RATE_LIMITING", default = false)] 457 457 pub disable_rate_limiting: bool, 458 458 459 + /// Skip the verified-comms-channel gate for login and record writes. 460 + /// Please keep this off unless you're an invite-only PDS! 461 + #[config(env = "DISABLE_ACCOUNT_VERIFICATION_GATE", default = false)] 462 + pub disable_account_verification_gate: bool, 463 + 459 464 /// List of additional banned words for handle validation. 460 465 #[config(env = "PDS_BANNED_WORDS", parse_env = split_comma_list)] 461 466 pub banned_words: Option<Vec<String>>,
+2
crates/tranquil-db-traits/src/user.rs
··· 44 44 pub deactivated_at: Option<DateTime<Utc>>, 45 45 pub takedown_ref: Option<String>, 46 46 pub is_admin: bool, 47 + pub inbound_migration: bool, 47 48 } 48 49 49 50 #[derive(Debug, Clone)] ··· 995 996 pub telegram_username: Option<String>, 996 997 pub signal_username: Option<String>, 997 998 pub deactivated_at: Option<DateTime<Utc>>, 999 + pub inbound_migration: bool, 998 1000 pub encrypted_key_bytes: Vec<u8>, 999 1001 pub encryption_version: i32, 1000 1002 pub reserved_key_id: Option<Uuid>,
+8 -5
crates/tranquil-db/src/postgres/user.rs
··· 47 47 impl UserRepository for PostgresUserRepository { 48 48 async fn get_by_did(&self, did: &Did) -> Result<Option<UserRow>, DbError> { 49 49 let row = sqlx::query!( 50 - r#"SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin 50 + r#"SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin, inbound_migration 51 51 FROM users WHERE did = $1"#, 52 52 did.as_str() 53 53 ) ··· 64 64 deactivated_at: r.deactivated_at, 65 65 takedown_ref: r.takedown_ref, 66 66 is_admin: r.is_admin, 67 + inbound_migration: r.inbound_migration, 67 68 })) 68 69 } 69 70 70 71 async fn get_by_handle(&self, handle: &Handle) -> Result<Option<UserRow>, DbError> { 71 72 let row = sqlx::query!( 72 - r#"SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin 73 + r#"SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin, inbound_migration 73 74 FROM users WHERE handle = $1"#, 74 75 handle.as_str() 75 76 ) ··· 86 87 deactivated_at: r.deactivated_at, 87 88 takedown_ref: r.takedown_ref, 88 89 is_admin: r.is_admin, 90 + inbound_migration: r.inbound_migration, 89 91 })) 90 92 } 91 93 ··· 1863 1865 1864 1866 async fn activate_account(&self, did: &Did) -> Result<bool, DbError> { 1865 1867 let result = sqlx::query!( 1866 - "UPDATE users SET deactivated_at = NULL WHERE did = $1", 1868 + "UPDATE users SET deactivated_at = NULL, inbound_migration = FALSE WHERE did = $1", 1867 1869 did.as_str() 1868 1870 ) 1869 1871 .execute(&self.pool) ··· 2426 2428 handle, email, did, password_hash, 2427 2429 preferred_comms_channel, 2428 2430 discord_username, telegram_username, signal_username, 2429 - is_admin, deactivated_at, email_verified 2430 - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, FALSE) RETURNING id"#, 2431 + is_admin, deactivated_at, inbound_migration, email_verified 2432 + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, FALSE) RETURNING id"#, 2431 2433 ) 2432 2434 .bind(input.handle.as_str()) 2433 2435 .bind(&input.email) ··· 2439 2441 .bind(&input.signal_username) 2440 2442 .bind(is_first_user) 2441 2443 .bind(input.deactivated_at) 2444 + .bind(input.inbound_migration) 2442 2445 .fetch_one(&mut *tx) 2443 2446 .await; 2444 2447
+2 -4
crates/tranquil-oauth-server/src/endpoints/authorize/login.rs
··· 488 488 if !password_valid { 489 489 return show_login_error("Invalid identifier or password.", json_response); 490 490 } 491 - let is_verified = user.channel_verification.has_any_verified(); 492 - if !is_verified { 491 + if tranquil_api::server::verification_blocks_login(&user.channel_verification) { 493 492 let resend_info = tranquil_api::server::auto_resend_verification(&state, &user.did).await; 494 493 let handle = resend_info 495 494 .as_ref() ··· 854 853 ); 855 854 } 856 855 }; 857 - let is_verified = user.channel_verification.has_any_verified(); 858 - if !is_verified { 856 + if tranquil_api::server::verification_blocks_login(&user.channel_verification) { 859 857 let resend_info = tranquil_api::server::auto_resend_verification(&state, &did).await; 860 858 return ( 861 859 StatusCode::FORBIDDEN,
+1 -3
crates/tranquil-oauth-server/src/endpoints/authorize/passkey.rs
··· 289 289 .into_response(); 290 290 } 291 291 292 - let is_verified = user.channel_verification.has_any_verified(); 293 - 294 - if !is_verified { 292 + if tranquil_api::server::verification_blocks_login(&user.channel_verification) { 295 293 let resend_info = tranquil_api::server::auto_resend_verification(&state, &user.did).await; 296 294 return ( 297 295 StatusCode::FORBIDDEN,
+5 -3
crates/tranquil-oauth-server/src/endpoints/authorize/registration.rs
··· 160 160 .into_response(); 161 161 } 162 162 163 - let is_verified = match state.repos.user.get_session_info_by_did(&did).await { 164 - Ok(Some(info)) => info.channel_verification.has_any_verified(), 163 + let login_blocked = match state.repos.user.get_session_info_by_did(&did).await { 164 + Ok(Some(info)) => { 165 + tranquil_api::server::verification_blocks_login(&info.channel_verification) 166 + } 165 167 Ok(None) => { 166 168 return ( 167 169 StatusCode::FORBIDDEN, ··· 189 191 } 190 192 }; 191 193 192 - if !is_verified { 194 + if login_blocked { 193 195 let resend_info = tranquil_api::server::auto_resend_verification(&state, &did).await; 194 196 return ( 195 197 StatusCode::FORBIDDEN,
+5 -3
crates/tranquil-oauth-server/src/sso_endpoints.rs
··· 402 402 } 403 403 }; 404 404 405 - let is_verified = match state 405 + let login_blocked = match state 406 406 .repos 407 407 .user 408 408 .get_session_info_by_did(&identity.did) 409 409 .await 410 410 { 411 - Ok(Some(info)) => info.channel_verification.has_any_verified(), 411 + Ok(Some(info)) => { 412 + tranquil_api::server::verification_blocks_login(&info.channel_verification) 413 + } 412 414 Ok(None) => { 413 415 tracing::error!("User not found for SSO login: {}", identity.did); 414 416 return redirect_to_error("Account not found"); ··· 419 421 } 420 422 }; 421 423 422 - if !is_verified { 424 + if login_blocked { 423 425 tracing::warn!( 424 426 did = %identity.did, 425 427 provider = %provider.as_str(),
+7
crates/tranquil-pds/src/auth/account_verified.rs
··· 21 21 state: &AppState, 22 22 user: &'a AuthenticatedUser, 23 23 ) -> Result<AccountVerified<'a>, ApiError> { 24 + if tranquil_config::get() 25 + .server 26 + .disable_account_verification_gate 27 + { 28 + return Ok(AccountVerified { user }); 29 + } 30 + 24 31 let is_verified = state 25 32 .repos 26 33 .user
+11
crates/tranquil-pds/src/state.rs
··· 610 610 } 611 611 } 612 612 613 + if std::env::var("TRANQUIL_PURGE_ORPHAN_REPOS").is_ok_and(|v| v == "1") { 614 + match metastore 615 + .repo_ops() 616 + .purge_orphan_repos(metastore.database()) 617 + { 618 + Ok(0) => tracing::info!("orphan repo purge: no orphans found"), 619 + Ok(n) => tracing::info!(purged = n, "orphan repo purge: removed orphan repo_meta"), 620 + Err(e) => tracing::error!(error = %e, "orphan repo purge failed"), 621 + } 622 + } 623 + 613 624 let notifier = bridge.notifier(); 614 625 let signal_db = metastore.database().clone(); 615 626 let signal_ks = metastore.signal_keyspace();
+32
crates/tranquil-pds/tests/store_parity.rs
··· 150 150 telegram_username: None, 151 151 signal_username: None, 152 152 deactivated_at: None, 153 + inbound_migration: false, 153 154 encrypted_key_bytes: vec![0u8; 32], 154 155 encryption_version: 0, 155 156 reserved_key_id: None, ··· 1463 1464 let store_colls = f.store.repo.list_collections(store_uid).await.unwrap(); 1464 1465 assert_eq!(pg_colls.len(), 0); 1465 1466 assert_eq!(store_colls.len(), 0); 1467 + } 1468 + 1469 + #[tokio::test] 1470 + async fn parity_account_deletion_clears_records_on_reregister() { 1471 + let f = ParityFixture::new().await; 1472 + let did = test_did("cuttle"); 1473 + let handle = test_handle("cuttle"); 1474 + let collection = test_nsid("post"); 1475 + 1476 + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; 1477 + 1478 + let records: Vec<(Rkey, CidLink)> = (0u8..3) 1479 + .map(|i| (test_rkey(&format!("3l{:02}aaaaaaaaa", i)), test_cid(i + 1))) 1480 + .collect(); 1481 + seed_records(&f.pg, pg_uid, &collection, &records).await; 1482 + seed_records(&f.store, store_uid, &collection, &records).await; 1483 + 1484 + f.pg.user 1485 + .delete_account_complete(pg_uid, &did) 1486 + .await 1487 + .unwrap(); 1488 + f.store 1489 + .user 1490 + .delete_account_complete(store_uid, &did) 1491 + .await 1492 + .unwrap(); 1493 + 1494 + let (pg_uid2, store_uid2) = seed_repos(&f, &did, &handle).await; 1495 + 1496 + assert_eq!(f.pg.repo.count_records(pg_uid2).await.unwrap(), 0); 1497 + assert_eq!(f.store.repo.count_records(store_uid2).await.unwrap(), 0); 1466 1498 } 1467 1499 1468 1500 #[tokio::test]
+8 -6
crates/tranquil-store/src/gauntlet/runner.rs
··· 1216 1216 Op::ExternalDeleteDataFile { choice } => { 1217 1217 let s = harness.store.clone(); 1218 1218 let pick = choice.0; 1219 - let lost_cids = tokio::task::spawn_blocking(move || externally_delete_data_file(&s, pick)) 1220 - .await 1221 - .map_err(|e| OpError::Join(e.to_string()))??; 1219 + let lost_cids = 1220 + tokio::task::spawn_blocking(move || externally_delete_data_file(&s, pick)) 1221 + .await 1222 + .map_err(|e| OpError::Join(e.to_string()))??; 1222 1223 if !lost_cids.is_empty() { 1223 1224 oracle.mark_blocks_lost(lost_cids); 1224 1225 } ··· 1561 1562 let mut guard = shared.write.lock().await; 1562 1563 let s = shared.store.clone(); 1563 1564 let pick = choice.0; 1564 - let lost_cids = tokio::task::spawn_blocking(move || externally_delete_data_file(&s, pick)) 1565 - .await 1566 - .map_err(|e| OpError::Join(e.to_string()))??; 1565 + let lost_cids = 1566 + tokio::task::spawn_blocking(move || externally_delete_data_file(&s, pick)) 1567 + .await 1568 + .map_err(|e| OpError::Join(e.to_string()))??; 1567 1569 if !lost_cids.is_empty() { 1568 1570 guard.oracle.mark_blocks_lost(lost_cids); 1569 1571 }
+22 -8
crates/tranquil-store/src/metastore/handler.rs
··· 5026 5026 } 5027 5027 } 5028 5028 5029 + fn purge_repo_side_data<S: StorageIO + 'static>( 5030 + state: &HandlerState<S>, 5031 + user_id: Uuid, 5032 + did: &Did, 5033 + ) -> Result<(), MetastoreError> { 5034 + let _ = state.metastore.blob_ops().delete_blobs_by_user(user_id)?; 5035 + let mut batch = state.metastore.database().batch(); 5036 + state 5037 + .metastore 5038 + .backlink_ops() 5039 + .remove_backlinks_by_repo(&mut batch, UserHash::from_did(did.as_str()))?; 5040 + batch.commit().map_err(MetastoreError::Fjall) 5041 + } 5042 + 5029 5043 fn dispatch_user<S: StorageIO + 'static>(state: &HandlerState<S>, req: UserRequest) { 5030 5044 let user = state.metastore.user_ops(); 5031 5045 match req { ··· 5698 5712 let _ = tx.send(user.get_user_key_by_did(&did).map_err(metastore_to_db)); 5699 5713 } 5700 5714 UserRequest::DeleteAccountComplete { user_id, did, tx } => { 5701 - let _ = tx.send( 5702 - user.delete_account_complete(user_id, &did) 5703 - .map_err(metastore_to_db), 5704 - ); 5715 + let result = purge_repo_side_data(state, user_id, &did) 5716 + .and_then(|()| user.delete_account_complete(user_id, &did)) 5717 + .map_err(metastore_to_db); 5718 + let _ = tx.send(result); 5705 5719 } 5706 5720 UserRequest::SetUserTakedown { 5707 5721 did, ··· 5714 5728 ); 5715 5729 } 5716 5730 UserRequest::AdminDeleteAccountComplete { user_id, did, tx } => { 5717 - let _ = tx.send( 5718 - user.admin_delete_account_complete(user_id, &did) 5719 - .map_err(metastore_to_db), 5720 - ); 5731 + let result = purge_repo_side_data(state, user_id, &did) 5732 + .and_then(|()| user.admin_delete_account_complete(user_id, &did)) 5733 + .map_err(metastore_to_db); 5734 + let _ = tx.send(result); 5721 5735 } 5722 5736 UserRequest::GetUserForDidDoc { did, tx } => { 5723 5737 let _ = tx.send(user.get_user_for_did_doc(&did).map_err(metastore_to_db));
+12
crates/tranquil-store/src/metastore/repo_meta.rs
··· 70 70 .build() 71 71 } 72 72 73 + pub fn stage_repo_meta_removal( 74 + batch: &mut fjall::OwnedWriteBatch, 75 + repo_data: &fjall::Keyspace, 76 + user_hash: UserHash, 77 + handle: &str, 78 + ) { 79 + batch.remove(repo_data, repo_meta_key(user_hash).as_slice()); 80 + if !handle.is_empty() { 81 + batch.remove(repo_data, handle_key(handle).as_slice()); 82 + } 83 + } 84 + 73 85 #[cfg(test)] 74 86 mod tests { 75 87 use super::*;
+181 -9
crates/tranquil-store/src/metastore/repo_ops.rs
··· 6 6 use super::MetastoreError; 7 7 use super::encoding::KeyReader; 8 8 use super::keys::{KeyTag, UserHash}; 9 - use super::repo_meta::{RepoMetaValue, RepoStatus, handle_key, repo_meta_key, repo_meta_prefix}; 10 - use super::scan::{count_prefix, point_lookup}; 9 + use super::records::record_user_prefix; 10 + use super::repo_meta::{ 11 + RepoMetaValue, RepoStatus, handle_key, repo_meta_key, repo_meta_prefix, stage_repo_meta_removal, 12 + }; 13 + use super::scan::{count_prefix, delete_all_by_prefix, point_lookup}; 14 + use super::user_blocks::user_block_user_prefix; 11 15 use super::user_hash::UserHashMap; 12 16 13 17 use tranquil_types::{CidLink, Did, Handle}; ··· 241 245 let meta = self.get_meta_value(key.as_slice())?; 242 246 243 247 let mut batch = db.batch(); 244 - batch.remove(&self.repo_data, key.as_slice()); 245 - 246 - match meta.handle.is_empty() { 247 - true => {} 248 - false => batch.remove(&self.repo_data, handle_key(&meta.handle).as_slice()), 249 - } 250 - 248 + stage_repo_meta_removal(&mut batch, &self.repo_data, user_hash, &meta.handle); 251 249 self.user_hashes.stage_remove(&mut batch, &user_id); 252 250 253 251 match batch.commit() { ··· 259 257 } 260 258 } 261 259 260 + pub fn purge_orphan_repos(&self, db: &fjall::Database) -> Result<usize, MetastoreError> { 261 + let prefix = repo_meta_prefix(); 262 + let orphans: Vec<(UserHash, String)> = self 263 + .repo_data 264 + .prefix(prefix.as_slice()) 265 + .map(|guard| -> Result<Option<(UserHash, String)>, MetastoreError> { 266 + let (k, v) = guard.into_inner().map_err(MetastoreError::Fjall)?; 267 + let user_hash = parse_repo_meta_key_hash(&k) 268 + .ok_or(MetastoreError::CorruptData("invalid repo_meta key"))?; 269 + match self.user_hashes.get_uuid(&user_hash) { 270 + Some(_) => Ok(None), 271 + None => { 272 + let handle = match RepoMetaValue::deserialize(&v) { 273 + Some(meta) => meta.handle, 274 + None => { 275 + tracing::warn!( 276 + user_hash = user_hash.raw(), 277 + "could not deserialize orphan repo_meta to recover handle for cleanup" 278 + ); 279 + String::new() 280 + } 281 + }; 282 + Ok(Some((user_hash, handle))) 283 + } 284 + } 285 + }) 286 + .filter_map(Result::transpose) 287 + .collect::<Result<Vec<_>, _>>()?; 288 + 289 + match orphans.is_empty() { 290 + true => Ok(0), 291 + false => { 292 + let mut batch = db.batch(); 293 + orphans.iter().try_for_each(|(user_hash, handle)| { 294 + stage_full_repo_data_removal(&mut batch, &self.repo_data, *user_hash, handle) 295 + })?; 296 + batch.commit().map_err(MetastoreError::Fjall)?; 297 + Ok(orphans.len()) 298 + } 299 + } 300 + } 301 + 262 302 pub fn get_repo(&self, user_id: Uuid) -> Result<Option<RepoInfo>, MetastoreError> { 263 303 let user_hash = match self.user_hashes.get(&user_id) { 264 304 Some(h) => h, ··· 569 609 Some(UserHash::from_raw(hash)) 570 610 } 571 611 612 + pub(super) fn stage_full_repo_data_removal( 613 + batch: &mut fjall::OwnedWriteBatch, 614 + repo_data: &Keyspace, 615 + user_hash: UserHash, 616 + handle: &str, 617 + ) -> Result<(), MetastoreError> { 618 + stage_repo_meta_removal(batch, repo_data, user_hash, handle); 619 + delete_all_by_prefix(repo_data, batch, record_user_prefix(user_hash).as_slice())?; 620 + delete_all_by_prefix( 621 + repo_data, 622 + batch, 623 + user_block_user_prefix(user_hash).as_slice(), 624 + )?; 625 + Ok(()) 626 + } 627 + 572 628 #[cfg(test)] 573 629 mod tests { 574 630 use super::*; 631 + use crate::metastore::partitions::Partition; 575 632 use crate::metastore::{Metastore, MetastoreConfig}; 576 633 577 634 fn test_config() -> MetastoreConfig { ··· 723 780 assert_eq!(repo.user_id, uid_b); 724 781 assert_eq!(repo.repo_rev.as_deref(), Some("r2")); 725 782 assert!(ops.get_repo(uid_a).unwrap().is_none()); 783 + } 784 + 785 + #[test] 786 + fn purge_orphan_repos_removes_entries_with_missing_reverse_mapping() { 787 + let (_dir, ms) = open_fresh(); 788 + let ops = ms.repo_ops(); 789 + let orphan_id = uuid::Uuid::new_v4(); 790 + let orphan_did = test_did("limpet"); 791 + let orphan_handle = test_handle("limpet"); 792 + let live_id = uuid::Uuid::new_v4(); 793 + let live_did = test_did("whelk"); 794 + let live_handle = test_handle("whelk"); 795 + let cid = test_cid_link(9); 796 + 797 + ops.create_repo( 798 + ms.database(), 799 + orphan_id, 800 + &orphan_did, 801 + &orphan_handle, 802 + &cid, 803 + "rev1", 804 + ) 805 + .unwrap(); 806 + ops.create_repo( 807 + ms.database(), 808 + live_id, 809 + &live_did, 810 + &live_handle, 811 + &cid, 812 + "rev1", 813 + ) 814 + .unwrap(); 815 + 816 + let mut batch = ms.database().batch(); 817 + ms.user_hashes().stage_remove(&mut batch, &orphan_id); 818 + batch.commit().unwrap(); 819 + 820 + assert!(matches!( 821 + ops.list_repos_paginated(None, 100), 822 + Err(MetastoreError::CorruptData( 823 + "user_hash has no reverse mapping" 824 + )) 825 + )); 826 + 827 + assert_eq!(ops.purge_orphan_repos(ms.database()).unwrap(), 1); 828 + 829 + let repos = ops.list_repos_paginated(None, 100).unwrap(); 830 + assert_eq!(repos.len(), 1); 831 + assert_eq!(repos[0].user_id, live_id); 832 + assert!(ops.lookup_handle(&orphan_handle).unwrap().is_none()); 833 + assert!(ops.lookup_handle(&live_handle).unwrap().is_some()); 834 + 835 + assert_eq!(ops.purge_orphan_repos(ms.database()).unwrap(), 0); 836 + } 837 + 838 + #[test] 839 + fn purge_orphan_repos_removes_records_and_blocks_for_orphan_only() { 840 + let (_dir, ms) = open_fresh(); 841 + let ops = ms.repo_ops(); 842 + let orphan_id = uuid::Uuid::new_v4(); 843 + let orphan_did = test_did("scallop"); 844 + let orphan_handle = test_handle("scallop"); 845 + let live_id = uuid::Uuid::new_v4(); 846 + let live_did = test_did("mussel"); 847 + let live_handle = test_handle("mussel"); 848 + let cid = test_cid_link(3); 849 + 850 + ops.create_repo( 851 + ms.database(), 852 + orphan_id, 853 + &orphan_did, 854 + &orphan_handle, 855 + &cid, 856 + "rev1", 857 + ) 858 + .unwrap(); 859 + ops.create_repo( 860 + ms.database(), 861 + live_id, 862 + &live_did, 863 + &live_handle, 864 + &cid, 865 + "rev1", 866 + ) 867 + .unwrap(); 868 + 869 + let orphan_hash = ms.user_hashes().get(&orphan_id).unwrap(); 870 + let live_hash = ms.user_hashes().get(&live_id).unwrap(); 871 + 872 + let seed = |hash: UserHash| { 873 + let mut batch = ms.database().batch(); 874 + let repo_data = ms.partition(Partition::RepoData); 875 + let mut rec_key = record_user_prefix(hash); 876 + rec_key.extend_from_slice(b"app.bsky.feed.post/seed"); 877 + batch.insert(repo_data, rec_key.as_slice(), b"r"); 878 + let mut blk_key = user_block_user_prefix(hash); 879 + blk_key.extend_from_slice(b"seed-cid"); 880 + batch.insert(repo_data, blk_key.as_slice(), b"b"); 881 + batch.commit().unwrap(); 882 + }; 883 + seed(orphan_hash); 884 + seed(live_hash); 885 + 886 + let mut batch = ms.database().batch(); 887 + ms.user_hashes().stage_remove(&mut batch, &orphan_id); 888 + batch.commit().unwrap(); 889 + 890 + let count = |prefix: &[u8]| ms.partition(Partition::RepoData).prefix(prefix).count(); 891 + 892 + assert_eq!(ops.purge_orphan_repos(ms.database()).unwrap(), 1); 893 + 894 + assert_eq!(count(record_user_prefix(orphan_hash).as_slice()), 0); 895 + assert_eq!(count(user_block_user_prefix(orphan_hash).as_slice()), 0); 896 + assert_eq!(count(record_user_prefix(live_hash).as_slice()), 1); 897 + assert_eq!(count(user_block_user_prefix(live_hash).as_slice()), 1); 726 898 } 727 899 728 900 #[test]
+26 -1
crates/tranquil-store/src/metastore/user_ops.rs
··· 8 8 use super::infra_schema::{channel_to_u8, u8_to_channel}; 9 9 use super::keys::UserHash; 10 10 use super::repo_meta::{RepoMetaValue, RepoStatus, handle_key, repo_meta_key}; 11 - use super::repo_ops::cid_link_to_bytes; 11 + use super::repo_ops::{cid_link_to_bytes, stage_full_repo_data_removal}; 12 12 use super::scan::{count_prefix, delete_all_by_prefix, point_lookup}; 13 13 use super::sessions::{SessionIndexValue, session_by_access_key}; 14 14 use super::user_hash::UserHashMap; ··· 182 182 .and_then(DateTime::from_timestamp_millis), 183 183 takedown_ref: val.takedown_ref.clone(), 184 184 is_admin: val.is_admin, 185 + inbound_migration: val.inbound_migration, 185 186 }) 186 187 } 187 188 ··· 2244 2245 self.mutate_user(user_hash, |u| { 2245 2246 u.deactivated_at_ms = None; 2246 2247 u.delete_after_ms = None; 2248 + u.inbound_migration = false; 2247 2249 }) 2248 2250 } 2249 2251 ··· 2421 2423 2422 2424 let mut batch = self.db.batch(); 2423 2425 self.delete_user_data(&mut batch, user_hash, &user)?; 2426 + self.stage_repo_data_removal(&mut batch, user_hash)?; 2424 2427 self.user_hashes.stage_remove(&mut batch, &user_id); 2425 2428 batch.commit().map_err(MetastoreError::Fjall) 2426 2429 } 2427 2430 2431 + fn stage_repo_data_removal( 2432 + &self, 2433 + batch: &mut fjall::OwnedWriteBatch, 2434 + user_hash: UserHash, 2435 + ) -> Result<(), MetastoreError> { 2436 + let handle = point_lookup( 2437 + &self.repo_data, 2438 + repo_meta_key(user_hash).as_slice(), 2439 + RepoMetaValue::deserialize, 2440 + "invalid repo_meta value", 2441 + )? 2442 + .map(|m| m.handle) 2443 + .unwrap_or_default(); 2444 + stage_full_repo_data_removal(batch, &self.repo_data, user_hash, &handle) 2445 + } 2446 + 2428 2447 pub fn set_user_takedown( 2429 2448 &self, 2430 2449 did: &Did, ··· 2657 2676 account_type: AccountType, 2658 2677 password_required: bool, 2659 2678 is_admin: bool, 2679 + inbound_migration: bool, 2660 2680 ) -> UserValue { 2661 2681 let now_ms = Utc::now().timestamp_millis(); 2662 2682 UserValue { ··· 2692 2712 signal_username: signal_username.map(str::to_owned), 2693 2713 signal_verified: false, 2694 2714 delete_after_ms: None, 2715 + inbound_migration, 2695 2716 } 2696 2717 } 2697 2718 ··· 2841 2862 AccountType::Personal, 2842 2863 true, 2843 2864 is_admin, 2865 + input.inbound_migration, 2844 2866 ); 2845 2867 2846 2868 self.write_new_account(&user_value, &input.commit_cid, &input.repo_rev) ··· 2865 2887 &input.encrypted_key_bytes, 2866 2888 input.encryption_version, 2867 2889 AccountType::Delegated, 2890 + false, 2868 2891 false, 2869 2892 false, 2870 2893 ); ··· 2898 2921 AccountType::Personal, 2899 2922 false, 2900 2923 is_admin, 2924 + false, 2901 2925 ); 2902 2926 2903 2927 let result = self.write_new_account(&user_value, &input.commit_cid, &input.repo_rev)?; ··· 2942 2966 AccountType::Personal, 2943 2967 false, 2944 2968 is_admin, 2969 + false, 2945 2970 ); 2946 2971 2947 2972 self.write_new_account(&user_value, &input.commit_cid, &input.repo_rev)
+58 -1
crates/tranquil-store/src/metastore/users.rs
··· 4 4 use super::encoding::KeyBuilder; 5 5 use super::keys::{KeyTag, UserHash}; 6 6 7 - const USER_SCHEMA_VERSION: u8 = 1; 7 + const USER_SCHEMA_VERSION: u8 = 2; 8 8 const PASSKEY_SCHEMA_VERSION: u8 = 1; 9 9 const TOTP_SCHEMA_VERSION: u8 = 1; 10 10 const BACKUP_CODE_SCHEMA_VERSION: u8 = 1; ··· 48 48 pub signal_username: Option<String>, 49 49 pub signal_verified: bool, 50 50 pub delete_after_ms: Option<i64>, 51 + pub inbound_migration: bool, 51 52 } 52 53 53 54 impl UserValue { ··· 63 64 let (&version, payload) = bytes.split_first()?; 64 65 match version { 65 66 USER_SCHEMA_VERSION => postcard::from_bytes(payload).ok(), 67 + 1 => { 68 + let mut extended = payload.to_vec(); 69 + extended.push(0); 70 + postcard::from_bytes(&extended).ok() 71 + } 66 72 _ => None, 67 73 } 68 74 } ··· 534 540 signal_username: None, 535 541 signal_verified: false, 536 542 delete_after_ms: None, 543 + inbound_migration: false, 537 544 }; 538 545 let bytes = val.serialize(); 539 546 assert_eq!(bytes[0], USER_SCHEMA_VERSION); 540 547 let decoded = UserValue::deserialize(&bytes).unwrap(); 541 548 assert_eq!(val, decoded); 549 + } 550 + 551 + #[test] 552 + fn deserialize_v1_defaults_inbound_migration_false() { 553 + let val = UserValue { 554 + id: uuid::Uuid::new_v4(), 555 + did: "did:plc:squid".to_owned(), 556 + handle: "witchcraft.systems".to_owned(), 557 + email: Some("nel@oyster.cafe".to_owned()), 558 + email_verified: true, 559 + password_hash: Some("hashed".to_owned()), 560 + created_at_ms: 1700000000000, 561 + deactivated_at_ms: Some(1700000000000), 562 + takedown_ref: None, 563 + is_admin: false, 564 + preferred_comms_channel: None, 565 + key_bytes: vec![1, 2, 3], 566 + encryption_version: 1, 567 + account_type: 0, 568 + password_required: true, 569 + two_factor_enabled: false, 570 + email_2fa_enabled: false, 571 + totp_enabled: false, 572 + allow_legacy_login: false, 573 + preferred_locale: None, 574 + invites_disabled: false, 575 + migrated_to_pds: None, 576 + migrated_at_ms: None, 577 + discord_username: None, 578 + discord_id: None, 579 + discord_verified: false, 580 + telegram_username: None, 581 + telegram_chat_id: None, 582 + telegram_verified: false, 583 + signal_username: None, 584 + signal_verified: false, 585 + delete_after_ms: None, 586 + inbound_migration: true, 587 + }; 588 + let v2 = val.serialize(); 589 + let mut v1 = Vec::with_capacity(v2.len() - 1); 590 + v1.push(1); 591 + v1.extend_from_slice(&v2[1..v2.len() - 1]); 592 + let decoded = UserValue::deserialize(&v1).expect("v1 user record must still decode"); 593 + assert!(!decoded.inbound_migration); 594 + assert_eq!(decoded.did, val.did); 595 + assert_eq!(decoded.handle, val.handle); 596 + assert_eq!(decoded.deactivated_at_ms, val.deactivated_at_ms); 542 597 } 543 598 544 599 #[test] ··· 774 829 signal_username: None, 775 830 signal_verified: false, 776 831 delete_after_ms: None, 832 + inbound_migration: false, 777 833 }; 778 834 assert_eq!(user.channel_verification(), 0); 779 835 user.email_verified = true; ··· 821 877 signal_username: None, 822 878 signal_verified: false, 823 879 delete_after_ms: None, 880 + inbound_migration: false, 824 881 }; 825 882 let mut bytes = val.serialize(); 826 883 bytes[0] = 99;
+22 -21
crates/tranquil-store/tests/gauntlet_index_backed.rs
··· 76 76 77 77 #[tokio::test] 78 78 async fn external_corruption_scenario_survives_many_seeds() { 79 - let failures: Vec<String> = futures::future::join_all((0..5).map(Seed).map(|seed| async move { 80 - let cfg = config_for(Scenario::ExternalCorruption, seed); 81 - let report = Gauntlet::new(cfg).expect("build gauntlet").run().await; 82 - (seed, report) 83 - })) 84 - .await 85 - .into_iter() 86 - .filter(|(_, r)| !r.is_clean()) 87 - .map(|(seed, r)| { 88 - format!( 89 - "seed {}: {} violations\n {}", 90 - seed.0, 91 - r.violations.len(), 92 - r.violations 93 - .iter() 94 - .map(|v| format!("{}: {}", v.invariant, v.detail)) 95 - .collect::<Vec<_>>() 96 - .join("\n ") 97 - ) 98 - }) 99 - .collect(); 79 + let failures: Vec<String> = 80 + futures::future::join_all((0..5).map(Seed).map(|seed| async move { 81 + let cfg = config_for(Scenario::ExternalCorruption, seed); 82 + let report = Gauntlet::new(cfg).expect("build gauntlet").run().await; 83 + (seed, report) 84 + })) 85 + .await 86 + .into_iter() 87 + .filter(|(_, r)| !r.is_clean()) 88 + .map(|(seed, r)| { 89 + format!( 90 + "seed {}: {} violations\n {}", 91 + seed.0, 92 + r.violations.len(), 93 + r.violations 94 + .iter() 95 + .map(|v| format!("{}: {}", v.invariant, v.detail)) 96 + .collect::<Vec<_>>() 97 + .join("\n ") 98 + ) 99 + }) 100 + .collect(); 100 101 assert!(failures.is_empty(), "{}", failures.join("\n---\n")); 101 102 } 102 103
+8
example.toml
··· 62 62 # Default value: false 63 63 #disable_rate_limiting = false 64 64 65 + # Skip the verified-comms-channel gate for login and record writes. 66 + # Please keep this off unless you're an invite-only PDS! 67 + # 68 + # Can also be specified via environment variable `DISABLE_ACCOUNT_VERIFICATION_GATE`. 69 + # 70 + # Default value: false 71 + #disable_account_verification_gate = false 72 + 65 73 # List of additional banned words for handle validation. 66 74 # 67 75 # Can also be specified via environment variable `PDS_BANNED_WORDS`.
+2
justfile
··· 7 7 docker compose --profile dev up 8 8 run-release: 9 9 cargo run -p tranquil-server --release 10 + gen-config: 11 + cargo run -p tranquil-server -- config-template > example.toml 10 12 build: 11 13 cargo build 12 14 build-release:
+1
migrations/20260523_add_inbound_migration_flag.sql
··· 1 + ALTER TABLE users ADD COLUMN inbound_migration BOOLEAN NOT NULL DEFAULT FALSE;