diff --git a/bobbin/crates/ingest/src/lib.rs b/bobbin/crates/ingest/src/lib.rs index 5ec1278c..e67a79c6 100644 --- a/bobbin/crates/ingest/src/lib.rs +++ b/bobbin/crates/ingest/src/lib.rs @@ -2985,6 +2985,95 @@ mod tests { ); } + #[tokio::test] + async fn repo_record_indexes_its_rkey() { + let (store, issue_states, pull_statuses, cov, resolver) = fresh(); + let repo: HydrantFrame = parse_frame(json!({ + "id": 1, + "type": "record", + "record": { + "live": false, + "did": "did:plc:nel", + "rev": fresh_tid().as_str(), + "collection": "sh.tangled.repo", + "rkey": "abcabcabcabcz", + "action": "create", + "record": { + "$type": "sh.tangled.repo", + "createdAt": "2026-05-01T00:00:00Z", + "knot": "oyster.cafe", + "name": "abalone" + } + } + })); + handle_frame( + repo, + &store, + &issue_states, + &pull_statuses, + &cov, + &NoopSearchSink, + &NoopRecordStore, + &resolver, + &sys_clock(), + now(), + ) + .await; + let owner = Did::new_owned("did:plc:nel").unwrap(); + assert_eq!( + resolver.lookup_by_name(&owner, "abcabcabcabcz").await, + Some(bobbin_types::ids::RepoIdent::new( + owner.clone(), + Rkey::new_owned("abcabcabcabcz").unwrap() + )), + ); + assert_eq!(resolver.lookup_by_name(&owner, "abalone").await, None); + } + + #[tokio::test] + async fn repo_record_without_a_name_indexes_its_rkey() { + let (store, issue_states, pull_statuses, cov, resolver) = fresh(); + let repo: HydrantFrame = parse_frame(json!({ + "id": 1, + "type": "record", + "record": { + "live": false, + "did": "did:plc:nel", + "rev": fresh_tid().as_str(), + "collection": "sh.tangled.repo", + "rkey": "abalone", + "action": "create", + "record": { + "$type": "sh.tangled.repo", + "createdAt": "2026-05-01T00:00:00Z", + "knot": "oyster.cafe" + } + } + })); + handle_frame( + repo, + &store, + &issue_states, + &pull_statuses, + &cov, + &NoopSearchSink, + &NoopRecordStore, + &resolver, + &sys_clock(), + now(), + ) + .await; + let owner = Did::new_owned("did:plc:nel").unwrap(); + assert_eq!( + resolver.lookup_by_name(&owner, "abalone").await, + Some(bobbin_types::ids::RepoIdent::new( + owner, + Rkey::new_owned("abalone").unwrap() + )), + "repos made before the name field are only reachable by rkey", + ); + } + #[tokio::test] async fn delete_repo_record_evicts_resolver_cache() { let (store, issue_states, pull_statuses, cov, resolver) = fresh(); @@ -2997,6 +3086,7 @@ mod tests { Some(Did::new_owned("did:plc:abalone").unwrap()), ) .await; + resolver.observe_rkey(owner.clone(), rkey.clone()).await; assert!( resolver.cached_resolution(&owner, &rkey).await.is_some(), "observe must seed the cache", @@ -3030,6 +3120,11 @@ mod tests { resolver.cached_resolution(&owner, &rkey).await.is_none(), "deleting the repo record must clear the resolver cache so future observes are not blocked by a stale Authoritative entry", ); + assert_eq!( + resolver.lookup_by_name(&owner, "abcabcabcabcz").await, + None, + "a deleted repo must stop answering on its url", + ); } #[tokio::test] diff --git a/bobbin/crates/resolver/src/lib.rs b/bobbin/crates/resolver/src/lib.rs index 3d1674a0..df14d793 100644 --- a/bobbin/crates/resolver/src/lib.rs +++ b/bobbin/crates/resolver/src/lib.rs @@ -21,7 +21,7 @@ use jacquard_common::DefaultStr; use jacquard_common::types::did::Did; use jacquard_common::types::nsid::Nsid; use jacquard_common::types::recordkey::Rkey; -use scc::HashMap as SccMap; +use scc::{HashMap as SccMap, HashSet as SccSet}; use tokio::sync::OnceCell; use tracing::warn; @@ -169,6 +169,7 @@ struct SlingshotProbe { pub struct RepoIdResolver { cache: SccMap, by_repo_did: SccMap, RepoIdent, RuntimeHasher>, + by_rkey: SccSet, in_flight: SccMap>, RuntimeHasher>, probe: Option, stats: ResolverStats, @@ -183,6 +184,7 @@ impl RepoIdResolver { Self { cache: SccMap::with_hasher(hasher.clone()), by_repo_did: SccMap::with_hasher(hasher.clone()), + by_rkey: SccSet::with_hasher(hasher.clone()), in_flight: SccMap::with_hasher(hasher), probe: Some(SlingshotProbe { client, clock }), stats: ResolverStats::default(), @@ -193,6 +195,7 @@ impl RepoIdResolver { Self { cache: SccMap::with_hasher(hasher.clone()), by_repo_did: SccMap::with_hasher(hasher.clone()), + by_rkey: SccSet::with_hasher(hasher.clone()), in_flight: SccMap::with_hasher(hasher), probe: None, stats: ResolverStats::default(), @@ -226,6 +229,16 @@ impl RepoIdResolver { .map(|e| e.get().clone()) } + pub async fn lookup_by_name(&self, owner: &Did, name: &str) -> Option { + let rkey = Rkey::new_owned(name).ok()?; + let ident = RepoIdent::new(owner.clone(), rkey); + self.by_rkey.contains_async(&ident).await.then_some(ident) + } + + pub async fn observe_rkey(&self, owner: Did, rkey: Rkey) { + let _ = self.by_rkey.insert_async(RepoIdent::new(owner, rkey)).await; + } + pub async fn observe( &self, owner: Did, @@ -233,6 +246,8 @@ impl RepoIdResolver { repo_did: Option>, ) -> Option { let ident = RepoIdent::new(owner, rkey); + self.observe_rkey(ident.owner.clone(), ident.rkey.clone()) + .await; let entry = CacheEntry::Authoritative(AuthoritativeResolution::from_repo_did(repo_did.clone())); self.cache @@ -268,6 +283,7 @@ impl RepoIdResolver { .remove_if_async(&repo_did, |existing| *existing == ident) .await; } + self.by_rkey.remove_async(&ident).await; } async fn fill_provisional(&self, key: RepoIdent, resolution: Resolution) { @@ -514,6 +530,55 @@ mod tests { ); } + #[tokio::test] + async fn lookup_by_name_finds_observed_rkey() { + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); + resolver + .observe_rkey(did("did:plc:nel"), rkey("3liuighjy2h22")) + .await; + let got = resolver + .lookup_by_name(&did("did:plc:nel"), "3liuighjy2h22") + .await; + assert_eq!( + got, + Some(RepoIdent::new(did("did:plc:nel"), rkey("3liuighjy2h22"))), + ); + } + + #[tokio::test] + async fn lookup_by_name_is_scoped_to_the_owner() { + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); + resolver + .observe_rkey(did("did:plc:nel"), rkey("3liuighjy2h22")) + .await; + let got = resolver + .lookup_by_name(&did("did:plc:olaren"), "3liuighjy2h22") + .await; + assert_eq!(got, None, "one owner's rkey must not answer for another's"); + } + + #[tokio::test] + async fn lookup_by_name_rejects_non_rkey() { + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); + let owner = did("did:plc:nel"); + resolver + .observe_rkey(owner.clone(), rkey("3liuighjy2h22")) + .await; + + assert_eq!(resolver.lookup_by_name(&owner, "my repo").await, None); + } + + #[tokio::test] + async fn forget_clears_the_rkey() { + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); + let owner = did("did:plc:nel"); + resolver + .observe_rkey(owner.clone(), rkey("3liuighjy2h22")) + .await; + resolver.forget(&owner, &rkey("3liuighjy2h22")).await; + assert_eq!(resolver.lookup_by_name(&owner, "3liuighjy2h22").await, None); + } + #[tokio::test] async fn observation_with_repo_did_resolves_mapped() { let resolver = RepoIdResolver::detached(RuntimeHasher::default()); diff --git a/bobbin/crates/xrpc/src/lib.rs b/bobbin/crates/xrpc/src/lib.rs index 6a70a2f0..338dff3c 100644 --- a/bobbin/crates/xrpc/src/lib.rs +++ b/bobbin/crates/xrpc/src/lib.rs @@ -181,6 +181,7 @@ pub fn router(state: AppState) -> Router { "/xrpc/sh.tangled.repo.getReposByRepoDids", get(get_repos_by_repo_dids), ) + .route("/xrpc/sh.tangled.repo.getRepoByName", get(get_repo_by_name)) .route("/xrpc/sh.tangled.actor.getProfile", get(get_profile)) .route("/xrpc/sh.tangled.actor.getProfiles", get(get_profiles)) .route("/xrpc/sh.tangled.repo.getIssue", get(get_issue)) @@ -607,6 +608,12 @@ struct GetRepoByRepoDidQuery { repo_did: Did, } +#[derive(Debug, Deserialize)] +struct GetRepoByNameQuery { + owner: Did, + name: String, +} + #[derive(Debug, Deserialize)] struct GetProfileQuery { actor: AtUri, @@ -1291,6 +1298,29 @@ async fn get_repo_by_repo_did( }))) } +async fn get_repo_by_name( + State(state): State, + XrpcQuery(q): XrpcQuery, +) -> Result>>, XrpcError> { + let ident = state + .resolver + .lookup_by_name(&q.owner, &q.name) + .await + .ok_or(XrpcError::NotFound)?; + let uri = AtUri::::from_parts_owned( + ident.owner.as_str(), + RepoRecord::NSID, + ident.rkey.as_str(), + ) + .expect("Did and Rkey newtypes already validated, at-uri assembly cannot fail"); + let (body, value) = fetch_from_uri::>(&state, uri).await?; + Ok(Json(Deduped(RepoGetRecordOutput { + cid: Some(body.cid.clone()), + uri: body.uri.clone(), + value, + }))) +} + async fn get_profile( State(state): State, XrpcQuery(q): XrpcQuery, diff --git a/bobbin/crates/xrpc/tests/cold_start.rs b/bobbin/crates/xrpc/tests/cold_start.rs index 30bc3bea..16c39dc7 100644 --- a/bobbin/crates/xrpc/tests/cold_start.rs +++ b/bobbin/crates/xrpc/tests/cold_start.rs @@ -90,6 +90,13 @@ fn xrpc_request(endpoint: &str, param: &str, value: &str) -> Request { .unwrap() } +fn xrpc_request2(endpoint: &str, a: (&str, &str), b: (&str, &str)) -> Request { + Request::builder() + .uri(format!("/xrpc/{endpoint}?{}={}&{}={}", a.0, a.1, b.0, b.1)) + .body(Body::empty()) + .unwrap() +} + fn xrpc_request_escaped(endpoint: &str, param: &str, value: &str) -> Request { let encoded: String = byte_serialize(value.as_bytes()).collect(); Request::builder() @@ -666,6 +673,106 @@ async fn get_repo_by_repo_did_returns_observed_record() { assert_eq!(body["value"]["repoDid"], repo_did.as_ref()); } +#[tokio::test] +async fn get_repo_by_name_returns_observed_record() { + let server = MockServer::start().await; + let owner_did = did("did:plc:scallop"); + let rk = rkey("core"); + mount_record( + &server, + &owner_did, + &nsid("sh.tangled.repo"), + &rk, + json!({ + "$type": "sh.tangled.repo", + "name": "core", + "knot": "oyster.cafe", + "createdAt": "2026-05-01T00:00:00Z", + }), + ) + .await; + + let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await; + state + .resolver + .observe_rkey(owner_did.clone(), rk.clone()) + .await; + + let app = router(state); + let resp = app + .oneshot(xrpc_request2( + "sh.tangled.repo.getRepoByName", + ("owner", owner_did.as_ref()), + // the query name is the url rkey; the record's `name` is cosmetic + ("name", rk.as_ref()), + )) + .await + .unwrap(); + let (status, body) = json_response(resp).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + body["uri"], + format!( + "at://{}/sh.tangled.repo/{}", + owner_did.as_ref(), + rk.as_ref() + ) + ); + assert_eq!(body["value"]["name"], "core"); +} + +#[tokio::test] +async fn get_repo_by_name_404_when_unobserved() { + let server = MockServer::start().await; + let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await; + let app = router(state); + let resp = app + .oneshot(xrpc_request2( + "sh.tangled.repo.getRepoByName", + ("owner", "did:plc:scallop"), + ("name", "core"), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn get_repo_by_name_404_for_another_owners_name() { + let server = MockServer::start().await; + let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await; + state + .resolver + .observe_rkey(did("did:plc:scallop"), rkey("core")) + .await; + let app = router(state); + let resp = app + .oneshot(xrpc_request2( + "sh.tangled.repo.getRepoByName", + ("owner", "did:plc:whelk"), + ("name", "core"), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn get_repo_by_name_400_on_invalid_owner() { + let server = MockServer::start().await; + let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await; + let app = router(state); + let resp = app + .oneshot(xrpc_request2( + "sh.tangled.repo.getRepoByName", + ("owner", "not-a-did"), + ("name", "core"), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + #[tokio::test] async fn get_repo_by_repo_did_404_when_unobserved() { let server = MockServer::start().await; diff --git a/lexicons/repo/getRepoByName.json b/lexicons/repo/getRepoByName.json new file mode 100644 index 00000000..44a90d3e --- /dev/null +++ b/lexicons/repo/getRepoByName.json @@ -0,0 +1,45 @@ +{ + "lexicon": 1, + "id": "sh.tangled.repo.getRepoByName", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "required": ["owner", "name"], + "properties": { + "owner": { + "type": "string", + "format": "did", + "description": "DID of the account that owns the repo." + }, + "name": { + "type": "string", + "description": "Name of the repo as it appears in its url." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri", "value"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "unknown", + "description": "Embedded sh.tangled.repo record." + } + } + } + } + } + } +}