Something went wrong. Try again.
A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405use crate::db::{DatabaseBackend, adapt_sql};use crate::error::AppError;use serde::{Deserialize, Serialize};use sqlx::AnyPool;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]#[serde(rename_all = "snake_case")]pub enum IdentityMode { DidWeb, DidPlc, AttachAccount, NotExposed,}
impl IdentityMode { pub fn as_str(&self) -> &'static str { match self { Self::DidWeb => "did_web", Self::DidPlc => "did_plc", Self::AttachAccount => "attach_account", Self::NotExposed => "not_exposed", } }
pub fn parse(s: &str) -> Option<Self> { match s { "did_web" => Some(Self::DidWeb), "did_plc" => Some(Self::DidPlc), "attach_account" => Some(Self::AttachAccount), "not_exposed" => Some(Self::NotExposed), _ => None, } }}
#[derive(Debug, Clone, Serialize)]pub struct ServiceIdentity { pub mode: IdentityMode, pub did: Option<String>, pub signing_key_enc: Option<String>, pub attached_account_did: Option<String>, pub setup_complete: bool, pub created_at: String, pub updated_at: String,}
#[derive(Debug, Clone, Serialize)]pub struct SetupStatus { pub identity_mode: Option<IdentityMode>, pub identity_configured: bool, pub plc_verified: bool, pub setup_complete: bool,}
type ServiceIdentityRow = ( String, Option<String>, Option<String>, Option<String>, i32, String, String,);
fn parse_row(r: ServiceIdentityRow) -> Result<ServiceIdentity, AppError> { let mode = IdentityMode::parse(&r.0) .ok_or_else(|| AppError::Internal(format!("invalid identity mode: {}", r.0)))?; Ok(ServiceIdentity { mode, did: r.1, signing_key_enc: r.2, attached_account_did: r.3, setup_complete: r.4 != 0, created_at: r.5, updated_at: r.6, })}
/// Fetch the service identity row (id = 1), if it exists.pub async fn get_identity( db: &AnyPool, backend: DatabaseBackend,) -> Result<Option<ServiceIdentity>, AppError> { let sql = adapt_sql( "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", backend, );
let row: Option<ServiceIdentityRow> = crate::db::query_as(&sql) .fetch_optional(db) .await .map_err(|e| AppError::Internal(format!("failed to get service identity: {e}")))?;
row.map(parse_row).transpose()}
/// Derive setup status from the current identity row.pub async fn get_setup_status( db: &AnyPool, backend: DatabaseBackend,) -> Result<SetupStatus, AppError> { let identity = get_identity(db, backend).await?;
match identity { None => Ok(SetupStatus { identity_mode: None, identity_configured: false, plc_verified: false, setup_complete: false, }), Some(id) => { let plc_verified = matches!(id.mode, IdentityMode::DidPlc) && id.setup_complete; let identity_configured = match id.mode { IdentityMode::DidWeb => id.signing_key_enc.is_some(), _ => id.did.is_some(), }; let setup_complete = id.setup_complete; let identity_mode = Some(id.mode); Ok(SetupStatus { identity_mode, identity_configured, plc_verified, setup_complete, }) } }}
/// Insert or update the service identity row (always resets setup_complete to FALSE).#[allow(clippy::too_many_arguments)]pub async fn upsert_identity( db: &AnyPool, backend: DatabaseBackend, mode: &IdentityMode, did: Option<&str>, signing_key_enc: Option<&str>, rotation_key_enc: Option<&str>, attached_account_did: Option<&str>,) -> Result<(), AppError> { let now = chrono::Utc::now().to_rfc3339(); let sql = adapt_sql( "INSERT INTO happyview_service_identity (id, mode, did, signing_key_enc, rotation_key_enc, attached_account_did, setup_complete, created_at, updated_at) VALUES (1, ?, ?, ?, ?, ?, FALSE, ?, ?) ON CONFLICT (id) DO UPDATE SET mode = excluded.mode, did = excluded.did, signing_key_enc = excluded.signing_key_enc, rotation_key_enc = excluded.rotation_key_enc, attached_account_did = excluded.attached_account_did, setup_complete = excluded.setup_complete, updated_at = excluded.updated_at", backend, );
crate::db::query(&sql) .bind(mode.as_str()) .bind(did) .bind(signing_key_enc) .bind(rotation_key_enc) .bind(attached_account_did) .bind(&now) .bind(&now) .execute(db) .await .map_err(|e| AppError::Internal(format!("failed to upsert service identity: {e}")))?;
Ok(())}
/// Mark setup as complete for the service identity row.pub async fn mark_setup_complete(db: &AnyPool, backend: DatabaseBackend) -> Result<(), AppError> { let now = chrono::Utc::now().to_rfc3339(); let sql = adapt_sql( "UPDATE happyview_service_identity SET setup_complete = TRUE, updated_at = ? WHERE id = 1", backend, );
crate::db::query(&sql) .bind(&now) .execute(db) .await .map_err(|e| AppError::Internal(format!("failed to mark setup complete: {e}")))?;
Ok(())}
/// Generate a DID document for did:web identity mode./// The DID is derived dynamically from the request host rather than stored,/// so the same signing key works across any domain pointing at this server./// Returns None if the identity mode is not DidWeb.////// `extra_verification_methods` is a slice of (fragment_id, key_type, public_key_multibase)/// tuples for additional verification methods (e.g. `#atproto_space`).pub fn generate_did_document( identity: &ServiceIdentity, host: &str, signing_key_multibase: &str, service_entries: &[(String, String)], service_endpoint: &str, extra_verification_methods: &[(String, String, String)],) -> Option<serde_json::Value> { if identity.mode != IdentityMode::DidWeb { return None; }
let did = format!("did:web:{}", host.replace(':', "%3A"));
let mut verification_methods: Vec<serde_json::Value> = vec![serde_json::json!({ "id": format!("{did}#atproto"), "type": "Multikey", "controller": &did, "publicKeyMultibase": signing_key_multibase })];
for (fragment_id, key_type, public_key_multibase) in extra_verification_methods { verification_methods.push(serde_json::json!({ "id": format!("{did}{fragment_id}"), "type": key_type, "controller": &did, "publicKeyMultibase": public_key_multibase })); }
let services: Vec<serde_json::Value> = service_entries .iter() .map(|(fragment, svc_type)| { serde_json::json!({ "id": fragment, "type": svc_type, "serviceEndpoint": service_endpoint }) }) .collect();
Some(serde_json::json!({ "@context": [ "https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1" ], "id": &did, "verificationMethod": verification_methods, "service": services }))}
#[cfg(test)]mod tests { use super::*;
fn make_identity(mode: IdentityMode, did: Option<&str>) -> ServiceIdentity { ServiceIdentity { mode, did: did.map(String::from), signing_key_enc: None, attached_account_did: None, setup_complete: true, created_at: "2024-01-01".into(), updated_at: "2024-01-01".into(), } }
#[test] fn identity_mode_roundtrip() { for mode in [ IdentityMode::DidWeb, IdentityMode::DidPlc, IdentityMode::AttachAccount, IdentityMode::NotExposed, ] { let s = mode.as_str(); let parsed = IdentityMode::parse(s).unwrap(); assert_eq!(parsed, mode); } }
#[test] fn identity_mode_from_str_invalid() { assert!(IdentityMode::parse("invalid").is_none()); assert!(IdentityMode::parse("").is_none()); }
#[test] fn generate_did_document_returns_none_for_non_web() { let identity = make_identity(IdentityMode::DidPlc, Some("did:plc:abc123")); assert!( generate_did_document( &identity, "example.com", "zKey", &[], "https://example.com", &[] ) .is_none() ); }
#[test] fn generate_did_document_derives_did_from_host() { let identity = make_identity(IdentityMode::DidWeb, None); let doc = generate_did_document( &identity, "example.com", "zKey123", &[], "https://example.com", &[], ) .unwrap(); assert_eq!(doc["id"], "did:web:example.com"); }
#[test] fn generate_did_document_with_no_entries() { let identity = make_identity(IdentityMode::DidWeb, None); let doc = generate_did_document( &identity, "example.com", "zKey123", &[], "https://example.com", &[], ) .unwrap(); assert_eq!(doc["id"], "did:web:example.com"); assert_eq!( doc["verificationMethod"][0]["publicKeyMultibase"], "zKey123" ); assert_eq!(doc["service"].as_array().unwrap().len(), 0); }
#[test] fn generate_did_document_with_entries() { let identity = make_identity(IdentityMode::DidWeb, None); let entries = vec![ ("#chess".to_string(), "ChessService".to_string()), ("#checkers".to_string(), "CheckersService".to_string()), ]; let doc = generate_did_document( &identity, "example.com", "zKey123", &entries, "https://example.com", &[], ) .unwrap(); let services = doc["service"].as_array().unwrap(); assert_eq!(services.len(), 2); assert_eq!(services[0]["id"], "#chess"); assert_eq!(services[0]["type"], "ChessService"); assert_eq!(services[0]["serviceEndpoint"], "https://example.com"); assert_eq!(services[1]["id"], "#checkers"); }
#[test] fn generate_did_document_context_and_structure() { let identity = make_identity(IdentityMode::DidWeb, None); let doc = generate_did_document( &identity, "example.com", "zKey", &[], "https://example.com", &[], ) .unwrap(); let context = doc["@context"].as_array().unwrap(); assert_eq!(context.len(), 2); assert_eq!(context[0], "https://www.w3.org/ns/did/v1"); assert_eq!(context[1], "https://w3id.org/security/multikey/v1");
let vm = &doc["verificationMethod"][0]; assert_eq!(vm["id"], "did:web:example.com#atproto"); assert_eq!(vm["type"], "Multikey"); assert_eq!(vm["controller"], "did:web:example.com"); }
#[test] fn generate_did_document_includes_extra_verification_methods() { let identity = make_identity(IdentityMode::DidWeb, None); let extra = vec![( "#atproto_space".to_string(), "Multikey".to_string(), "zExtraKey".to_string(), )]; let doc = generate_did_document( &identity, "example.com", "zKey123", &[], "https://example.com", &extra, ) .unwrap(); let vms = doc["verificationMethod"].as_array().unwrap(); assert_eq!(vms.len(), 2); assert_eq!(vms[0]["id"], "did:web:example.com#atproto"); assert_eq!(vms[1]["id"], "did:web:example.com#atproto_space"); assert_eq!(vms[1]["publicKeyMultibase"], "zExtraKey"); assert_eq!(vms[1]["type"], "Multikey"); }}