A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361use axum::extract::{Query, State};use axum::http::StatusCode;use axum::response::{IntoResponse, Response};use axum::routing::{get, post};use axum::{Json, Router};use serde::Deserialize;
use crate::AppState;use crate::auth::XrpcClaims;use crate::error::AppError;use crate::spaces::service;use crate::spaces::types::*;use crate::spaces::{SpaceUri, db, members};
// ---------------------------------------------------------------------------// Request / response types// ---------------------------------------------------------------------------
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]pub(crate) struct CreateSpaceInput { #[serde(rename = "type")] pub type_nsid: String, pub skey: String, pub display_name: Option<String>, pub description: Option<String>, pub mint_policy: Option<MintPolicy>, pub app_access: Option<AppAccess>, pub managing_app_did: Option<String>, pub config: Option<SpaceConfig>,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]pub(crate) struct SpaceUriQuery { pub space: String,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]pub(crate) struct DeleteSpaceInput { pub space: String,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]pub(crate) struct UpdateSpaceInput { pub space: String, pub display_name: Option<Option<String>>, pub description: Option<Option<String>>, pub mint_policy: Option<MintPolicy>, pub app_access: Option<AppAccess>, pub managing_app_did: Option<Option<String>>, pub config: Option<SpaceConfig>,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]pub(crate) struct AddMemberInput { pub space: String, pub did: String, pub access: Option<SpaceAccess>, pub is_delegation: Option<bool>,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]pub(crate) struct RemoveMemberInput { pub space: String, pub did: String,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]pub(crate) struct UpdateConfigInput { pub space: String, pub mint_policy: Option<MintPolicy>, pub app_access: Option<AppAccess>, pub managing_app: Option<Option<String>>,}
// ---------------------------------------------------------------------------// Route registration// ---------------------------------------------------------------------------
const NS: &str = "com.atproto";const LEGACY_NS: &str = "dev.happyview";
pub fn simplespace_routes() -> Router<AppState> { Router::new() // Management routes (com.atproto.simplespace.*) .route( &format!("/xrpc/{NS}.simplespace.createSpace"), post(create_space), ) .route( &format!("/xrpc/{NS}.simplespace.updateSpace"), post(update_space), ) .route( &format!("/xrpc/{NS}.simplespace.deleteSpace"), post(delete_space), ) .route( &format!("/xrpc/{NS}.simplespace.addMember"), post(add_member), ) .route( &format!("/xrpc/{NS}.simplespace.removeMember"), post(remove_member), ) .route( &format!("/xrpc/{NS}.simplespace.listMembers"), get(list_members), ) .route( &format!("/xrpc/{NS}.simplespace.getConfig"), get(get_config), ) .route( &format!("/xrpc/{NS}.simplespace.updateConfig"), post(update_config), ) // Backward-compatible aliases (dev.happyview.space.*) — kept until v3 .route( &format!("/xrpc/{LEGACY_NS}.space.createSpace"), post(create_space), ) .route( &format!("/xrpc/{LEGACY_NS}.space.updateSpace"), post(update_space), ) .route( &format!("/xrpc/{LEGACY_NS}.space.deleteSpace"), post(delete_space), ) .route( &format!("/xrpc/{LEGACY_NS}.space.addMember"), post(add_member), ) .route( &format!("/xrpc/{LEGACY_NS}.space.removeMember"), post(remove_member), ) .route( &format!("/xrpc/{LEGACY_NS}.space.listMembers"), get(list_members), )}
// ---------------------------------------------------------------------------// Helpers// ---------------------------------------------------------------------------
fn require_auth(claims: &XrpcClaims) -> Result<&crate::auth::Claims, AppError> { claims .identity .as_ref() .ok_or_else(|| AppError::Auth("This endpoint requires authentication".into()))}
async fn resolve_space(state: &AppState, space_uri: &str) -> Result<Space, AppError> { let uri = SpaceUri::parse(space_uri)?; db::get_space_by_address( &state.db, state.db_backend, &uri.did, &uri.type_nsid, &uri.skey, ) .await? .ok_or_else(|| AppError::NotFound("Space not found".into()))}
async fn require_space_admin(state: &AppState, space: &Space, did: &str) -> Result<(), AppError> { use crate::db::adapt_sql; if space.authority_did == did { return Ok(()); } let sql = adapt_sql( "SELECT is_super FROM happyview_users WHERE did = ?", state.db_backend, ); let row: Option<(i32,)> = crate::db::query_as(&sql) .bind(did) .fetch_optional(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to check admin status: {e}")))?; if row.is_some_and(|(is_super,)| is_super != 0) { return Ok(()); } Err(AppError::Forbidden( "Only the space authority can perform this action".into(), ))}
// ---------------------------------------------------------------------------// Space management handlers// ---------------------------------------------------------------------------
async fn create_space( State(state): State<AppState>, xrpc_claims: XrpcClaims, Json(input): Json<CreateSpaceInput>,) -> Result<Response, AppError> { let claims = require_auth(&xrpc_claims)?; let space = service::create_space( &state, claims.did(), &input.type_nsid, &input.skey, input.display_name, input.description, input.mint_policy, input.app_access, input.managing_app_did, input.config, ) .await?; let space_uri = format!( "at://{}/space/{}/{}", space.did, space.type_nsid, space.skey ); let mut response = Json(serde_json::json!({ "uri": space_uri })).into_response(); *response.status_mut() = StatusCode::CREATED; Ok(response)}
async fn delete_space( State(state): State<AppState>, xrpc_claims: XrpcClaims, Json(input): Json<DeleteSpaceInput>,) -> Result<Json<serde_json::Value>, AppError> { let claims = require_auth(&xrpc_claims)?; service::delete_space(&state, claims.did(), &input.space).await?; Ok(Json(serde_json::json!({ "success": true })))}
async fn update_space( State(state): State<AppState>, xrpc_claims: XrpcClaims, Json(input): Json<UpdateSpaceInput>,) -> Result<Json<serde_json::Value>, AppError> { let claims = require_auth(&xrpc_claims)?; let space = service::update_space( &state, claims.did(), &input.space, input.display_name, input.description, input.mint_policy, input.app_access, input.managing_app_did, input.config, ) .await?; let space_uri = format!( "at://{}/space/{}/{}", space.did, space.type_nsid, space.skey ); Ok(Json(serde_json::json!({ "uri": space_uri, "space": space, })))}
async fn list_members( State(state): State<AppState>, xrpc_claims: XrpcClaims, Query(query): Query<SpaceUriQuery>,) -> Result<Json<serde_json::Value>, AppError> { let space = resolve_space(&state, &query.space).await?;
if !space.config.membership_public { let claims = require_auth(&xrpc_claims)?; let member = members::is_member(&state.db, state.db_backend, &space.id, claims.did()).await?; member.ok_or_else(|| AppError::Forbidden("You are not a member of this space".into()))?; }
let resolved = members::resolve_members(&state.db, state.db_backend, &space.id).await?;
Ok(Json(serde_json::json!({ "members": resolved })))}
async fn add_member( State(state): State<AppState>, xrpc_claims: XrpcClaims, Json(input): Json<AddMemberInput>,) -> Result<Response, AppError> { let claims = require_auth(&xrpc_claims)?; let member = service::add_member( &state, claims.did(), &input.space, &input.did, input.access, input.is_delegation, ) .await?; let mut response = Json(serde_json::json!({ "member": member })).into_response(); *response.status_mut() = StatusCode::CREATED; Ok(response)}
async fn remove_member( State(state): State<AppState>, xrpc_claims: XrpcClaims, Json(input): Json<RemoveMemberInput>,) -> Result<Json<serde_json::Value>, AppError> { let claims = require_auth(&xrpc_claims)?; service::remove_member(&state, claims.did(), &input.space, &input.did).await?; Ok(Json(serde_json::json!({ "success": true })))}
async fn get_config( State(state): State<AppState>, xrpc_claims: XrpcClaims, Query(query): Query<SpaceUriQuery>,) -> Result<Json<serde_json::Value>, AppError> { let space = resolve_space(&state, &query.space).await?; let claims = require_auth(&xrpc_claims)?; require_space_admin(&state, &space, claims.did()).await?;
Ok(Json(serde_json::json!({ "$type": "com.atproto.simplespace.defs#spaceConfig", "mintPolicy": space.mint_policy, "appAccess": space.app_access, "managingApp": space.managing_app_did, })))}
async fn update_config( State(state): State<AppState>, xrpc_claims: XrpcClaims, Json(input): Json<UpdateConfigInput>,) -> Result<Json<serde_json::Value>, AppError> { let claims = require_auth(&xrpc_claims)?; let mut space = resolve_space(&state, &input.space).await?; require_space_admin(&state, &space, claims.did()).await?;
if let Some(policy) = input.mint_policy { space.mint_policy = policy; } if let Some(access) = input.app_access { space.app_access = access; } if let Some(managing_app) = input.managing_app { space.managing_app_did = managing_app; }
db::update_space(&state.db, state.db_backend, &space).await?;
Ok(Json(serde_json::json!({ "$type": "com.atproto.simplespace.defs#spaceConfig", "mintPolicy": space.mint_policy, "appAccess": space.app_access, "managingApp": space.managing_app_did, })))}