A lexicon-driven AppView for ATProto.
0

Configure Feed

Select the types of activity you want to include in your feed.

happyview / src / service_identity.rs
12 kB 404 lines
1use crate::db::{DatabaseBackend, adapt_sql}; 2use crate::error::AppError; 3use serde::{Deserialize, Serialize}; 4use sqlx::AnyPool; 5 6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 7#[serde(rename_all = "snake_case")] 8pub enum IdentityMode { 9 DidWeb, 10 DidPlc, 11 AttachAccount, 12 NotExposed, 13} 14 15impl IdentityMode { 16 pub fn as_str(&self) -> &'static str { 17 match self { 18 Self::DidWeb => "did_web", 19 Self::DidPlc => "did_plc", 20 Self::AttachAccount => "attach_account", 21 Self::NotExposed => "not_exposed", 22 } 23 } 24 25 pub fn parse(s: &str) -> Option<Self> { 26 match s { 27 "did_web" => Some(Self::DidWeb), 28 "did_plc" => Some(Self::DidPlc), 29 "attach_account" => Some(Self::AttachAccount), 30 "not_exposed" => Some(Self::NotExposed), 31 _ => None, 32 } 33 } 34} 35 36#[derive(Debug, Clone, Serialize)] 37pub struct ServiceIdentity { 38 pub mode: IdentityMode, 39 pub did: Option<String>, 40 pub signing_key_enc: Option<String>, 41 pub attached_account_did: Option<String>, 42 pub setup_complete: bool, 43 pub created_at: String, 44 pub updated_at: String, 45} 46 47#[derive(Debug, Clone, Serialize)] 48pub struct SetupStatus { 49 pub identity_mode: Option<IdentityMode>, 50 pub identity_configured: bool, 51 pub plc_verified: bool, 52 pub setup_complete: bool, 53} 54 55type ServiceIdentityRow = ( 56 String, 57 Option<String>, 58 Option<String>, 59 Option<String>, 60 i32, 61 String, 62 String, 63); 64 65fn parse_row(r: ServiceIdentityRow) -> Result<ServiceIdentity, AppError> { 66 let mode = IdentityMode::parse(&r.0) 67 .ok_or_else(|| AppError::Internal(format!("invalid identity mode: {}", r.0)))?; 68 Ok(ServiceIdentity { 69 mode, 70 did: r.1, 71 signing_key_enc: r.2, 72 attached_account_did: r.3, 73 setup_complete: r.4 != 0, 74 created_at: r.5, 75 updated_at: r.6, 76 }) 77} 78 79/// Fetch the service identity row (id = 1), if it exists. 80pub async fn get_identity( 81 db: &AnyPool, 82 backend: DatabaseBackend, 83) -> Result<Option<ServiceIdentity>, AppError> { 84 let sql = adapt_sql( 85 "SELECT mode, did, signing_key_enc, attached_account_did, CAST(setup_complete AS INTEGER), created_at, updated_at FROM happyview_service_identity WHERE id = 1", 86 backend, 87 ); 88 89 let row: Option<ServiceIdentityRow> = crate::db::query_as(&sql) 90 .fetch_optional(db) 91 .await 92 .map_err(|e| AppError::Internal(format!("failed to get service identity: {e}")))?; 93 94 row.map(parse_row).transpose() 95} 96 97/// Derive setup status from the current identity row. 98pub async fn get_setup_status( 99 db: &AnyPool, 100 backend: DatabaseBackend, 101) -> Result<SetupStatus, AppError> { 102 let identity = get_identity(db, backend).await?; 103 104 match identity { 105 None => Ok(SetupStatus { 106 identity_mode: None, 107 identity_configured: false, 108 plc_verified: false, 109 setup_complete: false, 110 }), 111 Some(id) => { 112 let plc_verified = matches!(id.mode, IdentityMode::DidPlc) && id.setup_complete; 113 let identity_configured = match id.mode { 114 IdentityMode::DidWeb => id.signing_key_enc.is_some(), 115 _ => id.did.is_some(), 116 }; 117 let setup_complete = id.setup_complete; 118 let identity_mode = Some(id.mode); 119 Ok(SetupStatus { 120 identity_mode, 121 identity_configured, 122 plc_verified, 123 setup_complete, 124 }) 125 } 126 } 127} 128 129/// Insert or update the service identity row (always resets setup_complete to FALSE). 130#[allow(clippy::too_many_arguments)] 131pub async fn upsert_identity( 132 db: &AnyPool, 133 backend: DatabaseBackend, 134 mode: &IdentityMode, 135 did: Option<&str>, 136 signing_key_enc: Option<&str>, 137 rotation_key_enc: Option<&str>, 138 attached_account_did: Option<&str>, 139) -> Result<(), AppError> { 140 let now = chrono::Utc::now().to_rfc3339(); 141 let sql = adapt_sql( 142 "INSERT INTO happyview_service_identity (id, mode, did, signing_key_enc, rotation_key_enc, attached_account_did, setup_complete, created_at, updated_at) 143 VALUES (1, ?, ?, ?, ?, ?, FALSE, ?, ?) 144 ON CONFLICT (id) DO UPDATE SET 145 mode = excluded.mode, 146 did = excluded.did, 147 signing_key_enc = excluded.signing_key_enc, 148 rotation_key_enc = excluded.rotation_key_enc, 149 attached_account_did = excluded.attached_account_did, 150 setup_complete = excluded.setup_complete, 151 updated_at = excluded.updated_at", 152 backend, 153 ); 154 155 crate::db::query(&sql) 156 .bind(mode.as_str()) 157 .bind(did) 158 .bind(signing_key_enc) 159 .bind(rotation_key_enc) 160 .bind(attached_account_did) 161 .bind(&now) 162 .bind(&now) 163 .execute(db) 164 .await 165 .map_err(|e| AppError::Internal(format!("failed to upsert service identity: {e}")))?; 166 167 Ok(()) 168} 169 170/// Mark setup as complete for the service identity row. 171pub async fn mark_setup_complete(db: &AnyPool, backend: DatabaseBackend) -> Result<(), AppError> { 172 let now = chrono::Utc::now().to_rfc3339(); 173 let sql = adapt_sql( 174 "UPDATE happyview_service_identity SET setup_complete = TRUE, updated_at = ? WHERE id = 1", 175 backend, 176 ); 177 178 crate::db::query(&sql) 179 .bind(&now) 180 .execute(db) 181 .await 182 .map_err(|e| AppError::Internal(format!("failed to mark setup complete: {e}")))?; 183 184 Ok(()) 185} 186 187/// Generate a DID document for did:web identity mode. 188/// The DID is derived dynamically from the request host rather than stored, 189/// so the same signing key works across any domain pointing at this server. 190/// Returns None if the identity mode is not DidWeb. 191/// 192/// `extra_verification_methods` is a slice of (fragment_id, key_type, public_key_multibase) 193/// tuples for additional verification methods (e.g. `#atproto_space`). 194pub fn generate_did_document( 195 identity: &ServiceIdentity, 196 host: &str, 197 signing_key_multibase: &str, 198 service_entries: &[(String, String)], 199 service_endpoint: &str, 200 extra_verification_methods: &[(String, String, String)], 201) -> Option<serde_json::Value> { 202 if identity.mode != IdentityMode::DidWeb { 203 return None; 204 } 205 206 let did = format!("did:web:{}", host.replace(':', "%3A")); 207 208 let mut verification_methods: Vec<serde_json::Value> = vec![serde_json::json!({ 209 "id": format!("{did}#atproto"), 210 "type": "Multikey", 211 "controller": &did, 212 "publicKeyMultibase": signing_key_multibase 213 })]; 214 215 for (fragment_id, key_type, public_key_multibase) in extra_verification_methods { 216 verification_methods.push(serde_json::json!({ 217 "id": format!("{did}{fragment_id}"), 218 "type": key_type, 219 "controller": &did, 220 "publicKeyMultibase": public_key_multibase 221 })); 222 } 223 224 let services: Vec<serde_json::Value> = service_entries 225 .iter() 226 .map(|(fragment, svc_type)| { 227 serde_json::json!({ 228 "id": fragment, 229 "type": svc_type, 230 "serviceEndpoint": service_endpoint 231 }) 232 }) 233 .collect(); 234 235 Some(serde_json::json!({ 236 "@context": [ 237 "https://www.w3.org/ns/did/v1", 238 "https://w3id.org/security/multikey/v1" 239 ], 240 "id": &did, 241 "verificationMethod": verification_methods, 242 "service": services 243 })) 244} 245 246#[cfg(test)] 247mod tests { 248 use super::*; 249 250 fn make_identity(mode: IdentityMode, did: Option<&str>) -> ServiceIdentity { 251 ServiceIdentity { 252 mode, 253 did: did.map(String::from), 254 signing_key_enc: None, 255 attached_account_did: None, 256 setup_complete: true, 257 created_at: "2024-01-01".into(), 258 updated_at: "2024-01-01".into(), 259 } 260 } 261 262 #[test] 263 fn identity_mode_roundtrip() { 264 for mode in [ 265 IdentityMode::DidWeb, 266 IdentityMode::DidPlc, 267 IdentityMode::AttachAccount, 268 IdentityMode::NotExposed, 269 ] { 270 let s = mode.as_str(); 271 let parsed = IdentityMode::parse(s).unwrap(); 272 assert_eq!(parsed, mode); 273 } 274 } 275 276 #[test] 277 fn identity_mode_from_str_invalid() { 278 assert!(IdentityMode::parse("invalid").is_none()); 279 assert!(IdentityMode::parse("").is_none()); 280 } 281 282 #[test] 283 fn generate_did_document_returns_none_for_non_web() { 284 let identity = make_identity(IdentityMode::DidPlc, Some("did:plc:abc123")); 285 assert!( 286 generate_did_document( 287 &identity, 288 "example.com", 289 "zKey", 290 &[], 291 "https://example.com", 292 &[] 293 ) 294 .is_none() 295 ); 296 } 297 298 #[test] 299 fn generate_did_document_derives_did_from_host() { 300 let identity = make_identity(IdentityMode::DidWeb, None); 301 let doc = generate_did_document( 302 &identity, 303 "example.com", 304 "zKey123", 305 &[], 306 "https://example.com", 307 &[], 308 ) 309 .unwrap(); 310 assert_eq!(doc["id"], "did:web:example.com"); 311 } 312 313 #[test] 314 fn generate_did_document_with_no_entries() { 315 let identity = make_identity(IdentityMode::DidWeb, None); 316 let doc = generate_did_document( 317 &identity, 318 "example.com", 319 "zKey123", 320 &[], 321 "https://example.com", 322 &[], 323 ) 324 .unwrap(); 325 assert_eq!(doc["id"], "did:web:example.com"); 326 assert_eq!( 327 doc["verificationMethod"][0]["publicKeyMultibase"], 328 "zKey123" 329 ); 330 assert_eq!(doc["service"].as_array().unwrap().len(), 0); 331 } 332 333 #[test] 334 fn generate_did_document_with_entries() { 335 let identity = make_identity(IdentityMode::DidWeb, None); 336 let entries = vec![ 337 ("#chess".to_string(), "ChessService".to_string()), 338 ("#checkers".to_string(), "CheckersService".to_string()), 339 ]; 340 let doc = generate_did_document( 341 &identity, 342 "example.com", 343 "zKey123", 344 &entries, 345 "https://example.com", 346 &[], 347 ) 348 .unwrap(); 349 let services = doc["service"].as_array().unwrap(); 350 assert_eq!(services.len(), 2); 351 assert_eq!(services[0]["id"], "#chess"); 352 assert_eq!(services[0]["type"], "ChessService"); 353 assert_eq!(services[0]["serviceEndpoint"], "https://example.com"); 354 assert_eq!(services[1]["id"], "#checkers"); 355 } 356 357 #[test] 358 fn generate_did_document_context_and_structure() { 359 let identity = make_identity(IdentityMode::DidWeb, None); 360 let doc = generate_did_document( 361 &identity, 362 "example.com", 363 "zKey", 364 &[], 365 "https://example.com", 366 &[], 367 ) 368 .unwrap(); 369 let context = doc["@context"].as_array().unwrap(); 370 assert_eq!(context.len(), 2); 371 assert_eq!(context[0], "https://www.w3.org/ns/did/v1"); 372 assert_eq!(context[1], "https://w3id.org/security/multikey/v1"); 373 374 let vm = &doc["verificationMethod"][0]; 375 assert_eq!(vm["id"], "did:web:example.com#atproto"); 376 assert_eq!(vm["type"], "Multikey"); 377 assert_eq!(vm["controller"], "did:web:example.com"); 378 } 379 380 #[test] 381 fn generate_did_document_includes_extra_verification_methods() { 382 let identity = make_identity(IdentityMode::DidWeb, None); 383 let extra = vec![( 384 "#atproto_space".to_string(), 385 "Multikey".to_string(), 386 "zExtraKey".to_string(), 387 )]; 388 let doc = generate_did_document( 389 &identity, 390 "example.com", 391 "zKey123", 392 &[], 393 "https://example.com", 394 &extra, 395 ) 396 .unwrap(); 397 let vms = doc["verificationMethod"].as_array().unwrap(); 398 assert_eq!(vms.len(), 2); 399 assert_eq!(vms[0]["id"], "did:web:example.com#atproto"); 400 assert_eq!(vms[1]["id"], "did:web:example.com#atproto_space"); 401 assert_eq!(vms[1]["publicKeyMultibase"], "zExtraKey"); 402 assert_eq!(vms[1]["type"], "Multikey"); 403 } 404}