+170
-46
crates/jacquard-identity/src/lexicon_resolver.rs
+170
-46
crates/jacquard-identity/src/lexicon_resolver.rs
···
130
130
)
131
131
}
132
132
133
+
pub fn resolution_failed(nsid: impl Into<SmolStr>, message: impl Into<SmolStr>) -> Self {
134
+
Self::new(
135
+
LexiconResolutionErrorKind::ResolutionFailed {
136
+
nsid: nsid.into(),
137
+
message: message.into(),
138
+
},
139
+
None,
140
+
)
141
+
}
142
+
133
143
pub fn invalid_collection() -> Self {
134
144
Self::new(LexiconResolutionErrorKind::InvalidCollection, None)
135
145
}
···
180
190
#[error("failed to parse lexicon schema for {nsid}")]
181
191
#[diagnostic(code(jacquard::lexicon::parse_failed))]
182
192
ParseFailed { nsid: SmolStr },
193
+
194
+
#[error("failed to parse lexicon schema for {nsid}")]
195
+
#[diagnostic(code(jacquard::lexicon::resolution_failed))]
196
+
ResolutionFailed { nsid: SmolStr, message: SmolStr },
183
197
184
198
#[error("invalid collection NSID")]
185
199
#[diagnostic(code(jacquard::lexicon::invalid_collection))]
···
248
262
&self,
249
263
nsid: &Nsid<'_>,
250
264
) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> {
251
-
if let Ok(mut url) = Url::parse("https://public.api.bsky.app") {
252
-
url.set_path("/xrpc/com.atproto.lexicon.resolveLexicon");
253
-
if let Ok(qs) =
254
-
serde_html_form::to_string(&ResolveLexicon::new().nsid(nsid.clone()).build())
255
-
{
256
-
url.set_query(Some(&qs));
257
-
} else {
258
-
return Err(LexiconResolutionError::invalid_collection());
259
-
}
260
-
if let Ok((buf, status)) = self.get_json_bytes(url).await {
261
-
if status.is_success() {
262
-
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&buf) {
263
-
if let Some(obj) = val.as_object() {
264
-
if let Some(schema) = obj.get("schema") {
265
-
if let Ok(schema) = from_json_value::<LexiconDoc>(schema.clone()) {
266
-
let uri =
267
-
obj.get("uri").expect("uri should be present").to_string();
268
-
let cid =
269
-
obj.get("cid").expect("cid should be present").to_string();
270
-
let uri = AtUri::new_owned(uri).map_err(|e| {
271
-
LexiconResolutionError::parse_failed("uri", e)
272
-
})?;
273
-
let cid = Cid::str(&cid).into_static();
274
-
let repo = Did::raw(uri.authority().as_str()).into_static();
275
-
return Ok(ResolvedLexiconSchema {
276
-
repo,
277
-
cid,
278
-
nsid: nsid.clone().into_static(),
279
-
doc: schema.into_static(),
280
-
});
281
-
}
282
-
}
283
-
}
284
-
}
285
-
}
286
-
}
265
+
#[cfg(feature = "tracing")]
266
+
tracing::debug!("resolving lexicon via XRPC: {}", nsid);
267
+
268
+
let mut url =
269
+
Url::parse("https://public.api.bsky.app").expect("hardcoded URL should be valid");
270
+
271
+
url.set_path("/xrpc/com.atproto.lexicon.resolveLexicon");
272
+
273
+
let qs = serde_html_form::to_string(&ResolveLexicon::new().nsid(nsid.clone()).build())
274
+
.map_err(|e| LexiconResolutionError::fetch_failed(nsid.as_str(), e))?;
275
+
url.set_query(Some(&qs));
276
+
277
+
#[cfg(feature = "tracing")]
278
+
tracing::debug!("fetching from URL: {}", url);
279
+
280
+
let (buf, status) = self
281
+
.get_json_bytes(url)
282
+
.await
283
+
.map_err(|e| LexiconResolutionError::fetch_failed(nsid.as_str(), e))?;
284
+
285
+
#[cfg(feature = "tracing")]
286
+
tracing::debug!("got response with status: {}", status);
287
+
288
+
if !status.is_success() {
289
+
return Err(LexiconResolutionError::resolution_failed(
290
+
nsid.as_str(),
291
+
format!("HTTP {}", status.as_u16()),
292
+
));
287
293
}
288
294
289
-
Err(LexiconResolutionError::invalid_collection())
295
+
let val = serde_json::from_slice::<serde_json::Value>(&buf)
296
+
.map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?;
297
+
298
+
#[cfg(feature = "tracing")]
299
+
tracing::debug!("parsed JSON response");
300
+
301
+
let obj = val.as_object().ok_or_else(|| {
302
+
LexiconResolutionError::resolution_failed(nsid.as_str(), "response not an object")
303
+
})?;
304
+
305
+
let schema_val = obj.get("schema").ok_or_else(|| {
306
+
#[cfg(feature = "tracing")]
307
+
tracing::error!(
308
+
"response missing 'schema' field, got keys: {:?}",
309
+
obj.keys().collect::<Vec<_>>()
310
+
);
311
+
312
+
LexiconResolutionError::resolution_failed(nsid.as_str(), "missing 'schema' field")
313
+
})?;
314
+
315
+
#[cfg(feature = "tracing")]
316
+
tracing::debug!("found schema field in response");
317
+
318
+
let schema = from_json_value::<LexiconDoc>(schema_val.clone())
319
+
.map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?;
320
+
321
+
let uri_str = obj.get("uri").and_then(|v| v.as_str()).ok_or_else(|| {
322
+
LexiconResolutionError::resolution_failed(
323
+
nsid.as_str(),
324
+
"missing or invalid 'uri' field",
325
+
)
326
+
})?;
327
+
328
+
let cid_str = obj.get("cid").and_then(|v| v.as_str()).ok_or_else(|| {
329
+
LexiconResolutionError::resolution_failed(
330
+
nsid.as_str(),
331
+
"missing or invalid 'cid' field",
332
+
)
333
+
})?;
334
+
335
+
let uri = AtUri::new_owned(uri_str)
336
+
.map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?;
337
+
338
+
let cid = Cid::str(cid_str).into_static();
339
+
let repo = Did::raw(uri.authority().as_str()).into_static();
340
+
341
+
#[cfg(feature = "tracing")]
342
+
tracing::debug!("successfully resolved lexicon schema for {}", nsid);
343
+
344
+
Ok(ResolvedLexiconSchema {
345
+
repo,
346
+
cid,
347
+
nsid: nsid.clone().into_static(),
348
+
doc: schema.into_static(),
349
+
})
290
350
}
291
351
}
292
352
···
334
394
impl LexiconAuthorityResolver for crate::JacquardResolver {
335
395
async fn resolve_lexicon_authority(
336
396
&self,
337
-
_nsid: &Nsid<'_>,
397
+
nsid: &Nsid<'_>,
338
398
) -> std::result::Result<Did<'static>, LexiconResolutionError> {
339
-
Err(LexiconResolutionError::dns_not_configured())
399
+
// Use DNS-over-HTTPS fallback for WASM/non-DNS builds
400
+
self.resolve_lexicon_authority_doh(nsid).await
401
+
}
402
+
}
403
+
404
+
impl crate::JacquardResolver {
405
+
/// Resolve lexicon authority via DNS-over-HTTPS (for WASM compatibility)
406
+
#[allow(dead_code)]
407
+
async fn resolve_lexicon_authority_doh(
408
+
&self,
409
+
nsid: &Nsid<'_>,
410
+
) -> std::result::Result<Did<'static>, LexiconResolutionError> {
411
+
// Try cache first
412
+
#[cfg(feature = "cache")]
413
+
if let Some(caches) = &self.caches {
414
+
let authority = jacquard_common::smol_str::SmolStr::from(nsid.domain_authority());
415
+
if let Some(did) = crate::cache_impl::get(&caches.authority_to_did, &authority) {
416
+
return Ok(did);
417
+
}
418
+
}
419
+
420
+
let authority = nsid.domain_authority();
421
+
let reversed_authority = authority.split('.').rev().collect::<Vec<_>>().join(".");
422
+
let fqdn = format!("_lexicon.{}.", reversed_authority);
423
+
424
+
#[cfg(feature = "tracing")]
425
+
tracing::trace!("resolving lexicon authority via DoH: {}", fqdn);
426
+
427
+
let response = self
428
+
.query_dns_doh(&fqdn, "TXT")
429
+
.await
430
+
.map_err(|e| LexiconResolutionError::dns_lookup_failed(authority, e))?;
431
+
432
+
// Parse DoH JSON response
433
+
let answers = response
434
+
.get("Answer")
435
+
.and_then(|a| a.as_array())
436
+
.ok_or_else(|| LexiconResolutionError::no_did_found(authority))?;
437
+
438
+
for answer in answers {
439
+
if let Some(data) = answer.get("data").and_then(|d| d.as_str()) {
440
+
// TXT records are quoted in DNS responses, strip quotes
441
+
let txt_data = data.trim_matches('"');
442
+
443
+
if let Some(did_str) = txt_data.strip_prefix("did=") {
444
+
let result = Did::new_owned(did_str)
445
+
.map(|d| d.into_static())
446
+
.map_err(|_| LexiconResolutionError::invalid_did(authority, did_str));
447
+
448
+
// Cache on success
449
+
if let Ok(ref did) = result {
450
+
#[cfg(feature = "cache")]
451
+
if let Some(caches) = &self.caches {
452
+
let authority_key = jacquard_common::smol_str::SmolStr::from(authority);
453
+
crate::cache_impl::insert(
454
+
&caches.authority_to_did,
455
+
authority_key,
456
+
did.clone(),
457
+
);
458
+
}
459
+
}
460
+
461
+
return result;
462
+
}
463
+
}
464
+
}
465
+
466
+
Err(LexiconResolutionError::no_did_found(authority))
340
467
}
341
468
}
342
469
···
358
485
}
359
486
360
487
// Perform resolution
361
-
#[cfg(feature = "dns")]
488
+
//#[cfg(feature = "dns")]
362
489
let result = async {
363
490
// 1. Resolve authority DID via DNS
364
491
let authority_did = self.resolve_lexicon_authority(nsid).await?;
365
492
366
493
#[cfg(feature = "tracing")]
367
-
tracing::debug!(
494
+
tracing::trace!(
368
495
"resolved lexicon authority {} -> {}",
369
496
nsid.domain_authority(),
370
497
authority_did
···
378
505
.ok_or_else(|| IdentityError::missing_pds_endpoint())?;
379
506
380
507
#[cfg(feature = "tracing")]
381
-
tracing::debug!("fetching lexicon {} from PDS {}", nsid, pds);
508
+
tracing::trace!("fetching lexicon {} from PDS {}", nsid, pds);
382
509
383
510
// 3. Fetch lexicon record via XRPC getRecord
384
511
let collection = Nsid::new("com.atproto.lexicon.schema")
···
408
535
.map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?;
409
536
410
537
#[cfg(feature = "tracing")]
411
-
tracing::debug!("successfully parsed lexicon schema {}", nsid);
538
+
tracing::trace!("successfully parsed lexicon schema {}", nsid);
412
539
413
540
let cid = output
414
541
.cid
···
423
550
})
424
551
}
425
552
.await;
426
-
427
-
#[cfg(not(feature = "dns"))]
428
-
let result = self.resolve_lexicon_xrpc(nsid).await;
429
553
430
554
// Handle result
431
555
match result {
+66
-9
crates/jacquard-identity/src/lib.rs
+66
-9
crates/jacquard-identity/src/lib.rs
···
477
477
Ok(out)
478
478
}
479
479
480
+
/// Query DNS via DNS-over-HTTPS using Cloudflare
481
+
pub async fn query_dns_doh(
482
+
&self,
483
+
name: &str,
484
+
record_type: &str,
485
+
) -> resolver::Result<serde_json::Value> {
486
+
#[cfg(feature = "tracing")]
487
+
tracing::trace!("querying DNS via DoH: {} ({})", name, record_type);
488
+
489
+
let mut url = Url::parse("https://cloudflare-dns.com/dns-query")
490
+
.expect("hardcoded URL should be valid");
491
+
492
+
url.query_pairs_mut()
493
+
.append_pair("name", name)
494
+
.append_pair("type", record_type);
495
+
496
+
let response = self
497
+
.http
498
+
.get(url)
499
+
.header("Accept", "application/dns-json")
500
+
.send()
501
+
.await?;
502
+
503
+
let status = response.status();
504
+
if !status.is_success() {
505
+
return Err(IdentityError::http_status(status));
506
+
}
507
+
508
+
let json: serde_json::Value = response.json().await?;
509
+
Ok(json)
510
+
}
511
+
512
+
#[cfg(not(feature = "dns"))]
513
+
async fn dns_txt(&self, name: &str) -> resolver::Result<Vec<String>> {
514
+
let fqdn = format!("_atproto.{name}.");
515
+
let response = self
516
+
.query_dns_doh(&fqdn, "TXT")
517
+
.await
518
+
.map_err(|e| IdentityError::dns(e))?;
519
+
520
+
// Parse DoH JSON response
521
+
let answers = response
522
+
.get("Answer")
523
+
.and_then(|a| a.as_array())
524
+
.ok_or_else(|| {
525
+
IdentityError::invalid_well_known().with_context(format!(
526
+
"couldn't parse cloudflare DoH answers looking for {name}"
527
+
))
528
+
})?;
529
+
530
+
let mut results: Vec<String> = Vec::new();
531
+
for answer in answers {
532
+
if let Some(data) = answer.get("data").and_then(|d| d.as_str()) {
533
+
// TXT records are quoted in DNS responses, strip quotes
534
+
results.push(data.trim_matches('"').to_string())
535
+
}
536
+
}
537
+
Ok(results)
538
+
}
539
+
480
540
fn parse_atproto_did_body(body: &str) -> resolver::Result<Did<'static>> {
481
541
let line = body
482
542
.lines()
···
592
652
'outer: for step in &self.opts.handle_order {
593
653
match step {
594
654
HandleStep::DnsTxt => {
595
-
#[cfg(feature = "dns")]
596
-
{
597
-
if let Ok(txts) = self.dns_txt(host).await {
598
-
for txt in txts {
599
-
if let Some(did_str) = txt.strip_prefix("did=") {
600
-
if let Ok(did) = Did::new(did_str) {
601
-
resolved_did = Some(did.into_static());
602
-
break 'outer;
603
-
}
655
+
if let Ok(txts) = self.dns_txt(host).await {
656
+
for txt in txts {
657
+
if let Some(did_str) = txt.strip_prefix("did=") {
658
+
if let Ok(did) = Did::new(did_str) {
659
+
resolved_did = Some(did.into_static());
660
+
break 'outer;
604
661
}
605
662
}
606
663
}
+4
-2
crates/jacquard-identity/src/resolver.rs
+4
-2
crates/jacquard-identity/src/resolver.rs
···
233
233
handle_order.push(HandleStep::PdsResolveHandle);
234
234
#[cfg(target_family = "wasm")]
235
235
handle_order.push(HandleStep::HttpsWellKnown);
236
+
#[cfg(target_family = "wasm")]
237
+
handle_order.push(HandleStep::DnsTxt);
236
238
237
239
let mut did_order = vec![];
238
240
#[cfg(not(target_family = "wasm"))]
···
558
560
Url,
559
561
560
562
/// DNS resolution error
561
-
#[cfg(all(feature = "dns", not(target_family = "wasm")))]
563
+
//#[cfg(all(feature = "dns", not(target_family = "wasm")))]
562
564
#[error("DNS resolution error")]
563
565
#[diagnostic(
564
566
code(jacquard::identity::dns),
···
667
669
}
668
670
669
671
/// Create a DNS error
670
-
#[cfg(all(feature = "dns", not(target_family = "wasm")))]
672
+
//#[cfg(all(feature = "dns", not(target_family = "wasm")))]
671
673
pub fn dns(source: impl std::error::Error + Send + Sync + 'static) -> Self {
672
674
Self::new(IdentityErrorKind::Dns, Some(Box::new(source)))
673
675
}
+5
-4
crates/jacquard-lexicon/src/validation.rs
+5
-4
crates/jacquard-lexicon/src/validation.rs
···
365
365
cache: DashMap<ValidationCacheKey, Arc<ValidationResult>>,
366
366
}
367
367
368
+
static VALIDATOR: LazyLock<SchemaValidator> = LazyLock::new(|| SchemaValidator {
369
+
registry: SchemaRegistry::from_inventory(),
370
+
cache: DashMap::new(),
371
+
});
372
+
368
373
impl SchemaValidator {
369
374
/// Get the global validator instance
370
375
pub fn global() -> &'static Self {
371
-
static VALIDATOR: LazyLock<SchemaValidator> = LazyLock::new(|| SchemaValidator {
372
-
registry: SchemaRegistry::from_inventory(),
373
-
cache: DashMap::new(),
374
-
});
375
376
&VALIDATOR
376
377
}
377
378