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
···
.claude
/.pre-commit-config.yaml
CLAUDE.md
+
AGENTS.md
crates/jacquard-lexicon/tests/fixtures/lexicons/atproto
crates/jacquard-lexicon/target
+
codegen_plan.md
+518 -9
Cargo.lock
···
]
[[package]]
+
name = "async-trait"
+
version = "0.1.89"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.106",
+
]
+
+
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270"
+
+
[[package]]
+
name = "base16ct"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64"
···
checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb"
[[package]]
+
name = "const-oid"
+
version = "0.9.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
+
name = "crypto-bigint"
+
version = "0.5.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+
dependencies = [
+
"generic-array",
+
"rand_core 0.6.4",
+
"subtle",
+
"zeroize",
+
]
+
+
[[package]]
name = "crypto-common"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
+
name = "curve25519-dalek"
+
version = "4.1.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+
dependencies = [
+
"cfg-if",
+
"cpufeatures",
+
"curve25519-dalek-derive",
+
"digest",
+
"fiat-crypto",
+
"rustc_version",
+
"subtle",
+
]
+
+
[[package]]
+
name = "curve25519-dalek-derive"
+
version = "0.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.106",
+
]
+
+
[[package]]
name = "darling"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
+
name = "der"
+
version = "0.7.10"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+
dependencies = [
+
"const-oid",
+
"zeroize",
+
]
+
+
[[package]]
name = "deranged"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
+
name = "ed25519"
+
version = "2.2.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+
dependencies = [
+
"pkcs8",
+
"signature",
+
]
+
+
[[package]]
+
name = "ed25519-dalek"
+
version = "2.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+
dependencies = [
+
"curve25519-dalek",
+
"ed25519",
+
"sha2",
+
"subtle",
+
]
+
+
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
+
name = "elliptic-curve"
+
version = "0.13.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+
dependencies = [
+
"base16ct",
+
"crypto-bigint",
+
"ff",
+
"generic-array",
+
"group",
+
"rand_core 0.6.4",
+
"sec1",
+
"subtle",
+
"zeroize",
+
]
+
+
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
+
name = "enum-as-inner"
+
version = "0.6.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
+
dependencies = [
+
"heck 0.5.0",
+
"proc-macro2",
+
"quote",
+
"syn 2.0.106",
+
]
+
+
[[package]]
name = "enum_dispatch"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
+
name = "ff"
+
version = "0.13.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+
dependencies = [
+
"rand_core 0.6.4",
+
"subtle",
+
]
+
+
[[package]]
+
name = "fiat-crypto"
+
version = "0.2.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
+
[[package]]
name = "find-msvc-tools"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
+
name = "futures-io"
+
version = "0.3.31"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
+
+
[[package]]
name = "futures-sink"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
"futures-task",
"pin-project-lite",
"pin-utils",
+
"slab",
]
[[package]]
···
dependencies = [
"typenum",
"version_check",
+
"zeroize",
]
[[package]]
···
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]]
+
name = "group"
+
version = "0.13.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+
dependencies = [
+
"ff",
+
"rand_core 0.6.4",
+
"subtle",
+
]
+
+
[[package]]
name = "h2"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "b07f60793ff0a4d9cef0f18e63b5357e06209987153a64648c972c1e5aff336f"
[[package]]
+
name = "hickory-proto"
+
version = "0.24.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248"
+
dependencies = [
+
"async-trait",
+
"cfg-if",
+
"data-encoding",
+
"enum-as-inner",
+
"futures-channel",
+
"futures-io",
+
"futures-util",
+
"idna",
+
"ipnet",
+
"once_cell",
+
"rand 0.8.5",
+
"thiserror 1.0.69",
+
"tinyvec",
+
"tokio",
+
"tracing",
+
"url",
+
]
+
+
[[package]]
+
name = "hickory-resolver"
+
version = "0.24.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e"
+
dependencies = [
+
"cfg-if",
+
"futures-util",
+
"hickory-proto",
+
"ipconfig",
+
"lru-cache",
+
"once_cell",
+
"parking_lot",
+
"rand 0.8.5",
+
"resolv-conf",
+
"smallvec",
+
"thiserror 1.0.69",
+
"tokio",
+
"tracing",
+
]
+
+
[[package]]
name = "http"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
"libc",
"percent-encoding",
"pin-project-lite",
-
"socket2",
+
"socket2 0.6.0",
"system-configuration",
"tokio",
"tower-service",
···
[[package]]
+
name = "ipconfig"
+
version = "0.3.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f"
+
dependencies = [
+
"socket2 0.5.10",
+
"widestring",
+
"windows-sys 0.48.0",
+
"winreg",
+
]
+
+
[[package]]
name = "ipld-core"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
name = "jacquard"
version = "0.1.0"
dependencies = [
+
"async-trait",
+
"bon",
"bytes",
"clap",
+
"hickory-resolver",
"http",
"jacquard-api",
"jacquard-common",
"jacquard-derive",
"miette",
+
"percent-encoding",
"reqwest",
"serde",
"serde_html_form",
"serde_ipld_dagcbor",
"serde_json",
+
"smol_str",
"thiserror 2.0.17",
"tokio",
+
"url",
+
"urlencoding",
[[package]]
···
version = "0.1.0"
dependencies = [
"base64",
+
"bon",
"bytes",
"chrono",
"cid",
+
"ed25519-dalek",
"enum_dispatch",
"ipld-core",
+
"k256",
"langtag",
"miette",
"multibase",
"multihash",
"num-traits",
"ouroboros",
-
"rand",
+
"p256",
+
"rand 0.9.2",
"regex",
"serde",
"serde_html_form",
···
[[package]]
+
name = "k256"
+
version = "0.13.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
+
dependencies = [
+
"cfg-if",
+
"elliptic-curve",
+
]
+
+
[[package]]
name = "langtag"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174"
[[package]]
+
name = "linked-hash-map"
+
version = "0.5.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
+
+
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
[[package]]
+
name = "lock_api"
+
version = "0.4.14"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+
dependencies = [
+
"scopeguard",
+
]
+
+
[[package]]
name = "log"
version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
+
+
[[package]]
+
name = "lru-cache"
+
version = "0.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c"
+
dependencies = [
+
"linked-hash-map",
+
]
[[package]]
name = "lru-slab"
···
checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e"
[[package]]
+
name = "p256"
+
version = "0.13.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+
dependencies = [
+
"elliptic-curve",
+
"primeorder",
+
]
+
+
[[package]]
+
name = "parking_lot"
+
version = "0.12.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+
dependencies = [
+
"lock_api",
+
"parking_lot_core",
+
]
+
+
[[package]]
+
name = "parking_lot_core"
+
version = "0.9.12"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+
dependencies = [
+
"cfg-if",
+
"libc",
+
"redox_syscall",
+
"smallvec",
+
"windows-link 0.2.0",
+
]
+
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
+
name = "pkcs8"
+
version = "0.10.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+
dependencies = [
+
"der",
+
"spki",
+
]
+
+
[[package]]
name = "potential_utf"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
+
name = "primeorder"
+
version = "0.13.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+
dependencies = [
+
"elliptic-curve",
+
]
+
+
[[package]]
name = "proc-macro-error"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
"quinn-udp",
"rustc-hash",
"rustls",
-
"socket2",
+
"socket2 0.6.0",
"thiserror 2.0.17",
"tokio",
"tracing",
···
"bytes",
"getrandom 0.3.3",
"lru-slab",
-
"rand",
+
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
···
"cfg_aliases",
"libc",
"once_cell",
-
"socket2",
+
"socket2 0.6.0",
"tracing",
"windows-sys 0.60.2",
···
[[package]]
name = "rand"
+
version = "0.8.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+
dependencies = [
+
"libc",
+
"rand_chacha 0.3.1",
+
"rand_core 0.6.4",
+
]
+
+
[[package]]
+
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
-
"rand_chacha",
-
"rand_core",
+
"rand_chacha 0.9.0",
+
"rand_core 0.9.3",
+
]
+
+
[[package]]
+
name = "rand_chacha"
+
version = "0.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+
dependencies = [
+
"ppv-lite86",
+
"rand_core 0.6.4",
[[package]]
···
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
-
"rand_core",
+
"rand_core 0.9.3",
+
]
+
+
[[package]]
+
name = "rand_core"
+
version = "0.6.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+
dependencies = [
+
"getrandom 0.2.16",
[[package]]
···
checksum = "d20581732dd76fa913c7dff1a2412b714afe3573e94d41c34719de73337cc8ab"
[[package]]
+
name = "redox_syscall"
+
version = "0.5.18"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+
dependencies = [
+
"bitflags",
+
]
+
+
[[package]]
name = "ref-cast"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
+
name = "resolv-conf"
+
version = "0.7.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799"
+
+
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
+
name = "rustc_version"
+
version = "0.4.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+
dependencies = [
+
"semver",
+
]
+
+
[[package]]
name = "rustix"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
+
name = "sec1"
+
version = "0.7.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+
dependencies = [
+
"base16ct",
+
"der",
+
"generic-array",
+
"subtle",
+
"zeroize",
+
]
+
+
[[package]]
+
name = "semver"
+
version = "1.0.27"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
+
+
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
+
name = "signature"
+
version = "2.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+
+
[[package]]
name = "slab"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
name = "socket2"
+
version = "0.5.10"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
+
dependencies = [
+
"libc",
+
"windows-sys 0.52.0",
+
]
+
+
[[package]]
+
name = "socket2"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807"
dependencies = [
"libc",
"windows-sys 0.59.0",
+
]
+
+
[[package]]
+
name = "spki"
+
version = "0.7.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+
dependencies = [
+
"der",
[[package]]
···
"mio",
"pin-project-lite",
"slab",
-
"socket2",
+
"socket2 0.6.0",
"tokio-macros",
"windows-sys 0.59.0",
···
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
"pin-project-lite",
+
"tracing-attributes",
"tracing-core",
[[package]]
+
name = "tracing-attributes"
+
version = "0.1.30"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.106",
+
]
+
+
[[package]]
name = "tracing-core"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
"percent-encoding",
"serde",
+
+
[[package]]
+
name = "urlencoding"
+
version = "2.1.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf8_iter"
···
[[package]]
+
name = "widestring"
+
version = "1.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d"
+
+
[[package]]
name = "windows-core"
version = "0.62.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
name = "windows-sys"
+
version = "0.48.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+
dependencies = [
+
"windows-targets 0.48.5",
+
]
+
+
[[package]]
+
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
···
[[package]]
name = "windows-targets"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+
dependencies = [
+
"windows_aarch64_gnullvm 0.48.5",
+
"windows_aarch64_msvc 0.48.5",
+
"windows_i686_gnu 0.48.5",
+
"windows_i686_msvc 0.48.5",
+
"windows_x86_64_gnu 0.48.5",
+
"windows_x86_64_gnullvm 0.48.5",
+
"windows_x86_64_msvc 0.48.5",
+
]
+
+
[[package]]
+
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
···
[[package]]
name = "windows_aarch64_gnullvm"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+
[[package]]
+
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
···
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
+
+
[[package]]
+
name = "windows_aarch64_msvc"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
···
[[package]]
name = "windows_i686_gnu"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+
[[package]]
+
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
···
[[package]]
name = "windows_i686_msvc"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+
[[package]]
+
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
···
[[package]]
name = "windows_x86_64_gnu"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+
[[package]]
+
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
···
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
+
+
[[package]]
+
name = "windows_x86_64_gnullvm"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
···
[[package]]
name = "windows_x86_64_msvc"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+
[[package]]
+
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
···
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
+
+
[[package]]
+
name = "winreg"
+
version = "0.50.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
+
dependencies = [
+
"cfg-if",
+
"windows-sys 0.48.0",
+
]
[[package]]
name = "wit-bindgen"
+1 -1
Cargo.toml
···
readme = "README.md"
exclude = [".direnv"]
homepage = "https://tangled.org/@nonbinary.computer/jacquard"
-
license-file = "LICENSE"
+
license = "MPL-2.0"
description = "Simple and powerful AT Protocol client library for Rust"
-378
codegen_plan.md
···
-
# Lexicon Codegen Plan
-
-
## Goal
-
Generate idiomatic Rust types from AT Protocol lexicon schemas with minimal nesting/indirection.
-
-
## Existing Infrastructure
-
-
### Already Implemented
-
- **lexicon.rs**: Complete lexicon parsing types (`LexiconDoc`, `LexUserType`, `LexObject`, etc)
-
- **fs.rs**: Directory walking for finding `.json` lexicon files
-
- **schema.rs**: `find_ref_unions()` - collects union fields from a single lexicon
-
- **output.rs**: Partial - has string type mapping and doc comment generation
-
-
### Attribute Macros
-
- `#[lexicon]` - adds `extra_data` field to structs
-
- `#[open_union]` - adds `Unknown(Data<'s>)` variant to enums
-
-
## Design Decisions
-
-
### Module/File Structure
-
- NSID `app.bsky.feed.post` → `app_bsky/feed/post.rs`
-
- Flat module names (no `app::bsky`, just `app_bsky`)
-
- Parent modules: `app_bsky/feed.rs` with `pub mod post;`
-
-
### Type Naming
-
- **Main def**: Use last segment of NSID
-
- `app.bsky.feed.post#main` → `Post`
-
- **Other defs**: Pascal-case the def name
-
- `replyRef` → `ReplyRef`
-
- **Union variants**: Use last segment of ref NSID
-
- `app.bsky.embed.images` → `Images`
-
- Collisions resolved by module path, not type name
-
- **No proliferation of `Main` types** like atrium has
-
-
### Type Generation
-
-
#### Records (lexRecord)
-
```rust
-
#[lexicon]
-
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
-
#[serde(rename_all = "camelCase")]
-
pub struct Post<'s> {
-
/// Client-declared timestamp...
-
pub created_at: Datetime,
-
#[serde(skip_serializing_if = "Option::is_none")]
-
pub embed: Option<RecordEmbed<'s>>,
-
pub text: CowStr<'s>,
-
}
-
```
-
-
#### Objects (lexObject)
-
Same as records but without `#[lexicon]` if inline/not a top-level def.
-
-
#### Unions (lexRefUnion)
-
```rust
-
#[open_union]
-
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
-
#[serde(tag = "$type")]
-
pub enum RecordEmbed<'s> {
-
#[serde(rename = "app.bsky.embed.images")]
-
Images(Box<jacquard_api::app_bsky::embed::Images<'s>>),
-
#[serde(rename = "app.bsky.embed.video")]
-
Video(Box<jacquard_api::app_bsky::embed::Video<'s>>),
-
}
-
```
-
-
- Use `Box<T>` for all variants (handles circular refs)
-
- `#[open_union]` adds `Unknown(Data<'s>)` catch-all
-
-
#### Queries (lexXrpcQuery)
-
```rust
-
pub struct GetAuthorFeedParams<'s> {
-
pub actor: AtIdentifier<'s>,
-
pub limit: Option<i64>,
-
pub cursor: Option<CowStr<'s>>,
-
}
-
-
pub struct GetAuthorFeedOutput<'s> {
-
pub cursor: Option<CowStr<'s>>,
-
pub feed: Vec<FeedViewPost<'s>>,
-
}
-
```
-
-
- Flat params/output structs
-
- No nesting like `Input { params: {...} }`
-
-
#### Procedures (lexXrpcProcedure)
-
Same as queries but with both `Input` and `Output` structs.
-
-
### Field Handling
-
-
#### Optional Fields
-
- Fields not in `required: []` → `Option<T>`
-
- Add `#[serde(skip_serializing_if = "Option::is_none")]`
-
-
#### Lifetimes
-
- All types have `'a` lifetime for borrowing from input
-
- `#[serde(borrow)]` where needed for zero-copy
-
-
#### Type Mapping
-
- `LexString` with format → specific types (`Datetime`, `Did`, etc)
-
- `LexString` without format → `CowStr<'a>`
-
- `LexInteger` → `i64`
-
- `LexBoolean` → `bool`
-
- `LexBytes` → `Bytes`
-
- `LexCidLink` → `CidLink<'a>`
-
- `LexBlob` → `Blob<'a>`
-
- `LexRef` → resolve to actual type path
-
- `LexRefUnion` → generate enum
-
- `LexArray` → `Vec<T>`
-
- `LexUnknown` → `Data<'a>`
-
-
### Reference Resolution
-
-
#### Known Refs
-
- Check corpus for ref existence
-
- `#ref: "app.bsky.embed.images"` → `jacquard_api::app_bsky::embed::Images<'a>`
-
- Handle fragments: `#ref: "com.example.foo#bar"` → `jacquard_api::com_example::foo::Bar<'a>`
-
-
#### Unknown Refs
-
- **In struct fields**: use `Data<'a>` as fallback type
-
- **In union variants**: handled by `Unknown(Data<'a>)` variant from `#[open_union]`
-
- Optional: log warnings for missing refs
-
-
## Implementation Phases
-
-
### Phase 1: Corpus Loading & Registry
-
**Goal**: Load all lexicons into memory for ref resolution
-
-
**Tasks**:
-
1. Create `LexiconCorpus` struct
-
- `BTreeMap<SmolStr, LexiconDoc<'static>>` - NSID → doc
-
- Methods: `load_from_dir()`, `get()`, `resolve_ref()`
-
2. Load all `.json` files from lexicon directory
-
3. Parse into `LexiconDoc` and insert into registry
-
4. Handle fragments in refs (`nsid#def`)
-
-
**Output**: Corpus registry that can resolve any ref
-
-
### Phase 2: Ref Analysis & Union Collection
-
**Goal**: Build complete picture of what refs exist and what unions need
-
-
**Tasks**:
-
1. Extend `find_ref_unions()` to work across entire corpus
-
2. For each union, collect all refs and check existence
-
3. Build `UnionRegistry`:
-
- Union name → list of (known refs, unknown refs)
-
4. Detect circular refs (optional - or just Box everything)
-
-
**Output**: Complete list of unions to generate with their variants
-
-
### Phase 3: Code Generation - Core Types
-
**Goal**: Generate Rust code for individual types
-
-
**Tasks**:
-
1. Implement type generators:
-
- `generate_struct()` for records/objects
-
- `generate_enum()` for unions
-
- `generate_field()` for object properties
-
- `generate_type()` for primitives/refs
-
2. Handle optional fields (`required` list)
-
3. Add doc comments from `description`
-
4. Apply `#[lexicon]` / `#[open_union]` macros
-
5. Add serde attributes
-
-
**Output**: `TokenStream` for each type
-
-
### Phase 4: Module Organization
-
**Goal**: Organize generated types into module hierarchy
-
-
**Tasks**:
-
1. Parse NSID into components: `["app", "bsky", "feed", "post"]`
-
2. Determine file paths: `app_bsky/feed/post.rs`
-
3. Generate module files: `app_bsky/feed.rs` with `pub mod post;`
-
4. Generate root module: `app_bsky.rs`
-
5. Handle re-exports if needed
-
-
**Output**: File path → generated code mapping
-
-
### Phase 5: File Writing
-
**Goal**: Write generated code to filesystem
-
-
**Tasks**:
-
1. Format code with `prettyplease`
-
2. Create directory structure
-
3. Write module files
-
4. Write type files
-
5. Optional: run `rustfmt`
-
-
**Output**: Generated code on disk
-
-
### Phase 6: Testing & Validation
-
**Goal**: Ensure generated code compiles and works
-
-
**Tasks**:
-
1. Generate code for test lexicons
-
2. Compile generated code
-
3. Test serialization/deserialization
-
4. Test union variant matching
-
5. Test extra_data capture
-
-
## Edge Cases & Considerations
-
-
### Circular References
-
- **Simple approach**: Union variants always use `Box<T>` → handles all circular refs
-
- **Alternative**: DFS cycle detection to only Box when needed
-
- Track visited refs and recursion stack
-
- If ref appears in rec_stack → cycle detected
-
- Algorithm:
-
```rust
-
fn has_cycle(corpus, start_ref, visited, rec_stack) -> bool {
-
visited.insert(start_ref);
-
rec_stack.insert(start_ref);
-
-
for child_ref in collect_refs_from_def(resolve(start_ref)) {
-
if !visited.contains(child_ref) {
-
if has_cycle(corpus, child_ref, visited, rec_stack) {
-
return true;
-
}
-
} else if rec_stack.contains(child_ref) {
-
return true; // back edge = cycle
-
}
-
}
-
-
rec_stack.remove(start_ref);
-
false
-
}
-
```
-
- Only box variants that participate in cycles
-
- **Recommendation**: Start with simple (always Box), optimize later if needed
-
-
### Name Collisions
-
- Multiple types with same name in different lexicons
-
- Module path disambiguates: `app_bsky::feed::Post` vs `com_example::feed::Post`
-
-
### Unknown Refs
-
- Fallback to `Data<'s>` in struct fields
-
- Caught by `Unknown` variant in unions
-
- Warn during generation
-
-
### Inline Defs
-
- Nested objects/unions in same lexicon
-
- Generate as separate types in same file
-
- Keep names scoped to parent (e.g., `PostReplyRef`)
-
-
### Arrays
-
- `Vec<T>` for arrays
-
- Handle nested unions in arrays
-
-
### Tokens
-
- Simple marker types
-
- Generate as unit structs or type aliases?
-
-
## Traits for Generated Types
-
-
### Collection Trait (Records)
-
Records implement the existing `Collection` trait from jacquard-common:
-
-
```rust
-
pub struct Post<'a> {
-
// ... fields
-
}
-
-
impl Collection for Post<'p> {
-
const NSID: &'static str = "app.bsky.feed.post";
-
type Record = Post<'p>;
-
}
-
```
-
-
### XrpcRequest Trait (Queries/Procedures)
-
New trait for XRPC endpoints:
-
-
```rust
-
pub trait XrpcRequest<'x> {
-
/// The NSID for this XRPC method
-
const NSID: &'static str;
-
-
/// XRPC method (query/GET, procedure/POST)
-
const METHOD: XrpcMethod;
-
-
/// Input encoding (MIME type, e.g., "application/json")
-
/// None for queries (no body)
-
const INPUT_ENCODING: Option<&'static str>;
-
-
/// Output encoding (MIME type)
-
const OUTPUT_ENCODING: &'static str;
-
-
/// Request parameters type (query params or body)
-
type Params: Serialize;
-
-
/// Response output type
-
type Output: Deserialize<'x>;
-
-
type Err: Error;
-
}
-
-
pub enum XrpcMethod {
-
Query, // GET
-
Procedure, // POST
-
}
-
```
-
-
-
-
**Generated implementation:**
-
```rust
-
pub struct GetAuthorFeedParams<'a> {
-
pub actor: AtIdentifier<'a>,
-
pub limit: Option<i64>,
-
pub cursor: Option<CowStr<'a>>,
-
}
-
-
pub struct GetAuthorFeedOutput<'a> {
-
pub cursor: Option<CowStr<'a>>,
-
pub feed: Vec<FeedViewPost<'a>>,
-
}
-
-
impl XrpcRequest for GetAuthorFeedParams<'_> {
-
const NSID: &'static str = "app.bsky.feed.getAuthorFeed";
-
const METHOD: XrpcMethod = XrpcMethod::Query;
-
const INPUT_ENCODING: Option<&'static str> = None; // queries have no body
-
const OUTPUT_ENCODING: &'static str = "application/json";
-
-
type Params = Self;
-
type Output = GetAuthorFeedOutput<'static>;
-
type Err = GetAuthorFeedError;
-
}
-
```
-
-
**Encoding variations:**
-
- Most procedures: `"application/json"` for input/output
-
- Blob uploads: `"*/*"` or specific MIME type for input
-
- CAR files: `"application/vnd.ipld.car"` for repo operations
-
- Read from lexicon's `input.encoding` and `output.encoding` fields
-
-
**Trait benefits:**
-
- Allows monomorphization (static dispatch) for performance
-
- Also supports `dyn XrpcRequest` for dynamic dispatch if needed
-
- Client code can be generic over `impl XrpcRequest`
-
-
-
#### XRPC Errors
-
Lexicons contain information on the kind of errors they can return.
-
Trait contains an associated error type. Error enum with thiserror::Error and
-
miette:Diagnostic derives and appropriate content generated based on lexicon info.
-
-
### Subscriptions
-
WebSocket streams - defer for now. Will need separate trait with message types.
-
-
## Open Questions
-
-
1. **Validation**: Generate runtime validation (min/max length, regex, etc)?
-
2. **Tokens**: How to represent token types?
-
3. **Errors**: How to handle codegen errors (missing refs, invalid schemas)?
-
4. **Incremental**: Support incremental codegen (only changed lexicons)?
-
5. **Formatting**: Always run rustfmt or rely on prettyplease?
-
6. **XrpcRequest location**: Should trait live in jacquard-common or separate jacquard-xrpc crate?
-
7. **Import shortening**: Track imports and shorten ref paths in generated code
-
- Instead of `jacquard_api::app_bsky::richtext::Facet<'a>` emit `use jacquard_api::app_bsky::richtext::Facet;` and just `Facet<'a>`
-
- Would require threading `ImportTracker` through all generate functions or post-processing token stream
-
- Long paths are ugly but explicit - revisit once basic codegen is confirmed working
-
8. **Web-based lexicon resolution**: Fetch lexicons from the web instead of requiring local files
-
- Implement [lexicon publication and resolution](https://atproto.com/specs/lexicon#lexicon-publication-and-resolution) spec
-
- `LexiconCorpus::fetch_from_web(nsids: &[&str])` - fetch specific NSIDs
-
- `LexiconCorpus::fetch_from_authority(authority: &str)` - fetch all from DID/domain
-
- Resolution: `https://{authority}/.well-known/atproto/lexicon/{nsid}.json`
-
- Recursively fetch refs, handle redirects/errors
-
- Use `reqwest` for HTTP - still fits in jacquard-lexicon as it's corpus loading
-
-
## Success Criteria
-
-
- [ ] Generates code for all official AT Protocol lexicons
-
- [ ] Generated code compiles without errors
-
- [ ] No `Main` proliferation
-
- [ ] Union variants have readable names
-
- [ ] Unknown refs handled gracefully
-
- [ ] `#[lexicon]` and `#[open_union]` applied correctly
-
- [ ] Serialization round-trips correctly
+1 -1
crates/jacquard-api/Cargo.toml
···
categories.workspace = true
readme.workspace = true
exclude.workspace = true
-
license-file.workspace = true
+
license.workspace = true
[features]
default = [ "com_atproto"]
+27 -1
crates/jacquard-common/Cargo.toml
···
categories.workspace = true
readme.workspace = true
exclude.workspace = true
-
license-file.workspace = true
+
license.workspace = true
[dependencies]
+
bon = "3"
base64 = "0.22.1"
bytes.workspace = true
chrono = "0.4.42"
···
smol_str.workspace = true
thiserror.workspace = true
url.workspace = true
+
+
[features]
+
default = []
+
crypto = []
+
crypto-ed25519 = ["crypto", "dep:ed25519-dalek"]
+
crypto-k256 = ["crypto", "dep:k256"]
+
crypto-p256 = ["crypto", "dep:p256"]
+
+
[dependencies.ed25519-dalek]
+
version = "2"
+
optional = true
+
default-features = false
+
features = ["pkcs8"]
+
+
[dependencies.k256]
+
version = "0.13"
+
optional = true
+
default-features = false
+
features = ["arithmetic"]
+
+
[dependencies.p256]
+
version = "0.13"
+
optional = true
+
default-features = false
+
features = ["arithmetic"]
+4
crates/jacquard-common/src/types.rs
···
pub mod datetime;
/// Decentralized Identifier (DID) types and validation
pub mod did;
+
/// DID Document types and helpers
+
pub mod did_doc;
+
/// Crypto helpers for keys (Multikey decoding, conversions)
+
pub mod crypto;
/// AT Protocol handle types and validation
pub mod handle;
/// AT Protocol identifier types (handle or DID)
+298
crates/jacquard-common/src/types/crypto.rs
···
+
//! Multikey decoding and optional conversions.
+
//!
+
//! This module provides a small `PublicKey` wrapper that can decode a
+
//! Multikey `publicKeyMultibase` string into raw bytes plus a codec
+
//! (`KeyCodec`). Feature‑gated helpers convert to popular Rust crypto
+
//! public‑key types (ed25519_dalek, k256, p256).
+
//! Example: decode an ed25519 multibase key
+
//! ```
+
//! use jacquard_common::types::crypto::{PublicKey, KeyCodec};
+
//! // ed25519 key: multicodec varint 0xED + 32 raw bytes, base58btc encoded
+
//! let mut key = [0u8; 32];
+
//! let s = {
+
//! 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 }
+
//! let mut buf = enc(0xED); buf.extend_from_slice(&key); multibase::encode(multibase::Base::Base58Btc, buf)
+
//! };
+
//! let pk = PublicKey::decode(&s).unwrap();
+
//! assert!(matches!(pk.codec, KeyCodec::Ed25519));
+
//! assert_eq!(pk.bytes.as_ref(), &key);
+
+
use crate::IntoStatic;
+
use std::borrow::Cow;
+
+
/// Known multicodec key codecs for Multikey public keys
+
///
+
+
/// ```
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+
pub enum KeyCodec {
+
/// Ed25519
+
Ed25519,
+
/// Secp256k1
+
Secp256k1,
+
/// P256
+
P256,
+
/// Unknown codec
+
Unknown(u64),
+
}
+
+
/// Public key decoded from a Multikey `publicKeyMultibase` string
+
#[derive(Debug, Clone, PartialEq, Eq)]
+
pub struct PublicKey<'a> {
+
/// Codec used to encode the public key
+
pub codec: KeyCodec,
+
/// Bytes of the public key
+
pub bytes: Cow<'a, [u8]>,
+
}
+
+
#[cfg(feature = "crypto")]
+
fn code_of(codec: KeyCodec) -> u64 {
+
match codec {
+
KeyCodec::Ed25519 => 0xED,
+
KeyCodec::Secp256k1 => 0xE7,
+
KeyCodec::P256 => 0x1200,
+
KeyCodec::Unknown(c) => c,
+
}
+
}
+
+
/// Errors from decoding or converting Multikey values
+
#[derive(Debug, Clone, thiserror::Error, miette::Diagnostic, PartialEq, Eq)]
+
pub enum CryptoError {
+
#[error("failed to decode multibase")]
+
/// Multibase decode errror
+
MultibaseDecode,
+
#[error("failed to decode multicodec varint")]
+
/// Multicodec decode error
+
MulticodecDecode,
+
#[error("unsupported key codec: {0}")]
+
/// Unsupported key codec error
+
UnsupportedCodec(u64),
+
#[error("invalid key length: expected {expected}, got {got}")]
+
/// Invalid key length error
+
InvalidLength {
+
/// Expected length of the key
+
expected: usize,
+
/// Actual length of the key
+
got: usize,
+
},
+
#[error("invalid key format")]
+
/// Invalid key format error
+
InvalidFormat,
+
#[error("conversion error: {0}")]
+
/// Conversion error
+
Conversion(String),
+
}
+
+
impl<'a> PublicKey<'a> {
+
/// Decode a Multikey public key from a multibase-encoded string
+
pub fn decode(multibase_str: &'a str) -> Result<PublicKey<'static>, CryptoError> {
+
let (_base, data) =
+
multibase::decode(multibase_str).map_err(|_| CryptoError::MultibaseDecode)?;
+
let (code, offset) = decode_uvarint(&data).ok_or(CryptoError::MulticodecDecode)?;
+
let bytes = &data[offset..];
+
let codec = match code {
+
0xED => KeyCodec::Ed25519, // ed25519-pub
+
0xE7 => KeyCodec::Secp256k1, // secp256k1-pub
+
0x1200 => KeyCodec::P256, // p256-pub
+
other => KeyCodec::Unknown(other),
+
};
+
// Minimal validation
+
match codec {
+
KeyCodec::Ed25519 => {
+
if bytes.len() != 32 {
+
return Err(CryptoError::InvalidLength {
+
expected: 32,
+
got: bytes.len(),
+
});
+
}
+
}
+
KeyCodec::Secp256k1 | KeyCodec::P256 => {
+
if !(bytes.len() == 33 || bytes.len() == 65) {
+
return Err(CryptoError::InvalidLength {
+
expected: 33,
+
got: bytes.len(),
+
});
+
}
+
// 0x02/0x03 compressed, 0x04 uncompressed
+
let first = *bytes.first().ok_or(CryptoError::InvalidFormat)?;
+
if first != 0x02 && first != 0x03 && first != 0x04 {
+
return Err(CryptoError::InvalidFormat);
+
}
+
}
+
KeyCodec::Unknown(code) => return Err(CryptoError::UnsupportedCodec(code)),
+
}
+
Ok(PublicKey {
+
codec,
+
bytes: Cow::Owned(bytes.to_vec()),
+
})
+
}
+
+
// decode_owned provided on PublicKey<'static>
+
+
/// Convert to ed25519_dalek verifying key (feature crypto-ed25519)
+
#[cfg(feature = "crypto-ed25519")]
+
pub fn to_ed25519(&self) -> Result<ed25519_dalek::VerifyingKey, CryptoError> {
+
if self.codec != KeyCodec::Ed25519 {
+
return Err(CryptoError::UnsupportedCodec(code_of(self.codec)));
+
}
+
ed25519_dalek::VerifyingKey::from_bytes(self.bytes.as_ref().try_into().map_err(|_| {
+
CryptoError::InvalidLength {
+
expected: 32,
+
got: self.bytes.len(),
+
}
+
})?)
+
.map_err(|e| CryptoError::Conversion(e.to_string()))
+
}
+
+
/// Convert to k256 public key (feature crypto-k256)
+
#[cfg(feature = "crypto-k256")]
+
pub fn to_k256(&self) -> Result<k256::PublicKey, CryptoError> {
+
if self.codec != KeyCodec::Secp256k1 {
+
return Err(CryptoError::UnsupportedCodec(code_of(self.codec)));
+
}
+
k256::PublicKey::from_sec1_bytes(self.bytes.as_ref())
+
.map_err(|e| CryptoError::Conversion(e.to_string()))
+
}
+
+
/// Convert to p256 public key (feature crypto-p256)
+
#[cfg(feature = "crypto-p256")]
+
pub fn to_p256(&self) -> Result<p256::PublicKey, CryptoError> {
+
if self.codec != KeyCodec::P256 {
+
return Err(CryptoError::UnsupportedCodec(code_of(self.codec)));
+
}
+
p256::PublicKey::from_sec1_bytes(self.bytes.as_ref())
+
.map_err(|e| CryptoError::Conversion(e.to_string()))
+
}
+
}
+
+
impl PublicKey<'static> {
+
/// Decode from an owned string-like value
+
pub fn decode_owned(s: impl AsRef<str>) -> Result<PublicKey<'static>, CryptoError> {
+
PublicKey::decode(s.as_ref())
+
}
+
}
+
+
impl IntoStatic for PublicKey<'_> {
+
type Output = PublicKey<'static>;
+
fn into_static(self) -> Self::Output {
+
match self.bytes {
+
Cow::Borrowed(b) => PublicKey {
+
codec: self.codec,
+
bytes: Cow::Owned(b.to_vec()),
+
},
+
Cow::Owned(b) => PublicKey {
+
codec: self.codec,
+
bytes: Cow::Owned(b),
+
},
+
}
+
}
+
}
+
+
fn decode_uvarint(data: &[u8]) -> Option<(u64, usize)> {
+
let mut x: u64 = 0;
+
let mut s: u32 = 0;
+
for (i, b) in data.iter().copied().enumerate() {
+
if b < 0x80 {
+
if i > 9 || (i == 9 && b > 1) {
+
return None;
+
}
+
return Some((x | ((b as u64) << s), i + 1));
+
}
+
x |= ((b & 0x7F) as u64) << s;
+
s += 7;
+
}
+
None
+
}
+
+
#[cfg(test)]
+
mod tests {
+
use super::*;
+
use multibase;
+
+
fn encode_uvarint(mut x: u64) -> Vec<u8> {
+
let mut out = Vec::new();
+
while x >= 0x80 {
+
out.push(((x as u8) & 0x7F) | 0x80);
+
x >>= 7;
+
}
+
out.push(x as u8);
+
out
+
}
+
+
fn multikey(code: u64, key: &[u8]) -> String {
+
let mut buf = encode_uvarint(code);
+
buf.extend_from_slice(key);
+
multibase::encode(multibase::Base::Base58Btc, buf)
+
}
+
+
#[test]
+
fn decode_ed25519() {
+
let key = [0u8; 32];
+
let s = multikey(0xED, &key);
+
let pk = PublicKey::decode(&s).expect("decode");
+
assert_eq!(pk.codec, KeyCodec::Ed25519);
+
assert_eq!(pk.bytes.as_ref(), &key);
+
}
+
+
#[test]
+
fn decode_k1_compressed() {
+
let mut key = [0u8; 33];
+
key[0] = 0x02; // compressed y-bit
+
let s = multikey(0xE7, &key);
+
let pk = PublicKey::decode(&s).expect("decode");
+
assert_eq!(pk.codec, KeyCodec::Secp256k1);
+
assert_eq!(pk.bytes.as_ref(), &key);
+
}
+
+
#[test]
+
fn decode_p256_uncompressed() {
+
let mut key = [0u8; 65];
+
key[0] = 0x04; // uncompressed
+
let s = multikey(0x1200, &key);
+
let pk = PublicKey::decode(&s).expect("decode");
+
assert_eq!(pk.codec, KeyCodec::P256);
+
assert_eq!(pk.bytes.as_ref(), &key);
+
}
+
+
#[cfg(feature = "crypto-ed25519")]
+
#[test]
+
fn ed25519_conversion_ok() {
+
use core::convert::TryFrom;
+
use ed25519_dalek::{SecretKey, SigningKey, VerifyingKey};
+
// Build a deterministic signing key from a fixed secret
+
let secret = SecretKey::try_from(&[7u8; 32][..]).expect("secret");
+
let sk = SigningKey::from_bytes(&secret);
+
let vk: VerifyingKey = sk.verifying_key();
+
let bytes = vk.to_bytes();
+
// Encode multikey: varint(0xED) + key bytes, base58btc
+
let mut buf = super::tests::encode_uvarint(0xED);
+
buf.extend_from_slice(&bytes);
+
let s = multibase::encode(multibase::Base::Base58Btc, buf);
+
let pk = PublicKey::decode(&s).expect("decode");
+
assert!(matches!(pk.codec, KeyCodec::Ed25519));
+
let vk2 = pk.to_ed25519().expect("to ed25519");
+
assert_eq!(vk.as_bytes(), vk2.as_bytes());
+
}
+
+
#[cfg(feature = "crypto-k256")]
+
#[test]
+
fn k256_unsupported_on_ed25519_codec() {
+
// Use a valid-looking ed25519 key, attempt k256 conversion → UnsupportedCodec
+
let key = [1u8; 32];
+
let s = super::tests::multikey(0xED, &key);
+
let pk = PublicKey::decode(&s).expect("decode");
+
let err = pk.to_k256().unwrap_err();
+
assert!(matches!(err, CryptoError::UnsupportedCodec(_)));
+
}
+
+
#[cfg(feature = "crypto-p256")]
+
#[test]
+
fn p256_unsupported_on_ed25519_codec() {
+
// Use a valid-looking ed25519 key, attempt p256 conversion → UnsupportedCodec
+
let key = [2u8; 32];
+
let s = super::tests::multikey(0xED, &key);
+
let pk = PublicKey::decode(&s).expect("decode");
+
let err = pk.to_p256().unwrap_err();
+
assert!(matches!(err, CryptoError::UnsupportedCodec(_)));
+
}
+
}
+264
crates/jacquard-common/src/types/did_doc.rs
···
+
use crate::types::crypto::{CryptoError, PublicKey};
+
use crate::types::string::{Did, Handle};
+
use crate::types::value::Data;
+
use crate::{CowStr, IntoStatic};
+
use bon::Builder;
+
use serde::{Deserialize, Serialize};
+
use smol_str::SmolStr;
+
use std::collections::BTreeMap;
+
use url::Url;
+
+
/// DID Document representation with borrowed data where possible.
+
///
+
/// Only the most commonly used fields are modeled explicitly. All other fields
+
/// are captured in `extra_data` for forward compatibility, using the same
+
/// pattern as lexicon structs.
+
///
+
/// Example
+
/// ```ignore
+
/// use jacquard_common::types::did_doc::DidDocument;
+
/// use serde_json::json;
+
/// let doc: DidDocument<'_> = serde_json::from_value(json!({
+
/// "id": "did:plc:alice",
+
/// "alsoKnownAs": ["at://alice.example"],
+
/// "service": [{"id":"#pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example"}],
+
/// "verificationMethod":[{"id":"#k","type":"Multikey","publicKeyMultibase":"z6Mki..."}]
+
/// })).unwrap();
+
/// assert_eq!(doc.id.as_str(), "did:plc:alice");
+
/// assert!(doc.pds_endpoint().is_some());
+
/// ```
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
+
#[builder(start_fn = new)]
+
#[serde(rename_all = "camelCase")]
+
pub struct DidDocument<'a> {
+
/// Document identifier (e.g., `did:plc:...` or `did:web:...`)
+
#[serde(borrow)]
+
pub id: Did<'a>,
+
+
/// Alternate identifiers for the subject, such as at://<handle>
+
#[serde(borrow)]
+
pub also_known_as: Option<Vec<CowStr<'a>>>,
+
+
/// Verification methods (keys) for this DID
+
#[serde(borrow)]
+
pub verification_method: Option<Vec<VerificationMethod<'a>>>,
+
+
/// Services associated with this DID (e.g., AtprotoPersonalDataServer)
+
#[serde(borrow)]
+
pub service: Option<Vec<Service<'a>>>,
+
+
/// Forward‑compatible capture of unmodeled fields
+
#[serde(flatten)]
+
pub extra_data: BTreeMap<SmolStr, Data<'a>>,
+
}
+
+
impl crate::IntoStatic for DidDocument<'_> {
+
type Output = DidDocument<'static>;
+
fn into_static(self) -> Self::Output {
+
DidDocument {
+
id: self.id.into_static(),
+
also_known_as: self.also_known_as.into_static(),
+
verification_method: self.verification_method.into_static(),
+
service: self.service.into_static(),
+
extra_data: self.extra_data.into_static(),
+
}
+
}
+
}
+
+
impl<'a> DidDocument<'a> {
+
/// Extract validated handles from `alsoKnownAs` entries like `at://<handle>`.
+
pub fn handles(&self) -> Vec<Handle<'static>> {
+
self.also_known_as
+
.as_ref()
+
.map(|v| {
+
v.iter()
+
.filter_map(|s| s.strip_prefix("at://"))
+
.filter_map(|h| Handle::new(h).ok())
+
.map(|h| h.into_static())
+
.collect()
+
})
+
.unwrap_or_default()
+
}
+
+
/// Extract the first Multikey `publicKeyMultibase` value from verification methods.
+
pub fn atproto_multikey(&self) -> Option<CowStr<'static>> {
+
self.verification_method.as_ref().and_then(|methods| {
+
methods.iter().find_map(|m| {
+
if m.r#type.as_ref() == "Multikey" {
+
m.public_key_multibase
+
.as_ref()
+
.map(|k| k.clone().into_static())
+
} else {
+
None
+
}
+
})
+
})
+
}
+
+
/// Extract the AtprotoPersonalDataServer service endpoint as a `Url`.
+
/// Accepts endpoint as string or object (string preferred).
+
pub fn pds_endpoint(&self) -> Option<Url> {
+
self.service.as_ref().and_then(|services| {
+
services.iter().find_map(|s| {
+
if s.r#type.as_ref() == "AtprotoPersonalDataServer" {
+
match &s.service_endpoint {
+
Some(Data::String(strv)) => Url::parse(strv.as_ref()).ok(),
+
Some(Data::Object(obj)) => {
+
// Some documents may include structured endpoints; try common fields
+
if let Some(Data::String(urlv)) = obj.0.get("url") {
+
Url::parse(urlv.as_ref()).ok()
+
} else {
+
None
+
}
+
}
+
_ => None,
+
}
+
} else {
+
None
+
}
+
})
+
})
+
}
+
+
/// Decode the atproto Multikey (first occurrence) into a typed public key.
+
pub fn atproto_public_key(&self) -> Result<Option<PublicKey<'static>>, CryptoError> {
+
if let Some(multibase) = self.atproto_multikey() {
+
let pk = PublicKey::decode(&multibase)?;
+
Ok(Some(pk))
+
} else {
+
Ok(None)
+
}
+
}
+
}
+
+
/// Verification method (key) entry in a DID Document.
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
+
#[builder(start_fn = new)]
+
#[serde(rename_all = "camelCase")]
+
pub struct VerificationMethod<'a> {
+
/// Identifier for this key material within the document
+
#[serde(borrow)]
+
pub id: CowStr<'a>,
+
/// Key type (e.g., `Multikey`)
+
#[serde(borrow, rename = "type")]
+
pub r#type: CowStr<'a>,
+
/// Optional controller DID
+
#[serde(borrow)]
+
pub controller: Option<CowStr<'a>>,
+
/// Multikey `publicKeyMultibase` (base58btc)
+
#[serde(borrow)]
+
pub public_key_multibase: Option<CowStr<'a>>,
+
+
/// Forward‑compatible capture of unmodeled fields
+
#[serde(flatten)]
+
pub extra_data: BTreeMap<SmolStr, Data<'a>>,
+
}
+
+
impl crate::IntoStatic for VerificationMethod<'_> {
+
type Output = VerificationMethod<'static>;
+
fn into_static(self) -> Self::Output {
+
VerificationMethod {
+
id: self.id.into_static(),
+
r#type: self.r#type.into_static(),
+
controller: self.controller.into_static(),
+
public_key_multibase: self.public_key_multibase.into_static(),
+
extra_data: self.extra_data.into_static(),
+
}
+
}
+
}
+
+
/// Service entry in a DID Document.
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
+
#[builder(start_fn = new)]
+
#[serde(rename_all = "camelCase")]
+
pub struct Service<'a> {
+
/// Service identifier
+
#[serde(borrow)]
+
pub id: CowStr<'a>,
+
/// Service type (e.g., `AtprotoPersonalDataServer`)
+
#[serde(borrow, rename = "type")]
+
pub r#type: CowStr<'a>,
+
/// String or object; we preserve as Data
+
#[serde(borrow)]
+
pub service_endpoint: Option<Data<'a>>,
+
+
/// Forward‑compatible capture of unmodeled fields
+
#[serde(flatten)]
+
pub extra_data: BTreeMap<SmolStr, Data<'a>>,
+
}
+
+
impl crate::IntoStatic for Service<'_> {
+
type Output = Service<'static>;
+
fn into_static(self) -> Self::Output {
+
Service {
+
id: self.id.into_static(),
+
r#type: self.r#type.into_static(),
+
service_endpoint: self.service_endpoint.into_static(),
+
extra_data: self.extra_data.into_static(),
+
}
+
}
+
}
+
+
#[cfg(test)]
+
mod tests {
+
use super::*;
+
use serde_json::json;
+
+
fn encode_uvarint(mut x: u64) -> Vec<u8> {
+
let mut out = Vec::new();
+
while x >= 0x80 {
+
out.push(((x as u8) & 0x7F) | 0x80);
+
x >>= 7;
+
}
+
out.push(x as u8);
+
out
+
}
+
+
fn multikey(code: u64, key: &[u8]) -> String {
+
let mut buf = encode_uvarint(code);
+
buf.extend_from_slice(key);
+
multibase::encode(multibase::Base::Base58Btc, buf)
+
}
+
+
#[test]
+
fn public_key_decode() {
+
let did = "did:plc:example";
+
let mut k = [0u8; 32];
+
k[0] = 7;
+
let mk = multikey(0xED, &k);
+
let doc_json = json!({
+
"id": did,
+
"verificationMethod": [
+
{
+
"id": "#key-1",
+
"type": "Multikey",
+
"publicKeyMultibase": mk,
+
}
+
]
+
});
+
let doc_string = serde_json::to_string(&doc_json).unwrap();
+
let doc: DidDocument<'_> = serde_json::from_str(&doc_string).unwrap();
+
let pk = doc.atproto_public_key().unwrap().expect("present");
+
assert!(matches!(pk.codec, crate::types::crypto::KeyCodec::Ed25519));
+
assert_eq!(pk.bytes.as_ref(), &k);
+
}
+
+
#[test]
+
fn parse_sample_doc_and_helpers() {
+
let raw = include_str!("test_did_doc.json");
+
let doc: DidDocument<'_> = serde_json::from_str(raw).expect("parse doc");
+
// id
+
assert_eq!(doc.id.as_str(), "did:plc:yfvwmnlztr4dwkb7hwz55r2g");
+
// pds endpoint
+
let pds = doc.pds_endpoint().expect("pds endpoint");
+
assert_eq!(pds.as_str(), "https://atproto.systems/");
+
// handle alias extraction
+
let handles = doc.handles();
+
assert!(handles.iter().any(|h| h.as_str() == "nonbinary.computer"));
+
// multikey string present
+
let mk = doc.atproto_multikey().expect("has multikey");
+
assert!(mk.as_ref().starts_with('z'));
+
// typed decode (may be ed25519, secp256k1, or p256 depending on multicodec)
+
let _ = doc.atproto_public_key().expect("decode ok");
+
}
+
}
+24
crates/jacquard-common/src/types/test_did_doc.json
···
+
{
+
"@context": [
+
"https://www.w3.org/ns/did/v1",
+
"https://w3id.org/security/multikey/v1",
+
"https://w3id.org/security/suites/secp256k1-2019/v1"
+
],
+
"alsoKnownAs": ["at://nonbinary.computer"],
+
"id": "did:plc:yfvwmnlztr4dwkb7hwz55r2g",
+
"service": [
+
{
+
"id": "#atproto_pds",
+
"serviceEndpoint": "https://atproto.systems",
+
"type": "AtprotoPersonalDataServer"
+
}
+
],
+
"verificationMethod": [
+
{
+
"controller": "did:plc:yfvwmnlztr4dwkb7hwz55r2g",
+
"id": "did:plc:yfvwmnlztr4dwkb7hwz55r2g#atproto",
+
"publicKeyMultibase": "zQ3shtTHyn59SehkrApkRCXMbE7UZyrrkeCdQTuDW9oRF3R9U",
+
"type": "Multikey"
+
}
+
]
+
}
+12
crates/jacquard-common/src/types/value/broken_record.json
···
+
{
+
"uri": "at://did:plc:5me6kvratkf2f5lgvezbqrk7/app.bsky.feed.post/3lxzkque3272s",
+
"cid": "bafyreibllccqp445znzudb6q635aghmtqy3v4dexdzrzb4s4x3zuhejy2i",
+
"value": {
+
"text": "RT @kickitout: Four goals. One mission. Football United.\n\n⚽ Inclusive culture\n👥 More representation on the pitch\n💼 More coaches and leaders��",
+
"$type": "app.bsky.feed.post",
+
"embed": {
+
"$type": ""
+
},
+
"createdAt": "2025-09-04T11:26:06-05:00"
+
}
+
}
+1 -1
crates/jacquard-derive/Cargo.toml
···
categories.workspace = true
readme.workspace = true
exclude.workspace = true
-
license-file.workspace = true
+
license.workspace = true
[lib]
proc-macro = true
+1 -1
crates/jacquard-lexicon/Cargo.toml
···
categories.workspace = true
readme.workspace = true
exclude.workspace = true
-
license-file.workspace = true
+
license.workspace = true
[[bin]]
name = "jacquard-codegen"
+9 -1
crates/jacquard/Cargo.toml
···
categories.workspace = true
readme.workspace = true
exclude.workspace = true
-
license-file.workspace = true
+
license.workspace = true
[features]
default = ["api_all"]
derive = ["dep:jacquard-derive"]
api = ["jacquard-api/com_atproto"]
api_all = ["api", "jacquard-api/app_bsky", "jacquard-api/chat_bsky", "jacquard-api/tools_ozone"]
+
dns = ["dep:hickory-resolver"]
[lib]
name = "jacquard"
···
path = "src/main.rs"
[dependencies]
+
bon = "3"
+
async-trait = "0.1"
bytes.workspace = true
clap.workspace = true
http.workspace = true
···
serde_json.workspace = true
thiserror.workspace = true
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
+
hickory-resolver = { version = "0.24", default-features = false, features = ["system-config", "tokio-runtime"], optional = true }
+
url.workspace = true
+
smol_str.workspace = true
+
percent-encoding = "2"
+
urlencoding = "2"
+10 -10
crates/jacquard/src/client.rs
···
fn send_http(
&self,
request: Request<Vec<u8>>,
-
) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>>;
+
) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send;
}
/// XRPC client trait for AT Protocol RPC calls
-
pub trait XrpcClient: HttpClient {
+
pub trait XrpcClient: HttpClient + Sync {
/// Get the base URI for XRPC requests (e.g., "https://bsky.social")
fn base_uri(&self) -> CowStr<'_>;
/// Get the authorization token for XRPC requests
···
fn authorization_token(
&self,
is_refresh: bool,
-
) -> impl Future<Output = Option<AuthorizationToken<'_>>> {
+
) -> impl Future<Output = Option<AuthorizationToken<'_>>> + Send {
async { None }
}
/// Get the `atproto-proxy` header.
-
fn atproto_proxy_header(&self) -> impl Future<Output = Option<String>> {
+
fn atproto_proxy_header(&self) -> impl Future<Output = Option<String>> + Send {
async { None }
}
/// Get the `atproto-accept-labelers` header.
-
fn atproto_accept_labelers_header(&self) -> impl Future<Output = Option<Vec<String>>> {
+
fn atproto_accept_labelers_header(&self) -> impl Future<Output = Option<Vec<String>>> + Send {
async { None }
}
/// Send an XRPC request and get back a response
-
fn send<R: XrpcRequest>(&self, request: R) -> impl Future<Output = Result<Response<R>>>
+
fn send<R: XrpcRequest + Send>(&self, request: R) -> impl Future<Output = Result<Response<R>>> + Send
where
-
Self: Sized,
+
Self: Sized + Sync,
{
send_xrpc(self, request)
}
···
/// Generic XRPC send implementation that uses HttpClient
async fn send_xrpc<R, C>(client: &C, request: R) -> Result<Response<R>>
where
-
R: XrpcRequest,
-
C: XrpcClient + ?Sized,
+
R: XrpcRequest + Send,
+
C: XrpcClient + ?Sized + Sync,
{
// Build URI: base_uri + /xrpc/ + NSID
let mut uri = format!("{}/xrpc/{}", client.base_uri(), R::NSID);
···
}
}
-
impl<C: HttpClient> XrpcClient for AuthenticatedClient<C> {
+
impl<C: HttpClient + Sync> XrpcClient for AuthenticatedClient<C> {
fn base_uri(&self) -> CowStr<'_> {
self.base_uri.clone()
}
+3
crates/jacquard/src/identity/mod.rs
···
+
//! Identity resolution utilities: DID and handle resolution, DID document fetch,
+
//! and helpers for PDS endpoint discovery. See `identity::resolver` for details.
+
pub mod resolver;
+960
crates/jacquard/src/identity/resolver.rs
···
+
//! Identity resolution: handle → DID and DID → document, with smart fallbacks.
+
//!
+
//! Fallback order (default):
+
//! - Handle → DID: DNS TXT (if `dns` feature) → HTTPS well-known → embedded XRPC
+
//! `resolveHandle` → public API fallback → Slingshot `resolveHandle` (if configured).
+
//! - DID → Doc: did:web well-known → PLC/slingshot HTTP → embedded XRPC `resolveDid`,
+
//! then Slingshot mini‑doc (partial) if configured.
+
//!
+
//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer
+
//! and optionally validate the document `id` against the requested DID.
+
+
use crate::CowStr;
+
use crate::client::AuthenticatedClient;
+
use bon::Builder;
+
use bytes::Bytes;
+
use jacquard_common::IntoStatic;
+
use miette::Diagnostic;
+
use percent_encoding::percent_decode_str;
+
use reqwest::StatusCode;
+
use thiserror::Error;
+
use url::{ParseError, Url};
+
+
use crate::api::com_atproto::identity::{resolve_did, resolve_handle::ResolveHandle};
+
use crate::types::did_doc::DidDocument;
+
use crate::types::ident::AtIdentifier;
+
use crate::types::string::{Did, Handle};
+
use crate::types::value::AtDataError;
+
+
#[cfg(feature = "dns")]
+
use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig};
+
+
/// Errors that can occur during identity resolution.
+
///
+
/// Note: when validating a fetched DID document against a requested DID, a
+
/// `DocIdMismatch` error is returned that includes the owned document so callers
+
/// can inspect it and decide how to proceed.
+
#[derive(Debug, Error, Diagnostic)]
+
#[allow(missing_docs)]
+
pub enum IdentityError {
+
#[error("unsupported DID method: {0}")]
+
UnsupportedDidMethod(String),
+
#[error("invalid well-known atproto-did content")]
+
InvalidWellKnown,
+
#[error("missing PDS endpoint in DID document")]
+
MissingPdsEndpoint,
+
#[error("HTTP error: {0}")]
+
Http(#[from] reqwest::Error),
+
#[error("HTTP status {0}")]
+
HttpStatus(StatusCode),
+
#[error("XRPC error: {0}")]
+
Xrpc(String),
+
#[error("URL parse error: {0}")]
+
Url(#[from] url::ParseError),
+
#[error("DNS error: {0}")]
+
#[cfg(feature = "dns")]
+
Dns(#[from] hickory_resolver::error::ResolveError),
+
#[error("serialize/deserialize error: {0}")]
+
Serde(#[from] serde_json::Error),
+
#[error("invalid DID document: {0}")]
+
InvalidDoc(String),
+
#[error(transparent)]
+
Data(#[from] AtDataError),
+
/// DID document id did not match requested DID; includes the fetched document
+
#[error("DID doc id mismatch")]
+
DocIdMismatch {
+
expected: Did<'static>,
+
doc: DidDocument<'static>,
+
},
+
}
+
+
/// Source to fetch PLC (did:plc) documents from.
+
///
+
/// - `PlcDirectory`: uses the public PLC directory (default `https://plc.directory/`).
+
/// - `Slingshot`: uses Slingshot which also exposes convenience endpoints such as
+
/// `com.atproto.identity.resolveHandle` and a "mini-doc"
+
/// endpoint (`com.bad-example.identity.resolveMiniDoc`).
+
#[derive(Debug, Clone, PartialEq, Eq)]
+
pub enum PlcSource {
+
/// Use the public PLC directory
+
PlcDirectory {
+
/// Base URL for the PLC directory
+
base: Url,
+
},
+
/// Use the slingshot mini-docs service
+
Slingshot {
+
/// Base URL for the Slingshot service
+
base: Url,
+
},
+
}
+
+
impl Default for PlcSource {
+
fn default() -> Self {
+
Self::PlcDirectory {
+
base: Url::parse("https://plc.directory/").expect("valid url"),
+
}
+
}
+
}
+
+
impl PlcSource {
+
/// Default Slingshot source (`https://slingshot.microcosm.blue`)
+
pub fn slingshot_default() -> Self {
+
PlcSource::Slingshot {
+
base: Url::parse("https://slingshot.microcosm.blue").expect("valid url"),
+
}
+
}
+
}
+
+
/// DID Document fetch response for borrowed/owned parsing.
+
///
+
/// Carries the raw response bytes and the HTTP status, plus the requested DID
+
/// (if supplied) to enable validation. Use `parse()` to borrow from the buffer
+
/// or `parse_validated()` to also enforce that the doc `id` matches the
+
/// requested DID (returns a `DocIdMismatch` containing the fetched doc on
+
/// mismatch). Use `into_owned()` to parse into an owned document.
+
#[derive(Clone)]
+
pub struct DidDocResponse {
+
buffer: Bytes,
+
status: StatusCode,
+
/// Optional DID we intended to resolve; used for validation helpers
+
requested: Option<Did<'static>>,
+
}
+
+
impl DidDocResponse {
+
/// Parse as borrowed DidDocument<'_>
+
pub fn parse<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
+
if self.status.is_success() {
+
serde_json::from_slice::<DidDocument<'b>>(&self.buffer).map_err(IdentityError::from)
+
} else {
+
Err(IdentityError::HttpStatus(self.status))
+
}
+
}
+
+
/// Parse and validate that the DID in the document matches the requested DID if present.
+
///
+
/// On mismatch, returns an error that contains the owned document for inspection.
+
pub fn parse_validated<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
+
let doc = self.parse()?;
+
if let Some(expected) = &self.requested {
+
if doc.id.as_str() != expected.as_str() {
+
return Err(IdentityError::DocIdMismatch {
+
expected: expected.clone(),
+
doc: doc.clone().into_static(),
+
});
+
}
+
}
+
Ok(doc)
+
}
+
+
/// Parse as owned DidDocument<'static>
+
pub fn into_owned(self) -> Result<DidDocument<'static>, IdentityError> {
+
if self.status.is_success() {
+
serde_json::from_slice::<DidDocument<'_>>(&self.buffer)
+
.map(|d| d.into_static())
+
.map_err(IdentityError::from)
+
} else {
+
Err(IdentityError::HttpStatus(self.status))
+
}
+
}
+
}
+
+
/// Handle → DID fallback step.
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+
pub enum HandleStep {
+
/// DNS TXT _atproto.<handle>
+
DnsTxt,
+
/// HTTPS GET https://<handle>/.well-known/atproto-did
+
HttpsWellKnown,
+
/// XRPC com.atproto.identity.resolveHandle against a provided PDS base
+
PdsResolveHandle,
+
}
+
+
/// DID → Doc fallback step.
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+
pub enum DidStep {
+
/// For did:web: fetch from the well-known location
+
DidWebHttps,
+
/// For did:plc: fetch from PLC source
+
PlcHttp,
+
/// If a PDS base is known, ask it for the DID doc
+
PdsResolveDid,
+
}
+
+
/// Configurable resolver options.
+
///
+
/// - `plc_source`: where to fetch did:plc documents (PLC Directory or Slingshot).
+
/// - `pds_fallback`: optional base URL of a PDS for XRPC fallbacks (auth-aware
+
/// paths available via helpers that take an `XrpcClient`).
+
/// - `handle_order`/`did_order`: ordered strategies for resolution.
+
/// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID,
+
/// returning `DocIdMismatch` with the fetched document on mismatch.
+
/// - `public_fallback_for_handle`: if true (default), attempt
+
/// `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle` as an unauth fallback.
+
/// There is no public fallback for DID documents; when `PdsResolveDid` is chosen and the embedded XRPC
+
/// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured.
+
#[derive(Debug, Clone, Builder)]
+
#[builder(start_fn = new)]
+
pub struct ResolverOptions {
+
/// PLC data source (directory or slingshot)
+
pub plc_source: PlcSource,
+
/// Optional PDS base to use for fallbacks
+
pub pds_fallback: Option<Url>,
+
/// Order of attempts for handle → DID resolution
+
pub handle_order: Vec<HandleStep>,
+
/// Order of attempts for DID → Doc resolution
+
pub did_order: Vec<DidStep>,
+
/// Validate that fetched DID document id matches the requested DID
+
pub validate_doc_id: bool,
+
/// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app
+
pub public_fallback_for_handle: bool,
+
}
+
+
impl Default for ResolverOptions {
+
fn default() -> Self {
+
// By default, prefer DNS then HTTPS for handles, then PDS fallback
+
// For DID documents, prefer method-native sources, then PDS fallback
+
Self::new()
+
.plc_source(PlcSource::default())
+
.handle_order(vec![
+
HandleStep::DnsTxt,
+
HandleStep::HttpsWellKnown,
+
HandleStep::PdsResolveHandle,
+
])
+
.did_order(vec![
+
DidStep::DidWebHttps,
+
DidStep::PlcHttp,
+
DidStep::PdsResolveDid,
+
])
+
.validate_doc_id(true)
+
.public_fallback_for_handle(true)
+
.build()
+
}
+
}
+
+
/// Trait for identity resolution, for pluggable implementations.
+
///
+
/// The provided `DefaultResolver` supports:
+
/// - DNS TXT (`_atproto.<handle>`) when compiled with the `dns` feature
+
/// - HTTPS well-known for handles and `did:web`
+
/// - PLC directory or Slingshot for `did:plc`
+
/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source
+
/// - Auth-aware PDS fallbacks via helpers that accept an `XrpcClient`
+
#[async_trait::async_trait]
+
pub trait IdentityResolver {
+
/// Access options for validation decisions in default methods
+
fn options(&self) -> &ResolverOptions;
+
+
/// Resolve handle
+
async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError>;
+
+
/// Resolve DID document
+
async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError>;
+
async fn resolve_did_doc_owned(
+
&self,
+
did: &Did<'_>,
+
) -> Result<DidDocument<'static>, IdentityError> {
+
self.resolve_did_doc(did).await?.into_owned()
+
}
+
async fn pds_for_did(&self, did: &Did<'_>) -> Result<Url, IdentityError> {
+
let resp = self.resolve_did_doc(did).await?;
+
let doc = resp.parse()?;
+
// Default-on doc id equality check
+
if self.options().validate_doc_id {
+
if doc.id.as_str() != did.as_str() {
+
return Err(IdentityError::DocIdMismatch {
+
expected: did.clone().into_static(),
+
doc: doc.clone().into_static(),
+
});
+
}
+
}
+
doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint)
+
}
+
async fn pds_for_handle(
+
&self,
+
handle: &Handle<'_>,
+
) -> Result<(Did<'static>, Url), IdentityError> {
+
let did = self.resolve_handle(handle).await?;
+
let pds = self.pds_for_did(&did).await?;
+
Ok((did, pds))
+
}
+
}
+
+
/// Default resolver implementation with configurable fallback order.
+
///
+
/// Behavior highlights:
+
/// - Handle resolution tries DNS TXT (if enabled via `dns` feature), then HTTPS
+
/// well-known, then Slingshot's unauthenticated `resolveHandle` when
+
/// `PlcSource::Slingshot` is configured.
+
/// - DID resolution tries did:web well-known for `did:web`, and the configured
+
/// PLC base (PLC directory or Slingshot) for `did:plc`.
+
/// - PDS-authenticated fallbacks (e.g., `resolveHandle`, `resolveDid` on a PDS)
+
/// are available via helper methods that accept a user-provided `XrpcClient`.
+
///
+
/// Example
+
/// ```ignore
+
/// use jacquard::identity::resolver::{DefaultResolver, ResolverOptions};
+
/// use jacquard::client::{AuthenticatedClient, XrpcClient};
+
/// use jacquard::types::string::Handle;
+
/// use jacquard::CowStr;
+
///
+
/// // Build an auth-capable XRPC client (without a session it behaves like public/unauth)
+
/// let http = reqwest::Client::new();
+
/// let xrpc = AuthenticatedClient::new(http.clone(), CowStr::from("https://bsky.social"));
+
/// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default());
+
///
+
/// // Resolve a handle to a DID
+
/// let did = tokio_test::block_on(async { resolver.resolve_handle(&Handle::new("bad-example.com").unwrap()).await }).unwrap();
+
/// ```
+
pub struct DefaultResolver<C: crate::client::XrpcClient + Send + Sync> {
+
http: reqwest::Client,
+
xrpc: C,
+
opts: ResolverOptions,
+
#[cfg(feature = "dns")]
+
dns: Option<TokioAsyncResolver>,
+
}
+
+
impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> {
+
pub fn new(http: reqwest::Client, xrpc: C, opts: ResolverOptions) -> Self {
+
Self {
+
http,
+
xrpc,
+
opts,
+
#[cfg(feature = "dns")]
+
dns: None,
+
}
+
}
+
+
#[cfg(feature = "dns")]
+
pub fn with_system_dns(mut self) -> Self {
+
self.dns = Some(TokioAsyncResolver::tokio(
+
ResolverConfig::default(),
+
Default::default(),
+
));
+
self
+
}
+
+
/// Set PLC source (PLC directory or Slingshot)
+
///
+
/// Example
+
/// ```ignore
+
/// use jacquard::identity::resolver::{DefaultResolver, ResolverOptions, PlcSource};
+
/// let http = reqwest::Client::new();
+
/// let xrpc = jacquard::client::AuthenticatedClient::new(http.clone(), jacquard::CowStr::from("https://public.api.bsky.app"));
+
/// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default())
+
/// .with_plc_source(PlcSource::slingshot_default());
+
/// ```
+
pub fn with_plc_source(mut self, source: PlcSource) -> Self {
+
self.opts.plc_source = source;
+
self
+
}
+
+
/// Enable/disable public unauthenticated fallback for resolveHandle
+
///
+
/// Example
+
/// ```ignore
+
/// # use jacquard::identity::resolver::{DefaultResolver, ResolverOptions};
+
/// # let http = reqwest::Client::new();
+
/// # let xrpc = jacquard::client::AuthenticatedClient::new(http.clone(), jacquard::CowStr::from("https://public.api.bsky.app"));
+
/// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default())
+
/// .with_public_fallback_for_handle(true);
+
/// ```
+
pub fn with_public_fallback_for_handle(mut self, enable: bool) -> Self {
+
self.opts.public_fallback_for_handle = enable;
+
self
+
}
+
+
/// Enable/disable doc id validation
+
///
+
/// Example
+
/// ```ignore
+
/// # use jacquard::identity::resolver::{DefaultResolver, ResolverOptions};
+
/// # let http = reqwest::Client::new();
+
/// # let xrpc = jacquard::client::AuthenticatedClient::new(http.clone(), jacquard::CowStr::from("https://public.api.bsky.app"));
+
/// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default())
+
/// .with_validate_doc_id(true);
+
/// ```
+
pub fn with_validate_doc_id(mut self, enable: bool) -> Self {
+
self.opts.validate_doc_id = enable;
+
self
+
}
+
+
/// Construct the well-known HTTPS URL for a `did:web` DID.
+
///
+
/// - `did:web:example.com` → `https://example.com/.well-known/did.json`
+
/// - `did:web:example.com:user:alice` → `https://example.com/user/alice/did.json`
+
fn did_web_url(&self, did: &Did<'_>) -> Result<Url, IdentityError> {
+
// did:web:example.com[:path:segments]
+
let s = did.as_str();
+
let rest = s
+
.strip_prefix("did:web:")
+
.ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?;
+
let mut parts = rest.split(':');
+
let host = parts
+
.next()
+
.ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?;
+
let mut url = Url::parse(&format!("https://{host}/")).map_err(IdentityError::Url)?;
+
let path: Vec<&str> = parts.collect();
+
if path.is_empty() {
+
url.set_path(".well-known/did.json");
+
} else {
+
// Append path segments and did.json
+
let mut segments = url
+
.path_segments_mut()
+
.map_err(|_| IdentityError::Url(ParseError::SetHostOnCannotBeABaseUrl))?;
+
for seg in path {
+
// Minimally percent-decode each segment per spec guidance
+
let decoded = percent_decode_str(seg).decode_utf8_lossy();
+
segments.push(&decoded);
+
}
+
segments.push("did.json");
+
// drop segments
+
}
+
Ok(url)
+
}
+
+
#[cfg(test)]
+
fn test_did_web_url_raw(&self, s: &str) -> String {
+
let did = Did::new(s).unwrap();
+
self.did_web_url(&did).unwrap().to_string()
+
}
+
+
async fn get_json_bytes(&self, url: Url) -> Result<(Bytes, StatusCode), IdentityError> {
+
let resp = self.http.get(url).send().await?;
+
let status = resp.status();
+
let buf = resp.bytes().await?;
+
Ok((buf, status))
+
}
+
+
async fn get_text(&self, url: Url) -> Result<String, IdentityError> {
+
let resp = self.http.get(url).send().await?;
+
if resp.status() == StatusCode::OK {
+
Ok(resp.text().await?)
+
} else {
+
Err(IdentityError::Http(resp.error_for_status().unwrap_err()))
+
}
+
}
+
+
#[cfg(feature = "dns")]
+
async fn dns_txt(&self, name: &str) -> Result<Vec<String>, IdentityError> {
+
let Some(dns) = &self.dns else {
+
return Ok(vec![]);
+
};
+
let fqdn = format!("_atproto.{name}.");
+
let response = dns.txt_lookup(fqdn).await?;
+
let mut out = Vec::new();
+
for txt in response.iter() {
+
for data in txt.txt_data().iter() {
+
out.push(String::from_utf8_lossy(data).to_string());
+
}
+
}
+
Ok(out)
+
}
+
+
fn parse_atproto_did_body(body: &str) -> Result<Did<'static>, IdentityError> {
+
let line = body
+
.lines()
+
.find(|l| !l.trim().is_empty())
+
.ok_or(IdentityError::InvalidWellKnown)?;
+
let did = Did::new(line.trim()).map_err(|_| IdentityError::InvalidWellKnown)?;
+
Ok(did.into_static())
+
}
+
}
+
+
impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> {
+
/// Resolve handle to DID via a PDS XRPC client (auth-aware path)
+
pub async fn resolve_handle_via_pds(
+
&self,
+
handle: &Handle<'_>,
+
) -> Result<Did<'static>, IdentityError> {
+
let req = ResolveHandle::new().handle((*handle).clone()).build();
+
let resp = self
+
.xrpc
+
.send(req)
+
.await
+
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
+
let out = resp
+
.into_output()
+
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
+
Did::new_owned(out.did.as_str())
+
.map(|d| d.into_static())
+
.map_err(|_| IdentityError::InvalidWellKnown)
+
}
+
+
/// Fetch DID document via PDS resolveDid (returns owned DidDocument)
+
pub async fn fetch_did_doc_via_pds_owned(
+
&self,
+
did: &Did<'_>,
+
) -> Result<DidDocument<'static>, IdentityError> {
+
let req = resolve_did::ResolveDid::new().did(did.clone()).build();
+
let resp = self
+
.xrpc
+
.send(req)
+
.await
+
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
+
let out = resp
+
.into_output()
+
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
+
let doc_json = serde_json::to_value(&out.did_doc)?;
+
let s = serde_json::to_string(&doc_json)?;
+
let doc_borrowed: DidDocument<'_> = serde_json::from_str(&s)?;
+
Ok(doc_borrowed.into_static())
+
}
+
+
/// Fetch a minimal DID document via a Slingshot mini-doc endpoint, if your PlcSource uses Slingshot.
+
/// Returns the raw response wrapper for borrowed parsing and validation.
+
pub async fn fetch_mini_doc_via_slingshot(
+
&self,
+
did: &Did<'_>,
+
) -> Result<DidDocResponse, IdentityError> {
+
let base = match &self.opts.plc_source {
+
PlcSource::Slingshot { base } => base.clone(),
+
_ => {
+
return Err(IdentityError::UnsupportedDidMethod(
+
"mini-doc requires Slingshot source".into(),
+
));
+
}
+
};
+
let mut url = base;
+
url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc");
+
if let Ok(qs) =
+
serde_html_form::to_string(&resolve_did::ResolveDid::new().did(did.clone()).build())
+
{
+
url.set_query(Some(&qs));
+
}
+
let (buf, status) = self.get_json_bytes(url).await?;
+
Ok(DidDocResponse {
+
buffer: buf,
+
status,
+
requested: Some(did.clone().into_static()),
+
})
+
}
+
}
+
+
#[async_trait::async_trait]
+
impl<C: crate::client::XrpcClient + Send + Sync> IdentityResolver for DefaultResolver<C> {
+
fn options(&self) -> &ResolverOptions {
+
&self.opts
+
}
+
async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> {
+
let host = handle.as_str();
+
for step in &self.opts.handle_order {
+
match step {
+
HandleStep::DnsTxt => {
+
#[cfg(feature = "dns")]
+
{
+
if let Ok(txts) = self.dns_txt(host).await {
+
for txt in txts {
+
if let Some(did_str) = txt.strip_prefix("did=") {
+
if let Ok(did) = Did::new(did_str) {
+
return Ok(did.into_static());
+
}
+
}
+
}
+
}
+
}
+
}
+
HandleStep::HttpsWellKnown => {
+
let url = Url::parse(&format!("https://{host}/.well-known/atproto-did"))?;
+
if let Ok(text) = self.get_text(url).await {
+
if let Ok(did) = Self::parse_atproto_did_body(&text) {
+
return Ok(did);
+
}
+
}
+
}
+
HandleStep::PdsResolveHandle => {
+
// Prefer embedded XRPC client
+
if let Ok(did) = self.resolve_handle_via_pds(handle).await {
+
return Ok(did);
+
}
+
// Public unauth fallback
+
if self.opts.public_fallback_for_handle {
+
if let Ok(mut url) = Url::parse("https://public.api.bsky.app") {
+
url.set_path("/xrpc/com.atproto.identity.resolveHandle");
+
if let Ok(qs) = serde_html_form::to_string(
+
&ResolveHandle::new().handle((*handle).clone()).build(),
+
) {
+
url.set_query(Some(&qs));
+
} else {
+
continue;
+
}
+
if let Ok((buf, status)) = self.get_json_bytes(url).await {
+
if status.is_success() {
+
if let Ok(val) =
+
serde_json::from_slice::<serde_json::Value>(&buf)
+
{
+
if let Some(did_str) =
+
val.get("did").and_then(|v| v.as_str())
+
{
+
if let Ok(did) = Did::new_owned(did_str) {
+
return Ok(did.into_static());
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+
// Non-auth path: if PlcSource is Slingshot, use its resolveHandle endpoint.
+
if let PlcSource::Slingshot { base } = &self.opts.plc_source {
+
let mut url = base.clone();
+
url.set_path("/xrpc/com.atproto.identity.resolveHandle");
+
if let Ok(qs) = serde_html_form::to_string(
+
&ResolveHandle::new().handle((*handle).clone()).build(),
+
) {
+
url.set_query(Some(&qs));
+
} else {
+
continue;
+
}
+
if let Ok((buf, status)) = self.get_json_bytes(url).await {
+
if status.is_success() {
+
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&buf) {
+
if let Some(did_str) = val.get("did").and_then(|v| v.as_str()) {
+
if let Ok(did) = Did::new_owned(did_str) {
+
return Ok(did.into_static());
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+
Err(IdentityError::InvalidWellKnown)
+
}
+
+
async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> {
+
let s = did.as_str();
+
for step in &self.opts.did_order {
+
match step {
+
DidStep::DidWebHttps if s.starts_with("did:web:") => {
+
let url = self.did_web_url(did)?;
+
if let Ok((buf, status)) = self.get_json_bytes(url).await {
+
return Ok(DidDocResponse {
+
buffer: buf,
+
status,
+
requested: Some(did.clone().into_static()),
+
});
+
}
+
}
+
DidStep::PlcHttp if s.starts_with("did:plc:") => {
+
let url = match &self.opts.plc_source {
+
PlcSource::PlcDirectory { base } => base.join(did.as_str())?,
+
PlcSource::Slingshot { base } => base.join(did.as_str())?,
+
};
+
if let Ok((buf, status)) = self.get_json_bytes(url).await {
+
return Ok(DidDocResponse {
+
buffer: buf,
+
status,
+
requested: Some(did.clone().into_static()),
+
});
+
}
+
}
+
DidStep::PdsResolveDid => {
+
// Try embedded XRPC client for full DID doc
+
if let Ok(doc) = self.fetch_did_doc_via_pds_owned(did).await {
+
let buf = serde_json::to_vec(&doc).unwrap_or_default();
+
return Ok(DidDocResponse {
+
buffer: Bytes::from(buf),
+
status: StatusCode::OK,
+
requested: Some(did.clone().into_static()),
+
});
+
}
+
// Fallback: if Slingshot configured, return mini-doc response (partial doc)
+
if let PlcSource::Slingshot { base } = &self.opts.plc_source {
+
let url = self.slingshot_mini_doc_url(base, did.as_str())?;
+
let (buf, status) = self.get_json_bytes(url).await?;
+
return Ok(DidDocResponse {
+
buffer: buf,
+
status,
+
requested: Some(did.clone().into_static()),
+
});
+
}
+
}
+
_ => {}
+
}
+
}
+
Err(IdentityError::UnsupportedDidMethod(s.to_string()))
+
}
+
}
+
+
/// Warnings produced during identity checks that are not fatal
+
#[derive(Debug, Clone, PartialEq, Eq)]
+
pub enum IdentityWarning {
+
/// The DID doc did not contain the expected handle alias under alsoKnownAs
+
HandleAliasMismatch { expected: Handle<'static> },
+
}
+
+
impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> {
+
/// Resolve a handle to its DID, fetch the DID document, and return doc plus any warnings.
+
/// This applies the default equality check on the document id (error with doc if mismatch).
+
pub async fn resolve_handle_and_doc(
+
&self,
+
handle: &Handle<'_>,
+
) -> Result<(Did<'static>, DidDocResponse, Vec<IdentityWarning>), IdentityError> {
+
let did = self.resolve_handle(handle).await?;
+
let resp = self.resolve_did_doc(&did).await?;
+
let resp_for_parse = resp.clone();
+
let doc_borrowed = resp_for_parse.parse()?;
+
if self.opts.validate_doc_id && doc_borrowed.id.as_str() != did.as_str() {
+
return Err(IdentityError::DocIdMismatch {
+
expected: did.clone().into_static(),
+
doc: doc_borrowed.clone().into_static(),
+
});
+
}
+
let mut warnings = Vec::new();
+
// Check handle alias presence (soft warning)
+
let expected_alias = format!("at://{}", handle.as_str());
+
let has_alias = doc_borrowed
+
.also_known_as
+
.as_ref()
+
.map(|v| v.iter().any(|s| s.as_ref() == expected_alias))
+
.unwrap_or(false);
+
if !has_alias {
+
warnings.push(IdentityWarning::HandleAliasMismatch {
+
expected: handle.clone().into_static(),
+
});
+
}
+
Ok((did, resp, warnings))
+
}
+
+
/// Build Slingshot mini-doc URL for an identifier (handle or DID)
+
fn slingshot_mini_doc_url(&self, base: &Url, identifier: &str) -> Result<Url, IdentityError> {
+
let mut url = base.clone();
+
url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc");
+
url.set_query(Some(&format!(
+
"identifier={}",
+
urlencoding::Encoded::new(identifier)
+
)));
+
Ok(url)
+
}
+
+
/// Fetch a minimal DID document via Slingshot's mini-doc endpoint using a generic at-identifier
+
pub async fn fetch_mini_doc_via_slingshot_identifier(
+
&self,
+
identifier: &AtIdentifier<'_>,
+
) -> Result<MiniDocResponse, IdentityError> {
+
let base = match &self.opts.plc_source {
+
PlcSource::Slingshot { base } => base.clone(),
+
_ => {
+
return Err(IdentityError::UnsupportedDidMethod(
+
"mini-doc requires Slingshot source".into(),
+
));
+
}
+
};
+
let url = self.slingshot_mini_doc_url(&base, identifier.as_str())?;
+
let (buf, status) = self.get_json_bytes(url).await?;
+
Ok(MiniDocResponse {
+
buffer: buf,
+
status,
+
})
+
}
+
}
+
+
/// Slingshot mini-doc JSON response wrapper
+
#[derive(Clone)]
+
pub struct MiniDocResponse {
+
buffer: Bytes,
+
status: StatusCode,
+
}
+
+
impl MiniDocResponse {
+
/// Parse borrowed MiniDoc
+
pub fn parse<'b>(&'b self) -> Result<MiniDoc<'b>, IdentityError> {
+
if self.status.is_success() {
+
serde_json::from_slice::<MiniDoc<'b>>(&self.buffer).map_err(IdentityError::from)
+
} else {
+
Err(IdentityError::HttpStatus(self.status))
+
}
+
}
+
}
+
+
/// Slingshot mini-doc data (subset of DID doc info)
+
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub struct MiniDoc<'a> {
+
#[serde(borrow)]
+
pub did: Did<'a>,
+
#[serde(borrow)]
+
pub handle: Handle<'a>,
+
#[serde(borrow)]
+
pub pds: crate::CowStr<'a>,
+
#[serde(borrow, rename = "signingKey", alias = "signing_key")]
+
pub signing_key: crate::CowStr<'a>,
+
}
+
+
#[cfg(test)]
+
mod tests {
+
use super::*;
+
+
#[test]
+
fn did_web_urls() {
+
let r = DefaultResolver::new(
+
reqwest::Client::new(),
+
TestXrpc::new(),
+
ResolverOptions::default(),
+
);
+
assert_eq!(
+
r.test_did_web_url_raw("did:web:example.com"),
+
"https://example.com/.well-known/did.json"
+
);
+
assert_eq!(
+
r.test_did_web_url_raw("did:web:example.com:user:alice"),
+
"https://example.com/user/alice/did.json"
+
);
+
}
+
+
#[test]
+
fn parse_validated_ok() {
+
let buf = Bytes::from_static(br#"{"id":"did:plc:alice"}"#);
+
let requested = Did::new_owned("did:plc:alice").unwrap();
+
let resp = DidDocResponse {
+
buffer: buf,
+
status: StatusCode::OK,
+
requested: Some(requested),
+
};
+
let _doc = resp.parse_validated().expect("valid");
+
}
+
+
#[test]
+
fn parse_validated_mismatch() {
+
let buf = Bytes::from_static(br#"{"id":"did:plc:bob"}"#);
+
let requested = Did::new_owned("did:plc:alice").unwrap();
+
let resp = DidDocResponse {
+
buffer: buf,
+
status: StatusCode::OK,
+
requested: Some(requested),
+
};
+
match resp.parse_validated() {
+
Err(IdentityError::DocIdMismatch { expected, doc }) => {
+
assert_eq!(expected.as_str(), "did:plc:alice");
+
assert_eq!(doc.id.as_str(), "did:plc:bob");
+
}
+
other => panic!("unexpected result: {:?}", other),
+
}
+
}
+
+
#[test]
+
fn slingshot_mini_doc_url_build() {
+
let r = DefaultResolver::new(
+
reqwest::Client::new(),
+
TestXrpc::new(),
+
ResolverOptions::default(),
+
);
+
let base = Url::parse("https://slingshot.microcosm.blue").unwrap();
+
let url = r.slingshot_mini_doc_url(&base, "bad-example.com").unwrap();
+
assert_eq!(
+
url.as_str(),
+
"https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=bad-example.com"
+
);
+
}
+
+
#[test]
+
fn slingshot_mini_doc_parse_success() {
+
let buf = Bytes::from_static(
+
br#"{
+
"did": "did:plc:hdhoaan3xa3jiuq4fg4mefid",
+
"handle": "bad-example.com",
+
"pds": "https://porcini.us-east.host.bsky.network",
+
"signing_key": "zQ3shpq1g134o7HGDb86CtQFxnHqzx5pZWknrVX2Waum3fF6j"
+
}"#,
+
);
+
let resp = MiniDocResponse {
+
buffer: buf,
+
status: StatusCode::OK,
+
};
+
let doc = resp.parse().expect("parse mini-doc");
+
assert_eq!(doc.did.as_str(), "did:plc:hdhoaan3xa3jiuq4fg4mefid");
+
assert_eq!(doc.handle.as_str(), "bad-example.com");
+
assert_eq!(
+
doc.pds.as_ref(),
+
"https://porcini.us-east.host.bsky.network"
+
);
+
assert!(doc.signing_key.as_ref().starts_with('z'));
+
}
+
+
#[test]
+
fn slingshot_mini_doc_parse_error_status() {
+
let buf = Bytes::from_static(
+
br#"{
+
"error": "RecordNotFound",
+
"message": "This record was deleted"
+
}"#,
+
);
+
let resp = MiniDocResponse {
+
buffer: buf,
+
status: StatusCode::BAD_REQUEST,
+
};
+
match resp.parse() {
+
Err(IdentityError::HttpStatus(s)) => assert_eq!(s, StatusCode::BAD_REQUEST),
+
other => panic!("unexpected: {:?}", other),
+
}
+
}
+
use crate::client::{HttpClient, XrpcClient};
+
use http::Request;
+
use jacquard_common::CowStr;
+
+
struct TestXrpc {
+
client: reqwest::Client,
+
}
+
impl TestXrpc {
+
fn new() -> Self {
+
Self {
+
client: reqwest::Client::new(),
+
}
+
}
+
}
+
impl HttpClient for TestXrpc {
+
type Error = reqwest::Error;
+
async fn send_http(
+
&self,
+
request: Request<Vec<u8>>,
+
) -> Result<http::Response<Vec<u8>>, Self::Error> {
+
self.client.send_http(request).await
+
}
+
}
+
impl XrpcClient for TestXrpc {
+
fn base_uri(&self) -> CowStr<'_> {
+
CowStr::from("https://public.api.bsky.app")
+
}
+
}
+
}
+
+
/// Resolver specialized for unauthenticated/public flows using reqwest + AuthenticatedClient
+
pub type PublicResolver = DefaultResolver<AuthenticatedClient<reqwest::Client>>;
+
+
impl Default for PublicResolver {
+
/// Build a resolver with:
+
/// - reqwest HTTP client
+
/// - XRPC base https://public.api.bsky.app (unauthenticated)
+
/// - default options (DNS enabled if compiled, public fallback for handles enabled)
+
///
+
/// Example
+
/// ```ignore
+
/// use jacquard::identity::resolver::PublicResolver;
+
/// let resolver = PublicResolver::default();
+
/// ```
+
fn default() -> Self {
+
let http = reqwest::Client::new();
+
let xrpc =
+
AuthenticatedClient::new(http.clone(), CowStr::from("https://public.api.bsky.app"));
+
let opts = ResolverOptions::default();
+
let resolver = DefaultResolver::new(http, xrpc, opts);
+
#[cfg(feature = "dns")]
+
let resolver = resolver.with_system_dns();
+
resolver
+
}
+
}
+
+
/// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and
+
/// mini-doc fallbacks, unauthenticated by default.
+
pub fn slingshot_resolver_default() -> PublicResolver {
+
let http = reqwest::Client::new();
+
let xrpc = AuthenticatedClient::new(http.clone(), CowStr::from("https://public.api.bsky.app"));
+
let mut opts = ResolverOptions::default();
+
opts.plc_source = PlcSource::slingshot_default();
+
let resolver = DefaultResolver::new(http, xrpc, opts);
+
#[cfg(feature = "dns")]
+
let resolver = resolver.with_system_dns();
+
resolver
+
}
+4 -1
crates/jacquard/src/lib.rs
···
//!
//! Dead simple api client. Logs in, prints the latest 5 posts from your timeline.
//!
-
//! ```rust
+
//! ```no_run
//! # use clap::Parser;
//! # use jacquard::CowStr;
//! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
···
#[cfg(feature = "derive")]
/// if enabled, reexport the attribute macros
pub use jacquard_derive::*;
+
+
/// Identity resolution helpers (DIDs, handles, PDS endpoints)
+
pub mod identity;
+14
docs/identity.md
···
+
# Identity Resolution
+
+
This module provides helpers for resolving AT Protocol identifiers (handles and DIDs) and fetching DID documents.
+
+
Highlights:
+
+
- DNS TXT (`_atproto.<handle>`) first when compiled with the `dns` feature, then HTTPS well-known, then Slingshot `resolveHandle` when configured as PLC source.
+
- DID resolution via did:web well-known or PLC base (PLC Directory or Slingshot), returning a `DidDocResponse` that supports borrowed parsing and validation.
+
- 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.
+
- Slingshot: supports unauthenticated `resolveHandle` and a minimal-document endpoint (`com.bad-example.identity.resolveMiniDoc`).
+
- Auth-aware fallbacks: PDS `resolveHandle` / `resolveDid` available via helpers that accept an `XrpcClient`.
+
+
See `jacquard::identity::resolver` rustdoc for examples.
+