+1
-1
.zed/settings.json
+1
-1
.zed/settings.json
+1
Cargo.lock
+1
Cargo.lock
+6
crates/jacquard-identity/Cargo.toml
+6
crates/jacquard-identity/Cargo.toml
···
23
23
bytes.workspace = true
24
24
jacquard-common = { version = "0.8", path = "../jacquard-common", features = ["reqwest-client"] }
25
25
jacquard-api = { version = "0.8", path = "../jacquard-api", default-features = false, features = ["minimal"] }
26
+
jacquard-lexicon = { version = "0.8", path = "../jacquard-lexicon", default-features = false }
26
27
percent-encoding.workspace = true
27
28
reqwest.workspace = true
28
29
url.workspace = true
···
39
40
[target.'cfg(not(target_family = "wasm"))'.dependencies]
40
41
hickory-resolver = { optional = true, version = "0.24", default-features = false, features = ["system-config", "tokio-runtime"]}
41
42
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
43
+
44
+
[[example]]
45
+
name = "resolve_lexicon"
46
+
path = "../../examples/resolve_lexicon.rs"
47
+
required-features = ["dns"]
+327
crates/jacquard-identity/src/lexicon_resolver.rs
+327
crates/jacquard-identity/src/lexicon_resolver.rs
···
1
+
//! Lexicon schema resolution via DNS and XRPC
2
+
//!
3
+
//! This module provides traits and implementations for resolving lexicon schemas at runtime:
4
+
//! 1. Resolve NSID authority to DID via DNS TXT records (`_lexicon.{reversed-authority}`)
5
+
//! 2. Fetch lexicon schema from `com.atproto.lexicon.schema` collection via XRPC
6
+
7
+
use crate::resolver::{IdentityError, IdentityResolver};
8
+
use jacquard_common::{
9
+
IntoStatic, smol_str,
10
+
types::{cid::Cid, did::Did, string::Nsid},
11
+
};
12
+
use smol_str::SmolStr;
13
+
14
+
/// Resolve lexicon authority (NSID → authoritative DID)
15
+
#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
16
+
pub trait LexiconAuthorityResolver {
17
+
/// Resolve an NSID to the authoritative DID via DNS
18
+
///
19
+
/// Uses DNS TXT records at `_lexicon.{reversed-authority}`, following the
20
+
/// AT Protocol lexicon authority spec. Authority segments are reversed
21
+
/// (e.g., `app.bsky.feed` → query `_lexicon.feed.bsky.app`).
22
+
///
23
+
/// Note: No hierarchical fallback - per the spec, only exact authority match is checked.
24
+
async fn resolve_lexicon_authority(
25
+
&self,
26
+
nsid: &Nsid,
27
+
) -> std::result::Result<Did<'static>, LexiconResolutionError>;
28
+
}
29
+
30
+
/// Resolve lexicon schemas (NSID → schema document)
31
+
#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
32
+
pub trait LexiconSchemaResolver {
33
+
/// Resolve a complete lexicon schema for an NSID
34
+
async fn resolve_lexicon_schema(
35
+
&self,
36
+
nsid: &Nsid,
37
+
) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError>;
38
+
}
39
+
40
+
/// A resolved lexicon schema with metadata
41
+
#[derive(Debug, Clone)]
42
+
pub struct ResolvedLexiconSchema<'s> {
43
+
/// The NSID of the schema
44
+
pub nsid: Nsid<'s>,
45
+
/// DID of the repository this schema was fetched from
46
+
pub repo: Did<'s>,
47
+
/// Content ID of the record (for cache invalidation)
48
+
pub cid: Cid<'s>,
49
+
/// Parsed lexicon document
50
+
pub doc: jacquard_lexicon::lexicon::LexiconDoc<'s>,
51
+
}
52
+
53
+
/// Error type for lexicon resolution operations
54
+
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
55
+
#[error("{kind}")]
56
+
pub struct LexiconResolutionError {
57
+
#[diagnostic_source]
58
+
kind: LexiconResolutionErrorKind,
59
+
#[source]
60
+
source: Option<Box<dyn std::error::Error + Send + Sync>>,
61
+
}
62
+
63
+
impl LexiconResolutionError {
64
+
pub fn new(
65
+
kind: LexiconResolutionErrorKind,
66
+
source: Option<Box<dyn std::error::Error + Send + Sync>>,
67
+
) -> Self {
68
+
Self { kind, source }
69
+
}
70
+
71
+
pub fn kind(&self) -> &LexiconResolutionErrorKind {
72
+
&self.kind
73
+
}
74
+
75
+
pub fn dns_lookup_failed(
76
+
authority: impl Into<SmolStr>,
77
+
source: impl std::error::Error + Send + Sync + 'static,
78
+
) -> Self {
79
+
Self::new(
80
+
LexiconResolutionErrorKind::DnsLookupFailed {
81
+
authority: authority.into(),
82
+
},
83
+
Some(Box::new(source)),
84
+
)
85
+
}
86
+
87
+
pub fn no_did_found(authority: impl Into<SmolStr>) -> Self {
88
+
Self::new(
89
+
LexiconResolutionErrorKind::NoDIDFound {
90
+
authority: authority.into(),
91
+
},
92
+
None,
93
+
)
94
+
}
95
+
96
+
pub fn invalid_did(authority: impl Into<SmolStr>, value: impl Into<SmolStr>) -> Self {
97
+
Self::new(
98
+
LexiconResolutionErrorKind::InvalidDID {
99
+
authority: authority.into(),
100
+
value: value.into(),
101
+
},
102
+
None,
103
+
)
104
+
}
105
+
106
+
pub fn dns_not_configured() -> Self {
107
+
Self::new(LexiconResolutionErrorKind::DnsNotConfigured, None)
108
+
}
109
+
110
+
pub fn fetch_failed(
111
+
nsid: impl Into<SmolStr>,
112
+
source: impl std::error::Error + Send + Sync + 'static,
113
+
) -> Self {
114
+
Self::new(
115
+
LexiconResolutionErrorKind::FetchFailed { nsid: nsid.into() },
116
+
Some(Box::new(source)),
117
+
)
118
+
}
119
+
120
+
pub fn parse_failed(
121
+
nsid: impl Into<SmolStr>,
122
+
source: impl std::error::Error + Send + Sync + 'static,
123
+
) -> Self {
124
+
Self::new(
125
+
LexiconResolutionErrorKind::ParseFailed { nsid: nsid.into() },
126
+
Some(Box::new(source)),
127
+
)
128
+
}
129
+
130
+
pub fn invalid_collection() -> Self {
131
+
Self::new(LexiconResolutionErrorKind::InvalidCollection, None)
132
+
}
133
+
134
+
pub fn missing_cid(nsid: impl Into<SmolStr>) -> Self {
135
+
Self::new(
136
+
LexiconResolutionErrorKind::MissingCID { nsid: nsid.into() },
137
+
None,
138
+
)
139
+
}
140
+
}
141
+
142
+
impl From<IdentityError> for LexiconResolutionError {
143
+
fn from(err: IdentityError) -> Self {
144
+
Self::new(LexiconResolutionErrorKind::IdentityResolution(err), None)
145
+
}
146
+
}
147
+
148
+
/// Error categories for lexicon resolution
149
+
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
150
+
pub enum LexiconResolutionErrorKind {
151
+
#[error("DNS lookup failed for authority {authority}")]
152
+
#[diagnostic(code(jacquard::lexicon::dns_lookup_failed))]
153
+
DnsLookupFailed { authority: SmolStr },
154
+
155
+
#[error("no DID found in DNS for authority {authority}")]
156
+
#[diagnostic(
157
+
code(jacquard::lexicon::no_did_found),
158
+
help("ensure _lexicon.{{reversed-authority}} TXT record exists with did=...")
159
+
)]
160
+
NoDIDFound { authority: SmolStr },
161
+
162
+
#[error("invalid DID in DNS for authority {authority}: {value}")]
163
+
#[diagnostic(code(jacquard::lexicon::invalid_did))]
164
+
InvalidDID { authority: SmolStr, value: SmolStr },
165
+
166
+
#[error("DNS not configured (dns feature disabled or WASM target)")]
167
+
#[diagnostic(
168
+
code(jacquard::lexicon::dns_not_configured),
169
+
help("enable the 'dns' feature or use a non-WASM target")
170
+
)]
171
+
DnsNotConfigured,
172
+
173
+
#[error("failed to fetch lexicon record for {nsid}")]
174
+
#[diagnostic(code(jacquard::lexicon::fetch_failed))]
175
+
FetchFailed { nsid: SmolStr },
176
+
177
+
#[error("failed to parse lexicon schema for {nsid}")]
178
+
#[diagnostic(code(jacquard::lexicon::parse_failed))]
179
+
ParseFailed { nsid: SmolStr },
180
+
181
+
#[error("invalid collection NSID")]
182
+
#[diagnostic(code(jacquard::lexicon::invalid_collection))]
183
+
InvalidCollection,
184
+
185
+
#[error("record missing CID for {nsid}")]
186
+
#[diagnostic(code(jacquard::lexicon::missing_cid))]
187
+
MissingCID { nsid: SmolStr },
188
+
189
+
#[error(transparent)]
190
+
#[diagnostic(code(jacquard::lexicon::identity_resolution_failed))]
191
+
IdentityResolution(#[from] crate::resolver::IdentityError),
192
+
}
193
+
194
+
// Implementation on JacquardResolver
195
+
impl crate::JacquardResolver {
196
+
/// Resolve lexicon authority via DNS
197
+
///
198
+
/// Queries `_lexicon.{reversed-authority}` for a TXT record containing `did=...`
199
+
#[cfg(all(feature = "dns", not(target_family = "wasm")))]
200
+
async fn resolve_lexicon_authority_dns(
201
+
&self,
202
+
nsid: &Nsid<'_>,
203
+
) -> std::result::Result<Did<'static>, LexiconResolutionError> {
204
+
let Some(dns) = &self.dns else {
205
+
return Err(LexiconResolutionError::dns_not_configured());
206
+
};
207
+
208
+
// Extract and reverse authority segments
209
+
let authority = nsid.domain_authority();
210
+
let reversed_authority = authority.split('.').rev().collect::<Vec<_>>().join(".");
211
+
let fqdn = format!("_lexicon.{}.", reversed_authority);
212
+
213
+
#[cfg(feature = "tracing")]
214
+
tracing::debug!("resolving lexicon authority via DNS: {}", fqdn);
215
+
216
+
let response = dns
217
+
.txt_lookup(fqdn)
218
+
.await
219
+
.map_err(|e| LexiconResolutionError::dns_lookup_failed(authority, e))?;
220
+
221
+
// Parse TXT records looking for "did=..."
222
+
for txt in response.iter() {
223
+
for data in txt.txt_data().iter() {
224
+
let text = std::str::from_utf8(data).unwrap_or("");
225
+
if let Some(did_str) = text.strip_prefix("did=") {
226
+
return Did::new_owned(did_str)
227
+
.map(|d| d.into_static())
228
+
.map_err(|_| LexiconResolutionError::invalid_did(authority, did_str));
229
+
}
230
+
}
231
+
}
232
+
233
+
Err(LexiconResolutionError::no_did_found(authority))
234
+
}
235
+
}
236
+
237
+
#[cfg(all(feature = "dns", not(target_family = "wasm")))]
238
+
impl LexiconAuthorityResolver for crate::JacquardResolver {
239
+
async fn resolve_lexicon_authority(
240
+
&self,
241
+
nsid: &Nsid<'_>,
242
+
) -> std::result::Result<Did<'static>, LexiconResolutionError> {
243
+
self.resolve_lexicon_authority_dns(nsid).await
244
+
}
245
+
}
246
+
247
+
#[cfg(not(all(feature = "dns", not(target_family = "wasm"))))]
248
+
impl LexiconAuthorityResolver for crate::JacquardResolver {
249
+
async fn resolve_lexicon_authority(
250
+
&self,
251
+
_nsid: &Nsid<'_>,
252
+
) -> std::result::Result<Did<'static>, LexiconResolutionError> {
253
+
Err(LexiconResolutionError::dns_not_configured())
254
+
}
255
+
}
256
+
257
+
impl LexiconSchemaResolver for crate::JacquardResolver {
258
+
async fn resolve_lexicon_schema(
259
+
&self,
260
+
nsid: &Nsid<'_>,
261
+
) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> {
262
+
use jacquard_api::com_atproto::repo::get_record::GetRecord;
263
+
use jacquard_common::{IntoStatic, xrpc::XrpcExt};
264
+
265
+
// 1. Resolve authority DID via DNS
266
+
let authority_did = self.resolve_lexicon_authority(nsid).await?;
267
+
268
+
#[cfg(feature = "tracing")]
269
+
tracing::debug!(
270
+
"resolved lexicon authority {} -> {}",
271
+
nsid.domain_authority(),
272
+
authority_did
273
+
);
274
+
275
+
// 2. Resolve DID document to get PDS endpoint
276
+
let did_doc_resp = self.resolve_did_doc(&authority_did).await?;
277
+
let did_doc = did_doc_resp.parse()?;
278
+
let pds = did_doc
279
+
.pds_endpoint()
280
+
.ok_or_else(|| IdentityError::missing_pds_endpoint())?;
281
+
282
+
#[cfg(feature = "tracing")]
283
+
tracing::debug!("fetching lexicon {} from PDS {}", nsid, pds);
284
+
285
+
// 3. Fetch lexicon record via XRPC getRecord
286
+
let collection = Nsid::new("com.atproto.lexicon.schema")
287
+
.map_err(|_| LexiconResolutionError::invalid_collection())?;
288
+
289
+
let request = GetRecord::new()
290
+
.repo(authority_did.clone())
291
+
.collection(collection.into_static())
292
+
.rkey(nsid.clone())
293
+
.build();
294
+
295
+
let response = self
296
+
.xrpc(pds)
297
+
.send(&request)
298
+
.await
299
+
.map_err(|e| LexiconResolutionError::fetch_failed(nsid.as_str(), e))?;
300
+
301
+
let output = response
302
+
.into_output()
303
+
.map_err(|e| LexiconResolutionError::fetch_failed(nsid.as_str(), e))?;
304
+
305
+
// 4. Parse lexicon document from value
306
+
let json_str = serde_json::to_string(&output.value)
307
+
.map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?;
308
+
309
+
let doc: jacquard_lexicon::lexicon::LexiconDoc = serde_json::from_str(&json_str)
310
+
.map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?;
311
+
312
+
#[cfg(feature = "tracing")]
313
+
tracing::debug!("successfully parsed lexicon schema {}", nsid);
314
+
315
+
let cid = output
316
+
.cid
317
+
.ok_or_else(|| LexiconResolutionError::missing_cid(nsid.as_str()))?
318
+
.into_static();
319
+
320
+
Ok(ResolvedLexiconSchema {
321
+
nsid: nsid.clone().into_static(),
322
+
repo: authority_did.into_static(),
323
+
cid,
324
+
doc: doc.into_static(),
325
+
})
326
+
}
327
+
}
+1
crates/jacquard-identity/src/lib.rs
+1
crates/jacquard-identity/src/lib.rs
+7
-1
crates/jacquard-lexgen/Cargo.toml
+7
-1
crates/jacquard-lexgen/Cargo.toml
···
23
23
name = "extract-schemas"
24
24
path = "src/bin/extract_schemas.rs"
25
25
26
+
[[example]]
27
+
name = "extract_inventory"
28
+
path = "../../examples/extract_inventory.rs"
29
+
required-features = ["codegen"]
30
+
31
+
26
32
[dependencies]
27
33
clap.workspace = true
28
34
glob = "0.3"
···
30
36
jacquard-api = { version = "0.8", path = "../jacquard-api", default-features = false, features = [ "minimal" ] }
31
37
jacquard-common = { version = "0.8", features = [ "reqwest-client" ], path = "../jacquard-common" }
32
38
jacquard-derive = { version = "0.8", path = "../jacquard-derive" }
33
-
jacquard-identity = { version = "0.8", path = "../jacquard-identity" }
39
+
jacquard-identity = { version = "0.8", path = "../jacquard-identity", features = ["dns"] }
34
40
jacquard-lexicon = { version = "0.8", path = "../jacquard-lexicon" }
35
41
kdl = "6"
36
42
miette = { workspace = true, features = ["fancy"] }
crates/jacquard-lexgen/examples/extract_inventory.rs
examples/extract_inventory.rs
crates/jacquard-lexgen/examples/extract_inventory.rs
examples/extract_inventory.rs
+5
crates/jacquard-lexgen/lexicons.kdl.example
+5
crates/jacquard-lexgen/lexicons.kdl.example
···
20
20
pattern "lexicons/**/*.json"
21
21
}
22
22
23
+
// Fetch a single lexicon by NSID - will use DNS to resolve authority
24
+
source "bsky-post" type="atproto" priority=30 {
25
+
endpoint "app.bsky.feed.post"
26
+
}
27
+
23
28
// Fetch lexicons from a Git repository
24
29
source "my-lexicons" type="git" priority=100 {
25
30
repo "https://github.com/example/my-lexicons"
+25
-2
crates/jacquard-lexgen/src/fetch/sources/atproto.rs
+25
-2
crates/jacquard-lexgen/src/fetch/sources/atproto.rs
···
5
5
use jacquard_common::xrpc::XrpcExt;
6
6
use jacquard_common::{CowStr, IntoStatic};
7
7
use jacquard_identity::JacquardResolver;
8
+
use jacquard_identity::lexicon_resolver::LexiconSchemaResolver;
8
9
use jacquard_identity::resolver::{IdentityResolver, ResolverOptions};
9
10
use jacquard_lexicon::lexicon::LexiconDoc;
10
11
use miette::{Result, miette};
···
17
18
}
18
19
19
20
impl AtProtoSource {
21
+
/// Fetch a single lexicon schema by NSID using DNS + XRPC resolution
22
+
async fn fetch_single_lexicon(
23
+
&self,
24
+
resolver: &JacquardResolver,
25
+
nsid: &Nsid<'_>,
26
+
) -> Result<HashMap<String, LexiconDoc<'_>>> {
27
+
let schema = resolver
28
+
.resolve_lexicon_schema(nsid)
29
+
.await
30
+
.map_err(|e| miette!("Failed to resolve lexicon '{}': {}", nsid, e))?;
31
+
32
+
let mut lexicons = HashMap::new();
33
+
lexicons.insert(schema.nsid.to_string(), schema.doc);
34
+
35
+
Ok(lexicons)
36
+
}
37
+
20
38
fn parse_lexicon_record(record_data: &Record<'_>) -> Option<LexiconDoc<'static>> {
21
39
// // Extract the 'value' field from the record
22
40
// let value = match record_data {
···
46
64
impl LexiconSource for AtProtoSource {
47
65
async fn fetch(&self) -> Result<HashMap<String, LexiconDoc<'_>>> {
48
66
let http = reqwest::Client::new();
49
-
let resolver = JacquardResolver::new(http, ResolverOptions::default());
67
+
let resolver = JacquardResolver::new_dns(http.clone(), ResolverOptions::default());
68
+
69
+
// Try parsing as NSID first (for single lexicon fetch)
70
+
if let Ok(nsid) = Nsid::new(&self.endpoint) {
71
+
return self.fetch_single_lexicon(&resolver, &nsid).await;
72
+
}
50
73
51
-
// Parse endpoint as at-identifier (handle or DID)
74
+
// Otherwise parse as at-identifier (handle or DID) for bulk fetch
52
75
let identifier = AtIdentifier::new(&self.endpoint)
53
76
.map_err(|e| miette!("Invalid endpoint '{}': {}", self.endpoint, e))?;
54
77
+57
examples/resolve_lexicon.rs
+57
examples/resolve_lexicon.rs
···
1
+
//! Example demonstrating lexicon schema resolution via DNS + XRPC
2
+
//!
3
+
//! Run with: cargo run --example resolve_lexicon --features dns
4
+
5
+
use jacquard_common::types::string::Nsid;
6
+
use jacquard_identity::{
7
+
JacquardResolver,
8
+
lexicon_resolver::{LexiconAuthorityResolver, LexiconSchemaResolver},
9
+
resolver::ResolverOptions,
10
+
};
11
+
12
+
#[tokio::main]
13
+
async fn main() -> miette::Result<()> {
14
+
// Set up resolver with DNS enabled
15
+
let http = reqwest::Client::new();
16
+
let opts = ResolverOptions::default();
17
+
let resolver = JacquardResolver::new_dns(http, opts);
18
+
19
+
// Test NSID - using app.bsky.feed.post as a known lexicon
20
+
let nsid =
21
+
Nsid::new("app.bsky.feed.post").map_err(|e| miette::miette!("invalid NSID: {}", e))?;
22
+
23
+
println!("Resolving lexicon for: {}", nsid);
24
+
println!();
25
+
println!("Resolving authority via DNS...");
26
+
match resolver.resolve_lexicon_authority(&nsid).await {
27
+
Ok(did) => {
28
+
println!("Authority DID: {}", did);
29
+
}
30
+
Err(e) => {
31
+
eprintln!("Failed to resolve authority: {:?}", e);
32
+
return Err(e.into());
33
+
}
34
+
}
35
+
36
+
println!();
37
+
println!("Fetching full lexicon schema...");
38
+
match resolver.resolve_lexicon_schema(&nsid).await {
39
+
Ok(schema) => {
40
+
println!("Successfully fetched schema");
41
+
println!(" NSID: {}", schema.nsid);
42
+
println!(" Repo: {}", schema.repo);
43
+
println!(" CID: {}", schema.cid);
44
+
println!(" Doc ID: {}", schema.doc.id);
45
+
println!(
46
+
"\nSchema:\n{}",
47
+
serde_json::to_string_pretty(&schema.doc).unwrap()
48
+
)
49
+
}
50
+
Err(e) => {
51
+
eprintln!("Failed to resolve schema: {:?}", e);
52
+
return Err(e.into());
53
+
}
54
+
}
55
+
56
+
Ok(())
57
+
}