Something went wrong. Try again.
A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482use 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 uuid::Uuid;
use crate::AppState;use crate::auth::XrpcClaims;use crate::db::now_rfc3339;use crate::error::AppError;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 did = claims.did().to_string();
if input.type_nsid.is_empty() || input.skey.is_empty() { return Err(AppError::BadRequest("type and skey are required".into())); }
let existing = db::get_space_by_address( &state.db, state.db_backend, &did, &input.type_nsid, &input.skey, ) .await?; if existing.is_some() { return Err(AppError::Conflict( "A space with this address already exists".into(), )); }
// Optionally resolve the space type declaration from the lexicon registry. // If the type NSID maps to a stored space declaration, use its collections // as the default `allowed_collections` in the space config. let mut config = input.config.unwrap_or_default(); if let Some(decl) = state.lexicons.get_space_declaration(&input.type_nsid).await && let Some(collections) = decl.space_collections && !collections.is_empty() && !config.extra.contains_key("allowedCollections") { config.extra.insert( "allowedCollections".to_string(), serde_json::Value::Array( collections .into_iter() .map(serde_json::Value::String) .collect(), ), ); }
let space = Space { id: Uuid::new_v4().to_string(), did: did.clone(), authority_did: did.clone(), creator_did: did.clone(), type_nsid: input.type_nsid, skey: input.skey, display_name: input.display_name, description: input.description, mint_policy: input.mint_policy.unwrap_or(MintPolicy::MemberList), app_access: input.app_access.unwrap_or_default(), managing_app_did: input.managing_app_did, config, revision: None, created_at: now_rfc3339(), updated_at: now_rfc3339(), };
db::create_space(&state.db, state.db_backend, &space).await?;
// Auto-provision #atproto_space verification method if TOKEN_ENCRYPTION_KEY is available if let Some(encryption_key) = &state.config.token_encryption_key && let Err(e) = crate::verification_methods::ensure_atproto_space_method( &state.db, state.db_backend, encryption_key, ) .await { tracing::warn!("failed to auto-provision #atproto_space verification method: {e}"); }
let member = SpaceMember { id: Uuid::new_v4().to_string(), space_id: space.id.clone(), did: did.clone(), access: SpaceAccess::Write, is_delegation: false, granted_by: Some(did), created_at: now_rfc3339(), }; db::add_member(&state.db, state.db_backend, &member).await?;
let space_uri = format!( "at://{}/space/{}/{}", space.did, space.type_nsid, space.skey ); let body = serde_json::json!({ "uri": space_uri, });
let mut response = Json(body).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)?; let space = resolve_space(&state, &input.space).await?; require_space_admin(&state, &space, claims.did()).await?;
db::delete_space(&state.db, state.db_backend, &space.id).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 mut space = resolve_space(&state, &input.space).await?; require_space_admin(&state, &space, claims.did()).await?;
if let Some(name) = input.display_name { space.display_name = name; } if let Some(desc) = input.description { space.description = desc; } 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(did) = input.managing_app_did { space.managing_app_did = did; } if let Some(config) = input.config { space.config = config; }
db::update_space(&state.db, state.db_backend, &space).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 space = resolve_space(&state, &input.space).await?; require_space_admin(&state, &space, claims.did()).await?;
let existing = db::get_member(&state.db, state.db_backend, &space.id, &input.did).await?; if existing.is_some() { return Err(AppError::Conflict( "Member already exists in this space".into(), )); }
let member = SpaceMember { id: Uuid::new_v4().to_string(), space_id: space.id, did: input.did, access: input.access.unwrap_or(SpaceAccess::Read), is_delegation: input.is_delegation.unwrap_or(false), granted_by: Some(claims.did().to_string()), created_at: now_rfc3339(), };
db::add_member(&state.db, state.db_backend, &member).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)?; let space = resolve_space(&state, &input.space).await?; require_space_admin(&state, &space, claims.did()).await?;
// Revoke any outstanding space credentials before removing the membership // row. If revocation fails we bail out with the member still present, so a // retry can complete both steps — the reverse order could leave a removed // member holding valid credentials until their 2h TTL (M3). Revoking for a // non-member is a harmless no-op, so the NotFound check still gates on the // delete below. db::revoke_space_credentials_for_member(&state.db, state.db_backend, &space.id, &input.did) .await?;
let removed = db::remove_member(&state.db, state.db_backend, &space.id, &input.did).await?;
if !removed { return Err(AppError::NotFound("Member not found in this space".into())); }
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, })))}