Something went wrong. Try again.
A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469use 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 sha2::{Digest, Sha256};use uuid::Uuid;
use crate::AppState;use crate::auth::XrpcClaims;use crate::db::{adapt_sql, now_rfc3339};use crate::error::AppError;use crate::lua::tid::generate_tid;use crate::spaces::types::*;use crate::spaces::{SpaceUri, db, members};
// ---------------------------------------------------------------------------// Request / response types// ---------------------------------------------------------------------------
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct CreateSpaceInput { #[serde(rename = "type")] type_nsid: String, skey: String, display_name: Option<String>, description: Option<String>, access_mode: Option<AccessMode>, managing_app_did: Option<String>, config: Option<SpaceConfig>,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct SpaceUriQuery { space: String,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct ListSpacesQuery { did: Option<String>, limit: Option<i64>, cursor: Option<String>,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct DeleteSpaceInput { space: String,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct UpdateSpaceInput { space: String, display_name: Option<Option<String>>, description: Option<Option<String>>, access_mode: Option<AccessMode>, app_allowlist: Option<Option<Vec<String>>>, app_denylist: Option<Option<Vec<String>>>, managing_app_did: Option<Option<String>>, config: Option<SpaceConfig>,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct PutRecordInput { space: String, collection: String, rkey: String, record: serde_json::Value, swap_record: Option<String>,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct DeleteRecordInput { space: String, collection: String, rkey: String, swap_record: Option<String>,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct GetRecordQuery { space: String, collection: String, rkey: String,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct ListRecordsQuery { space: String, repo: Option<String>, collection: Option<String>, limit: Option<i64>, cursor: Option<String>, reverse: Option<bool>,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct AddMemberInput { space: String, did: String, access: Option<SpaceAccess>, is_delegation: Option<bool>,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct RemoveMemberInput { space: String, did: String,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct CreateInviteInput { space: String, access: Option<SpaceAccess>, max_uses: Option<i64>, expires_at: Option<String>,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct RedeemInviteInput { token: String,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct RevokeInviteInput { space: String, invite_id: String,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct GetMemberGrantInput { space: String,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct GetSpaceCredentialInput { grant: String,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct CreateRecordInput { space: String, collection: String, record: serde_json::Value,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct ApplyWritesInput { space: String, swap_commit: Option<String>, writes: Vec<WriteOp>,}
#[derive(Deserialize)]#[serde(tag = "action", rename_all = "camelCase")]enum WriteOp { Create { collection: String, rkey: Option<String>, value: serde_json::Value, }, Update { collection: String, rkey: String, value: serde_json::Value, #[serde(rename = "swapRecord")] swap_record: Option<String>, }, Delete { collection: String, rkey: String, #[serde(rename = "swapRecord")] swap_record: Option<String>, },}
// ---------------------------------------------------------------------------// Route registration// ---------------------------------------------------------------------------
const NS: &str = "dev.happyview";
pub fn space_routes() -> Router<AppState> { Router::new() // Space CRUD .route(&format!("/xrpc/{NS}.space.createSpace"), post(create_space)) .route(&format!("/xrpc/{NS}.space.getSpace"), get(get_space)) .route(&format!("/xrpc/{NS}.space.listSpaces"), get(list_spaces)) .route(&format!("/xrpc/{NS}.space.deleteSpace"), post(delete_space)) .route(&format!("/xrpc/{NS}.space.updateSpace"), post(update_space)) // Records .route( &format!("/xrpc/{NS}.space.createRecord"), post(create_record), ) .route(&format!("/xrpc/{NS}.space.putRecord"), post(put_record)) .route( &format!("/xrpc/{NS}.space.deleteRecord"), post(delete_record), ) .route(&format!("/xrpc/{NS}.space.applyWrites"), post(apply_writes)) .route(&format!("/xrpc/{NS}.space.getRecord"), get(get_record)) .route(&format!("/xrpc/{NS}.space.listRecords"), get(list_records)) // Members .route(&format!("/xrpc/{NS}.space.listMembers"), get(list_members)) .route(&format!("/xrpc/{NS}.space.addMember"), post(add_member)) .route( &format!("/xrpc/{NS}.space.removeMember"), post(remove_member), ) // Invites .route( &format!("/xrpc/{NS}.space.createInvite"), post(create_invite), ) .route( &format!("/xrpc/{NS}.space.redeemInvite"), post(redeem_invite), ) .route( &format!("/xrpc/{NS}.space.revokeInvite"), post(revoke_invite), ) .route(&format!("/xrpc/{NS}.space.listInvites"), get(list_invites)) // Credentials .route( &format!("/xrpc/{NS}.space.getMemberGrant"), post(get_member_grant), ) .route( &format!("/xrpc/{NS}.space.getSpaceCredential"), post(get_space_credential), ) // Legacy aliases (will be removed in a future release) .route(&format!("/xrpc/{NS}.space.create"), post(create_space)) .route(&format!("/xrpc/{NS}.space.get"), get(get_space)) .route(&format!("/xrpc/{NS}.space.list"), get(list_spaces)) .route(&format!("/xrpc/{NS}.space.delete"), post(delete_space)) .route(&format!("/xrpc/{NS}.space.update"), post(update_space)) .route( &format!("/xrpc/{NS}.space.invite.create"), post(create_invite), ) .route( &format!("/xrpc/{NS}.space.invite.redeem"), post(redeem_invite), ) .route( &format!("/xrpc/{NS}.space.invite.revoke"), post(revoke_invite), ) .route(&format!("/xrpc/{NS}.space.invite.list"), get(list_invites))}
// ---------------------------------------------------------------------------// 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()))}
/// Like `require_auth`, but also accepts a verified space credential as an/// identity source. Use this in space endpoints that support `Bearer/// <space_credential>` in addition to DPoP auth.async fn require_auth_or_credential( state: &AppState, claims: &XrpcClaims,) -> Result<String, AppError> { if let Some(identity) = &claims.identity { return Ok(identity.did().to_string()); }
if let Some(token) = &claims.space_credential { let verified = crate::spaces::credential::verify_external_credential( token, &state.http, &state.config.plc_url, ) .await?; return Ok(verified.sub); }
Err(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> { if space.owner_did == did { return Ok(()); } let sql = adapt_sql("SELECT is_super FROM users WHERE did = ?", state.db_backend); let row: Option<(i32,)> = sqlx::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 owner can perform this action".into(), ))}
async fn require_membership( state: &AppState, space: &Space, did: &str, require_write: bool, space_credential: Option<&str>,) -> Result<SpaceAccess, AppError> { if let Some(token) = space_credential { let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); match crate::spaces::credential::verify_external_credential( token, &state.http, &state.config.plc_url, ) .await { Ok(claims) if claims.space == space_uri => { let access = match claims.scope.as_str() { "write" => SpaceAccess::Write, _ => SpaceAccess::Read, }; if require_write && !access.can_write() { return Err(AppError::Forbidden( "Write access is required for this action".into(), )); } return Ok(access); } Ok(_) => { // Credential is valid but for a different space — fall through } Err(_) => { // External verification failed — fall through to local check } } }
let access = members::is_member(&state.db, state.db_backend, &space.id, did) .await? .ok_or_else(|| AppError::Forbidden("You are not a member of this space".into()))?; if require_write && !access.can_write() { return Err(AppError::Forbidden( "Write access is required for this action".into(), )); } Ok(access)}
fn content_cid(record: &serde_json::Value) -> String { let bytes = serde_json::to_vec(record).unwrap_or_default(); let hash = Sha256::digest(&bytes); format!("bafyrei{}", hex::encode(&hash[..20]))}
// ---------------------------------------------------------------------------// Space CRUD 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(), )); }
let space = Space { id: Uuid::new_v4().to_string(), did: did.clone(), owner_did: did.clone(), type_nsid: input.type_nsid, skey: input.skey, display_name: input.display_name, description: input.description, access_mode: input.access_mode.unwrap_or(AccessMode::DefaultAllow), app_allowlist: None, app_denylist: None, managing_app_did: input.managing_app_did, config: input.config.unwrap_or_default(), revision: None, created_at: now_rfc3339(), updated_at: now_rfc3339(), };
db::create_space(&state.db, state.db_backend, &space).await?;
// Auto-add the creator as a write member 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!("ats://{}/{}/{}", 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 get_space( 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 the space's membership is not public, require auth + membership if !space.config.membership_public { let claims = require_auth(&xrpc_claims)?; let did = claims.did(); if space.owner_did != did { members::is_member(&state.db, state.db_backend, &space.id, did) .await? .ok_or_else(|| AppError::NotFound("Space not found".into()))?; } }
let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); Ok(Json(serde_json::json!({ "uri": space_uri, "space": space, })))}
async fn list_spaces( State(state): State<AppState>, xrpc_claims: XrpcClaims, Query(query): Query<ListSpacesQuery>,) -> Result<Json<serde_json::Value>, AppError> { let claims = require_auth(&xrpc_claims)?; let did = query.did.unwrap_or_else(|| claims.did().to_string()); let limit = query.limit.unwrap_or(50).min(100);
let views = db::list_spaces_for_user( &state.db, state.db_backend, &did, limit, query.cursor.as_deref(), ) .await?;
let cursor = if views.len() as i64 == limit { views.last().map(|v| v.created_at.clone()) } else { None };
let spaces_json: Vec<serde_json::Value> = views .into_iter() .map(|v| { serde_json::json!({ "uri": v.uri, "isOwner": v.is_owner, }) }) .collect();
Ok(Json(serde_json::json!({ "spaces": spaces_json, "cursor": cursor, })))}
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(mode) = input.access_mode { space.access_mode = mode; } if let Some(list) = input.app_allowlist { space.app_allowlist = list; } if let Some(list) = input.app_denylist { space.app_denylist = list; } 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!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); Ok(Json(serde_json::json!({ "uri": space_uri, "space": space, })))}
// ---------------------------------------------------------------------------// Record handlers// ---------------------------------------------------------------------------
async fn create_record( State(state): State<AppState>, xrpc_claims: XrpcClaims, Json(input): Json<CreateRecordInput>,) -> Result<Response, AppError> { let did = require_auth_or_credential(&state, &xrpc_claims).await?; let space = resolve_space(&state, &input.space).await?; require_membership( &state, &space, &did, true, xrpc_claims.space_credential.as_deref(), ) .await?;
let rkey = generate_tid(); let cid = content_cid(&input.record); let record_uri = format!( "ats://{}/{}/{}/{}/{}/{}", space.did, space.type_nsid, space.skey, did, input.collection, rkey );
let record = SpaceRecord { uri: record_uri.clone(), space_id: space.id.clone(), author_did: did, collection: input.collection, rkey, record: input.record, cid: cid.clone(), indexed_at: now_rfc3339(), };
db::insert_space_record(&state.db, state.db_backend, &record).await?;
let rev = generate_tid(); db::update_space_revision(&state.db, state.db_backend, &space.id, &rev).await?;
let body = serde_json::json!({ "uri": record_uri, "cid": cid, });
let mut response = Json(body).into_response(); *response.status_mut() = StatusCode::CREATED; Ok(response)}
async fn put_record( State(state): State<AppState>, xrpc_claims: XrpcClaims, Json(input): Json<PutRecordInput>,) -> Result<Response, AppError> { let did = require_auth_or_credential(&state, &xrpc_claims).await?; let space = resolve_space(&state, &input.space).await?; require_membership( &state, &space, &did, true, xrpc_claims.space_credential.as_deref(), ) .await?;
let cid = content_cid(&input.record); let record_uri = format!( "ats://{}/{}/{}/{}/{}/{}", space.did, space.type_nsid, space.skey, did, input.collection, input.rkey );
let record = SpaceRecord { uri: record_uri.clone(), space_id: space.id.clone(), author_did: did, collection: input.collection, rkey: input.rkey, record: input.record, cid: cid.clone(), indexed_at: now_rfc3339(), };
if let Some(swap_cid) = input.swap_record { db::upsert_space_record_with_swap(&state.db, state.db_backend, &record, &swap_cid).await?; } else { db::upsert_space_record(&state.db, state.db_backend, &record).await?; }
let rev = generate_tid(); db::update_space_revision(&state.db, state.db_backend, &space.id, &rev).await?;
let body = serde_json::json!({ "uri": record_uri, "cid": cid, });
let mut response = Json(body).into_response(); *response.status_mut() = StatusCode::CREATED; Ok(response)}
async fn delete_record( State(state): State<AppState>, xrpc_claims: XrpcClaims, Json(input): Json<DeleteRecordInput>,) -> Result<Json<serde_json::Value>, AppError> { let claims = require_auth(&xrpc_claims)?; let did = claims.did().to_string(); let space = resolve_space(&state, &input.space).await?;
let record_uri = format!( "ats://{}/{}/{}/{}/{}/{}", space.did, space.type_nsid, space.skey, did, input.collection, input.rkey );
if let Some(swap_cid) = input.swap_record { db::delete_space_record_with_swap(&state.db, state.db_backend, &record_uri, &swap_cid) .await?; } else { let record = db::get_space_record(&state.db, state.db_backend, &record_uri).await?; match record { Some(r) if r.author_did != did => { return Err(AppError::Forbidden( "You can only delete your own records".into(), )); } None => { return Err(AppError::NotFound("Record not found".into())); } _ => {} } db::delete_space_record(&state.db, state.db_backend, &record_uri).await?; }
let rev = generate_tid(); db::update_space_revision(&state.db, state.db_backend, &space.id, &rev).await?;
Ok(Json(serde_json::json!({ "success": true })))}
async fn apply_writes( State(state): State<AppState>, xrpc_claims: XrpcClaims, Json(input): Json<ApplyWritesInput>,) -> Result<Json<serde_json::Value>, AppError> { let did = require_auth_or_credential(&state, &xrpc_claims).await?; let space = resolve_space(&state, &input.space).await?; require_membership( &state, &space, &did, true, xrpc_claims.space_credential.as_deref(), ) .await?;
if let Some(ref expected_rev) = input.swap_commit { match &space.revision { Some(current_rev) if current_rev != expected_rev => { return Err(AppError::Conflict("swapCommit mismatch".into())); } None if !expected_rev.is_empty() => { return Err(AppError::Conflict("swapCommit mismatch".into())); } _ => {} } }
let mut results = Vec::with_capacity(input.writes.len());
for op in input.writes { match op { WriteOp::Create { collection, rkey, value, } => { let rkey = rkey.unwrap_or_else(generate_tid); let cid = content_cid(&value); let record_uri = format!( "ats://{}/{}/{}/{}/{}/{}", space.did, space.type_nsid, space.skey, did, collection, rkey ); let record = SpaceRecord { uri: record_uri.clone(), space_id: space.id.clone(), author_did: did.clone(), collection, rkey, record: value, cid: cid.clone(), indexed_at: now_rfc3339(), }; db::insert_space_record(&state.db, state.db_backend, &record).await?; results.push(serde_json::json!({ "uri": record_uri, "cid": cid, })); } WriteOp::Update { collection, rkey, value, swap_record, } => { let cid = content_cid(&value); let record_uri = format!( "ats://{}/{}/{}/{}/{}/{}", space.did, space.type_nsid, space.skey, did, collection, rkey ); let record = SpaceRecord { uri: record_uri.clone(), space_id: space.id.clone(), author_did: did.clone(), collection, rkey, record: value, cid: cid.clone(), indexed_at: now_rfc3339(), }; if let Some(swap_cid) = swap_record { db::upsert_space_record_with_swap( &state.db, state.db_backend, &record, &swap_cid, ) .await?; } else { db::upsert_space_record(&state.db, state.db_backend, &record).await?; } results.push(serde_json::json!({ "uri": record_uri, "cid": cid, })); } WriteOp::Delete { collection, rkey, swap_record, } => { let record_uri = format!( "ats://{}/{}/{}/{}/{}/{}", space.did, space.type_nsid, space.skey, did, collection, rkey ); if let Some(swap_cid) = swap_record { db::delete_space_record_with_swap( &state.db, state.db_backend, &record_uri, &swap_cid, ) .await?; } else { db::delete_space_record(&state.db, state.db_backend, &record_uri).await?; } results.push(serde_json::json!({})); } } }
let rev = generate_tid(); db::update_space_revision(&state.db, state.db_backend, &space.id, &rev).await?;
Ok(Json(serde_json::json!({ "results": results, })))}
async fn get_record( State(state): State<AppState>, xrpc_claims: XrpcClaims, Query(query): Query<GetRecordQuery>,) -> Result<Json<serde_json::Value>, AppError> { let did = require_auth_or_credential(&state, &xrpc_claims).await?; let space = resolve_space(&state, &query.space).await?; require_membership( &state, &space, &did, false, xrpc_claims.space_credential.as_deref(), ) .await?;
let record = db::get_space_record_by_parts( &state.db, state.db_backend, &space.id, &query.collection, &query.rkey, ) .await? .ok_or_else(|| AppError::NotFound("Record not found".into()))?;
Ok(Json(serde_json::json!({ "uri": record.uri, "cid": record.cid, "value": record.record, })))}
async fn list_records( State(state): State<AppState>, xrpc_claims: XrpcClaims, Query(query): Query<ListRecordsQuery>,) -> Result<Json<serde_json::Value>, AppError> { let did = require_auth_or_credential(&state, &xrpc_claims).await?; let space = resolve_space(&state, &query.space).await?; require_membership( &state, &space, &did, false, xrpc_claims.space_credential.as_deref(), ) .await?;
let repo = query.repo.as_deref().or_else(|| { if xrpc_claims.space_credential.is_some() { None } else { Some(did.as_str()) } });
let limit = query.limit.unwrap_or(50).min(100); let reverse = query.reverse.unwrap_or(false); let records = db::list_space_records( &state.db, state.db_backend, &space.id, repo, query.collection.as_deref(), limit, query.cursor.as_deref(), reverse, ) .await?;
let cursor = records.last().map(|r| r.indexed_at.clone());
let records_json: Vec<serde_json::Value> = records .into_iter() .map(|r| { serde_json::json!({ "collection": r.collection, "rkey": r.rkey, "cid": r.cid, }) }) .collect();
Ok(Json(serde_json::json!({ "records": records_json, "cursor": cursor, })))}
// ---------------------------------------------------------------------------// Member handlers// ---------------------------------------------------------------------------
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 did = require_auth_or_credential(&state, &xrpc_claims).await?; require_membership( &state, &space, &did, false, xrpc_claims.space_credential.as_deref(), ) .await?; }
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?;
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 })))}
// ---------------------------------------------------------------------------// Invite handlers// ---------------------------------------------------------------------------
async fn create_invite( State(state): State<AppState>, xrpc_claims: XrpcClaims, Json(input): Json<CreateInviteInput>,) -> 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 mut token_bytes = [0u8; 24]; rand::Fill::fill(&mut token_bytes, &mut rand::rng()); let token = hex::encode(token_bytes); let token_hash = hex::encode(Sha256::digest(token.as_bytes()));
let invite = SpaceInvite { id: Uuid::new_v4().to_string(), space_id: space.id, token_hash, created_by: claims.did().to_string(), access: input.access.unwrap_or(SpaceAccess::Read), max_uses: input.max_uses, uses: 0, expires_at: input.expires_at, revoked: false, created_at: now_rfc3339(), };
db::create_invite(&state.db, state.db_backend, &invite).await?;
let mut response = Json(serde_json::json!({ "inviteId": invite.id, "token": token, "access": invite.access, "maxUses": invite.max_uses, "expiresAt": invite.expires_at, })) .into_response(); *response.status_mut() = StatusCode::CREATED; Ok(response)}
async fn redeem_invite( State(state): State<AppState>, xrpc_claims: XrpcClaims, Json(input): Json<RedeemInviteInput>,) -> Result<Response, AppError> { let claims = require_auth(&xrpc_claims)?; let did = claims.did().to_string();
let token_hash = hex::encode(Sha256::digest(input.token.as_bytes())); let invite = db::get_invite_by_token_hash(&state.db, state.db_backend, &token_hash) .await? .ok_or_else(|| AppError::NotFound("Invalid invite token".into()))?;
if invite.revoked { return Err(AppError::BadRequest("This invite has been revoked".into())); }
if let Some(max) = invite.max_uses && invite.uses >= max { return Err(AppError::BadRequest( "This invite has reached its maximum uses".into(), )); }
if let Some(ref expires) = invite.expires_at { let now = now_rfc3339(); if now > *expires { return Err(AppError::BadRequest("This invite has expired".into())); } }
let existing = db::get_member(&state.db, state.db_backend, &invite.space_id, &did).await?; if existing.is_some() { return Err(AppError::Conflict( "You are already a member of this space".into(), )); }
let member = SpaceMember { id: Uuid::new_v4().to_string(), space_id: invite.space_id.clone(), did, access: invite.access, is_delegation: false, granted_by: Some(invite.created_by.clone()), created_at: now_rfc3339(), };
db::add_member(&state.db, state.db_backend, &member).await?; db::increment_invite_uses(&state.db, state.db_backend, &invite.id).await?;
let space = db::get_space(&state.db, state.db_backend, &invite.space_id).await?; let space_uri = space.map(|s| format!("ats://{}/{}/{}", s.did, s.type_nsid, s.skey));
let mut response = Json(serde_json::json!({ "uri": space_uri, "access": member.access, })) .into_response(); *response.status_mut() = StatusCode::CREATED; Ok(response)}
async fn revoke_invite( State(state): State<AppState>, xrpc_claims: XrpcClaims, Json(input): Json<RevokeInviteInput>,) -> 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?;
let revoked = db::revoke_invite(&state.db, state.db_backend, &input.invite_id).await?; if !revoked { return Err(AppError::NotFound("Invite not found".into())); }
Ok(Json(serde_json::json!({ "success": true })))}
async fn list_invites( State(state): State<AppState>, xrpc_claims: XrpcClaims, Query(query): Query<SpaceUriQuery>,) -> Result<Json<serde_json::Value>, AppError> { let claims = require_auth(&xrpc_claims)?; let space = resolve_space(&state, &query.space).await?; require_space_admin(&state, &space, claims.did()).await?;
let invites = db::list_invites(&state.db, state.db_backend, &space.id).await?;
let invites_json: Vec<serde_json::Value> = invites .into_iter() .map(|i| { serde_json::json!({ "id": i.id, "access": i.access, "maxUses": i.max_uses, "uses": i.uses, "expiresAt": i.expires_at, "revoked": i.revoked, "createdBy": i.created_by, "createdAt": i.created_at, }) }) .collect();
Ok(Json(serde_json::json!({ "invites": invites_json })))}
// ---------------------------------------------------------------------------// Credential handlers// ---------------------------------------------------------------------------
async fn get_member_grant( State(state): State<AppState>, xrpc_claims: XrpcClaims, Json(input): Json<GetMemberGrantInput>,) -> Result<Json<serde_json::Value>, AppError> { let claims = require_auth(&xrpc_claims)?; let did = claims.did().to_string(); let space = resolve_space(&state, &input.space).await?;
require_membership(&state, &space, &did, false, None).await?;
let encryption_key = state.config.token_encryption_key.as_ref().ok_or_else(|| { AppError::Internal("TOKEN_ENCRYPTION_KEY is required for space credentials".into()) })?;
let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(); let exp = now + crate::spaces::credential::GRANT_TTL_SECS;
let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); let grant_claims = crate::spaces::credential::MemberGrantClaims { sub: did, space: space_uri, scope: "read".into(), iat: now, exp, };
let grant = crate::spaces::credential::sign_grant(&grant_claims, encryption_key)?;
let expires_at = chrono::DateTime::from_timestamp(exp as i64, 0) .map(|dt| dt.to_rfc3339()) .unwrap_or_default();
Ok(Json(serde_json::json!({ "grant": grant, "expiresAt": expires_at, })))}
async fn get_space_credential( State(state): State<AppState>, xrpc_claims: XrpcClaims, Json(input): Json<GetSpaceCredentialInput>,) -> Result<Json<serde_json::Value>, AppError> { let claims = require_auth(&xrpc_claims)?;
let encryption_key = state.config.token_encryption_key.as_ref().ok_or_else(|| { AppError::Internal("TOKEN_ENCRYPTION_KEY is required for space credentials".into()) })?;
let grant_claims = crate::spaces::credential::verify_grant(&input.grant, encryption_key)?;
let space = resolve_space(&state, &grant_claims.space).await?;
let client_id = claims.client_key().map(|k| k.to_string()); let issued = crate::spaces::auth::issue_credential( &state.db, state.db_backend, encryption_key, &space, &grant_claims.sub, client_id.as_deref(), ) .await?;
Ok(Json(serde_json::json!({ "credential": issued.token, "expiresAt": issued.expires_at, })))}
#[cfg(test)]mod tests { use super::*; use serde_json::json;
#[test] fn content_cid_deterministic() { let record = json!({"text": "hello"}); let cid1 = content_cid(&record); let cid2 = content_cid(&record); assert_eq!(cid1, cid2); assert!(cid1.starts_with("bafyrei")); }
#[test] fn content_cid_changes_for_different_records() { let a = content_cid(&json!({"text": "hello"})); let b = content_cid(&json!({"text": "world"})); assert_ne!(a, b); }
#[test] fn deserialize_create_record_input() { let input: CreateRecordInput = serde_json::from_value(json!({ "space": "ats://did:plc:abc/com.example.forum/main", "collection": "com.example.forum.post", "record": { "text": "hello" } })) .unwrap(); assert_eq!(input.space, "ats://did:plc:abc/com.example.forum/main"); assert_eq!(input.collection, "com.example.forum.post"); assert_eq!(input.record["text"], "hello"); }
#[test] fn deserialize_put_record_with_swap() { let input: PutRecordInput = serde_json::from_value(json!({ "space": "ats://did:plc:abc/com.example.forum/main", "collection": "com.example.forum.post", "rkey": "3k2abc", "record": { "text": "updated" }, "swapRecord": "bafyrei123" })) .unwrap(); assert_eq!(input.swap_record.as_deref(), Some("bafyrei123")); }
#[test] fn deserialize_put_record_without_swap() { let input: PutRecordInput = serde_json::from_value(json!({ "space": "ats://did:plc:abc/com.example.forum/main", "collection": "com.example.forum.post", "rkey": "3k2abc", "record": { "text": "hello" } })) .unwrap(); assert_eq!(input.swap_record, None); }
#[test] fn deserialize_delete_record_with_swap() { let input: DeleteRecordInput = serde_json::from_value(json!({ "space": "ats://did:plc:abc/com.example.forum/main", "collection": "com.example.forum.post", "rkey": "3k2abc", "swapRecord": "bafyrei456" })) .unwrap(); assert_eq!(input.swap_record.as_deref(), Some("bafyrei456")); }
#[test] fn deserialize_write_op_create() { let op: WriteOp = serde_json::from_value(json!({ "action": "create", "collection": "com.example.forum.post", "value": { "text": "new post" } })) .unwrap(); match op { WriteOp::Create { collection, rkey, value, } => { assert_eq!(collection, "com.example.forum.post"); assert_eq!(rkey, None); assert_eq!(value["text"], "new post"); } _ => panic!("expected Create"), } }
#[test] fn deserialize_write_op_create_with_rkey() { let op: WriteOp = serde_json::from_value(json!({ "action": "create", "collection": "com.example.forum.post", "rkey": "custom-key", "value": { "text": "new post" } })) .unwrap(); match op { WriteOp::Create { rkey, .. } => { assert_eq!(rkey.as_deref(), Some("custom-key")); } _ => panic!("expected Create"), } }
#[test] fn deserialize_write_op_update() { let op: WriteOp = serde_json::from_value(json!({ "action": "update", "collection": "com.example.forum.post", "rkey": "3k2abc", "value": { "text": "updated" }, "swapRecord": "bafyrei789" })) .unwrap(); match op { WriteOp::Update { collection, rkey, swap_record, .. } => { assert_eq!(collection, "com.example.forum.post"); assert_eq!(rkey, "3k2abc"); assert_eq!(swap_record.as_deref(), Some("bafyrei789")); } _ => panic!("expected Update"), } }
#[test] fn deserialize_write_op_delete() { let op: WriteOp = serde_json::from_value(json!({ "action": "delete", "collection": "com.example.forum.post", "rkey": "3k2abc" })) .unwrap(); match op { WriteOp::Delete { collection, rkey, swap_record, } => { assert_eq!(collection, "com.example.forum.post"); assert_eq!(rkey, "3k2abc"); assert_eq!(swap_record, None); } _ => panic!("expected Delete"), } }
#[test] fn deserialize_apply_writes_input() { let input: ApplyWritesInput = serde_json::from_value(json!({ "space": "ats://did:plc:abc/com.example.forum/main", "swapCommit": "tid123", "writes": [ { "action": "create", "collection": "com.example.forum.post", "value": { "text": "post 1" } }, { "action": "delete", "collection": "com.example.forum.post", "rkey": "old-key" } ] })) .unwrap(); assert_eq!(input.space, "ats://did:plc:abc/com.example.forum/main"); assert_eq!(input.swap_commit.as_deref(), Some("tid123")); assert_eq!(input.writes.len(), 2); }
#[test] fn deserialize_apply_writes_without_swap_commit() { let input: ApplyWritesInput = serde_json::from_value(json!({ "space": "ats://did:plc:abc/com.example.forum/main", "writes": [ { "action": "create", "collection": "com.example.forum.post", "value": { "text": "post" } } ] })) .unwrap(); assert_eq!(input.swap_commit, None); }
#[test] fn deserialize_write_op_rejects_unknown_action() { let result = serde_json::from_value::<WriteOp>(json!({ "action": "unknown", "collection": "test", "rkey": "key" })); assert!(result.is_err()); }}