char/plc-mirror
git clone https://git.t4t.associates/char/plc-mirror
c2f2166
main
1use std:: net:: SocketAddr ; 2use std:: path:: PathBuf ; 3use std:: sync:: Arc ; 4 5use anyhow::{ Context , Result }; 6use axum::{ 7Router , 8 extract::{ Path as AxumPath , State }, 9 http::{ HeaderValue , StatusCode , header:: CONTENT_TYPE }, 10 response::{ IntoResponse , Response }, 11 routing:: get, 12}; 13use clap:: Parser ; 14use serde::{ Deserialize , Serialize }; 15 16mod db; 17 18// Vendored from plox/src/opcodec.rs; keep the storage format implementation in sync. 19# [ allow ( dead_code )] 20mod opcodec; 21 22# [ derive ( Parser )] 23struct Args { 24# [ arg ( long , env = "PLC_MIRROR_DB" , default_value = "../plox/data/plox.db" )] 25db : PathBuf , 26 27# [ arg ( long , env = "PLC_MIRROR_LISTEN" , default_value = "127.0.0.1:2486" )] 28listen : SocketAddr , 29 30# [ arg ( 31long , 32env = "PLC_MIRROR_DB_POOL_SIZE" , 33default_value_t = 16 , 34value_parser = clap :: value_parser! ( u32 ). range ( 1 .. ) 35)] 36db_pool_size : u32 , 37} 38 39# [ derive ( Deserialize )] 40# [ serde ( tag = "type" )] 41enum Operation { 42# [ serde ( rename = "plc_operation" )] 43Regular ( RegularOperation ), 44# [ serde ( rename = "create" )] 45Legacy ( LegacyOperation ), 46# [ serde ( rename = "plc_tombstone" )] 47Tombstone , 48} 49 50# [ derive ( Deserialize )] 51# [ serde ( rename_all = "camelCase" )] 52struct RegularOperation { 53verification_methods : indexmap:: IndexMap < String , String >, 54also_known_as : Vec < String >, 55services : indexmap:: IndexMap < String , OperationService >, 56} 57 58# [ derive ( Deserialize )] 59struct OperationService { 60# [ serde ( rename = "type" )] 61kind : String , 62endpoint : String , 63} 64 65# [ derive ( Deserialize )] 66# [ serde ( rename_all = "camelCase" )] 67struct LegacyOperation { 68signing_key : String , 69handle : String , 70service : String , 71} 72 73# [ derive ( Serialize )] 74# [ serde ( rename_all = "camelCase" )] 75struct DidDocument { 76# [ serde ( rename = "@context" )] 77context : Vec < String >, 78id : String , 79also_known_as : Vec < String >, 80verification_method : Vec < VerificationMethod >, 81service : Vec < Service >, 82} 83 84# [ derive ( Serialize )] 85# [ serde ( rename_all = "camelCase" )] 86struct VerificationMethod { 87id : String , 88# [ serde ( rename = "type" )] 89kind : &' static str , 90controller : String , 91public_key_multibase : String , 92} 93 94# [ derive ( Serialize )] 95# [ serde ( rename_all = "camelCase" )] 96struct Service { 97id : String , 98# [ serde ( rename = "type" )] 99kind : String , 100service_endpoint : String , 101} 102 103enum Resolution { 104Document ( DidDocument ), 105Tombstone , 106} 107 108fn resolution_from_operation ( did : & str , operation : serde_json:: Value ) ->Result < Resolution > { 109let operation: Operation = 110 serde_json:: from_value ( operation). context ( "parsing PLC operation" ) ?; 111// legacy create ops resolve through the same path as regular ops once 112// normalized, mirroring plc.directory's normalizeOp 113let ( verification_methods, also_known_as, services) =match operation{ 114Operation :: Regular ( op) =>( op. verification_methods , op. also_known_as , op. services ), 115Operation :: Legacy ( op) =>( 116[( "atproto" . to_string (), op. signing_key )] 117. into_iter () 118. collect (), 119vec! [ ensure_atproto_prefix ( & op. handle )], 120[( 121"atproto_pds" . to_string (), 122OperationService { 123kind : "AtprotoPersonalDataServer" . into (), 124endpoint : ensure_http_prefix ( & op. service ), 125}, 126)] 127. into_iter () 128. collect (), 129), 130Operation :: Tombstone =>return Ok ( Resolution :: Tombstone ), 131}; 132 133let mut context =vec! [ 134"https://www.w3.org/ns/did/v1" . to_string (), 135"https://w3id.org/security/multikey/v1" . to_string (), 136]; 137let verification_method = verification_methods 138. into_iter () 139. map ( |( name, key) |{ 140let multibase = key. strip_prefix ( "did:key:" ). unwrap_or ( & key); 141if let Some ( suite) =suite_context ( multibase) 142 && !context. iter (). any ( |existing| existing == suite) 143{ 144 context. push ( suite. into ()); 145} 146VerificationMethod { 147id : format! ( "{did}#{name}" ), 148kind : "Multikey" , 149controller : did. into (), 150public_key_multibase : multibase. into (), 151} 152}) 153. collect (); 154let service = services 155. into_iter () 156. map ( |( name, service) |Service { 157id : format! ( "#{name}" ), 158kind : service. kind , 159service_endpoint : service. endpoint , 160}) 161. collect (); 162 163Ok ( Resolution :: Document ( DidDocument { 164 context, 165id : did. into (), 166 also_known_as, 167 verification_method, 168 service, 169})) 170} 171 172/// Suite context for a verification key, mirroring plc.directory's 173/// formatKeyAndContext: the key type comes from the did:key multibase 174/// prefix (zDnae = P-256, zQ3sh = secp256k1); other types get none. 175fn suite_context ( public_key_multibase : & str ) ->Option < &' static str > { 176if public_key_multibase. starts_with ( "zDnae" ) { 177Some ( "https://w3id.org/security/suites/ecdsa-2019/v1" ) 178} else if public_key_multibase. starts_with ( "zQ3sh" ) { 179Some ( "https://w3id.org/security/suites/secp256k1-2019/v1" ) 180} else { 181None 182} 183} 184 185fn ensure_http_prefix ( s : & str ) ->String { 186if s. starts_with ( "http://" ) || s. starts_with ( "https://" ) { 187 s. to_string () 188} else { 189format! ( "https://{s}" ) 190} 191} 192 193fn ensure_atproto_prefix ( s : & str ) ->String { 194if s. starts_with ( "at://" ) { 195return s. to_string (); 196} 197let stripped = s 198. strip_prefix ( "https://" ) 199. or_else ( || s. strip_prefix ( "http://" )) 200. unwrap_or ( s); 201format! ( "at://{stripped}" ) 202} 203 204async fn get_did ( State ( db_pool): State < Arc < db:: Db >>, AxumPath ( did): AxumPath < String >) ->Response { 205if !did. starts_with ( "did:plc:" ) { 206return StatusCode :: NOT_FOUND . into_response (); 207} 208 209match db:: resolve ( & db_pool, & did). await { 210Ok ( Some ( Resolution :: Document ( document))) =>{ 211// plc.directory serves did documents as did+ld+json 212let mut response = axum:: Json ( document). into_response (); 213 response. headers_mut (). insert ( 214CONTENT_TYPE , 215HeaderValue :: from_static ( "application/did+ld+json" ), 216); 217 response 218} 219// plc.directory answers tombstoned dids with 404 ("DID not available") 220Ok ( Some ( Resolution :: Tombstone ) |None ) =>StatusCode :: NOT_FOUND . into_response (), 221Err ( error) =>{ 222 tracing:: error!( %error, "failed to resolve DID" ); 223StatusCode :: INTERNAL_SERVER_ERROR . into_response () 224} 225} 226} 227 228# [ tokio :: main ] 229async fn main () ->Result <()> { 230 tracing_subscriber:: fmt () 231. with_env_filter ( 232 tracing_subscriber:: EnvFilter :: try_from_default_env () 233. unwrap_or_else ( |_|"plc_mirror=info" . into ()), 234) 235. init (); 236 237let args =Args :: parse (); 238 tracing:: info!( db = %args. db . display (), "opening plox database read-only" ); 239let db_pool = db:: open_database ( & args. db , args. db_pool_size as usize ) ?; 240 241let app =Router :: new () 242. route ( 243"/" , 244get ( ||async { 245"plc-mirror: a read-only mirror of plc.directory.\n\ 246\n\ 247GET /{did} resolve a did:plc DID to its DID document\n" 248}), 249) 250. route ( "/{did}" , get ( get_did)) 251. with_state ( db_pool); 252let listener = tokio:: net:: TcpListener :: bind ( args. listen ). await ?; 253 tracing:: info!( listen = %args. listen , "serving PLC DID documents" ); 254 axum:: serve ( listener, app) 255. with_graceful_shutdown ( async { 256let _ = tokio:: signal:: ctrl_c (). await ; 257}) 258. await ?; 259Ok (()) 260} 261 262# [ cfg ( test )] 263mod tests{ 264use super :: * ; 265use crate :: db:: open_database; 266use rusqlite::{ Connection , params}; 267 268const REGULAR_OPERATION : & str =r#"{ 269"type": "plc_operation", 270"rotationKeys": ["did:key:zRotation"], 271"verificationMethods": { 272"atproto": "did:key:zQ3shSigning", 273"backup": "did:key:zQ3shBackup" 274}, 275"alsoKnownAs": ["at://alice.example"], 276"services": { 277"atproto_pds": { 278"type": "AtprotoPersonalDataServer", 279"endpoint": "https://pds.example" 280} 281}, 282"prev": null 283}"# ; 284 285# [ test ] 286fn converts_legacy_genesis () { 287let Resolution :: Document ( document) =resolution_from_operation ( 288"did:plc:example" , 289 serde_json:: from_str ( 290r#"{ 291"type": "create", 292"signingKey": "did:key:zDnaeSigning", 293"recoveryKey": "did:key:zRecovery", 294"handle": "alice.example", 295"service": "pds.example" 296}"# , 297) 298. unwrap (), 299) 300. unwrap () else { 301panic! ( "legacy genesis should produce a document" ) 302}; 303 304assert_eq! ( document. also_known_as , [ "at://alice.example" ]); 305assert_eq! ( 306 document. context , 307[ 308"https://www.w3.org/ns/did/v1" , 309"https://w3id.org/security/multikey/v1" , 310"https://w3id.org/security/suites/ecdsa-2019/v1" 311] 312); 313assert_eq! ( 314 document. verification_method [ 0 ]. id , 315"did:plc:example#atproto" 316); 317assert_eq! ( 318 document. verification_method [ 0 ]. public_key_multibase , 319"zDnaeSigning" 320); 321assert_eq! ( document. service [ 0 ]. id , "#atproto_pds" ); 322assert_eq! ( document. service [ 0 ]. service_endpoint , "https://pds.example" ); 323} 324 325# [ test ] 326fn serializes_regular_operation_as_did_document () { 327let Resolution :: Document ( document) =resolution_from_operation ( 328"did:plc:example" , 329 serde_json:: from_str ( REGULAR_OPERATION ). unwrap (), 330) 331. unwrap () else { 332panic! ( "regular operation should produce a document" ) 333}; 334 335assert_eq! ( 336 serde_json:: to_value ( document). unwrap (), 337 serde_json:: json!({ 338"@context" : [ 339"https://www.w3.org/ns/did/v1" , 340"https://w3id.org/security/multikey/v1" , 341"https://w3id.org/security/suites/secp256k1-2019/v1" 342], 343"id" : "did:plc:example" , 344"alsoKnownAs" : [ "at://alice.example" ], 345"verificationMethod" : [ 346{ 347"id" : "did:plc:example#atproto" , 348"type" : "Multikey" , 349"controller" : "did:plc:example" , 350"publicKeyMultibase" : "zQ3shSigning" 351}, 352{ 353"id" : "did:plc:example#backup" , 354"type" : "Multikey" , 355"controller" : "did:plc:example" , 356"publicKeyMultibase" : "zQ3shBackup" 357} 358], 359"service" : [{ 360"id" : "#atproto_pds" , 361"type" : "AtprotoPersonalDataServer" , 362"serviceEndpoint" : "https://pds.example" 363}] 364}) 365); 366} 367 368# [ test ] 369fn recognizes_tombstones () { 370assert! ( matches! ( 371resolution_from_operation ( 372"did:plc:example" , 373 serde_json:: json!({ "type" : "plc_tombstone" , "prev" : "previous" , "sig" : "signature" }) 374), 375Ok ( Resolution :: Tombstone ) 376)); 377} 378 379fn database () ->( tempfile:: NamedTempFile , Connection ) { 380let database = tempfile:: NamedTempFile :: new (). unwrap (); 381let connection =Connection :: open ( database. path ()). unwrap (); 382 connection 383. execute_batch ( 384"CREATE TABLE ops ( 385seq INTEGER PRIMARY KEY CHECK (seq > 0), 386did BLOB NOT NULL, 387operation BLOB NOT NULL, 388created_at BLOB NOT NULL DEFAULT X'00' 389) STRICT; 390CREATE INDEX ops_did ON ops(did); 391CREATE TABLE strings (id INTEGER PRIMARY KEY, value TEXT NOT NULL UNIQUE) STRICT; 392INSERT INTO strings VALUES (0, 'https://pds.example'); 393PRAGMA user_version = 1;" , 394) 395. unwrap (); 396( database, connection) 397} 398 399# [ tokio :: test ] 400async fn serves_the_latest_database_operation () { 401let ( database, connection) =database (); 402 connection 403. execute ( 404"INSERT INTO ops (seq, did, operation) VALUES (?1, ?2, ?3)" , 405params! [ 4061 , 407 opcodec:: encode_did ( "did:plc:ragtjsm2j2vknwkz3zp4oxrd" ), 408 opcodec:: encode ( & serde_json:: json!({ "type" : "plc_tombstone" }), |_|None ) 409. unwrap () 410], 411) 412. unwrap (); 413 connection 414. execute ( 415"INSERT INTO ops (seq, did, operation) VALUES (?1, ?2, ?3)" , 416params! [ 4172 , 418 opcodec:: encode_did ( "did:plc:ragtjsm2j2vknwkz3zp4oxrd" ), 419 opcodec:: encode ( & serde_json:: from_str ( REGULAR_OPERATION ). unwrap (), |s|( s 420 =="https://pds.example" ) 421. then_some ( 0 )) 422. unwrap () 423], 424) 425. unwrap (); 426 427let pool =open_database ( database. path (), 1 ). unwrap (); 428let response =get_did ( 429State ( pool. clone ()), 430AxumPath ( "did:plc:ragtjsm2j2vknwkz3zp4oxrd" . into ()), 431) 432. await ; 433assert_eq! ( response. status (), StatusCode :: OK ); 434assert_eq! ( 435 response. headers (). get ( CONTENT_TYPE ). unwrap (), 436"application/did+ld+json" 437); 438let body = axum:: body:: to_bytes ( response. into_body (), usize:: MAX ) 439. await 440. unwrap (); 441let document: serde_json:: Value = serde_json:: from_slice ( & body). unwrap (); 442assert_eq! ( 443 document[ "alsoKnownAs" ], 444 serde_json:: json!([ "at://alice.example" ]) 445); 446 447assert_eq! ( 448get_did ( State ( pool. clone ()), AxumPath ( "did:plc:missing" . into ())) 449. await 450. status (), 451StatusCode :: NOT_FOUND 452); 453assert_eq! ( 454get_did ( State ( pool), AxumPath ( "did:web:example.com" . into ())) 455. await 456. status (), 457StatusCode :: NOT_FOUND 458); 459} 460 461# [ tokio :: test ] 462async fn reads_dictionary_additions_while_serving () { 463let ( database, mut connection) =database (); 464let pool =open_database ( database. path (), 2 ). unwrap (); 465let did ="did:plc:ragtjsm2j2vknwkz3zp4oxrd" ; 466for ( id, endpoint) in [( 0 , "https://pds.example" ), ( 1 , "https://new-pds.example" )] { 467let tx = connection. transaction (). unwrap (); 468 tx. execute ( 469"INSERT OR IGNORE INTO strings VALUES (?1, ?2)" , 470params! [ id, endpoint], 471) 472. unwrap (); 473// Version 1 fixture: interned endpoint, handle, literal key, signature, Bluesky genesis. 474let mut program =b"\x01\x00\x0a\x05alice\x00\x14did:key:zQ3shSigning\x03" . to_vec (); 475 program[ 1 ] = idas u8 ; 476 program. extend ([ 0 ; 64 ]); 477 program. push ( 7 ); 478 tx. execute ( 479"INSERT INTO ops (seq, did, operation) VALUES (?1, ?2, ?3)" , 480params! [ id +1 , opcodec:: encode_did ( did), program], 481) 482. unwrap (); 483 tx. commit (). unwrap (); 484 485let response =get_did ( State ( pool. clone ()), AxumPath ( did. into ())). await ; 486assert_eq! ( response. status (), StatusCode :: OK ); 487let body = axum:: body:: to_bytes ( response. into_body (), usize:: MAX ) 488. await 489. unwrap (); 490let document: serde_json:: Value = serde_json:: from_slice ( & body). unwrap (); 491assert_eq! ( document[ "id" ], did); 492assert_eq! ( 493 document[ "alsoKnownAs" ], 494 serde_json:: json!([ "at://alice.bsky.social" ]) 495); 496assert_eq! ( 497 document[ "verificationMethod" ][ 0 ][ "publicKeyMultibase" ], 498"zQ3shSigning" 499); 500assert_eq! ( document[ "service" ][ 0 ][ "serviceEndpoint" ], endpoint); 501} 502} 503 504# [ tokio :: test ] 505async fn serves_compact_bluesky_create () { 506let ( database, connection) =database (); 507let did ="did:plc:ragtjsm2j2vknwkz3zp4oxrd" ; 508// Fixed BSKY_CREATE fixture, independent of the encoder. 509let mut program = 510b"\x01\x00\x00\x11alice.bsky.social\x00\x14did:key:zQ3shSigning\x03" . to_vec (); 511 program. extend ([ 0 ; 64 ]); 512 program. push ( 11 ); 513 connection 514. execute ( 515"INSERT INTO ops (seq, did, operation) VALUES (1, ?1, ?2)" , 516params! [ opcodec:: encode_did ( did), program], 517) 518. unwrap (); 519 520let pool =open_database ( database. path (), 1 ). unwrap (); 521let response =get_did ( State ( pool), AxumPath ( did. into ())). await ; 522assert_eq! ( response. status (), StatusCode :: OK ); 523let body = axum:: body:: to_bytes ( response. into_body (), usize:: MAX ) 524. await 525. unwrap (); 526let document: serde_json:: Value = serde_json:: from_slice ( & body). unwrap (); 527assert_eq! ( 528 document, 529 serde_json:: json!({ 530"@context" : [ 531"https://www.w3.org/ns/did/v1" , 532"https://w3id.org/security/multikey/v1" , 533"https://w3id.org/security/suites/secp256k1-2019/v1" 534], 535"id" : did, 536"alsoKnownAs" : [ "at://alice.bsky.social" ], 537"verificationMethod" : [{ 538"id" : format! ( "{did}#atproto" ), 539"type" : "Multikey" , 540"controller" : did, 541"publicKeyMultibase" : "zQ3shSigning" 542}], 543"service" : [{ 544"id" : "#atproto_pds" , 545"type" : "AtprotoPersonalDataServer" , 546"serviceEndpoint" : "https://pds.example" 547}] 548}) 549); 550} 551 552# [ tokio :: test ] 553async fn serves_legacy_operations_tombstones_and_decode_errors () { 554let (database, connection) = database (); 555let pool = open_database (database. path (), 1 ). unwrap (); 556let legacy = serde_json::json!({ 557"type" : "create" , "signingKey" : "did:key:zDnaeSigning" , 558"handle" : "alice.example" , "service" : "https://pds.example" 559}); 560for (program, status) in [ 561( 562opcodec:: encode ( & legacy, |s| (s == "https://pds.example" ). then_some ( 0 )). unwrap (), 563StatusCode :: OK , 564), 565( 566opcodec:: encode ( & serde_json::json!({ "type" : "plc_tombstone" }), |_| None ). unwrap (), 567StatusCode :: NOT_FOUND , 568), 569( vec! [ 255 ], StatusCode :: INTERNAL_SERVER_ERROR ), 570( vec! [ 1 , 99 ], StatusCode :: INTERNAL_SERVER_ERROR ), 571] { 572connection 573. execute ( 574"INSERT OR REPLACE INTO ops (seq, did, operation) VALUES (1, ?1, ?2)" , 575params! [ b"\x00did:plc:example" . as_slice (), program], 576) 577. unwrap (); 578assert_eq! ( 579get_did ( State (pool. clone ()), AxumPath ( "did:plc:example" . into ())) 580. await 581. status (), 582status, 583); 584} 585} 586 587# [ test ] 588fn rejects_unsupported_or_unfinished_databases () { 589let (database, connection) = database (); 590for version in [ 0 , 2 ] { 591connection 592. pragma_update ( None , "user_version" , version) 593. unwrap (); 594let error = open_database (database. path (), 1 ). err (). unwrap (); 595assert! ( 596error 597. to_string () 598. contains ( "unsupported plox database version" ) 599); 600} 601connection 602. execute_batch ( 603"PRAGMA user_version = 1; 604CREATE TABLE compaction (singleton INTEGER PRIMARY KEY, complete INTEGER NOT NULL); 605INSERT INTO compaction VALUES (1, 0);" , 606) 607. unwrap (); 608let error = open_database (database. path (), 1 ). err (). unwrap (); 609assert! (error. to_string (). contains ( "compaction is unfinished" )); 610connection 611. execute ( "UPDATE compaction SET complete = 1" , []) 612. unwrap (); 613assert! ( open_database (database. path (), 1 ). is_ok ()); 614 615let missing = database. path (). with_extension ( "missing" ); 616assert! ( open_database ( & missing, 1 ). is_err ()); 617assert! (!missing. exists ()); 618} 619}