+2
.gitignore
+2
.gitignore
+518
-9
Cargo.lock
+518
-9
Cargo.lock
···
124
124
]
125
125
126
126
[[package]]
127
+
name = "async-trait"
128
+
version = "0.1.89"
129
+
source = "registry+https://github.com/rust-lang/crates.io-index"
130
+
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
131
+
dependencies = [
132
+
"proc-macro2",
133
+
"quote",
134
+
"syn 2.0.106",
135
+
]
136
+
137
+
[[package]]
127
138
name = "atomic-waker"
128
139
version = "1.1.2"
129
140
source = "registry+https://github.com/rust-lang/crates.io-index"
···
164
175
version = "0.2.11"
165
176
source = "registry+https://github.com/rust-lang/crates.io-index"
166
177
checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270"
178
+
179
+
[[package]]
180
+
name = "base16ct"
181
+
version = "0.2.0"
182
+
source = "registry+https://github.com/rust-lang/crates.io-index"
183
+
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
167
184
168
185
[[package]]
169
186
name = "base64"
···
418
435
checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb"
419
436
420
437
[[package]]
438
+
name = "const-oid"
439
+
version = "0.9.6"
440
+
source = "registry+https://github.com/rust-lang/crates.io-index"
441
+
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
442
+
443
+
[[package]]
421
444
name = "core-foundation"
422
445
version = "0.9.4"
423
446
source = "registry+https://github.com/rust-lang/crates.io-index"
···
467
490
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
468
491
469
492
[[package]]
493
+
name = "crypto-bigint"
494
+
version = "0.5.5"
495
+
source = "registry+https://github.com/rust-lang/crates.io-index"
496
+
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
497
+
dependencies = [
498
+
"generic-array",
499
+
"rand_core 0.6.4",
500
+
"subtle",
501
+
"zeroize",
502
+
]
503
+
504
+
[[package]]
470
505
name = "crypto-common"
471
506
version = "0.1.6"
472
507
source = "registry+https://github.com/rust-lang/crates.io-index"
···
477
512
]
478
513
479
514
[[package]]
515
+
name = "curve25519-dalek"
516
+
version = "4.1.3"
517
+
source = "registry+https://github.com/rust-lang/crates.io-index"
518
+
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
519
+
dependencies = [
520
+
"cfg-if",
521
+
"cpufeatures",
522
+
"curve25519-dalek-derive",
523
+
"digest",
524
+
"fiat-crypto",
525
+
"rustc_version",
526
+
"subtle",
527
+
]
528
+
529
+
[[package]]
530
+
name = "curve25519-dalek-derive"
531
+
version = "0.1.1"
532
+
source = "registry+https://github.com/rust-lang/crates.io-index"
533
+
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
534
+
dependencies = [
535
+
"proc-macro2",
536
+
"quote",
537
+
"syn 2.0.106",
538
+
]
539
+
540
+
[[package]]
480
541
name = "darling"
481
542
version = "0.21.3"
482
543
source = "registry+https://github.com/rust-lang/crates.io-index"
···
538
599
]
539
600
540
601
[[package]]
602
+
name = "der"
603
+
version = "0.7.10"
604
+
source = "registry+https://github.com/rust-lang/crates.io-index"
605
+
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
606
+
dependencies = [
607
+
"const-oid",
608
+
"zeroize",
609
+
]
610
+
611
+
[[package]]
541
612
name = "deranged"
542
613
version = "0.5.4"
543
614
source = "registry+https://github.com/rust-lang/crates.io-index"
···
575
646
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
576
647
577
648
[[package]]
649
+
name = "ed25519"
650
+
version = "2.2.3"
651
+
source = "registry+https://github.com/rust-lang/crates.io-index"
652
+
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
653
+
dependencies = [
654
+
"pkcs8",
655
+
"signature",
656
+
]
657
+
658
+
[[package]]
659
+
name = "ed25519-dalek"
660
+
version = "2.2.0"
661
+
source = "registry+https://github.com/rust-lang/crates.io-index"
662
+
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
663
+
dependencies = [
664
+
"curve25519-dalek",
665
+
"ed25519",
666
+
"sha2",
667
+
"subtle",
668
+
]
669
+
670
+
[[package]]
578
671
name = "either"
579
672
version = "1.15.0"
580
673
source = "registry+https://github.com/rust-lang/crates.io-index"
581
674
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
582
675
583
676
[[package]]
677
+
name = "elliptic-curve"
678
+
version = "0.13.8"
679
+
source = "registry+https://github.com/rust-lang/crates.io-index"
680
+
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
681
+
dependencies = [
682
+
"base16ct",
683
+
"crypto-bigint",
684
+
"ff",
685
+
"generic-array",
686
+
"group",
687
+
"rand_core 0.6.4",
688
+
"sec1",
689
+
"subtle",
690
+
"zeroize",
691
+
]
692
+
693
+
[[package]]
584
694
name = "encoding_rs"
585
695
version = "0.8.35"
586
696
source = "registry+https://github.com/rust-lang/crates.io-index"
···
590
700
]
591
701
592
702
[[package]]
703
+
name = "enum-as-inner"
704
+
version = "0.6.1"
705
+
source = "registry+https://github.com/rust-lang/crates.io-index"
706
+
checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
707
+
dependencies = [
708
+
"heck 0.5.0",
709
+
"proc-macro2",
710
+
"quote",
711
+
"syn 2.0.106",
712
+
]
713
+
714
+
[[package]]
593
715
name = "enum_dispatch"
594
716
version = "0.3.13"
595
717
source = "registry+https://github.com/rust-lang/crates.io-index"
···
624
746
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
625
747
626
748
[[package]]
749
+
name = "ff"
750
+
version = "0.13.1"
751
+
source = "registry+https://github.com/rust-lang/crates.io-index"
752
+
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
753
+
dependencies = [
754
+
"rand_core 0.6.4",
755
+
"subtle",
756
+
]
757
+
758
+
[[package]]
759
+
name = "fiat-crypto"
760
+
version = "0.2.9"
761
+
source = "registry+https://github.com/rust-lang/crates.io-index"
762
+
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
763
+
764
+
[[package]]
627
765
name = "find-msvc-tools"
628
766
version = "0.1.2"
629
767
source = "registry+https://github.com/rust-lang/crates.io-index"
···
670
808
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
671
809
672
810
[[package]]
811
+
name = "futures-io"
812
+
version = "0.3.31"
813
+
source = "registry+https://github.com/rust-lang/crates.io-index"
814
+
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
815
+
816
+
[[package]]
673
817
name = "futures-sink"
674
818
version = "0.3.31"
675
819
source = "registry+https://github.com/rust-lang/crates.io-index"
···
691
835
"futures-task",
692
836
"pin-project-lite",
693
837
"pin-utils",
838
+
"slab",
694
839
]
695
840
696
841
[[package]]
···
701
846
dependencies = [
702
847
"typenum",
703
848
"version_check",
849
+
"zeroize",
704
850
]
705
851
706
852
[[package]]
···
737
883
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
738
884
739
885
[[package]]
886
+
name = "group"
887
+
version = "0.13.0"
888
+
source = "registry+https://github.com/rust-lang/crates.io-index"
889
+
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
890
+
dependencies = [
891
+
"ff",
892
+
"rand_core 0.6.4",
893
+
"subtle",
894
+
]
895
+
896
+
[[package]]
740
897
name = "h2"
741
898
version = "0.4.12"
742
899
source = "registry+https://github.com/rust-lang/crates.io-index"
···
802
959
checksum = "b07f60793ff0a4d9cef0f18e63b5357e06209987153a64648c972c1e5aff336f"
803
960
804
961
[[package]]
962
+
name = "hickory-proto"
963
+
version = "0.24.4"
964
+
source = "registry+https://github.com/rust-lang/crates.io-index"
965
+
checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248"
966
+
dependencies = [
967
+
"async-trait",
968
+
"cfg-if",
969
+
"data-encoding",
970
+
"enum-as-inner",
971
+
"futures-channel",
972
+
"futures-io",
973
+
"futures-util",
974
+
"idna",
975
+
"ipnet",
976
+
"once_cell",
977
+
"rand 0.8.5",
978
+
"thiserror 1.0.69",
979
+
"tinyvec",
980
+
"tokio",
981
+
"tracing",
982
+
"url",
983
+
]
984
+
985
+
[[package]]
986
+
name = "hickory-resolver"
987
+
version = "0.24.4"
988
+
source = "registry+https://github.com/rust-lang/crates.io-index"
989
+
checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e"
990
+
dependencies = [
991
+
"cfg-if",
992
+
"futures-util",
993
+
"hickory-proto",
994
+
"ipconfig",
995
+
"lru-cache",
996
+
"once_cell",
997
+
"parking_lot",
998
+
"rand 0.8.5",
999
+
"resolv-conf",
1000
+
"smallvec",
1001
+
"thiserror 1.0.69",
1002
+
"tokio",
1003
+
"tracing",
1004
+
]
1005
+
1006
+
[[package]]
805
1007
name = "http"
806
1008
version = "1.3.1"
807
1009
source = "registry+https://github.com/rust-lang/crates.io-index"
···
898
1100
"libc",
899
1101
"percent-encoding",
900
1102
"pin-project-lite",
901
-
"socket2",
1103
+
"socket2 0.6.0",
902
1104
"system-configuration",
903
1105
"tokio",
904
1106
"tower-service",
···
1084
1286
]
1085
1287
1086
1288
[[package]]
1289
+
name = "ipconfig"
1290
+
version = "0.3.2"
1291
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1292
+
checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f"
1293
+
dependencies = [
1294
+
"socket2 0.5.10",
1295
+
"widestring",
1296
+
"windows-sys 0.48.0",
1297
+
"winreg",
1298
+
]
1299
+
1300
+
[[package]]
1087
1301
name = "ipld-core"
1088
1302
version = "0.4.2"
1089
1303
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1141
1355
name = "jacquard"
1142
1356
version = "0.1.0"
1143
1357
dependencies = [
1358
+
"async-trait",
1359
+
"bon",
1144
1360
"bytes",
1145
1361
"clap",
1362
+
"hickory-resolver",
1146
1363
"http",
1147
1364
"jacquard-api",
1148
1365
"jacquard-common",
1149
1366
"jacquard-derive",
1150
1367
"miette",
1368
+
"percent-encoding",
1151
1369
"reqwest",
1152
1370
"serde",
1153
1371
"serde_html_form",
1154
1372
"serde_ipld_dagcbor",
1155
1373
"serde_json",
1374
+
"smol_str",
1156
1375
"thiserror 2.0.17",
1157
1376
"tokio",
1377
+
"url",
1378
+
"urlencoding",
1158
1379
]
1159
1380
1160
1381
[[package]]
···
1175
1396
version = "0.1.0"
1176
1397
dependencies = [
1177
1398
"base64",
1399
+
"bon",
1178
1400
"bytes",
1179
1401
"chrono",
1180
1402
"cid",
1403
+
"ed25519-dalek",
1181
1404
"enum_dispatch",
1182
1405
"ipld-core",
1406
+
"k256",
1183
1407
"langtag",
1184
1408
"miette",
1185
1409
"multibase",
1186
1410
"multihash",
1187
1411
"num-traits",
1188
1412
"ouroboros",
1189
-
"rand",
1413
+
"p256",
1414
+
"rand 0.9.2",
1190
1415
"regex",
1191
1416
"serde",
1192
1417
"serde_html_form",
···
1246
1471
]
1247
1472
1248
1473
[[package]]
1474
+
name = "k256"
1475
+
version = "0.13.4"
1476
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1477
+
checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
1478
+
dependencies = [
1479
+
"cfg-if",
1480
+
"elliptic-curve",
1481
+
]
1482
+
1483
+
[[package]]
1249
1484
name = "langtag"
1250
1485
version = "0.4.0"
1251
1486
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1263
1498
checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174"
1264
1499
1265
1500
[[package]]
1501
+
name = "linked-hash-map"
1502
+
version = "0.5.6"
1503
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1504
+
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
1505
+
1506
+
[[package]]
1266
1507
name = "linux-raw-sys"
1267
1508
version = "0.11.0"
1268
1509
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1275
1516
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
1276
1517
1277
1518
[[package]]
1519
+
name = "lock_api"
1520
+
version = "0.4.14"
1521
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1522
+
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
1523
+
dependencies = [
1524
+
"scopeguard",
1525
+
]
1526
+
1527
+
[[package]]
1278
1528
name = "log"
1279
1529
version = "0.4.28"
1280
1530
source = "registry+https://github.com/rust-lang/crates.io-index"
1281
1531
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
1532
+
1533
+
[[package]]
1534
+
name = "lru-cache"
1535
+
version = "0.1.2"
1536
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1537
+
checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c"
1538
+
dependencies = [
1539
+
"linked-hash-map",
1540
+
]
1282
1541
1283
1542
[[package]]
1284
1543
name = "lru-slab"
···
1453
1712
checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e"
1454
1713
1455
1714
[[package]]
1715
+
name = "p256"
1716
+
version = "0.13.2"
1717
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1718
+
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
1719
+
dependencies = [
1720
+
"elliptic-curve",
1721
+
"primeorder",
1722
+
]
1723
+
1724
+
[[package]]
1725
+
name = "parking_lot"
1726
+
version = "0.12.5"
1727
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1728
+
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
1729
+
dependencies = [
1730
+
"lock_api",
1731
+
"parking_lot_core",
1732
+
]
1733
+
1734
+
[[package]]
1735
+
name = "parking_lot_core"
1736
+
version = "0.9.12"
1737
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1738
+
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
1739
+
dependencies = [
1740
+
"cfg-if",
1741
+
"libc",
1742
+
"redox_syscall",
1743
+
"smallvec",
1744
+
"windows-link 0.2.0",
1745
+
]
1746
+
1747
+
[[package]]
1456
1748
name = "percent-encoding"
1457
1749
version = "2.3.2"
1458
1750
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1471
1763
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
1472
1764
1473
1765
[[package]]
1766
+
name = "pkcs8"
1767
+
version = "0.10.2"
1768
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1769
+
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
1770
+
dependencies = [
1771
+
"der",
1772
+
"spki",
1773
+
]
1774
+
1775
+
[[package]]
1474
1776
name = "potential_utf"
1475
1777
version = "0.1.3"
1476
1778
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1505
1807
]
1506
1808
1507
1809
[[package]]
1810
+
name = "primeorder"
1811
+
version = "0.13.6"
1812
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1813
+
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
1814
+
dependencies = [
1815
+
"elliptic-curve",
1816
+
]
1817
+
1818
+
[[package]]
1508
1819
name = "proc-macro-error"
1509
1820
version = "1.0.4"
1510
1821
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1563
1874
"quinn-udp",
1564
1875
"rustc-hash",
1565
1876
"rustls",
1566
-
"socket2",
1877
+
"socket2 0.6.0",
1567
1878
"thiserror 2.0.17",
1568
1879
"tokio",
1569
1880
"tracing",
···
1579
1890
"bytes",
1580
1891
"getrandom 0.3.3",
1581
1892
"lru-slab",
1582
-
"rand",
1893
+
"rand 0.9.2",
1583
1894
"ring",
1584
1895
"rustc-hash",
1585
1896
"rustls",
···
1600
1911
"cfg_aliases",
1601
1912
"libc",
1602
1913
"once_cell",
1603
-
"socket2",
1914
+
"socket2 0.6.0",
1604
1915
"tracing",
1605
1916
"windows-sys 0.60.2",
1606
1917
]
···
1622
1933
1623
1934
[[package]]
1624
1935
name = "rand"
1936
+
version = "0.8.5"
1937
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1938
+
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
1939
+
dependencies = [
1940
+
"libc",
1941
+
"rand_chacha 0.3.1",
1942
+
"rand_core 0.6.4",
1943
+
]
1944
+
1945
+
[[package]]
1946
+
name = "rand"
1625
1947
version = "0.9.2"
1626
1948
source = "registry+https://github.com/rust-lang/crates.io-index"
1627
1949
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
1628
1950
dependencies = [
1629
-
"rand_chacha",
1630
-
"rand_core",
1951
+
"rand_chacha 0.9.0",
1952
+
"rand_core 0.9.3",
1953
+
]
1954
+
1955
+
[[package]]
1956
+
name = "rand_chacha"
1957
+
version = "0.3.1"
1958
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1959
+
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
1960
+
dependencies = [
1961
+
"ppv-lite86",
1962
+
"rand_core 0.6.4",
1631
1963
]
1632
1964
1633
1965
[[package]]
···
1637
1969
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
1638
1970
dependencies = [
1639
1971
"ppv-lite86",
1640
-
"rand_core",
1972
+
"rand_core 0.9.3",
1973
+
]
1974
+
1975
+
[[package]]
1976
+
name = "rand_core"
1977
+
version = "0.6.4"
1978
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1979
+
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1980
+
dependencies = [
1981
+
"getrandom 0.2.16",
1641
1982
]
1642
1983
1643
1984
[[package]]
···
1656
1997
checksum = "d20581732dd76fa913c7dff1a2412b714afe3573e94d41c34719de73337cc8ab"
1657
1998
1658
1999
[[package]]
2000
+
name = "redox_syscall"
2001
+
version = "0.5.18"
2002
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2003
+
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
2004
+
dependencies = [
2005
+
"bitflags",
2006
+
]
2007
+
2008
+
[[package]]
1659
2009
name = "ref-cast"
1660
2010
version = "1.0.24"
1661
2011
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1749
2099
]
1750
2100
1751
2101
[[package]]
2102
+
name = "resolv-conf"
2103
+
version = "0.7.5"
2104
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2105
+
checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799"
2106
+
2107
+
[[package]]
1752
2108
name = "ring"
1753
2109
version = "0.17.14"
1754
2110
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1775
2131
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
1776
2132
1777
2133
[[package]]
2134
+
name = "rustc_version"
2135
+
version = "0.4.1"
2136
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2137
+
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
2138
+
dependencies = [
2139
+
"semver",
2140
+
]
2141
+
2142
+
[[package]]
1778
2143
name = "rustix"
1779
2144
version = "1.1.2"
1780
2145
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1865
2230
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1866
2231
1867
2232
[[package]]
2233
+
name = "sec1"
2234
+
version = "0.7.3"
2235
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2236
+
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
2237
+
dependencies = [
2238
+
"base16ct",
2239
+
"der",
2240
+
"generic-array",
2241
+
"subtle",
2242
+
"zeroize",
2243
+
]
2244
+
2245
+
[[package]]
2246
+
name = "semver"
2247
+
version = "1.0.27"
2248
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2249
+
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
2250
+
2251
+
[[package]]
1868
2252
name = "serde"
1869
2253
version = "1.0.228"
1870
2254
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2014
2398
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
2015
2399
2016
2400
[[package]]
2401
+
name = "signature"
2402
+
version = "2.2.0"
2403
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2404
+
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
2405
+
2406
+
[[package]]
2017
2407
name = "slab"
2018
2408
version = "0.4.11"
2019
2409
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2037
2427
2038
2428
[[package]]
2039
2429
name = "socket2"
2430
+
version = "0.5.10"
2431
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2432
+
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
2433
+
dependencies = [
2434
+
"libc",
2435
+
"windows-sys 0.52.0",
2436
+
]
2437
+
2438
+
[[package]]
2439
+
name = "socket2"
2040
2440
version = "0.6.0"
2041
2441
source = "registry+https://github.com/rust-lang/crates.io-index"
2042
2442
checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807"
2043
2443
dependencies = [
2044
2444
"libc",
2045
2445
"windows-sys 0.59.0",
2446
+
]
2447
+
2448
+
[[package]]
2449
+
name = "spki"
2450
+
version = "0.7.3"
2451
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2452
+
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
2453
+
dependencies = [
2454
+
"der",
2046
2455
]
2047
2456
2048
2457
[[package]]
···
2314
2723
"mio",
2315
2724
"pin-project-lite",
2316
2725
"slab",
2317
-
"socket2",
2726
+
"socket2 0.6.0",
2318
2727
"tokio-macros",
2319
2728
"windows-sys 0.59.0",
2320
2729
]
···
2405
2814
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
2406
2815
dependencies = [
2407
2816
"pin-project-lite",
2817
+
"tracing-attributes",
2408
2818
"tracing-core",
2409
2819
]
2410
2820
2411
2821
[[package]]
2822
+
name = "tracing-attributes"
2823
+
version = "0.1.30"
2824
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2825
+
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
2826
+
dependencies = [
2827
+
"proc-macro2",
2828
+
"quote",
2829
+
"syn 2.0.106",
2830
+
]
2831
+
2832
+
[[package]]
2412
2833
name = "tracing-core"
2413
2834
version = "0.1.34"
2414
2835
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2476
2897
"percent-encoding",
2477
2898
"serde",
2478
2899
]
2900
+
2901
+
[[package]]
2902
+
name = "urlencoding"
2903
+
version = "2.1.3"
2904
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2905
+
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
2479
2906
2480
2907
[[package]]
2481
2908
name = "utf8_iter"
···
2630
3057
]
2631
3058
2632
3059
[[package]]
3060
+
name = "widestring"
3061
+
version = "1.2.0"
3062
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3063
+
checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d"
3064
+
3065
+
[[package]]
2633
3066
name = "windows-core"
2634
3067
version = "0.62.1"
2635
3068
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2725
3158
2726
3159
[[package]]
2727
3160
name = "windows-sys"
3161
+
version = "0.48.0"
3162
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3163
+
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
3164
+
dependencies = [
3165
+
"windows-targets 0.48.5",
3166
+
]
3167
+
3168
+
[[package]]
3169
+
name = "windows-sys"
2728
3170
version = "0.52.0"
2729
3171
source = "registry+https://github.com/rust-lang/crates.io-index"
2730
3172
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
···
2752
3194
2753
3195
[[package]]
2754
3196
name = "windows-targets"
3197
+
version = "0.48.5"
3198
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3199
+
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
3200
+
dependencies = [
3201
+
"windows_aarch64_gnullvm 0.48.5",
3202
+
"windows_aarch64_msvc 0.48.5",
3203
+
"windows_i686_gnu 0.48.5",
3204
+
"windows_i686_msvc 0.48.5",
3205
+
"windows_x86_64_gnu 0.48.5",
3206
+
"windows_x86_64_gnullvm 0.48.5",
3207
+
"windows_x86_64_msvc 0.48.5",
3208
+
]
3209
+
3210
+
[[package]]
3211
+
name = "windows-targets"
2755
3212
version = "0.52.6"
2756
3213
source = "registry+https://github.com/rust-lang/crates.io-index"
2757
3214
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
···
2785
3242
2786
3243
[[package]]
2787
3244
name = "windows_aarch64_gnullvm"
3245
+
version = "0.48.5"
3246
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3247
+
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
3248
+
3249
+
[[package]]
3250
+
name = "windows_aarch64_gnullvm"
2788
3251
version = "0.52.6"
2789
3252
source = "registry+https://github.com/rust-lang/crates.io-index"
2790
3253
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
···
2794
3257
version = "0.53.0"
2795
3258
source = "registry+https://github.com/rust-lang/crates.io-index"
2796
3259
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
3260
+
3261
+
[[package]]
3262
+
name = "windows_aarch64_msvc"
3263
+
version = "0.48.5"
3264
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3265
+
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
2797
3266
2798
3267
[[package]]
2799
3268
name = "windows_aarch64_msvc"
···
2809
3278
2810
3279
[[package]]
2811
3280
name = "windows_i686_gnu"
3281
+
version = "0.48.5"
3282
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3283
+
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
3284
+
3285
+
[[package]]
3286
+
name = "windows_i686_gnu"
2812
3287
version = "0.52.6"
2813
3288
source = "registry+https://github.com/rust-lang/crates.io-index"
2814
3289
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
···
2833
3308
2834
3309
[[package]]
2835
3310
name = "windows_i686_msvc"
3311
+
version = "0.48.5"
3312
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3313
+
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
3314
+
3315
+
[[package]]
3316
+
name = "windows_i686_msvc"
2836
3317
version = "0.52.6"
2837
3318
source = "registry+https://github.com/rust-lang/crates.io-index"
2838
3319
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
···
2845
3326
2846
3327
[[package]]
2847
3328
name = "windows_x86_64_gnu"
3329
+
version = "0.48.5"
3330
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3331
+
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
3332
+
3333
+
[[package]]
3334
+
name = "windows_x86_64_gnu"
2848
3335
version = "0.52.6"
2849
3336
source = "registry+https://github.com/rust-lang/crates.io-index"
2850
3337
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
···
2854
3341
version = "0.53.0"
2855
3342
source = "registry+https://github.com/rust-lang/crates.io-index"
2856
3343
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
3344
+
3345
+
[[package]]
3346
+
name = "windows_x86_64_gnullvm"
3347
+
version = "0.48.5"
3348
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3349
+
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
2857
3350
2858
3351
[[package]]
2859
3352
name = "windows_x86_64_gnullvm"
···
2869
3362
2870
3363
[[package]]
2871
3364
name = "windows_x86_64_msvc"
3365
+
version = "0.48.5"
3366
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3367
+
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
3368
+
3369
+
[[package]]
3370
+
name = "windows_x86_64_msvc"
2872
3371
version = "0.52.6"
2873
3372
source = "registry+https://github.com/rust-lang/crates.io-index"
2874
3373
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
···
2878
3377
version = "0.53.0"
2879
3378
source = "registry+https://github.com/rust-lang/crates.io-index"
2880
3379
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
3380
+
3381
+
[[package]]
3382
+
name = "winreg"
3383
+
version = "0.50.0"
3384
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3385
+
checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
3386
+
dependencies = [
3387
+
"cfg-if",
3388
+
"windows-sys 0.48.0",
3389
+
]
2881
3390
2882
3391
[[package]]
2883
3392
name = "wit-bindgen"
+1
-1
Cargo.toml
+1
-1
Cargo.toml
-378
codegen_plan.md
-378
codegen_plan.md
···
1
-
# Lexicon Codegen Plan
2
-
3
-
## Goal
4
-
Generate idiomatic Rust types from AT Protocol lexicon schemas with minimal nesting/indirection.
5
-
6
-
## Existing Infrastructure
7
-
8
-
### Already Implemented
9
-
- **lexicon.rs**: Complete lexicon parsing types (`LexiconDoc`, `LexUserType`, `LexObject`, etc)
10
-
- **fs.rs**: Directory walking for finding `.json` lexicon files
11
-
- **schema.rs**: `find_ref_unions()` - collects union fields from a single lexicon
12
-
- **output.rs**: Partial - has string type mapping and doc comment generation
13
-
14
-
### Attribute Macros
15
-
- `#[lexicon]` - adds `extra_data` field to structs
16
-
- `#[open_union]` - adds `Unknown(Data<'s>)` variant to enums
17
-
18
-
## Design Decisions
19
-
20
-
### Module/File Structure
21
-
- NSID `app.bsky.feed.post` → `app_bsky/feed/post.rs`
22
-
- Flat module names (no `app::bsky`, just `app_bsky`)
23
-
- Parent modules: `app_bsky/feed.rs` with `pub mod post;`
24
-
25
-
### Type Naming
26
-
- **Main def**: Use last segment of NSID
27
-
- `app.bsky.feed.post#main` → `Post`
28
-
- **Other defs**: Pascal-case the def name
29
-
- `replyRef` → `ReplyRef`
30
-
- **Union variants**: Use last segment of ref NSID
31
-
- `app.bsky.embed.images` → `Images`
32
-
- Collisions resolved by module path, not type name
33
-
- **No proliferation of `Main` types** like atrium has
34
-
35
-
### Type Generation
36
-
37
-
#### Records (lexRecord)
38
-
```rust
39
-
#[lexicon]
40
-
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
41
-
#[serde(rename_all = "camelCase")]
42
-
pub struct Post<'s> {
43
-
/// Client-declared timestamp...
44
-
pub created_at: Datetime,
45
-
#[serde(skip_serializing_if = "Option::is_none")]
46
-
pub embed: Option<RecordEmbed<'s>>,
47
-
pub text: CowStr<'s>,
48
-
}
49
-
```
50
-
51
-
#### Objects (lexObject)
52
-
Same as records but without `#[lexicon]` if inline/not a top-level def.
53
-
54
-
#### Unions (lexRefUnion)
55
-
```rust
56
-
#[open_union]
57
-
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
58
-
#[serde(tag = "$type")]
59
-
pub enum RecordEmbed<'s> {
60
-
#[serde(rename = "app.bsky.embed.images")]
61
-
Images(Box<jacquard_api::app_bsky::embed::Images<'s>>),
62
-
#[serde(rename = "app.bsky.embed.video")]
63
-
Video(Box<jacquard_api::app_bsky::embed::Video<'s>>),
64
-
}
65
-
```
66
-
67
-
- Use `Box<T>` for all variants (handles circular refs)
68
-
- `#[open_union]` adds `Unknown(Data<'s>)` catch-all
69
-
70
-
#### Queries (lexXrpcQuery)
71
-
```rust
72
-
pub struct GetAuthorFeedParams<'s> {
73
-
pub actor: AtIdentifier<'s>,
74
-
pub limit: Option<i64>,
75
-
pub cursor: Option<CowStr<'s>>,
76
-
}
77
-
78
-
pub struct GetAuthorFeedOutput<'s> {
79
-
pub cursor: Option<CowStr<'s>>,
80
-
pub feed: Vec<FeedViewPost<'s>>,
81
-
}
82
-
```
83
-
84
-
- Flat params/output structs
85
-
- No nesting like `Input { params: {...} }`
86
-
87
-
#### Procedures (lexXrpcProcedure)
88
-
Same as queries but with both `Input` and `Output` structs.
89
-
90
-
### Field Handling
91
-
92
-
#### Optional Fields
93
-
- Fields not in `required: []` → `Option<T>`
94
-
- Add `#[serde(skip_serializing_if = "Option::is_none")]`
95
-
96
-
#### Lifetimes
97
-
- All types have `'a` lifetime for borrowing from input
98
-
- `#[serde(borrow)]` where needed for zero-copy
99
-
100
-
#### Type Mapping
101
-
- `LexString` with format → specific types (`Datetime`, `Did`, etc)
102
-
- `LexString` without format → `CowStr<'a>`
103
-
- `LexInteger` → `i64`
104
-
- `LexBoolean` → `bool`
105
-
- `LexBytes` → `Bytes`
106
-
- `LexCidLink` → `CidLink<'a>`
107
-
- `LexBlob` → `Blob<'a>`
108
-
- `LexRef` → resolve to actual type path
109
-
- `LexRefUnion` → generate enum
110
-
- `LexArray` → `Vec<T>`
111
-
- `LexUnknown` → `Data<'a>`
112
-
113
-
### Reference Resolution
114
-
115
-
#### Known Refs
116
-
- Check corpus for ref existence
117
-
- `#ref: "app.bsky.embed.images"` → `jacquard_api::app_bsky::embed::Images<'a>`
118
-
- Handle fragments: `#ref: "com.example.foo#bar"` → `jacquard_api::com_example::foo::Bar<'a>`
119
-
120
-
#### Unknown Refs
121
-
- **In struct fields**: use `Data<'a>` as fallback type
122
-
- **In union variants**: handled by `Unknown(Data<'a>)` variant from `#[open_union]`
123
-
- Optional: log warnings for missing refs
124
-
125
-
## Implementation Phases
126
-
127
-
### Phase 1: Corpus Loading & Registry
128
-
**Goal**: Load all lexicons into memory for ref resolution
129
-
130
-
**Tasks**:
131
-
1. Create `LexiconCorpus` struct
132
-
- `BTreeMap<SmolStr, LexiconDoc<'static>>` - NSID → doc
133
-
- Methods: `load_from_dir()`, `get()`, `resolve_ref()`
134
-
2. Load all `.json` files from lexicon directory
135
-
3. Parse into `LexiconDoc` and insert into registry
136
-
4. Handle fragments in refs (`nsid#def`)
137
-
138
-
**Output**: Corpus registry that can resolve any ref
139
-
140
-
### Phase 2: Ref Analysis & Union Collection
141
-
**Goal**: Build complete picture of what refs exist and what unions need
142
-
143
-
**Tasks**:
144
-
1. Extend `find_ref_unions()` to work across entire corpus
145
-
2. For each union, collect all refs and check existence
146
-
3. Build `UnionRegistry`:
147
-
- Union name → list of (known refs, unknown refs)
148
-
4. Detect circular refs (optional - or just Box everything)
149
-
150
-
**Output**: Complete list of unions to generate with their variants
151
-
152
-
### Phase 3: Code Generation - Core Types
153
-
**Goal**: Generate Rust code for individual types
154
-
155
-
**Tasks**:
156
-
1. Implement type generators:
157
-
- `generate_struct()` for records/objects
158
-
- `generate_enum()` for unions
159
-
- `generate_field()` for object properties
160
-
- `generate_type()` for primitives/refs
161
-
2. Handle optional fields (`required` list)
162
-
3. Add doc comments from `description`
163
-
4. Apply `#[lexicon]` / `#[open_union]` macros
164
-
5. Add serde attributes
165
-
166
-
**Output**: `TokenStream` for each type
167
-
168
-
### Phase 4: Module Organization
169
-
**Goal**: Organize generated types into module hierarchy
170
-
171
-
**Tasks**:
172
-
1. Parse NSID into components: `["app", "bsky", "feed", "post"]`
173
-
2. Determine file paths: `app_bsky/feed/post.rs`
174
-
3. Generate module files: `app_bsky/feed.rs` with `pub mod post;`
175
-
4. Generate root module: `app_bsky.rs`
176
-
5. Handle re-exports if needed
177
-
178
-
**Output**: File path → generated code mapping
179
-
180
-
### Phase 5: File Writing
181
-
**Goal**: Write generated code to filesystem
182
-
183
-
**Tasks**:
184
-
1. Format code with `prettyplease`
185
-
2. Create directory structure
186
-
3. Write module files
187
-
4. Write type files
188
-
5. Optional: run `rustfmt`
189
-
190
-
**Output**: Generated code on disk
191
-
192
-
### Phase 6: Testing & Validation
193
-
**Goal**: Ensure generated code compiles and works
194
-
195
-
**Tasks**:
196
-
1. Generate code for test lexicons
197
-
2. Compile generated code
198
-
3. Test serialization/deserialization
199
-
4. Test union variant matching
200
-
5. Test extra_data capture
201
-
202
-
## Edge Cases & Considerations
203
-
204
-
### Circular References
205
-
- **Simple approach**: Union variants always use `Box<T>` → handles all circular refs
206
-
- **Alternative**: DFS cycle detection to only Box when needed
207
-
- Track visited refs and recursion stack
208
-
- If ref appears in rec_stack → cycle detected
209
-
- Algorithm:
210
-
```rust
211
-
fn has_cycle(corpus, start_ref, visited, rec_stack) -> bool {
212
-
visited.insert(start_ref);
213
-
rec_stack.insert(start_ref);
214
-
215
-
for child_ref in collect_refs_from_def(resolve(start_ref)) {
216
-
if !visited.contains(child_ref) {
217
-
if has_cycle(corpus, child_ref, visited, rec_stack) {
218
-
return true;
219
-
}
220
-
} else if rec_stack.contains(child_ref) {
221
-
return true; // back edge = cycle
222
-
}
223
-
}
224
-
225
-
rec_stack.remove(start_ref);
226
-
false
227
-
}
228
-
```
229
-
- Only box variants that participate in cycles
230
-
- **Recommendation**: Start with simple (always Box), optimize later if needed
231
-
232
-
### Name Collisions
233
-
- Multiple types with same name in different lexicons
234
-
- Module path disambiguates: `app_bsky::feed::Post` vs `com_example::feed::Post`
235
-
236
-
### Unknown Refs
237
-
- Fallback to `Data<'s>` in struct fields
238
-
- Caught by `Unknown` variant in unions
239
-
- Warn during generation
240
-
241
-
### Inline Defs
242
-
- Nested objects/unions in same lexicon
243
-
- Generate as separate types in same file
244
-
- Keep names scoped to parent (e.g., `PostReplyRef`)
245
-
246
-
### Arrays
247
-
- `Vec<T>` for arrays
248
-
- Handle nested unions in arrays
249
-
250
-
### Tokens
251
-
- Simple marker types
252
-
- Generate as unit structs or type aliases?
253
-
254
-
## Traits for Generated Types
255
-
256
-
### Collection Trait (Records)
257
-
Records implement the existing `Collection` trait from jacquard-common:
258
-
259
-
```rust
260
-
pub struct Post<'a> {
261
-
// ... fields
262
-
}
263
-
264
-
impl Collection for Post<'p> {
265
-
const NSID: &'static str = "app.bsky.feed.post";
266
-
type Record = Post<'p>;
267
-
}
268
-
```
269
-
270
-
### XrpcRequest Trait (Queries/Procedures)
271
-
New trait for XRPC endpoints:
272
-
273
-
```rust
274
-
pub trait XrpcRequest<'x> {
275
-
/// The NSID for this XRPC method
276
-
const NSID: &'static str;
277
-
278
-
/// XRPC method (query/GET, procedure/POST)
279
-
const METHOD: XrpcMethod;
280
-
281
-
/// Input encoding (MIME type, e.g., "application/json")
282
-
/// None for queries (no body)
283
-
const INPUT_ENCODING: Option<&'static str>;
284
-
285
-
/// Output encoding (MIME type)
286
-
const OUTPUT_ENCODING: &'static str;
287
-
288
-
/// Request parameters type (query params or body)
289
-
type Params: Serialize;
290
-
291
-
/// Response output type
292
-
type Output: Deserialize<'x>;
293
-
294
-
type Err: Error;
295
-
}
296
-
297
-
pub enum XrpcMethod {
298
-
Query, // GET
299
-
Procedure, // POST
300
-
}
301
-
```
302
-
303
-
304
-
305
-
**Generated implementation:**
306
-
```rust
307
-
pub struct GetAuthorFeedParams<'a> {
308
-
pub actor: AtIdentifier<'a>,
309
-
pub limit: Option<i64>,
310
-
pub cursor: Option<CowStr<'a>>,
311
-
}
312
-
313
-
pub struct GetAuthorFeedOutput<'a> {
314
-
pub cursor: Option<CowStr<'a>>,
315
-
pub feed: Vec<FeedViewPost<'a>>,
316
-
}
317
-
318
-
impl XrpcRequest for GetAuthorFeedParams<'_> {
319
-
const NSID: &'static str = "app.bsky.feed.getAuthorFeed";
320
-
const METHOD: XrpcMethod = XrpcMethod::Query;
321
-
const INPUT_ENCODING: Option<&'static str> = None; // queries have no body
322
-
const OUTPUT_ENCODING: &'static str = "application/json";
323
-
324
-
type Params = Self;
325
-
type Output = GetAuthorFeedOutput<'static>;
326
-
type Err = GetAuthorFeedError;
327
-
}
328
-
```
329
-
330
-
**Encoding variations:**
331
-
- Most procedures: `"application/json"` for input/output
332
-
- Blob uploads: `"*/*"` or specific MIME type for input
333
-
- CAR files: `"application/vnd.ipld.car"` for repo operations
334
-
- Read from lexicon's `input.encoding` and `output.encoding` fields
335
-
336
-
**Trait benefits:**
337
-
- Allows monomorphization (static dispatch) for performance
338
-
- Also supports `dyn XrpcRequest` for dynamic dispatch if needed
339
-
- Client code can be generic over `impl XrpcRequest`
340
-
341
-
342
-
#### XRPC Errors
343
-
Lexicons contain information on the kind of errors they can return.
344
-
Trait contains an associated error type. Error enum with thiserror::Error and
345
-
miette:Diagnostic derives and appropriate content generated based on lexicon info.
346
-
347
-
### Subscriptions
348
-
WebSocket streams - defer for now. Will need separate trait with message types.
349
-
350
-
## Open Questions
351
-
352
-
1. **Validation**: Generate runtime validation (min/max length, regex, etc)?
353
-
2. **Tokens**: How to represent token types?
354
-
3. **Errors**: How to handle codegen errors (missing refs, invalid schemas)?
355
-
4. **Incremental**: Support incremental codegen (only changed lexicons)?
356
-
5. **Formatting**: Always run rustfmt or rely on prettyplease?
357
-
6. **XrpcRequest location**: Should trait live in jacquard-common or separate jacquard-xrpc crate?
358
-
7. **Import shortening**: Track imports and shorten ref paths in generated code
359
-
- Instead of `jacquard_api::app_bsky::richtext::Facet<'a>` emit `use jacquard_api::app_bsky::richtext::Facet;` and just `Facet<'a>`
360
-
- Would require threading `ImportTracker` through all generate functions or post-processing token stream
361
-
- Long paths are ugly but explicit - revisit once basic codegen is confirmed working
362
-
8. **Web-based lexicon resolution**: Fetch lexicons from the web instead of requiring local files
363
-
- Implement [lexicon publication and resolution](https://atproto.com/specs/lexicon#lexicon-publication-and-resolution) spec
364
-
- `LexiconCorpus::fetch_from_web(nsids: &[&str])` - fetch specific NSIDs
365
-
- `LexiconCorpus::fetch_from_authority(authority: &str)` - fetch all from DID/domain
366
-
- Resolution: `https://{authority}/.well-known/atproto/lexicon/{nsid}.json`
367
-
- Recursively fetch refs, handle redirects/errors
368
-
- Use `reqwest` for HTTP - still fits in jacquard-lexicon as it's corpus loading
369
-
370
-
## Success Criteria
371
-
372
-
- [ ] Generates code for all official AT Protocol lexicons
373
-
- [ ] Generated code compiles without errors
374
-
- [ ] No `Main` proliferation
375
-
- [ ] Union variants have readable names
376
-
- [ ] Unknown refs handled gracefully
377
-
- [ ] `#[lexicon]` and `#[open_union]` applied correctly
378
-
- [ ] Serialization round-trips correctly
+1
-1
crates/jacquard-api/Cargo.toml
+1
-1
crates/jacquard-api/Cargo.toml
+27
-1
crates/jacquard-common/Cargo.toml
+27
-1
crates/jacquard-common/Cargo.toml
···
9
9
categories.workspace = true
10
10
readme.workspace = true
11
11
exclude.workspace = true
12
-
license-file.workspace = true
12
+
license.workspace = true
13
13
14
14
15
15
[dependencies]
16
+
bon = "3"
16
17
base64 = "0.22.1"
17
18
bytes.workspace = true
18
19
chrono = "0.4.42"
···
34
35
smol_str.workspace = true
35
36
thiserror.workspace = true
36
37
url.workspace = true
38
+
39
+
[features]
40
+
default = []
41
+
crypto = []
42
+
crypto-ed25519 = ["crypto", "dep:ed25519-dalek"]
43
+
crypto-k256 = ["crypto", "dep:k256"]
44
+
crypto-p256 = ["crypto", "dep:p256"]
45
+
46
+
[dependencies.ed25519-dalek]
47
+
version = "2"
48
+
optional = true
49
+
default-features = false
50
+
features = ["pkcs8"]
51
+
52
+
[dependencies.k256]
53
+
version = "0.13"
54
+
optional = true
55
+
default-features = false
56
+
features = ["arithmetic"]
57
+
58
+
[dependencies.p256]
59
+
version = "0.13"
60
+
optional = true
61
+
default-features = false
62
+
features = ["arithmetic"]
+4
crates/jacquard-common/src/types.rs
+4
crates/jacquard-common/src/types.rs
···
12
12
pub mod datetime;
13
13
/// Decentralized Identifier (DID) types and validation
14
14
pub mod did;
15
+
/// DID Document types and helpers
16
+
pub mod did_doc;
17
+
/// Crypto helpers for keys (Multikey decoding, conversions)
18
+
pub mod crypto;
15
19
/// AT Protocol handle types and validation
16
20
pub mod handle;
17
21
/// AT Protocol identifier types (handle or DID)
+298
crates/jacquard-common/src/types/crypto.rs
+298
crates/jacquard-common/src/types/crypto.rs
···
1
+
//! Multikey decoding and optional conversions.
2
+
//!
3
+
//! This module provides a small `PublicKey` wrapper that can decode a
4
+
//! Multikey `publicKeyMultibase` string into raw bytes plus a codec
5
+
//! (`KeyCodec`). Feature‑gated helpers convert to popular Rust crypto
6
+
//! public‑key types (ed25519_dalek, k256, p256).
7
+
//! Example: decode an ed25519 multibase key
8
+
//! ```
9
+
//! use jacquard_common::types::crypto::{PublicKey, KeyCodec};
10
+
//! // ed25519 key: multicodec varint 0xED + 32 raw bytes, base58btc encoded
11
+
//! let mut key = [0u8; 32];
12
+
//! let s = {
13
+
//! fn enc(mut x: u64) -> Vec<u8> { let mut v=Vec::new(); while x>=0x80{v.push(((x as u8)&0x7F)|0x80); x >>= 7;} v.push(x as u8); v }
14
+
//! let mut buf = enc(0xED); buf.extend_from_slice(&key); multibase::encode(multibase::Base::Base58Btc, buf)
15
+
//! };
16
+
//! let pk = PublicKey::decode(&s).unwrap();
17
+
//! assert!(matches!(pk.codec, KeyCodec::Ed25519));
18
+
//! assert_eq!(pk.bytes.as_ref(), &key);
19
+
20
+
use crate::IntoStatic;
21
+
use std::borrow::Cow;
22
+
23
+
/// Known multicodec key codecs for Multikey public keys
24
+
///
25
+
26
+
/// ```
27
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28
+
pub enum KeyCodec {
29
+
/// Ed25519
30
+
Ed25519,
31
+
/// Secp256k1
32
+
Secp256k1,
33
+
/// P256
34
+
P256,
35
+
/// Unknown codec
36
+
Unknown(u64),
37
+
}
38
+
39
+
/// Public key decoded from a Multikey `publicKeyMultibase` string
40
+
#[derive(Debug, Clone, PartialEq, Eq)]
41
+
pub struct PublicKey<'a> {
42
+
/// Codec used to encode the public key
43
+
pub codec: KeyCodec,
44
+
/// Bytes of the public key
45
+
pub bytes: Cow<'a, [u8]>,
46
+
}
47
+
48
+
#[cfg(feature = "crypto")]
49
+
fn code_of(codec: KeyCodec) -> u64 {
50
+
match codec {
51
+
KeyCodec::Ed25519 => 0xED,
52
+
KeyCodec::Secp256k1 => 0xE7,
53
+
KeyCodec::P256 => 0x1200,
54
+
KeyCodec::Unknown(c) => c,
55
+
}
56
+
}
57
+
58
+
/// Errors from decoding or converting Multikey values
59
+
#[derive(Debug, Clone, thiserror::Error, miette::Diagnostic, PartialEq, Eq)]
60
+
pub enum CryptoError {
61
+
#[error("failed to decode multibase")]
62
+
/// Multibase decode errror
63
+
MultibaseDecode,
64
+
#[error("failed to decode multicodec varint")]
65
+
/// Multicodec decode error
66
+
MulticodecDecode,
67
+
#[error("unsupported key codec: {0}")]
68
+
/// Unsupported key codec error
69
+
UnsupportedCodec(u64),
70
+
#[error("invalid key length: expected {expected}, got {got}")]
71
+
/// Invalid key length error
72
+
InvalidLength {
73
+
/// Expected length of the key
74
+
expected: usize,
75
+
/// Actual length of the key
76
+
got: usize,
77
+
},
78
+
#[error("invalid key format")]
79
+
/// Invalid key format error
80
+
InvalidFormat,
81
+
#[error("conversion error: {0}")]
82
+
/// Conversion error
83
+
Conversion(String),
84
+
}
85
+
86
+
impl<'a> PublicKey<'a> {
87
+
/// Decode a Multikey public key from a multibase-encoded string
88
+
pub fn decode(multibase_str: &'a str) -> Result<PublicKey<'static>, CryptoError> {
89
+
let (_base, data) =
90
+
multibase::decode(multibase_str).map_err(|_| CryptoError::MultibaseDecode)?;
91
+
let (code, offset) = decode_uvarint(&data).ok_or(CryptoError::MulticodecDecode)?;
92
+
let bytes = &data[offset..];
93
+
let codec = match code {
94
+
0xED => KeyCodec::Ed25519, // ed25519-pub
95
+
0xE7 => KeyCodec::Secp256k1, // secp256k1-pub
96
+
0x1200 => KeyCodec::P256, // p256-pub
97
+
other => KeyCodec::Unknown(other),
98
+
};
99
+
// Minimal validation
100
+
match codec {
101
+
KeyCodec::Ed25519 => {
102
+
if bytes.len() != 32 {
103
+
return Err(CryptoError::InvalidLength {
104
+
expected: 32,
105
+
got: bytes.len(),
106
+
});
107
+
}
108
+
}
109
+
KeyCodec::Secp256k1 | KeyCodec::P256 => {
110
+
if !(bytes.len() == 33 || bytes.len() == 65) {
111
+
return Err(CryptoError::InvalidLength {
112
+
expected: 33,
113
+
got: bytes.len(),
114
+
});
115
+
}
116
+
// 0x02/0x03 compressed, 0x04 uncompressed
117
+
let first = *bytes.first().ok_or(CryptoError::InvalidFormat)?;
118
+
if first != 0x02 && first != 0x03 && first != 0x04 {
119
+
return Err(CryptoError::InvalidFormat);
120
+
}
121
+
}
122
+
KeyCodec::Unknown(code) => return Err(CryptoError::UnsupportedCodec(code)),
123
+
}
124
+
Ok(PublicKey {
125
+
codec,
126
+
bytes: Cow::Owned(bytes.to_vec()),
127
+
})
128
+
}
129
+
130
+
// decode_owned provided on PublicKey<'static>
131
+
132
+
/// Convert to ed25519_dalek verifying key (feature crypto-ed25519)
133
+
#[cfg(feature = "crypto-ed25519")]
134
+
pub fn to_ed25519(&self) -> Result<ed25519_dalek::VerifyingKey, CryptoError> {
135
+
if self.codec != KeyCodec::Ed25519 {
136
+
return Err(CryptoError::UnsupportedCodec(code_of(self.codec)));
137
+
}
138
+
ed25519_dalek::VerifyingKey::from_bytes(self.bytes.as_ref().try_into().map_err(|_| {
139
+
CryptoError::InvalidLength {
140
+
expected: 32,
141
+
got: self.bytes.len(),
142
+
}
143
+
})?)
144
+
.map_err(|e| CryptoError::Conversion(e.to_string()))
145
+
}
146
+
147
+
/// Convert to k256 public key (feature crypto-k256)
148
+
#[cfg(feature = "crypto-k256")]
149
+
pub fn to_k256(&self) -> Result<k256::PublicKey, CryptoError> {
150
+
if self.codec != KeyCodec::Secp256k1 {
151
+
return Err(CryptoError::UnsupportedCodec(code_of(self.codec)));
152
+
}
153
+
k256::PublicKey::from_sec1_bytes(self.bytes.as_ref())
154
+
.map_err(|e| CryptoError::Conversion(e.to_string()))
155
+
}
156
+
157
+
/// Convert to p256 public key (feature crypto-p256)
158
+
#[cfg(feature = "crypto-p256")]
159
+
pub fn to_p256(&self) -> Result<p256::PublicKey, CryptoError> {
160
+
if self.codec != KeyCodec::P256 {
161
+
return Err(CryptoError::UnsupportedCodec(code_of(self.codec)));
162
+
}
163
+
p256::PublicKey::from_sec1_bytes(self.bytes.as_ref())
164
+
.map_err(|e| CryptoError::Conversion(e.to_string()))
165
+
}
166
+
}
167
+
168
+
impl PublicKey<'static> {
169
+
/// Decode from an owned string-like value
170
+
pub fn decode_owned(s: impl AsRef<str>) -> Result<PublicKey<'static>, CryptoError> {
171
+
PublicKey::decode(s.as_ref())
172
+
}
173
+
}
174
+
175
+
impl IntoStatic for PublicKey<'_> {
176
+
type Output = PublicKey<'static>;
177
+
fn into_static(self) -> Self::Output {
178
+
match self.bytes {
179
+
Cow::Borrowed(b) => PublicKey {
180
+
codec: self.codec,
181
+
bytes: Cow::Owned(b.to_vec()),
182
+
},
183
+
Cow::Owned(b) => PublicKey {
184
+
codec: self.codec,
185
+
bytes: Cow::Owned(b),
186
+
},
187
+
}
188
+
}
189
+
}
190
+
191
+
fn decode_uvarint(data: &[u8]) -> Option<(u64, usize)> {
192
+
let mut x: u64 = 0;
193
+
let mut s: u32 = 0;
194
+
for (i, b) in data.iter().copied().enumerate() {
195
+
if b < 0x80 {
196
+
if i > 9 || (i == 9 && b > 1) {
197
+
return None;
198
+
}
199
+
return Some((x | ((b as u64) << s), i + 1));
200
+
}
201
+
x |= ((b & 0x7F) as u64) << s;
202
+
s += 7;
203
+
}
204
+
None
205
+
}
206
+
207
+
#[cfg(test)]
208
+
mod tests {
209
+
use super::*;
210
+
use multibase;
211
+
212
+
fn encode_uvarint(mut x: u64) -> Vec<u8> {
213
+
let mut out = Vec::new();
214
+
while x >= 0x80 {
215
+
out.push(((x as u8) & 0x7F) | 0x80);
216
+
x >>= 7;
217
+
}
218
+
out.push(x as u8);
219
+
out
220
+
}
221
+
222
+
fn multikey(code: u64, key: &[u8]) -> String {
223
+
let mut buf = encode_uvarint(code);
224
+
buf.extend_from_slice(key);
225
+
multibase::encode(multibase::Base::Base58Btc, buf)
226
+
}
227
+
228
+
#[test]
229
+
fn decode_ed25519() {
230
+
let key = [0u8; 32];
231
+
let s = multikey(0xED, &key);
232
+
let pk = PublicKey::decode(&s).expect("decode");
233
+
assert_eq!(pk.codec, KeyCodec::Ed25519);
234
+
assert_eq!(pk.bytes.as_ref(), &key);
235
+
}
236
+
237
+
#[test]
238
+
fn decode_k1_compressed() {
239
+
let mut key = [0u8; 33];
240
+
key[0] = 0x02; // compressed y-bit
241
+
let s = multikey(0xE7, &key);
242
+
let pk = PublicKey::decode(&s).expect("decode");
243
+
assert_eq!(pk.codec, KeyCodec::Secp256k1);
244
+
assert_eq!(pk.bytes.as_ref(), &key);
245
+
}
246
+
247
+
#[test]
248
+
fn decode_p256_uncompressed() {
249
+
let mut key = [0u8; 65];
250
+
key[0] = 0x04; // uncompressed
251
+
let s = multikey(0x1200, &key);
252
+
let pk = PublicKey::decode(&s).expect("decode");
253
+
assert_eq!(pk.codec, KeyCodec::P256);
254
+
assert_eq!(pk.bytes.as_ref(), &key);
255
+
}
256
+
257
+
#[cfg(feature = "crypto-ed25519")]
258
+
#[test]
259
+
fn ed25519_conversion_ok() {
260
+
use core::convert::TryFrom;
261
+
use ed25519_dalek::{SecretKey, SigningKey, VerifyingKey};
262
+
// Build a deterministic signing key from a fixed secret
263
+
let secret = SecretKey::try_from(&[7u8; 32][..]).expect("secret");
264
+
let sk = SigningKey::from_bytes(&secret);
265
+
let vk: VerifyingKey = sk.verifying_key();
266
+
let bytes = vk.to_bytes();
267
+
// Encode multikey: varint(0xED) + key bytes, base58btc
268
+
let mut buf = super::tests::encode_uvarint(0xED);
269
+
buf.extend_from_slice(&bytes);
270
+
let s = multibase::encode(multibase::Base::Base58Btc, buf);
271
+
let pk = PublicKey::decode(&s).expect("decode");
272
+
assert!(matches!(pk.codec, KeyCodec::Ed25519));
273
+
let vk2 = pk.to_ed25519().expect("to ed25519");
274
+
assert_eq!(vk.as_bytes(), vk2.as_bytes());
275
+
}
276
+
277
+
#[cfg(feature = "crypto-k256")]
278
+
#[test]
279
+
fn k256_unsupported_on_ed25519_codec() {
280
+
// Use a valid-looking ed25519 key, attempt k256 conversion → UnsupportedCodec
281
+
let key = [1u8; 32];
282
+
let s = super::tests::multikey(0xED, &key);
283
+
let pk = PublicKey::decode(&s).expect("decode");
284
+
let err = pk.to_k256().unwrap_err();
285
+
assert!(matches!(err, CryptoError::UnsupportedCodec(_)));
286
+
}
287
+
288
+
#[cfg(feature = "crypto-p256")]
289
+
#[test]
290
+
fn p256_unsupported_on_ed25519_codec() {
291
+
// Use a valid-looking ed25519 key, attempt p256 conversion → UnsupportedCodec
292
+
let key = [2u8; 32];
293
+
let s = super::tests::multikey(0xED, &key);
294
+
let pk = PublicKey::decode(&s).expect("decode");
295
+
let err = pk.to_p256().unwrap_err();
296
+
assert!(matches!(err, CryptoError::UnsupportedCodec(_)));
297
+
}
298
+
}
+264
crates/jacquard-common/src/types/did_doc.rs
+264
crates/jacquard-common/src/types/did_doc.rs
···
1
+
use crate::types::crypto::{CryptoError, PublicKey};
2
+
use crate::types::string::{Did, Handle};
3
+
use crate::types::value::Data;
4
+
use crate::{CowStr, IntoStatic};
5
+
use bon::Builder;
6
+
use serde::{Deserialize, Serialize};
7
+
use smol_str::SmolStr;
8
+
use std::collections::BTreeMap;
9
+
use url::Url;
10
+
11
+
/// DID Document representation with borrowed data where possible.
12
+
///
13
+
/// Only the most commonly used fields are modeled explicitly. All other fields
14
+
/// are captured in `extra_data` for forward compatibility, using the same
15
+
/// pattern as lexicon structs.
16
+
///
17
+
/// Example
18
+
/// ```ignore
19
+
/// use jacquard_common::types::did_doc::DidDocument;
20
+
/// use serde_json::json;
21
+
/// let doc: DidDocument<'_> = serde_json::from_value(json!({
22
+
/// "id": "did:plc:alice",
23
+
/// "alsoKnownAs": ["at://alice.example"],
24
+
/// "service": [{"id":"#pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example"}],
25
+
/// "verificationMethod":[{"id":"#k","type":"Multikey","publicKeyMultibase":"z6Mki..."}]
26
+
/// })).unwrap();
27
+
/// assert_eq!(doc.id.as_str(), "did:plc:alice");
28
+
/// assert!(doc.pds_endpoint().is_some());
29
+
/// ```
30
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
31
+
#[builder(start_fn = new)]
32
+
#[serde(rename_all = "camelCase")]
33
+
pub struct DidDocument<'a> {
34
+
/// Document identifier (e.g., `did:plc:...` or `did:web:...`)
35
+
#[serde(borrow)]
36
+
pub id: Did<'a>,
37
+
38
+
/// Alternate identifiers for the subject, such as at://<handle>
39
+
#[serde(borrow)]
40
+
pub also_known_as: Option<Vec<CowStr<'a>>>,
41
+
42
+
/// Verification methods (keys) for this DID
43
+
#[serde(borrow)]
44
+
pub verification_method: Option<Vec<VerificationMethod<'a>>>,
45
+
46
+
/// Services associated with this DID (e.g., AtprotoPersonalDataServer)
47
+
#[serde(borrow)]
48
+
pub service: Option<Vec<Service<'a>>>,
49
+
50
+
/// Forward‑compatible capture of unmodeled fields
51
+
#[serde(flatten)]
52
+
pub extra_data: BTreeMap<SmolStr, Data<'a>>,
53
+
}
54
+
55
+
impl crate::IntoStatic for DidDocument<'_> {
56
+
type Output = DidDocument<'static>;
57
+
fn into_static(self) -> Self::Output {
58
+
DidDocument {
59
+
id: self.id.into_static(),
60
+
also_known_as: self.also_known_as.into_static(),
61
+
verification_method: self.verification_method.into_static(),
62
+
service: self.service.into_static(),
63
+
extra_data: self.extra_data.into_static(),
64
+
}
65
+
}
66
+
}
67
+
68
+
impl<'a> DidDocument<'a> {
69
+
/// Extract validated handles from `alsoKnownAs` entries like `at://<handle>`.
70
+
pub fn handles(&self) -> Vec<Handle<'static>> {
71
+
self.also_known_as
72
+
.as_ref()
73
+
.map(|v| {
74
+
v.iter()
75
+
.filter_map(|s| s.strip_prefix("at://"))
76
+
.filter_map(|h| Handle::new(h).ok())
77
+
.map(|h| h.into_static())
78
+
.collect()
79
+
})
80
+
.unwrap_or_default()
81
+
}
82
+
83
+
/// Extract the first Multikey `publicKeyMultibase` value from verification methods.
84
+
pub fn atproto_multikey(&self) -> Option<CowStr<'static>> {
85
+
self.verification_method.as_ref().and_then(|methods| {
86
+
methods.iter().find_map(|m| {
87
+
if m.r#type.as_ref() == "Multikey" {
88
+
m.public_key_multibase
89
+
.as_ref()
90
+
.map(|k| k.clone().into_static())
91
+
} else {
92
+
None
93
+
}
94
+
})
95
+
})
96
+
}
97
+
98
+
/// Extract the AtprotoPersonalDataServer service endpoint as a `Url`.
99
+
/// Accepts endpoint as string or object (string preferred).
100
+
pub fn pds_endpoint(&self) -> Option<Url> {
101
+
self.service.as_ref().and_then(|services| {
102
+
services.iter().find_map(|s| {
103
+
if s.r#type.as_ref() == "AtprotoPersonalDataServer" {
104
+
match &s.service_endpoint {
105
+
Some(Data::String(strv)) => Url::parse(strv.as_ref()).ok(),
106
+
Some(Data::Object(obj)) => {
107
+
// Some documents may include structured endpoints; try common fields
108
+
if let Some(Data::String(urlv)) = obj.0.get("url") {
109
+
Url::parse(urlv.as_ref()).ok()
110
+
} else {
111
+
None
112
+
}
113
+
}
114
+
_ => None,
115
+
}
116
+
} else {
117
+
None
118
+
}
119
+
})
120
+
})
121
+
}
122
+
123
+
/// Decode the atproto Multikey (first occurrence) into a typed public key.
124
+
pub fn atproto_public_key(&self) -> Result<Option<PublicKey<'static>>, CryptoError> {
125
+
if let Some(multibase) = self.atproto_multikey() {
126
+
let pk = PublicKey::decode(&multibase)?;
127
+
Ok(Some(pk))
128
+
} else {
129
+
Ok(None)
130
+
}
131
+
}
132
+
}
133
+
134
+
/// Verification method (key) entry in a DID Document.
135
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
136
+
#[builder(start_fn = new)]
137
+
#[serde(rename_all = "camelCase")]
138
+
pub struct VerificationMethod<'a> {
139
+
/// Identifier for this key material within the document
140
+
#[serde(borrow)]
141
+
pub id: CowStr<'a>,
142
+
/// Key type (e.g., `Multikey`)
143
+
#[serde(borrow, rename = "type")]
144
+
pub r#type: CowStr<'a>,
145
+
/// Optional controller DID
146
+
#[serde(borrow)]
147
+
pub controller: Option<CowStr<'a>>,
148
+
/// Multikey `publicKeyMultibase` (base58btc)
149
+
#[serde(borrow)]
150
+
pub public_key_multibase: Option<CowStr<'a>>,
151
+
152
+
/// Forward‑compatible capture of unmodeled fields
153
+
#[serde(flatten)]
154
+
pub extra_data: BTreeMap<SmolStr, Data<'a>>,
155
+
}
156
+
157
+
impl crate::IntoStatic for VerificationMethod<'_> {
158
+
type Output = VerificationMethod<'static>;
159
+
fn into_static(self) -> Self::Output {
160
+
VerificationMethod {
161
+
id: self.id.into_static(),
162
+
r#type: self.r#type.into_static(),
163
+
controller: self.controller.into_static(),
164
+
public_key_multibase: self.public_key_multibase.into_static(),
165
+
extra_data: self.extra_data.into_static(),
166
+
}
167
+
}
168
+
}
169
+
170
+
/// Service entry in a DID Document.
171
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
172
+
#[builder(start_fn = new)]
173
+
#[serde(rename_all = "camelCase")]
174
+
pub struct Service<'a> {
175
+
/// Service identifier
176
+
#[serde(borrow)]
177
+
pub id: CowStr<'a>,
178
+
/// Service type (e.g., `AtprotoPersonalDataServer`)
179
+
#[serde(borrow, rename = "type")]
180
+
pub r#type: CowStr<'a>,
181
+
/// String or object; we preserve as Data
182
+
#[serde(borrow)]
183
+
pub service_endpoint: Option<Data<'a>>,
184
+
185
+
/// Forward‑compatible capture of unmodeled fields
186
+
#[serde(flatten)]
187
+
pub extra_data: BTreeMap<SmolStr, Data<'a>>,
188
+
}
189
+
190
+
impl crate::IntoStatic for Service<'_> {
191
+
type Output = Service<'static>;
192
+
fn into_static(self) -> Self::Output {
193
+
Service {
194
+
id: self.id.into_static(),
195
+
r#type: self.r#type.into_static(),
196
+
service_endpoint: self.service_endpoint.into_static(),
197
+
extra_data: self.extra_data.into_static(),
198
+
}
199
+
}
200
+
}
201
+
202
+
#[cfg(test)]
203
+
mod tests {
204
+
use super::*;
205
+
use serde_json::json;
206
+
207
+
fn encode_uvarint(mut x: u64) -> Vec<u8> {
208
+
let mut out = Vec::new();
209
+
while x >= 0x80 {
210
+
out.push(((x as u8) & 0x7F) | 0x80);
211
+
x >>= 7;
212
+
}
213
+
out.push(x as u8);
214
+
out
215
+
}
216
+
217
+
fn multikey(code: u64, key: &[u8]) -> String {
218
+
let mut buf = encode_uvarint(code);
219
+
buf.extend_from_slice(key);
220
+
multibase::encode(multibase::Base::Base58Btc, buf)
221
+
}
222
+
223
+
#[test]
224
+
fn public_key_decode() {
225
+
let did = "did:plc:example";
226
+
let mut k = [0u8; 32];
227
+
k[0] = 7;
228
+
let mk = multikey(0xED, &k);
229
+
let doc_json = json!({
230
+
"id": did,
231
+
"verificationMethod": [
232
+
{
233
+
"id": "#key-1",
234
+
"type": "Multikey",
235
+
"publicKeyMultibase": mk,
236
+
}
237
+
]
238
+
});
239
+
let doc_string = serde_json::to_string(&doc_json).unwrap();
240
+
let doc: DidDocument<'_> = serde_json::from_str(&doc_string).unwrap();
241
+
let pk = doc.atproto_public_key().unwrap().expect("present");
242
+
assert!(matches!(pk.codec, crate::types::crypto::KeyCodec::Ed25519));
243
+
assert_eq!(pk.bytes.as_ref(), &k);
244
+
}
245
+
246
+
#[test]
247
+
fn parse_sample_doc_and_helpers() {
248
+
let raw = include_str!("test_did_doc.json");
249
+
let doc: DidDocument<'_> = serde_json::from_str(raw).expect("parse doc");
250
+
// id
251
+
assert_eq!(doc.id.as_str(), "did:plc:yfvwmnlztr4dwkb7hwz55r2g");
252
+
// pds endpoint
253
+
let pds = doc.pds_endpoint().expect("pds endpoint");
254
+
assert_eq!(pds.as_str(), "https://atproto.systems/");
255
+
// handle alias extraction
256
+
let handles = doc.handles();
257
+
assert!(handles.iter().any(|h| h.as_str() == "nonbinary.computer"));
258
+
// multikey string present
259
+
let mk = doc.atproto_multikey().expect("has multikey");
260
+
assert!(mk.as_ref().starts_with('z'));
261
+
// typed decode (may be ed25519, secp256k1, or p256 depending on multicodec)
262
+
let _ = doc.atproto_public_key().expect("decode ok");
263
+
}
264
+
}
+24
crates/jacquard-common/src/types/test_did_doc.json
+24
crates/jacquard-common/src/types/test_did_doc.json
···
1
+
{
2
+
"@context": [
3
+
"https://www.w3.org/ns/did/v1",
4
+
"https://w3id.org/security/multikey/v1",
5
+
"https://w3id.org/security/suites/secp256k1-2019/v1"
6
+
],
7
+
"alsoKnownAs": ["at://nonbinary.computer"],
8
+
"id": "did:plc:yfvwmnlztr4dwkb7hwz55r2g",
9
+
"service": [
10
+
{
11
+
"id": "#atproto_pds",
12
+
"serviceEndpoint": "https://atproto.systems",
13
+
"type": "AtprotoPersonalDataServer"
14
+
}
15
+
],
16
+
"verificationMethod": [
17
+
{
18
+
"controller": "did:plc:yfvwmnlztr4dwkb7hwz55r2g",
19
+
"id": "did:plc:yfvwmnlztr4dwkb7hwz55r2g#atproto",
20
+
"publicKeyMultibase": "zQ3shtTHyn59SehkrApkRCXMbE7UZyrrkeCdQTuDW9oRF3R9U",
21
+
"type": "Multikey"
22
+
}
23
+
]
24
+
}
+12
crates/jacquard-common/src/types/value/broken_record.json
+12
crates/jacquard-common/src/types/value/broken_record.json
···
1
+
{
2
+
"uri": "at://did:plc:5me6kvratkf2f5lgvezbqrk7/app.bsky.feed.post/3lxzkque3272s",
3
+
"cid": "bafyreibllccqp445znzudb6q635aghmtqy3v4dexdzrzb4s4x3zuhejy2i",
4
+
"value": {
5
+
"text": "RT @kickitout: Four goals. One mission. Football United.\n\n⚽ Inclusive culture\n👥 More representation on the pitch\n💼 More coaches and leaders��",
6
+
"$type": "app.bsky.feed.post",
7
+
"embed": {
8
+
"$type": ""
9
+
},
10
+
"createdAt": "2025-09-04T11:26:06-05:00"
11
+
}
12
+
}
+1
-1
crates/jacquard-derive/Cargo.toml
+1
-1
crates/jacquard-derive/Cargo.toml
+1
-1
crates/jacquard-lexicon/Cargo.toml
+1
-1
crates/jacquard-lexicon/Cargo.toml
+9
-1
crates/jacquard/Cargo.toml
+9
-1
crates/jacquard/Cargo.toml
···
9
9
categories.workspace = true
10
10
readme.workspace = true
11
11
exclude.workspace = true
12
-
license-file.workspace = true
12
+
license.workspace = true
13
13
14
14
[features]
15
15
default = ["api_all"]
16
16
derive = ["dep:jacquard-derive"]
17
17
api = ["jacquard-api/com_atproto"]
18
18
api_all = ["api", "jacquard-api/app_bsky", "jacquard-api/chat_bsky", "jacquard-api/tools_ozone"]
19
+
dns = ["dep:hickory-resolver"]
19
20
20
21
[lib]
21
22
name = "jacquard"
···
26
27
path = "src/main.rs"
27
28
28
29
[dependencies]
30
+
bon = "3"
31
+
async-trait = "0.1"
29
32
bytes.workspace = true
30
33
clap.workspace = true
31
34
http.workspace = true
···
40
43
serde_json.workspace = true
41
44
thiserror.workspace = true
42
45
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
46
+
hickory-resolver = { version = "0.24", default-features = false, features = ["system-config", "tokio-runtime"], optional = true }
47
+
url.workspace = true
48
+
smol_str.workspace = true
49
+
percent-encoding = "2"
50
+
urlencoding = "2"
+10
-10
crates/jacquard/src/client.rs
+10
-10
crates/jacquard/src/client.rs
···
69
69
fn send_http(
70
70
&self,
71
71
request: Request<Vec<u8>>,
72
-
) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>>;
72
+
) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send;
73
73
}
74
74
/// XRPC client trait for AT Protocol RPC calls
75
-
pub trait XrpcClient: HttpClient {
75
+
pub trait XrpcClient: HttpClient + Sync {
76
76
/// Get the base URI for XRPC requests (e.g., "https://bsky.social")
77
77
fn base_uri(&self) -> CowStr<'_>;
78
78
/// Get the authorization token for XRPC requests
···
80
80
fn authorization_token(
81
81
&self,
82
82
is_refresh: bool,
83
-
) -> impl Future<Output = Option<AuthorizationToken<'_>>> {
83
+
) -> impl Future<Output = Option<AuthorizationToken<'_>>> + Send {
84
84
async { None }
85
85
}
86
86
/// Get the `atproto-proxy` header.
87
-
fn atproto_proxy_header(&self) -> impl Future<Output = Option<String>> {
87
+
fn atproto_proxy_header(&self) -> impl Future<Output = Option<String>> + Send {
88
88
async { None }
89
89
}
90
90
/// Get the `atproto-accept-labelers` header.
91
-
fn atproto_accept_labelers_header(&self) -> impl Future<Output = Option<Vec<String>>> {
91
+
fn atproto_accept_labelers_header(&self) -> impl Future<Output = Option<Vec<String>>> + Send {
92
92
async { None }
93
93
}
94
94
/// Send an XRPC request and get back a response
95
-
fn send<R: XrpcRequest>(&self, request: R) -> impl Future<Output = Result<Response<R>>>
95
+
fn send<R: XrpcRequest + Send>(&self, request: R) -> impl Future<Output = Result<Response<R>>> + Send
96
96
where
97
-
Self: Sized,
97
+
Self: Sized + Sync,
98
98
{
99
99
send_xrpc(self, request)
100
100
}
···
149
149
/// Generic XRPC send implementation that uses HttpClient
150
150
async fn send_xrpc<R, C>(client: &C, request: R) -> Result<Response<R>>
151
151
where
152
-
R: XrpcRequest,
153
-
C: XrpcClient + ?Sized,
152
+
R: XrpcRequest + Send,
153
+
C: XrpcClient + ?Sized + Sync,
154
154
{
155
155
// Build URI: base_uri + /xrpc/ + NSID
156
156
let mut uri = format!("{}/xrpc/{}", client.base_uri(), R::NSID);
···
314
314
}
315
315
}
316
316
317
-
impl<C: HttpClient> XrpcClient for AuthenticatedClient<C> {
317
+
impl<C: HttpClient + Sync> XrpcClient for AuthenticatedClient<C> {
318
318
fn base_uri(&self) -> CowStr<'_> {
319
319
self.base_uri.clone()
320
320
}
+3
crates/jacquard/src/identity/mod.rs
+3
crates/jacquard/src/identity/mod.rs
+960
crates/jacquard/src/identity/resolver.rs
+960
crates/jacquard/src/identity/resolver.rs
···
1
+
//! Identity resolution: handle → DID and DID → document, with smart fallbacks.
2
+
//!
3
+
//! Fallback order (default):
4
+
//! - Handle → DID: DNS TXT (if `dns` feature) → HTTPS well-known → embedded XRPC
5
+
//! `resolveHandle` → public API fallback → Slingshot `resolveHandle` (if configured).
6
+
//! - DID → Doc: did:web well-known → PLC/slingshot HTTP → embedded XRPC `resolveDid`,
7
+
//! then Slingshot mini‑doc (partial) if configured.
8
+
//!
9
+
//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer
10
+
//! and optionally validate the document `id` against the requested DID.
11
+
12
+
use crate::CowStr;
13
+
use crate::client::AuthenticatedClient;
14
+
use bon::Builder;
15
+
use bytes::Bytes;
16
+
use jacquard_common::IntoStatic;
17
+
use miette::Diagnostic;
18
+
use percent_encoding::percent_decode_str;
19
+
use reqwest::StatusCode;
20
+
use thiserror::Error;
21
+
use url::{ParseError, Url};
22
+
23
+
use crate::api::com_atproto::identity::{resolve_did, resolve_handle::ResolveHandle};
24
+
use crate::types::did_doc::DidDocument;
25
+
use crate::types::ident::AtIdentifier;
26
+
use crate::types::string::{Did, Handle};
27
+
use crate::types::value::AtDataError;
28
+
29
+
#[cfg(feature = "dns")]
30
+
use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig};
31
+
32
+
/// Errors that can occur during identity resolution.
33
+
///
34
+
/// Note: when validating a fetched DID document against a requested DID, a
35
+
/// `DocIdMismatch` error is returned that includes the owned document so callers
36
+
/// can inspect it and decide how to proceed.
37
+
#[derive(Debug, Error, Diagnostic)]
38
+
#[allow(missing_docs)]
39
+
pub enum IdentityError {
40
+
#[error("unsupported DID method: {0}")]
41
+
UnsupportedDidMethod(String),
42
+
#[error("invalid well-known atproto-did content")]
43
+
InvalidWellKnown,
44
+
#[error("missing PDS endpoint in DID document")]
45
+
MissingPdsEndpoint,
46
+
#[error("HTTP error: {0}")]
47
+
Http(#[from] reqwest::Error),
48
+
#[error("HTTP status {0}")]
49
+
HttpStatus(StatusCode),
50
+
#[error("XRPC error: {0}")]
51
+
Xrpc(String),
52
+
#[error("URL parse error: {0}")]
53
+
Url(#[from] url::ParseError),
54
+
#[error("DNS error: {0}")]
55
+
#[cfg(feature = "dns")]
56
+
Dns(#[from] hickory_resolver::error::ResolveError),
57
+
#[error("serialize/deserialize error: {0}")]
58
+
Serde(#[from] serde_json::Error),
59
+
#[error("invalid DID document: {0}")]
60
+
InvalidDoc(String),
61
+
#[error(transparent)]
62
+
Data(#[from] AtDataError),
63
+
/// DID document id did not match requested DID; includes the fetched document
64
+
#[error("DID doc id mismatch")]
65
+
DocIdMismatch {
66
+
expected: Did<'static>,
67
+
doc: DidDocument<'static>,
68
+
},
69
+
}
70
+
71
+
/// Source to fetch PLC (did:plc) documents from.
72
+
///
73
+
/// - `PlcDirectory`: uses the public PLC directory (default `https://plc.directory/`).
74
+
/// - `Slingshot`: uses Slingshot which also exposes convenience endpoints such as
75
+
/// `com.atproto.identity.resolveHandle` and a "mini-doc"
76
+
/// endpoint (`com.bad-example.identity.resolveMiniDoc`).
77
+
#[derive(Debug, Clone, PartialEq, Eq)]
78
+
pub enum PlcSource {
79
+
/// Use the public PLC directory
80
+
PlcDirectory {
81
+
/// Base URL for the PLC directory
82
+
base: Url,
83
+
},
84
+
/// Use the slingshot mini-docs service
85
+
Slingshot {
86
+
/// Base URL for the Slingshot service
87
+
base: Url,
88
+
},
89
+
}
90
+
91
+
impl Default for PlcSource {
92
+
fn default() -> Self {
93
+
Self::PlcDirectory {
94
+
base: Url::parse("https://plc.directory/").expect("valid url"),
95
+
}
96
+
}
97
+
}
98
+
99
+
impl PlcSource {
100
+
/// Default Slingshot source (`https://slingshot.microcosm.blue`)
101
+
pub fn slingshot_default() -> Self {
102
+
PlcSource::Slingshot {
103
+
base: Url::parse("https://slingshot.microcosm.blue").expect("valid url"),
104
+
}
105
+
}
106
+
}
107
+
108
+
/// DID Document fetch response for borrowed/owned parsing.
109
+
///
110
+
/// Carries the raw response bytes and the HTTP status, plus the requested DID
111
+
/// (if supplied) to enable validation. Use `parse()` to borrow from the buffer
112
+
/// or `parse_validated()` to also enforce that the doc `id` matches the
113
+
/// requested DID (returns a `DocIdMismatch` containing the fetched doc on
114
+
/// mismatch). Use `into_owned()` to parse into an owned document.
115
+
#[derive(Clone)]
116
+
pub struct DidDocResponse {
117
+
buffer: Bytes,
118
+
status: StatusCode,
119
+
/// Optional DID we intended to resolve; used for validation helpers
120
+
requested: Option<Did<'static>>,
121
+
}
122
+
123
+
impl DidDocResponse {
124
+
/// Parse as borrowed DidDocument<'_>
125
+
pub fn parse<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
126
+
if self.status.is_success() {
127
+
serde_json::from_slice::<DidDocument<'b>>(&self.buffer).map_err(IdentityError::from)
128
+
} else {
129
+
Err(IdentityError::HttpStatus(self.status))
130
+
}
131
+
}
132
+
133
+
/// Parse and validate that the DID in the document matches the requested DID if present.
134
+
///
135
+
/// On mismatch, returns an error that contains the owned document for inspection.
136
+
pub fn parse_validated<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
137
+
let doc = self.parse()?;
138
+
if let Some(expected) = &self.requested {
139
+
if doc.id.as_str() != expected.as_str() {
140
+
return Err(IdentityError::DocIdMismatch {
141
+
expected: expected.clone(),
142
+
doc: doc.clone().into_static(),
143
+
});
144
+
}
145
+
}
146
+
Ok(doc)
147
+
}
148
+
149
+
/// Parse as owned DidDocument<'static>
150
+
pub fn into_owned(self) -> Result<DidDocument<'static>, IdentityError> {
151
+
if self.status.is_success() {
152
+
serde_json::from_slice::<DidDocument<'_>>(&self.buffer)
153
+
.map(|d| d.into_static())
154
+
.map_err(IdentityError::from)
155
+
} else {
156
+
Err(IdentityError::HttpStatus(self.status))
157
+
}
158
+
}
159
+
}
160
+
161
+
/// Handle → DID fallback step.
162
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163
+
pub enum HandleStep {
164
+
/// DNS TXT _atproto.<handle>
165
+
DnsTxt,
166
+
/// HTTPS GET https://<handle>/.well-known/atproto-did
167
+
HttpsWellKnown,
168
+
/// XRPC com.atproto.identity.resolveHandle against a provided PDS base
169
+
PdsResolveHandle,
170
+
}
171
+
172
+
/// DID → Doc fallback step.
173
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174
+
pub enum DidStep {
175
+
/// For did:web: fetch from the well-known location
176
+
DidWebHttps,
177
+
/// For did:plc: fetch from PLC source
178
+
PlcHttp,
179
+
/// If a PDS base is known, ask it for the DID doc
180
+
PdsResolveDid,
181
+
}
182
+
183
+
/// Configurable resolver options.
184
+
///
185
+
/// - `plc_source`: where to fetch did:plc documents (PLC Directory or Slingshot).
186
+
/// - `pds_fallback`: optional base URL of a PDS for XRPC fallbacks (auth-aware
187
+
/// paths available via helpers that take an `XrpcClient`).
188
+
/// - `handle_order`/`did_order`: ordered strategies for resolution.
189
+
/// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID,
190
+
/// returning `DocIdMismatch` with the fetched document on mismatch.
191
+
/// - `public_fallback_for_handle`: if true (default), attempt
192
+
/// `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle` as an unauth fallback.
193
+
/// There is no public fallback for DID documents; when `PdsResolveDid` is chosen and the embedded XRPC
194
+
/// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured.
195
+
#[derive(Debug, Clone, Builder)]
196
+
#[builder(start_fn = new)]
197
+
pub struct ResolverOptions {
198
+
/// PLC data source (directory or slingshot)
199
+
pub plc_source: PlcSource,
200
+
/// Optional PDS base to use for fallbacks
201
+
pub pds_fallback: Option<Url>,
202
+
/// Order of attempts for handle → DID resolution
203
+
pub handle_order: Vec<HandleStep>,
204
+
/// Order of attempts for DID → Doc resolution
205
+
pub did_order: Vec<DidStep>,
206
+
/// Validate that fetched DID document id matches the requested DID
207
+
pub validate_doc_id: bool,
208
+
/// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app
209
+
pub public_fallback_for_handle: bool,
210
+
}
211
+
212
+
impl Default for ResolverOptions {
213
+
fn default() -> Self {
214
+
// By default, prefer DNS then HTTPS for handles, then PDS fallback
215
+
// For DID documents, prefer method-native sources, then PDS fallback
216
+
Self::new()
217
+
.plc_source(PlcSource::default())
218
+
.handle_order(vec![
219
+
HandleStep::DnsTxt,
220
+
HandleStep::HttpsWellKnown,
221
+
HandleStep::PdsResolveHandle,
222
+
])
223
+
.did_order(vec![
224
+
DidStep::DidWebHttps,
225
+
DidStep::PlcHttp,
226
+
DidStep::PdsResolveDid,
227
+
])
228
+
.validate_doc_id(true)
229
+
.public_fallback_for_handle(true)
230
+
.build()
231
+
}
232
+
}
233
+
234
+
/// Trait for identity resolution, for pluggable implementations.
235
+
///
236
+
/// The provided `DefaultResolver` supports:
237
+
/// - DNS TXT (`_atproto.<handle>`) when compiled with the `dns` feature
238
+
/// - HTTPS well-known for handles and `did:web`
239
+
/// - PLC directory or Slingshot for `did:plc`
240
+
/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source
241
+
/// - Auth-aware PDS fallbacks via helpers that accept an `XrpcClient`
242
+
#[async_trait::async_trait]
243
+
pub trait IdentityResolver {
244
+
/// Access options for validation decisions in default methods
245
+
fn options(&self) -> &ResolverOptions;
246
+
247
+
/// Resolve handle
248
+
async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError>;
249
+
250
+
/// Resolve DID document
251
+
async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError>;
252
+
async fn resolve_did_doc_owned(
253
+
&self,
254
+
did: &Did<'_>,
255
+
) -> Result<DidDocument<'static>, IdentityError> {
256
+
self.resolve_did_doc(did).await?.into_owned()
257
+
}
258
+
async fn pds_for_did(&self, did: &Did<'_>) -> Result<Url, IdentityError> {
259
+
let resp = self.resolve_did_doc(did).await?;
260
+
let doc = resp.parse()?;
261
+
// Default-on doc id equality check
262
+
if self.options().validate_doc_id {
263
+
if doc.id.as_str() != did.as_str() {
264
+
return Err(IdentityError::DocIdMismatch {
265
+
expected: did.clone().into_static(),
266
+
doc: doc.clone().into_static(),
267
+
});
268
+
}
269
+
}
270
+
doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint)
271
+
}
272
+
async fn pds_for_handle(
273
+
&self,
274
+
handle: &Handle<'_>,
275
+
) -> Result<(Did<'static>, Url), IdentityError> {
276
+
let did = self.resolve_handle(handle).await?;
277
+
let pds = self.pds_for_did(&did).await?;
278
+
Ok((did, pds))
279
+
}
280
+
}
281
+
282
+
/// Default resolver implementation with configurable fallback order.
283
+
///
284
+
/// Behavior highlights:
285
+
/// - Handle resolution tries DNS TXT (if enabled via `dns` feature), then HTTPS
286
+
/// well-known, then Slingshot's unauthenticated `resolveHandle` when
287
+
/// `PlcSource::Slingshot` is configured.
288
+
/// - DID resolution tries did:web well-known for `did:web`, and the configured
289
+
/// PLC base (PLC directory or Slingshot) for `did:plc`.
290
+
/// - PDS-authenticated fallbacks (e.g., `resolveHandle`, `resolveDid` on a PDS)
291
+
/// are available via helper methods that accept a user-provided `XrpcClient`.
292
+
///
293
+
/// Example
294
+
/// ```ignore
295
+
/// use jacquard::identity::resolver::{DefaultResolver, ResolverOptions};
296
+
/// use jacquard::client::{AuthenticatedClient, XrpcClient};
297
+
/// use jacquard::types::string::Handle;
298
+
/// use jacquard::CowStr;
299
+
///
300
+
/// // Build an auth-capable XRPC client (without a session it behaves like public/unauth)
301
+
/// let http = reqwest::Client::new();
302
+
/// let xrpc = AuthenticatedClient::new(http.clone(), CowStr::from("https://bsky.social"));
303
+
/// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default());
304
+
///
305
+
/// // Resolve a handle to a DID
306
+
/// let did = tokio_test::block_on(async { resolver.resolve_handle(&Handle::new("bad-example.com").unwrap()).await }).unwrap();
307
+
/// ```
308
+
pub struct DefaultResolver<C: crate::client::XrpcClient + Send + Sync> {
309
+
http: reqwest::Client,
310
+
xrpc: C,
311
+
opts: ResolverOptions,
312
+
#[cfg(feature = "dns")]
313
+
dns: Option<TokioAsyncResolver>,
314
+
}
315
+
316
+
impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> {
317
+
pub fn new(http: reqwest::Client, xrpc: C, opts: ResolverOptions) -> Self {
318
+
Self {
319
+
http,
320
+
xrpc,
321
+
opts,
322
+
#[cfg(feature = "dns")]
323
+
dns: None,
324
+
}
325
+
}
326
+
327
+
#[cfg(feature = "dns")]
328
+
pub fn with_system_dns(mut self) -> Self {
329
+
self.dns = Some(TokioAsyncResolver::tokio(
330
+
ResolverConfig::default(),
331
+
Default::default(),
332
+
));
333
+
self
334
+
}
335
+
336
+
/// Set PLC source (PLC directory or Slingshot)
337
+
///
338
+
/// Example
339
+
/// ```ignore
340
+
/// use jacquard::identity::resolver::{DefaultResolver, ResolverOptions, PlcSource};
341
+
/// let http = reqwest::Client::new();
342
+
/// let xrpc = jacquard::client::AuthenticatedClient::new(http.clone(), jacquard::CowStr::from("https://public.api.bsky.app"));
343
+
/// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default())
344
+
/// .with_plc_source(PlcSource::slingshot_default());
345
+
/// ```
346
+
pub fn with_plc_source(mut self, source: PlcSource) -> Self {
347
+
self.opts.plc_source = source;
348
+
self
349
+
}
350
+
351
+
/// Enable/disable public unauthenticated fallback for resolveHandle
352
+
///
353
+
/// Example
354
+
/// ```ignore
355
+
/// # use jacquard::identity::resolver::{DefaultResolver, ResolverOptions};
356
+
/// # let http = reqwest::Client::new();
357
+
/// # let xrpc = jacquard::client::AuthenticatedClient::new(http.clone(), jacquard::CowStr::from("https://public.api.bsky.app"));
358
+
/// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default())
359
+
/// .with_public_fallback_for_handle(true);
360
+
/// ```
361
+
pub fn with_public_fallback_for_handle(mut self, enable: bool) -> Self {
362
+
self.opts.public_fallback_for_handle = enable;
363
+
self
364
+
}
365
+
366
+
/// Enable/disable doc id validation
367
+
///
368
+
/// Example
369
+
/// ```ignore
370
+
/// # use jacquard::identity::resolver::{DefaultResolver, ResolverOptions};
371
+
/// # let http = reqwest::Client::new();
372
+
/// # let xrpc = jacquard::client::AuthenticatedClient::new(http.clone(), jacquard::CowStr::from("https://public.api.bsky.app"));
373
+
/// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default())
374
+
/// .with_validate_doc_id(true);
375
+
/// ```
376
+
pub fn with_validate_doc_id(mut self, enable: bool) -> Self {
377
+
self.opts.validate_doc_id = enable;
378
+
self
379
+
}
380
+
381
+
/// Construct the well-known HTTPS URL for a `did:web` DID.
382
+
///
383
+
/// - `did:web:example.com` → `https://example.com/.well-known/did.json`
384
+
/// - `did:web:example.com:user:alice` → `https://example.com/user/alice/did.json`
385
+
fn did_web_url(&self, did: &Did<'_>) -> Result<Url, IdentityError> {
386
+
// did:web:example.com[:path:segments]
387
+
let s = did.as_str();
388
+
let rest = s
389
+
.strip_prefix("did:web:")
390
+
.ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?;
391
+
let mut parts = rest.split(':');
392
+
let host = parts
393
+
.next()
394
+
.ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?;
395
+
let mut url = Url::parse(&format!("https://{host}/")).map_err(IdentityError::Url)?;
396
+
let path: Vec<&str> = parts.collect();
397
+
if path.is_empty() {
398
+
url.set_path(".well-known/did.json");
399
+
} else {
400
+
// Append path segments and did.json
401
+
let mut segments = url
402
+
.path_segments_mut()
403
+
.map_err(|_| IdentityError::Url(ParseError::SetHostOnCannotBeABaseUrl))?;
404
+
for seg in path {
405
+
// Minimally percent-decode each segment per spec guidance
406
+
let decoded = percent_decode_str(seg).decode_utf8_lossy();
407
+
segments.push(&decoded);
408
+
}
409
+
segments.push("did.json");
410
+
// drop segments
411
+
}
412
+
Ok(url)
413
+
}
414
+
415
+
#[cfg(test)]
416
+
fn test_did_web_url_raw(&self, s: &str) -> String {
417
+
let did = Did::new(s).unwrap();
418
+
self.did_web_url(&did).unwrap().to_string()
419
+
}
420
+
421
+
async fn get_json_bytes(&self, url: Url) -> Result<(Bytes, StatusCode), IdentityError> {
422
+
let resp = self.http.get(url).send().await?;
423
+
let status = resp.status();
424
+
let buf = resp.bytes().await?;
425
+
Ok((buf, status))
426
+
}
427
+
428
+
async fn get_text(&self, url: Url) -> Result<String, IdentityError> {
429
+
let resp = self.http.get(url).send().await?;
430
+
if resp.status() == StatusCode::OK {
431
+
Ok(resp.text().await?)
432
+
} else {
433
+
Err(IdentityError::Http(resp.error_for_status().unwrap_err()))
434
+
}
435
+
}
436
+
437
+
#[cfg(feature = "dns")]
438
+
async fn dns_txt(&self, name: &str) -> Result<Vec<String>, IdentityError> {
439
+
let Some(dns) = &self.dns else {
440
+
return Ok(vec![]);
441
+
};
442
+
let fqdn = format!("_atproto.{name}.");
443
+
let response = dns.txt_lookup(fqdn).await?;
444
+
let mut out = Vec::new();
445
+
for txt in response.iter() {
446
+
for data in txt.txt_data().iter() {
447
+
out.push(String::from_utf8_lossy(data).to_string());
448
+
}
449
+
}
450
+
Ok(out)
451
+
}
452
+
453
+
fn parse_atproto_did_body(body: &str) -> Result<Did<'static>, IdentityError> {
454
+
let line = body
455
+
.lines()
456
+
.find(|l| !l.trim().is_empty())
457
+
.ok_or(IdentityError::InvalidWellKnown)?;
458
+
let did = Did::new(line.trim()).map_err(|_| IdentityError::InvalidWellKnown)?;
459
+
Ok(did.into_static())
460
+
}
461
+
}
462
+
463
+
impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> {
464
+
/// Resolve handle to DID via a PDS XRPC client (auth-aware path)
465
+
pub async fn resolve_handle_via_pds(
466
+
&self,
467
+
handle: &Handle<'_>,
468
+
) -> Result<Did<'static>, IdentityError> {
469
+
let req = ResolveHandle::new().handle((*handle).clone()).build();
470
+
let resp = self
471
+
.xrpc
472
+
.send(req)
473
+
.await
474
+
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
475
+
let out = resp
476
+
.into_output()
477
+
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
478
+
Did::new_owned(out.did.as_str())
479
+
.map(|d| d.into_static())
480
+
.map_err(|_| IdentityError::InvalidWellKnown)
481
+
}
482
+
483
+
/// Fetch DID document via PDS resolveDid (returns owned DidDocument)
484
+
pub async fn fetch_did_doc_via_pds_owned(
485
+
&self,
486
+
did: &Did<'_>,
487
+
) -> Result<DidDocument<'static>, IdentityError> {
488
+
let req = resolve_did::ResolveDid::new().did(did.clone()).build();
489
+
let resp = self
490
+
.xrpc
491
+
.send(req)
492
+
.await
493
+
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
494
+
let out = resp
495
+
.into_output()
496
+
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
497
+
let doc_json = serde_json::to_value(&out.did_doc)?;
498
+
let s = serde_json::to_string(&doc_json)?;
499
+
let doc_borrowed: DidDocument<'_> = serde_json::from_str(&s)?;
500
+
Ok(doc_borrowed.into_static())
501
+
}
502
+
503
+
/// Fetch a minimal DID document via a Slingshot mini-doc endpoint, if your PlcSource uses Slingshot.
504
+
/// Returns the raw response wrapper for borrowed parsing and validation.
505
+
pub async fn fetch_mini_doc_via_slingshot(
506
+
&self,
507
+
did: &Did<'_>,
508
+
) -> Result<DidDocResponse, IdentityError> {
509
+
let base = match &self.opts.plc_source {
510
+
PlcSource::Slingshot { base } => base.clone(),
511
+
_ => {
512
+
return Err(IdentityError::UnsupportedDidMethod(
513
+
"mini-doc requires Slingshot source".into(),
514
+
));
515
+
}
516
+
};
517
+
let mut url = base;
518
+
url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc");
519
+
if let Ok(qs) =
520
+
serde_html_form::to_string(&resolve_did::ResolveDid::new().did(did.clone()).build())
521
+
{
522
+
url.set_query(Some(&qs));
523
+
}
524
+
let (buf, status) = self.get_json_bytes(url).await?;
525
+
Ok(DidDocResponse {
526
+
buffer: buf,
527
+
status,
528
+
requested: Some(did.clone().into_static()),
529
+
})
530
+
}
531
+
}
532
+
533
+
#[async_trait::async_trait]
534
+
impl<C: crate::client::XrpcClient + Send + Sync> IdentityResolver for DefaultResolver<C> {
535
+
fn options(&self) -> &ResolverOptions {
536
+
&self.opts
537
+
}
538
+
async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> {
539
+
let host = handle.as_str();
540
+
for step in &self.opts.handle_order {
541
+
match step {
542
+
HandleStep::DnsTxt => {
543
+
#[cfg(feature = "dns")]
544
+
{
545
+
if let Ok(txts) = self.dns_txt(host).await {
546
+
for txt in txts {
547
+
if let Some(did_str) = txt.strip_prefix("did=") {
548
+
if let Ok(did) = Did::new(did_str) {
549
+
return Ok(did.into_static());
550
+
}
551
+
}
552
+
}
553
+
}
554
+
}
555
+
}
556
+
HandleStep::HttpsWellKnown => {
557
+
let url = Url::parse(&format!("https://{host}/.well-known/atproto-did"))?;
558
+
if let Ok(text) = self.get_text(url).await {
559
+
if let Ok(did) = Self::parse_atproto_did_body(&text) {
560
+
return Ok(did);
561
+
}
562
+
}
563
+
}
564
+
HandleStep::PdsResolveHandle => {
565
+
// Prefer embedded XRPC client
566
+
if let Ok(did) = self.resolve_handle_via_pds(handle).await {
567
+
return Ok(did);
568
+
}
569
+
// Public unauth fallback
570
+
if self.opts.public_fallback_for_handle {
571
+
if let Ok(mut url) = Url::parse("https://public.api.bsky.app") {
572
+
url.set_path("/xrpc/com.atproto.identity.resolveHandle");
573
+
if let Ok(qs) = serde_html_form::to_string(
574
+
&ResolveHandle::new().handle((*handle).clone()).build(),
575
+
) {
576
+
url.set_query(Some(&qs));
577
+
} else {
578
+
continue;
579
+
}
580
+
if let Ok((buf, status)) = self.get_json_bytes(url).await {
581
+
if status.is_success() {
582
+
if let Ok(val) =
583
+
serde_json::from_slice::<serde_json::Value>(&buf)
584
+
{
585
+
if let Some(did_str) =
586
+
val.get("did").and_then(|v| v.as_str())
587
+
{
588
+
if let Ok(did) = Did::new_owned(did_str) {
589
+
return Ok(did.into_static());
590
+
}
591
+
}
592
+
}
593
+
}
594
+
}
595
+
}
596
+
}
597
+
// Non-auth path: if PlcSource is Slingshot, use its resolveHandle endpoint.
598
+
if let PlcSource::Slingshot { base } = &self.opts.plc_source {
599
+
let mut url = base.clone();
600
+
url.set_path("/xrpc/com.atproto.identity.resolveHandle");
601
+
if let Ok(qs) = serde_html_form::to_string(
602
+
&ResolveHandle::new().handle((*handle).clone()).build(),
603
+
) {
604
+
url.set_query(Some(&qs));
605
+
} else {
606
+
continue;
607
+
}
608
+
if let Ok((buf, status)) = self.get_json_bytes(url).await {
609
+
if status.is_success() {
610
+
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&buf) {
611
+
if let Some(did_str) = val.get("did").and_then(|v| v.as_str()) {
612
+
if let Ok(did) = Did::new_owned(did_str) {
613
+
return Ok(did.into_static());
614
+
}
615
+
}
616
+
}
617
+
}
618
+
}
619
+
}
620
+
}
621
+
}
622
+
}
623
+
Err(IdentityError::InvalidWellKnown)
624
+
}
625
+
626
+
async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> {
627
+
let s = did.as_str();
628
+
for step in &self.opts.did_order {
629
+
match step {
630
+
DidStep::DidWebHttps if s.starts_with("did:web:") => {
631
+
let url = self.did_web_url(did)?;
632
+
if let Ok((buf, status)) = self.get_json_bytes(url).await {
633
+
return Ok(DidDocResponse {
634
+
buffer: buf,
635
+
status,
636
+
requested: Some(did.clone().into_static()),
637
+
});
638
+
}
639
+
}
640
+
DidStep::PlcHttp if s.starts_with("did:plc:") => {
641
+
let url = match &self.opts.plc_source {
642
+
PlcSource::PlcDirectory { base } => base.join(did.as_str())?,
643
+
PlcSource::Slingshot { base } => base.join(did.as_str())?,
644
+
};
645
+
if let Ok((buf, status)) = self.get_json_bytes(url).await {
646
+
return Ok(DidDocResponse {
647
+
buffer: buf,
648
+
status,
649
+
requested: Some(did.clone().into_static()),
650
+
});
651
+
}
652
+
}
653
+
DidStep::PdsResolveDid => {
654
+
// Try embedded XRPC client for full DID doc
655
+
if let Ok(doc) = self.fetch_did_doc_via_pds_owned(did).await {
656
+
let buf = serde_json::to_vec(&doc).unwrap_or_default();
657
+
return Ok(DidDocResponse {
658
+
buffer: Bytes::from(buf),
659
+
status: StatusCode::OK,
660
+
requested: Some(did.clone().into_static()),
661
+
});
662
+
}
663
+
// Fallback: if Slingshot configured, return mini-doc response (partial doc)
664
+
if let PlcSource::Slingshot { base } = &self.opts.plc_source {
665
+
let url = self.slingshot_mini_doc_url(base, did.as_str())?;
666
+
let (buf, status) = self.get_json_bytes(url).await?;
667
+
return Ok(DidDocResponse {
668
+
buffer: buf,
669
+
status,
670
+
requested: Some(did.clone().into_static()),
671
+
});
672
+
}
673
+
}
674
+
_ => {}
675
+
}
676
+
}
677
+
Err(IdentityError::UnsupportedDidMethod(s.to_string()))
678
+
}
679
+
}
680
+
681
+
/// Warnings produced during identity checks that are not fatal
682
+
#[derive(Debug, Clone, PartialEq, Eq)]
683
+
pub enum IdentityWarning {
684
+
/// The DID doc did not contain the expected handle alias under alsoKnownAs
685
+
HandleAliasMismatch { expected: Handle<'static> },
686
+
}
687
+
688
+
impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> {
689
+
/// Resolve a handle to its DID, fetch the DID document, and return doc plus any warnings.
690
+
/// This applies the default equality check on the document id (error with doc if mismatch).
691
+
pub async fn resolve_handle_and_doc(
692
+
&self,
693
+
handle: &Handle<'_>,
694
+
) -> Result<(Did<'static>, DidDocResponse, Vec<IdentityWarning>), IdentityError> {
695
+
let did = self.resolve_handle(handle).await?;
696
+
let resp = self.resolve_did_doc(&did).await?;
697
+
let resp_for_parse = resp.clone();
698
+
let doc_borrowed = resp_for_parse.parse()?;
699
+
if self.opts.validate_doc_id && doc_borrowed.id.as_str() != did.as_str() {
700
+
return Err(IdentityError::DocIdMismatch {
701
+
expected: did.clone().into_static(),
702
+
doc: doc_borrowed.clone().into_static(),
703
+
});
704
+
}
705
+
let mut warnings = Vec::new();
706
+
// Check handle alias presence (soft warning)
707
+
let expected_alias = format!("at://{}", handle.as_str());
708
+
let has_alias = doc_borrowed
709
+
.also_known_as
710
+
.as_ref()
711
+
.map(|v| v.iter().any(|s| s.as_ref() == expected_alias))
712
+
.unwrap_or(false);
713
+
if !has_alias {
714
+
warnings.push(IdentityWarning::HandleAliasMismatch {
715
+
expected: handle.clone().into_static(),
716
+
});
717
+
}
718
+
Ok((did, resp, warnings))
719
+
}
720
+
721
+
/// Build Slingshot mini-doc URL for an identifier (handle or DID)
722
+
fn slingshot_mini_doc_url(&self, base: &Url, identifier: &str) -> Result<Url, IdentityError> {
723
+
let mut url = base.clone();
724
+
url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc");
725
+
url.set_query(Some(&format!(
726
+
"identifier={}",
727
+
urlencoding::Encoded::new(identifier)
728
+
)));
729
+
Ok(url)
730
+
}
731
+
732
+
/// Fetch a minimal DID document via Slingshot's mini-doc endpoint using a generic at-identifier
733
+
pub async fn fetch_mini_doc_via_slingshot_identifier(
734
+
&self,
735
+
identifier: &AtIdentifier<'_>,
736
+
) -> Result<MiniDocResponse, IdentityError> {
737
+
let base = match &self.opts.plc_source {
738
+
PlcSource::Slingshot { base } => base.clone(),
739
+
_ => {
740
+
return Err(IdentityError::UnsupportedDidMethod(
741
+
"mini-doc requires Slingshot source".into(),
742
+
));
743
+
}
744
+
};
745
+
let url = self.slingshot_mini_doc_url(&base, identifier.as_str())?;
746
+
let (buf, status) = self.get_json_bytes(url).await?;
747
+
Ok(MiniDocResponse {
748
+
buffer: buf,
749
+
status,
750
+
})
751
+
}
752
+
}
753
+
754
+
/// Slingshot mini-doc JSON response wrapper
755
+
#[derive(Clone)]
756
+
pub struct MiniDocResponse {
757
+
buffer: Bytes,
758
+
status: StatusCode,
759
+
}
760
+
761
+
impl MiniDocResponse {
762
+
/// Parse borrowed MiniDoc
763
+
pub fn parse<'b>(&'b self) -> Result<MiniDoc<'b>, IdentityError> {
764
+
if self.status.is_success() {
765
+
serde_json::from_slice::<MiniDoc<'b>>(&self.buffer).map_err(IdentityError::from)
766
+
} else {
767
+
Err(IdentityError::HttpStatus(self.status))
768
+
}
769
+
}
770
+
}
771
+
772
+
/// Slingshot mini-doc data (subset of DID doc info)
773
+
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
774
+
#[serde(rename_all = "camelCase")]
775
+
pub struct MiniDoc<'a> {
776
+
#[serde(borrow)]
777
+
pub did: Did<'a>,
778
+
#[serde(borrow)]
779
+
pub handle: Handle<'a>,
780
+
#[serde(borrow)]
781
+
pub pds: crate::CowStr<'a>,
782
+
#[serde(borrow, rename = "signingKey", alias = "signing_key")]
783
+
pub signing_key: crate::CowStr<'a>,
784
+
}
785
+
786
+
#[cfg(test)]
787
+
mod tests {
788
+
use super::*;
789
+
790
+
#[test]
791
+
fn did_web_urls() {
792
+
let r = DefaultResolver::new(
793
+
reqwest::Client::new(),
794
+
TestXrpc::new(),
795
+
ResolverOptions::default(),
796
+
);
797
+
assert_eq!(
798
+
r.test_did_web_url_raw("did:web:example.com"),
799
+
"https://example.com/.well-known/did.json"
800
+
);
801
+
assert_eq!(
802
+
r.test_did_web_url_raw("did:web:example.com:user:alice"),
803
+
"https://example.com/user/alice/did.json"
804
+
);
805
+
}
806
+
807
+
#[test]
808
+
fn parse_validated_ok() {
809
+
let buf = Bytes::from_static(br#"{"id":"did:plc:alice"}"#);
810
+
let requested = Did::new_owned("did:plc:alice").unwrap();
811
+
let resp = DidDocResponse {
812
+
buffer: buf,
813
+
status: StatusCode::OK,
814
+
requested: Some(requested),
815
+
};
816
+
let _doc = resp.parse_validated().expect("valid");
817
+
}
818
+
819
+
#[test]
820
+
fn parse_validated_mismatch() {
821
+
let buf = Bytes::from_static(br#"{"id":"did:plc:bob"}"#);
822
+
let requested = Did::new_owned("did:plc:alice").unwrap();
823
+
let resp = DidDocResponse {
824
+
buffer: buf,
825
+
status: StatusCode::OK,
826
+
requested: Some(requested),
827
+
};
828
+
match resp.parse_validated() {
829
+
Err(IdentityError::DocIdMismatch { expected, doc }) => {
830
+
assert_eq!(expected.as_str(), "did:plc:alice");
831
+
assert_eq!(doc.id.as_str(), "did:plc:bob");
832
+
}
833
+
other => panic!("unexpected result: {:?}", other),
834
+
}
835
+
}
836
+
837
+
#[test]
838
+
fn slingshot_mini_doc_url_build() {
839
+
let r = DefaultResolver::new(
840
+
reqwest::Client::new(),
841
+
TestXrpc::new(),
842
+
ResolverOptions::default(),
843
+
);
844
+
let base = Url::parse("https://slingshot.microcosm.blue").unwrap();
845
+
let url = r.slingshot_mini_doc_url(&base, "bad-example.com").unwrap();
846
+
assert_eq!(
847
+
url.as_str(),
848
+
"https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=bad-example.com"
849
+
);
850
+
}
851
+
852
+
#[test]
853
+
fn slingshot_mini_doc_parse_success() {
854
+
let buf = Bytes::from_static(
855
+
br#"{
856
+
"did": "did:plc:hdhoaan3xa3jiuq4fg4mefid",
857
+
"handle": "bad-example.com",
858
+
"pds": "https://porcini.us-east.host.bsky.network",
859
+
"signing_key": "zQ3shpq1g134o7HGDb86CtQFxnHqzx5pZWknrVX2Waum3fF6j"
860
+
}"#,
861
+
);
862
+
let resp = MiniDocResponse {
863
+
buffer: buf,
864
+
status: StatusCode::OK,
865
+
};
866
+
let doc = resp.parse().expect("parse mini-doc");
867
+
assert_eq!(doc.did.as_str(), "did:plc:hdhoaan3xa3jiuq4fg4mefid");
868
+
assert_eq!(doc.handle.as_str(), "bad-example.com");
869
+
assert_eq!(
870
+
doc.pds.as_ref(),
871
+
"https://porcini.us-east.host.bsky.network"
872
+
);
873
+
assert!(doc.signing_key.as_ref().starts_with('z'));
874
+
}
875
+
876
+
#[test]
877
+
fn slingshot_mini_doc_parse_error_status() {
878
+
let buf = Bytes::from_static(
879
+
br#"{
880
+
"error": "RecordNotFound",
881
+
"message": "This record was deleted"
882
+
}"#,
883
+
);
884
+
let resp = MiniDocResponse {
885
+
buffer: buf,
886
+
status: StatusCode::BAD_REQUEST,
887
+
};
888
+
match resp.parse() {
889
+
Err(IdentityError::HttpStatus(s)) => assert_eq!(s, StatusCode::BAD_REQUEST),
890
+
other => panic!("unexpected: {:?}", other),
891
+
}
892
+
}
893
+
use crate::client::{HttpClient, XrpcClient};
894
+
use http::Request;
895
+
use jacquard_common::CowStr;
896
+
897
+
struct TestXrpc {
898
+
client: reqwest::Client,
899
+
}
900
+
impl TestXrpc {
901
+
fn new() -> Self {
902
+
Self {
903
+
client: reqwest::Client::new(),
904
+
}
905
+
}
906
+
}
907
+
impl HttpClient for TestXrpc {
908
+
type Error = reqwest::Error;
909
+
async fn send_http(
910
+
&self,
911
+
request: Request<Vec<u8>>,
912
+
) -> Result<http::Response<Vec<u8>>, Self::Error> {
913
+
self.client.send_http(request).await
914
+
}
915
+
}
916
+
impl XrpcClient for TestXrpc {
917
+
fn base_uri(&self) -> CowStr<'_> {
918
+
CowStr::from("https://public.api.bsky.app")
919
+
}
920
+
}
921
+
}
922
+
923
+
/// Resolver specialized for unauthenticated/public flows using reqwest + AuthenticatedClient
924
+
pub type PublicResolver = DefaultResolver<AuthenticatedClient<reqwest::Client>>;
925
+
926
+
impl Default for PublicResolver {
927
+
/// Build a resolver with:
928
+
/// - reqwest HTTP client
929
+
/// - XRPC base https://public.api.bsky.app (unauthenticated)
930
+
/// - default options (DNS enabled if compiled, public fallback for handles enabled)
931
+
///
932
+
/// Example
933
+
/// ```ignore
934
+
/// use jacquard::identity::resolver::PublicResolver;
935
+
/// let resolver = PublicResolver::default();
936
+
/// ```
937
+
fn default() -> Self {
938
+
let http = reqwest::Client::new();
939
+
let xrpc =
940
+
AuthenticatedClient::new(http.clone(), CowStr::from("https://public.api.bsky.app"));
941
+
let opts = ResolverOptions::default();
942
+
let resolver = DefaultResolver::new(http, xrpc, opts);
943
+
#[cfg(feature = "dns")]
944
+
let resolver = resolver.with_system_dns();
945
+
resolver
946
+
}
947
+
}
948
+
949
+
/// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and
950
+
/// mini-doc fallbacks, unauthenticated by default.
951
+
pub fn slingshot_resolver_default() -> PublicResolver {
952
+
let http = reqwest::Client::new();
953
+
let xrpc = AuthenticatedClient::new(http.clone(), CowStr::from("https://public.api.bsky.app"));
954
+
let mut opts = ResolverOptions::default();
955
+
opts.plc_source = PlcSource::slingshot_default();
956
+
let resolver = DefaultResolver::new(http, xrpc, opts);
957
+
#[cfg(feature = "dns")]
958
+
let resolver = resolver.with_system_dns();
959
+
resolver
960
+
}
+4
-1
crates/jacquard/src/lib.rs
+4
-1
crates/jacquard/src/lib.rs
···
19
19
//!
20
20
//! Dead simple api client. Logs in, prints the latest 5 posts from your timeline.
21
21
//!
22
-
//! ```rust
22
+
//! ```no_run
23
23
//! # use clap::Parser;
24
24
//! # use jacquard::CowStr;
25
25
//! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
···
100
100
#[cfg(feature = "derive")]
101
101
/// if enabled, reexport the attribute macros
102
102
pub use jacquard_derive::*;
103
+
104
+
/// Identity resolution helpers (DIDs, handles, PDS endpoints)
105
+
pub mod identity;
+14
docs/identity.md
+14
docs/identity.md
···
1
+
# Identity Resolution
2
+
3
+
This module provides helpers for resolving AT Protocol identifiers (handles and DIDs) and fetching DID documents.
4
+
5
+
Highlights:
6
+
7
+
- DNS TXT (`_atproto.<handle>`) first when compiled with the `dns` feature, then HTTPS well-known, then Slingshot `resolveHandle` when configured as PLC source.
8
+
- DID resolution via did:web well-known or PLC base (PLC Directory or Slingshot), returning a `DidDocResponse` that supports borrowed parsing and validation.
9
+
- Validation: convenience helpers validate that the fetched DID document `id` matches the requested DID (default on). On mismatch, a `DocIdMismatch` error includes the fetched document for callers to inspect.
10
+
- Slingshot: supports unauthenticated `resolveHandle` and a minimal-document endpoint (`com.bad-example.identity.resolveMiniDoc`).
11
+
- Auth-aware fallbacks: PDS `resolveHandle` / `resolveDid` available via helpers that accept an `XrpcClient`.
12
+
13
+
See `jacquard::identity::resolver` rustdoc for examples.
14
+