Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm

little cleanup

basic api stuff

Changed files
+275 -135
.github
workflows
pocket
quasar
+1 -1
.github/workflows/checks.yml
··· 28 28 - name: get nightly toolchain for jetstream fmt 29 29 run: rustup toolchain install nightly --allow-downgrade -c rustfmt 30 30 - name: fmt 31 - run: cargo fmt --package links --package constellation --package ufos --package spacedust --package who-am-i --package slingshot -- --check 31 + run: cargo fmt --package links --package constellation --package ufos --package spacedust --package who-am-i --package slingshot --package pocket -- --check 32 32 - name: fmt jetstream (nightly) 33 33 run: cargo +nightly fmt --package jetstream -- --check 34 34 - name: clippy
+1 -1
Makefile
··· 5 5 cargo test --all-features 6 6 7 7 fmt: 8 - cargo fmt --package links --package constellation --package ufos --package spacedust --package who-am-i --package slingshot 8 + cargo fmt --package links --package constellation --package ufos --package spacedust --package who-am-i --package slingshot --package pocket 9 9 cargo +nightly fmt --package jetstream 10 10 11 11 clippy:
+17
pocket/api-description.md
··· 1 + _A pocket dimension to stash a bit of non-public user data._ 2 + 3 + 4 + # Pocket: user preference storage 5 + 6 + This API leverages atproto service proxying to offer a bit of per-user per-app non-public data storage. 7 + Perfect for things like application preferences that might be better left out of the public PDS data. 8 + 9 + The intent is to use oauth scopes to isolate storage on a per-application basis, and to allow easy data migration from a community hosted instance to your own if you end up needing that. 10 + 11 + 12 + ### Current status 13 + 14 + > [!important] 15 + > Pocket is currently in a **v0, pre-release state**. There is one production instance and you can use it! Expect short downtimes for restarts as development progresses and occaisional data loss until it's stable. 16 + 17 + ATProto might end up adding a similar feature to [PDSs](https://atproto.com/guides/glossary#pds-personal-data-server). If/when that happens, you should use it instead of this!
+1 -1
pocket/src/lib.rs
··· 2 2 mod token; 3 3 4 4 pub use server::serve; 5 - pub use token::verify; 5 + pub use token::TokenVerifier;
-1
pocket/src/main.rs
··· 6 6 println!("Hello, world!"); 7 7 serve("mac.cinnebar-tet.ts.net").await 8 8 } 9 -
+72 -78
pocket/src/server.rs
··· 1 + use crate::TokenVerifier; 1 2 use poem::{ 2 - endpoint::make_sync, 3 - Endpoint, 4 - Route, 5 - Server, 6 - EndpointExt, 7 - http::{Method, HeaderMap}, 3 + Endpoint, EndpointExt, Route, Server, 4 + endpoint::{StaticFileEndpoint, make_sync}, 5 + http::Method, 6 + listener::TcpListener, 8 7 middleware::{CatchPanic, Cors, Tracing}, 9 - listener::TcpListener, 10 8 }; 11 9 use poem_openapi::{ 12 - ContactObject, 13 - ExternalDocumentObject, 14 - OpenApi, 15 - OpenApiService, 16 - Tags, 17 - Object, 18 - ApiResponse, 10 + ApiResponse, ContactObject, ExternalDocumentObject, Object, OpenApi, OpenApiService, 11 + SecurityScheme, Tags, 12 + auth::Bearer, 13 + payload::{Json, PlainText}, 19 14 types::Example, 20 - auth::Bearer, 21 - payload::Json, 22 - SecurityScheme, 23 15 }; 24 - use crate::verify; 25 16 use serde::Serialize; 26 17 use serde_json::{Value, json}; 27 - 28 18 29 19 #[derive(Debug, SecurityScheme)] 30 20 #[oai(ty = "bearer")] 31 - struct BlahAuth(Bearer); 32 - 21 + struct XrpcAuth(Bearer); 33 22 34 23 #[derive(Tags)] 35 24 enum ApiTags { 36 - /// Bluesky-compatible APIs. 37 - #[oai(rename = "app.bsky.* queries")] 38 - AppBsky, 25 + /// Custom pocket APIs 26 + #[oai(rename = "Pocket APIs")] 27 + Pocket, 39 28 } 40 29 41 30 #[derive(Object)] ··· 86 75 /// Bad request or no preferences to return 87 76 #[oai(status = 400)] 88 77 BadRequest(XrpcError), 78 + } 79 + 80 + #[derive(ApiResponse)] 81 + enum PutBskyPrefsResponse { 82 + /// Record found 83 + #[oai(status = 200)] 84 + Ok(PlainText<String>), 85 + /// Bad request or no preferences to return 86 + #[oai(status = 400)] 87 + BadRequest(XrpcError), 89 88 // /// Server errors 90 89 // #[oai(status = 500)] 91 90 // ServerError(XrpcError), 92 91 } 93 92 94 93 struct Xrpc { 95 - domain: String, 94 + verifier: TokenVerifier, 96 95 } 97 96 98 97 #[OpenApi] 99 98 impl Xrpc { 100 - /// app.bsky.actor.getPreferences 99 + /// com.bad-example.pocket.getPreferences 101 100 /// 102 101 /// get stored bluesky prefs 103 102 #[oai( 104 - path = "/app.bsky.actor.getPreferences", 103 + path = "/com.bad-example.pocket.getPreferences", 105 104 method = "get", 106 - tag = "ApiTags::AppBsky" 105 + tag = "ApiTags::Pocket" 107 106 )] 108 - async fn app_bsky_get_prefs( 109 - &self, 110 - BlahAuth(auth): BlahAuth, 111 - m: &HeaderMap, 112 - ) -> GetBskyPrefsResponse { 113 - log::warn!("hm: {m:?}"); 114 - match verify( 115 - &format!("did:web:{}#bsky_appview", self.domain), 116 - "app.bsky.actor.getPreferences", 117 - &auth.token, 118 - ).await { 119 - Ok(did) => log::info!("wooo! {did}"), 120 - Err(err) => return GetBskyPrefsResponse::BadRequest(xrpc_error("booo", err)), 107 + async fn app_bsky_get_prefs(&self, XrpcAuth(auth): XrpcAuth) -> GetBskyPrefsResponse { 108 + let did = match self 109 + .verifier 110 + .verify("app.bsky.actor.getPreferences", &auth.token) 111 + .await 112 + { 113 + Ok(d) => d, 114 + Err(e) => return GetBskyPrefsResponse::BadRequest(xrpc_error("boooo", e.to_string())), 121 115 }; 122 - log::warn!("got bearer: {:?}", auth.token); 116 + log::info!("verified did: {did}"); 117 + // TODO: fetch from storage 123 118 GetBskyPrefsResponse::Ok(Json(GetBskyPrefsResponseObject::example())) 124 119 } 125 120 126 - /// app.bsky.actor.putPreferences 121 + /// com.bad-example.pocket.putPreferences 127 122 /// 128 123 /// store bluesky prefs 129 124 #[oai( 130 - path = "/app.bsky.actor.putPreferences", 125 + path = "/com.bad-example.pocket.putPreferences", 131 126 method = "post", 132 - tag = "ApiTags::AppBsky" 127 + tag = "ApiTags::Pocket" 133 128 )] 134 129 async fn app_bsky_put_prefs( 135 130 &self, 131 + XrpcAuth(auth): XrpcAuth, 136 132 Json(prefs): Json<Value>, 137 - ) -> () { 133 + ) -> PutBskyPrefsResponse { 134 + let did = match self 135 + .verifier 136 + .verify("app.bsky.actor.getPreferences", &auth.token) 137 + .await 138 + { 139 + Ok(d) => d, 140 + Err(e) => return PutBskyPrefsResponse::BadRequest(xrpc_error("boooo", e.to_string())), 141 + }; 142 + log::info!("verified did: {did}"); 138 143 log::warn!("received prefs: {prefs:?}"); 139 - () 144 + // TODO: put prefs into storage 145 + PutBskyPrefsResponse::Ok(PlainText("hiiiiii".to_string())) 140 146 } 141 147 } 142 148 ··· 157 163 let doc = poem::web::Json(AppViewDoc { 158 164 id: format!("did:web:{domain}"), 159 165 service: [AppViewService { 160 - id: "#bsky_appview".to_string(), 161 - r#type: "PocketBlueskyPreferences".to_string(), 166 + id: "#pocket_prefs".to_string(), 167 + r#type: "PocketPreferences".to_string(), 162 168 service_endpoint: format!("https://{domain}"), 163 169 }], 164 170 }); 165 171 make_sync(move |_| doc.clone()) 166 172 } 167 173 168 - pub async fn serve( 169 - domain: &str, 170 - ) -> () { 171 - let api_service = OpenApiService::new( 172 - Xrpc { domain: domain.to_string() }, 173 - "Pocket", 174 - env!("CARGO_PKG_VERSION"), 175 - ) 176 - .server(domain) 177 - .url_prefix("/xrpc") 178 - .contact( 179 - ContactObject::new() 180 - .name("@microcosm.blue") 181 - .url("https://bsky.app/profile/microcosm.blue"), 182 - ) 183 - // .description(include_str!("../api-description.md")) 184 - .external_document(ExternalDocumentObject::new( 185 - "https://microcosm.blue/pocket", 186 - )); 174 + pub async fn serve(domain: &str) -> () { 175 + let verifier = TokenVerifier::new(domain); 176 + let api_service = OpenApiService::new(Xrpc { verifier }, "Pocket", env!("CARGO_PKG_VERSION")) 177 + .server(domain) 178 + .url_prefix("/xrpc") 179 + .contact( 180 + ContactObject::new() 181 + .name("@microcosm.blue") 182 + .url("https://bsky.app/profile/microcosm.blue"), 183 + ) 184 + .description(include_str!("../api-description.md")) 185 + .external_document(ExternalDocumentObject::new("https://microcosm.blue/pocket")); 187 186 188 187 let app = Route::new() 189 - .at("/.well-known/did.json", get_did_doc(&domain)) 188 + .nest("/openapi", api_service.spec_endpoint()) 190 189 .nest("/xrpc/", api_service) 191 - // .at("/", StaticFileEndpoint::new("./static/index.html")) 192 - // .nest("/openapi", api_service.spec_endpoint()) 190 + .at("/.well-known/did.json", get_did_doc(domain)) 191 + .at("/", StaticFileEndpoint::new("./static/index.html")) 193 192 .with( 194 193 Cors::new() 195 194 .allow_method(Method::GET) 196 - .allow_method(Method::POST) 195 + .allow_method(Method::POST), 197 196 ) 198 197 .with(CatchPanic::new()) 199 198 .with(Tracing); 200 199 201 200 let listener = TcpListener::bind("127.0.0.1:3000"); 202 - Server::new(listener) 203 - .name("pocket") 204 - .run(app) 205 - .await 206 - .unwrap(); 207 - 201 + Server::new(listener).name("pocket").run(app).await.unwrap(); 208 202 }
+113 -52
pocket/src/token.rs
··· 1 - use jwt_compact::{Claims, UntrustedToken}; 2 1 use atrium_crypto::did::parse_multikey; 3 2 use atrium_crypto::verify::Verifier; 4 - use std::collections::HashMap; 3 + use jwt_compact::UntrustedToken; 5 4 use serde::Deserialize; 5 + use std::collections::HashMap; 6 + use std::time::Duration; 7 + use thiserror::Error; 6 8 7 9 #[derive(Debug, Deserialize)] 8 10 struct MiniDoc { 9 11 signing_key: String, 12 + did: String, 10 13 } 11 14 12 - pub async fn verify( 13 - expected_aud: &str, 14 - expected_lxm: &str, 15 - token: &str, 16 - ) -> Result<String, &'static str> { 17 - let untrusted = UntrustedToken::new(token).unwrap(); 15 + #[derive(Error, Debug)] 16 + pub enum VerifyError { 17 + #[error("The cross-service authorization token failed verification: {0}")] 18 + VerificationFailed(&'static str), 19 + #[error("Error trying to resolve the DID to a signing key, retry in a moment: {0}")] 20 + ResolutionFailed(&'static str), 21 + } 22 + 23 + pub struct TokenVerifier { 24 + domain: String, 25 + client: reqwest::Client, 26 + } 27 + 28 + impl TokenVerifier { 29 + pub fn new(domain: &str) -> Self { 30 + let client = reqwest::Client::builder() 31 + .user_agent(format!( 32 + "microcosm pocket v{} (dev: @bad-example.com)", 33 + env!("CARGO_PKG_VERSION") 34 + )) 35 + .no_proxy() 36 + .timeout(Duration::from_secs(12)) // slingshot timeout is 10s 37 + .build() 38 + .unwrap(); 39 + Self { 40 + client, 41 + domain: domain.to_string(), 42 + } 43 + } 44 + 45 + pub async fn verify(&self, expected_lxm: &str, token: &str) -> Result<String, VerifyError> { 46 + let untrusted = UntrustedToken::new(token).unwrap(); 47 + 48 + // danger! unfortunately we need to decode the DID from the jwt body before we have a public key to verify the jwt with 49 + let Ok(untrusted_claims) = 50 + untrusted.deserialize_claims_unchecked::<HashMap<String, String>>() 51 + else { 52 + return Err(VerifyError::VerificationFailed( 53 + "could not deserialize jtw claims", 54 + )); 55 + }; 18 56 19 - let claims: Claims<HashMap<String, String>> = untrusted.deserialize_claims_unchecked().unwrap(); 57 + // get the (untrusted!) claimed DID 58 + let Some(untrusted_did) = untrusted_claims.custom.get("iss") else { 59 + return Err(VerifyError::VerificationFailed( 60 + "jwt must include the user's did in `iss`", 61 + )); 62 + }; 20 63 21 - let Some(did) = claims.custom.get("iss") else { 22 - return Err("jwt must include the user's did in `iss`"); 23 - }; 64 + // bail if it's not even a user-ish did 65 + if !untrusted_did.starts_with("did:") { 66 + return Err(VerifyError::VerificationFailed("iss should be a did")); 67 + } 68 + if untrusted_did.contains("#") { 69 + return Err(VerifyError::VerificationFailed( 70 + "iss should be a user did without a service identifier", 71 + )); 72 + } 24 73 25 - if !did.starts_with("did:") { 26 - return Err("iss should be a did"); 27 - } 28 - if did.contains("#") { 29 - return Err("iss should be a user did without a service identifier"); 30 - } 74 + let endpoint = 75 + "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc"; 76 + let doc: MiniDoc = self 77 + .client 78 + .get(format!("{endpoint}?identifier={untrusted_did}")) 79 + .send() 80 + .await 81 + .map_err(|_| VerifyError::ResolutionFailed("failed to fetch minidoc"))? 82 + .error_for_status() 83 + .map_err(|_| VerifyError::ResolutionFailed("non-ok response for minidoc"))? 84 + .json() 85 + .await 86 + .map_err(|_| VerifyError::ResolutionFailed("failed to parse json to minidoc"))?; 31 87 32 - println!("Claims: {claims:#?}"); 33 - println!("did: {did:#?}"); 88 + // sanity check before we go ahead with this signing key 89 + if doc.did != *untrusted_did { 90 + return Err(VerifyError::VerificationFailed( 91 + "wtf, resolveMiniDoc returned a doc for a different DID, slingshot bug", 92 + )); 93 + } 34 94 35 - let endpoint = "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc"; 36 - let doc: MiniDoc = reqwest::get(format!("{endpoint}?identifier={did}")) 37 - .await 38 - .unwrap() 39 - .error_for_status() 40 - .unwrap() 41 - .json() 42 - .await 43 - .unwrap(); 95 + let Ok((alg, public_key)) = parse_multikey(&doc.signing_key) else { 96 + return Err(VerifyError::VerificationFailed( 97 + "could not parse signing key form minidoc", 98 + )); 99 + }; 44 100 45 - log::info!("got minidoc response: {doc:?}"); 101 + // i _guess_ we've successfully bootstrapped the verification of the jwt unless this fails 102 + if let Err(e) = Verifier::default().verify( 103 + alg, 104 + &public_key, 105 + &untrusted.signed_data, 106 + untrusted.signature_bytes(), 107 + ) { 108 + log::warn!("jwt verification failed: {e}"); 109 + return Err(VerifyError::VerificationFailed( 110 + "jwt signature verification failed", 111 + )); 112 + } 46 113 47 - let (alg, public_key) = parse_multikey(&doc.signing_key).unwrap(); 48 - log::info!("parsed key: {public_key:?}"); 114 + // past this point we're should have established trust. crossing ts and dotting is. 115 + let did = &untrusted_did; 116 + let claims = &untrusted_claims; 49 117 50 - Verifier::default().verify( 51 - alg, 52 - &public_key, 53 - &untrusted.signed_data, 54 - untrusted.signature_bytes(), 55 - ).unwrap(); 56 - // if this passes, then our claims were trustworthy after all(??) 118 + let Some(aud) = claims.custom.get("aud") else { 119 + return Err(VerifyError::VerificationFailed("missing aud")); 120 + }; 121 + if *aud != format!("did:web:{}#bsky_appview", self.domain) { 122 + return Err(VerifyError::VerificationFailed("wrong aud")); 123 + } 124 + let Some(lxm) = claims.custom.get("lxm") else { 125 + return Err(VerifyError::VerificationFailed("missing lxm")); 126 + }; 127 + if lxm != expected_lxm { 128 + return Err(VerifyError::VerificationFailed("wrong lxm")); 129 + } 57 130 58 - let Some(aud) = claims.custom.get("aud") else { 59 - return Err("missing aud"); 60 - }; 61 - if aud != expected_aud { 62 - return Err("wrong aud"); 63 - } 64 - let Some(lxm) = claims.custom.get("lxm") else { 65 - return Err("missing lxm"); 66 - }; 67 - if lxm != expected_lxm { 68 - return Err("wrong lxm"); 131 + Ok(did.to_string()) 69 132 } 70 - 71 - Ok(did.to_string()) 72 133 }
+67
pocket/static/index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <title>Pocket: atproto user preference storage</title> 6 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 7 + <meta name="description" content="API Documentation for Pocket, a simple user-preference storage system for atproto" /> 8 + <style> 9 + :root { 10 + --scalar-small: 13px; 11 + } 12 + .scalar-app .markdown .markdown-alert { 13 + font-size: var(--scalar-small); 14 + } 15 + .sidebar-heading-link-title { 16 + line-height: 1.2; 17 + } 18 + .custom-header { 19 + height: 42px; 20 + background-color: #221828; 21 + box-shadow: inset 0 -1px 0 var(--scalar-border-color); 22 + color: var(--scalar-color-1); 23 + font-size: var(--scalar-font-size-3); 24 + font-family: 'Iowan Old Style', 'Palatino Linotype', 'URW Palladio L', P052, serif; 25 + padding: 0 18px; 26 + justify-content: space-between; 27 + } 28 + .custom-header, 29 + .custom-header nav { 30 + display: flex; 31 + align-items: center; 32 + gap: 18px; 33 + } 34 + .custom-header a:hover { 35 + color: var(--scalar-color-2); 36 + } 37 + 38 + .light-mode .custom-header { 39 + background-color: thistle; 40 + } 41 + </style> 42 + </head> 43 + <body> 44 + <header class="custom-header scalar-app"> 45 + <p> 46 + TODO: thing 47 + </p> 48 + <nav> 49 + <b>a <a href="https://microcosm.blue">microcosm</a> project</b> 50 + <a href="https://bsky.app/profile/microcosm.blue">@microcosm.blue</a> 51 + <a href="https://github.com/at-microcosm">github</a> 52 + </nav> 53 + </header> 54 + 55 + <script id="api-reference" type="application/json" data-url="/openapi"></script> 56 + 57 + <script> 58 + var configuration = { 59 + theme: 'purple', 60 + hideModels: true, 61 + } 62 + document.getElementById('api-reference').dataset.configuration = JSON.stringify(configuration) 63 + </script> 64 + 65 + <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script> 66 + </body> 67 + </html>
+2
quasar/src/lib.rs
··· 1 1 mod storage; 2 + 3 + pub use storage::Storage;
+1 -1
quasar/src/storage.rs
··· 1 1 2 - trait Storage { 2 + pub trait Storage { 3 3 4 4 }