use 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, pub description: Option, pub mint_policy: Option, pub app_access: Option, pub managing_app_did: Option, pub config: Option, } #[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>, pub description: Option>, pub mint_policy: Option, pub app_access: Option, pub managing_app_did: Option>, pub config: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct AddMemberInput { pub space: String, pub did: String, pub access: Option, pub is_delegation: Option, } #[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, pub app_access: Option, pub managing_app: Option>, } // --------------------------------------------------------------------------- // Route registration // --------------------------------------------------------------------------- const NS: &str = "com.atproto"; const LEGACY_NS: &str = "dev.happyview"; pub fn simplespace_routes() -> Router { 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 { 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, xrpc_claims: XrpcClaims, Json(input): Json, ) -> Result { 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, xrpc_claims: XrpcClaims, Json(input): Json, ) -> Result, 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, xrpc_claims: XrpcClaims, Json(input): Json, ) -> Result, 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, xrpc_claims: XrpcClaims, Query(query): Query, ) -> Result, 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, xrpc_claims: XrpcClaims, Json(input): Json, ) -> Result { 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, xrpc_claims: XrpcClaims, Json(input): Json, ) -> Result, 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, xrpc_claims: XrpcClaims, Query(query): Query, ) -> Result, 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, xrpc_claims: XrpcClaims, Json(input): Json, ) -> Result, 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, }))) }