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.

feat: put permissioned spaces behind an experimental flag

Trezy ed57c934 26d57cae

+487 -6
+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",
+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"),
+1
src/delegation/mod.rs
··· 42 42 } 43 43 } 44 44 45 + #[allow(clippy::should_implement_trait)] 45 46 pub fn from_str(s: &str) -> Option<Self> { 46 47 match s { 47 48 "owner" => Some(DelegateRole::Owner),
+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 + }
+2
src/lib.rs
··· 10 10 pub mod error; 11 11 pub mod event_log; 12 12 pub mod external_auth; 13 + pub mod feature_flags; 14 + pub mod feature_middleware; 13 15 pub mod jetstream; 14 16 pub mod labeler; 15 17 pub mod lexicon;
+36
src/lua/atproto_api.rs
··· 266 266 lua.create_async_function(move |_lua, (space_uri, did): (String, String)| { 267 267 let state = state_clone.clone(); 268 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 + } 269 278 let uri = crate::spaces::SpaceUri::parse(&space_uri) 270 279 .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?; 271 280 let space = crate::spaces::db::get_space_by_address( ··· 298 307 lua.create_async_function(move |_lua, (space_uri, did): (String, String)| { 299 308 let state = state_clone.clone(); 300 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 + } 301 319 let uri = crate::spaces::SpaceUri::parse(&space_uri) 302 320 .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?; 303 321 let space = crate::spaces::db::get_space_by_address( ··· 329 347 let list_members_fn = lua.create_async_function(move |lua, space_uri: String| { 330 348 let state = state_clone.clone(); 331 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 + } 332 359 let uri = crate::spaces::SpaceUri::parse(&space_uri) 333 360 .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?; 334 361 let space = crate::spaces::db::get_space_by_address( ··· 368 395 let query_fn = lua.create_async_function(move |lua, opts: mlua::Table| { 369 396 let state = state_clone.clone(); 370 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 + } 371 407 let space_uri: String = opts 372 408 .get("space_uri") 373 409 .map_err(|_| mlua::Error::runtime("space_uri is required"))?;
+1
src/lua/context.rs
··· 32 32 } 33 33 34 34 /// Set global context variables for a procedure script. 35 + #[allow(clippy::too_many_arguments)] 35 36 pub fn set_procedure_context( 36 37 lua: &Lua, 37 38 method: &str,
+16 -1
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(crate::spaces::routes::space_routes()) 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 + ) 66 71 .nest("/auth", crate::auth::routes::routes()) 67 72 .nest("/external-auth", crate::external_auth::routes()) 68 73 .nest("/oauth", crate::oauth::routes::routes()) ··· 188 193 _ => env!("CARGO_PKG_VERSION"), 189 194 }; 190 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 + 191 203 Json(serde_json::json!({ 192 204 "public_url": domain_url, 193 205 "version": version, ··· 199 211 "default_rate_limit_refill_rate": state.config.default_rate_limit_refill_rate, 200 212 "app_name": app_name, 201 213 "logo_url": logo_url, 214 + "features": { 215 + "spaces": spaces_enabled, 216 + }, 202 217 })) 203 218 } 204 219
+1
src/xrpc/procedure.rs
··· 346 346 } 347 347 } 348 348 349 + #[allow(clippy::too_many_arguments)] 349 350 async fn handle_dpop_procedure( 350 351 state: &AppState, 351 352 claims: &Claims,
+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 + }