A better Rust ATProto crate

did doc and handle/did/doc resolution

Orual ea4ed671 a2184d0b

Changed files
+2153 -404
crates
jacquard
jacquard-api
jacquard-common
jacquard-derive
jacquard-lexicon
docs
+2
.gitignore
··· 5 5 .claude 6 6 /.pre-commit-config.yaml 7 7 CLAUDE.md 8 + AGENTS.md 8 9 crates/jacquard-lexicon/tests/fixtures/lexicons/atproto 9 10 crates/jacquard-lexicon/target 11 + codegen_plan.md
+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
··· 13 13 readme = "README.md" 14 14 exclude = [".direnv"] 15 15 homepage = "https://tangled.org/@nonbinary.computer/jacquard" 16 - license-file = "LICENSE" 16 + license = "MPL-2.0" 17 17 18 18 description = "Simple and powerful AT Protocol client library for Rust" 19 19
-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
··· 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 = [ "com_atproto"]
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 [lib] 15 15 proc-macro = true
+1 -1
crates/jacquard-lexicon/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 [[bin]] 15 15 name = "jacquard-codegen"
+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
··· 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
··· 1 + //! Identity resolution utilities: DID and handle resolution, DID document fetch, 2 + //! and helpers for PDS endpoint discovery. See `identity::resolver` for details. 3 + pub mod resolver;
+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
··· 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
··· 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 +