A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

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

feat: add support for account write delegation

Trezy 26d57cae 190578f0

+2589 -13
+6
migrations/postgres/20260429100000_create_delegated_accounts.sql
··· 1 + CREATE TABLE IF NOT EXISTS delegated_accounts ( 2 + account_did TEXT PRIMARY KEY, 3 + linked_by TEXT NOT NULL, 4 + api_client_id TEXT NOT NULL, 5 + created_at TEXT NOT NULL 6 + );
+8
migrations/postgres/20260429100001_create_account_delegates.sql
··· 1 + CREATE TABLE IF NOT EXISTS account_delegates ( 2 + account_did TEXT NOT NULL REFERENCES delegated_accounts(account_did) ON DELETE CASCADE, 3 + user_did TEXT NOT NULL, 4 + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')), 5 + granted_by TEXT NOT NULL, 6 + created_at TEXT NOT NULL, 7 + PRIMARY KEY (account_did, user_did) 8 + );
+6
migrations/sqlite/20260429100000_create_delegated_accounts.sql
··· 1 + CREATE TABLE IF NOT EXISTS delegated_accounts ( 2 + account_did TEXT PRIMARY KEY, 3 + linked_by TEXT NOT NULL, 4 + api_client_id TEXT NOT NULL, 5 + created_at TEXT NOT NULL 6 + );
+8
migrations/sqlite/20260429100001_create_account_delegates.sql
··· 1 + CREATE TABLE IF NOT EXISTS account_delegates ( 2 + account_did TEXT NOT NULL REFERENCES delegated_accounts(account_did) ON DELETE CASCADE, 3 + user_did TEXT NOT NULL, 4 + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')), 5 + granted_by TEXT NOT NULL, 6 + created_at TEXT NOT NULL, 7 + PRIMARY KEY (account_did, user_did) 8 + );
+103
src/delegation/add_delegate.rs
··· 1 + use axum::Json; 2 + use axum::extract::State; 3 + use axum::http::StatusCode; 4 + use axum::response::{IntoResponse, Response}; 5 + use serde::Deserialize; 6 + use serde_json::json; 7 + 8 + use crate::AppState; 9 + use crate::auth::XrpcClaims; 10 + use crate::error::AppError; 11 + use crate::event_log::{EventLog, Severity, log_event}; 12 + 13 + use super::DelegateRole; 14 + use super::db; 15 + 16 + #[derive(Deserialize)] 17 + #[serde(rename_all = "camelCase")] 18 + pub struct AddDelegateInput { 19 + pub account_did: String, 20 + pub user_did: String, 21 + pub role: String, 22 + } 23 + 24 + pub async fn add_delegate( 25 + State(state): State<AppState>, 26 + xrpc_claims: XrpcClaims, 27 + Json(input): Json<AddDelegateInput>, 28 + ) -> Result<Response, AppError> { 29 + let claims = xrpc_claims 30 + .0 31 + .ok_or_else(|| AppError::Auth("addDelegate requires authentication".into()))?; 32 + 33 + let caller_did = claims.did().to_string(); 34 + 35 + super::verify_client_scope(&state, &claims, &input.account_did).await?; 36 + 37 + let caller_role = 38 + db::get_delegate_role(&state.db, state.db_backend, &input.account_did, &caller_did) 39 + .await? 40 + .ok_or_else(|| AppError::Forbidden("you are not a delegate of this account".into()))?; 41 + 42 + if !caller_role.can_manage_members() { 43 + return Err(AppError::Forbidden( 44 + "only owners and admins can add delegates".into(), 45 + )); 46 + } 47 + 48 + let target_role = match input.role.as_str() { 49 + "admin" => DelegateRole::Admin, 50 + "member" => DelegateRole::Member, 51 + "owner" => { 52 + return Err(AppError::BadRequest( 53 + "cannot add a second owner — use unlinkAccount and re-link instead".into(), 54 + )); 55 + } 56 + _ => { 57 + return Err(AppError::BadRequest( 58 + "role must be 'admin' or 'member'".into(), 59 + )); 60 + } 61 + }; 62 + 63 + let existing = db::get_delegate_role( 64 + &state.db, 65 + state.db_backend, 66 + &input.account_did, 67 + &input.user_did, 68 + ) 69 + .await?; 70 + if existing.is_some() { 71 + return Err(AppError::Conflict( 72 + "user is already a delegate — remove them first to change role".into(), 73 + )); 74 + } 75 + 76 + db::add_delegate( 77 + &state.db, 78 + state.db_backend, 79 + &input.account_did, 80 + &input.user_did, 81 + target_role, 82 + &caller_did, 83 + ) 84 + .await?; 85 + 86 + log_event( 87 + &state.db, 88 + EventLog { 89 + event_type: "delegation.delegate_added".to_string(), 90 + severity: Severity::Info, 91 + actor_did: Some(caller_did), 92 + subject: Some(input.user_did.clone()), 93 + detail: json!({ 94 + "account_did": input.account_did, 95 + "role": input.role, 96 + }), 97 + }, 98 + state.db_backend, 99 + ) 100 + .await; 101 + 102 + Ok((StatusCode::CREATED, Json(json!({}))).into_response()) 103 + }
+223
src/delegation/db.rs
··· 1 + use crate::db::{adapt_sql, now_rfc3339}; 2 + use crate::error::AppError; 3 + use sqlx::AnyPool; 4 + 5 + use super::{DelegateRole, DelegateView, DelegatedAccountView}; 6 + 7 + pub async fn create_delegated_account( 8 + pool: &AnyPool, 9 + backend: crate::db::DatabaseBackend, 10 + account_did: &str, 11 + linked_by: &str, 12 + api_client_id: &str, 13 + ) -> Result<(), AppError> { 14 + let now = now_rfc3339(); 15 + let sql = adapt_sql( 16 + "INSERT INTO delegated_accounts (account_did, linked_by, api_client_id, created_at) VALUES (?, ?, ?, ?)", 17 + backend, 18 + ); 19 + sqlx::query(&sql) 20 + .bind(account_did) 21 + .bind(linked_by) 22 + .bind(api_client_id) 23 + .bind(&now) 24 + .execute(pool) 25 + .await 26 + .map_err(|e| AppError::Internal(format!("failed to create delegated account: {e}")))?; 27 + Ok(()) 28 + } 29 + 30 + pub async fn delete_delegated_account( 31 + pool: &AnyPool, 32 + backend: crate::db::DatabaseBackend, 33 + account_did: &str, 34 + ) -> Result<(), AppError> { 35 + let sql = adapt_sql( 36 + "DELETE FROM delegated_accounts WHERE account_did = ?", 37 + backend, 38 + ); 39 + sqlx::query(&sql) 40 + .bind(account_did) 41 + .execute(pool) 42 + .await 43 + .map_err(|e| AppError::Internal(format!("failed to delete delegated account: {e}")))?; 44 + Ok(()) 45 + } 46 + 47 + pub async fn get_delegated_account_owner( 48 + pool: &AnyPool, 49 + backend: crate::db::DatabaseBackend, 50 + account_did: &str, 51 + ) -> Result<Option<String>, AppError> { 52 + let sql = adapt_sql( 53 + "SELECT linked_by FROM delegated_accounts WHERE account_did = ?", 54 + backend, 55 + ); 56 + let row: Option<(String,)> = sqlx::query_as(&sql) 57 + .bind(account_did) 58 + .fetch_optional(pool) 59 + .await 60 + .map_err(|e| AppError::Internal(format!("failed to query delegated account: {e}")))?; 61 + Ok(row.map(|r| r.0)) 62 + } 63 + 64 + pub async fn is_account_linked( 65 + pool: &AnyPool, 66 + backend: crate::db::DatabaseBackend, 67 + account_did: &str, 68 + ) -> Result<bool, AppError> { 69 + let owner = get_delegated_account_owner(pool, backend, account_did).await?; 70 + Ok(owner.is_some()) 71 + } 72 + 73 + pub async fn get_api_client_id( 74 + pool: &AnyPool, 75 + backend: crate::db::DatabaseBackend, 76 + account_did: &str, 77 + ) -> Result<Option<String>, AppError> { 78 + let sql = adapt_sql( 79 + "SELECT api_client_id FROM delegated_accounts WHERE account_did = ?", 80 + backend, 81 + ); 82 + let row: Option<(String,)> = sqlx::query_as(&sql) 83 + .bind(account_did) 84 + .fetch_optional(pool) 85 + .await 86 + .map_err(|e| AppError::Internal(format!("failed to query delegated account: {e}")))?; 87 + Ok(row.map(|r| r.0)) 88 + } 89 + 90 + pub async fn add_delegate( 91 + pool: &AnyPool, 92 + backend: crate::db::DatabaseBackend, 93 + account_did: &str, 94 + user_did: &str, 95 + role: DelegateRole, 96 + granted_by: &str, 97 + ) -> Result<(), AppError> { 98 + let now = now_rfc3339(); 99 + let sql = adapt_sql( 100 + "INSERT INTO account_delegates (account_did, user_did, role, granted_by, created_at) VALUES (?, ?, ?, ?, ?)", 101 + backend, 102 + ); 103 + sqlx::query(&sql) 104 + .bind(account_did) 105 + .bind(user_did) 106 + .bind(role.as_str()) 107 + .bind(granted_by) 108 + .bind(&now) 109 + .execute(pool) 110 + .await 111 + .map_err(|e| AppError::Internal(format!("failed to add delegate: {e}")))?; 112 + Ok(()) 113 + } 114 + 115 + pub async fn remove_delegate( 116 + pool: &AnyPool, 117 + backend: crate::db::DatabaseBackend, 118 + account_did: &str, 119 + user_did: &str, 120 + ) -> Result<(), AppError> { 121 + let sql = adapt_sql( 122 + "DELETE FROM account_delegates WHERE account_did = ? AND user_did = ?", 123 + backend, 124 + ); 125 + sqlx::query(&sql) 126 + .bind(account_did) 127 + .bind(user_did) 128 + .execute(pool) 129 + .await 130 + .map_err(|e| AppError::Internal(format!("failed to remove delegate: {e}")))?; 131 + Ok(()) 132 + } 133 + 134 + pub async fn get_delegate_role( 135 + pool: &AnyPool, 136 + backend: crate::db::DatabaseBackend, 137 + account_did: &str, 138 + user_did: &str, 139 + ) -> Result<Option<DelegateRole>, AppError> { 140 + let sql = adapt_sql( 141 + "SELECT role FROM account_delegates WHERE account_did = ? AND user_did = ?", 142 + backend, 143 + ); 144 + let row: Option<(String,)> = sqlx::query_as(&sql) 145 + .bind(account_did) 146 + .bind(user_did) 147 + .fetch_optional(pool) 148 + .await 149 + .map_err(|e| AppError::Internal(format!("failed to query delegate role: {e}")))?; 150 + Ok(row.and_then(|r| DelegateRole::from_str(&r.0))) 151 + } 152 + 153 + pub async fn list_accounts_for_user( 154 + pool: &AnyPool, 155 + backend: crate::db::DatabaseBackend, 156 + user_did: &str, 157 + api_client_id: &str, 158 + ) -> Result<Vec<DelegatedAccountView>, AppError> { 159 + let sql = adapt_sql( 160 + "SELECT ad.account_did, ad.role, ad.created_at FROM account_delegates ad JOIN delegated_accounts da ON da.account_did = ad.account_did WHERE ad.user_did = ? AND da.api_client_id = ? ORDER BY ad.created_at DESC", 161 + backend, 162 + ); 163 + let rows: Vec<(String, String, String)> = sqlx::query_as(&sql) 164 + .bind(user_did) 165 + .bind(api_client_id) 166 + .fetch_all(pool) 167 + .await 168 + .map_err(|e| AppError::Internal(format!("failed to list delegated accounts: {e}")))?; 169 + 170 + Ok(rows 171 + .into_iter() 172 + .map(|(did, role, created_at)| DelegatedAccountView { 173 + did, 174 + role, 175 + created_at, 176 + }) 177 + .collect()) 178 + } 179 + 180 + pub async fn get_account_for_user( 181 + pool: &AnyPool, 182 + backend: crate::db::DatabaseBackend, 183 + account_did: &str, 184 + user_did: &str, 185 + ) -> Result<Option<(String, String, String)>, AppError> { 186 + let sql = adapt_sql( 187 + "SELECT da.linked_by, ad.role, ad.created_at FROM delegated_accounts da JOIN account_delegates ad ON da.account_did = ad.account_did WHERE da.account_did = ? AND ad.user_did = ?", 188 + backend, 189 + ); 190 + let row: Option<(String, String, String)> = sqlx::query_as(&sql) 191 + .bind(account_did) 192 + .bind(user_did) 193 + .fetch_optional(pool) 194 + .await 195 + .map_err(|e| AppError::Internal(format!("failed to get delegated account: {e}")))?; 196 + Ok(row) 197 + } 198 + 199 + pub async fn list_delegates( 200 + pool: &AnyPool, 201 + backend: crate::db::DatabaseBackend, 202 + account_did: &str, 203 + ) -> Result<Vec<DelegateView>, AppError> { 204 + let sql = adapt_sql( 205 + "SELECT user_did, role, granted_by, created_at FROM account_delegates WHERE account_did = ? ORDER BY created_at ASC", 206 + backend, 207 + ); 208 + let rows: Vec<(String, String, String, String)> = sqlx::query_as(&sql) 209 + .bind(account_did) 210 + .fetch_all(pool) 211 + .await 212 + .map_err(|e| AppError::Internal(format!("failed to list delegates: {e}")))?; 213 + 214 + Ok(rows 215 + .into_iter() 216 + .map(|(user_did, role, granted_by, created_at)| DelegateView { 217 + user_did, 218 + role, 219 + granted_by, 220 + created_at, 221 + }) 222 + .collect()) 223 + }
+46
src/delegation/get_account.rs
··· 1 + use axum::Json; 2 + use axum::extract::{Query, State}; 3 + use axum::response::{IntoResponse, Response}; 4 + use serde::Deserialize; 5 + use serde_json::json; 6 + 7 + use crate::AppState; 8 + use crate::auth::XrpcClaims; 9 + use crate::error::AppError; 10 + 11 + use super::db; 12 + 13 + #[derive(Deserialize)] 14 + pub struct GetAccountParams { 15 + pub did: String, 16 + } 17 + 18 + pub async fn get_account( 19 + State(state): State<AppState>, 20 + xrpc_claims: XrpcClaims, 21 + Query(params): Query<GetAccountParams>, 22 + ) -> Result<Response, AppError> { 23 + let claims = xrpc_claims 24 + .0 25 + .ok_or_else(|| AppError::Auth("getAccount requires authentication".into()))?; 26 + 27 + super::verify_client_scope(&state, &claims, &params.did).await?; 28 + 29 + let is_linked = db::is_account_linked(&state.db, state.db_backend, &params.did).await?; 30 + if !is_linked { 31 + return Err(AppError::NotFound("delegated account not found".into())); 32 + } 33 + 34 + let (linked_by, role, created_at) = 35 + db::get_account_for_user(&state.db, state.db_backend, &params.did, claims.did()) 36 + .await? 37 + .ok_or_else(|| AppError::NotFound("you are not a delegate of this account".into()))?; 38 + 39 + Ok(Json(json!({ 40 + "did": params.did, 41 + "role": role, 42 + "linkedBy": linked_by, 43 + "createdAt": created_at, 44 + })) 45 + .into_response()) 46 + }
+27
src/delegation/list_accounts.rs
··· 1 + use axum::Json; 2 + use axum::extract::State; 3 + use axum::response::{IntoResponse, Response}; 4 + use serde_json::json; 5 + 6 + use crate::AppState; 7 + use crate::auth::XrpcClaims; 8 + use crate::error::AppError; 9 + 10 + use super::db; 11 + 12 + pub async fn list_accounts( 13 + State(state): State<AppState>, 14 + xrpc_claims: XrpcClaims, 15 + ) -> Result<Response, AppError> { 16 + let claims = xrpc_claims 17 + .0 18 + .ok_or_else(|| AppError::Auth("listAccounts requires authentication".into()))?; 19 + 20 + let caller_client_id = super::resolve_caller_client_id(&state, &claims).await?; 21 + 22 + let accounts = 23 + db::list_accounts_for_user(&state.db, state.db_backend, claims.did(), &caller_client_id) 24 + .await?; 25 + 26 + Ok(Json(json!({ "accounts": accounts })).into_response()) 27 + }
+48
src/delegation/list_delegates.rs
··· 1 + use axum::Json; 2 + use axum::extract::{Query, State}; 3 + use axum::response::{IntoResponse, Response}; 4 + use serde::Deserialize; 5 + use serde_json::json; 6 + 7 + use crate::AppState; 8 + use crate::auth::XrpcClaims; 9 + use crate::error::AppError; 10 + 11 + use super::db; 12 + 13 + #[derive(Deserialize)] 14 + #[serde(rename_all = "camelCase")] 15 + pub struct ListDelegatesParams { 16 + pub account_did: String, 17 + } 18 + 19 + pub async fn list_delegates( 20 + State(state): State<AppState>, 21 + xrpc_claims: XrpcClaims, 22 + Query(params): Query<ListDelegatesParams>, 23 + ) -> Result<Response, AppError> { 24 + let claims = xrpc_claims 25 + .0 26 + .ok_or_else(|| AppError::Auth("listDelegates requires authentication".into()))?; 27 + 28 + super::verify_client_scope(&state, &claims, &params.account_did).await?; 29 + 30 + let caller_role = db::get_delegate_role( 31 + &state.db, 32 + state.db_backend, 33 + &params.account_did, 34 + claims.did(), 35 + ) 36 + .await? 37 + .ok_or_else(|| AppError::Forbidden("you are not a delegate of this account".into()))?; 38 + 39 + if !caller_role.can_manage_members() { 40 + return Err(AppError::Forbidden( 41 + "only owners and admins can list delegates".into(), 42 + )); 43 + } 44 + 45 + let delegates = db::list_delegates(&state.db, state.db_backend, &params.account_did).await?; 46 + 47 + Ok(Json(json!({ "delegates": delegates })).into_response()) 48 + }
+91
src/delegation/mod.rs
··· 1 + pub mod add_delegate; 2 + pub mod db; 3 + pub mod get_account; 4 + pub mod link_account; 5 + pub mod list_accounts; 6 + pub mod list_delegates; 7 + pub mod remove_delegate; 8 + pub mod unlink_account; 9 + 10 + use serde::Serialize; 11 + 12 + #[derive(Debug, Clone, Serialize)] 13 + #[serde(rename_all = "camelCase")] 14 + pub struct DelegatedAccountView { 15 + pub did: String, 16 + pub role: String, 17 + pub created_at: String, 18 + } 19 + 20 + #[derive(Debug, Clone, Serialize)] 21 + #[serde(rename_all = "camelCase")] 22 + pub struct DelegateView { 23 + pub user_did: String, 24 + pub role: String, 25 + pub granted_by: String, 26 + pub created_at: String, 27 + } 28 + 29 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 30 + pub enum DelegateRole { 31 + Owner, 32 + Admin, 33 + Member, 34 + } 35 + 36 + impl DelegateRole { 37 + pub fn as_str(&self) -> &'static str { 38 + match self { 39 + DelegateRole::Owner => "owner", 40 + DelegateRole::Admin => "admin", 41 + DelegateRole::Member => "member", 42 + } 43 + } 44 + 45 + pub fn from_str(s: &str) -> Option<Self> { 46 + match s { 47 + "owner" => Some(DelegateRole::Owner), 48 + "admin" => Some(DelegateRole::Admin), 49 + "member" => Some(DelegateRole::Member), 50 + _ => None, 51 + } 52 + } 53 + 54 + pub fn can_write(&self) -> bool { 55 + matches!(self, DelegateRole::Owner | DelegateRole::Admin) 56 + } 57 + 58 + pub fn can_manage_members(&self) -> bool { 59 + matches!(self, DelegateRole::Owner | DelegateRole::Admin) 60 + } 61 + } 62 + 63 + pub(crate) async fn resolve_caller_client_id( 64 + state: &crate::AppState, 65 + claims: &crate::auth::Claims, 66 + ) -> Result<String, crate::error::AppError> { 67 + let client_key = claims.client_key().ok_or_else(|| { 68 + crate::error::AppError::Auth("delegation requires DPoP authentication".into()) 69 + })?; 70 + crate::repo::get_dpop_client_id(state, client_key).await 71 + } 72 + 73 + pub(crate) async fn verify_client_scope( 74 + state: &crate::AppState, 75 + claims: &crate::auth::Claims, 76 + account_did: &str, 77 + ) -> Result<(), crate::error::AppError> { 78 + let caller_client_id = resolve_caller_client_id(state, claims).await?; 79 + 80 + let stored_client_id = db::get_api_client_id(&state.db, state.db_backend, account_did) 81 + .await? 82 + .ok_or_else(|| crate::error::AppError::NotFound("delegated account not found".into()))?; 83 + 84 + if caller_client_id != stored_client_id { 85 + return Err(crate::error::AppError::Forbidden( 86 + "delegation is scoped to a different application".into(), 87 + )); 88 + } 89 + 90 + Ok(()) 91 + }
+92
src/delegation/remove_delegate.rs
··· 1 + use axum::Json; 2 + use axum::extract::State; 3 + use axum::http::StatusCode; 4 + use axum::response::{IntoResponse, Response}; 5 + use serde::Deserialize; 6 + use serde_json::json; 7 + 8 + use crate::AppState; 9 + use crate::auth::XrpcClaims; 10 + use crate::error::AppError; 11 + use crate::event_log::{EventLog, Severity, log_event}; 12 + 13 + use super::DelegateRole; 14 + use super::db; 15 + 16 + #[derive(Deserialize)] 17 + #[serde(rename_all = "camelCase")] 18 + pub struct RemoveDelegateInput { 19 + pub account_did: String, 20 + pub user_did: String, 21 + } 22 + 23 + pub async fn remove_delegate( 24 + State(state): State<AppState>, 25 + xrpc_claims: XrpcClaims, 26 + Json(input): Json<RemoveDelegateInput>, 27 + ) -> Result<Response, AppError> { 28 + let claims = xrpc_claims 29 + .0 30 + .ok_or_else(|| AppError::Auth("removeDelegate requires authentication".into()))?; 31 + 32 + let caller_did = claims.did().to_string(); 33 + 34 + super::verify_client_scope(&state, &claims, &input.account_did).await?; 35 + 36 + let caller_role = 37 + db::get_delegate_role(&state.db, state.db_backend, &input.account_did, &caller_did) 38 + .await? 39 + .ok_or_else(|| AppError::Forbidden("you are not a delegate of this account".into()))?; 40 + 41 + if !caller_role.can_manage_members() { 42 + return Err(AppError::Forbidden( 43 + "only owners and admins can remove delegates".into(), 44 + )); 45 + } 46 + 47 + let target_role = db::get_delegate_role( 48 + &state.db, 49 + state.db_backend, 50 + &input.account_did, 51 + &input.user_did, 52 + ) 53 + .await? 54 + .ok_or_else(|| AppError::NotFound("user is not a delegate of this account".into()))?; 55 + 56 + if target_role == DelegateRole::Owner { 57 + return Err(AppError::Forbidden( 58 + "cannot remove the owner — use unlinkAccount instead".into(), 59 + )); 60 + } 61 + 62 + if caller_role == DelegateRole::Admin && target_role == DelegateRole::Admin { 63 + return Err(AppError::Forbidden( 64 + "admins cannot remove other admins — only the owner can".into(), 65 + )); 66 + } 67 + 68 + db::remove_delegate( 69 + &state.db, 70 + state.db_backend, 71 + &input.account_did, 72 + &input.user_did, 73 + ) 74 + .await?; 75 + 76 + log_event( 77 + &state.db, 78 + EventLog { 79 + event_type: "delegation.delegate_removed".to_string(), 80 + severity: Severity::Info, 81 + actor_did: Some(caller_did), 82 + subject: Some(input.user_did.clone()), 83 + detail: json!({ 84 + "account_did": input.account_did, 85 + }), 86 + }, 87 + state.db_backend, 88 + ) 89 + .await; 90 + 91 + Ok((StatusCode::OK, Json(json!({}))).into_response()) 92 + }
+1
src/lib.rs
··· 2 2 pub mod auth; 3 3 pub mod config; 4 4 pub mod db; 5 + pub mod delegation; 5 6 pub mod dev_happyview; 6 7 pub mod dns; 7 8 pub mod domain;
+35
src/lua/context.rs
··· 40 40 caller_did: &str, 41 41 collection: &str, 42 42 space: Option<&SpaceContext>, 43 + delegate_did: Option<&str>, 43 44 ) -> LuaResult<()> { 44 45 let globals = lua.globals(); 45 46 globals.set("method", method.to_string())?; ··· 47 48 globals.set("params", lua.to_value(params)?)?; 48 49 globals.set("caller_did", caller_did.to_string())?; 49 50 globals.set("collection", collection.to_string())?; 51 + match delegate_did { 52 + Some(did) => globals.set("delegate_did", did.to_string())?, 53 + None => globals.set("delegate_did", mlua::Value::Nil)?, 54 + } 50 55 set_space_context(lua, space)?; 51 56 Ok(()) 52 57 } ··· 151 156 "did:plc:test", 152 157 "com.example.thing", 153 158 None, 159 + None, 154 160 ) 155 161 .unwrap(); 156 162 ··· 164 170 globals.get::<String>("collection").unwrap(), 165 171 "com.example.thing" 166 172 ); 173 + assert!(globals.get::<mlua::Value>("delegate_did").unwrap().is_nil()); 167 174 168 175 let input_table: mlua::Table = globals.get("input").unwrap(); 169 176 assert_eq!(input_table.get::<String>("key").unwrap(), "val"); ··· 201 208 let params_table: mlua::Table = globals.get("params").unwrap(); 202 209 assert_eq!(params_table.get::<String>("limit").unwrap(), "10"); 203 210 assert_eq!(params_table.get::<String>("cursor").unwrap(), "abc"); 211 + } 212 + 213 + #[test] 214 + fn procedure_context_with_delegate_did() { 215 + let lua = create_sandbox().unwrap(); 216 + let input = json!({"key": "val"}); 217 + let params = HashMap::new(); 218 + set_procedure_context( 219 + &lua, 220 + "com.example.doThing", 221 + &input, 222 + &params, 223 + "did:plc:caller", 224 + "com.example.thing", 225 + None, 226 + Some("did:plc:delegate"), 227 + ) 228 + .unwrap(); 229 + 230 + let globals = lua.globals(); 231 + assert_eq!( 232 + globals.get::<String>("delegate_did").unwrap(), 233 + "did:plc:delegate" 234 + ); 235 + assert_eq!( 236 + globals.get::<String>("caller_did").unwrap(), 237 + "did:plc:caller" 238 + ); 204 239 } 205 240 206 241 #[test]
+9 -1
src/lua/execute.rs
··· 43 43 lexicon: &ParsedLexicon, 44 44 script: &str, 45 45 space_ctx: Option<&context::SpaceContext>, 46 + delegate_did: Option<&str>, 46 47 ) -> Result<Response, AppError> { 47 48 let start = Instant::now(); 48 49 let backend = state.db_backend; ··· 253 254 return Err(AppError::Internal(error_message)); 254 255 } 255 256 256 - if let Err(e) = record::register_record_api(&lua, state_arc, claims_arc, pds_auth_arc) { 257 + if let Err(e) = record::register_record_api( 258 + &lua, 259 + state_arc, 260 + claims_arc, 261 + pds_auth_arc, 262 + delegate_did.map(|s| s.to_string()), 263 + ) { 257 264 let error_message = format!("failed to register Record API: {e}"); 258 265 log_event( 259 266 &state.db, ··· 285 292 claims.did(), 286 293 collection, 287 294 space_ctx, 295 + delegate_did, 288 296 ) { 289 297 let error_message = format!("failed to set context: {e}"); 290 298 log_event(
+23 -3
src/lua/record.rs
··· 23 23 24 24 /// Register the `Record` global constructor and static methods. 25 25 /// Only registered for procedure scripts (not queries). 26 + /// 27 + /// When `delegate_did` is `Some`, record writes default to the delegate's repo 28 + /// instead of the caller's DID. Scripts can still override via `record:set_repo()`. 26 29 pub fn register_record_api( 27 30 lua: &Lua, 28 31 state: Arc<AppState>, 29 32 claims: Arc<Claims>, 30 33 pds_auth: Arc<PdsAuth>, 34 + delegate_did: Option<String>, 31 35 ) -> LuaResult<()> { 32 36 // -- methods table (shared by all Record instances) -- 33 37 let methods = lua.create_table()?; ··· 37 41 let state = state.clone(); 38 42 let claims = claims.clone(); 39 43 let pds_auth = pds_auth.clone(); 44 + let delegate_did = delegate_did.clone(); 40 45 let save_fn = lua.create_async_function(move |lua, this: mlua::Table| { 41 46 let state = state.clone(); 42 47 let claims = claims.clone(); 43 48 let pds_auth = pds_auth.clone(); 49 + let delegate_did = delegate_did.clone(); 44 50 async move { 45 51 let backend = state.db_backend; 46 52 let collection: String = this.raw_get("_collection")?; 47 53 let schema: mlua::Value = this.raw_get("_schema")?; 48 54 let repo_override: Option<String> = this.raw_get("_repo_override")?; 49 - let repo = repo_override.as_deref().unwrap_or_else(|| claims.did()); 55 + let repo = repo_override 56 + .as_deref() 57 + .or(delegate_did.as_deref()) 58 + .unwrap_or_else(|| claims.did()); 50 59 51 60 // Validate required fields against schema 52 61 if let mlua::Value::Table(ref schema_table) = schema { ··· 207 216 let state = state.clone(); 208 217 let claims = claims.clone(); 209 218 let pds_auth = pds_auth.clone(); 219 + let delegate_did = delegate_did.clone(); 210 220 let delete_fn = lua.create_async_function(move |_lua, this: mlua::Table| { 211 221 let state = state.clone(); 212 222 let claims = claims.clone(); 213 223 let pds_auth = pds_auth.clone(); 224 + let delegate_did = delegate_did.clone(); 214 225 async move { 215 226 let backend = state.db_backend; 216 227 let uri: String = this.raw_get::<Option<String>>("_uri")?.ok_or_else(|| { ··· 218 229 })?; 219 230 let collection: String = this.raw_get("_collection")?; 220 231 let repo_override: Option<String> = this.raw_get("_repo_override")?; 221 - let repo = repo_override.as_deref().unwrap_or_else(|| claims.did()); 232 + let repo = repo_override 233 + .as_deref() 234 + .or(delegate_did.as_deref()) 235 + .unwrap_or_else(|| claims.did()); 222 236 223 237 let rkey = uri 224 238 .split('/') ··· 439 453 let state = state.clone(); 440 454 let claims = claims.clone(); 441 455 let pds_auth = pds_auth.clone(); 456 + let delegate_did = delegate_did.clone(); 442 457 let save_all_fn = 443 458 lua.create_async_function(move |lua, records_table: mlua::Table| { 444 459 let state = state.clone(); 445 460 let claims = claims.clone(); 446 461 let pds_auth = pds_auth.clone(); 462 + let delegate_did = delegate_did.clone(); 447 463 async move { 448 464 let backend = state.db_backend; 449 465 // Extract save data from each record (sync) ··· 472 488 let state = state.clone(); 473 489 let claims = claims.clone(); 474 490 let pds_auth = pds_auth.clone(); 491 + let delegate_did = delegate_did.clone(); 475 492 let collection = collection.clone(); 476 493 let existing_uri = existing_uri.clone(); 477 494 let rkey = rkey.clone(); 478 495 let repo_override = repo_override.clone(); 479 496 let data = data.clone(); 480 497 async move { 481 - let repo = repo_override.as_deref().unwrap_or_else(|| claims.did()); 498 + let repo = repo_override 499 + .as_deref() 500 + .or(delegate_did.as_deref()) 501 + .unwrap_or_else(|| claims.did()); 482 502 if let Some(ref uri) = existing_uri { 483 503 let rkey = uri 484 504 .split('/')
+29
src/server.rs
··· 89 89 "/xrpc/dev.happyview.deleteApiClient", 90 90 post(crate::dev_happyview::delete_api_client), 91 91 ) 92 + // Delegation 93 + .route( 94 + "/xrpc/dev.happyview.delegation.linkAccount", 95 + post(crate::delegation::link_account::link_account), 96 + ) 97 + .route( 98 + "/xrpc/dev.happyview.delegation.unlinkAccount", 99 + post(crate::delegation::unlink_account::unlink_account), 100 + ) 101 + .route( 102 + "/xrpc/dev.happyview.delegation.addDelegate", 103 + post(crate::delegation::add_delegate::add_delegate), 104 + ) 105 + .route( 106 + "/xrpc/dev.happyview.delegation.removeDelegate", 107 + post(crate::delegation::remove_delegate::remove_delegate), 108 + ) 109 + .route( 110 + "/xrpc/dev.happyview.delegation.listAccounts", 111 + get(crate::delegation::list_accounts::list_accounts), 112 + ) 113 + .route( 114 + "/xrpc/dev.happyview.delegation.getAccount", 115 + get(crate::delegation::get_account::get_account), 116 + ) 117 + .route( 118 + "/xrpc/dev.happyview.delegation.listDelegates", 119 + get(crate::delegation::list_delegates::list_delegates), 120 + ) 92 121 // Catch-all for dynamically registered lexicons 93 122 .route("/xrpc/{method}", get(xrpc::xrpc_get).post(xrpc::xrpc_post)) 94 123 .route("/config", get(config_endpoint))
+114 -8
src/xrpc/procedure.rs
··· 19 19 lexicon: &crate::lexicon::ParsedLexicon, 20 20 ) -> Result<Response, AppError> { 21 21 if let Some(ref script) = lexicon.script { 22 + let delegate_did = input 23 + .get("delegateDid") 24 + .and_then(|v| v.as_str()) 25 + .map(|s| s.to_string()); 26 + 27 + if let Some(ref did) = delegate_did { 28 + let client_key = claims 29 + .client_key() 30 + .ok_or_else(|| AppError::Auth("delegation requires DPoP authentication".into()))?; 31 + let api_client_id = repo::get_dpop_client_id(state, client_key).await?; 32 + 33 + let role = crate::delegation::db::get_delegate_role( 34 + &state.db, 35 + state.db_backend, 36 + did, 37 + claims.did(), 38 + ) 39 + .await? 40 + .ok_or_else(|| AppError::Forbidden("you are not a delegate of this account".into()))?; 41 + 42 + if !role.can_write() { 43 + return Err(AppError::Forbidden( 44 + "your role does not have write access to this account".into(), 45 + )); 46 + } 47 + 48 + let stored_client_id = 49 + crate::delegation::db::get_api_client_id(&state.db, state.db_backend, did) 50 + .await? 51 + .ok_or_else(|| { 52 + AppError::Internal("delegated account missing api_client_id".into()) 53 + })?; 54 + 55 + if api_client_id != stored_client_id { 56 + return Err(AppError::Forbidden( 57 + "delegation is scoped to a different application".into(), 58 + )); 59 + } 60 + } 61 + 62 + let mut script_input = input.clone(); 63 + if let Some(obj) = script_input.as_object_mut() { 64 + obj.remove("delegateDid"); 65 + } 66 + 22 67 return crate::lua::execute_procedure_script( 23 - state, method, claims, input, params, lexicon, script, None, 68 + state, 69 + method, 70 + claims, 71 + &script_input, 72 + params, 73 + lexicon, 74 + script, 75 + None, 76 + delegate_did.as_deref(), 24 77 ) 25 78 .await; 26 79 } ··· 40 93 41 94 let api_client_id = repo::get_dpop_client_id(state, client_key).await?; 42 95 96 + let delegate_did = input 97 + .get("delegateDid") 98 + .and_then(|v| v.as_str()) 99 + .map(|s| s.to_string()); 100 + 43 101 return handle_dpop_procedure( 44 102 state, 45 103 claims, ··· 48 106 &lexicon.action, 49 107 &api_client_id, 50 108 encryption_key, 109 + delegate_did.as_deref(), 51 110 ) 52 111 .await; 53 112 } ··· 295 354 action: &ProcedureAction, 296 355 api_client_id: &str, 297 356 encryption_key: &[u8; 32], 357 + delegate_did: Option<&str>, 298 358 ) -> Result<Response, AppError> { 359 + // If delegating, verify the caller has write access and resolve the 360 + // api_client_id that owns the delegated session. 361 + let (target_did, effective_api_client_id) = if let Some(did) = delegate_did { 362 + let role = crate::delegation::db::get_delegate_role( 363 + &state.db, 364 + state.db_backend, 365 + did, 366 + claims.did(), 367 + ) 368 + .await? 369 + .ok_or_else(|| AppError::Forbidden("you are not a delegate of this account".into()))?; 370 + 371 + if !role.can_write() { 372 + return Err(AppError::Forbidden( 373 + "your role does not have write access to this account".into(), 374 + )); 375 + } 376 + 377 + let stored_client_id = 378 + crate::delegation::db::get_api_client_id(&state.db, state.db_backend, did) 379 + .await? 380 + .ok_or_else(|| { 381 + AppError::Internal("delegated account missing api_client_id".into()) 382 + })?; 383 + 384 + if api_client_id != stored_client_id { 385 + return Err(AppError::Forbidden( 386 + "delegation is scoped to a different application".into(), 387 + )); 388 + } 389 + 390 + (did, stored_client_id) 391 + } else { 392 + (claims.did(), api_client_id.to_string()) 393 + }; 394 + 395 + // Strip delegateDid from input — it's a control field, not record data 396 + let mut input = input.clone(); 397 + if let Some(obj) = input.as_object_mut() { 398 + obj.remove("delegateDid"); 399 + } 400 + 299 401 let (xrpc_method, pds_body) = match action { 300 402 ProcedureAction::Create => { 301 403 let mut record = input.clone(); 302 404 if let Some(obj) = record.as_object_mut() { 303 405 obj.insert("$type".to_string(), json!(collection)); 304 406 obj.remove("shouldPublish"); 407 + obj.remove("delegateDid"); 305 408 } 306 409 ( 307 410 "com.atproto.repo.createRecord", 308 411 json!({ 309 - "repo": claims.did(), 412 + "repo": target_did, 310 413 "collection": collection, 311 414 "record": record, 312 415 }), ··· 326 429 obj.insert("$type".to_string(), json!(collection)); 327 430 obj.remove("uri"); 328 431 obj.remove("shouldPublish"); 432 + obj.remove("delegateDid"); 329 433 } 330 434 ( 331 435 "com.atproto.repo.putRecord", 332 436 json!({ 333 - "repo": claims.did(), 437 + "repo": target_did, 334 438 "collection": collection, 335 439 "rkey": rkey, 336 440 "record": record, ··· 349 453 ( 350 454 "com.atproto.repo.deleteRecord", 351 455 json!({ 352 - "repo": claims.did(), 456 + "repo": target_did, 353 457 "collection": collection, 354 458 "rkey": rkey, 355 459 }), ··· 368 472 obj.insert("$type".to_string(), json!(collection)); 369 473 obj.remove("uri"); 370 474 obj.remove("shouldPublish"); 475 + obj.remove("delegateDid"); 371 476 } 372 477 ( 373 478 "com.atproto.repo.putRecord", 374 479 json!({ 375 - "repo": claims.did(), 480 + "repo": target_did, 376 481 "collection": collection, 377 482 "rkey": rkey, 378 483 "record": record, ··· 383 488 if let Some(obj) = record.as_object_mut() { 384 489 obj.insert("$type".to_string(), json!(collection)); 385 490 obj.remove("shouldPublish"); 491 + obj.remove("delegateDid"); 386 492 } 387 493 ( 388 494 "com.atproto.repo.createRecord", 389 495 json!({ 390 - "repo": claims.did(), 496 + "repo": target_did, 391 497 "collection": collection, 392 498 "record": record, 393 499 }), ··· 403 509 encryption_key, 404 510 &state.oauth, 405 511 &state.config.plc_url, 406 - api_client_id, 407 - claims.did(), 512 + &effective_api_client_id, 513 + target_did, 408 514 xrpc_method, 409 515 &pds_body, 410 516 )
+3 -1
tests/common/db.rs
··· 20 20 match backend { 21 21 DatabaseBackend::Postgres => { 22 22 sqlx::query( 23 - "TRUNCATE records, lexicons, backfill_jobs, users, user_permissions, api_keys, event_logs, script_variables, dead_letter_hooks, record_refs, labeler_subscriptions, labels, instance_settings, domains, dpop_sessions, dpop_keys, api_clients RESTART IDENTITY CASCADE", 23 + "TRUNCATE records, lexicons, backfill_jobs, users, user_permissions, api_keys, event_logs, script_variables, dead_letter_hooks, record_refs, labeler_subscriptions, labels, instance_settings, domains, dpop_sessions, dpop_keys, api_clients, delegated_accounts, account_delegates RESTART IDENTITY CASCADE", 24 24 ) 25 25 .execute(pool) 26 26 .await ··· 28 28 } 29 29 DatabaseBackend::Sqlite => { 30 30 let tables = [ 31 + "account_delegates", 32 + "delegated_accounts", 31 33 "dpop_sessions", 32 34 "dpop_keys", 33 35 "api_clients",
+1534
tests/e2e_delegation.rs
··· 1 + mod common; 2 + 3 + use axum::body::Body; 4 + use axum::http::{Request, StatusCode}; 5 + use happyview::db::adapt_sql; 6 + use happyview::oauth::pds_write::generate_dpop_proof; 7 + use http_body_util::BodyExt; 8 + use serde_json::{Value, json}; 9 + use serial_test::serial; 10 + use tower::ServiceExt; 11 + use wiremock::matchers::{method, path}; 12 + use wiremock::{Mock, ResponseTemplate}; 13 + 14 + // --------------------------------------------------------------------------- 15 + // Helpers 16 + // --------------------------------------------------------------------------- 17 + 18 + async fn response_json(resp: axum::response::Response) -> Value { 19 + let body = resp.into_body().collect().await.unwrap().to_bytes(); 20 + serde_json::from_slice(&body).unwrap_or(json!(null)) 21 + } 22 + 23 + fn post_json_with_headers(uri: &str, body: &Value, headers: Vec<(&str, &str)>) -> Request<Body> { 24 + let mut builder = Request::builder() 25 + .method("POST") 26 + .uri(uri) 27 + .header("content-type", "application/json") 28 + .header("host", "127.0.0.1:0"); 29 + for (name, value) in headers { 30 + builder = builder.header(name, value); 31 + } 32 + builder 33 + .body(Body::from(serde_json::to_vec(body).unwrap())) 34 + .unwrap() 35 + } 36 + 37 + fn get_with_headers(uri: &str, headers: Vec<(&str, &str)>) -> Request<Body> { 38 + let mut builder = Request::builder() 39 + .method("GET") 40 + .uri(uri) 41 + .header("host", "127.0.0.1:0"); 42 + for (name, value) in headers { 43 + builder = builder.header(name, value); 44 + } 45 + builder.body(Body::empty()).unwrap() 46 + } 47 + 48 + /// Set up a full DPoP session and return `(client_key, dpop_key, access_token)`. 49 + async fn setup_dpop_session(app: &common::app::TestApp, user_did: &str) -> (String, Value, String) { 50 + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; 51 + 52 + let key_req = post_json_with_headers( 53 + "/oauth/dpop-keys", 54 + &json!({}), 55 + vec![ 56 + ("x-client-key", &client_key), 57 + ("x-client-secret", &client_secret), 58 + ], 59 + ); 60 + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); 61 + assert_eq!(key_resp.status(), StatusCode::CREATED); 62 + let key_body = response_json(key_resp).await; 63 + let provision_id = key_body["provision_id"].as_str().unwrap().to_string(); 64 + let dpop_key = key_body["dpop_key"].clone(); 65 + 66 + let access_token = format!("test-access-{}", uuid::Uuid::new_v4()); 67 + let session_req = post_json_with_headers( 68 + "/oauth/sessions", 69 + &json!({ 70 + "provision_id": provision_id, 71 + "did": user_did, 72 + "access_token": &access_token, 73 + "scopes": "atproto", 74 + "pds_url": "https://pds.example.com", 75 + }), 76 + vec![ 77 + ("x-client-key", &client_key), 78 + ("x-client-secret", &client_secret), 79 + ], 80 + ); 81 + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); 82 + assert_eq!(session_resp.status(), StatusCode::CREATED); 83 + 84 + (client_key, dpop_key, access_token) 85 + } 86 + 87 + /// Build DPoP auth headers for a request. 88 + fn dpop_auth_headers<'a>( 89 + client_key: &'a str, 90 + dpop_key: &Value, 91 + access_token: &'a str, 92 + method: &str, 93 + url: &str, 94 + ) -> Vec<(&'static str, String)> { 95 + // DPoP htu must not include query/fragment 96 + let htu = url.split('?').next().unwrap_or(url); 97 + let proof = generate_dpop_proof(dpop_key, method, htu, access_token, None) 98 + .expect("failed to generate DPoP proof"); 99 + vec![ 100 + ("x-client-key", client_key.to_string()), 101 + ("authorization", format!("DPoP {}", access_token)), 102 + ("dpop", proof), 103 + ] 104 + } 105 + 106 + /// Make an authenticated POST request. 107 + async fn dpop_post( 108 + app: &common::app::TestApp, 109 + path: &str, 110 + body: &Value, 111 + client_key: &str, 112 + dpop_key: &Value, 113 + access_token: &str, 114 + ) -> axum::response::Response { 115 + let url = format!("http://127.0.0.1:0{}", path); 116 + let headers = dpop_auth_headers(client_key, dpop_key, access_token, "POST", &url); 117 + let str_headers: Vec<(&str, &str)> = headers.iter().map(|(k, v)| (*k, v.as_str())).collect(); 118 + let req = post_json_with_headers(path, body, str_headers); 119 + app.router.clone().oneshot(req).await.unwrap() 120 + } 121 + 122 + /// Make an authenticated GET request. 123 + async fn dpop_get( 124 + app: &common::app::TestApp, 125 + path: &str, 126 + client_key: &str, 127 + dpop_key: &Value, 128 + access_token: &str, 129 + ) -> axum::response::Response { 130 + let url = format!("http://127.0.0.1:0{}", path); 131 + let headers = dpop_auth_headers(client_key, dpop_key, access_token, "GET", &url); 132 + let str_headers: Vec<(&str, &str)> = headers.iter().map(|(k, v)| (*k, v.as_str())).collect(); 133 + let req = get_with_headers(path, str_headers); 134 + app.router.clone().oneshot(req).await.unwrap() 135 + } 136 + 137 + /// Register a DPoP session for a target DID using the same API client. 138 + /// This simulates the client completing OAuth for the target account. 139 + async fn register_target_session( 140 + app: &common::app::TestApp, 141 + client_key: &str, 142 + client_secret: &str, 143 + target_did: &str, 144 + ) { 145 + // Look up the client secret from the DB — we need it for the dpop-keys endpoint. 146 + // Actually, setup_dpop_session already provisions a key, but we need a separate session 147 + // for the target DID under the same api_client. 148 + // We can reuse the same provision_id (DPoP key) — register another session with a 149 + // different DID. 150 + 151 + // Provision a new DPoP key for this target session 152 + let key_req = post_json_with_headers( 153 + "/oauth/dpop-keys", 154 + &json!({}), 155 + vec![ 156 + ("x-client-key", client_key), 157 + ("x-client-secret", client_secret), 158 + ], 159 + ); 160 + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); 161 + assert_eq!(key_resp.status(), StatusCode::CREATED); 162 + let key_body = response_json(key_resp).await; 163 + let provision_id = key_body["provision_id"].as_str().unwrap().to_string(); 164 + 165 + let access_token = format!("test-target-access-{}", uuid::Uuid::new_v4()); 166 + let session_req = post_json_with_headers( 167 + "/oauth/sessions", 168 + &json!({ 169 + "provision_id": provision_id, 170 + "did": target_did, 171 + "access_token": &access_token, 172 + "scopes": "atproto", 173 + "pds_url": "https://pds.example.com", 174 + }), 175 + vec![ 176 + ("x-client-key", client_key), 177 + ("x-client-secret", client_secret), 178 + ], 179 + ); 180 + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); 181 + assert_eq!( 182 + session_resp.status(), 183 + StatusCode::CREATED, 184 + "failed to register target session" 185 + ); 186 + } 187 + 188 + /// Full setup: create an API client, register DPoP sessions for both the owner 189 + /// and the target account, then call linkAccount. 190 + /// Returns `(client_key, client_secret, dpop_key, access_token)`. 191 + async fn setup_linked_account( 192 + app: &common::app::TestApp, 193 + owner_did: &str, 194 + target_did: &str, 195 + ) -> (String, String, Value, String) { 196 + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; 197 + 198 + // Provision DPoP key + session for the owner 199 + let key_req = post_json_with_headers( 200 + "/oauth/dpop-keys", 201 + &json!({}), 202 + vec![ 203 + ("x-client-key", &client_key), 204 + ("x-client-secret", &client_secret), 205 + ], 206 + ); 207 + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); 208 + assert_eq!(key_resp.status(), StatusCode::CREATED); 209 + let key_body = response_json(key_resp).await; 210 + let provision_id = key_body["provision_id"].as_str().unwrap().to_string(); 211 + let dpop_key = key_body["dpop_key"].clone(); 212 + 213 + let access_token = format!("test-owner-access-{}", uuid::Uuid::new_v4()); 214 + let session_req = post_json_with_headers( 215 + "/oauth/sessions", 216 + &json!({ 217 + "provision_id": provision_id, 218 + "did": owner_did, 219 + "access_token": &access_token, 220 + "scopes": "atproto", 221 + "pds_url": "https://pds.example.com", 222 + }), 223 + vec![ 224 + ("x-client-key", &client_key), 225 + ("x-client-secret", &client_secret), 226 + ], 227 + ); 228 + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); 229 + assert_eq!(session_resp.status(), StatusCode::CREATED); 230 + 231 + // Register a session for the target account under the same API client 232 + register_target_session(app, &client_key, &client_secret, target_did).await; 233 + 234 + // Link the account 235 + let resp = dpop_post( 236 + app, 237 + "/xrpc/dev.happyview.delegation.linkAccount", 238 + &json!({ "did": target_did }), 239 + &client_key, 240 + &dpop_key, 241 + &access_token, 242 + ) 243 + .await; 244 + assert_eq!(resp.status(), StatusCode::CREATED, "linkAccount failed"); 245 + 246 + (client_key, client_secret, dpop_key, access_token) 247 + } 248 + 249 + /// Provision a DPoP session for a user under an existing API client. 250 + /// Returns `(dpop_key, access_token)` — use the shared `client_key` for requests. 251 + async fn setup_session_for_client( 252 + app: &common::app::TestApp, 253 + client_key: &str, 254 + client_secret: &str, 255 + user_did: &str, 256 + ) -> (Value, String) { 257 + let key_req = post_json_with_headers( 258 + "/oauth/dpop-keys", 259 + &json!({}), 260 + vec![ 261 + ("x-client-key", client_key), 262 + ("x-client-secret", client_secret), 263 + ], 264 + ); 265 + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); 266 + assert_eq!(key_resp.status(), StatusCode::CREATED); 267 + let key_body = response_json(key_resp).await; 268 + let provision_id = key_body["provision_id"].as_str().unwrap().to_string(); 269 + let dpop_key = key_body["dpop_key"].clone(); 270 + 271 + let access_token = format!("test-access-{}", uuid::Uuid::new_v4()); 272 + let session_req = post_json_with_headers( 273 + "/oauth/sessions", 274 + &json!({ 275 + "provision_id": provision_id, 276 + "did": user_did, 277 + "access_token": &access_token, 278 + "scopes": "atproto", 279 + "pds_url": "https://pds.example.com", 280 + }), 281 + vec![ 282 + ("x-client-key", client_key), 283 + ("x-client-secret", client_secret), 284 + ], 285 + ); 286 + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); 287 + assert_eq!(session_resp.status(), StatusCode::CREATED); 288 + 289 + (dpop_key, access_token) 290 + } 291 + 292 + // --------------------------------------------------------------------------- 293 + // linkAccount 294 + // --------------------------------------------------------------------------- 295 + 296 + #[tokio::test] 297 + #[serial] 298 + async fn link_account_success() { 299 + let app = common::app::TestApp::new_with_encryption().await; 300 + let owner_did = "did:plc:owner1"; 301 + let target_did = "did:plc:studio1"; 302 + 303 + let (client_key, _client_secret, dpop_key, access_token) = 304 + setup_linked_account(&app, owner_did, target_did).await; 305 + 306 + // Verify via getAccount 307 + let resp = dpop_get( 308 + &app, 309 + &format!( 310 + "/xrpc/dev.happyview.delegation.getAccount?did={}", 311 + target_did 312 + ), 313 + &client_key, 314 + &dpop_key, 315 + &access_token, 316 + ) 317 + .await; 318 + assert_eq!(resp.status(), StatusCode::OK); 319 + let body = response_json(resp).await; 320 + assert_eq!(body["did"], target_did); 321 + assert_eq!(body["role"], "owner"); 322 + assert_eq!(body["linkedBy"], owner_did); 323 + } 324 + 325 + #[tokio::test] 326 + #[serial] 327 + async fn link_account_already_linked() { 328 + let app = common::app::TestApp::new_with_encryption().await; 329 + let owner_did = "did:plc:owner2"; 330 + let target_did = "did:plc:studio2"; 331 + 332 + let (client_key, _client_secret, dpop_key, access_token) = 333 + setup_linked_account(&app, owner_did, target_did).await; 334 + 335 + // Try to link again 336 + let resp = dpop_post( 337 + &app, 338 + "/xrpc/dev.happyview.delegation.linkAccount", 339 + &json!({ "did": target_did }), 340 + &client_key, 341 + &dpop_key, 342 + &access_token, 343 + ) 344 + .await; 345 + assert_eq!(resp.status(), StatusCode::CONFLICT); 346 + } 347 + 348 + #[tokio::test] 349 + #[serial] 350 + async fn link_account_self_link_rejected() { 351 + let app = common::app::TestApp::new_with_encryption().await; 352 + let user_did = "did:plc:selflinker"; 353 + 354 + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; 355 + 356 + let resp = dpop_post( 357 + &app, 358 + "/xrpc/dev.happyview.delegation.linkAccount", 359 + &json!({ "did": user_did }), 360 + &client_key, 361 + &dpop_key, 362 + &access_token, 363 + ) 364 + .await; 365 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 366 + } 367 + 368 + #[tokio::test] 369 + #[serial] 370 + async fn link_account_no_session_for_target() { 371 + let app = common::app::TestApp::new_with_encryption().await; 372 + let owner_did = "did:plc:owner3"; 373 + let target_did = "did:plc:nosession"; 374 + 375 + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, owner_did).await; 376 + 377 + // Don't register a session for target_did — should fail 378 + let resp = dpop_post( 379 + &app, 380 + "/xrpc/dev.happyview.delegation.linkAccount", 381 + &json!({ "did": target_did }), 382 + &client_key, 383 + &dpop_key, 384 + &access_token, 385 + ) 386 + .await; 387 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 388 + } 389 + 390 + // --------------------------------------------------------------------------- 391 + // unlinkAccount 392 + // --------------------------------------------------------------------------- 393 + 394 + #[tokio::test] 395 + #[serial] 396 + async fn unlink_account_success() { 397 + let app = common::app::TestApp::new_with_encryption().await; 398 + let owner_did = "did:plc:unlink_owner"; 399 + let target_did = "did:plc:unlink_studio"; 400 + 401 + let (client_key, _client_secret, dpop_key, access_token) = 402 + setup_linked_account(&app, owner_did, target_did).await; 403 + 404 + let resp = dpop_post( 405 + &app, 406 + "/xrpc/dev.happyview.delegation.unlinkAccount", 407 + &json!({ "did": target_did }), 408 + &client_key, 409 + &dpop_key, 410 + &access_token, 411 + ) 412 + .await; 413 + assert_eq!(resp.status(), StatusCode::OK); 414 + 415 + // Verify account is gone 416 + let resp = dpop_get( 417 + &app, 418 + &format!( 419 + "/xrpc/dev.happyview.delegation.getAccount?did={}", 420 + target_did 421 + ), 422 + &client_key, 423 + &dpop_key, 424 + &access_token, 425 + ) 426 + .await; 427 + assert_eq!(resp.status(), StatusCode::NOT_FOUND); 428 + } 429 + 430 + #[tokio::test] 431 + #[serial] 432 + async fn unlink_account_non_owner_rejected() { 433 + let app = common::app::TestApp::new_with_encryption().await; 434 + let owner_did = "did:plc:unlink_owner2"; 435 + let admin_did = "did:plc:unlink_admin2"; 436 + let target_did = "did:plc:unlink_studio2"; 437 + 438 + let (owner_key, owner_secret, owner_dpop, owner_token) = 439 + setup_linked_account(&app, owner_did, target_did).await; 440 + 441 + // Add an admin 442 + let resp = dpop_post( 443 + &app, 444 + "/xrpc/dev.happyview.delegation.addDelegate", 445 + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), 446 + &owner_key, 447 + &owner_dpop, 448 + &owner_token, 449 + ) 450 + .await; 451 + assert_eq!(resp.status(), StatusCode::CREATED); 452 + 453 + // Admin tries to unlink — should be rejected (owner-only) 454 + let (admin_dpop, admin_token) = 455 + setup_session_for_client(&app, &owner_key, &owner_secret, admin_did).await; 456 + let resp = dpop_post( 457 + &app, 458 + "/xrpc/dev.happyview.delegation.unlinkAccount", 459 + &json!({ "did": target_did }), 460 + &owner_key, 461 + &admin_dpop, 462 + &admin_token, 463 + ) 464 + .await; 465 + assert_eq!(resp.status(), StatusCode::FORBIDDEN); 466 + } 467 + 468 + // --------------------------------------------------------------------------- 469 + // addDelegate 470 + // --------------------------------------------------------------------------- 471 + 472 + #[tokio::test] 473 + #[serial] 474 + async fn add_delegate_success() { 475 + let app = common::app::TestApp::new_with_encryption().await; 476 + let owner_did = "did:plc:add_owner"; 477 + let member_did = "did:plc:add_member"; 478 + let target_did = "did:plc:add_studio"; 479 + 480 + let (client_key, _client_secret, dpop_key, access_token) = 481 + setup_linked_account(&app, owner_did, target_did).await; 482 + 483 + let resp = dpop_post( 484 + &app, 485 + "/xrpc/dev.happyview.delegation.addDelegate", 486 + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), 487 + &client_key, 488 + &dpop_key, 489 + &access_token, 490 + ) 491 + .await; 492 + assert_eq!(resp.status(), StatusCode::CREATED); 493 + 494 + // Verify via listDelegates 495 + let resp = dpop_get( 496 + &app, 497 + &format!( 498 + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", 499 + target_did 500 + ), 501 + &client_key, 502 + &dpop_key, 503 + &access_token, 504 + ) 505 + .await; 506 + assert_eq!(resp.status(), StatusCode::OK); 507 + let body = response_json(resp).await; 508 + let delegates = body["delegates"].as_array().unwrap(); 509 + assert_eq!(delegates.len(), 2); // owner + member 510 + let member = delegates 511 + .iter() 512 + .find(|d| d["userDid"] == member_did) 513 + .unwrap(); 514 + assert_eq!(member["role"], "member"); 515 + assert_eq!(member["grantedBy"], owner_did); 516 + } 517 + 518 + #[tokio::test] 519 + #[serial] 520 + async fn add_delegate_owner_role_rejected() { 521 + let app = common::app::TestApp::new_with_encryption().await; 522 + let owner_did = "did:plc:add_owner2"; 523 + let target_did = "did:plc:add_studio2"; 524 + 525 + let (client_key, _client_secret, dpop_key, access_token) = 526 + setup_linked_account(&app, owner_did, target_did).await; 527 + 528 + let resp = dpop_post( 529 + &app, 530 + "/xrpc/dev.happyview.delegation.addDelegate", 531 + &json!({ "accountDid": target_did, "userDid": "did:plc:someone", "role": "owner" }), 532 + &client_key, 533 + &dpop_key, 534 + &access_token, 535 + ) 536 + .await; 537 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 538 + } 539 + 540 + #[tokio::test] 541 + #[serial] 542 + async fn add_delegate_already_exists() { 543 + let app = common::app::TestApp::new_with_encryption().await; 544 + let owner_did = "did:plc:add_owner3"; 545 + let member_did = "did:plc:add_member3"; 546 + let target_did = "did:plc:add_studio3"; 547 + 548 + let (client_key, _client_secret, dpop_key, access_token) = 549 + setup_linked_account(&app, owner_did, target_did).await; 550 + 551 + // Add member 552 + let resp = dpop_post( 553 + &app, 554 + "/xrpc/dev.happyview.delegation.addDelegate", 555 + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), 556 + &client_key, 557 + &dpop_key, 558 + &access_token, 559 + ) 560 + .await; 561 + assert_eq!(resp.status(), StatusCode::CREATED); 562 + 563 + // Try to add again 564 + let resp = dpop_post( 565 + &app, 566 + "/xrpc/dev.happyview.delegation.addDelegate", 567 + &json!({ "accountDid": target_did, "userDid": member_did, "role": "admin" }), 568 + &client_key, 569 + &dpop_key, 570 + &access_token, 571 + ) 572 + .await; 573 + assert_eq!(resp.status(), StatusCode::CONFLICT); 574 + } 575 + 576 + #[tokio::test] 577 + #[serial] 578 + async fn add_delegate_member_cannot_add() { 579 + let app = common::app::TestApp::new_with_encryption().await; 580 + let owner_did = "did:plc:add_owner4"; 581 + let member_did = "did:plc:add_member4"; 582 + let target_did = "did:plc:add_studio4"; 583 + 584 + let (owner_key, owner_secret, owner_dpop, owner_token) = 585 + setup_linked_account(&app, owner_did, target_did).await; 586 + 587 + // Add a member 588 + let resp = dpop_post( 589 + &app, 590 + "/xrpc/dev.happyview.delegation.addDelegate", 591 + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), 592 + &owner_key, 593 + &owner_dpop, 594 + &owner_token, 595 + ) 596 + .await; 597 + assert_eq!(resp.status(), StatusCode::CREATED); 598 + 599 + // Member tries to add another delegate — should fail (members can't manage) 600 + let (member_dpop, member_token) = 601 + setup_session_for_client(&app, &owner_key, &owner_secret, member_did).await; 602 + let resp = dpop_post( 603 + &app, 604 + "/xrpc/dev.happyview.delegation.addDelegate", 605 + &json!({ "accountDid": target_did, "userDid": "did:plc:someone", "role": "member" }), 606 + &owner_key, 607 + &member_dpop, 608 + &member_token, 609 + ) 610 + .await; 611 + assert_eq!(resp.status(), StatusCode::FORBIDDEN); 612 + } 613 + 614 + // --------------------------------------------------------------------------- 615 + // removeDelegate 616 + // --------------------------------------------------------------------------- 617 + 618 + #[tokio::test] 619 + #[serial] 620 + async fn remove_delegate_success() { 621 + let app = common::app::TestApp::new_with_encryption().await; 622 + let owner_did = "did:plc:rm_owner"; 623 + let member_did = "did:plc:rm_member"; 624 + let target_did = "did:plc:rm_studio"; 625 + 626 + let (client_key, _client_secret, dpop_key, access_token) = 627 + setup_linked_account(&app, owner_did, target_did).await; 628 + 629 + // Add then remove a member 630 + dpop_post( 631 + &app, 632 + "/xrpc/dev.happyview.delegation.addDelegate", 633 + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), 634 + &client_key, 635 + &dpop_key, 636 + &access_token, 637 + ) 638 + .await; 639 + 640 + let resp = dpop_post( 641 + &app, 642 + "/xrpc/dev.happyview.delegation.removeDelegate", 643 + &json!({ "accountDid": target_did, "userDid": member_did }), 644 + &client_key, 645 + &dpop_key, 646 + &access_token, 647 + ) 648 + .await; 649 + assert_eq!(resp.status(), StatusCode::OK); 650 + 651 + // Verify only owner remains 652 + let resp = dpop_get( 653 + &app, 654 + &format!( 655 + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", 656 + target_did 657 + ), 658 + &client_key, 659 + &dpop_key, 660 + &access_token, 661 + ) 662 + .await; 663 + let body = response_json(resp).await; 664 + let delegates = body["delegates"].as_array().unwrap(); 665 + assert_eq!(delegates.len(), 1); 666 + assert_eq!(delegates[0]["role"], "owner"); 667 + } 668 + 669 + #[tokio::test] 670 + #[serial] 671 + async fn remove_delegate_cannot_remove_owner() { 672 + let app = common::app::TestApp::new_with_encryption().await; 673 + let owner_did = "did:plc:rm_owner2"; 674 + let target_did = "did:plc:rm_studio2"; 675 + 676 + let (client_key, _client_secret, dpop_key, access_token) = 677 + setup_linked_account(&app, owner_did, target_did).await; 678 + 679 + let resp = dpop_post( 680 + &app, 681 + "/xrpc/dev.happyview.delegation.removeDelegate", 682 + &json!({ "accountDid": target_did, "userDid": owner_did }), 683 + &client_key, 684 + &dpop_key, 685 + &access_token, 686 + ) 687 + .await; 688 + assert_eq!(resp.status(), StatusCode::FORBIDDEN); 689 + } 690 + 691 + #[tokio::test] 692 + #[serial] 693 + async fn remove_delegate_admin_cannot_remove_admin() { 694 + let app = common::app::TestApp::new_with_encryption().await; 695 + let owner_did = "did:plc:rm_owner3"; 696 + let admin1_did = "did:plc:rm_admin3a"; 697 + let admin2_did = "did:plc:rm_admin3b"; 698 + let target_did = "did:plc:rm_studio3"; 699 + 700 + let (owner_key, owner_secret, owner_dpop, owner_token) = 701 + setup_linked_account(&app, owner_did, target_did).await; 702 + 703 + // Add two admins 704 + dpop_post( 705 + &app, 706 + "/xrpc/dev.happyview.delegation.addDelegate", 707 + &json!({ "accountDid": target_did, "userDid": admin1_did, "role": "admin" }), 708 + &owner_key, 709 + &owner_dpop, 710 + &owner_token, 711 + ) 712 + .await; 713 + dpop_post( 714 + &app, 715 + "/xrpc/dev.happyview.delegation.addDelegate", 716 + &json!({ "accountDid": target_did, "userDid": admin2_did, "role": "admin" }), 717 + &owner_key, 718 + &owner_dpop, 719 + &owner_token, 720 + ) 721 + .await; 722 + 723 + // Admin1 tries to remove admin2 724 + let (a1_dpop, a1_token) = 725 + setup_session_for_client(&app, &owner_key, &owner_secret, admin1_did).await; 726 + let resp = dpop_post( 727 + &app, 728 + "/xrpc/dev.happyview.delegation.removeDelegate", 729 + &json!({ "accountDid": target_did, "userDid": admin2_did }), 730 + &owner_key, 731 + &a1_dpop, 732 + &a1_token, 733 + ) 734 + .await; 735 + assert_eq!(resp.status(), StatusCode::FORBIDDEN); 736 + } 737 + 738 + // --------------------------------------------------------------------------- 739 + // listAccounts 740 + // --------------------------------------------------------------------------- 741 + 742 + #[tokio::test] 743 + #[serial] 744 + async fn list_accounts_returns_linked_accounts() { 745 + let app = common::app::TestApp::new_with_encryption().await; 746 + let owner_did = "did:plc:list_owner"; 747 + let studio1 = "did:plc:list_studio1"; 748 + let studio2 = "did:plc:list_studio2"; 749 + 750 + let (client_key, client_secret, dpop_key, access_token) = 751 + setup_linked_account(&app, owner_did, studio1).await; 752 + 753 + // Link a second account under the same API client 754 + register_target_session(&app, &client_key, &client_secret, studio2).await; 755 + let resp = dpop_post( 756 + &app, 757 + "/xrpc/dev.happyview.delegation.linkAccount", 758 + &json!({ "did": studio2 }), 759 + &client_key, 760 + &dpop_key, 761 + &access_token, 762 + ) 763 + .await; 764 + assert_eq!(resp.status(), StatusCode::CREATED); 765 + 766 + // List accounts — should include both 767 + let resp = dpop_get( 768 + &app, 769 + "/xrpc/dev.happyview.delegation.listAccounts", 770 + &client_key, 771 + &dpop_key, 772 + &access_token, 773 + ) 774 + .await; 775 + assert_eq!(resp.status(), StatusCode::OK); 776 + let body = response_json(resp).await; 777 + let accounts = body["accounts"].as_array().unwrap(); 778 + assert_eq!(accounts.len(), 2); 779 + } 780 + 781 + #[tokio::test] 782 + #[serial] 783 + async fn list_accounts_empty() { 784 + let app = common::app::TestApp::new_with_encryption().await; 785 + let user_did = "did:plc:no_accounts"; 786 + 787 + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; 788 + 789 + let resp = dpop_get( 790 + &app, 791 + "/xrpc/dev.happyview.delegation.listAccounts", 792 + &client_key, 793 + &dpop_key, 794 + &access_token, 795 + ) 796 + .await; 797 + assert_eq!(resp.status(), StatusCode::OK); 798 + let body = response_json(resp).await; 799 + let accounts = body["accounts"].as_array().unwrap(); 800 + assert!(accounts.is_empty()); 801 + } 802 + 803 + // --------------------------------------------------------------------------- 804 + // getAccount 805 + // --------------------------------------------------------------------------- 806 + 807 + #[tokio::test] 808 + #[serial] 809 + async fn get_account_not_a_delegate() { 810 + let app = common::app::TestApp::new_with_encryption().await; 811 + let owner_did = "did:plc:ga_owner"; 812 + let target_did = "did:plc:ga_studio"; 813 + 814 + let (owner_key, owner_secret, _owner_dpop, _owner_token) = 815 + setup_linked_account(&app, owner_did, target_did).await; 816 + 817 + // Different user (same app, but not a delegate) tries to get account details 818 + let outsider_did = "did:plc:ga_outsider"; 819 + let (out_dpop, out_token) = 820 + setup_session_for_client(&app, &owner_key, &owner_secret, outsider_did).await; 821 + let resp = dpop_get( 822 + &app, 823 + &format!( 824 + "/xrpc/dev.happyview.delegation.getAccount?did={}", 825 + target_did 826 + ), 827 + &owner_key, 828 + &out_dpop, 829 + &out_token, 830 + ) 831 + .await; 832 + assert_eq!(resp.status(), StatusCode::NOT_FOUND); 833 + } 834 + 835 + // --------------------------------------------------------------------------- 836 + // listDelegates 837 + // --------------------------------------------------------------------------- 838 + 839 + #[tokio::test] 840 + #[serial] 841 + async fn list_delegates_member_cannot_list() { 842 + let app = common::app::TestApp::new_with_encryption().await; 843 + let owner_did = "did:plc:ld_owner"; 844 + let member_did = "did:plc:ld_member"; 845 + let target_did = "did:plc:ld_studio"; 846 + 847 + let (owner_key, owner_secret, owner_dpop, owner_token) = 848 + setup_linked_account(&app, owner_did, target_did).await; 849 + 850 + // Add a member 851 + dpop_post( 852 + &app, 853 + "/xrpc/dev.happyview.delegation.addDelegate", 854 + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), 855 + &owner_key, 856 + &owner_dpop, 857 + &owner_token, 858 + ) 859 + .await; 860 + 861 + // Member tries to list delegates (same client, but member role can't list) 862 + let (member_dpop, member_token) = 863 + setup_session_for_client(&app, &owner_key, &owner_secret, member_did).await; 864 + let resp = dpop_get( 865 + &app, 866 + &format!( 867 + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", 868 + target_did 869 + ), 870 + &owner_key, 871 + &member_dpop, 872 + &member_token, 873 + ) 874 + .await; 875 + assert_eq!(resp.status(), StatusCode::FORBIDDEN); 876 + } 877 + 878 + // --------------------------------------------------------------------------- 879 + // DelegateRole unit tests (no TestApp needed) 880 + // --------------------------------------------------------------------------- 881 + 882 + #[test] 883 + fn delegate_role_from_str_roundtrip() { 884 + use happyview::delegation::DelegateRole; 885 + for role in &[ 886 + DelegateRole::Owner, 887 + DelegateRole::Admin, 888 + DelegateRole::Member, 889 + ] { 890 + let s = role.as_str(); 891 + assert_eq!(DelegateRole::from_str(s), Some(*role)); 892 + } 893 + assert_eq!(DelegateRole::from_str("invalid"), None); 894 + } 895 + 896 + #[test] 897 + fn delegate_role_can_write() { 898 + use happyview::delegation::DelegateRole; 899 + assert!(DelegateRole::Owner.can_write()); 900 + assert!(DelegateRole::Admin.can_write()); 901 + assert!(!DelegateRole::Member.can_write()); 902 + } 903 + 904 + #[test] 905 + fn delegate_role_can_manage_members() { 906 + use happyview::delegation::DelegateRole; 907 + assert!(DelegateRole::Owner.can_manage_members()); 908 + assert!(DelegateRole::Admin.can_manage_members()); 909 + assert!(!DelegateRole::Member.can_manage_members()); 910 + } 911 + 912 + // --------------------------------------------------------------------------- 913 + // Helpers for delegated write tests 914 + // --------------------------------------------------------------------------- 915 + 916 + fn admin_post_request( 917 + uri: &str, 918 + cookie: (axum::http::HeaderName, axum::http::HeaderValue), 919 + body: &Value, 920 + ) -> Request<Body> { 921 + Request::builder() 922 + .method("POST") 923 + .uri(uri) 924 + .header(cookie.0, cookie.1) 925 + .header("content-type", "application/json") 926 + .header("host", "127.0.0.1:0") 927 + .body(Body::from(serde_json::to_vec(body).unwrap())) 928 + .unwrap() 929 + } 930 + 931 + async fn seed_procedure_lexicon(app: &common::app::TestApp) { 932 + let resp = app 933 + .router 934 + .clone() 935 + .oneshot(admin_post_request( 936 + "/admin/lexicons", 937 + app.admin_cookie(), 938 + &json!({ 939 + "lexicon_json": common::fixtures::create_game_procedure_lexicon(), 940 + "target_collection": "games.gamesgamesgamesgames.game" 941 + }), 942 + )) 943 + .await 944 + .unwrap(); 945 + assert!( 946 + resp.status().is_success(), 947 + "failed to seed procedure lexicon: {}", 948 + resp.status() 949 + ); 950 + } 951 + 952 + async fn update_session_pds_url(app: &common::app::TestApp, user_did: &str, pds_url: &str) { 953 + let sql = adapt_sql( 954 + "UPDATE dpop_sessions SET pds_url = ? WHERE user_did = ?", 955 + app.state.db_backend, 956 + ); 957 + sqlx::query(&sql) 958 + .bind(pds_url) 959 + .bind(user_did) 960 + .execute(&app.state.db) 961 + .await 962 + .expect("failed to update session pds_url"); 963 + } 964 + 965 + // --------------------------------------------------------------------------- 966 + // Delegated writes — auth gates 967 + // --------------------------------------------------------------------------- 968 + 969 + #[tokio::test] 970 + #[serial] 971 + async fn delegated_write_non_delegate_rejected() { 972 + let app = common::app::TestApp::new_with_encryption().await; 973 + seed_procedure_lexicon(&app).await; 974 + 975 + let owner_did = "did:plc:dw_owner1"; 976 + let target_did = "did:plc:dw_studio1"; 977 + setup_linked_account(&app, owner_did, target_did).await; 978 + 979 + // Outsider (not a delegate) tries a delegated write 980 + let outsider_did = "did:plc:dw_outsider1"; 981 + let (out_key, out_dpop, out_token) = setup_dpop_session(&app, outsider_did).await; 982 + 983 + let resp = dpop_post( 984 + &app, 985 + "/xrpc/games.gamesgamesgamesgames.createGame", 986 + &json!({ "title": "Hacked Game", "delegateDid": target_did }), 987 + &out_key, 988 + &out_dpop, 989 + &out_token, 990 + ) 991 + .await; 992 + assert_eq!(resp.status(), StatusCode::FORBIDDEN); 993 + } 994 + 995 + #[tokio::test] 996 + #[serial] 997 + async fn delegated_write_member_rejected() { 998 + let app = common::app::TestApp::new_with_encryption().await; 999 + seed_procedure_lexicon(&app).await; 1000 + 1001 + let owner_did = "did:plc:dw_owner2"; 1002 + let member_did = "did:plc:dw_member2"; 1003 + let target_did = "did:plc:dw_studio2"; 1004 + 1005 + let (owner_key, owner_secret, owner_dpop, owner_token) = 1006 + setup_linked_account(&app, owner_did, target_did).await; 1007 + 1008 + // Add a member (cannot write) 1009 + let resp = dpop_post( 1010 + &app, 1011 + "/xrpc/dev.happyview.delegation.addDelegate", 1012 + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), 1013 + &owner_key, 1014 + &owner_dpop, 1015 + &owner_token, 1016 + ) 1017 + .await; 1018 + assert_eq!(resp.status(), StatusCode::CREATED); 1019 + 1020 + // Member tries a delegated write (same client, but member role can't write) 1021 + let (mem_dpop, mem_token) = 1022 + setup_session_for_client(&app, &owner_key, &owner_secret, member_did).await; 1023 + let resp = dpop_post( 1024 + &app, 1025 + "/xrpc/games.gamesgamesgamesgames.createGame", 1026 + &json!({ "title": "Member Game", "delegateDid": target_did }), 1027 + &owner_key, 1028 + &mem_dpop, 1029 + &mem_token, 1030 + ) 1031 + .await; 1032 + assert_eq!(resp.status(), StatusCode::FORBIDDEN); 1033 + } 1034 + 1035 + // --------------------------------------------------------------------------- 1036 + // Delegated writes — happy path 1037 + // --------------------------------------------------------------------------- 1038 + 1039 + #[tokio::test] 1040 + #[serial] 1041 + async fn delegated_write_owner_success() { 1042 + let app = common::app::TestApp::new_with_encryption().await; 1043 + seed_procedure_lexicon(&app).await; 1044 + 1045 + let owner_did = "did:plc:dw_owner3"; 1046 + let target_did = "did:plc:dw_studio3"; 1047 + 1048 + let (owner_key, _owner_secret, owner_dpop, owner_token) = 1049 + setup_linked_account(&app, owner_did, target_did).await; 1050 + 1051 + // Point the target's DPoP session at the mock server so dpop_pds_post 1052 + // reaches wiremock instead of a real PDS. 1053 + let mock_url = app.mock_server.uri(); 1054 + update_session_pds_url(&app, target_did, &mock_url).await; 1055 + 1056 + // Mock PDS createRecord 1057 + Mock::given(method("POST")) 1058 + .and(path("/xrpc/com.atproto.repo.createRecord")) 1059 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 1060 + "uri": format!("at://{target_did}/games.gamesgamesgamesgames.game/abc123"), 1061 + "cid": "bafytest123" 1062 + }))) 1063 + .expect(1) 1064 + .mount(&app.mock_server) 1065 + .await; 1066 + 1067 + let resp = dpop_post( 1068 + &app, 1069 + "/xrpc/games.gamesgamesgamesgames.createGame", 1070 + &json!({ "title": "Studio Game", "delegateDid": target_did }), 1071 + &owner_key, 1072 + &owner_dpop, 1073 + &owner_token, 1074 + ) 1075 + .await; 1076 + assert_eq!(resp.status(), StatusCode::OK); 1077 + 1078 + let body = response_json(resp).await; 1079 + assert_eq!( 1080 + body["uri"], 1081 + format!("at://{target_did}/games.gamesgamesgamesgames.game/abc123") 1082 + ); 1083 + assert_eq!(body["cid"], "bafytest123"); 1084 + } 1085 + 1086 + #[tokio::test] 1087 + #[serial] 1088 + async fn delegated_write_admin_success() { 1089 + let app = common::app::TestApp::new_with_encryption().await; 1090 + seed_procedure_lexicon(&app).await; 1091 + 1092 + let owner_did = "did:plc:dw_owner4"; 1093 + let admin_did = "did:plc:dw_admin4"; 1094 + let target_did = "did:plc:dw_studio4"; 1095 + 1096 + let (owner_key, owner_secret, owner_dpop, owner_token) = 1097 + setup_linked_account(&app, owner_did, target_did).await; 1098 + 1099 + // Add an admin 1100 + let resp = dpop_post( 1101 + &app, 1102 + "/xrpc/dev.happyview.delegation.addDelegate", 1103 + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), 1104 + &owner_key, 1105 + &owner_dpop, 1106 + &owner_token, 1107 + ) 1108 + .await; 1109 + assert_eq!(resp.status(), StatusCode::CREATED); 1110 + 1111 + // Point the target's DPoP session at the mock server 1112 + let mock_url = app.mock_server.uri(); 1113 + update_session_pds_url(&app, target_did, &mock_url).await; 1114 + 1115 + // Mock PDS createRecord 1116 + Mock::given(method("POST")) 1117 + .and(path("/xrpc/com.atproto.repo.createRecord")) 1118 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 1119 + "uri": format!("at://{target_did}/games.gamesgamesgamesgames.game/def456"), 1120 + "cid": "bafyadmin456" 1121 + }))) 1122 + .expect(1) 1123 + .mount(&app.mock_server) 1124 + .await; 1125 + 1126 + // Admin does a delegated write (same client) 1127 + let (adm_dpop, adm_token) = 1128 + setup_session_for_client(&app, &owner_key, &owner_secret, admin_did).await; 1129 + let resp = dpop_post( 1130 + &app, 1131 + "/xrpc/games.gamesgamesgamesgames.createGame", 1132 + &json!({ "title": "Admin Game", "delegateDid": target_did }), 1133 + &owner_key, 1134 + &adm_dpop, 1135 + &adm_token, 1136 + ) 1137 + .await; 1138 + assert_eq!(resp.status(), StatusCode::OK); 1139 + 1140 + let body = response_json(resp).await; 1141 + assert_eq!(body["cid"], "bafyadmin456"); 1142 + } 1143 + 1144 + // --------------------------------------------------------------------------- 1145 + // Positive-path coverage for admin / member operations 1146 + // --------------------------------------------------------------------------- 1147 + 1148 + #[tokio::test] 1149 + #[serial] 1150 + async fn admin_can_add_delegate() { 1151 + let app = common::app::TestApp::new_with_encryption().await; 1152 + 1153 + let owner_did = "did:plc:acd_owner"; 1154 + let admin_did = "did:plc:acd_admin"; 1155 + let new_member_did = "did:plc:acd_newmember"; 1156 + let target_did = "did:plc:acd_studio"; 1157 + 1158 + let (owner_key, owner_secret, owner_dpop, owner_token) = 1159 + setup_linked_account(&app, owner_did, target_did).await; 1160 + 1161 + // Owner adds admin 1162 + let resp = dpop_post( 1163 + &app, 1164 + "/xrpc/dev.happyview.delegation.addDelegate", 1165 + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), 1166 + &owner_key, 1167 + &owner_dpop, 1168 + &owner_token, 1169 + ) 1170 + .await; 1171 + assert_eq!(resp.status(), StatusCode::CREATED); 1172 + 1173 + // Admin adds a member (same client) 1174 + let (adm_dpop, adm_token) = 1175 + setup_session_for_client(&app, &owner_key, &owner_secret, admin_did).await; 1176 + let resp = dpop_post( 1177 + &app, 1178 + "/xrpc/dev.happyview.delegation.addDelegate", 1179 + &json!({ "accountDid": target_did, "userDid": new_member_did, "role": "member" }), 1180 + &owner_key, 1181 + &adm_dpop, 1182 + &adm_token, 1183 + ) 1184 + .await; 1185 + assert_eq!(resp.status(), StatusCode::CREATED); 1186 + 1187 + // Verify the member exists via listDelegates (as owner) 1188 + let resp = dpop_get( 1189 + &app, 1190 + &format!( 1191 + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", 1192 + target_did 1193 + ), 1194 + &owner_key, 1195 + &owner_dpop, 1196 + &owner_token, 1197 + ) 1198 + .await; 1199 + assert_eq!(resp.status(), StatusCode::OK); 1200 + let body = response_json(resp).await; 1201 + let delegates = body["delegates"].as_array().unwrap(); 1202 + assert_eq!(delegates.len(), 3); // owner + admin + member 1203 + let member = delegates 1204 + .iter() 1205 + .find(|d| d["userDid"] == new_member_did) 1206 + .unwrap(); 1207 + assert_eq!(member["role"], "member"); 1208 + assert_eq!(member["grantedBy"], admin_did); 1209 + } 1210 + 1211 + #[tokio::test] 1212 + #[serial] 1213 + async fn admin_can_remove_member() { 1214 + let app = common::app::TestApp::new_with_encryption().await; 1215 + 1216 + let owner_did = "did:plc:arm_owner"; 1217 + let admin_did = "did:plc:arm_admin"; 1218 + let member_did = "did:plc:arm_member"; 1219 + let target_did = "did:plc:arm_studio"; 1220 + 1221 + let (owner_key, owner_secret, owner_dpop, owner_token) = 1222 + setup_linked_account(&app, owner_did, target_did).await; 1223 + 1224 + // Owner adds admin and member 1225 + dpop_post( 1226 + &app, 1227 + "/xrpc/dev.happyview.delegation.addDelegate", 1228 + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), 1229 + &owner_key, 1230 + &owner_dpop, 1231 + &owner_token, 1232 + ) 1233 + .await; 1234 + dpop_post( 1235 + &app, 1236 + "/xrpc/dev.happyview.delegation.addDelegate", 1237 + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), 1238 + &owner_key, 1239 + &owner_dpop, 1240 + &owner_token, 1241 + ) 1242 + .await; 1243 + 1244 + // Admin removes member (same client) 1245 + let (adm_dpop, adm_token) = 1246 + setup_session_for_client(&app, &owner_key, &owner_secret, admin_did).await; 1247 + let resp = dpop_post( 1248 + &app, 1249 + "/xrpc/dev.happyview.delegation.removeDelegate", 1250 + &json!({ "accountDid": target_did, "userDid": member_did }), 1251 + &owner_key, 1252 + &adm_dpop, 1253 + &adm_token, 1254 + ) 1255 + .await; 1256 + assert_eq!(resp.status(), StatusCode::OK); 1257 + 1258 + // Verify member is gone 1259 + let resp = dpop_get( 1260 + &app, 1261 + &format!( 1262 + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", 1263 + target_did 1264 + ), 1265 + &owner_key, 1266 + &owner_dpop, 1267 + &owner_token, 1268 + ) 1269 + .await; 1270 + let body = response_json(resp).await; 1271 + let delegates = body["delegates"].as_array().unwrap(); 1272 + assert_eq!(delegates.len(), 2); // owner + admin only 1273 + assert!(delegates.iter().all(|d| d["userDid"] != member_did)); 1274 + } 1275 + 1276 + #[tokio::test] 1277 + #[serial] 1278 + async fn admin_can_list_delegates() { 1279 + let app = common::app::TestApp::new_with_encryption().await; 1280 + 1281 + let owner_did = "did:plc:ald_owner"; 1282 + let admin_did = "did:plc:ald_admin"; 1283 + let target_did = "did:plc:ald_studio"; 1284 + 1285 + let (owner_key, owner_secret, owner_dpop, owner_token) = 1286 + setup_linked_account(&app, owner_did, target_did).await; 1287 + 1288 + // Owner adds admin 1289 + dpop_post( 1290 + &app, 1291 + "/xrpc/dev.happyview.delegation.addDelegate", 1292 + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), 1293 + &owner_key, 1294 + &owner_dpop, 1295 + &owner_token, 1296 + ) 1297 + .await; 1298 + 1299 + // Admin lists delegates (same client) 1300 + let (adm_dpop, adm_token) = 1301 + setup_session_for_client(&app, &owner_key, &owner_secret, admin_did).await; 1302 + let resp = dpop_get( 1303 + &app, 1304 + &format!( 1305 + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", 1306 + target_did 1307 + ), 1308 + &owner_key, 1309 + &adm_dpop, 1310 + &adm_token, 1311 + ) 1312 + .await; 1313 + assert_eq!(resp.status(), StatusCode::OK); 1314 + let body = response_json(resp).await; 1315 + let delegates = body["delegates"].as_array().unwrap(); 1316 + assert_eq!(delegates.len(), 2); // owner + admin 1317 + } 1318 + 1319 + #[tokio::test] 1320 + #[serial] 1321 + async fn member_can_view_account() { 1322 + let app = common::app::TestApp::new_with_encryption().await; 1323 + 1324 + let owner_did = "did:plc:mva_owner"; 1325 + let member_did = "did:plc:mva_member"; 1326 + let target_did = "did:plc:mva_studio"; 1327 + 1328 + let (owner_key, owner_secret, owner_dpop, owner_token) = 1329 + setup_linked_account(&app, owner_did, target_did).await; 1330 + 1331 + // Owner adds member 1332 + dpop_post( 1333 + &app, 1334 + "/xrpc/dev.happyview.delegation.addDelegate", 1335 + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), 1336 + &owner_key, 1337 + &owner_dpop, 1338 + &owner_token, 1339 + ) 1340 + .await; 1341 + 1342 + // Member calls getAccount (same client) 1343 + let (mem_dpop, mem_token) = 1344 + setup_session_for_client(&app, &owner_key, &owner_secret, member_did).await; 1345 + let resp = dpop_get( 1346 + &app, 1347 + &format!( 1348 + "/xrpc/dev.happyview.delegation.getAccount?did={}", 1349 + target_did 1350 + ), 1351 + &owner_key, 1352 + &mem_dpop, 1353 + &mem_token, 1354 + ) 1355 + .await; 1356 + assert_eq!(resp.status(), StatusCode::OK); 1357 + let body = response_json(resp).await; 1358 + assert_eq!(body["did"], target_did); 1359 + assert_eq!(body["role"], "member"); 1360 + } 1361 + 1362 + #[tokio::test] 1363 + #[serial] 1364 + async fn owner_can_remove_admin() { 1365 + let app = common::app::TestApp::new_with_encryption().await; 1366 + 1367 + let owner_did = "did:plc:ora_owner"; 1368 + let admin_did = "did:plc:ora_admin"; 1369 + let target_did = "did:plc:ora_studio"; 1370 + 1371 + let (owner_key, _owner_secret, owner_dpop, owner_token) = 1372 + setup_linked_account(&app, owner_did, target_did).await; 1373 + 1374 + // Owner adds admin 1375 + dpop_post( 1376 + &app, 1377 + "/xrpc/dev.happyview.delegation.addDelegate", 1378 + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), 1379 + &owner_key, 1380 + &owner_dpop, 1381 + &owner_token, 1382 + ) 1383 + .await; 1384 + 1385 + // Owner removes admin 1386 + let resp = dpop_post( 1387 + &app, 1388 + "/xrpc/dev.happyview.delegation.removeDelegate", 1389 + &json!({ "accountDid": target_did, "userDid": admin_did }), 1390 + &owner_key, 1391 + &owner_dpop, 1392 + &owner_token, 1393 + ) 1394 + .await; 1395 + assert_eq!(resp.status(), StatusCode::OK); 1396 + 1397 + // Verify only owner remains 1398 + let resp = dpop_get( 1399 + &app, 1400 + &format!( 1401 + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", 1402 + target_did 1403 + ), 1404 + &owner_key, 1405 + &owner_dpop, 1406 + &owner_token, 1407 + ) 1408 + .await; 1409 + let body = response_json(resp).await; 1410 + let delegates = body["delegates"].as_array().unwrap(); 1411 + assert_eq!(delegates.len(), 1); 1412 + assert_eq!(delegates[0]["role"], "owner"); 1413 + } 1414 + 1415 + // --------------------------------------------------------------------------- 1416 + // Cross-client scoping — operations from a different API client are rejected 1417 + // --------------------------------------------------------------------------- 1418 + 1419 + #[tokio::test] 1420 + #[serial] 1421 + async fn cross_client_get_account_rejected() { 1422 + let app = common::app::TestApp::new_with_encryption().await; 1423 + let owner_did = "did:plc:xc_ga_owner"; 1424 + let target_did = "did:plc:xc_ga_studio"; 1425 + 1426 + setup_linked_account(&app, owner_did, target_did).await; 1427 + 1428 + // Different API client tries to access the account 1429 + let (other_key, other_dpop, other_token) = setup_dpop_session(&app, owner_did).await; 1430 + let resp = dpop_get( 1431 + &app, 1432 + &format!( 1433 + "/xrpc/dev.happyview.delegation.getAccount?did={}", 1434 + target_did 1435 + ), 1436 + &other_key, 1437 + &other_dpop, 1438 + &other_token, 1439 + ) 1440 + .await; 1441 + assert_eq!(resp.status(), StatusCode::FORBIDDEN); 1442 + } 1443 + 1444 + #[tokio::test] 1445 + #[serial] 1446 + async fn cross_client_add_delegate_rejected() { 1447 + let app = common::app::TestApp::new_with_encryption().await; 1448 + let owner_did = "did:plc:xc_ad_owner"; 1449 + let target_did = "did:plc:xc_ad_studio"; 1450 + 1451 + setup_linked_account(&app, owner_did, target_did).await; 1452 + 1453 + // Different API client tries to add a delegate 1454 + let (other_key, other_dpop, other_token) = setup_dpop_session(&app, owner_did).await; 1455 + let resp = dpop_post( 1456 + &app, 1457 + "/xrpc/dev.happyview.delegation.addDelegate", 1458 + &json!({ "accountDid": target_did, "userDid": "did:plc:xc_someone", "role": "member" }), 1459 + &other_key, 1460 + &other_dpop, 1461 + &other_token, 1462 + ) 1463 + .await; 1464 + assert_eq!(resp.status(), StatusCode::FORBIDDEN); 1465 + } 1466 + 1467 + #[tokio::test] 1468 + #[serial] 1469 + async fn cross_client_delegated_write_rejected() { 1470 + let app = common::app::TestApp::new_with_encryption().await; 1471 + seed_procedure_lexicon(&app).await; 1472 + 1473 + let owner_did = "did:plc:xc_dw_owner"; 1474 + let admin_did = "did:plc:xc_dw_admin"; 1475 + let target_did = "did:plc:xc_dw_studio"; 1476 + 1477 + let (owner_key, _owner_secret, owner_dpop, owner_token) = 1478 + setup_linked_account(&app, owner_did, target_did).await; 1479 + 1480 + // Add an admin under the correct client 1481 + dpop_post( 1482 + &app, 1483 + "/xrpc/dev.happyview.delegation.addDelegate", 1484 + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), 1485 + &owner_key, 1486 + &owner_dpop, 1487 + &owner_token, 1488 + ) 1489 + .await; 1490 + 1491 + // Admin authenticates via a different API client and tries a delegated write 1492 + let (other_key, other_dpop, other_token) = setup_dpop_session(&app, admin_did).await; 1493 + let resp = dpop_post( 1494 + &app, 1495 + "/xrpc/games.gamesgamesgamesgames.createGame", 1496 + &json!({ "title": "Cross-client Game", "delegateDid": target_did }), 1497 + &other_key, 1498 + &other_dpop, 1499 + &other_token, 1500 + ) 1501 + .await; 1502 + assert_eq!(resp.status(), StatusCode::FORBIDDEN); 1503 + } 1504 + 1505 + #[tokio::test] 1506 + #[serial] 1507 + async fn cross_client_list_accounts_isolated() { 1508 + let app = common::app::TestApp::new_with_encryption().await; 1509 + let owner_did = "did:plc:xc_la_owner"; 1510 + let studio1 = "did:plc:xc_la_studio1"; 1511 + let studio2 = "did:plc:xc_la_studio2"; 1512 + 1513 + // Link studio1 under client A 1514 + setup_linked_account(&app, owner_did, studio1).await; 1515 + 1516 + // Link studio2 under client B (different API client) 1517 + let (client_b_key, _client_b_secret, client_b_dpop, client_b_token) = 1518 + setup_linked_account(&app, owner_did, studio2).await; 1519 + 1520 + // listAccounts from client B should only show studio2 1521 + let resp = dpop_get( 1522 + &app, 1523 + "/xrpc/dev.happyview.delegation.listAccounts", 1524 + &client_b_key, 1525 + &client_b_dpop, 1526 + &client_b_token, 1527 + ) 1528 + .await; 1529 + assert_eq!(resp.status(), StatusCode::OK); 1530 + let body = response_json(resp).await; 1531 + let accounts = body["accounts"].as_array().unwrap(); 1532 + assert_eq!(accounts.len(), 1); 1533 + assert_eq!(accounts[0]["did"], studio2); 1534 + }