Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm
1use crate::{
2 CachedRecord, ErrorResponseObject, Identity, Repo,
3 error::{RecordError, ServerError},
4};
5use atrium_api::types::string::{Cid, Did, Handle, Nsid, RecordKey};
6use foyer::HybridCache;
7use microcosm_links::at_uri::parse_at_uri as normalize_at_uri;
8use serde::Serialize;
9use std::path::PathBuf;
10use std::str::FromStr;
11use std::sync::Arc;
12use std::time::Instant;
13use tokio_util::sync::CancellationToken;
14
15use poem::{
16 Endpoint, EndpointExt, IntoResponse, Route, Server,
17 endpoint::{StaticFileEndpoint, make_sync},
18 http::Method,
19 listener::{
20 Listener, TcpListener,
21 acme::{AutoCert, LETS_ENCRYPT_PRODUCTION},
22 },
23 middleware::{CatchPanic, Cors, Tracing},
24};
25use poem_openapi::{
26 ApiResponse, ContactObject, ExternalDocumentObject, Object, OpenApi, OpenApiService, Tags,
27 param::Query, payload::Json, types::Example,
28};
29
30fn example_handle() -> String {
31 "bad-example.com".to_string()
32}
33fn example_did() -> String {
34 "did:plc:hdhoaan3xa3jiuq4fg4mefid".to_string()
35}
36fn example_collection() -> String {
37 "app.bsky.feed.like".to_string()
38}
39fn example_rkey() -> String {
40 "3lv4ouczo2b2a".to_string()
41}
42fn example_uri() -> String {
43 format!(
44 "at://{}/{}/{}",
45 example_did(),
46 example_collection(),
47 example_rkey()
48 )
49}
50fn example_pds() -> String {
51 "https://porcini.us-east.host.bsky.network".to_string()
52}
53fn example_signing_key() -> String {
54 "zQ3shpq1g134o7HGDb86CtQFxnHqzx5pZWknrVX2Waum3fF6j".to_string()
55}
56
57#[derive(Object)]
58#[oai(example = true)]
59struct XrpcErrorResponseObject {
60 /// Should correspond an error `name` in the lexicon errors array
61 error: String,
62 /// Human-readable description and possibly additonal context
63 message: String,
64}
65impl Example for XrpcErrorResponseObject {
66 fn example() -> Self {
67 Self {
68 error: "RecordNotFound".to_string(),
69 message: "This record was deleted".to_string(),
70 }
71 }
72}
73type XrpcError = Json<XrpcErrorResponseObject>;
74fn xrpc_error(error: impl AsRef<str>, message: impl AsRef<str>) -> XrpcError {
75 Json(XrpcErrorResponseObject {
76 error: error.as_ref().to_string(),
77 message: message.as_ref().to_string(),
78 })
79}
80
81fn bad_request_handler_get_record(err: poem::Error) -> GetRecordResponse {
82 GetRecordResponse::BadRequest(Json(XrpcErrorResponseObject {
83 error: "InvalidRequest".to_string(),
84 message: format!("Bad request, here's some info that maybe should not be exposed: {err}"),
85 }))
86}
87
88fn bad_request_handler_resolve_mini(err: poem::Error) -> ResolveMiniIDResponse {
89 ResolveMiniIDResponse::BadRequest(Json(XrpcErrorResponseObject {
90 error: "InvalidRequest".to_string(),
91 message: format!("Bad request, here's some info that maybe should not be exposed: {err}"),
92 }))
93}
94
95fn bad_request_handler_resolve_handle(err: poem::Error) -> JustDidResponse {
96 JustDidResponse::BadRequest(Json(XrpcErrorResponseObject {
97 error: "InvalidRequest".to_string(),
98 message: format!("Bad request, here's some info that maybe should not be exposed: {err}"),
99 }))
100}
101
102#[derive(Object)]
103#[oai(example = true)]
104struct FoundRecordResponseObject {
105 /// at-uri for this record
106 uri: String,
107 /// CID for this exact version of the record
108 ///
109 /// Slingshot will always return the CID, despite it not being a required
110 /// response property in the official lexicon.
111 ///
112 /// TODO: probably actually let it be optional, idk are some pds's weirdly
113 /// not returning it?
114 cid: Option<String>,
115 /// the record itself as JSON
116 value: serde_json::Value,
117}
118impl Example for FoundRecordResponseObject {
119 fn example() -> Self {
120 Self {
121 uri: example_uri(),
122 cid: Some("bafyreialv3mzvvxaoyrfrwoer3xmabbmdchvrbyhayd7bga47qjbycy74e".to_string()),
123 value: serde_json::json!({
124 "$type": "app.bsky.feed.like",
125 "createdAt": "2025-07-29T18:02:02.327Z",
126 "subject": {
127 "cid": "bafyreia2gy6eyk5qfetgahvshpq35vtbwy6negpy3gnuulcdi723mi7vxy",
128 "uri": "at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post/3lv4lkb4vgs2k"
129 }
130 }),
131 }
132 }
133}
134
135#[derive(ApiResponse)]
136#[oai(bad_request_handler = "bad_request_handler_get_record")]
137enum GetRecordResponse {
138 /// Record found
139 #[oai(status = 200)]
140 Ok(Json<FoundRecordResponseObject>),
141 /// Bad request or no record to return
142 ///
143 /// The only error name in the repo.getRecord lexicon is `RecordNotFound`,
144 /// but the [canonical api docs](https://docs.bsky.app/docs/api/com-atproto-repo-get-record)
145 /// also list `InvalidRequest`, `ExpiredToken`, and `InvalidToken`. Of
146 /// these, slingshot will only generate `RecordNotFound` or `InvalidRequest`,
147 /// but may return any proxied error code from the upstream repo.
148 #[oai(status = 400)]
149 BadRequest(XrpcError),
150 /// Server errors
151 #[oai(status = 500)]
152 ServerError(XrpcError),
153}
154
155#[derive(Object)]
156#[oai(example = true)]
157struct MiniDocResponseObject {
158 /// DID, bi-directionally verified if a handle was provided in the query.
159 did: String,
160 /// The validated handle of the account or `handle.invalid` if the handle
161 /// did not bi-directionally match the DID document.
162 handle: String,
163 /// The identity's PDS URL
164 pds: String,
165 /// The atproto signing key publicKeyMultibase
166 ///
167 /// Legacy key encoding not supported. the key is returned directly; `id`,
168 /// `type`, and `controller` are omitted.
169 signing_key: String,
170}
171impl Example for MiniDocResponseObject {
172 fn example() -> Self {
173 Self {
174 did: example_did(),
175 handle: example_handle(),
176 pds: example_pds(),
177 signing_key: example_signing_key(),
178 }
179 }
180}
181
182#[derive(ApiResponse)]
183#[oai(bad_request_handler = "bad_request_handler_resolve_mini")]
184enum ResolveMiniIDResponse {
185 /// Identity resolved
186 #[oai(status = 200)]
187 Ok(Json<MiniDocResponseObject>),
188 /// Bad request or identity not resolved
189 #[oai(status = 400)]
190 BadRequest(XrpcError),
191}
192
193#[derive(Object)]
194#[oai(example = true)]
195struct FoundDidResponseObject {
196 /// the DID, bi-directionally verified if using Slingshot
197 did: String,
198}
199impl Example for FoundDidResponseObject {
200 fn example() -> Self {
201 Self { did: example_did() }
202 }
203}
204
205#[derive(ApiResponse)]
206#[oai(bad_request_handler = "bad_request_handler_resolve_handle")]
207enum JustDidResponse {
208 /// Resolution succeeded
209 #[oai(status = 200)]
210 Ok(Json<FoundDidResponseObject>),
211 /// Bad request, failed to resolve, or failed to verify
212 ///
213 /// `error` will be one of `InvalidRequest`, `HandleNotFound`.
214 #[oai(status = 400)]
215 BadRequest(XrpcError),
216 /// Something went wrong trying to complete the request
217 #[oai(status = 500)]
218 ServerError(XrpcError),
219}
220
221struct Xrpc {
222 cache: HybridCache<String, CachedRecord>,
223 identity: Identity,
224 repo: Arc<Repo>,
225}
226
227#[derive(Tags)]
228enum ApiTags {
229 /// Core ATProtocol-compatible APIs.
230 ///
231 /// > [!tip]
232 /// > Upstream documentation is available at
233 /// > https://docs.bsky.app/docs/category/http-reference
234 ///
235 /// These queries are usually executed directly against the PDS containing
236 /// the data being requested. Slingshot offers a caching view of the same
237 /// contents with better expected performance and reliability.
238 #[oai(rename = "com.atproto.* queries")]
239 ComAtproto,
240 /// Additional and improved APIs.
241 ///
242 /// These APIs offer small tweaks to the core ATProtocol APIs, with more
243 /// more convenient [request parameters](#tag/slingshot-specific-queries/GET/xrpc/com.bad-example.repo.getUriRecord)
244 /// or [response formats](#tag/slingshot-specific-queries/GET/xrpc/com.bad-example.identity.resolveMiniDoc).
245 ///
246 /// > [!important]
247 /// > At the moment, these are namespaced under the `com.bad-example.*` NSID
248 /// > prefix, but as they stabilize they may be migrated to an org namespace
249 /// > like `blue.microcosm.*`. Support for asliasing to `com.bad-example.*`
250 /// > will be maintained as long as it's in use.
251 #[oai(rename = "slingshot-specific queries")]
252 Custom,
253}
254
255#[OpenApi]
256impl Xrpc {
257 /// com.atproto.repo.getRecord
258 ///
259 /// Get a single record from a repository. Does not require auth.
260 ///
261 /// > [!tip]
262 /// > See also the [canonical `com.atproto` XRPC documentation](https://docs.bsky.app/docs/api/com-atproto-repo-get-record)
263 /// > that this endpoint aims to be compatible with.
264 #[oai(
265 path = "/com.atproto.repo.getRecord",
266 method = "get",
267 tag = "ApiTags::ComAtproto"
268 )]
269 async fn get_record(
270 &self,
271 /// The DID or handle of the repo
272 #[oai(example = "example_did")]
273 Query(repo): Query<String>,
274 /// The NSID of the record collection
275 #[oai(example = "example_collection")]
276 Query(collection): Query<String>,
277 /// The Record key
278 #[oai(example = "example_rkey")]
279 Query(rkey): Query<String>,
280 /// Optional: the CID of the version of the record.
281 ///
282 /// If not specified, then return the most recent version.
283 ///
284 /// If a stale `CID` is specified and a newer version of the record
285 /// exists, Slingshot returns a `NotFound` error. That is: Slingshot
286 /// only retains the most recent version of a record.
287 Query(cid): Query<Option<String>>,
288 ) -> GetRecordResponse {
289 self.get_record_impl(repo, collection, rkey, cid).await
290 }
291
292 /// blue.microcosm.repo.getRecordByUri
293 ///
294 /// alias of `com.bad-example.repo.getUriRecord` with intention to stabilize under this name
295 #[oai(
296 path = "/blue.microcosm.repo.getRecordByUri",
297 method = "get",
298 tag = "ApiTags::Custom"
299 )]
300 async fn get_record_by_uri(
301 &self,
302 /// The at-uri of the record
303 ///
304 /// The identifier can be a DID or an atproto handle, and the collection
305 /// and rkey segments must be present.
306 #[oai(example = "example_uri")]
307 Query(at_uri): Query<String>,
308 /// Optional: the CID of the version of the record.
309 ///
310 /// If not specified, then return the most recent version.
311 ///
312 /// > [!tip]
313 /// > If specified and a newer version of the record exists, returns 404 not
314 /// > found. That is: slingshot only retains the most recent version of a
315 /// > record.
316 Query(cid): Query<Option<String>>,
317 ) -> GetRecordResponse {
318 self.get_uri_record(Query(at_uri), Query(cid)).await
319 }
320
321 /// com.bad-example.repo.getUriRecord
322 ///
323 /// Ergonomic complement to [`com.atproto.repo.getRecord`](https://docs.bsky.app/docs/api/com-atproto-repo-get-record)
324 /// which accepts an `at-uri` instead of individual repo/collection/rkey params
325 #[oai(
326 path = "/com.bad-example.repo.getUriRecord",
327 method = "get",
328 tag = "ApiTags::Custom"
329 )]
330 async fn get_uri_record(
331 &self,
332 /// The at-uri of the record
333 ///
334 /// The identifier can be a DID or an atproto handle, and the collection
335 /// and rkey segments must be present.
336 #[oai(example = "example_uri")]
337 Query(at_uri): Query<String>,
338 /// Optional: the CID of the version of the record.
339 ///
340 /// If not specified, then return the most recent version.
341 ///
342 /// > [!tip]
343 /// > If specified and a newer version of the record exists, returns 404 not
344 /// > found. That is: slingshot only retains the most recent version of a
345 /// > record.
346 Query(cid): Query<Option<String>>,
347 ) -> GetRecordResponse {
348 let bad_at_uri = || {
349 GetRecordResponse::BadRequest(xrpc_error(
350 "InvalidRequest",
351 "at-uri does not appear to be valid",
352 ))
353 };
354
355 let Some(normalized) = normalize_at_uri(&at_uri) else {
356 return bad_at_uri();
357 };
358
359 // TODO: move this to links
360 let Some(rest) = normalized.strip_prefix("at://") else {
361 return bad_at_uri();
362 };
363 let Some((repo, rest)) = rest.split_once('/') else {
364 return bad_at_uri();
365 };
366 let Some((collection, rest)) = rest.split_once('/') else {
367 return bad_at_uri();
368 };
369 let rkey = if let Some((rkey, _rest)) = rest.split_once('?') {
370 rkey
371 } else {
372 rest
373 };
374
375 self.get_record_impl(
376 repo.to_string(),
377 collection.to_string(),
378 rkey.to_string(),
379 cid,
380 )
381 .await
382 }
383
384 /// com.atproto.identity.resolveHandle
385 ///
386 /// Resolves an atproto [`handle`](https://atproto.com/guides/glossary#handle)
387 /// (hostname) to a [`DID`](https://atproto.com/guides/glossary#did-decentralized-id).
388 ///
389 /// > [!tip]
390 /// > Compatibility note: Slingshot will **always bi-directionally verify
391 /// > against the DID document**, which is optional according to the
392 /// > authoritative lexicon.
393 ///
394 /// > [!tip]
395 /// > See the [canonical `com.atproto` XRPC documentation](https://docs.bsky.app/docs/api/com-atproto-identity-resolve-handle)
396 /// > that this endpoint aims to be compatible with.
397 #[oai(
398 path = "/com.atproto.identity.resolveHandle",
399 method = "get",
400 tag = "ApiTags::ComAtproto"
401 )]
402 async fn resolve_handle(
403 &self,
404 /// The handle to resolve.
405 #[oai(example = "example_handle")]
406 Query(handle): Query<String>,
407 ) -> JustDidResponse {
408 let Ok(handle) = Handle::new(handle.to_lowercase()) else {
409 return JustDidResponse::BadRequest(xrpc_error("InvalidRequest", "not a valid handle"));
410 };
411
412 let Ok(alleged_did) = self.identity.handle_to_did(handle.clone()).await else {
413 return JustDidResponse::ServerError(xrpc_error("Failed", "Could not resolve handle"));
414 };
415
416 let Some(alleged_did) = alleged_did else {
417 return JustDidResponse::BadRequest(xrpc_error(
418 "HandleNotFound",
419 "Could not resolve handle to a DID",
420 ));
421 };
422
423 let Ok(partial_doc) = self.identity.did_to_partial_mini_doc(&alleged_did).await else {
424 return JustDidResponse::ServerError(xrpc_error("Failed", "Could not fetch DID doc"));
425 };
426
427 let Some(partial_doc) = partial_doc else {
428 return JustDidResponse::BadRequest(xrpc_error(
429 "HandleNotFound",
430 "Resolved handle but could not find DID doc for the DID",
431 ));
432 };
433
434 if partial_doc.unverified_handle != handle {
435 return JustDidResponse::BadRequest(xrpc_error(
436 "HandleNotFound",
437 "Resolved handle failed bi-directional validation",
438 ));
439 }
440
441 JustDidResponse::Ok(Json(FoundDidResponseObject {
442 did: alleged_did.to_string(),
443 }))
444 }
445
446 /// blue.microcosm.identity.resolveMiniDoc
447 ///
448 /// alias of `com.bad-example.identity.resolveMiniDoc` with intention to stabilize under this name
449 #[oai(
450 path = "/blue.microcosm.identity.resolveMiniDoc",
451 method = "get",
452 tag = "ApiTags::Custom"
453 )]
454 async fn resolve_mini_doc(
455 &self,
456 /// Handle or DID to resolve
457 #[oai(example = "example_handle")]
458 Query(identifier): Query<String>,
459 ) -> ResolveMiniIDResponse {
460 self.resolve_mini_id(Query(identifier)).await
461 }
462
463 /// com.bad-example.identity.resolveMiniDoc
464 ///
465 /// Like [com.atproto.identity.resolveIdentity](https://docs.bsky.app/docs/api/com-atproto-identity-resolve-identity)
466 /// but instead of the full `didDoc` it returns an atproto-relevant subset.
467 #[oai(
468 path = "/com.bad-example.identity.resolveMiniDoc",
469 method = "get",
470 tag = "ApiTags::Custom"
471 )]
472 async fn resolve_mini_id(
473 &self,
474 /// Handle or DID to resolve
475 #[oai(example = "example_handle")]
476 Query(identifier): Query<String>,
477 ) -> ResolveMiniIDResponse {
478 let invalid = |reason: &'static str| {
479 ResolveMiniIDResponse::BadRequest(xrpc_error("InvalidRequest", reason))
480 };
481
482 let mut unverified_handle = None;
483 let did = match Did::new(identifier.clone()) {
484 Ok(did) => did,
485 Err(_) => {
486 let Ok(alleged_handle) = Handle::new(identifier.to_lowercase()) else {
487 return invalid("Identifier was not a valid DID or handle");
488 };
489
490 match self.identity.handle_to_did(alleged_handle.clone()).await {
491 Ok(res) => {
492 if let Some(did) = res {
493 // we did it joe
494 unverified_handle = Some(alleged_handle);
495 did
496 } else {
497 return invalid("Could not resolve handle identifier to a DID");
498 }
499 }
500 Err(e) => {
501 log::debug!("failed to resolve handle: {e}");
502 // TODO: ServerError not BadRequest
503 return invalid("Errored while trying to resolve handle to DID");
504 }
505 }
506 }
507 };
508 let Ok(partial_doc) = self.identity.did_to_partial_mini_doc(&did).await else {
509 return invalid("Failed to get DID doc");
510 };
511 let Some(partial_doc) = partial_doc else {
512 return invalid("Failed to find DID doc");
513 };
514
515 // ok so here's where we're at:
516 // ✅ we have a DID
517 // ✅ we have a partial doc
518 // 🔶 if we have a handle, it's from the `identifier` (user-input)
519 // -> then we just need to compare to the partial doc to confirm
520 // -> else we need to resolve the DID doc's to a handle and check
521 let handle = if let Some(h) = unverified_handle {
522 if h == partial_doc.unverified_handle {
523 h.to_string()
524 } else {
525 "handle.invalid".to_string()
526 }
527 } else {
528 let Ok(handle_did) = self
529 .identity
530 .handle_to_did(partial_doc.unverified_handle.clone())
531 .await
532 else {
533 return invalid("Failed to get DID doc's handle");
534 };
535 let Some(handle_did) = handle_did else {
536 return invalid("Failed to resolve DID doc's handle");
537 };
538 if handle_did == did {
539 partial_doc.unverified_handle.to_string()
540 } else {
541 "handle.invalid".to_string()
542 }
543 };
544
545 ResolveMiniIDResponse::Ok(Json(MiniDocResponseObject {
546 did: did.to_string(),
547 handle,
548 pds: partial_doc.pds,
549 signing_key: partial_doc.signing_key,
550 }))
551 }
552
553 async fn get_record_impl(
554 &self,
555 repo: String,
556 collection: String,
557 rkey: String,
558 cid: Option<String>,
559 ) -> GetRecordResponse {
560 let did = match Did::new(repo.clone()) {
561 Ok(did) => did,
562 Err(_) => {
563 let Ok(handle) = Handle::new(repo.to_lowercase()) else {
564 return GetRecordResponse::BadRequest(xrpc_error(
565 "InvalidRequest",
566 "Repo was not a valid DID or handle",
567 ));
568 };
569 match self.identity.handle_to_did(handle).await {
570 Ok(res) => {
571 if let Some(did) = res {
572 did
573 } else {
574 return GetRecordResponse::BadRequest(xrpc_error(
575 "InvalidRequest",
576 "Could not resolve handle repo to a DID",
577 ));
578 }
579 }
580 Err(e) => {
581 log::debug!("handle resolution failed: {e}");
582 return GetRecordResponse::ServerError(xrpc_error(
583 "ResolutionFailed",
584 "Errored while trying to resolve handle to DID",
585 ));
586 }
587 }
588 }
589 };
590
591 let Ok(collection) = Nsid::new(collection) else {
592 return GetRecordResponse::BadRequest(xrpc_error(
593 "InvalidRequest",
594 "Invalid NSID for collection",
595 ));
596 };
597
598 let Ok(rkey) = RecordKey::new(rkey) else {
599 return GetRecordResponse::BadRequest(xrpc_error("InvalidRequest", "Invalid rkey"));
600 };
601
602 let cid: Option<Cid> = if let Some(cid) = cid {
603 let Ok(cid) = Cid::from_str(&cid) else {
604 return GetRecordResponse::BadRequest(xrpc_error("InvalidRequest", "Invalid CID"));
605 };
606 Some(cid)
607 } else {
608 None
609 };
610
611 let at_uri = format!("at://{}/{}/{}", &*did, &*collection, &*rkey);
612
613 metrics::counter!("slingshot_get_record").increment(1);
614 let fr = self
615 .cache
616 .get_or_fetch(&at_uri, {
617 let cid = cid.clone();
618 let repo_api = self.repo.clone();
619 || async move {
620 let t0 = Instant::now();
621 let res = repo_api.get_record(&did, &collection, &rkey, &cid).await;
622 let success = if res.is_ok() { "true" } else { "false" };
623 metrics::histogram!("slingshot_fetch_record", "success" => success)
624 .record(t0.elapsed());
625 res
626 }
627 })
628 .await;
629
630 let entry = match fr {
631 Ok(e) => e,
632 Err(e) if e.kind() == foyer::ErrorKind::External => {
633 let record_error = match e.source().map(|s| s.downcast_ref::<RecordError>()) {
634 Some(Some(e)) => e,
635 other => {
636 if other.is_none() {
637 log::error!("external error without a source. wat? {e}");
638 } else {
639 log::error!("downcast to RecordError failed...? {e}");
640 }
641 return GetRecordResponse::ServerError(xrpc_error(
642 "ServerError",
643 "sorry, something went wrong",
644 ));
645 }
646 };
647 let RecordError::UpstreamBadRequest(ErrorResponseObject {
648 ref error,
649 ref message,
650 }) = *record_error
651 else {
652 log::error!("RecordError getting cache entry, {record_error:?}");
653 return GetRecordResponse::ServerError(xrpc_error(
654 "ServerError",
655 "sorry, something went wrong",
656 ));
657 };
658
659 // all of the noise around here is so that we can ultimately reach this:
660 // upstream BadRequest extracted from the foyer result which we can proxy back
661 return GetRecordResponse::BadRequest(xrpc_error(
662 error,
663 format!("Upstream bad request: {message}"),
664 ));
665 }
666 Err(e) => {
667 log::error!("error (foyer) getting cache entry, {e:?}");
668 return GetRecordResponse::ServerError(xrpc_error(
669 "ServerError",
670 "sorry, something went wrong",
671 ));
672 }
673 };
674
675 match *entry {
676 CachedRecord::Found(ref raw) => {
677 let (found_cid, raw_value) = raw.into();
678 if cid.clone().map(|c| c != found_cid).unwrap_or(false) {
679 return GetRecordResponse::BadRequest(Json(XrpcErrorResponseObject {
680 error: "RecordNotFound".to_string(),
681 message: "A record was found but its CID did not match that requested"
682 .to_string(),
683 }));
684 }
685 // TODO: thank u stellz: https://gist.github.com/stella3d/51e679e55b264adff89d00a1e58d0272
686 let value =
687 serde_json::from_str(raw_value.get()).expect("RawValue to be valid json");
688 GetRecordResponse::Ok(Json(FoundRecordResponseObject {
689 uri: at_uri,
690 cid: Some(found_cid.as_ref().to_string()),
691 value,
692 }))
693 }
694 CachedRecord::Deleted => GetRecordResponse::BadRequest(Json(XrpcErrorResponseObject {
695 error: "RecordNotFound".to_string(),
696 message: "This record was deleted".to_string(),
697 })),
698 }
699 }
700
701 // TODO
702 // #[oai(path = "/com.atproto.identity.resolveDid", method = "get")]
703 // but these are both not specified to do bidirectional validation, which is what we want to offer
704 // com.atproto.identity.resolveIdentity seems right, but requires returning the full did-doc
705 // would be nice if there were two queries:
706 // did -> verified handle + pds url
707 // handle -> verified did + pds url
708 //
709 // we could do horrible things and implement resolveIdentity with only a stripped-down fake did doc
710 // but this will *definitely* cause problems probably
711 //
712 // resolveMiniDoc gets most of this well enough.
713}
714
715#[derive(Debug, Clone, Serialize)]
716#[serde(rename_all = "camelCase")]
717struct AppViewService {
718 id: String,
719 r#type: String,
720 service_endpoint: String,
721}
722#[derive(Debug, Clone, Serialize)]
723struct AppViewDoc {
724 id: String,
725 service: [AppViewService; 1],
726}
727/// Serve a did document for did:web for this to be an xrpc appview
728///
729/// No slingshot endpoints currently require auth, so it's not necessary to do
730/// service proxying, however clients may wish to:
731///
732/// - PDS proxying offers a level of client IP anonymity from slingshot
733/// - slingshot *may* implement more generous per-user rate-limits for proxied requests in the future
734fn get_did_doc(domain: &str) -> impl Endpoint + use<> {
735 let doc = poem::web::Json(AppViewDoc {
736 id: format!("did:web:{domain}"),
737 service: [AppViewService {
738 id: "#slingshot".to_string(),
739 r#type: "SlingshotRecordProxy".to_string(),
740 service_endpoint: format!("https://{domain}"),
741 }],
742 });
743 make_sync(move |_| doc.clone())
744}
745
746#[allow(clippy::too_many_arguments)]
747pub async fn serve(
748 cache: HybridCache<String, CachedRecord>,
749 identity: Identity,
750 repo: Repo,
751 acme_domain: Option<String>,
752 acme_contact: Option<String>,
753 acme_cache_path: Option<PathBuf>,
754 acme_ipv6: bool,
755 shutdown: CancellationToken,
756 bind: std::net::SocketAddr,
757) -> Result<(), ServerError> {
758 let repo = Arc::new(repo);
759 let api_service = OpenApiService::new(
760 Xrpc {
761 cache,
762 identity,
763 repo,
764 },
765 "Slingshot",
766 env!("CARGO_PKG_VERSION"),
767 )
768 .server(if let Some(ref h) = acme_domain {
769 format!("https://{h}")
770 } else {
771 format!("http://{bind}") // yeah should probably fix this for reverse-proxy scenarios but it's ok for dev for now
772 })
773 .url_prefix("/xrpc")
774 .contact(
775 ContactObject::new()
776 .name("@microcosm.blue")
777 .url("https://bsky.app/profile/microcosm.blue"),
778 )
779 .description(include_str!("../api-description.md"))
780 .external_document(ExternalDocumentObject::new(
781 "https://microcosm.blue/slingshot",
782 ));
783
784 let mut app = Route::new()
785 .at("/", StaticFileEndpoint::new("./static/index.html"))
786 .nest("/openapi", api_service.spec_endpoint())
787 .nest("/xrpc/", api_service);
788
789 if let Some(domain) = acme_domain {
790 rustls::crypto::aws_lc_rs::default_provider()
791 .install_default()
792 .expect("alskfjalksdjf");
793
794 app = app.at("/.well-known/did.json", get_did_doc(&domain));
795
796 let mut auto_cert = AutoCert::builder()
797 .directory_url(LETS_ENCRYPT_PRODUCTION)
798 .domain(&domain);
799 if let Some(contact) = acme_contact {
800 auto_cert = auto_cert.contact(contact);
801 }
802 if let Some(cache_path) = acme_cache_path {
803 auto_cert = auto_cert.cache_path(cache_path);
804 }
805 let auto_cert = auto_cert.build().map_err(ServerError::AcmeBuildError)?;
806
807 run(
808 TcpListener::bind(if acme_ipv6 { "[::]:443" } else { "0.0.0.0:443" }).acme(auto_cert),
809 app,
810 shutdown,
811 )
812 .await
813 } else {
814 run(TcpListener::bind(bind), app, shutdown).await
815 }
816}
817
818async fn run<L>(listener: L, app: Route, shutdown: CancellationToken) -> Result<(), ServerError>
819where
820 L: Listener + 'static,
821{
822 let app = app
823 .with(
824 Cors::new()
825 .allow_origin_regex("*")
826 .allow_methods([Method::GET])
827 .allow_credentials(false),
828 )
829 .with(CatchPanic::new())
830 .around(request_counter)
831 .with(Tracing);
832
833 Server::new(listener)
834 .name("slingshot")
835 .run_with_graceful_shutdown(app, shutdown.cancelled(), None)
836 .await
837 .map_err(ServerError::ServerExited)
838 .inspect(|()| log::info!("server ended. goodbye."))
839}
840
841async fn request_counter<E: Endpoint>(next: E, req: poem::Request) -> poem::Result<poem::Response> {
842 let t0 = std::time::Instant::now();
843 let method = req.method().to_string();
844 let path = req.uri().path().to_string();
845 let res = next.call(req).await?.into_response();
846 metrics::histogram!(
847 "server_request",
848 "endpoint" => format!("{method} {path}"),
849 "status" => res.status().to_string(),
850 )
851 .record(t0.elapsed());
852 Ok(res)
853}