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.

Merge branch 'permissioned-spaces' into dev

Trezy 8f9a4bac d9e8fc33

+7017 -29
+19
migrations/postgres/20260429000000_create_spaces.sql
··· 1 + CREATE TABLE IF NOT EXISTS spaces ( 2 + id TEXT PRIMARY KEY, 3 + owner_did TEXT NOT NULL, 4 + type_nsid TEXT NOT NULL, 5 + skey TEXT NOT NULL, 6 + display_name TEXT, 7 + description TEXT, 8 + access_mode TEXT NOT NULL DEFAULT 'default_allow', 9 + app_allowlist TEXT, 10 + app_denylist TEXT, 11 + managing_app_did TEXT, 12 + config TEXT NOT NULL DEFAULT '{}', 13 + created_at TEXT NOT NULL, 14 + updated_at TEXT NOT NULL, 15 + UNIQUE(owner_did, type_nsid, skey) 16 + ); 17 + 18 + CREATE INDEX idx_spaces_owner_did ON spaces(owner_did); 19 + CREATE INDEX idx_spaces_type_nsid ON spaces(type_nsid);
+13
migrations/postgres/20260429000001_create_space_members.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_members ( 2 + id TEXT PRIMARY KEY, 3 + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, 4 + member_did TEXT NOT NULL, 5 + access TEXT NOT NULL DEFAULT 'read', 6 + is_delegation INTEGER NOT NULL DEFAULT 0, 7 + granted_by TEXT, 8 + created_at TEXT NOT NULL, 9 + UNIQUE(space_id, member_did) 10 + ); 11 + 12 + CREATE INDEX idx_space_members_did ON space_members(member_did); 13 + CREATE INDEX idx_space_members_space_id ON space_members(space_id);
+14
migrations/postgres/20260429000002_create_space_records.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_records ( 2 + uri TEXT PRIMARY KEY, 3 + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, 4 + author_did TEXT NOT NULL, 5 + collection TEXT NOT NULL, 6 + rkey TEXT NOT NULL, 7 + record TEXT NOT NULL, 8 + cid TEXT NOT NULL, 9 + indexed_at TEXT NOT NULL 10 + ); 11 + 12 + CREATE INDEX idx_space_records_space_id ON space_records(space_id); 13 + CREATE INDEX idx_space_records_author ON space_records(author_did); 14 + CREATE INDEX idx_space_records_collection ON space_records(space_id, collection);
+10
migrations/postgres/20260429000003_create_space_credentials.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_credentials ( 2 + id TEXT PRIMARY KEY, 3 + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, 4 + issued_to TEXT NOT NULL, 5 + token_hash TEXT NOT NULL, 6 + expires_at TEXT NOT NULL, 7 + created_at TEXT NOT NULL 8 + ); 9 + 10 + CREATE INDEX idx_space_credentials_space_id ON space_credentials(space_id);
+14
migrations/postgres/20260429000004_create_space_invites.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_invites ( 2 + id TEXT PRIMARY KEY, 3 + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, 4 + token_hash TEXT NOT NULL UNIQUE, 5 + created_by TEXT NOT NULL, 6 + access TEXT NOT NULL DEFAULT 'read', 7 + max_uses INTEGER, 8 + uses INTEGER NOT NULL DEFAULT 0, 9 + expires_at TEXT, 10 + revoked INTEGER NOT NULL DEFAULT 0, 11 + created_at TEXT NOT NULL 12 + ); 13 + 14 + CREATE INDEX idx_space_invites_space_id ON space_invites(space_id);
+12
migrations/postgres/20260429000005_create_space_dids.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_dids ( 2 + id TEXT PRIMARY KEY, 3 + did TEXT NOT NULL UNIQUE, 4 + space_id TEXT REFERENCES spaces(id) ON DELETE SET NULL, 5 + signing_key_enc BYTEA NOT NULL, 6 + rotation_key_enc BYTEA NOT NULL, 7 + created_by TEXT NOT NULL, 8 + created_at TEXT NOT NULL 9 + ); 10 + 11 + CREATE INDEX idx_space_dids_did ON space_dids(did); 12 + CREATE INDEX idx_space_dids_space_id ON space_dids(space_id);
+12
migrations/postgres/20260429000006_create_space_sync_state.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_sync_state ( 2 + id TEXT PRIMARY KEY, 3 + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, 4 + member_did TEXT NOT NULL, 5 + cursor TEXT, 6 + last_synced_at TEXT, 7 + status TEXT NOT NULL DEFAULT 'pending', 8 + error TEXT, 9 + UNIQUE(space_id, member_did) 10 + ); 11 + 12 + CREATE INDEX idx_space_sync_state_space_id ON space_sync_state(space_id);
+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 + );
+19
migrations/sqlite/20260429000000_create_spaces.sql
··· 1 + CREATE TABLE IF NOT EXISTS spaces ( 2 + id TEXT PRIMARY KEY, 3 + owner_did TEXT NOT NULL, 4 + type_nsid TEXT NOT NULL, 5 + skey TEXT NOT NULL, 6 + display_name TEXT, 7 + description TEXT, 8 + access_mode TEXT NOT NULL DEFAULT 'default_allow', 9 + app_allowlist TEXT, 10 + app_denylist TEXT, 11 + managing_app_did TEXT, 12 + config TEXT NOT NULL DEFAULT '{}', 13 + created_at TEXT NOT NULL, 14 + updated_at TEXT NOT NULL, 15 + UNIQUE(owner_did, type_nsid, skey) 16 + ); 17 + 18 + CREATE INDEX idx_spaces_owner_did ON spaces(owner_did); 19 + CREATE INDEX idx_spaces_type_nsid ON spaces(type_nsid);
+13
migrations/sqlite/20260429000001_create_space_members.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_members ( 2 + id TEXT PRIMARY KEY, 3 + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, 4 + member_did TEXT NOT NULL, 5 + access TEXT NOT NULL DEFAULT 'read', 6 + is_delegation INTEGER NOT NULL DEFAULT 0, 7 + granted_by TEXT, 8 + created_at TEXT NOT NULL, 9 + UNIQUE(space_id, member_did) 10 + ); 11 + 12 + CREATE INDEX idx_space_members_did ON space_members(member_did); 13 + CREATE INDEX idx_space_members_space_id ON space_members(space_id);
+14
migrations/sqlite/20260429000002_create_space_records.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_records ( 2 + uri TEXT PRIMARY KEY, 3 + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, 4 + author_did TEXT NOT NULL, 5 + collection TEXT NOT NULL, 6 + rkey TEXT NOT NULL, 7 + record TEXT NOT NULL, 8 + cid TEXT NOT NULL, 9 + indexed_at TEXT NOT NULL 10 + ); 11 + 12 + CREATE INDEX idx_space_records_space_id ON space_records(space_id); 13 + CREATE INDEX idx_space_records_author ON space_records(author_did); 14 + CREATE INDEX idx_space_records_collection ON space_records(space_id, collection);
+10
migrations/sqlite/20260429000003_create_space_credentials.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_credentials ( 2 + id TEXT PRIMARY KEY, 3 + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, 4 + issued_to TEXT NOT NULL, 5 + token_hash TEXT NOT NULL, 6 + expires_at TEXT NOT NULL, 7 + created_at TEXT NOT NULL 8 + ); 9 + 10 + CREATE INDEX idx_space_credentials_space_id ON space_credentials(space_id);
+14
migrations/sqlite/20260429000004_create_space_invites.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_invites ( 2 + id TEXT PRIMARY KEY, 3 + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, 4 + token_hash TEXT NOT NULL UNIQUE, 5 + created_by TEXT NOT NULL, 6 + access TEXT NOT NULL DEFAULT 'read', 7 + max_uses INTEGER, 8 + uses INTEGER NOT NULL DEFAULT 0, 9 + expires_at TEXT, 10 + revoked INTEGER NOT NULL DEFAULT 0, 11 + created_at TEXT NOT NULL 12 + ); 13 + 14 + CREATE INDEX idx_space_invites_space_id ON space_invites(space_id);
+12
migrations/sqlite/20260429000005_create_space_dids.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_dids ( 2 + id TEXT PRIMARY KEY, 3 + did TEXT NOT NULL UNIQUE, 4 + space_id TEXT REFERENCES spaces(id) ON DELETE SET NULL, 5 + signing_key_enc BLOB NOT NULL, 6 + rotation_key_enc BLOB NOT NULL, 7 + created_by TEXT NOT NULL, 8 + created_at TEXT NOT NULL 9 + ); 10 + 11 + CREATE INDEX idx_space_dids_did ON space_dids(did); 12 + CREATE INDEX idx_space_dids_space_id ON space_dids(space_id);
+12
migrations/sqlite/20260429000006_create_space_sync_state.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_sync_state ( 2 + id TEXT PRIMARY KEY, 3 + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, 4 + member_did TEXT NOT NULL, 5 + cursor TEXT, 6 + last_synced_at TEXT, 7 + status TEXT NOT NULL DEFAULT 'pending', 8 + error TEXT, 9 + UNIQUE(space_id, member_did) 10 + ); 11 + 12 + CREATE INDEX idx_space_sync_state_space_id ON space_sync_state(space_id);
+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 + );
+18
src/admin/feature_flags.rs
··· 1 + use axum::Json; 2 + use axum::extract::State; 3 + 4 + use crate::AppState; 5 + use crate::error::AppError; 6 + use crate::feature_flags; 7 + 8 + use super::auth::UserAuth; 9 + use super::permissions::Permission; 10 + 11 + pub(super) async fn list( 12 + State(state): State<AppState>, 13 + auth: UserAuth, 14 + ) -> Result<Json<Vec<feature_flags::FeatureFlagStatus>>, AppError> { 15 + auth.require(Permission::SettingsManage).await?; 16 + let flags = feature_flags::list_flags(&state.db, state.db_backend).await; 17 + Ok(Json(flags)) 18 + }
+2
src/admin/mod.rs
··· 5 5 mod dead_letters; 6 6 mod domains; 7 7 mod events; 8 + mod feature_flags; 8 9 mod labelers; 9 10 mod lexicons; 10 11 mod network_lexicons; ··· 72 73 "/labelers/{did}", 73 74 patch(labelers::update).delete(labelers::delete), 74 75 ) 76 + .route("/feature-flags", get(feature_flags::list)) 75 77 .route("/settings", get(settings::list)) 76 78 .route( 77 79 "/settings/logo",
+42 -1
src/admin/permissions.rs
··· 2 2 3 3 use serde::{Deserialize, Serialize}; 4 4 5 - /// All 29 permissions in the system. 5 + /// All 37 permissions in the system. 6 6 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 7 7 pub enum Permission { 8 8 #[serde(rename = "lexicons:create")] ··· 83 83 DeadLettersRead, 84 84 #[serde(rename = "dead-letters:manage")] 85 85 DeadLettersManage, 86 + 87 + #[serde(rename = "spaces:create")] 88 + SpacesCreate, 89 + #[serde(rename = "spaces:read")] 90 + SpacesRead, 91 + #[serde(rename = "spaces:update")] 92 + SpacesUpdate, 93 + #[serde(rename = "spaces:delete")] 94 + SpacesDelete, 95 + #[serde(rename = "spaces:manage-members")] 96 + SpacesManageMembers, 97 + #[serde(rename = "spaces:manage-invites")] 98 + SpacesManageInvites, 99 + #[serde(rename = "spaces:manage-records")] 100 + SpacesManageRecords, 101 + #[serde(rename = "spaces:manage-credentials")] 102 + SpacesManageCredentials, 86 103 } 87 104 88 105 impl Permission { ··· 122 139 Self::ApiClientsDelete => "api-clients:delete", 123 140 Self::DeadLettersRead => "dead-letters:read", 124 141 Self::DeadLettersManage => "dead-letters:manage", 142 + Self::SpacesCreate => "spaces:create", 143 + Self::SpacesRead => "spaces:read", 144 + Self::SpacesUpdate => "spaces:update", 145 + Self::SpacesDelete => "spaces:delete", 146 + Self::SpacesManageMembers => "spaces:manage-members", 147 + Self::SpacesManageInvites => "spaces:manage-invites", 148 + Self::SpacesManageRecords => "spaces:manage-records", 149 + Self::SpacesManageCredentials => "spaces:manage-credentials", 125 150 } 126 151 } 127 152 ··· 161 186 Self::ApiClientsDelete, 162 187 Self::DeadLettersRead, 163 188 Self::DeadLettersManage, 189 + Self::SpacesCreate, 190 + Self::SpacesRead, 191 + Self::SpacesUpdate, 192 + Self::SpacesDelete, 193 + Self::SpacesManageMembers, 194 + Self::SpacesManageInvites, 195 + Self::SpacesManageRecords, 196 + Self::SpacesManageCredentials, 164 197 ]) 165 198 } 166 199 } ··· 215 248 perms.insert(Permission::ApiClientsCreate); 216 249 perms.insert(Permission::ApiClientsEdit); 217 250 perms.insert(Permission::ApiClientsDelete); 251 + perms.insert(Permission::SpacesCreate); 252 + perms.insert(Permission::SpacesRead); 253 + perms.insert(Permission::SpacesUpdate); 254 + perms.insert(Permission::SpacesDelete); 255 + perms.insert(Permission::SpacesManageMembers); 256 + perms.insert(Permission::SpacesManageInvites); 257 + perms.insert(Permission::SpacesManageRecords); 258 + perms.insert(Permission::SpacesManageCredentials); 218 259 perms 219 260 } 220 261 Self::FullAccess => Permission::all(),
+1
src/admin/settings.rs
··· 17 17 const ENV_FALLBACKS: &[(&str, &str)] = &[ 18 18 ("app_name", "APP_NAME"), 19 19 ("client_uri", "CLIENT_URI"), 20 + ("feature.spaces_enabled", "FEATURE_SPACES_ENABLED"), 20 21 ("logo_uri", "LOGO_URI"), 21 22 ("tos_uri", "TOS_URI"), 22 23 ("policy_uri", "POLICY_URI"),
+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 + }
+92
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 + #[allow(clippy::should_implement_trait)] 46 + pub fn from_str(s: &str) -> Option<Self> { 47 + match s { 48 + "owner" => Some(DelegateRole::Owner), 49 + "admin" => Some(DelegateRole::Admin), 50 + "member" => Some(DelegateRole::Member), 51 + _ => None, 52 + } 53 + } 54 + 55 + pub fn can_write(&self) -> bool { 56 + matches!(self, DelegateRole::Owner | DelegateRole::Admin) 57 + } 58 + 59 + pub fn can_manage_members(&self) -> bool { 60 + matches!(self, DelegateRole::Owner | DelegateRole::Admin) 61 + } 62 + } 63 + 64 + pub(crate) async fn resolve_caller_client_id( 65 + state: &crate::AppState, 66 + claims: &crate::auth::Claims, 67 + ) -> Result<String, crate::error::AppError> { 68 + let client_key = claims.client_key().ok_or_else(|| { 69 + crate::error::AppError::Auth("delegation requires DPoP authentication".into()) 70 + })?; 71 + crate::repo::get_dpop_client_id(state, client_key).await 72 + } 73 + 74 + pub(crate) async fn verify_client_scope( 75 + state: &crate::AppState, 76 + claims: &crate::auth::Claims, 77 + account_did: &str, 78 + ) -> Result<(), crate::error::AppError> { 79 + let caller_client_id = resolve_caller_client_id(state, claims).await?; 80 + 81 + let stored_client_id = db::get_api_client_id(&state.db, state.db_backend, account_did) 82 + .await? 83 + .ok_or_else(|| crate::error::AppError::NotFound("delegated account not found".into()))?; 84 + 85 + if caller_client_id != stored_client_id { 86 + return Err(crate::error::AppError::Forbidden( 87 + "delegation is scoped to a different application".into(), 88 + )); 89 + } 90 + 91 + Ok(()) 92 + }
+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 + }
+23
src/error.rs
··· 52 52 BadGateway(String), 53 53 BadRequest(String), 54 54 Conflict(String), 55 + FeatureDisabled(String), 55 56 Forbidden(String), 56 57 InsufficientPermissions(String), 57 58 Internal(String), ··· 78 79 AppError::BadGateway(msg) => write!(f, "bad gateway: {msg}"), 79 80 AppError::BadRequest(msg) => write!(f, "bad request: {msg}"), 80 81 AppError::Conflict(msg) => write!(f, "conflict: {msg}"), 82 + AppError::FeatureDisabled(msg) => write!(f, "feature disabled: {msg}"), 81 83 AppError::Forbidden(msg) => write!(f, "forbidden: {msg}"), 82 84 AppError::InsufficientPermissions(perm) => write!(f, "Missing permission: {perm}"), 83 85 AppError::Internal(msg) => write!(f, "internal error: {msg}"), ··· 142 144 }); 143 145 (status, axum::Json(body)).into_response() 144 146 } 147 + AppError::FeatureDisabled(msg) => { 148 + let body = serde_json::json!({ 149 + "error": "FeatureDisabled", 150 + "message": msg, 151 + }); 152 + (StatusCode::NOT_FOUND, axum::Json(body)).into_response() 153 + } 145 154 AppError::InsufficientPermissions(perm) => { 146 155 let body = serde_json::json!({ 147 156 "error": "InsufficientPermissions", ··· 181 190 AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), 182 191 AppError::PdsError(..) 183 192 | AppError::AuthDpopNonce(..) 193 + | AppError::FeatureDisabled(..) 184 194 | AppError::InsufficientPermissions(..) 185 195 | AppError::RateLimited { .. } 186 196 | AppError::ScriptError { .. } => unreachable!(), ··· 276 286 } 277 287 278 288 #[tokio::test] 289 + async fn feature_disabled_returns_404() { 290 + let (status, body) = 291 + response_parts(AppError::FeatureDisabled("spaces not enabled".into())).await; 292 + assert_eq!(status, StatusCode::NOT_FOUND); 293 + assert_eq!(body["error"], "FeatureDisabled"); 294 + assert_eq!(body["message"], "spaces not enabled"); 295 + } 296 + 297 + #[tokio::test] 279 298 async fn not_found_returns_404() { 280 299 let (status, body) = response_parts(AppError::NotFound("no such thing".into())).await; 281 300 assert_eq!(status, StatusCode::NOT_FOUND); ··· 342 361 assert_eq!( 343 362 AppError::Internal("z".into()).to_string(), 344 363 "internal error: z" 364 + ); 365 + assert_eq!( 366 + AppError::FeatureDisabled("x".into()).to_string(), 367 + "feature disabled: x" 345 368 ); 346 369 assert_eq!(AppError::NotFound("w".into()).to_string(), "not found: w"); 347 370 assert_eq!(
+45
src/feature_flags.rs
··· 1 + use sqlx::AnyPool; 2 + 3 + use crate::admin::settings::get_setting; 4 + use crate::db::DatabaseBackend; 5 + 6 + pub struct FeatureFlag; 7 + 8 + impl FeatureFlag { 9 + pub const SPACES_ENABLED: &str = "feature.spaces_enabled"; 10 + } 11 + 12 + pub async fn is_enabled(pool: &AnyPool, key: &str, backend: DatabaseBackend) -> bool { 13 + get_setting(pool, key, backend) 14 + .await 15 + .map(|v| v.eq_ignore_ascii_case("true")) 16 + .unwrap_or(false) 17 + } 18 + 19 + #[derive(serde::Serialize)] 20 + pub struct FeatureFlagStatus { 21 + pub key: String, 22 + pub name: String, 23 + pub description: String, 24 + pub enabled: bool, 25 + } 26 + 27 + pub async fn list_flags(pool: &AnyPool, backend: DatabaseBackend) -> Vec<FeatureFlagStatus> { 28 + let all_flags = [( 29 + FeatureFlag::SPACES_ENABLED, 30 + "Permissioned Spaces", 31 + "Collaborative data spaces with granular permissions, membership, and invites.", 32 + )]; 33 + 34 + let mut result = Vec::new(); 35 + for (key, name, description) in all_flags { 36 + let enabled = is_enabled(pool, key, backend).await; 37 + result.push(FeatureFlagStatus { 38 + key: key.to_string(), 39 + name: name.to_string(), 40 + description: description.to_string(), 41 + enabled, 42 + }); 43 + } 44 + result 45 + }
+35
src/feature_middleware.rs
··· 1 + use axum::extract::{Request, State}; 2 + use axum::middleware::Next; 3 + use axum::response::Response; 4 + 5 + use crate::AppState; 6 + use crate::error::AppError; 7 + 8 + async fn require_feature( 9 + flag_key: &'static str, 10 + State(state): State<AppState>, 11 + req: Request, 12 + next: Next, 13 + ) -> Result<Response, AppError> { 14 + if !crate::feature_flags::is_enabled(&state.db, flag_key, state.db_backend).await { 15 + return Err(AppError::FeatureDisabled(format!( 16 + "The feature '{}' is not currently enabled on this instance", 17 + flag_key 18 + ))); 19 + } 20 + Ok(next.run(req).await) 21 + } 22 + 23 + pub async fn require_spaces( 24 + state: State<AppState>, 25 + req: Request, 26 + next: Next, 27 + ) -> Result<Response, AppError> { 28 + require_feature( 29 + crate::feature_flags::FeatureFlag::SPACES_ENABLED, 30 + state, 31 + req, 32 + next, 33 + ) 34 + .await 35 + }
+47
src/lexicon.rs
··· 85 85 pub index_hook: Option<String>, 86 86 /// Optional per-NSID token cost for rate limiting. 87 87 pub token_cost: Option<u32>, 88 + /// Optional space type NSID indicating this lexicon is designed for use within spaces of that type. 89 + pub space_type: Option<String>, 88 90 } 89 91 90 92 impl ParsedLexicon { ··· 127 129 let output = main_def.and_then(|m| m.get("output")).cloned(); 128 130 let record_schema = main_def.and_then(|m| m.get("record")).cloned(); 129 131 132 + let space_type = raw 133 + .get("spaceType") 134 + .and_then(|v| v.as_str()) 135 + .map(|s| s.to_string()); 136 + 130 137 Ok(Self { 131 138 id, 132 139 lexicon_type, ··· 142 149 script, 143 150 index_hook, 144 151 token_cost, 152 + space_type, 145 153 }) 146 154 } 147 155 } ··· 792 800 let reg = LexiconRegistry::new(); 793 801 let script = reg.get_index_hook("nonexistent").await; 794 802 assert!(script.is_none()); 803 + } 804 + 805 + #[test] 806 + fn parse_space_type_from_lexicon() { 807 + let raw = json!({ 808 + "lexicon": 1, 809 + "id": "com.example.forum.post", 810 + "spaceType": "com.example.forum", 811 + "defs": { 812 + "main": { 813 + "type": "record", 814 + "key": "tid", 815 + "record": { 816 + "type": "object", 817 + "properties": { 818 + "text": { "type": "string" } 819 + } 820 + } 821 + } 822 + } 823 + }); 824 + let parsed = 825 + ParsedLexicon::parse(raw, 1, None, ProcedureAction::Upsert, None, None, None).unwrap(); 826 + assert_eq!(parsed.space_type.as_deref(), Some("com.example.forum")); 827 + } 828 + 829 + #[test] 830 + fn parse_space_type_none_by_default() { 831 + let parsed = ParsedLexicon::parse( 832 + record_lexicon_json(), 833 + 1, 834 + None, 835 + ProcedureAction::Upsert, 836 + None, 837 + None, 838 + None, 839 + ) 840 + .unwrap(); 841 + assert!(parsed.space_type.is_none()); 795 842 } 796 843 }
+4
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; ··· 9 10 pub mod error; 10 11 pub mod event_log; 11 12 pub mod external_auth; 13 + pub mod feature_flags; 14 + pub mod feature_middleware; 12 15 pub mod jetstream; 13 16 pub mod labeler; 14 17 pub mod lexicon; ··· 23 26 pub mod repo; 24 27 pub mod resolve; 25 28 pub mod server; 29 + pub mod spaces; 26 30 pub mod xrpc; 27 31 28 32 use auth::oauth_store::{DbSessionStore, DbStateStore};
+228
src/lua/atproto_api.rs
··· 257 257 atproto_table.set("verify_signature", verify_fn)?; 258 258 } 259 259 260 + // atproto.spaces sub-table 261 + let spaces_table = lua.create_table()?; 262 + 263 + // atproto.spaces.is_member(space_uri, did) -> boolean 264 + let state_clone = state.clone(); 265 + let is_member_fn = 266 + lua.create_async_function(move |_lua, (space_uri, did): (String, String)| { 267 + let state = state_clone.clone(); 268 + async move { 269 + if !crate::feature_flags::is_enabled( 270 + &state.db, 271 + crate::feature_flags::FeatureFlag::SPACES_ENABLED, 272 + state.db_backend, 273 + ) 274 + .await 275 + { 276 + return Err(mlua::Error::runtime("spaces feature is not enabled")); 277 + } 278 + let uri = crate::spaces::SpaceUri::parse(&space_uri) 279 + .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?; 280 + let space = crate::spaces::db::get_space_by_address( 281 + &state.db, 282 + state.db_backend, 283 + &uri.owner_did, 284 + &uri.type_nsid, 285 + &uri.skey, 286 + ) 287 + .await 288 + .map_err(|e| mlua::Error::runtime(format!("space lookup failed: {e}")))?; 289 + let space = match space { 290 + Some(s) => s, 291 + None => return Ok(false), 292 + }; 293 + let access = 294 + crate::spaces::members::is_member(&state.db, state.db_backend, &space.id, &did) 295 + .await 296 + .map_err(|e| { 297 + mlua::Error::runtime(format!("membership check failed: {e}")) 298 + })?; 299 + Ok(access.is_some()) 300 + } 301 + })?; 302 + spaces_table.set("is_member", is_member_fn)?; 303 + 304 + // atproto.spaces.get_access(space_uri, did) -> 'read' | 'write' | nil 305 + let state_clone = state.clone(); 306 + let get_access_fn = 307 + lua.create_async_function(move |_lua, (space_uri, did): (String, String)| { 308 + let state = state_clone.clone(); 309 + async move { 310 + if !crate::feature_flags::is_enabled( 311 + &state.db, 312 + crate::feature_flags::FeatureFlag::SPACES_ENABLED, 313 + state.db_backend, 314 + ) 315 + .await 316 + { 317 + return Err(mlua::Error::runtime("spaces feature is not enabled")); 318 + } 319 + let uri = crate::spaces::SpaceUri::parse(&space_uri) 320 + .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?; 321 + let space = crate::spaces::db::get_space_by_address( 322 + &state.db, 323 + state.db_backend, 324 + &uri.owner_did, 325 + &uri.type_nsid, 326 + &uri.skey, 327 + ) 328 + .await 329 + .map_err(|e| mlua::Error::runtime(format!("space lookup failed: {e}")))?; 330 + let space = match space { 331 + Some(s) => s, 332 + None => return Ok(None), 333 + }; 334 + let access = 335 + crate::spaces::members::is_member(&state.db, state.db_backend, &space.id, &did) 336 + .await 337 + .map_err(|e| { 338 + mlua::Error::runtime(format!("membership check failed: {e}")) 339 + })?; 340 + Ok(access.map(|a| a.as_str().to_string())) 341 + } 342 + })?; 343 + spaces_table.set("get_access", get_access_fn)?; 344 + 345 + // atproto.spaces.list_members(space_uri) -> array of { did, access } 346 + let state_clone = state.clone(); 347 + let list_members_fn = lua.create_async_function(move |lua, space_uri: String| { 348 + let state = state_clone.clone(); 349 + async move { 350 + if !crate::feature_flags::is_enabled( 351 + &state.db, 352 + crate::feature_flags::FeatureFlag::SPACES_ENABLED, 353 + state.db_backend, 354 + ) 355 + .await 356 + { 357 + return Err(mlua::Error::runtime("spaces feature is not enabled")); 358 + } 359 + let uri = crate::spaces::SpaceUri::parse(&space_uri) 360 + .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?; 361 + let space = crate::spaces::db::get_space_by_address( 362 + &state.db, 363 + state.db_backend, 364 + &uri.owner_did, 365 + &uri.type_nsid, 366 + &uri.skey, 367 + ) 368 + .await 369 + .map_err(|e| mlua::Error::runtime(format!("space lookup failed: {e}")))?; 370 + let space = match space { 371 + Some(s) => s, 372 + None => { 373 + return Err(mlua::Error::runtime("space not found")); 374 + } 375 + }; 376 + let members = 377 + crate::spaces::members::resolve_members(&state.db, state.db_backend, &space.id) 378 + .await 379 + .map_err(|e| mlua::Error::runtime(format!("member resolution failed: {e}")))?; 380 + 381 + let result = lua.create_table()?; 382 + for (i, member) in members.iter().enumerate() { 383 + let entry = lua.create_table()?; 384 + entry.set("did", member.did.as_str())?; 385 + entry.set("access", member.access.as_str())?; 386 + result.set(i + 1, entry)?; 387 + } 388 + Ok(mlua::Value::Table(result)) 389 + } 390 + })?; 391 + spaces_table.set("list_members", list_members_fn)?; 392 + 393 + // atproto.spaces.query({ space_uri, collection, limit, cursor }) -> { records, cursor } 394 + let state_clone = state.clone(); 395 + let query_fn = lua.create_async_function(move |lua, opts: mlua::Table| { 396 + let state = state_clone.clone(); 397 + async move { 398 + if !crate::feature_flags::is_enabled( 399 + &state.db, 400 + crate::feature_flags::FeatureFlag::SPACES_ENABLED, 401 + state.db_backend, 402 + ) 403 + .await 404 + { 405 + return Err(mlua::Error::runtime("spaces feature is not enabled")); 406 + } 407 + let space_uri: String = opts 408 + .get("space_uri") 409 + .map_err(|_| mlua::Error::runtime("space_uri is required"))?; 410 + let collection: Option<String> = opts.get("collection").ok(); 411 + let limit: i64 = opts.get("limit").unwrap_or(50); 412 + let cursor: Option<String> = opts.get("cursor").ok(); 413 + 414 + let uri = crate::spaces::SpaceUri::parse(&space_uri) 415 + .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?; 416 + let space = crate::spaces::db::get_space_by_address( 417 + &state.db, 418 + state.db_backend, 419 + &uri.owner_did, 420 + &uri.type_nsid, 421 + &uri.skey, 422 + ) 423 + .await 424 + .map_err(|e| mlua::Error::runtime(format!("space lookup failed: {e}")))?; 425 + let space = match space { 426 + Some(s) => s, 427 + None => { 428 + return Err(mlua::Error::runtime("space not found")); 429 + } 430 + }; 431 + 432 + let records = crate::spaces::db::list_space_records( 433 + &state.db, 434 + state.db_backend, 435 + &space.id, 436 + collection.as_deref(), 437 + limit.min(100), 438 + cursor.as_deref(), 439 + ) 440 + .await 441 + .map_err(|e| mlua::Error::runtime(format!("record query failed: {e}")))?; 442 + 443 + let next_cursor = records.last().map(|r| r.indexed_at.clone()); 444 + 445 + let result = lua.create_table()?; 446 + let records_table = lua.create_table()?; 447 + for (i, record) in records.iter().enumerate() { 448 + let entry = lua.to_value(&serde_json::json!({ 449 + "uri": record.uri, 450 + "collection": record.collection, 451 + "rkey": record.rkey, 452 + "record": record.record, 453 + "cid": record.cid, 454 + "authorDid": record.author_did, 455 + }))?; 456 + records_table.set(i + 1, entry)?; 457 + } 458 + result.set("records", records_table)?; 459 + match next_cursor { 460 + Some(c) => result.set("cursor", c)?, 461 + None => result.set("cursor", mlua::Value::Nil)?, 462 + } 463 + 464 + Ok(mlua::Value::Table(result)) 465 + } 466 + })?; 467 + spaces_table.set("query", query_fn)?; 468 + 469 + atproto_table.set("spaces", spaces_table)?; 470 + 260 471 lua.globals().set("atproto", atproto_table)?; 261 472 Ok(()) 262 473 } ··· 516 727 "#; 517 728 let result: bool = lua.load(chunk).eval_async().await.unwrap(); 518 729 assert!(!result); 730 + } 731 + 732 + #[tokio::test] 733 + async fn spaces_api_is_registered() { 734 + let state = test_state_with_plc(""); 735 + let lua = mlua::Lua::new(); 736 + register_atproto_api(&lua, Arc::new(state), None).unwrap(); 737 + 738 + let chunk = r#" 739 + return type(atproto.spaces) == "table" 740 + and type(atproto.spaces.is_member) == "function" 741 + and type(atproto.spaces.get_access) == "function" 742 + and type(atproto.spaces.list_members) == "function" 743 + and type(atproto.spaces.query) == "function" 744 + "#; 745 + let result: bool = lua.load(chunk).eval_async().await.unwrap(); 746 + assert!(result); 519 747 } 520 748 }
+123
src/lua/context.rs
··· 2 2 use serde_json::Value; 3 3 use std::collections::HashMap; 4 4 5 + /// Optional space context passed to Lua scripts when the request is space-scoped. 6 + #[derive(Debug, Clone)] 7 + pub struct SpaceContext { 8 + pub space_uri: String, 9 + pub space_id: String, 10 + pub owner_did: String, 11 + pub type_nsid: String, 12 + pub skey: String, 13 + } 14 + 15 + fn set_space_context(lua: &Lua, space: Option<&SpaceContext>) -> LuaResult<()> { 16 + let globals = lua.globals(); 17 + match space { 18 + Some(ctx) => { 19 + let table = lua.create_table()?; 20 + table.set("space_uri", ctx.space_uri.as_str())?; 21 + table.set("space_id", ctx.space_id.as_str())?; 22 + table.set("owner_did", ctx.owner_did.as_str())?; 23 + table.set("type_nsid", ctx.type_nsid.as_str())?; 24 + table.set("skey", ctx.skey.as_str())?; 25 + globals.set("space", table)?; 26 + } 27 + None => { 28 + globals.set("space", mlua::Value::Nil)?; 29 + } 30 + } 31 + Ok(()) 32 + } 33 + 5 34 /// Set global context variables for a procedure script. 35 + #[allow(clippy::too_many_arguments)] 6 36 pub fn set_procedure_context( 7 37 lua: &Lua, 8 38 method: &str, ··· 10 40 params: &HashMap<String, Value>, 11 41 caller_did: &str, 12 42 collection: &str, 43 + space: Option<&SpaceContext>, 44 + delegate_did: Option<&str>, 13 45 ) -> LuaResult<()> { 14 46 let globals = lua.globals(); 15 47 globals.set("method", method.to_string())?; ··· 17 49 globals.set("params", lua.to_value(params)?)?; 18 50 globals.set("caller_did", caller_did.to_string())?; 19 51 globals.set("collection", collection.to_string())?; 52 + match delegate_did { 53 + Some(did) => globals.set("delegate_did", did.to_string())?, 54 + None => globals.set("delegate_did", mlua::Value::Nil)?, 55 + } 56 + set_space_context(lua, space)?; 20 57 Ok(()) 21 58 } 22 59 ··· 27 64 params: &HashMap<String, Value>, 28 65 collection: &str, 29 66 caller_did: Option<&str>, 67 + space: Option<&SpaceContext>, 30 68 ) -> LuaResult<()> { 31 69 let globals = lua.globals(); 32 70 globals.set("method", method.to_string())?; ··· 36 74 Some(did) => globals.set("caller_did", did.to_string())?, 37 75 None => globals.set("caller_did", mlua::Value::Nil)?, 38 76 } 77 + set_space_context(lua, space)?; 39 78 Ok(()) 40 79 } 41 80 ··· 117 156 &params, 118 157 "did:plc:test", 119 158 "com.example.thing", 159 + None, 160 + None, 120 161 ) 121 162 .unwrap(); 122 163 ··· 130 171 globals.get::<String>("collection").unwrap(), 131 172 "com.example.thing" 132 173 ); 174 + assert!(globals.get::<mlua::Value>("delegate_did").unwrap().is_nil()); 133 175 134 176 let input_table: mlua::Table = globals.get("input").unwrap(); 135 177 assert_eq!(input_table.get::<String>("key").unwrap(), "val"); ··· 150 192 &params, 151 193 "com.example.thing", 152 194 Some("did:plc:test"), 195 + None, 153 196 ) 154 197 .unwrap(); 155 198 ··· 169 212 } 170 213 171 214 #[test] 215 + fn procedure_context_with_delegate_did() { 216 + let lua = create_sandbox().unwrap(); 217 + let input = json!({"key": "val"}); 218 + let params = HashMap::new(); 219 + set_procedure_context( 220 + &lua, 221 + "com.example.doThing", 222 + &input, 223 + &params, 224 + "did:plc:caller", 225 + "com.example.thing", 226 + None, 227 + Some("did:plc:delegate"), 228 + ) 229 + .unwrap(); 230 + 231 + let globals = lua.globals(); 232 + assert_eq!( 233 + globals.get::<String>("delegate_did").unwrap(), 234 + "did:plc:delegate" 235 + ); 236 + assert_eq!( 237 + globals.get::<String>("caller_did").unwrap(), 238 + "did:plc:caller" 239 + ); 240 + } 241 + 242 + #[test] 172 243 fn env_context_sets_table() { 173 244 let lua = create_sandbox().unwrap(); 174 245 let mut vars = HashMap::new(); ··· 191 262 let globals = lua.globals(); 192 263 let env: mlua::Table = globals.get("env").unwrap(); 193 264 assert!(env.get::<mlua::Value>("anything").unwrap().is_nil()); 265 + } 266 + 267 + #[test] 268 + fn query_context_with_space() { 269 + let lua = create_sandbox().unwrap(); 270 + let params = HashMap::new(); 271 + let space = SpaceContext { 272 + space_uri: "ats://did:plc:owner/com.example.forum/main".into(), 273 + space_id: "space-123".into(), 274 + owner_did: "did:plc:owner".into(), 275 + type_nsid: "com.example.forum".into(), 276 + skey: "main".into(), 277 + }; 278 + set_query_context( 279 + &lua, 280 + "com.example.listPosts", 281 + &params, 282 + "com.example.forum.post", 283 + Some("did:plc:test"), 284 + Some(&space), 285 + ) 286 + .unwrap(); 287 + 288 + let globals = lua.globals(); 289 + let space_table: mlua::Table = globals.get("space").unwrap(); 290 + assert_eq!( 291 + space_table.get::<String>("space_uri").unwrap(), 292 + "ats://did:plc:owner/com.example.forum/main" 293 + ); 294 + assert_eq!(space_table.get::<String>("space_id").unwrap(), "space-123"); 295 + assert_eq!( 296 + space_table.get::<String>("owner_did").unwrap(), 297 + "did:plc:owner" 298 + ); 299 + } 300 + 301 + #[test] 302 + fn query_context_without_space() { 303 + let lua = create_sandbox().unwrap(); 304 + let params = HashMap::new(); 305 + set_query_context( 306 + &lua, 307 + "com.example.listThings", 308 + &params, 309 + "com.example.thing", 310 + None, 311 + None, 312 + ) 313 + .unwrap(); 314 + 315 + let globals = lua.globals(); 316 + assert!(globals.get::<mlua::Value>("space").unwrap().is_nil()); 194 317 } 195 318 196 319 #[test]
+29 -7
src/lua/execute.rs
··· 33 33 } 34 34 35 35 /// Execute a Lua script for a procedure endpoint. 36 + #[allow(clippy::too_many_arguments)] 36 37 pub async fn execute_procedure_script( 37 38 state: &AppState, 38 39 method: &str, ··· 41 42 params: &std::collections::HashMap<String, Value>, 42 43 lexicon: &ParsedLexicon, 43 44 script: &str, 45 + space_ctx: Option<&context::SpaceContext>, 46 + delegate_did: Option<&str>, 44 47 ) -> Result<Response, AppError> { 45 48 let start = Instant::now(); 46 49 let backend = state.db_backend; ··· 251 254 return Err(AppError::Internal(error_message)); 252 255 } 253 256 254 - 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 + ) { 255 264 let error_message = format!("failed to register Record API: {e}"); 256 265 log_event( 257 266 &state.db, ··· 275 284 return Err(AppError::Internal(error_message)); 276 285 } 277 286 278 - if let Err(e) = 279 - context::set_procedure_context(&lua, method, input, params, claims.did(), collection) 280 - { 287 + if let Err(e) = context::set_procedure_context( 288 + &lua, 289 + method, 290 + input, 291 + params, 292 + claims.did(), 293 + collection, 294 + space_ctx, 295 + delegate_did, 296 + ) { 281 297 let error_message = format!("failed to set context: {e}"); 282 298 log_event( 283 299 &state.db, ··· 503 519 lexicon: &ParsedLexicon, 504 520 script: &str, 505 521 claims: Option<&Claims>, 522 + space_ctx: Option<&context::SpaceContext>, 506 523 ) -> Result<Response, AppError> { 507 524 let start = Instant::now(); 508 525 let backend = state.db_backend; ··· 632 649 return Err(AppError::Internal(error_message)); 633 650 } 634 651 635 - if let Err(e) = 636 - context::set_query_context(&lua, method, params, collection, claims.map(|c| c.did())) 637 - { 652 + if let Err(e) = context::set_query_context( 653 + &lua, 654 + method, 655 + params, 656 + collection, 657 + claims.map(|c| c.did()), 658 + space_ctx, 659 + ) { 638 660 let error_message = format!("failed to set context: {e}"); 639 661 log_event( 640 662 &state.db,
+2
src/lua/mod.rs
··· 8 8 mod tid; 9 9 mod xrpc_api; 10 10 11 + #[allow(unused_imports)] 12 + pub(crate) use context::SpaceContext; 11 13 pub(crate) use execute::{ 12 14 HookEvent, execute_hook_script, execute_procedure_script, execute_query_script, run_hook_once, 13 15 };
+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('/')
+2
src/lua/xrpc_api.rs
··· 310 310 script: script.map(|s| s.to_string()), 311 311 index_hook: None, 312 312 token_cost: None, 313 + space_type: None, 313 314 } 314 315 } 315 316 ··· 329 330 script: script.map(|s| s.to_string()), 330 331 index_hook: None, 331 332 token_cost: None, 333 + space_type: None, 332 334 } 333 335 } 334 336
+19 -7
src/profile.rs
··· 27 27 28 28 #[derive(Deserialize)] 29 29 #[serde(rename_all = "camelCase")] 30 - struct DidDocument { 30 + pub struct DidDocument { 31 31 #[serde(default)] 32 - also_known_as: Vec<String>, 32 + pub also_known_as: Vec<String>, 33 33 #[serde(default)] 34 - service: Vec<DidService>, 34 + pub verification_method: Vec<DidVerificationMethod>, 35 + #[serde(default)] 36 + pub service: Vec<DidService>, 35 37 } 36 38 37 39 #[derive(Deserialize)] 38 40 #[serde(rename_all = "camelCase")] 39 - struct DidService { 40 - id: String, 41 - service_endpoint: String, 41 + pub struct DidVerificationMethod { 42 + pub id: String, 43 + #[serde(rename = "type")] 44 + pub method_type: String, 45 + #[serde(default)] 46 + pub public_key_multibase: Option<String>, 47 + } 48 + 49 + #[derive(Deserialize)] 50 + #[serde(rename_all = "camelCase")] 51 + pub struct DidService { 52 + pub id: String, 53 + pub service_endpoint: String, 42 54 } 43 55 44 56 #[derive(Deserialize)] ··· 117 129 } 118 130 119 131 /// Fetch a DID document from the PLC directory or via `did:web` resolution. 120 - async fn resolve_did_document( 132 + pub async fn resolve_did_document( 121 133 http: &reqwest::Client, 122 134 plc_url: &str, 123 135 did: &str,
+45
src/server.rs
··· 62 62 let serve_dir = ServeDir::new(&static_dir).not_found_service(spa_fallback); 63 63 64 64 let domain_routes = Router::new() 65 + .merge( 66 + crate::spaces::routes::space_routes().layer(axum::middleware::from_fn_with_state( 67 + state.clone(), 68 + crate::feature_middleware::require_spaces, 69 + )), 70 + ) 65 71 .nest("/auth", crate::auth::routes::routes()) 66 72 .nest("/external-auth", crate::external_auth::routes()) 67 73 .nest("/oauth", crate::oauth::routes::routes()) ··· 88 94 "/xrpc/dev.happyview.deleteApiClient", 89 95 post(crate::dev_happyview::delete_api_client), 90 96 ) 97 + // Delegation 98 + .route( 99 + "/xrpc/dev.happyview.delegation.linkAccount", 100 + post(crate::delegation::link_account::link_account), 101 + ) 102 + .route( 103 + "/xrpc/dev.happyview.delegation.unlinkAccount", 104 + post(crate::delegation::unlink_account::unlink_account), 105 + ) 106 + .route( 107 + "/xrpc/dev.happyview.delegation.addDelegate", 108 + post(crate::delegation::add_delegate::add_delegate), 109 + ) 110 + .route( 111 + "/xrpc/dev.happyview.delegation.removeDelegate", 112 + post(crate::delegation::remove_delegate::remove_delegate), 113 + ) 114 + .route( 115 + "/xrpc/dev.happyview.delegation.listAccounts", 116 + get(crate::delegation::list_accounts::list_accounts), 117 + ) 118 + .route( 119 + "/xrpc/dev.happyview.delegation.getAccount", 120 + get(crate::delegation::get_account::get_account), 121 + ) 122 + .route( 123 + "/xrpc/dev.happyview.delegation.listDelegates", 124 + get(crate::delegation::list_delegates::list_delegates), 125 + ) 91 126 // Catch-all for dynamically registered lexicons 92 127 .route("/xrpc/{method}", get(xrpc::xrpc_get).post(xrpc::xrpc_post)) 93 128 .route("/config", get(config_endpoint)) ··· 158 193 _ => env!("CARGO_PKG_VERSION"), 159 194 }; 160 195 196 + let spaces_enabled = crate::feature_flags::is_enabled( 197 + pool, 198 + crate::feature_flags::FeatureFlag::SPACES_ENABLED, 199 + backend, 200 + ) 201 + .await; 202 + 161 203 Json(serde_json::json!({ 162 204 "public_url": domain_url, 163 205 "version": version, ··· 169 211 "default_rate_limit_refill_rate": state.config.default_rate_limit_refill_rate, 170 212 "app_name": app_name, 171 213 "logo_url": logo_url, 214 + "features": { 215 + "spaces": spaces_enabled, 216 + }, 172 217 })) 173 218 } 174 219
+315
src/spaces/auth.rs
··· 1 + use base64::Engine; 2 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3 + use p256::ecdsa::SigningKey; 4 + use rand::RngCore; 5 + use sha2::{Digest, Sha256}; 6 + use uuid::Uuid; 7 + 8 + use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; 9 + use crate::error::AppError; 10 + use crate::plugin::encryption::{decrypt, encrypt}; 11 + use crate::spaces::credential::{ 12 + DEFAULT_CREDENTIAL_TTL_SECS, SpaceCredentialClaims, sign_credential, verify_credential, 13 + }; 14 + use crate::spaces::types::{AccessMode, Space}; 15 + 16 + pub struct IssuedCredential { 17 + pub token: String, 18 + pub expires_at: String, 19 + } 20 + 21 + pub async fn issue_credential( 22 + pool: &sqlx::AnyPool, 23 + backend: DatabaseBackend, 24 + encryption_key: &[u8; 32], 25 + space: &Space, 26 + subject_did: &str, 27 + client_id: Option<&str>, 28 + ) -> Result<IssuedCredential, AppError> { 29 + check_app_access(space, client_id)?; 30 + 31 + let private_jwk = get_or_create_signing_key(pool, backend, encryption_key, space).await?; 32 + 33 + let now = std::time::SystemTime::now() 34 + .duration_since(std::time::UNIX_EPOCH) 35 + .unwrap() 36 + .as_secs(); 37 + let exp = now + DEFAULT_CREDENTIAL_TTL_SECS; 38 + 39 + let claims = SpaceCredentialClaims { 40 + iss: space.owner_did.clone(), 41 + sub: subject_did.to_string(), 42 + space: format!("{}/{}/{}", space.owner_did, space.type_nsid, space.skey), 43 + scope: "read".into(), 44 + iat: now, 45 + exp, 46 + }; 47 + 48 + let token = sign_credential(&claims, &private_jwk)?; 49 + 50 + let token_hash = hex::encode(Sha256::digest(token.as_bytes())); 51 + store_credential_record(pool, backend, &space.id, subject_did, &token_hash, exp).await?; 52 + 53 + let expires_at = chrono::DateTime::from_timestamp(exp as i64, 0) 54 + .map(|dt| dt.to_rfc3339()) 55 + .unwrap_or_default(); 56 + 57 + Ok(IssuedCredential { token, expires_at }) 58 + } 59 + 60 + pub async fn refresh_credential( 61 + pool: &sqlx::AnyPool, 62 + backend: DatabaseBackend, 63 + encryption_key: &[u8; 32], 64 + space: &Space, 65 + current_token: &str, 66 + ) -> Result<IssuedCredential, AppError> { 67 + let public_jwk = get_public_key(pool, backend, encryption_key, space).await?; 68 + let claims = verify_credential(current_token, &public_jwk)?; 69 + 70 + issue_credential(pool, backend, encryption_key, space, &claims.sub, None).await 71 + } 72 + 73 + pub fn check_app_access(space: &Space, client_id: Option<&str>) -> Result<(), AppError> { 74 + let Some(client_id) = client_id else { 75 + return Ok(()); 76 + }; 77 + 78 + match space.access_mode { 79 + AccessMode::DefaultDeny => { 80 + if let Some(ref allowlist) = space.app_allowlist { 81 + if !allowlist.iter().any(|id| id == client_id) { 82 + return Err(AppError::Forbidden( 83 + "This app is not authorized to access this space".into(), 84 + )); 85 + } 86 + } else { 87 + return Err(AppError::Forbidden( 88 + "Space is in default_deny mode with no allowlist".into(), 89 + )); 90 + } 91 + } 92 + AccessMode::DefaultAllow => { 93 + if let Some(ref denylist) = space.app_denylist 94 + && denylist.iter().any(|id| id == client_id) 95 + { 96 + return Err(AppError::Forbidden( 97 + "This app has been denied access to this space".into(), 98 + )); 99 + } 100 + } 101 + } 102 + 103 + Ok(()) 104 + } 105 + 106 + async fn get_or_create_signing_key( 107 + pool: &sqlx::AnyPool, 108 + backend: DatabaseBackend, 109 + encryption_key: &[u8; 32], 110 + space: &Space, 111 + ) -> Result<serde_json::Value, AppError> { 112 + let sql = adapt_sql( 113 + "SELECT signing_key_enc FROM space_dids WHERE space_id = ?", 114 + backend, 115 + ); 116 + let row: Option<(Vec<u8>,)> = sqlx::query_as(&sql) 117 + .bind(&space.id) 118 + .fetch_optional(pool) 119 + .await 120 + .map_err(|e| AppError::Internal(format!("failed to look up space signing key: {e}")))?; 121 + 122 + if let Some((encrypted,)) = row { 123 + let decrypted = decrypt(encryption_key, &encrypted) 124 + .map_err(|e| AppError::Internal(format!("failed to decrypt signing key: {e}")))?; 125 + let jwk: serde_json::Value = serde_json::from_slice(&decrypted) 126 + .map_err(|e| AppError::Internal(format!("failed to parse signing key: {e}")))?; 127 + return Ok(jwk); 128 + } 129 + 130 + let keypair = generate_space_keypair()?; 131 + let key_bytes = serde_json::to_vec(&keypair.private_jwk) 132 + .map_err(|e| AppError::Internal(format!("failed to serialize signing key: {e}")))?; 133 + let encrypted_signing = encrypt(encryption_key, &key_bytes) 134 + .map_err(|e| AppError::Internal(format!("failed to encrypt signing key: {e}")))?; 135 + 136 + // Rotation key is a separate keypair for recovery 137 + let rotation_keypair = generate_space_keypair()?; 138 + let rotation_bytes = serde_json::to_vec(&rotation_keypair.private_jwk) 139 + .map_err(|e| AppError::Internal(format!("failed to serialize rotation key: {e}")))?; 140 + let encrypted_rotation = encrypt(encryption_key, &rotation_bytes) 141 + .map_err(|e| AppError::Internal(format!("failed to encrypt rotation key: {e}")))?; 142 + 143 + let now = now_rfc3339(); 144 + let insert_sql = adapt_sql( 145 + "INSERT INTO space_dids (id, did, space_id, signing_key_enc, rotation_key_enc, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", 146 + backend, 147 + ); 148 + 149 + sqlx::query(&insert_sql) 150 + .bind(Uuid::new_v4().to_string()) 151 + .bind(&space.owner_did) 152 + .bind(&space.id) 153 + .bind(&encrypted_signing) 154 + .bind(&encrypted_rotation) 155 + .bind(&space.owner_did) 156 + .bind(&now) 157 + .execute(pool) 158 + .await 159 + .map_err(|e| AppError::Internal(format!("failed to store space signing key: {e}")))?; 160 + 161 + Ok(keypair.private_jwk) 162 + } 163 + 164 + async fn get_public_key( 165 + pool: &sqlx::AnyPool, 166 + backend: DatabaseBackend, 167 + encryption_key: &[u8; 32], 168 + space: &Space, 169 + ) -> Result<serde_json::Value, AppError> { 170 + let private_jwk = get_or_create_signing_key(pool, backend, encryption_key, space).await?; 171 + Ok(serde_json::json!({ 172 + "kty": "EC", 173 + "crv": "P-256", 174 + "x": private_jwk["x"], 175 + "y": private_jwk["y"], 176 + })) 177 + } 178 + 179 + struct SpaceKeypair { 180 + private_jwk: serde_json::Value, 181 + } 182 + 183 + fn generate_space_keypair() -> Result<SpaceKeypair, AppError> { 184 + let mut rng_bytes = [0u8; 32]; 185 + rand::rng().fill_bytes(&mut rng_bytes); 186 + 187 + let signing_key = SigningKey::from_bytes((&rng_bytes[..]).into()) 188 + .map_err(|e| AppError::Internal(format!("failed to generate signing key: {e}")))?; 189 + 190 + let verifying_key = signing_key.verifying_key(); 191 + let public_point = verifying_key.to_encoded_point(false); 192 + 193 + let x_bytes = public_point 194 + .x() 195 + .ok_or_else(|| AppError::Internal("missing x coordinate".into()))?; 196 + let y_bytes = public_point 197 + .y() 198 + .ok_or_else(|| AppError::Internal("missing y coordinate".into()))?; 199 + 200 + let x_b64 = URL_SAFE_NO_PAD.encode(x_bytes); 201 + let y_b64 = URL_SAFE_NO_PAD.encode(y_bytes); 202 + let d_b64 = URL_SAFE_NO_PAD.encode(rng_bytes); 203 + 204 + let private_jwk = serde_json::json!({ 205 + "kty": "EC", 206 + "crv": "P-256", 207 + "x": x_b64, 208 + "y": y_b64, 209 + "d": d_b64, 210 + }); 211 + 212 + Ok(SpaceKeypair { private_jwk }) 213 + } 214 + 215 + async fn store_credential_record( 216 + pool: &sqlx::AnyPool, 217 + backend: DatabaseBackend, 218 + space_id: &str, 219 + issued_to: &str, 220 + token_hash: &str, 221 + expires_at_epoch: u64, 222 + ) -> Result<(), AppError> { 223 + let now = now_rfc3339(); 224 + let expires_at = chrono::DateTime::from_timestamp(expires_at_epoch as i64, 0) 225 + .map(|dt| dt.to_rfc3339()) 226 + .unwrap_or_default(); 227 + 228 + let sql = adapt_sql( 229 + "INSERT INTO space_credentials (id, space_id, issued_to, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)", 230 + backend, 231 + ); 232 + 233 + sqlx::query(&sql) 234 + .bind(Uuid::new_v4().to_string()) 235 + .bind(space_id) 236 + .bind(issued_to) 237 + .bind(token_hash) 238 + .bind(&expires_at) 239 + .bind(&now) 240 + .execute(pool) 241 + .await 242 + .map_err(|e| AppError::Internal(format!("failed to store credential record: {e}")))?; 243 + 244 + Ok(()) 245 + } 246 + 247 + #[cfg(test)] 248 + mod tests { 249 + use super::*; 250 + use crate::spaces::types::{AccessMode, Space, SpaceConfig}; 251 + 252 + fn test_space(access_mode: AccessMode) -> Space { 253 + Space { 254 + id: "test-space".into(), 255 + owner_did: "did:plc:owner".into(), 256 + type_nsid: "com.example.forum".into(), 257 + skey: "main".into(), 258 + display_name: None, 259 + description: None, 260 + access_mode, 261 + app_allowlist: None, 262 + app_denylist: None, 263 + managing_app_did: None, 264 + config: SpaceConfig::default(), 265 + created_at: String::new(), 266 + updated_at: String::new(), 267 + } 268 + } 269 + 270 + #[test] 271 + fn app_access_default_allow_no_lists() { 272 + let space = test_space(AccessMode::DefaultAllow); 273 + assert!(check_app_access(&space, Some("any-app")).is_ok()); 274 + } 275 + 276 + #[test] 277 + fn app_access_default_allow_denied() { 278 + let mut space = test_space(AccessMode::DefaultAllow); 279 + space.app_denylist = Some(vec!["bad-app".into()]); 280 + 281 + assert!(check_app_access(&space, Some("good-app")).is_ok()); 282 + assert!(check_app_access(&space, Some("bad-app")).is_err()); 283 + } 284 + 285 + #[test] 286 + fn app_access_default_deny_no_allowlist() { 287 + let space = test_space(AccessMode::DefaultDeny); 288 + assert!(check_app_access(&space, Some("any-app")).is_err()); 289 + } 290 + 291 + #[test] 292 + fn app_access_default_deny_allowed() { 293 + let mut space = test_space(AccessMode::DefaultDeny); 294 + space.app_allowlist = Some(vec!["good-app".into()]); 295 + 296 + assert!(check_app_access(&space, Some("good-app")).is_ok()); 297 + assert!(check_app_access(&space, Some("other-app")).is_err()); 298 + } 299 + 300 + #[test] 301 + fn app_access_no_client_id_always_passes() { 302 + let space = test_space(AccessMode::DefaultDeny); 303 + assert!(check_app_access(&space, None).is_ok()); 304 + } 305 + 306 + #[test] 307 + fn generate_keypair_produces_valid_jwk() { 308 + let kp = generate_space_keypair().unwrap(); 309 + assert_eq!(kp.private_jwk["kty"], "EC"); 310 + assert_eq!(kp.private_jwk["crv"], "P-256"); 311 + assert!(kp.private_jwk["d"].is_string()); 312 + assert!(kp.private_jwk["x"].is_string()); 313 + assert!(kp.private_jwk["y"].is_string()); 314 + } 315 + }
+284
src/spaces/credential.rs
··· 1 + use base64::Engine; 2 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3 + use p256::ecdsa::{Signature, SigningKey, VerifyingKey, signature::Signer, signature::Verifier}; 4 + use serde::{Deserialize, Serialize}; 5 + 6 + use crate::error::AppError; 7 + use crate::profile; 8 + 9 + pub const DEFAULT_CREDENTIAL_TTL_SECS: u64 = 4 * 60 * 60; // 4 hours 10 + 11 + #[derive(Debug, Clone, Serialize, Deserialize)] 12 + pub struct SpaceCredentialClaims { 13 + pub iss: String, 14 + pub sub: String, 15 + pub space: String, 16 + pub scope: String, 17 + pub iat: u64, 18 + pub exp: u64, 19 + } 20 + 21 + pub fn sign_credential( 22 + claims: &SpaceCredentialClaims, 23 + private_jwk: &serde_json::Value, 24 + ) -> Result<String, AppError> { 25 + let d_b64 = private_jwk["d"] 26 + .as_str() 27 + .ok_or_else(|| AppError::Internal("signing key missing d parameter".into()))?; 28 + 29 + let d_bytes = URL_SAFE_NO_PAD 30 + .decode(d_b64) 31 + .map_err(|_| AppError::Internal("invalid signing key d parameter".into()))?; 32 + 33 + let signing_key = SigningKey::from_bytes((&d_bytes[..]).into()) 34 + .map_err(|e| AppError::Internal(format!("invalid signing key: {e}")))?; 35 + 36 + let header = serde_json::json!({ 37 + "alg": "ES256", 38 + "typ": "JWT", 39 + }); 40 + 41 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); 42 + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap()); 43 + 44 + let message = format!("{}.{}", header_b64, payload_b64); 45 + let signature: Signature = signing_key.sign(message.as_bytes()); 46 + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 47 + 48 + Ok(format!("{}.{}.{}", header_b64, payload_b64, sig_b64)) 49 + } 50 + 51 + pub fn verify_credential( 52 + token: &str, 53 + public_jwk: &serde_json::Value, 54 + ) -> Result<SpaceCredentialClaims, AppError> { 55 + let parts: Vec<&str> = token.split('.').collect(); 56 + if parts.len() != 3 { 57 + return Err(AppError::Auth("invalid credential format".into())); 58 + } 59 + 60 + let header_bytes = URL_SAFE_NO_PAD 61 + .decode(parts[0]) 62 + .map_err(|_| AppError::Auth("invalid credential header encoding".into()))?; 63 + let header: serde_json::Value = serde_json::from_slice(&header_bytes) 64 + .map_err(|_| AppError::Auth("invalid credential header".into()))?; 65 + 66 + if header["alg"].as_str() != Some("ES256") { 67 + return Err(AppError::Auth("credential alg must be ES256".into())); 68 + } 69 + 70 + let x_b64 = public_jwk["x"] 71 + .as_str() 72 + .ok_or_else(|| AppError::Auth("public key missing x".into()))?; 73 + let y_b64 = public_jwk["y"] 74 + .as_str() 75 + .ok_or_else(|| AppError::Auth("public key missing y".into()))?; 76 + 77 + let x_bytes = URL_SAFE_NO_PAD 78 + .decode(x_b64) 79 + .map_err(|_| AppError::Auth("invalid public key x".into()))?; 80 + let y_bytes = URL_SAFE_NO_PAD 81 + .decode(y_b64) 82 + .map_err(|_| AppError::Auth("invalid public key y".into()))?; 83 + 84 + let mut sec1 = Vec::with_capacity(1 + 32 + 32); 85 + sec1.push(0x04); 86 + sec1.extend_from_slice(&x_bytes); 87 + sec1.extend_from_slice(&y_bytes); 88 + 89 + let verifying_key = VerifyingKey::from_sec1_bytes(&sec1) 90 + .map_err(|_| AppError::Auth("invalid space credential public key".into()))?; 91 + 92 + let message = format!("{}.{}", parts[0], parts[1]); 93 + let sig_bytes = URL_SAFE_NO_PAD 94 + .decode(parts[2]) 95 + .map_err(|_| AppError::Auth("invalid credential signature encoding".into()))?; 96 + let signature = Signature::from_bytes(sig_bytes.as_slice().into()) 97 + .map_err(|_| AppError::Auth("invalid credential signature format".into()))?; 98 + 99 + verifying_key 100 + .verify(message.as_bytes(), &signature) 101 + .map_err(|_| AppError::Auth("credential signature verification failed".into()))?; 102 + 103 + let payload_bytes = URL_SAFE_NO_PAD 104 + .decode(parts[1]) 105 + .map_err(|_| AppError::Auth("invalid credential payload encoding".into()))?; 106 + let claims: SpaceCredentialClaims = serde_json::from_slice(&payload_bytes) 107 + .map_err(|_| AppError::Auth("invalid credential payload".into()))?; 108 + 109 + let now = std::time::SystemTime::now() 110 + .duration_since(std::time::UNIX_EPOCH) 111 + .unwrap() 112 + .as_secs(); 113 + 114 + if now > claims.exp { 115 + return Err(AppError::Auth("credential has expired".into())); 116 + } 117 + 118 + Ok(claims) 119 + } 120 + 121 + /// Convert a multibase-encoded P-256 public key (from a DID doc `publicKeyMultibase`) 122 + /// into a JWK suitable for `verify_credential`. 123 + pub fn multikey_to_p256_jwk(public_key_multibase: &str) -> Result<serde_json::Value, AppError> { 124 + let (_base, key_bytes) = multibase::decode(public_key_multibase) 125 + .map_err(|e| AppError::Auth(format!("invalid multibase encoding: {e}")))?; 126 + 127 + // P-256 multicodec prefix: varint 0x1200 → bytes [0x80, 0x24] 128 + if key_bytes.len() < 2 || key_bytes[0] != 0x80 || key_bytes[1] != 0x24 { 129 + return Err(AppError::Auth( 130 + "public key is not a P-256 multicodec key".into(), 131 + )); 132 + } 133 + 134 + let compressed = &key_bytes[2..]; 135 + let verifying_key = VerifyingKey::from_sec1_bytes(compressed) 136 + .map_err(|_| AppError::Auth("invalid P-256 public key bytes".into()))?; 137 + 138 + let point = verifying_key.to_encoded_point(false); 139 + let x = point 140 + .x() 141 + .ok_or_else(|| AppError::Auth("failed to extract x coordinate".into()))?; 142 + let y = point 143 + .y() 144 + .ok_or_else(|| AppError::Auth("failed to extract y coordinate".into()))?; 145 + 146 + Ok(serde_json::json!({ 147 + "kty": "EC", 148 + "crv": "P-256", 149 + "x": URL_SAFE_NO_PAD.encode(x), 150 + "y": URL_SAFE_NO_PAD.encode(y), 151 + })) 152 + } 153 + 154 + /// Verify a space credential JWT issued by an external space host. 155 + /// 156 + /// Resolves the issuer's DID document, extracts the `#atproto` signing key, 157 + /// and verifies the JWT signature and expiry. 158 + pub async fn verify_external_credential( 159 + token: &str, 160 + http: &reqwest::Client, 161 + plc_url: &str, 162 + ) -> Result<SpaceCredentialClaims, AppError> { 163 + // Peek at the payload to extract the issuer DID without verifying yet 164 + let parts: Vec<&str> = token.split('.').collect(); 165 + if parts.len() != 3 { 166 + return Err(AppError::Auth("invalid credential format".into())); 167 + } 168 + 169 + let payload_bytes = URL_SAFE_NO_PAD 170 + .decode(parts[1]) 171 + .map_err(|_| AppError::Auth("invalid credential payload encoding".into()))?; 172 + let peek: SpaceCredentialClaims = serde_json::from_slice(&payload_bytes) 173 + .map_err(|_| AppError::Auth("invalid credential payload".into()))?; 174 + 175 + let did_doc = profile::resolve_did_document(http, plc_url, &peek.iss).await?; 176 + 177 + let vm = did_doc 178 + .verification_method 179 + .iter() 180 + .find(|v| v.id.ends_with("#atproto")) 181 + .ok_or_else(|| AppError::Auth("issuer DID has no #atproto verification method".into()))?; 182 + 183 + let multibase = vm 184 + .public_key_multibase 185 + .as_deref() 186 + .ok_or_else(|| AppError::Auth("verification method missing publicKeyMultibase".into()))?; 187 + 188 + let jwk = multikey_to_p256_jwk(multibase)?; 189 + verify_credential(token, &jwk) 190 + } 191 + 192 + #[cfg(test)] 193 + mod tests { 194 + use super::*; 195 + use crate::oauth::keys::generate_dpop_keypair; 196 + 197 + fn make_claims() -> SpaceCredentialClaims { 198 + let now = std::time::SystemTime::now() 199 + .duration_since(std::time::UNIX_EPOCH) 200 + .unwrap() 201 + .as_secs(); 202 + SpaceCredentialClaims { 203 + iss: "did:plc:spaceowner".into(), 204 + sub: "did:plc:requester".into(), 205 + space: "did:plc:spaceowner/com.example.forum/main".into(), 206 + scope: "read".into(), 207 + iat: now, 208 + exp: now + DEFAULT_CREDENTIAL_TTL_SECS, 209 + } 210 + } 211 + 212 + #[test] 213 + fn sign_and_verify_roundtrip() { 214 + let keypair = generate_dpop_keypair().unwrap(); 215 + let claims = make_claims(); 216 + 217 + let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); 218 + let verified = verify_credential(&token, &keypair.public_jwk).unwrap(); 219 + 220 + assert_eq!(verified.iss, claims.iss); 221 + assert_eq!(verified.sub, claims.sub); 222 + assert_eq!(verified.space, claims.space); 223 + assert_eq!(verified.scope, claims.scope); 224 + assert_eq!(verified.iat, claims.iat); 225 + assert_eq!(verified.exp, claims.exp); 226 + } 227 + 228 + #[test] 229 + fn verify_rejects_tampered_payload() { 230 + let keypair = generate_dpop_keypair().unwrap(); 231 + let claims = make_claims(); 232 + let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); 233 + 234 + // Tamper with the payload 235 + let parts: Vec<&str> = token.split('.').collect(); 236 + let mut payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); 237 + payload_bytes[0] ^= 0xFF; 238 + let tampered_payload = URL_SAFE_NO_PAD.encode(&payload_bytes); 239 + let tampered = format!("{}.{}.{}", parts[0], tampered_payload, parts[2]); 240 + 241 + let result = verify_credential(&tampered, &keypair.public_jwk); 242 + assert!(result.is_err()); 243 + } 244 + 245 + #[test] 246 + fn verify_rejects_wrong_key() { 247 + let keypair1 = generate_dpop_keypair().unwrap(); 248 + let keypair2 = generate_dpop_keypair().unwrap(); 249 + let claims = make_claims(); 250 + let token = sign_credential(&claims, &keypair1.private_jwk).unwrap(); 251 + 252 + let result = verify_credential(&token, &keypair2.public_jwk); 253 + assert!(result.is_err()); 254 + } 255 + 256 + #[test] 257 + fn verify_rejects_expired() { 258 + let keypair = generate_dpop_keypair().unwrap(); 259 + let now = std::time::SystemTime::now() 260 + .duration_since(std::time::UNIX_EPOCH) 261 + .unwrap() 262 + .as_secs(); 263 + let claims = SpaceCredentialClaims { 264 + iss: "did:plc:owner".into(), 265 + sub: "did:plc:user".into(), 266 + space: "did:plc:owner/test/main".into(), 267 + scope: "read".into(), 268 + iat: now - 7200, 269 + exp: now - 3600, // expired 1 hour ago 270 + }; 271 + 272 + let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); 273 + let result = verify_credential(&token, &keypair.public_jwk); 274 + assert!(result.is_err()); 275 + assert!(result.unwrap_err().to_string().contains("expired")); 276 + } 277 + 278 + #[test] 279 + fn verify_rejects_invalid_format() { 280 + let keypair = generate_dpop_keypair().unwrap(); 281 + let result = verify_credential("not-a-jwt", &keypair.public_jwk); 282 + assert!(result.is_err()); 283 + } 284 + }
+780
src/spaces/db.rs
··· 1 + use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; 2 + use crate::error::AppError; 3 + use crate::spaces::types::*; 4 + 5 + // --------------------------------------------------------------------------- 6 + // Spaces 7 + // --------------------------------------------------------------------------- 8 + 9 + pub async fn create_space( 10 + pool: &sqlx::AnyPool, 11 + backend: DatabaseBackend, 12 + space: &Space, 13 + ) -> Result<(), AppError> { 14 + let now = now_rfc3339(); 15 + let config_json = serde_json::to_string(&space.config) 16 + .map_err(|e| AppError::Internal(format!("failed to serialize space config: {e}")))?; 17 + let allowlist_json = space 18 + .app_allowlist 19 + .as_ref() 20 + .map(|v| serde_json::to_string(v).unwrap_or_default()); 21 + let denylist_json = space 22 + .app_denylist 23 + .as_ref() 24 + .map(|v| serde_json::to_string(v).unwrap_or_default()); 25 + 26 + let sql = adapt_sql( 27 + "INSERT INTO spaces (id, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 28 + backend, 29 + ); 30 + 31 + sqlx::query(&sql) 32 + .bind(&space.id) 33 + .bind(&space.owner_did) 34 + .bind(&space.type_nsid) 35 + .bind(&space.skey) 36 + .bind(&space.display_name) 37 + .bind(&space.description) 38 + .bind(space.access_mode.as_str()) 39 + .bind(&allowlist_json) 40 + .bind(&denylist_json) 41 + .bind(&space.managing_app_did) 42 + .bind(&config_json) 43 + .bind(&now) 44 + .bind(&now) 45 + .execute(pool) 46 + .await 47 + .map_err(|e| AppError::Internal(format!("failed to create space: {e}")))?; 48 + 49 + Ok(()) 50 + } 51 + 52 + pub async fn get_space( 53 + pool: &sqlx::AnyPool, 54 + backend: DatabaseBackend, 55 + id: &str, 56 + ) -> Result<Option<Space>, AppError> { 57 + let sql = adapt_sql( 58 + "SELECT id, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, created_at, updated_at FROM spaces WHERE id = ?", 59 + backend, 60 + ); 61 + 62 + let row: Option<SpaceRow> = sqlx::query_as(&sql) 63 + .bind(id) 64 + .fetch_optional(pool) 65 + .await 66 + .map_err(|e| AppError::Internal(format!("failed to get space: {e}")))?; 67 + 68 + row.map(parse_space_row).transpose() 69 + } 70 + 71 + pub async fn get_space_by_address( 72 + pool: &sqlx::AnyPool, 73 + backend: DatabaseBackend, 74 + owner_did: &str, 75 + type_nsid: &str, 76 + skey: &str, 77 + ) -> Result<Option<Space>, AppError> { 78 + let sql = adapt_sql( 79 + "SELECT id, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, created_at, updated_at FROM spaces WHERE owner_did = ? AND type_nsid = ? AND skey = ?", 80 + backend, 81 + ); 82 + 83 + let row: Option<SpaceRow> = sqlx::query_as(&sql) 84 + .bind(owner_did) 85 + .bind(type_nsid) 86 + .bind(skey) 87 + .fetch_optional(pool) 88 + .await 89 + .map_err(|e| AppError::Internal(format!("failed to get space: {e}")))?; 90 + 91 + row.map(parse_space_row).transpose() 92 + } 93 + 94 + pub async fn list_spaces_by_owner( 95 + pool: &sqlx::AnyPool, 96 + backend: DatabaseBackend, 97 + owner_did: &str, 98 + ) -> Result<Vec<Space>, AppError> { 99 + let sql = adapt_sql( 100 + "SELECT id, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, created_at, updated_at FROM spaces WHERE owner_did = ? ORDER BY created_at DESC", 101 + backend, 102 + ); 103 + 104 + let rows: Vec<SpaceRow> = sqlx::query_as(&sql) 105 + .bind(owner_did) 106 + .fetch_all(pool) 107 + .await 108 + .map_err(|e| AppError::Internal(format!("failed to list spaces: {e}")))?; 109 + 110 + rows.into_iter().map(parse_space_row).collect() 111 + } 112 + 113 + pub async fn update_space( 114 + pool: &sqlx::AnyPool, 115 + backend: DatabaseBackend, 116 + space: &Space, 117 + ) -> Result<bool, AppError> { 118 + let now = now_rfc3339(); 119 + let config_json = serde_json::to_string(&space.config) 120 + .map_err(|e| AppError::Internal(format!("failed to serialize space config: {e}")))?; 121 + let allowlist_json = space 122 + .app_allowlist 123 + .as_ref() 124 + .map(|v| serde_json::to_string(v).unwrap_or_default()); 125 + let denylist_json = space 126 + .app_denylist 127 + .as_ref() 128 + .map(|v| serde_json::to_string(v).unwrap_or_default()); 129 + 130 + let sql = adapt_sql( 131 + "UPDATE spaces SET display_name = ?, description = ?, access_mode = ?, app_allowlist = ?, app_denylist = ?, managing_app_did = ?, config = ?, updated_at = ? WHERE id = ?", 132 + backend, 133 + ); 134 + 135 + let result = sqlx::query(&sql) 136 + .bind(&space.display_name) 137 + .bind(&space.description) 138 + .bind(space.access_mode.as_str()) 139 + .bind(&allowlist_json) 140 + .bind(&denylist_json) 141 + .bind(&space.managing_app_did) 142 + .bind(&config_json) 143 + .bind(&now) 144 + .bind(&space.id) 145 + .execute(pool) 146 + .await 147 + .map_err(|e| AppError::Internal(format!("failed to update space: {e}")))?; 148 + 149 + Ok(result.rows_affected() > 0) 150 + } 151 + 152 + pub async fn delete_space( 153 + pool: &sqlx::AnyPool, 154 + backend: DatabaseBackend, 155 + id: &str, 156 + ) -> Result<bool, AppError> { 157 + let sql = adapt_sql("DELETE FROM spaces WHERE id = ?", backend); 158 + 159 + let result = sqlx::query(&sql) 160 + .bind(id) 161 + .execute(pool) 162 + .await 163 + .map_err(|e| AppError::Internal(format!("failed to delete space: {e}")))?; 164 + 165 + Ok(result.rows_affected() > 0) 166 + } 167 + 168 + type SpaceRow = ( 169 + String, 170 + String, 171 + String, 172 + String, 173 + Option<String>, 174 + Option<String>, 175 + String, 176 + Option<String>, 177 + Option<String>, 178 + Option<String>, 179 + String, 180 + String, 181 + String, 182 + ); 183 + 184 + fn parse_space_row(r: SpaceRow) -> Result<Space, AppError> { 185 + let access_mode = AccessMode::parse(&r.6) 186 + .ok_or_else(|| AppError::Internal(format!("invalid access_mode: {}", r.6)))?; 187 + let app_allowlist: Option<Vec<String>> = 188 + r.7.as_deref() 189 + .map(serde_json::from_str) 190 + .transpose() 191 + .map_err(|e| AppError::Internal(format!("invalid app_allowlist: {e}")))?; 192 + let app_denylist: Option<Vec<String>> = 193 + r.8.as_deref() 194 + .map(serde_json::from_str) 195 + .transpose() 196 + .map_err(|e| AppError::Internal(format!("invalid app_denylist: {e}")))?; 197 + let config: SpaceConfig = serde_json::from_str(&r.10) 198 + .map_err(|e| AppError::Internal(format!("invalid space config: {e}")))?; 199 + 200 + Ok(Space { 201 + id: r.0, 202 + owner_did: r.1, 203 + type_nsid: r.2, 204 + skey: r.3, 205 + display_name: r.4, 206 + description: r.5, 207 + access_mode, 208 + app_allowlist, 209 + app_denylist, 210 + managing_app_did: r.9, 211 + config, 212 + created_at: r.11, 213 + updated_at: r.12, 214 + }) 215 + } 216 + 217 + // --------------------------------------------------------------------------- 218 + // Space Members 219 + // --------------------------------------------------------------------------- 220 + 221 + pub async fn add_member( 222 + pool: &sqlx::AnyPool, 223 + backend: DatabaseBackend, 224 + member: &SpaceMember, 225 + ) -> Result<(), AppError> { 226 + let now = now_rfc3339(); 227 + let sql = adapt_sql( 228 + "INSERT INTO space_members (id, space_id, member_did, access, is_delegation, granted_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", 229 + backend, 230 + ); 231 + 232 + sqlx::query(&sql) 233 + .bind(&member.id) 234 + .bind(&member.space_id) 235 + .bind(&member.member_did) 236 + .bind(member.access.as_str()) 237 + .bind(member.is_delegation as i32) 238 + .bind(&member.granted_by) 239 + .bind(&now) 240 + .execute(pool) 241 + .await 242 + .map_err(|e| AppError::Internal(format!("failed to add member: {e}")))?; 243 + 244 + Ok(()) 245 + } 246 + 247 + pub async fn remove_member( 248 + pool: &sqlx::AnyPool, 249 + backend: DatabaseBackend, 250 + space_id: &str, 251 + member_did: &str, 252 + ) -> Result<bool, AppError> { 253 + let sql = adapt_sql( 254 + "DELETE FROM space_members WHERE space_id = ? AND member_did = ?", 255 + backend, 256 + ); 257 + 258 + let result = sqlx::query(&sql) 259 + .bind(space_id) 260 + .bind(member_did) 261 + .execute(pool) 262 + .await 263 + .map_err(|e| AppError::Internal(format!("failed to remove member: {e}")))?; 264 + 265 + Ok(result.rows_affected() > 0) 266 + } 267 + 268 + pub async fn get_member( 269 + pool: &sqlx::AnyPool, 270 + backend: DatabaseBackend, 271 + space_id: &str, 272 + member_did: &str, 273 + ) -> Result<Option<SpaceMember>, AppError> { 274 + let sql = adapt_sql( 275 + "SELECT id, space_id, member_did, access, is_delegation, granted_by, created_at FROM space_members WHERE space_id = ? AND member_did = ?", 276 + backend, 277 + ); 278 + 279 + let row: Option<MemberRow> = sqlx::query_as(&sql) 280 + .bind(space_id) 281 + .bind(member_did) 282 + .fetch_optional(pool) 283 + .await 284 + .map_err(|e| AppError::Internal(format!("failed to get member: {e}")))?; 285 + 286 + row.map(parse_member_row).transpose() 287 + } 288 + 289 + pub async fn list_direct_members( 290 + pool: &sqlx::AnyPool, 291 + backend: DatabaseBackend, 292 + space_id: &str, 293 + ) -> Result<Vec<SpaceMember>, AppError> { 294 + let sql = adapt_sql( 295 + "SELECT id, space_id, member_did, access, is_delegation, granted_by, created_at FROM space_members WHERE space_id = ? ORDER BY created_at ASC", 296 + backend, 297 + ); 298 + 299 + let rows: Vec<MemberRow> = sqlx::query_as(&sql) 300 + .bind(space_id) 301 + .fetch_all(pool) 302 + .await 303 + .map_err(|e| AppError::Internal(format!("failed to list members: {e}")))?; 304 + 305 + rows.into_iter().map(parse_member_row).collect() 306 + } 307 + 308 + pub async fn list_spaces_for_member( 309 + pool: &sqlx::AnyPool, 310 + backend: DatabaseBackend, 311 + member_did: &str, 312 + ) -> Result<Vec<SpaceMember>, AppError> { 313 + let sql = adapt_sql( 314 + "SELECT id, space_id, member_did, access, is_delegation, granted_by, created_at FROM space_members WHERE member_did = ? ORDER BY created_at ASC", 315 + backend, 316 + ); 317 + 318 + let rows: Vec<MemberRow> = sqlx::query_as(&sql) 319 + .bind(member_did) 320 + .fetch_all(pool) 321 + .await 322 + .map_err(|e| AppError::Internal(format!("failed to list spaces for member: {e}")))?; 323 + 324 + rows.into_iter().map(parse_member_row).collect() 325 + } 326 + 327 + type MemberRow = (String, String, String, String, i32, Option<String>, String); 328 + 329 + fn parse_member_row(r: MemberRow) -> Result<SpaceMember, AppError> { 330 + let access = SpaceAccess::parse(&r.3) 331 + .ok_or_else(|| AppError::Internal(format!("invalid access: {}", r.3)))?; 332 + 333 + Ok(SpaceMember { 334 + id: r.0, 335 + space_id: r.1, 336 + member_did: r.2, 337 + access, 338 + is_delegation: r.4 != 0, 339 + granted_by: r.5, 340 + created_at: r.6, 341 + }) 342 + } 343 + 344 + // --------------------------------------------------------------------------- 345 + // Space Records 346 + // --------------------------------------------------------------------------- 347 + 348 + pub async fn upsert_space_record( 349 + pool: &sqlx::AnyPool, 350 + backend: DatabaseBackend, 351 + record: &SpaceRecord, 352 + ) -> Result<(), AppError> { 353 + let now = now_rfc3339(); 354 + let record_json = serde_json::to_string(&record.record) 355 + .map_err(|e| AppError::Internal(format!("failed to serialize record: {e}")))?; 356 + 357 + let sql = match backend { 358 + DatabaseBackend::Sqlite => { 359 + "INSERT OR REPLACE INTO space_records (uri, space_id, author_did, collection, rkey, record, cid, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)".to_string() 360 + } 361 + DatabaseBackend::Postgres => adapt_sql( 362 + "INSERT INTO space_records (uri, space_id, author_did, collection, rkey, record, cid, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, cid = EXCLUDED.cid, indexed_at = EXCLUDED.indexed_at", 363 + backend, 364 + ), 365 + }; 366 + 367 + sqlx::query(&sql) 368 + .bind(&record.uri) 369 + .bind(&record.space_id) 370 + .bind(&record.author_did) 371 + .bind(&record.collection) 372 + .bind(&record.rkey) 373 + .bind(&record_json) 374 + .bind(&record.cid) 375 + .bind(&now) 376 + .execute(pool) 377 + .await 378 + .map_err(|e| AppError::Internal(format!("failed to upsert space record: {e}")))?; 379 + 380 + Ok(()) 381 + } 382 + 383 + pub async fn get_space_record( 384 + pool: &sqlx::AnyPool, 385 + backend: DatabaseBackend, 386 + uri: &str, 387 + ) -> Result<Option<SpaceRecord>, AppError> { 388 + let sql = adapt_sql( 389 + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE uri = ?", 390 + backend, 391 + ); 392 + 393 + let row: Option<RecordRow> = sqlx::query_as(&sql) 394 + .bind(uri) 395 + .fetch_optional(pool) 396 + .await 397 + .map_err(|e| AppError::Internal(format!("failed to get space record: {e}")))?; 398 + 399 + row.map(parse_record_row).transpose() 400 + } 401 + 402 + pub async fn get_space_record_by_parts( 403 + pool: &sqlx::AnyPool, 404 + backend: DatabaseBackend, 405 + space_id: &str, 406 + collection: &str, 407 + rkey: &str, 408 + ) -> Result<Option<SpaceRecord>, AppError> { 409 + let sql = adapt_sql( 410 + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE space_id = ? AND collection = ? AND rkey = ? LIMIT 1", 411 + backend, 412 + ); 413 + 414 + let row: Option<RecordRow> = sqlx::query_as(&sql) 415 + .bind(space_id) 416 + .bind(collection) 417 + .bind(rkey) 418 + .fetch_optional(pool) 419 + .await 420 + .map_err(|e| AppError::Internal(format!("failed to get space record: {e}")))?; 421 + 422 + row.map(parse_record_row).transpose() 423 + } 424 + 425 + pub async fn list_space_records( 426 + pool: &sqlx::AnyPool, 427 + backend: DatabaseBackend, 428 + space_id: &str, 429 + collection: Option<&str>, 430 + limit: i64, 431 + cursor: Option<&str>, 432 + ) -> Result<Vec<SpaceRecord>, AppError> { 433 + let (sql, has_collection, has_cursor) = match (collection, cursor) { 434 + (Some(_), Some(_)) => ( 435 + adapt_sql( 436 + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE space_id = ? AND collection = ? AND indexed_at > ? ORDER BY indexed_at ASC LIMIT ?", 437 + backend, 438 + ), 439 + true, 440 + true, 441 + ), 442 + (Some(_), None) => ( 443 + adapt_sql( 444 + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE space_id = ? AND collection = ? ORDER BY indexed_at ASC LIMIT ?", 445 + backend, 446 + ), 447 + true, 448 + false, 449 + ), 450 + (None, Some(_)) => ( 451 + adapt_sql( 452 + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE space_id = ? AND indexed_at > ? ORDER BY indexed_at ASC LIMIT ?", 453 + backend, 454 + ), 455 + false, 456 + true, 457 + ), 458 + (None, None) => ( 459 + adapt_sql( 460 + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE space_id = ? ORDER BY indexed_at ASC LIMIT ?", 461 + backend, 462 + ), 463 + false, 464 + false, 465 + ), 466 + }; 467 + 468 + let mut query = sqlx::query_as::<_, RecordRow>(&sql).bind(space_id); 469 + 470 + if has_collection { 471 + query = query.bind(collection.unwrap()); 472 + } 473 + if has_cursor { 474 + query = query.bind(cursor.unwrap()); 475 + } 476 + query = query.bind(limit); 477 + 478 + let rows = query 479 + .fetch_all(pool) 480 + .await 481 + .map_err(|e| AppError::Internal(format!("failed to list space records: {e}")))?; 482 + 483 + rows.into_iter().map(parse_record_row).collect() 484 + } 485 + 486 + pub async fn delete_space_record( 487 + pool: &sqlx::AnyPool, 488 + backend: DatabaseBackend, 489 + uri: &str, 490 + ) -> Result<bool, AppError> { 491 + let sql = adapt_sql("DELETE FROM space_records WHERE uri = ?", backend); 492 + 493 + let result = sqlx::query(&sql) 494 + .bind(uri) 495 + .execute(pool) 496 + .await 497 + .map_err(|e| AppError::Internal(format!("failed to delete space record: {e}")))?; 498 + 499 + Ok(result.rows_affected() > 0) 500 + } 501 + 502 + type RecordRow = ( 503 + String, 504 + String, 505 + String, 506 + String, 507 + String, 508 + String, 509 + String, 510 + String, 511 + ); 512 + 513 + fn parse_record_row(r: RecordRow) -> Result<SpaceRecord, AppError> { 514 + let record: serde_json::Value = serde_json::from_str(&r.5) 515 + .map_err(|e| AppError::Internal(format!("invalid record JSON: {e}")))?; 516 + 517 + Ok(SpaceRecord { 518 + uri: r.0, 519 + space_id: r.1, 520 + author_did: r.2, 521 + collection: r.3, 522 + rkey: r.4, 523 + record, 524 + cid: r.6, 525 + indexed_at: r.7, 526 + }) 527 + } 528 + 529 + // --------------------------------------------------------------------------- 530 + // Space Invites 531 + // --------------------------------------------------------------------------- 532 + 533 + pub async fn create_invite( 534 + pool: &sqlx::AnyPool, 535 + backend: DatabaseBackend, 536 + invite: &SpaceInvite, 537 + ) -> Result<(), AppError> { 538 + let now = now_rfc3339(); 539 + let sql = adapt_sql( 540 + "INSERT INTO space_invites (id, space_id, token_hash, created_by, access, max_uses, uses, expires_at, revoked, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 541 + backend, 542 + ); 543 + 544 + sqlx::query(&sql) 545 + .bind(&invite.id) 546 + .bind(&invite.space_id) 547 + .bind(&invite.token_hash) 548 + .bind(&invite.created_by) 549 + .bind(invite.access.as_str()) 550 + .bind(invite.max_uses) 551 + .bind(invite.uses) 552 + .bind(&invite.expires_at) 553 + .bind(invite.revoked as i32) 554 + .bind(&now) 555 + .execute(pool) 556 + .await 557 + .map_err(|e| AppError::Internal(format!("failed to create invite: {e}")))?; 558 + 559 + Ok(()) 560 + } 561 + 562 + pub async fn get_invite_by_token_hash( 563 + pool: &sqlx::AnyPool, 564 + backend: DatabaseBackend, 565 + token_hash: &str, 566 + ) -> Result<Option<SpaceInvite>, AppError> { 567 + let sql = adapt_sql( 568 + "SELECT id, space_id, token_hash, created_by, access, max_uses, uses, expires_at, revoked, created_at FROM space_invites WHERE token_hash = ?", 569 + backend, 570 + ); 571 + 572 + let row: Option<InviteRow> = sqlx::query_as(&sql) 573 + .bind(token_hash) 574 + .fetch_optional(pool) 575 + .await 576 + .map_err(|e| AppError::Internal(format!("failed to get invite: {e}")))?; 577 + 578 + row.map(parse_invite_row).transpose() 579 + } 580 + 581 + pub async fn increment_invite_uses( 582 + pool: &sqlx::AnyPool, 583 + backend: DatabaseBackend, 584 + invite_id: &str, 585 + ) -> Result<(), AppError> { 586 + let sql = adapt_sql( 587 + "UPDATE space_invites SET uses = uses + 1 WHERE id = ?", 588 + backend, 589 + ); 590 + 591 + sqlx::query(&sql) 592 + .bind(invite_id) 593 + .execute(pool) 594 + .await 595 + .map_err(|e| AppError::Internal(format!("failed to increment invite uses: {e}")))?; 596 + 597 + Ok(()) 598 + } 599 + 600 + pub async fn revoke_invite( 601 + pool: &sqlx::AnyPool, 602 + backend: DatabaseBackend, 603 + invite_id: &str, 604 + ) -> Result<bool, AppError> { 605 + let sql = adapt_sql("UPDATE space_invites SET revoked = 1 WHERE id = ?", backend); 606 + 607 + let result = sqlx::query(&sql) 608 + .bind(invite_id) 609 + .execute(pool) 610 + .await 611 + .map_err(|e| AppError::Internal(format!("failed to revoke invite: {e}")))?; 612 + 613 + Ok(result.rows_affected() > 0) 614 + } 615 + 616 + pub async fn list_invites( 617 + pool: &sqlx::AnyPool, 618 + backend: DatabaseBackend, 619 + space_id: &str, 620 + ) -> Result<Vec<SpaceInvite>, AppError> { 621 + let sql = adapt_sql( 622 + "SELECT id, space_id, token_hash, created_by, access, max_uses, uses, expires_at, revoked, created_at FROM space_invites WHERE space_id = ? ORDER BY created_at DESC", 623 + backend, 624 + ); 625 + 626 + let rows: Vec<InviteRow> = sqlx::query_as(&sql) 627 + .bind(space_id) 628 + .fetch_all(pool) 629 + .await 630 + .map_err(|e| AppError::Internal(format!("failed to list invites: {e}")))?; 631 + 632 + rows.into_iter().map(parse_invite_row).collect() 633 + } 634 + 635 + type InviteRow = ( 636 + String, 637 + String, 638 + String, 639 + String, 640 + String, 641 + Option<i64>, 642 + i64, 643 + Option<String>, 644 + i32, 645 + String, 646 + ); 647 + 648 + fn parse_invite_row(r: InviteRow) -> Result<SpaceInvite, AppError> { 649 + let access = SpaceAccess::parse(&r.4) 650 + .ok_or_else(|| AppError::Internal(format!("invalid invite access: {}", r.4)))?; 651 + 652 + Ok(SpaceInvite { 653 + id: r.0, 654 + space_id: r.1, 655 + token_hash: r.2, 656 + created_by: r.3, 657 + access, 658 + max_uses: r.5, 659 + uses: r.6, 660 + expires_at: r.7, 661 + revoked: r.8 != 0, 662 + created_at: r.9, 663 + }) 664 + } 665 + 666 + // --------------------------------------------------------------------------- 667 + // Space Sync State 668 + // --------------------------------------------------------------------------- 669 + 670 + pub async fn get_sync_state( 671 + pool: &sqlx::AnyPool, 672 + backend: DatabaseBackend, 673 + space_id: &str, 674 + member_did: &str, 675 + ) -> Result<Option<SpaceSyncState>, AppError> { 676 + let sql = adapt_sql( 677 + "SELECT id, space_id, member_did, cursor, last_synced_at, status, error FROM space_sync_state WHERE space_id = ? AND member_did = ?", 678 + backend, 679 + ); 680 + 681 + let row: Option<SyncStateRow> = sqlx::query_as(&sql) 682 + .bind(space_id) 683 + .bind(member_did) 684 + .fetch_optional(pool) 685 + .await 686 + .map_err(|e| AppError::Internal(format!("failed to get sync state: {e}")))?; 687 + 688 + row.map(parse_sync_state_row).transpose() 689 + } 690 + 691 + pub async fn upsert_sync_state( 692 + pool: &sqlx::AnyPool, 693 + backend: DatabaseBackend, 694 + state: &SpaceSyncState, 695 + ) -> Result<(), AppError> { 696 + let sql = match backend { 697 + DatabaseBackend::Sqlite => { 698 + "INSERT OR REPLACE INTO space_sync_state (id, space_id, member_did, cursor, last_synced_at, status, error) VALUES (?, ?, ?, ?, ?, ?, ?)".to_string() 699 + } 700 + DatabaseBackend::Postgres => adapt_sql( 701 + "INSERT INTO space_sync_state (id, space_id, member_did, cursor, last_synced_at, status, error) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (space_id, member_did) DO UPDATE SET cursor = EXCLUDED.cursor, last_synced_at = EXCLUDED.last_synced_at, status = EXCLUDED.status, error = EXCLUDED.error", 702 + backend, 703 + ), 704 + }; 705 + 706 + sqlx::query(&sql) 707 + .bind(&state.id) 708 + .bind(&state.space_id) 709 + .bind(&state.member_did) 710 + .bind(&state.cursor) 711 + .bind(&state.last_synced_at) 712 + .bind(state.status.as_str()) 713 + .bind(&state.error) 714 + .execute(pool) 715 + .await 716 + .map_err(|e| AppError::Internal(format!("failed to upsert sync state: {e}")))?; 717 + 718 + Ok(()) 719 + } 720 + 721 + pub async fn list_sync_states_for_space( 722 + pool: &sqlx::AnyPool, 723 + backend: DatabaseBackend, 724 + space_id: &str, 725 + ) -> Result<Vec<SpaceSyncState>, AppError> { 726 + let sql = adapt_sql( 727 + "SELECT id, space_id, member_did, cursor, last_synced_at, status, error FROM space_sync_state WHERE space_id = ? ORDER BY member_did ASC", 728 + backend, 729 + ); 730 + 731 + let rows: Vec<SyncStateRow> = sqlx::query_as(&sql) 732 + .bind(space_id) 733 + .fetch_all(pool) 734 + .await 735 + .map_err(|e| AppError::Internal(format!("failed to list sync states: {e}")))?; 736 + 737 + rows.into_iter().map(parse_sync_state_row).collect() 738 + } 739 + 740 + pub async fn list_pending_syncs( 741 + pool: &sqlx::AnyPool, 742 + backend: DatabaseBackend, 743 + ) -> Result<Vec<SpaceSyncState>, AppError> { 744 + let sql = adapt_sql( 745 + "SELECT id, space_id, member_did, cursor, last_synced_at, status, error FROM space_sync_state WHERE status = 'pending' OR status = 'error' ORDER BY last_synced_at ASC NULLS FIRST LIMIT 50", 746 + backend, 747 + ); 748 + 749 + let rows: Vec<SyncStateRow> = sqlx::query_as(&sql) 750 + .fetch_all(pool) 751 + .await 752 + .map_err(|e| AppError::Internal(format!("failed to list pending syncs: {e}")))?; 753 + 754 + rows.into_iter().map(parse_sync_state_row).collect() 755 + } 756 + 757 + type SyncStateRow = ( 758 + String, 759 + String, 760 + String, 761 + Option<String>, 762 + Option<String>, 763 + String, 764 + Option<String>, 765 + ); 766 + 767 + fn parse_sync_state_row(r: SyncStateRow) -> Result<SpaceSyncState, AppError> { 768 + let status = SyncStatus::parse(&r.5) 769 + .ok_or_else(|| AppError::Internal(format!("invalid sync status: {}", r.5)))?; 770 + 771 + Ok(SpaceSyncState { 772 + id: r.0, 773 + space_id: r.1, 774 + member_did: r.2, 775 + cursor: r.3, 776 + last_synced_at: r.4, 777 + status, 778 + error: r.6, 779 + }) 780 + }
+142
src/spaces/members.rs
··· 1 + use std::collections::{HashMap, HashSet}; 2 + 3 + use crate::db::DatabaseBackend; 4 + use crate::error::AppError; 5 + use crate::spaces::SpaceUri; 6 + use crate::spaces::db; 7 + use crate::spaces::types::{ResolvedMember, SpaceAccess, SpaceMember}; 8 + 9 + const MAX_DELEGATION_DEPTH: usize = 10; 10 + 11 + /// Resolve the full member list for a space, traversing delegation references. 12 + /// 13 + /// When a space delegates to another space (is_delegation=true), the delegated 14 + /// space's members are included in the result. If both a direct membership and 15 + /// a delegated membership exist for the same DID, the higher access level wins 16 + /// (write > read). 17 + pub async fn resolve_members( 18 + pool: &sqlx::AnyPool, 19 + backend: DatabaseBackend, 20 + space_id: &str, 21 + ) -> Result<Vec<ResolvedMember>, AppError> { 22 + let mut resolved: HashMap<String, SpaceAccess> = HashMap::new(); 23 + let mut visited: HashSet<String> = HashSet::new(); 24 + 25 + resolve_members_recursive(pool, backend, space_id, &mut resolved, &mut visited, 0).await?; 26 + 27 + let mut members: Vec<ResolvedMember> = resolved 28 + .into_iter() 29 + .map(|(did, access)| ResolvedMember { did, access }) 30 + .collect(); 31 + members.sort_by(|a, b| a.did.cmp(&b.did)); 32 + Ok(members) 33 + } 34 + 35 + /// Check if a DID is a member of a space (resolving delegations). 36 + pub async fn is_member( 37 + pool: &sqlx::AnyPool, 38 + backend: DatabaseBackend, 39 + space_id: &str, 40 + did: &str, 41 + ) -> Result<Option<SpaceAccess>, AppError> { 42 + let members = resolve_members(pool, backend, space_id).await?; 43 + Ok(members.into_iter().find(|m| m.did == did).map(|m| m.access)) 44 + } 45 + 46 + fn resolve_members_recursive<'a>( 47 + pool: &'a sqlx::AnyPool, 48 + backend: DatabaseBackend, 49 + space_id: &'a str, 50 + resolved: &'a mut HashMap<String, SpaceAccess>, 51 + visited: &'a mut HashSet<String>, 52 + depth: usize, 53 + ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), AppError>> + Send + 'a>> { 54 + Box::pin(async move { 55 + if depth >= MAX_DELEGATION_DEPTH { 56 + return Ok(()); 57 + } 58 + 59 + if !visited.insert(space_id.to_string()) { 60 + return Ok(()); 61 + } 62 + 63 + let direct_members = db::list_direct_members(pool, backend, space_id).await?; 64 + 65 + for member in direct_members { 66 + if member.is_delegation { 67 + let delegated_space_id = resolve_delegation_target(pool, backend, &member).await?; 68 + if let Some(target_id) = delegated_space_id { 69 + resolve_members_recursive( 70 + pool, 71 + backend, 72 + &target_id, 73 + resolved, 74 + visited, 75 + depth + 1, 76 + ) 77 + .await?; 78 + } 79 + } else { 80 + merge_access(resolved, &member.member_did, member.access); 81 + } 82 + } 83 + 84 + Ok(()) 85 + }) 86 + } 87 + 88 + /// Resolve a delegation member entry to the target space ID. 89 + /// 90 + /// Delegation entries store either an ats:// URI or a space ID directly. 91 + async fn resolve_delegation_target( 92 + pool: &sqlx::AnyPool, 93 + backend: DatabaseBackend, 94 + member: &SpaceMember, 95 + ) -> Result<Option<String>, AppError> { 96 + if member.member_did.starts_with("ats://") { 97 + let uri = SpaceUri::parse(&member.member_did)?; 98 + let space = 99 + db::get_space_by_address(pool, backend, &uri.owner_did, &uri.type_nsid, &uri.skey) 100 + .await?; 101 + Ok(space.map(|s| s.id)) 102 + } else { 103 + let space = db::get_space(pool, backend, &member.member_did).await?; 104 + Ok(space.map(|s| s.id)) 105 + } 106 + } 107 + 108 + fn merge_access(resolved: &mut HashMap<String, SpaceAccess>, did: &str, access: SpaceAccess) { 109 + let entry = resolved.entry(did.to_string()).or_insert(SpaceAccess::Read); 110 + if access.can_write() { 111 + *entry = SpaceAccess::Write; 112 + } 113 + } 114 + 115 + #[cfg(test)] 116 + mod tests { 117 + use super::*; 118 + 119 + #[test] 120 + fn merge_access_write_wins() { 121 + let mut map = HashMap::new(); 122 + merge_access(&mut map, "did:plc:user1", SpaceAccess::Read); 123 + assert_eq!(map["did:plc:user1"], SpaceAccess::Read); 124 + 125 + merge_access(&mut map, "did:plc:user1", SpaceAccess::Write); 126 + assert_eq!(map["did:plc:user1"], SpaceAccess::Write); 127 + 128 + // Write should not be downgraded to Read 129 + merge_access(&mut map, "did:plc:user1", SpaceAccess::Read); 130 + assert_eq!(map["did:plc:user1"], SpaceAccess::Write); 131 + } 132 + 133 + #[test] 134 + fn merge_access_multiple_users() { 135 + let mut map = HashMap::new(); 136 + merge_access(&mut map, "did:plc:alice", SpaceAccess::Write); 137 + merge_access(&mut map, "did:plc:bob", SpaceAccess::Read); 138 + assert_eq!(map.len(), 2); 139 + assert_eq!(map["did:plc:alice"], SpaceAccess::Write); 140 + assert_eq!(map["did:plc:bob"], SpaceAccess::Read); 141 + } 142 + }
+212
src/spaces/mod.rs
··· 1 + pub mod auth; 2 + pub mod credential; 3 + pub mod db; 4 + pub mod members; 5 + pub mod notifications; 6 + pub mod routes; 7 + pub mod sync; 8 + pub mod types; 9 + 10 + use crate::error::AppError; 11 + use std::fmt; 12 + 13 + /// A parsed `ats://` URI for addressing permissioned data. 14 + /// 15 + /// Full form: `ats://<space-owner-did>/<space-type-nsid>/<skey>/<user-did>/<collection-nsid>/<rkey>` 16 + /// Space-only form: `ats://<space-owner-did>/<space-type-nsid>/<skey>` 17 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 18 + pub struct SpaceUri { 19 + pub owner_did: String, 20 + pub type_nsid: String, 21 + pub skey: String, 22 + pub user_did: Option<String>, 23 + pub collection: Option<String>, 24 + pub rkey: Option<String>, 25 + } 26 + 27 + impl SpaceUri { 28 + pub fn parse(uri: &str) -> Result<Self, AppError> { 29 + let stripped = uri 30 + .strip_prefix("ats://") 31 + .ok_or_else(|| AppError::BadRequest("SpaceUri must start with ats://".into()))?; 32 + 33 + let parts: Vec<&str> = stripped.split('/').collect(); 34 + 35 + if parts.len() < 3 { 36 + return Err(AppError::BadRequest( 37 + "SpaceUri requires at least owner_did/type_nsid/skey".into(), 38 + )); 39 + } 40 + 41 + if parts[0].is_empty() || parts[1].is_empty() || parts[2].is_empty() { 42 + return Err(AppError::BadRequest( 43 + "SpaceUri components must not be empty".into(), 44 + )); 45 + } 46 + 47 + let owner_did = parts[0].to_string(); 48 + let type_nsid = parts[1].to_string(); 49 + let skey = parts[2].to_string(); 50 + 51 + let (user_did, collection, rkey) = if parts.len() >= 6 { 52 + ( 53 + Some(parts[3].to_string()), 54 + Some(parts[4].to_string()), 55 + Some(parts[5].to_string()), 56 + ) 57 + } else if parts.len() == 3 { 58 + (None, None, None) 59 + } else { 60 + return Err(AppError::BadRequest( 61 + "SpaceUri must have 3 components (space) or 6 components (record)".into(), 62 + )); 63 + }; 64 + 65 + Ok(SpaceUri { 66 + owner_did, 67 + type_nsid, 68 + skey, 69 + user_did, 70 + collection, 71 + rkey, 72 + }) 73 + } 74 + 75 + pub fn space_uri(&self) -> String { 76 + format!("ats://{}/{}/{}", self.owner_did, self.type_nsid, self.skey) 77 + } 78 + 79 + pub fn is_record_uri(&self) -> bool { 80 + self.user_did.is_some() 81 + } 82 + 83 + pub fn is_space_uri(&self) -> bool { 84 + self.user_did.is_none() 85 + } 86 + } 87 + 88 + impl fmt::Display for SpaceUri { 89 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 90 + write!( 91 + f, 92 + "ats://{}/{}/{}", 93 + self.owner_did, self.type_nsid, self.skey 94 + )?; 95 + if let (Some(user), Some(col), Some(rkey)) = (&self.user_did, &self.collection, &self.rkey) 96 + { 97 + write!(f, "/{}/{}/{}", user, col, rkey)?; 98 + } 99 + Ok(()) 100 + } 101 + } 102 + 103 + #[cfg(test)] 104 + mod tests { 105 + use super::*; 106 + 107 + #[test] 108 + fn parse_space_uri() { 109 + let uri = SpaceUri::parse("ats://did:plc:abc123/com.example.forum/main").unwrap(); 110 + assert_eq!(uri.owner_did, "did:plc:abc123"); 111 + assert_eq!(uri.type_nsid, "com.example.forum"); 112 + assert_eq!(uri.skey, "main"); 113 + assert!(uri.is_space_uri()); 114 + assert!(!uri.is_record_uri()); 115 + assert_eq!(uri.user_did, None); 116 + } 117 + 118 + #[test] 119 + fn parse_record_uri() { 120 + let uri = SpaceUri::parse( 121 + "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc", 122 + ) 123 + .unwrap(); 124 + assert_eq!(uri.owner_did, "did:plc:abc123"); 125 + assert_eq!(uri.type_nsid, "com.example.forum"); 126 + assert_eq!(uri.skey, "main"); 127 + assert_eq!(uri.user_did.as_deref(), Some("did:plc:user1")); 128 + assert_eq!(uri.collection.as_deref(), Some("com.example.forum.post")); 129 + assert_eq!(uri.rkey.as_deref(), Some("3k2abc")); 130 + assert!(uri.is_record_uri()); 131 + assert!(!uri.is_space_uri()); 132 + } 133 + 134 + #[test] 135 + fn display_space_uri() { 136 + let uri = SpaceUri { 137 + owner_did: "did:plc:abc123".into(), 138 + type_nsid: "com.example.forum".into(), 139 + skey: "main".into(), 140 + user_did: None, 141 + collection: None, 142 + rkey: None, 143 + }; 144 + assert_eq!( 145 + uri.to_string(), 146 + "ats://did:plc:abc123/com.example.forum/main" 147 + ); 148 + } 149 + 150 + #[test] 151 + fn display_record_uri() { 152 + let uri = SpaceUri { 153 + owner_did: "did:plc:abc123".into(), 154 + type_nsid: "com.example.forum".into(), 155 + skey: "main".into(), 156 + user_did: Some("did:plc:user1".into()), 157 + collection: Some("com.example.forum.post".into()), 158 + rkey: Some("3k2abc".into()), 159 + }; 160 + assert_eq!( 161 + uri.to_string(), 162 + "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc" 163 + ); 164 + } 165 + 166 + #[test] 167 + fn space_uri_extracts_space_part() { 168 + let uri = SpaceUri::parse( 169 + "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc", 170 + ) 171 + .unwrap(); 172 + assert_eq!( 173 + uri.space_uri(), 174 + "ats://did:plc:abc123/com.example.forum/main" 175 + ); 176 + } 177 + 178 + #[test] 179 + fn reject_at_scheme() { 180 + let result = SpaceUri::parse("at://did:plc:abc123/com.example.forum/main"); 181 + assert!(result.is_err()); 182 + } 183 + 184 + #[test] 185 + fn reject_too_few_components() { 186 + let result = SpaceUri::parse("ats://did:plc:abc123/com.example.forum"); 187 + assert!(result.is_err()); 188 + } 189 + 190 + #[test] 191 + fn reject_wrong_component_count() { 192 + let result = SpaceUri::parse("ats://did:plc:abc123/com.example.forum/main/did:plc:user1"); 193 + assert!(result.is_err()); 194 + } 195 + 196 + #[test] 197 + fn reject_empty_components() { 198 + let result = SpaceUri::parse("ats:///com.example.forum/main"); 199 + assert!(result.is_err()); 200 + } 201 + 202 + #[test] 203 + fn roundtrip_parse_display() { 204 + let original = "ats://did:plc:abc123/com.example.forum/main"; 205 + let uri = SpaceUri::parse(original).unwrap(); 206 + assert_eq!(uri.to_string(), original); 207 + 208 + let original_record = "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc"; 209 + let uri = SpaceUri::parse(original_record).unwrap(); 210 + assert_eq!(uri.to_string(), original_record); 211 + } 212 + }
+86
src/spaces/notifications.rs
··· 1 + use serde::Deserialize; 2 + use uuid::Uuid; 3 + 4 + use crate::db::DatabaseBackend; 5 + use crate::error::AppError; 6 + use crate::spaces::db; 7 + use crate::spaces::types::*; 8 + 9 + #[derive(Debug, Deserialize)] 10 + #[serde(rename_all = "camelCase")] 11 + pub struct WriteNotification { 12 + pub space_uri: String, 13 + pub author_did: String, 14 + pub collection: String, 15 + pub rkey: String, 16 + pub action: WriteAction, 17 + } 18 + 19 + #[derive(Debug, Deserialize)] 20 + #[serde(rename_all = "lowercase")] 21 + pub enum WriteAction { 22 + Create, 23 + Update, 24 + Delete, 25 + } 26 + 27 + /// Process a write notification by queuing a sync pull for the affected member. 28 + /// 29 + /// This marks the member's sync state as pending so the next sync pass picks it up. 30 + pub async fn handle_write_notification( 31 + pool: &sqlx::AnyPool, 32 + backend: DatabaseBackend, 33 + space_id: &str, 34 + notification: &WriteNotification, 35 + ) -> Result<(), AppError> { 36 + let existing = db::get_sync_state(pool, backend, space_id, &notification.author_did).await?; 37 + 38 + let state = SpaceSyncState { 39 + id: existing 40 + .map(|s| s.id) 41 + .unwrap_or_else(|| Uuid::new_v4().to_string()), 42 + space_id: space_id.to_string(), 43 + member_did: notification.author_did.clone(), 44 + cursor: None, 45 + last_synced_at: None, 46 + status: SyncStatus::Pending, 47 + error: None, 48 + }; 49 + 50 + db::upsert_sync_state(pool, backend, &state).await?; 51 + 52 + Ok(()) 53 + } 54 + 55 + #[cfg(test)] 56 + mod tests { 57 + use super::*; 58 + 59 + #[test] 60 + fn write_action_deserializes() { 61 + let action: WriteAction = serde_json::from_str("\"create\"").unwrap(); 62 + assert!(matches!(action, WriteAction::Create)); 63 + 64 + let action: WriteAction = serde_json::from_str("\"update\"").unwrap(); 65 + assert!(matches!(action, WriteAction::Update)); 66 + 67 + let action: WriteAction = serde_json::from_str("\"delete\"").unwrap(); 68 + assert!(matches!(action, WriteAction::Delete)); 69 + } 70 + 71 + #[test] 72 + fn write_notification_deserializes() { 73 + let json = r#"{ 74 + "spaceUri": "ats://did:plc:owner/com.example.forum/main", 75 + "authorDid": "did:plc:alice", 76 + "collection": "com.example.forum.post", 77 + "rkey": "3k2abc", 78 + "action": "create" 79 + }"#; 80 + 81 + let notif: WriteNotification = serde_json::from_str(json).unwrap(); 82 + assert_eq!(notif.author_did, "did:plc:alice"); 83 + assert_eq!(notif.collection, "com.example.forum.post"); 84 + assert!(matches!(notif.action, WriteAction::Create)); 85 + } 86 + }
+982
src/spaces/routes.rs
··· 1 + use axum::extract::{Query, State}; 2 + use axum::http::{HeaderMap, StatusCode}; 3 + use axum::response::{IntoResponse, Response}; 4 + use axum::routing::{get, post}; 5 + use axum::{Json, Router}; 6 + use serde::Deserialize; 7 + use sha2::{Digest, Sha256}; 8 + use uuid::Uuid; 9 + 10 + use crate::AppState; 11 + use crate::auth::XrpcClaims; 12 + use crate::db::{adapt_sql, now_rfc3339}; 13 + use crate::error::AppError; 14 + use crate::spaces::types::*; 15 + use crate::spaces::{SpaceUri, db, members}; 16 + 17 + // --------------------------------------------------------------------------- 18 + // Request / response types 19 + // --------------------------------------------------------------------------- 20 + 21 + #[derive(Deserialize)] 22 + #[serde(rename_all = "camelCase")] 23 + struct CreateSpaceInput { 24 + type_nsid: String, 25 + skey: String, 26 + display_name: Option<String>, 27 + description: Option<String>, 28 + access_mode: Option<AccessMode>, 29 + managing_app_did: Option<String>, 30 + config: Option<SpaceConfig>, 31 + } 32 + 33 + #[derive(Deserialize)] 34 + #[serde(rename_all = "camelCase")] 35 + struct SpaceUriQuery { 36 + space_uri: String, 37 + } 38 + 39 + #[derive(Deserialize)] 40 + #[serde(rename_all = "camelCase")] 41 + struct ListSpacesQuery { 42 + owner_did: Option<String>, 43 + } 44 + 45 + #[derive(Deserialize)] 46 + #[serde(rename_all = "camelCase")] 47 + struct DeleteSpaceInput { 48 + space_uri: String, 49 + } 50 + 51 + #[derive(Deserialize)] 52 + #[serde(rename_all = "camelCase")] 53 + struct UpdateSpaceInput { 54 + space_uri: String, 55 + display_name: Option<Option<String>>, 56 + description: Option<Option<String>>, 57 + access_mode: Option<AccessMode>, 58 + app_allowlist: Option<Option<Vec<String>>>, 59 + app_denylist: Option<Option<Vec<String>>>, 60 + managing_app_did: Option<Option<String>>, 61 + config: Option<SpaceConfig>, 62 + } 63 + 64 + #[derive(Deserialize)] 65 + #[serde(rename_all = "camelCase")] 66 + struct PutRecordInput { 67 + space_uri: String, 68 + collection: String, 69 + rkey: String, 70 + record: serde_json::Value, 71 + } 72 + 73 + #[derive(Deserialize)] 74 + #[serde(rename_all = "camelCase")] 75 + struct DeleteRecordInput { 76 + space_uri: String, 77 + collection: String, 78 + rkey: String, 79 + } 80 + 81 + #[derive(Deserialize)] 82 + #[serde(rename_all = "camelCase")] 83 + struct GetRecordQuery { 84 + space_uri: String, 85 + collection: String, 86 + rkey: String, 87 + } 88 + 89 + #[derive(Deserialize)] 90 + #[serde(rename_all = "camelCase")] 91 + struct ListRecordsQuery { 92 + space_uri: String, 93 + collection: Option<String>, 94 + limit: Option<i64>, 95 + cursor: Option<String>, 96 + } 97 + 98 + #[derive(Deserialize)] 99 + #[serde(rename_all = "camelCase")] 100 + struct AddMemberInput { 101 + space_uri: String, 102 + member_did: String, 103 + access: Option<SpaceAccess>, 104 + is_delegation: Option<bool>, 105 + } 106 + 107 + #[derive(Deserialize)] 108 + #[serde(rename_all = "camelCase")] 109 + struct RemoveMemberInput { 110 + space_uri: String, 111 + member_did: String, 112 + } 113 + 114 + #[derive(Deserialize)] 115 + #[serde(rename_all = "camelCase")] 116 + struct CreateInviteInput { 117 + space_uri: String, 118 + access: Option<SpaceAccess>, 119 + max_uses: Option<i64>, 120 + expires_at: Option<String>, 121 + } 122 + 123 + #[derive(Deserialize)] 124 + #[serde(rename_all = "camelCase")] 125 + struct RedeemInviteInput { 126 + token: String, 127 + } 128 + 129 + #[derive(Deserialize)] 130 + #[serde(rename_all = "camelCase")] 131 + struct RevokeInviteInput { 132 + space_uri: String, 133 + invite_id: String, 134 + } 135 + 136 + #[derive(Deserialize)] 137 + #[serde(rename_all = "camelCase")] 138 + struct GetCredentialInput { 139 + space_uri: String, 140 + } 141 + 142 + #[derive(Deserialize)] 143 + #[serde(rename_all = "camelCase")] 144 + struct RefreshCredentialInput { 145 + space_uri: String, 146 + credential: String, 147 + } 148 + 149 + #[derive(Deserialize)] 150 + #[serde(rename_all = "camelCase")] 151 + struct WriteNotificationInput { 152 + space_uri: String, 153 + author_did: String, 154 + collection: String, 155 + rkey: String, 156 + action: crate::spaces::notifications::WriteAction, 157 + } 158 + 159 + // --------------------------------------------------------------------------- 160 + // Route registration 161 + // --------------------------------------------------------------------------- 162 + 163 + const NS: &str = "dev.happyview"; 164 + 165 + pub fn space_routes() -> Router<AppState> { 166 + Router::new() 167 + // Space CRUD 168 + .route(&format!("/xrpc/{NS}.space.create"), post(create_space)) 169 + .route(&format!("/xrpc/{NS}.space.get"), get(get_space)) 170 + .route(&format!("/xrpc/{NS}.space.list"), get(list_spaces)) 171 + .route(&format!("/xrpc/{NS}.space.delete"), post(delete_space)) 172 + .route(&format!("/xrpc/{NS}.space.update"), post(update_space)) 173 + // Records 174 + .route(&format!("/xrpc/{NS}.space.putRecord"), post(put_record)) 175 + .route( 176 + &format!("/xrpc/{NS}.space.deleteRecord"), 177 + post(delete_record), 178 + ) 179 + .route(&format!("/xrpc/{NS}.space.getRecord"), get(get_record)) 180 + .route(&format!("/xrpc/{NS}.space.listRecords"), get(list_records)) 181 + // Members 182 + .route(&format!("/xrpc/{NS}.space.listMembers"), get(list_members)) 183 + .route(&format!("/xrpc/{NS}.space.addMember"), post(add_member)) 184 + .route( 185 + &format!("/xrpc/{NS}.space.removeMember"), 186 + post(remove_member), 187 + ) 188 + // Invites 189 + .route( 190 + &format!("/xrpc/{NS}.space.invite.create"), 191 + post(create_invite), 192 + ) 193 + .route( 194 + &format!("/xrpc/{NS}.space.invite.redeem"), 195 + post(redeem_invite), 196 + ) 197 + .route( 198 + &format!("/xrpc/{NS}.space.invite.revoke"), 199 + post(revoke_invite), 200 + ) 201 + .route(&format!("/xrpc/{NS}.space.invite.list"), get(list_invites)) 202 + // Credentials 203 + .route( 204 + &format!("/xrpc/{NS}.space.getCredential"), 205 + post(get_credential), 206 + ) 207 + .route( 208 + &format!("/xrpc/{NS}.space.refreshCredential"), 209 + post(refresh_credential), 210 + ) 211 + // Notifications 212 + .route( 213 + &format!("/xrpc/{NS}.space.writeNotification"), 214 + post(write_notification), 215 + ) 216 + } 217 + 218 + // --------------------------------------------------------------------------- 219 + // Helpers 220 + // --------------------------------------------------------------------------- 221 + 222 + fn require_auth(claims: &XrpcClaims) -> Result<&crate::auth::Claims, AppError> { 223 + claims 224 + .0 225 + .as_ref() 226 + .ok_or_else(|| AppError::Auth("This endpoint requires DPoP authentication".into())) 227 + } 228 + 229 + async fn resolve_space(state: &AppState, space_uri: &str) -> Result<Space, AppError> { 230 + let uri = SpaceUri::parse(space_uri)?; 231 + db::get_space_by_address( 232 + &state.db, 233 + state.db_backend, 234 + &uri.owner_did, 235 + &uri.type_nsid, 236 + &uri.skey, 237 + ) 238 + .await? 239 + .ok_or_else(|| AppError::NotFound("Space not found".into())) 240 + } 241 + 242 + async fn require_space_admin(state: &AppState, space: &Space, did: &str) -> Result<(), AppError> { 243 + if space.owner_did == did { 244 + return Ok(()); 245 + } 246 + let sql = adapt_sql("SELECT is_super FROM users WHERE did = ?", state.db_backend); 247 + let row: Option<(i32,)> = sqlx::query_as(&sql) 248 + .bind(did) 249 + .fetch_optional(&state.db) 250 + .await 251 + .map_err(|e| AppError::Internal(format!("failed to check admin status: {e}")))?; 252 + if row.is_some_and(|(is_super,)| is_super != 0) { 253 + return Ok(()); 254 + } 255 + Err(AppError::Forbidden( 256 + "Only the space owner can perform this action".into(), 257 + )) 258 + } 259 + 260 + fn extract_space_credential(headers: &HeaderMap) -> Option<String> { 261 + headers 262 + .get("x-space-credential") 263 + .and_then(|v| v.to_str().ok()) 264 + .map(|s| s.to_string()) 265 + } 266 + 267 + async fn require_membership( 268 + state: &AppState, 269 + space: &Space, 270 + did: &str, 271 + require_write: bool, 272 + space_credential: Option<&str>, 273 + ) -> Result<SpaceAccess, AppError> { 274 + if let Some(token) = space_credential { 275 + let space_uri = format!( 276 + "ats://{}/{}/{}", 277 + space.owner_did, space.type_nsid, space.skey 278 + ); 279 + match crate::spaces::credential::verify_external_credential( 280 + token, 281 + &state.http, 282 + &state.config.plc_url, 283 + ) 284 + .await 285 + { 286 + Ok(claims) if claims.space == space_uri => { 287 + let access = match claims.scope.as_str() { 288 + "write" => SpaceAccess::Write, 289 + _ => SpaceAccess::Read, 290 + }; 291 + if require_write && !access.can_write() { 292 + return Err(AppError::Forbidden( 293 + "Write access is required for this action".into(), 294 + )); 295 + } 296 + return Ok(access); 297 + } 298 + Ok(_) => { 299 + // Credential is valid but for a different space — fall through 300 + } 301 + Err(_) => { 302 + // External verification failed — fall through to local check 303 + } 304 + } 305 + } 306 + 307 + let access = members::is_member(&state.db, state.db_backend, &space.id, did) 308 + .await? 309 + .ok_or_else(|| AppError::Forbidden("You are not a member of this space".into()))?; 310 + if require_write && !access.can_write() { 311 + return Err(AppError::Forbidden( 312 + "Write access is required for this action".into(), 313 + )); 314 + } 315 + Ok(access) 316 + } 317 + 318 + fn content_cid(record: &serde_json::Value) -> String { 319 + let bytes = serde_json::to_vec(record).unwrap_or_default(); 320 + let hash = Sha256::digest(&bytes); 321 + format!("bafyrei{}", hex::encode(&hash[..20])) 322 + } 323 + 324 + // --------------------------------------------------------------------------- 325 + // Space CRUD handlers 326 + // --------------------------------------------------------------------------- 327 + 328 + async fn create_space( 329 + State(state): State<AppState>, 330 + xrpc_claims: XrpcClaims, 331 + Json(input): Json<CreateSpaceInput>, 332 + ) -> Result<Response, AppError> { 333 + let claims = require_auth(&xrpc_claims)?; 334 + let did = claims.did().to_string(); 335 + 336 + if input.type_nsid.is_empty() || input.skey.is_empty() { 337 + return Err(AppError::BadRequest( 338 + "type_nsid and skey are required".into(), 339 + )); 340 + } 341 + 342 + let existing = db::get_space_by_address( 343 + &state.db, 344 + state.db_backend, 345 + &did, 346 + &input.type_nsid, 347 + &input.skey, 348 + ) 349 + .await?; 350 + if existing.is_some() { 351 + return Err(AppError::Conflict( 352 + "A space with this address already exists".into(), 353 + )); 354 + } 355 + 356 + let space = Space { 357 + id: Uuid::new_v4().to_string(), 358 + owner_did: did.clone(), 359 + type_nsid: input.type_nsid, 360 + skey: input.skey, 361 + display_name: input.display_name, 362 + description: input.description, 363 + access_mode: input.access_mode.unwrap_or(AccessMode::DefaultAllow), 364 + app_allowlist: None, 365 + app_denylist: None, 366 + managing_app_did: input.managing_app_did, 367 + config: input.config.unwrap_or_default(), 368 + created_at: now_rfc3339(), 369 + updated_at: now_rfc3339(), 370 + }; 371 + 372 + db::create_space(&state.db, state.db_backend, &space).await?; 373 + 374 + // Auto-add the creator as a write member 375 + let member = SpaceMember { 376 + id: Uuid::new_v4().to_string(), 377 + space_id: space.id.clone(), 378 + member_did: did.clone(), 379 + access: SpaceAccess::Write, 380 + is_delegation: false, 381 + granted_by: Some(did), 382 + created_at: now_rfc3339(), 383 + }; 384 + db::add_member(&state.db, state.db_backend, &member).await?; 385 + 386 + let space_uri = format!( 387 + "ats://{}/{}/{}", 388 + space.owner_did, space.type_nsid, space.skey 389 + ); 390 + let body = serde_json::json!({ 391 + "spaceUri": space_uri, 392 + "space": space, 393 + }); 394 + 395 + let mut response = Json(body).into_response(); 396 + *response.status_mut() = StatusCode::CREATED; 397 + Ok(response) 398 + } 399 + 400 + async fn get_space( 401 + State(state): State<AppState>, 402 + xrpc_claims: XrpcClaims, 403 + Query(query): Query<SpaceUriQuery>, 404 + ) -> Result<Json<serde_json::Value>, AppError> { 405 + let space = resolve_space(&state, &query.space_uri).await?; 406 + 407 + // If the space's membership is not public, require auth + membership 408 + if !space.config.membership_public { 409 + let claims = require_auth(&xrpc_claims)?; 410 + let did = claims.did(); 411 + if space.owner_did != did { 412 + members::is_member(&state.db, state.db_backend, &space.id, did) 413 + .await? 414 + .ok_or_else(|| AppError::NotFound("Space not found".into()))?; 415 + } 416 + } 417 + 418 + let space_uri = format!( 419 + "ats://{}/{}/{}", 420 + space.owner_did, space.type_nsid, space.skey 421 + ); 422 + Ok(Json(serde_json::json!({ 423 + "spaceUri": space_uri, 424 + "space": space, 425 + }))) 426 + } 427 + 428 + async fn list_spaces( 429 + State(state): State<AppState>, 430 + xrpc_claims: XrpcClaims, 431 + Query(query): Query<ListSpacesQuery>, 432 + ) -> Result<Json<serde_json::Value>, AppError> { 433 + let claims = require_auth(&xrpc_claims)?; 434 + let did = claims.did().to_string(); 435 + 436 + let owner = query.owner_did.as_deref().unwrap_or(&did); 437 + let spaces = db::list_spaces_by_owner(&state.db, state.db_backend, owner).await?; 438 + 439 + let spaces_with_uris: Vec<serde_json::Value> = spaces 440 + .into_iter() 441 + .map(|s| { 442 + let uri = format!("ats://{}/{}/{}", s.owner_did, s.type_nsid, s.skey); 443 + serde_json::json!({ "spaceUri": uri, "space": s }) 444 + }) 445 + .collect(); 446 + 447 + Ok(Json(serde_json::json!({ "spaces": spaces_with_uris }))) 448 + } 449 + 450 + async fn delete_space( 451 + State(state): State<AppState>, 452 + xrpc_claims: XrpcClaims, 453 + Json(input): Json<DeleteSpaceInput>, 454 + ) -> Result<Json<serde_json::Value>, AppError> { 455 + let claims = require_auth(&xrpc_claims)?; 456 + let space = resolve_space(&state, &input.space_uri).await?; 457 + require_space_admin(&state, &space, claims.did()).await?; 458 + 459 + db::delete_space(&state.db, state.db_backend, &space.id).await?; 460 + 461 + Ok(Json(serde_json::json!({ "success": true }))) 462 + } 463 + 464 + async fn update_space( 465 + State(state): State<AppState>, 466 + xrpc_claims: XrpcClaims, 467 + Json(input): Json<UpdateSpaceInput>, 468 + ) -> Result<Json<serde_json::Value>, AppError> { 469 + let claims = require_auth(&xrpc_claims)?; 470 + let mut space = resolve_space(&state, &input.space_uri).await?; 471 + require_space_admin(&state, &space, claims.did()).await?; 472 + 473 + if let Some(name) = input.display_name { 474 + space.display_name = name; 475 + } 476 + if let Some(desc) = input.description { 477 + space.description = desc; 478 + } 479 + if let Some(mode) = input.access_mode { 480 + space.access_mode = mode; 481 + } 482 + if let Some(list) = input.app_allowlist { 483 + space.app_allowlist = list; 484 + } 485 + if let Some(list) = input.app_denylist { 486 + space.app_denylist = list; 487 + } 488 + if let Some(did) = input.managing_app_did { 489 + space.managing_app_did = did; 490 + } 491 + if let Some(config) = input.config { 492 + space.config = config; 493 + } 494 + 495 + db::update_space(&state.db, state.db_backend, &space).await?; 496 + 497 + let space_uri = format!( 498 + "ats://{}/{}/{}", 499 + space.owner_did, space.type_nsid, space.skey 500 + ); 501 + Ok(Json(serde_json::json!({ 502 + "spaceUri": space_uri, 503 + "space": space, 504 + }))) 505 + } 506 + 507 + // --------------------------------------------------------------------------- 508 + // Record handlers 509 + // --------------------------------------------------------------------------- 510 + 511 + async fn put_record( 512 + State(state): State<AppState>, 513 + xrpc_claims: XrpcClaims, 514 + headers: HeaderMap, 515 + Json(input): Json<PutRecordInput>, 516 + ) -> Result<Response, AppError> { 517 + let claims = require_auth(&xrpc_claims)?; 518 + let did = claims.did().to_string(); 519 + let space = resolve_space(&state, &input.space_uri).await?; 520 + let cred = extract_space_credential(&headers); 521 + require_membership(&state, &space, &did, true, cred.as_deref()).await?; 522 + 523 + let cid = content_cid(&input.record); 524 + let record_uri = format!( 525 + "ats://{}/{}/{}/{}/{}/{}", 526 + space.owner_did, space.type_nsid, space.skey, did, input.collection, input.rkey 527 + ); 528 + 529 + let record = SpaceRecord { 530 + uri: record_uri.clone(), 531 + space_id: space.id, 532 + author_did: did, 533 + collection: input.collection, 534 + rkey: input.rkey, 535 + record: input.record, 536 + cid: cid.clone(), 537 + indexed_at: now_rfc3339(), 538 + }; 539 + 540 + db::upsert_space_record(&state.db, state.db_backend, &record).await?; 541 + 542 + let body = serde_json::json!({ 543 + "uri": record_uri, 544 + "cid": cid, 545 + }); 546 + 547 + let mut response = Json(body).into_response(); 548 + *response.status_mut() = StatusCode::CREATED; 549 + Ok(response) 550 + } 551 + 552 + async fn delete_record( 553 + State(state): State<AppState>, 554 + xrpc_claims: XrpcClaims, 555 + Json(input): Json<DeleteRecordInput>, 556 + ) -> Result<Json<serde_json::Value>, AppError> { 557 + let claims = require_auth(&xrpc_claims)?; 558 + let did = claims.did().to_string(); 559 + let space = resolve_space(&state, &input.space_uri).await?; 560 + 561 + let record_uri = format!( 562 + "ats://{}/{}/{}/{}/{}/{}", 563 + space.owner_did, space.type_nsid, space.skey, did, input.collection, input.rkey 564 + ); 565 + 566 + let record = db::get_space_record(&state.db, state.db_backend, &record_uri).await?; 567 + match record { 568 + Some(r) if r.author_did != did => { 569 + return Err(AppError::Forbidden( 570 + "You can only delete your own records".into(), 571 + )); 572 + } 573 + None => { 574 + return Err(AppError::NotFound("Record not found".into())); 575 + } 576 + _ => {} 577 + } 578 + 579 + db::delete_space_record(&state.db, state.db_backend, &record_uri).await?; 580 + 581 + Ok(Json(serde_json::json!({ "success": true }))) 582 + } 583 + 584 + async fn get_record( 585 + State(state): State<AppState>, 586 + xrpc_claims: XrpcClaims, 587 + headers: HeaderMap, 588 + Query(query): Query<GetRecordQuery>, 589 + ) -> Result<Json<serde_json::Value>, AppError> { 590 + let claims = require_auth(&xrpc_claims)?; 591 + let space = resolve_space(&state, &query.space_uri).await?; 592 + let cred = extract_space_credential(&headers); 593 + require_membership(&state, &space, claims.did(), false, cred.as_deref()).await?; 594 + 595 + let record = db::get_space_record_by_parts( 596 + &state.db, 597 + state.db_backend, 598 + &space.id, 599 + &query.collection, 600 + &query.rkey, 601 + ) 602 + .await? 603 + .ok_or_else(|| AppError::NotFound("Record not found".into()))?; 604 + 605 + Ok(Json(serde_json::json!({ 606 + "uri": record.uri, 607 + "space": query.space_uri, 608 + "collection": record.collection, 609 + "record": record.record, 610 + "cid": record.cid, 611 + }))) 612 + } 613 + 614 + async fn list_records( 615 + State(state): State<AppState>, 616 + xrpc_claims: XrpcClaims, 617 + headers: HeaderMap, 618 + Query(query): Query<ListRecordsQuery>, 619 + ) -> Result<Json<serde_json::Value>, AppError> { 620 + let claims = require_auth(&xrpc_claims)?; 621 + let space = resolve_space(&state, &query.space_uri).await?; 622 + let cred = extract_space_credential(&headers); 623 + require_membership(&state, &space, claims.did(), false, cred.as_deref()).await?; 624 + 625 + let limit = query.limit.unwrap_or(50).min(100); 626 + let records = db::list_space_records( 627 + &state.db, 628 + state.db_backend, 629 + &space.id, 630 + query.collection.as_deref(), 631 + limit, 632 + query.cursor.as_deref(), 633 + ) 634 + .await?; 635 + 636 + let cursor = records.last().map(|r| r.indexed_at.clone()); 637 + 638 + let records_json: Vec<serde_json::Value> = records 639 + .into_iter() 640 + .map(|r| { 641 + serde_json::json!({ 642 + "uri": r.uri, 643 + "space": query.space_uri, 644 + "collection": r.collection, 645 + "record": r.record, 646 + "cid": r.cid, 647 + }) 648 + }) 649 + .collect(); 650 + 651 + Ok(Json(serde_json::json!({ 652 + "records": records_json, 653 + "cursor": cursor, 654 + }))) 655 + } 656 + 657 + // --------------------------------------------------------------------------- 658 + // Member handlers 659 + // --------------------------------------------------------------------------- 660 + 661 + async fn list_members( 662 + State(state): State<AppState>, 663 + xrpc_claims: XrpcClaims, 664 + headers: HeaderMap, 665 + Query(query): Query<SpaceUriQuery>, 666 + ) -> Result<Json<serde_json::Value>, AppError> { 667 + let space = resolve_space(&state, &query.space_uri).await?; 668 + 669 + if !space.config.membership_public { 670 + let claims = require_auth(&xrpc_claims)?; 671 + let cred = extract_space_credential(&headers); 672 + require_membership(&state, &space, claims.did(), false, cred.as_deref()).await?; 673 + } 674 + 675 + let resolved = members::resolve_members(&state.db, state.db_backend, &space.id).await?; 676 + 677 + Ok(Json(serde_json::json!({ "members": resolved }))) 678 + } 679 + 680 + async fn add_member( 681 + State(state): State<AppState>, 682 + xrpc_claims: XrpcClaims, 683 + Json(input): Json<AddMemberInput>, 684 + ) -> Result<Response, AppError> { 685 + let claims = require_auth(&xrpc_claims)?; 686 + let space = resolve_space(&state, &input.space_uri).await?; 687 + require_space_admin(&state, &space, claims.did()).await?; 688 + 689 + let existing = 690 + db::get_member(&state.db, state.db_backend, &space.id, &input.member_did).await?; 691 + if existing.is_some() { 692 + return Err(AppError::Conflict( 693 + "Member already exists in this space".into(), 694 + )); 695 + } 696 + 697 + let member = SpaceMember { 698 + id: Uuid::new_v4().to_string(), 699 + space_id: space.id, 700 + member_did: input.member_did, 701 + access: input.access.unwrap_or(SpaceAccess::Read), 702 + is_delegation: input.is_delegation.unwrap_or(false), 703 + granted_by: Some(claims.did().to_string()), 704 + created_at: now_rfc3339(), 705 + }; 706 + 707 + db::add_member(&state.db, state.db_backend, &member).await?; 708 + 709 + let mut response = Json(serde_json::json!({ "member": member })).into_response(); 710 + *response.status_mut() = StatusCode::CREATED; 711 + Ok(response) 712 + } 713 + 714 + async fn remove_member( 715 + State(state): State<AppState>, 716 + xrpc_claims: XrpcClaims, 717 + Json(input): Json<RemoveMemberInput>, 718 + ) -> Result<Json<serde_json::Value>, AppError> { 719 + let claims = require_auth(&xrpc_claims)?; 720 + let space = resolve_space(&state, &input.space_uri).await?; 721 + require_space_admin(&state, &space, claims.did()).await?; 722 + 723 + let removed = 724 + db::remove_member(&state.db, state.db_backend, &space.id, &input.member_did).await?; 725 + 726 + if !removed { 727 + return Err(AppError::NotFound("Member not found in this space".into())); 728 + } 729 + 730 + Ok(Json(serde_json::json!({ "success": true }))) 731 + } 732 + 733 + // --------------------------------------------------------------------------- 734 + // Invite handlers 735 + // --------------------------------------------------------------------------- 736 + 737 + async fn create_invite( 738 + State(state): State<AppState>, 739 + xrpc_claims: XrpcClaims, 740 + Json(input): Json<CreateInviteInput>, 741 + ) -> Result<Response, AppError> { 742 + let claims = require_auth(&xrpc_claims)?; 743 + let space = resolve_space(&state, &input.space_uri).await?; 744 + require_space_admin(&state, &space, claims.did()).await?; 745 + 746 + let mut token_bytes = [0u8; 24]; 747 + rand::Fill::fill(&mut token_bytes, &mut rand::rng()); 748 + let token = hex::encode(token_bytes); 749 + let token_hash = hex::encode(Sha256::digest(token.as_bytes())); 750 + 751 + let invite = SpaceInvite { 752 + id: Uuid::new_v4().to_string(), 753 + space_id: space.id, 754 + token_hash, 755 + created_by: claims.did().to_string(), 756 + access: input.access.unwrap_or(SpaceAccess::Read), 757 + max_uses: input.max_uses, 758 + uses: 0, 759 + expires_at: input.expires_at, 760 + revoked: false, 761 + created_at: now_rfc3339(), 762 + }; 763 + 764 + db::create_invite(&state.db, state.db_backend, &invite).await?; 765 + 766 + let mut response = Json(serde_json::json!({ 767 + "inviteId": invite.id, 768 + "token": token, 769 + "access": invite.access, 770 + "maxUses": invite.max_uses, 771 + "expiresAt": invite.expires_at, 772 + })) 773 + .into_response(); 774 + *response.status_mut() = StatusCode::CREATED; 775 + Ok(response) 776 + } 777 + 778 + async fn redeem_invite( 779 + State(state): State<AppState>, 780 + xrpc_claims: XrpcClaims, 781 + Json(input): Json<RedeemInviteInput>, 782 + ) -> Result<Response, AppError> { 783 + let claims = require_auth(&xrpc_claims)?; 784 + let did = claims.did().to_string(); 785 + 786 + let token_hash = hex::encode(Sha256::digest(input.token.as_bytes())); 787 + let invite = db::get_invite_by_token_hash(&state.db, state.db_backend, &token_hash) 788 + .await? 789 + .ok_or_else(|| AppError::NotFound("Invalid invite token".into()))?; 790 + 791 + if invite.revoked { 792 + return Err(AppError::BadRequest("This invite has been revoked".into())); 793 + } 794 + 795 + if let Some(max) = invite.max_uses 796 + && invite.uses >= max 797 + { 798 + return Err(AppError::BadRequest( 799 + "This invite has reached its maximum uses".into(), 800 + )); 801 + } 802 + 803 + if let Some(ref expires) = invite.expires_at { 804 + let now = now_rfc3339(); 805 + if now > *expires { 806 + return Err(AppError::BadRequest("This invite has expired".into())); 807 + } 808 + } 809 + 810 + let existing = db::get_member(&state.db, state.db_backend, &invite.space_id, &did).await?; 811 + if existing.is_some() { 812 + return Err(AppError::Conflict( 813 + "You are already a member of this space".into(), 814 + )); 815 + } 816 + 817 + let member = SpaceMember { 818 + id: Uuid::new_v4().to_string(), 819 + space_id: invite.space_id.clone(), 820 + member_did: did, 821 + access: invite.access, 822 + is_delegation: false, 823 + granted_by: Some(invite.created_by.clone()), 824 + created_at: now_rfc3339(), 825 + }; 826 + 827 + db::add_member(&state.db, state.db_backend, &member).await?; 828 + db::increment_invite_uses(&state.db, state.db_backend, &invite.id).await?; 829 + 830 + let space = db::get_space(&state.db, state.db_backend, &invite.space_id).await?; 831 + let space_uri = space.map(|s| format!("ats://{}/{}/{}", s.owner_did, s.type_nsid, s.skey)); 832 + 833 + let mut response = Json(serde_json::json!({ 834 + "spaceUri": space_uri, 835 + "access": member.access, 836 + })) 837 + .into_response(); 838 + *response.status_mut() = StatusCode::CREATED; 839 + Ok(response) 840 + } 841 + 842 + async fn revoke_invite( 843 + State(state): State<AppState>, 844 + xrpc_claims: XrpcClaims, 845 + Json(input): Json<RevokeInviteInput>, 846 + ) -> Result<Json<serde_json::Value>, AppError> { 847 + let claims = require_auth(&xrpc_claims)?; 848 + let space = resolve_space(&state, &input.space_uri).await?; 849 + require_space_admin(&state, &space, claims.did()).await?; 850 + 851 + let revoked = db::revoke_invite(&state.db, state.db_backend, &input.invite_id).await?; 852 + if !revoked { 853 + return Err(AppError::NotFound("Invite not found".into())); 854 + } 855 + 856 + Ok(Json(serde_json::json!({ "success": true }))) 857 + } 858 + 859 + async fn list_invites( 860 + State(state): State<AppState>, 861 + xrpc_claims: XrpcClaims, 862 + Query(query): Query<SpaceUriQuery>, 863 + ) -> Result<Json<serde_json::Value>, AppError> { 864 + let claims = require_auth(&xrpc_claims)?; 865 + let space = resolve_space(&state, &query.space_uri).await?; 866 + require_space_admin(&state, &space, claims.did()).await?; 867 + 868 + let invites = db::list_invites(&state.db, state.db_backend, &space.id).await?; 869 + 870 + let invites_json: Vec<serde_json::Value> = invites 871 + .into_iter() 872 + .map(|i| { 873 + serde_json::json!({ 874 + "id": i.id, 875 + "access": i.access, 876 + "maxUses": i.max_uses, 877 + "uses": i.uses, 878 + "expiresAt": i.expires_at, 879 + "revoked": i.revoked, 880 + "createdBy": i.created_by, 881 + "createdAt": i.created_at, 882 + }) 883 + }) 884 + .collect(); 885 + 886 + Ok(Json(serde_json::json!({ "invites": invites_json }))) 887 + } 888 + 889 + // --------------------------------------------------------------------------- 890 + // Credential handlers 891 + // --------------------------------------------------------------------------- 892 + 893 + async fn get_credential( 894 + State(state): State<AppState>, 895 + xrpc_claims: XrpcClaims, 896 + Json(input): Json<GetCredentialInput>, 897 + ) -> Result<Json<serde_json::Value>, AppError> { 898 + let claims = require_auth(&xrpc_claims)?; 899 + let did = claims.did().to_string(); 900 + let space = resolve_space(&state, &input.space_uri).await?; 901 + 902 + require_membership(&state, &space, &did, false, None).await?; 903 + 904 + let encryption_key = state.config.token_encryption_key.as_ref().ok_or_else(|| { 905 + AppError::Internal("TOKEN_ENCRYPTION_KEY is required for space credentials".into()) 906 + })?; 907 + 908 + let client_id = claims.client_key().map(|k| k.to_string()); 909 + let issued = crate::spaces::auth::issue_credential( 910 + &state.db, 911 + state.db_backend, 912 + encryption_key, 913 + &space, 914 + &did, 915 + client_id.as_deref(), 916 + ) 917 + .await?; 918 + 919 + Ok(Json(serde_json::json!({ 920 + "credential": issued.token, 921 + "expiresAt": issued.expires_at, 922 + }))) 923 + } 924 + 925 + async fn refresh_credential( 926 + State(state): State<AppState>, 927 + xrpc_claims: XrpcClaims, 928 + Json(input): Json<RefreshCredentialInput>, 929 + ) -> Result<Json<serde_json::Value>, AppError> { 930 + let _claims = require_auth(&xrpc_claims)?; 931 + let space = resolve_space(&state, &input.space_uri).await?; 932 + 933 + let encryption_key = state.config.token_encryption_key.as_ref().ok_or_else(|| { 934 + AppError::Internal("TOKEN_ENCRYPTION_KEY is required for space credentials".into()) 935 + })?; 936 + 937 + let issued = crate::spaces::auth::refresh_credential( 938 + &state.db, 939 + state.db_backend, 940 + encryption_key, 941 + &space, 942 + &input.credential, 943 + ) 944 + .await?; 945 + 946 + Ok(Json(serde_json::json!({ 947 + "credential": issued.token, 948 + "expiresAt": issued.expires_at, 949 + }))) 950 + } 951 + 952 + // --------------------------------------------------------------------------- 953 + // Notification handlers 954 + // --------------------------------------------------------------------------- 955 + 956 + async fn write_notification( 957 + State(state): State<AppState>, 958 + xrpc_claims: XrpcClaims, 959 + Json(input): Json<WriteNotificationInput>, 960 + ) -> Result<Json<serde_json::Value>, AppError> { 961 + let claims = require_auth(&xrpc_claims)?; 962 + let space = resolve_space(&state, &input.space_uri).await?; 963 + require_space_admin(&state, &space, claims.did()).await?; 964 + 965 + let notification = crate::spaces::notifications::WriteNotification { 966 + space_uri: input.space_uri, 967 + author_did: input.author_did, 968 + collection: input.collection, 969 + rkey: input.rkey, 970 + action: input.action, 971 + }; 972 + 973 + crate::spaces::notifications::handle_write_notification( 974 + &state.db, 975 + state.db_backend, 976 + &space.id, 977 + &notification, 978 + ) 979 + .await?; 980 + 981 + Ok(Json(serde_json::json!({ "success": true }))) 982 + }
+293
src/spaces/sync.rs
··· 1 + use uuid::Uuid; 2 + 3 + use crate::db::DatabaseBackend; 4 + use crate::db::now_rfc3339; 5 + use crate::error::AppError; 6 + use crate::profile::resolve_pds_endpoint; 7 + use crate::spaces::types::*; 8 + use crate::spaces::{db, members}; 9 + 10 + /// Sync all members of a space by pulling records from their PDSes. 11 + pub async fn sync_space( 12 + http: &reqwest::Client, 13 + pool: &sqlx::AnyPool, 14 + backend: DatabaseBackend, 15 + plc_url: &str, 16 + space_id: &str, 17 + collections: &[String], 18 + ) -> Result<SyncSpaceResult, AppError> { 19 + let resolved = members::resolve_members(pool, backend, space_id).await?; 20 + let mut results = Vec::new(); 21 + 22 + for member in &resolved { 23 + let result = sync_member( 24 + http, 25 + pool, 26 + backend, 27 + plc_url, 28 + space_id, 29 + &member.did, 30 + collections, 31 + ) 32 + .await; 33 + 34 + results.push(MemberSyncResult { 35 + did: member.did.clone(), 36 + records_synced: result.as_ref().map(|r| r.records_synced).unwrap_or(0), 37 + error: result.err().map(|e| e.to_string()), 38 + }); 39 + } 40 + 41 + let total = results.iter().map(|r| r.records_synced).sum(); 42 + 43 + Ok(SyncSpaceResult { 44 + members_processed: results.len(), 45 + total_records_synced: total, 46 + member_results: results, 47 + }) 48 + } 49 + 50 + /// Sync records from a single member's PDS for a given space. 51 + pub async fn sync_member( 52 + http: &reqwest::Client, 53 + pool: &sqlx::AnyPool, 54 + backend: DatabaseBackend, 55 + plc_url: &str, 56 + space_id: &str, 57 + member_did: &str, 58 + collections: &[String], 59 + ) -> Result<MemberSyncSummary, AppError> { 60 + let state_id = match db::get_sync_state(pool, backend, space_id, member_did).await? { 61 + Some(s) => s.id, 62 + None => { 63 + let id = Uuid::new_v4().to_string(); 64 + let initial = SpaceSyncState { 65 + id: id.clone(), 66 + space_id: space_id.to_string(), 67 + member_did: member_did.to_string(), 68 + cursor: None, 69 + last_synced_at: None, 70 + status: SyncStatus::Pending, 71 + error: None, 72 + }; 73 + db::upsert_sync_state(pool, backend, &initial).await?; 74 + id 75 + } 76 + }; 77 + 78 + // Mark as syncing 79 + let syncing_state = SpaceSyncState { 80 + id: state_id.clone(), 81 + space_id: space_id.to_string(), 82 + member_did: member_did.to_string(), 83 + cursor: None, 84 + last_synced_at: None, 85 + status: SyncStatus::Syncing, 86 + error: None, 87 + }; 88 + db::upsert_sync_state(pool, backend, &syncing_state).await?; 89 + 90 + let result = pull_member_records( 91 + http, 92 + pool, 93 + backend, 94 + plc_url, 95 + space_id, 96 + member_did, 97 + collections, 98 + ) 99 + .await; 100 + 101 + match result { 102 + Ok(summary) => { 103 + let done = SpaceSyncState { 104 + id: state_id, 105 + space_id: space_id.to_string(), 106 + member_did: member_did.to_string(), 107 + cursor: summary.cursor.clone(), 108 + last_synced_at: Some(now_rfc3339()), 109 + status: SyncStatus::Synced, 110 + error: None, 111 + }; 112 + db::upsert_sync_state(pool, backend, &done).await?; 113 + Ok(summary) 114 + } 115 + Err(e) => { 116 + let err_state = SpaceSyncState { 117 + id: state_id, 118 + space_id: space_id.to_string(), 119 + member_did: member_did.to_string(), 120 + cursor: None, 121 + last_synced_at: Some(now_rfc3339()), 122 + status: SyncStatus::Error, 123 + error: Some(e.to_string()), 124 + }; 125 + db::upsert_sync_state(pool, backend, &err_state).await?; 126 + Err(e) 127 + } 128 + } 129 + } 130 + 131 + async fn pull_member_records( 132 + http: &reqwest::Client, 133 + pool: &sqlx::AnyPool, 134 + backend: DatabaseBackend, 135 + plc_url: &str, 136 + space_id: &str, 137 + member_did: &str, 138 + collections: &[String], 139 + ) -> Result<MemberSyncSummary, AppError> { 140 + let pds_url = resolve_pds_endpoint(http, plc_url, member_did).await?; 141 + let mut total_records = 0usize; 142 + let mut last_cursor = None; 143 + 144 + for collection in collections { 145 + let mut cursor: Option<String> = None; 146 + loop { 147 + let (records, next_cursor) = fetch_records_page( 148 + http, 149 + &pds_url, 150 + member_did, 151 + collection, 152 + cursor.as_deref(), 153 + 100, 154 + ) 155 + .await?; 156 + 157 + if records.is_empty() { 158 + break; 159 + } 160 + 161 + for record in &records { 162 + let uri = record["uri"].as_str().unwrap_or(""); 163 + let rkey = extract_rkey(uri); 164 + let cid = record["cid"].as_str().unwrap_or("").to_string(); 165 + let value = record 166 + .get("value") 167 + .cloned() 168 + .unwrap_or(serde_json::Value::Null); 169 + 170 + let space_record_uri = format!("ats://{space_id}/{member_did}/{collection}/{rkey}"); 171 + 172 + let space_record = SpaceRecord { 173 + uri: space_record_uri, 174 + space_id: space_id.to_string(), 175 + author_did: member_did.to_string(), 176 + collection: collection.clone(), 177 + rkey: rkey.to_string(), 178 + record: value, 179 + cid, 180 + indexed_at: now_rfc3339(), 181 + }; 182 + 183 + db::upsert_space_record(pool, backend, &space_record).await?; 184 + total_records += 1; 185 + } 186 + 187 + last_cursor = next_cursor.clone(); 188 + cursor = next_cursor; 189 + 190 + if cursor.is_none() { 191 + break; 192 + } 193 + } 194 + } 195 + 196 + Ok(MemberSyncSummary { 197 + records_synced: total_records, 198 + cursor: last_cursor, 199 + }) 200 + } 201 + 202 + async fn fetch_records_page( 203 + http: &reqwest::Client, 204 + pds_url: &str, 205 + repo: &str, 206 + collection: &str, 207 + cursor: Option<&str>, 208 + limit: u32, 209 + ) -> Result<(Vec<serde_json::Value>, Option<String>), AppError> { 210 + let mut url = format!( 211 + "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit={}", 212 + pds_url.trim_end_matches('/'), 213 + repo, 214 + collection, 215 + limit, 216 + ); 217 + 218 + if let Some(c) = cursor { 219 + url.push_str(&format!("&cursor={c}")); 220 + } 221 + 222 + let resp = http 223 + .get(&url) 224 + .send() 225 + .await 226 + .map_err(|e| AppError::Internal(format!("PDS request failed: {e}")))?; 227 + 228 + if !resp.status().is_success() { 229 + let status = resp.status(); 230 + return Err(AppError::Internal(format!( 231 + "PDS listRecords failed with {status} for {repo}/{collection}" 232 + ))); 233 + } 234 + 235 + let body: serde_json::Value = resp 236 + .json() 237 + .await 238 + .map_err(|e| AppError::Internal(format!("invalid PDS response: {e}")))?; 239 + 240 + let records = body["records"].as_array().cloned().unwrap_or_default(); 241 + 242 + let next_cursor = body["cursor"].as_str().map(|s| s.to_string()); 243 + 244 + Ok((records, next_cursor)) 245 + } 246 + 247 + fn extract_rkey(uri: &str) -> &str { 248 + uri.rsplit('/').next().unwrap_or("") 249 + } 250 + 251 + // --------------------------------------------------------------------------- 252 + // Result types 253 + // --------------------------------------------------------------------------- 254 + 255 + pub struct SyncSpaceResult { 256 + pub members_processed: usize, 257 + pub total_records_synced: usize, 258 + pub member_results: Vec<MemberSyncResult>, 259 + } 260 + 261 + pub struct MemberSyncResult { 262 + pub did: String, 263 + pub records_synced: usize, 264 + pub error: Option<String>, 265 + } 266 + 267 + pub struct MemberSyncSummary { 268 + pub records_synced: usize, 269 + pub cursor: Option<String>, 270 + } 271 + 272 + #[cfg(test)] 273 + mod tests { 274 + use super::*; 275 + 276 + #[test] 277 + fn extract_rkey_from_at_uri() { 278 + assert_eq!( 279 + extract_rkey("at://did:plc:abc/app.bsky.feed.post/3k2abc"), 280 + "3k2abc" 281 + ); 282 + } 283 + 284 + #[test] 285 + fn extract_rkey_from_empty() { 286 + assert_eq!(extract_rkey(""), ""); 287 + } 288 + 289 + #[test] 290 + fn extract_rkey_no_slash() { 291 + assert_eq!(extract_rkey("singlevalue"), "singlevalue"); 292 + } 293 + }
+248
src/spaces/types.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + use std::fmt; 3 + 4 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 5 + #[serde(rename_all = "lowercase")] 6 + pub enum SpaceAccess { 7 + Read, 8 + Write, 9 + } 10 + 11 + impl SpaceAccess { 12 + pub fn as_str(&self) -> &'static str { 13 + match self { 14 + SpaceAccess::Read => "read", 15 + SpaceAccess::Write => "write", 16 + } 17 + } 18 + 19 + pub fn parse(s: &str) -> Option<Self> { 20 + match s { 21 + "read" => Some(SpaceAccess::Read), 22 + "write" => Some(SpaceAccess::Write), 23 + _ => None, 24 + } 25 + } 26 + 27 + pub fn can_write(&self) -> bool { 28 + matches!(self, SpaceAccess::Write) 29 + } 30 + 31 + pub fn can_read(&self) -> bool { 32 + true 33 + } 34 + } 35 + 36 + impl fmt::Display for SpaceAccess { 37 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 38 + f.write_str(self.as_str()) 39 + } 40 + } 41 + 42 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 43 + #[serde(rename_all = "snake_case")] 44 + pub enum AccessMode { 45 + DefaultAllow, 46 + DefaultDeny, 47 + } 48 + 49 + impl AccessMode { 50 + pub fn as_str(&self) -> &'static str { 51 + match self { 52 + AccessMode::DefaultAllow => "default_allow", 53 + AccessMode::DefaultDeny => "default_deny", 54 + } 55 + } 56 + 57 + pub fn parse(s: &str) -> Option<Self> { 58 + match s { 59 + "default_allow" => Some(AccessMode::DefaultAllow), 60 + "default_deny" => Some(AccessMode::DefaultDeny), 61 + _ => None, 62 + } 63 + } 64 + } 65 + 66 + impl fmt::Display for AccessMode { 67 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 68 + f.write_str(self.as_str()) 69 + } 70 + } 71 + 72 + #[derive(Debug, Clone, Serialize, Deserialize)] 73 + pub struct Space { 74 + pub id: String, 75 + pub owner_did: String, 76 + pub type_nsid: String, 77 + pub skey: String, 78 + pub display_name: Option<String>, 79 + pub description: Option<String>, 80 + pub access_mode: AccessMode, 81 + pub app_allowlist: Option<Vec<String>>, 82 + pub app_denylist: Option<Vec<String>>, 83 + pub managing_app_did: Option<String>, 84 + pub config: SpaceConfig, 85 + pub created_at: String, 86 + pub updated_at: String, 87 + } 88 + 89 + #[derive(Debug, Clone, Default, Serialize, Deserialize)] 90 + pub struct SpaceConfig { 91 + #[serde(default)] 92 + pub membership_public: bool, 93 + #[serde(default)] 94 + pub records_public: bool, 95 + #[serde(flatten)] 96 + pub extra: serde_json::Map<String, serde_json::Value>, 97 + } 98 + 99 + #[derive(Debug, Clone, Serialize, Deserialize)] 100 + pub struct SpaceMember { 101 + pub id: String, 102 + pub space_id: String, 103 + pub member_did: String, 104 + pub access: SpaceAccess, 105 + pub is_delegation: bool, 106 + pub granted_by: Option<String>, 107 + pub created_at: String, 108 + } 109 + 110 + #[derive(Debug, Clone, Serialize, Deserialize)] 111 + pub struct ResolvedMember { 112 + pub did: String, 113 + pub access: SpaceAccess, 114 + } 115 + 116 + #[derive(Debug, Clone, Serialize, Deserialize)] 117 + pub struct SpaceRecord { 118 + pub uri: String, 119 + pub space_id: String, 120 + pub author_did: String, 121 + pub collection: String, 122 + pub rkey: String, 123 + pub record: serde_json::Value, 124 + pub cid: String, 125 + pub indexed_at: String, 126 + } 127 + 128 + #[derive(Debug, Clone, Serialize, Deserialize)] 129 + pub struct SpaceInvite { 130 + pub id: String, 131 + pub space_id: String, 132 + pub token_hash: String, 133 + pub created_by: String, 134 + pub access: SpaceAccess, 135 + pub max_uses: Option<i64>, 136 + pub uses: i64, 137 + pub expires_at: Option<String>, 138 + pub revoked: bool, 139 + pub created_at: String, 140 + } 141 + 142 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 143 + #[serde(rename_all = "lowercase")] 144 + pub enum SyncStatus { 145 + Pending, 146 + Syncing, 147 + Synced, 148 + Error, 149 + } 150 + 151 + impl SyncStatus { 152 + pub fn as_str(&self) -> &'static str { 153 + match self { 154 + SyncStatus::Pending => "pending", 155 + SyncStatus::Syncing => "syncing", 156 + SyncStatus::Synced => "synced", 157 + SyncStatus::Error => "error", 158 + } 159 + } 160 + 161 + pub fn parse(s: &str) -> Option<Self> { 162 + match s { 163 + "pending" => Some(SyncStatus::Pending), 164 + "syncing" => Some(SyncStatus::Syncing), 165 + "synced" => Some(SyncStatus::Synced), 166 + "error" => Some(SyncStatus::Error), 167 + _ => None, 168 + } 169 + } 170 + } 171 + 172 + #[derive(Debug, Clone, Serialize, Deserialize)] 173 + pub struct SpaceSyncState { 174 + pub id: String, 175 + pub space_id: String, 176 + pub member_did: String, 177 + pub cursor: Option<String>, 178 + pub last_synced_at: Option<String>, 179 + pub status: SyncStatus, 180 + pub error: Option<String>, 181 + } 182 + 183 + #[cfg(test)] 184 + mod tests { 185 + use super::*; 186 + 187 + #[test] 188 + fn space_access_roundtrip() { 189 + assert_eq!(SpaceAccess::parse("read"), Some(SpaceAccess::Read)); 190 + assert_eq!(SpaceAccess::parse("write"), Some(SpaceAccess::Write)); 191 + assert_eq!(SpaceAccess::parse("admin"), None); 192 + 193 + assert_eq!(SpaceAccess::Read.as_str(), "read"); 194 + assert_eq!(SpaceAccess::Write.as_str(), "write"); 195 + } 196 + 197 + #[test] 198 + fn space_access_permissions() { 199 + assert!(SpaceAccess::Read.can_read()); 200 + assert!(!SpaceAccess::Read.can_write()); 201 + assert!(SpaceAccess::Write.can_read()); 202 + assert!(SpaceAccess::Write.can_write()); 203 + } 204 + 205 + #[test] 206 + fn access_mode_roundtrip() { 207 + assert_eq!( 208 + AccessMode::parse("default_allow"), 209 + Some(AccessMode::DefaultAllow) 210 + ); 211 + assert_eq!( 212 + AccessMode::parse("default_deny"), 213 + Some(AccessMode::DefaultDeny) 214 + ); 215 + assert_eq!(AccessMode::parse("open"), None); 216 + } 217 + 218 + #[test] 219 + fn space_config_defaults() { 220 + let config: SpaceConfig = serde_json::from_str("{}").unwrap(); 221 + assert!(!config.membership_public); 222 + assert!(!config.records_public); 223 + } 224 + 225 + #[test] 226 + fn space_config_with_extra_fields() { 227 + let config: SpaceConfig = 228 + serde_json::from_str(r#"{"membership_public": true, "custom_field": 42}"#).unwrap(); 229 + assert!(config.membership_public); 230 + assert!(!config.records_public); 231 + assert_eq!(config.extra.get("custom_field").unwrap(), &42); 232 + } 233 + 234 + #[test] 235 + fn space_access_serialization() { 236 + let json = serde_json::to_string(&SpaceAccess::Read).unwrap(); 237 + assert_eq!(json, "\"read\""); 238 + 239 + let json = serde_json::to_string(&SpaceAccess::Write).unwrap(); 240 + assert_eq!(json, "\"write\""); 241 + 242 + let parsed: SpaceAccess = serde_json::from_str("\"read\"").unwrap(); 243 + assert_eq!(parsed, SpaceAccess::Read); 244 + 245 + let parsed: SpaceAccess = serde_json::from_str("\"write\"").unwrap(); 246 + assert_eq!(parsed, SpaceAccess::Write); 247 + } 248 + }
+115 -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, 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 } ··· 287 346 } 288 347 } 289 348 349 + #[allow(clippy::too_many_arguments)] 290 350 async fn handle_dpop_procedure( 291 351 state: &AppState, 292 352 claims: &Claims, ··· 295 355 action: &ProcedureAction, 296 356 api_client_id: &str, 297 357 encryption_key: &[u8; 32], 358 + delegate_did: Option<&str>, 298 359 ) -> Result<Response, AppError> { 360 + // If delegating, verify the caller has write access and resolve the 361 + // api_client_id that owns the delegated session. 362 + let (target_did, effective_api_client_id) = if let Some(did) = delegate_did { 363 + let role = crate::delegation::db::get_delegate_role( 364 + &state.db, 365 + state.db_backend, 366 + did, 367 + claims.did(), 368 + ) 369 + .await? 370 + .ok_or_else(|| AppError::Forbidden("you are not a delegate of this account".into()))?; 371 + 372 + if !role.can_write() { 373 + return Err(AppError::Forbidden( 374 + "your role does not have write access to this account".into(), 375 + )); 376 + } 377 + 378 + let stored_client_id = 379 + crate::delegation::db::get_api_client_id(&state.db, state.db_backend, did) 380 + .await? 381 + .ok_or_else(|| { 382 + AppError::Internal("delegated account missing api_client_id".into()) 383 + })?; 384 + 385 + if api_client_id != stored_client_id { 386 + return Err(AppError::Forbidden( 387 + "delegation is scoped to a different application".into(), 388 + )); 389 + } 390 + 391 + (did, stored_client_id) 392 + } else { 393 + (claims.did(), api_client_id.to_string()) 394 + }; 395 + 396 + // Strip delegateDid from input — it's a control field, not record data 397 + let mut input = input.clone(); 398 + if let Some(obj) = input.as_object_mut() { 399 + obj.remove("delegateDid"); 400 + } 401 + 299 402 let (xrpc_method, pds_body) = match action { 300 403 ProcedureAction::Create => { 301 404 let mut record = input.clone(); 302 405 if let Some(obj) = record.as_object_mut() { 303 406 obj.insert("$type".to_string(), json!(collection)); 304 407 obj.remove("shouldPublish"); 408 + obj.remove("delegateDid"); 305 409 } 306 410 ( 307 411 "com.atproto.repo.createRecord", 308 412 json!({ 309 - "repo": claims.did(), 413 + "repo": target_did, 310 414 "collection": collection, 311 415 "record": record, 312 416 }), ··· 326 430 obj.insert("$type".to_string(), json!(collection)); 327 431 obj.remove("uri"); 328 432 obj.remove("shouldPublish"); 433 + obj.remove("delegateDid"); 329 434 } 330 435 ( 331 436 "com.atproto.repo.putRecord", 332 437 json!({ 333 - "repo": claims.did(), 438 + "repo": target_did, 334 439 "collection": collection, 335 440 "rkey": rkey, 336 441 "record": record, ··· 349 454 ( 350 455 "com.atproto.repo.deleteRecord", 351 456 json!({ 352 - "repo": claims.did(), 457 + "repo": target_did, 353 458 "collection": collection, 354 459 "rkey": rkey, 355 460 }), ··· 368 473 obj.insert("$type".to_string(), json!(collection)); 369 474 obj.remove("uri"); 370 475 obj.remove("shouldPublish"); 476 + obj.remove("delegateDid"); 371 477 } 372 478 ( 373 479 "com.atproto.repo.putRecord", 374 480 json!({ 375 - "repo": claims.did(), 481 + "repo": target_did, 376 482 "collection": collection, 377 483 "rkey": rkey, 378 484 "record": record, ··· 383 489 if let Some(obj) = record.as_object_mut() { 384 490 obj.insert("$type".to_string(), json!(collection)); 385 491 obj.remove("shouldPublish"); 492 + obj.remove("delegateDid"); 386 493 } 387 494 ( 388 495 "com.atproto.repo.createRecord", 389 496 json!({ 390 - "repo": claims.did(), 497 + "repo": target_did, 391 498 "collection": collection, 392 499 "record": record, 393 500 }), ··· 403 510 encryption_key, 404 511 &state.oauth, 405 512 &state.config.plc_url, 406 - api_client_id, 407 - claims.did(), 513 + &effective_api_client_id, 514 + target_did, 408 515 xrpc_method, 409 516 &pds_body, 410 517 )
+4 -2
src/xrpc/query.rs
··· 16 16 claims: Option<&Claims>, 17 17 ) -> Result<Response, AppError> { 18 18 if let Some(ref script) = lexicon.script { 19 - return crate::lua::execute_query_script(state, method, params, lexicon, script, claims) 20 - .await; 19 + return crate::lua::execute_query_script( 20 + state, method, params, lexicon, script, claims, None, 21 + ) 22 + .await; 21 23 } 22 24 23 25 // Single-record query: has a `uri` parameter
+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 + }
+302
tests/e2e_feature_flags.rs
··· 1 + mod common; 2 + 3 + use axum::body::Body; 4 + use axum::http::{Method, Request, StatusCode}; 5 + use http_body_util::BodyExt; 6 + use serde_json::{Value, json}; 7 + use serial_test::serial; 8 + use tower::ServiceExt; 9 + 10 + use common::app::TestApp; 11 + 12 + async fn json_body(resp: axum::response::Response) -> Value { 13 + let body = resp.into_body().collect().await.unwrap().to_bytes(); 14 + serde_json::from_slice(&body).unwrap() 15 + } 16 + 17 + fn admin_get( 18 + uri: &str, 19 + cookie: (axum::http::HeaderName, axum::http::HeaderValue), 20 + ) -> Request<Body> { 21 + Request::builder() 22 + .uri(uri) 23 + .header(cookie.0, cookie.1) 24 + .body(Body::empty()) 25 + .unwrap() 26 + } 27 + 28 + fn admin_put( 29 + uri: &str, 30 + cookie: (axum::http::HeaderName, axum::http::HeaderValue), 31 + body: &Value, 32 + ) -> Request<Body> { 33 + Request::builder() 34 + .method(Method::PUT) 35 + .uri(uri) 36 + .header(cookie.0, cookie.1) 37 + .header("content-type", "application/json") 38 + .body(Body::from(serde_json::to_vec(body).unwrap())) 39 + .unwrap() 40 + } 41 + 42 + fn admin_delete( 43 + uri: &str, 44 + cookie: (axum::http::HeaderName, axum::http::HeaderValue), 45 + ) -> Request<Body> { 46 + Request::builder() 47 + .method(Method::DELETE) 48 + .uri(uri) 49 + .header(cookie.0, cookie.1) 50 + .body(Body::empty()) 51 + .unwrap() 52 + } 53 + 54 + #[tokio::test] 55 + #[serial] 56 + #[ignore] 57 + async fn space_routes_blocked_when_flag_disabled() { 58 + let app = TestApp::new().await; 59 + 60 + let resp = app 61 + .router 62 + .clone() 63 + .oneshot( 64 + Request::builder() 65 + .uri("/xrpc/dev.happyview.space.list") 66 + .body(Body::empty()) 67 + .unwrap(), 68 + ) 69 + .await 70 + .unwrap(); 71 + 72 + assert_eq!(resp.status(), StatusCode::NOT_FOUND); 73 + let body = json_body(resp).await; 74 + assert_eq!(body["error"], "FeatureDisabled"); 75 + } 76 + 77 + #[tokio::test] 78 + #[serial] 79 + #[ignore] 80 + async fn space_routes_allowed_after_enabling_flag() { 81 + let app = TestApp::new().await; 82 + 83 + // Enable the feature flag 84 + let resp = app 85 + .router 86 + .clone() 87 + .oneshot(admin_put( 88 + "/admin/settings/feature.spaces_enabled", 89 + app.admin_cookie(), 90 + &json!({ "value": "true" }), 91 + )) 92 + .await 93 + .unwrap(); 94 + assert!(resp.status().is_success()); 95 + 96 + // Space routes should now pass through (will get auth error, not FeatureDisabled) 97 + let resp = app 98 + .router 99 + .clone() 100 + .oneshot( 101 + Request::builder() 102 + .uri("/xrpc/dev.happyview.space.list") 103 + .body(Body::empty()) 104 + .unwrap(), 105 + ) 106 + .await 107 + .unwrap(); 108 + 109 + let body = json_body(resp).await; 110 + assert_ne!( 111 + body["error"].as_str().unwrap_or(""), 112 + "FeatureDisabled", 113 + "expected request to pass through feature gate" 114 + ); 115 + } 116 + 117 + #[tokio::test] 118 + #[serial] 119 + #[ignore] 120 + async fn space_routes_blocked_again_after_disabling_flag() { 121 + let app = TestApp::new().await; 122 + 123 + // Enable 124 + let resp = app 125 + .router 126 + .clone() 127 + .oneshot(admin_put( 128 + "/admin/settings/feature.spaces_enabled", 129 + app.admin_cookie(), 130 + &json!({ "value": "true" }), 131 + )) 132 + .await 133 + .unwrap(); 134 + assert!(resp.status().is_success()); 135 + 136 + // Disable 137 + let resp = app 138 + .router 139 + .clone() 140 + .oneshot(admin_delete( 141 + "/admin/settings/feature.spaces_enabled", 142 + app.admin_cookie(), 143 + )) 144 + .await 145 + .unwrap(); 146 + assert!(resp.status().is_success()); 147 + 148 + // Space routes should be blocked again 149 + let resp = app 150 + .router 151 + .clone() 152 + .oneshot( 153 + Request::builder() 154 + .uri("/xrpc/dev.happyview.space.list") 155 + .body(Body::empty()) 156 + .unwrap(), 157 + ) 158 + .await 159 + .unwrap(); 160 + 161 + assert_eq!(resp.status(), StatusCode::NOT_FOUND); 162 + let body = json_body(resp).await; 163 + assert_eq!(body["error"], "FeatureDisabled"); 164 + } 165 + 166 + #[tokio::test] 167 + #[serial] 168 + #[ignore] 169 + async fn admin_feature_flags_lists_flags() { 170 + let app = TestApp::new().await; 171 + 172 + let resp = app 173 + .router 174 + .clone() 175 + .oneshot(admin_get("/admin/feature-flags", app.admin_cookie())) 176 + .await 177 + .unwrap(); 178 + 179 + assert_eq!(resp.status(), StatusCode::OK); 180 + let body = json_body(resp).await; 181 + let flags = body.as_array().expect("expected array"); 182 + assert!(!flags.is_empty()); 183 + 184 + let spaces_flag = flags 185 + .iter() 186 + .find(|f| f["key"] == "feature.spaces_enabled") 187 + .expect("spaces flag not found"); 188 + assert_eq!(spaces_flag["enabled"], false); 189 + assert!(spaces_flag["name"].as_str().is_some()); 190 + assert!(spaces_flag["description"].as_str().is_some()); 191 + } 192 + 193 + #[tokio::test] 194 + #[serial] 195 + #[ignore] 196 + async fn admin_feature_flags_reflects_enabled_state() { 197 + let app = TestApp::new().await; 198 + 199 + // Enable the flag 200 + let resp = app 201 + .router 202 + .clone() 203 + .oneshot(admin_put( 204 + "/admin/settings/feature.spaces_enabled", 205 + app.admin_cookie(), 206 + &json!({ "value": "true" }), 207 + )) 208 + .await 209 + .unwrap(); 210 + assert!(resp.status().is_success()); 211 + 212 + let resp = app 213 + .router 214 + .clone() 215 + .oneshot(admin_get("/admin/feature-flags", app.admin_cookie())) 216 + .await 217 + .unwrap(); 218 + 219 + assert_eq!(resp.status(), StatusCode::OK); 220 + let body = json_body(resp).await; 221 + let flags = body.as_array().unwrap(); 222 + let spaces_flag = flags 223 + .iter() 224 + .find(|f| f["key"] == "feature.spaces_enabled") 225 + .unwrap(); 226 + assert_eq!(spaces_flag["enabled"], true); 227 + } 228 + 229 + #[tokio::test] 230 + #[serial] 231 + #[ignore] 232 + async fn config_endpoint_includes_features() { 233 + let app = TestApp::new().await; 234 + 235 + // Default: spaces disabled 236 + let resp = app 237 + .router 238 + .clone() 239 + .oneshot( 240 + Request::builder() 241 + .uri("/config") 242 + .body(Body::empty()) 243 + .unwrap(), 244 + ) 245 + .await 246 + .unwrap(); 247 + 248 + assert_eq!(resp.status(), StatusCode::OK); 249 + let body = json_body(resp).await; 250 + assert_eq!(body["features"]["spaces"], false); 251 + 252 + // Enable the flag 253 + let resp = app 254 + .router 255 + .clone() 256 + .oneshot(admin_put( 257 + "/admin/settings/feature.spaces_enabled", 258 + app.admin_cookie(), 259 + &json!({ "value": "true" }), 260 + )) 261 + .await 262 + .unwrap(); 263 + assert!(resp.status().is_success()); 264 + 265 + // Now config should reflect enabled 266 + let resp = app 267 + .router 268 + .clone() 269 + .oneshot( 270 + Request::builder() 271 + .uri("/config") 272 + .body(Body::empty()) 273 + .unwrap(), 274 + ) 275 + .await 276 + .unwrap(); 277 + 278 + assert_eq!(resp.status(), StatusCode::OK); 279 + let body = json_body(resp).await; 280 + assert_eq!(body["features"]["spaces"], true); 281 + } 282 + 283 + #[tokio::test] 284 + #[serial] 285 + #[ignore] 286 + async fn admin_feature_flags_requires_auth() { 287 + let app = TestApp::new().await; 288 + 289 + let resp = app 290 + .router 291 + .clone() 292 + .oneshot( 293 + Request::builder() 294 + .uri("/admin/feature-flags") 295 + .body(Body::empty()) 296 + .unwrap(), 297 + ) 298 + .await 299 + .unwrap(); 300 + 301 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 302 + }