diff --git a/.sqlx/query-21972fb9b0f6048feb7659779fd1a42b477e79a01890bc11ea5357b474123ea3.json b/.sqlx/query-1cecc225ef78976b6b2307c6f25f53ef800024e1ee20ff2782992471d0f22cea.json similarity index 66% rename from .sqlx/query-21972fb9b0f6048feb7659779fd1a42b477e79a01890bc11ea5357b474123ea3.json rename to .sqlx/query-1cecc225ef78976b6b2307c6f25f53ef800024e1ee20ff2782992471d0f22cea.json index a86cbe0..f9558f6 100644 --- a/.sqlx/query-21972fb9b0f6048feb7659779fd1a42b477e79a01890bc11ea5357b474123ea3.json +++ b/.sqlx/query-1cecc225ef78976b6b2307c6f25f53ef800024e1ee20ff2782992471d0f22cea.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT u.did, u.deactivated_at, u.takedown_ref, r.repo_root_cid, r.repo_rev\n FROM repos r\n JOIN users u ON r.user_id = u.id\n WHERE u.did > $1\n ORDER BY u.did ASC\n LIMIT $2", + "query": "SELECT u.did, u.deactivated_at, u.takedown_ref, r.repo_root_cid, r.repo_rev\n FROM repos r\n JOIN users u ON r.user_id = u.id\n WHERE $1 = '' OR (u.created_at, u.did) > (\n SELECT cursor_user.created_at, cursor_user.did\n FROM users cursor_user\n WHERE cursor_user.did = $1\n )\n ORDER BY u.created_at ASC, u.did ASC\n LIMIT $2", "describe": { "columns": [ { @@ -43,5 +43,5 @@ true ] }, - "hash": "21972fb9b0f6048feb7659779fd1a42b477e79a01890bc11ea5357b474123ea3" + "hash": "1cecc225ef78976b6b2307c6f25f53ef800024e1ee20ff2782992471d0f22cea" } diff --git a/crates/tranquil-db/src/postgres/repo.rs b/crates/tranquil-db/src/postgres/repo.rs index 6b10f6e..ed04a06 100644 --- a/crates/tranquil-db/src/postgres/repo.rs +++ b/crates/tranquil-db/src/postgres/repo.rs @@ -1235,8 +1235,12 @@ impl RepoRepository for PostgresRepoRepository { r#"SELECT u.did, u.deactivated_at, u.takedown_ref, r.repo_root_cid, r.repo_rev FROM repos r JOIN users u ON r.user_id = u.id - WHERE u.did > $1 - ORDER BY u.did ASC + WHERE $1 = '' OR (u.created_at, u.did) > ( + SELECT cursor_user.created_at, cursor_user.did + FROM users cursor_user + WHERE cursor_user.did = $1 + ) + ORDER BY u.created_at ASC, u.did ASC LIMIT $2"#, cursor_str, limit diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs index ac12090..be6f0f2 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs @@ -119,4 +119,21 @@ mod tests { ); assert!(!eff.permitted.contains("identity")); } + + #[tokio::test] + async fn delegated_owner_keeps_transition_scopes() { + let c = MapCache::default(); + let granted = DbScope::new(tranquil_pds::delegation::OWNER_FULL_SCOPES).unwrap(); + let eff = resolve_effective_scopes( + &c, + "atproto transition:generic transition:chat.bsky transition:email", + Authority::Delegated(&granted), + ) + .await; + + assert_eq!( + eff.permitted, + "atproto transition:chat.bsky transition:email transition:generic" + ); + } } diff --git a/crates/tranquil-pds/src/delegation/scopes.rs b/crates/tranquil-pds/src/delegation/scopes.rs index 9ccaaaf..347b3db 100644 --- a/crates/tranquil-pds/src/delegation/scopes.rs +++ b/crates/tranquil-pds/src/delegation/scopes.rs @@ -50,11 +50,13 @@ pub fn intersect_scopes(requested: &str, granted: &str) -> String { let requested_set: HashSet<&str> = requested.split_whitespace().collect(); let granted_parsed: Vec = granted.split_whitespace().map(parse_scope).collect(); + let has_owner_access = owner_access_is_granted(&granted_parsed); let mut scopes: Vec<&str> = requested_set .iter() .filter(|requested_scope| { - **requested_scope != "atproto" && any_granted_covers(requested_scope, &granted_parsed) + **requested_scope != "atproto" + && transition_scope_is_covered(requested_scope, &granted_parsed, has_owner_access) }) .copied() .chain(requested_set.contains("atproto").then_some("atproto")) @@ -69,7 +71,39 @@ pub fn grant_covers(granted: &str, scope: &str) -> bool { } let granted_parsed: Vec = granted.split_whitespace().map(parse_scope).collect(); - any_granted_covers(scope, &granted_parsed) + transition_scope_is_covered( + scope, + &granted_parsed, + owner_access_is_granted(&granted_parsed), + ) +} + +fn transition_scope_is_covered( + requested: &str, + granted: &[tranquil_scopes::ParsedScope], + has_owner_access: bool, +) -> bool { + match parse_scope(requested) { + tranquil_scopes::ParsedScope::TransitionGeneric + | tranquil_scopes::ParsedScope::TransitionChat + if has_owner_access => + { + true + } + tranquil_scopes::ParsedScope::TransitionEmail => { + has_owner_access + || any_granted_covers("account:email?action=read", granted) + || any_granted_covers(requested, granted) + } + _ => any_granted_covers(requested, granted), + } +} + +fn owner_access_is_granted(granted: &[tranquil_scopes::ParsedScope]) -> bool { + OWNER_FULL_SCOPES + .split_whitespace() + .filter(|scope| *scope != "atproto") + .all(|scope| any_granted_covers(scope, granted)) } fn any_granted_covers(requested: &str, granted: &[tranquil_scopes::ParsedScope]) -> bool { @@ -93,6 +127,36 @@ mod tests { assert!(result.contains("blob:*/*")); } + #[test] + fn test_intersect_owner_grant_covers_transition_scopes() { + let result = intersect_scopes( + "atproto transition:generic transition:chat.bsky transition:email", + OWNER_FULL_SCOPES, + ); + assert_eq!( + result, + "atproto transition:chat.bsky transition:email transition:generic" + ); + } + + #[test] + fn test_intersect_partial_grant_does_not_gain_broad_transition_scopes() { + let result = intersect_scopes( + "transition:generic transition:chat.bsky", + "repo:* blob:*/* account:*?action=manage", + ); + assert!(result.is_empty()); + } + + #[test] + fn test_intersect_email_grant_covers_transition_email_only() { + let result = intersect_scopes( + "transition:generic transition:chat.bsky transition:email", + "account:email?action=read", + ); + assert_eq!(result, "transition:email"); + } + #[test] fn test_intersect_bare_atproto_grant_is_auth_only() { let requested = "atproto repo:*?action=create blob:*/*"; diff --git a/crates/tranquil-pds/tests/sync_repo.rs b/crates/tranquil-pds/tests/sync_repo.rs index 24ddc7f..3fdf876 100644 --- a/crates/tranquil-pds/tests/sync_repo.rs +++ b/crates/tranquil-pds/tests/sync_repo.rs @@ -103,6 +103,69 @@ async fn test_list_repos_with_limit() { assert!(repos.len() <= 2); } +#[tokio::test] +async fn test_list_repos_orders_oldest_to_newest() { + let client = client(); + let (_, did1) = create_account_and_login(&client).await; + let (_, did2) = create_account_and_login(&client).await; + let (older_did, newer_did) = if is_store_backend() { + (did1, did2) + } else if did1 > did2 { + (did1, did2) + } else { + (did2, did1) + }; + if !is_store_backend() { + let pool = get_test_db_pool().await; + sqlx::query("UPDATE users SET created_at = '2100-01-01T00:00:00Z' WHERE did = $1") + .bind(&older_did) + .execute(pool) + .await + .unwrap(); + sqlx::query("UPDATE users SET created_at = '2100-01-02T00:00:00Z' WHERE did = $1") + .bind(&newer_did) + .execute(pool) + .await + .unwrap(); + } + + let mut listed_dids = Vec::new(); + let mut cursor: Option = None; + loop { + let mut request = client + .get(format!( + "{}/xrpc/com.atproto.sync.listRepos", + base_url().await + )) + .query(&[("limit", "1000")]); + if let Some(cursor) = cursor.as_ref() { + request = request.query(&[("cursor", cursor)]); + } + let body: Value = request.send().await.unwrap().json().await.unwrap(); + listed_dids.extend( + body["repos"] + .as_array() + .unwrap() + .iter() + .filter_map(|repo| repo["did"].as_str().map(str::to_owned)), + ); + cursor = body["cursor"].as_str().map(str::to_owned); + if cursor.is_none() { + break; + } + } + + let older_position = listed_dids + .iter() + .position(|did| did == &older_did) + .unwrap(); + let newer_position = listed_dids + .iter() + .position(|did| did == &newer_did) + .unwrap(); + assert!(older_position < newer_position); +} + #[tokio::test] async fn test_list_repos_pagination() { let client = client(); diff --git a/crates/tranquil-store/src/metastore/client.rs b/crates/tranquil-store/src/metastore/client.rs index 2090333..5e02f40 100644 --- a/crates/tranquil-store/src/metastore/client.rs +++ b/crates/tranquil-store/src/metastore/client.rs @@ -38,7 +38,6 @@ use super::handler::{ InfraRequest, MetastoreRequest, OAuthRequest, RecordRequest, RepoRequest, SessionRequest, SsoRequest, UserBlockRequest, UserRequest, }; -use super::keys::UserHash; use crate::eventlog::{EventLog, TimestampMicros}; use crate::io::StorageIO; @@ -703,11 +702,10 @@ impl tranquil_db_traits::RepoRepository for MetastoreCli cursor_did: Option<&Did>, limit: i64, ) -> Result, DbError> { - let cursor_hash = cursor_did.map(|d| UserHash::from_did(d.as_str()).raw()); let (tx, rx) = oneshot::channel(); self.pool .send(MetastoreRequest::Repo(RepoRequest::ListReposPaginated { - cursor_user_hash: cursor_hash, + cursor_did: cursor_did.cloned(), limit: usize::try_from(limit).unwrap_or(0), tx, }))?; diff --git a/crates/tranquil-store/src/metastore/handler.rs b/crates/tranquil-store/src/metastore/handler.rs index 2c76c40..9d9efaf 100644 --- a/crates/tranquil-store/src/metastore/handler.rs +++ b/crates/tranquil-store/src/metastore/handler.rs @@ -237,7 +237,7 @@ pub enum RepoRequest { tx: Tx>, }, ListReposPaginated { - cursor_user_hash: Option, + cursor_did: Option, limit: usize, tx: Tx>, }, @@ -2663,16 +2663,49 @@ fn dispatch_repo(state: &HandlerState, req: RepoRequest) { let _ = tx.send(result); } RepoRequest::ListReposPaginated { - cursor_user_hash, + cursor_did, limit, tx, } => { - let result = state - .metastore - .repo_ops() - .list_repos_paginated(cursor_user_hash, limit) - .map_err(metastore_to_db) - .and_then(|entries| entries.into_iter().map(convert_repo_list_entry).collect()); + let result = (|| -> Result, DbError> { + let entries = state + .metastore + .repo_ops() + .list_repos_paginated(None, usize::MAX) + .map_err(metastore_to_db)?; + let mut repos = entries + .into_iter() + .map(|entry| { + let item = convert_repo_list_entry(entry)?; + let user = state + .metastore + .user_ops() + .get_by_did(&item.did) + .map_err(metastore_to_db)? + .ok_or(DbError::CorruptData("repo user not found"))?; + Ok((user.created_at, item)) + }) + .collect::, DbError>>()?; + repos.sort_by(|(created_at_a, item_a), (created_at_b, item_b)| { + created_at_a + .cmp(created_at_b) + .then_with(|| item_a.did.as_str().cmp(item_b.did.as_str())) + }); + + let start = match cursor_did.as_ref() { + Some(cursor) => match repos.iter().position(|(_, item)| &item.did == cursor) { + Some(position) => position + 1, + None => return Ok(Vec::new()), + }, + None => 0, + }; + Ok(repos + .into_iter() + .skip(start) + .take(limit) + .map(|(_, item)| item) + .collect()) + })(); let _ = tx.send(result); } RepoRequest::UpdateRepoStatus { diff --git a/migrations/20260729_users_created_at_did_index.sql b/migrations/20260729_users_created_at_did_index.sql new file mode 100644 index 0000000..d3cd6ee --- /dev/null +++ b/migrations/20260729_users_created_at_did_index.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS idx_users_created_at_did ON users(created_at ASC, did ASC); \ No newline at end of file