From ea4ed6710d65c3da9f2014d0e069d1e6f931742c Mon Sep 17 00:00:00 2001 From: Orual Date: Sat, 4 Oct 2025 13:04:22 -0400 Subject: [PATCH] did doc and handle/did/doc resolution --- .gitignore | 2 + Cargo.lock | 527 +++++++++- Cargo.toml | 2 +- codegen_plan.md | 378 ------- crates/jacquard-api/Cargo.toml | 2 +- crates/jacquard-common/Cargo.toml | 28 +- crates/jacquard-common/src/types.rs | 4 + crates/jacquard-common/src/types/crypto.rs | 298 ++++++ crates/jacquard-common/src/types/did_doc.rs | 264 +++++ .../src/types/test_did_doc.json | 24 + .../src/types/value/broken_record.json | 12 + crates/jacquard-derive/Cargo.toml | 2 +- crates/jacquard-lexicon/Cargo.toml | 2 +- crates/jacquard/Cargo.toml | 10 +- crates/jacquard/src/client.rs | 20 +- crates/jacquard/src/identity/mod.rs | 3 + crates/jacquard/src/identity/resolver.rs | 960 ++++++++++++++++++ crates/jacquard/src/lib.rs | 5 +- docs/identity.md | 14 + 19 files changed, 2153 insertions(+), 404 deletions(-) delete mode 100644 codegen_plan.md create mode 100644 crates/jacquard-common/src/types/crypto.rs create mode 100644 crates/jacquard-common/src/types/did_doc.rs create mode 100644 crates/jacquard-common/src/types/test_did_doc.json create mode 100644 crates/jacquard-common/src/types/value/broken_record.json create mode 100644 crates/jacquard/src/identity/mod.rs create mode 100644 crates/jacquard/src/identity/resolver.rs create mode 100644 docs/identity.md diff --git a/.gitignore b/.gitignore index e5fc2754..24a4d865 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,7 @@ .claude /.pre-commit-config.yaml CLAUDE.md +AGENTS.md crates/jacquard-lexicon/tests/fixtures/lexicons/atproto crates/jacquard-lexicon/target +codegen_plan.md diff --git a/Cargo.lock b/Cargo.lock index df8b65cc..5ee61708 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,6 +123,17 @@ dependencies = [ "tokio", ] +[[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" @@ -165,6 +176,12 @@ 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" version = "0.22.1" @@ -417,6 +434,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" 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" @@ -466,6 +489,18 @@ version = "0.2.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" @@ -476,6 +511,32 @@ dependencies = [ "typenum", ] +[[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" @@ -537,6 +598,16 @@ dependencies = [ "syn 2.0.106", ] +[[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" @@ -574,12 +645,51 @@ version = "1.0.20" 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" @@ -589,6 +699,18 @@ dependencies = [ "cfg-if", ] +[[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" @@ -623,6 +745,22 @@ version = "2.3.0" 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" @@ -669,6 +807,12 @@ version = "0.3.31" 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" @@ -691,6 +835,7 @@ dependencies = [ "futures-task", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -701,6 +846,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -736,6 +882,17 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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" @@ -801,6 +958,51 @@ version = "0.3.0" 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" @@ -898,7 +1100,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.0", "system-configuration", "tokio", "tower-service", @@ -1083,6 +1285,18 @@ dependencies = [ "libc", ] +[[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" @@ -1141,20 +1355,27 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 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]] @@ -1175,18 +1396,22 @@ name = "jacquard-common" 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", @@ -1245,6 +1470,16 @@ dependencies = [ "wasm-bindgen", ] +[[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" @@ -1262,6 +1497,12 @@ version = "0.2.176" 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" @@ -1274,12 +1515,30 @@ version = "0.8.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" version = "0.1.2" @@ -1452,6 +1711,39 @@ version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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" @@ -1470,6 +1762,16 @@ version = "0.1.0" 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" @@ -1504,6 +1806,15 @@ dependencies = [ "syn 2.0.106", ] +[[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" @@ -1563,7 +1874,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.0", "thiserror 2.0.17", "tokio", "tracing", @@ -1579,7 +1890,7 @@ dependencies = [ "bytes", "getrandom 0.3.3", "lru-slab", - "rand", + "rand 0.9.2", "ring", "rustc-hash", "rustls", @@ -1600,7 +1911,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.0", "tracing", "windows-sys 0.60.2", ] @@ -1620,14 +1931,35 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[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]] @@ -1637,7 +1969,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" 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]] @@ -1655,6 +1996,15 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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" @@ -1748,6 +2098,12 @@ dependencies = [ "webpki-roots", ] +[[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" @@ -1774,6 +2130,15 @@ version = "2.1.1" 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" @@ -1864,6 +2229,25 @@ version = "1.2.0" 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" @@ -2013,6 +2397,12 @@ version = "1.3.0" 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" @@ -2035,6 +2425,16 @@ dependencies = [ "serde", ] +[[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" @@ -2045,6 +2445,15 @@ dependencies = [ "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]] name = "stable_deref_trait" version = "1.2.0" @@ -2314,7 +2723,7 @@ dependencies = [ "mio", "pin-project-lite", "slab", - "socket2", + "socket2 0.6.0", "tokio-macros", "windows-sys 0.59.0", ] @@ -2405,9 +2814,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" 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" @@ -2477,6 +2898,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2629,6 +3056,12 @@ dependencies = [ "rustls-pki-types", ] +[[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" @@ -2723,6 +3156,15 @@ dependencies = [ "windows-link 0.2.0", ] +[[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" @@ -2750,6 +3192,21 @@ dependencies = [ "windows-targets 0.53.4", ] +[[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" @@ -2783,6 +3240,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[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" @@ -2795,6 +3258,12 @@ 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" version = "0.52.6" @@ -2807,6 +3276,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[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" @@ -2831,6 +3306,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[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" @@ -2843,6 +3324,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[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" @@ -2855,6 +3342,12 @@ 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" version = "0.52.6" @@ -2867,6 +3360,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[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" @@ -2879,6 +3378,16 @@ 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" version = "0.46.0" diff --git a/Cargo.toml b/Cargo.toml index 7416de32..aac919d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ categories = ["api-bindings", "web-programming::http-client"] 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" diff --git a/codegen_plan.md b/codegen_plan.md deleted file mode 100644 index abb27f9f..00000000 --- a/codegen_plan.md +++ /dev/null @@ -1,378 +0,0 @@ -# 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>, - 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>), - #[serde(rename = "app.bsky.embed.video")] - Video(Box>), -} -``` - -- Use `Box` 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, - pub cursor: Option>, -} - -pub struct GetAuthorFeedOutput<'s> { - pub cursor: Option>, - pub feed: Vec>, -} -``` - -- 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` -- 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` -- `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>` - 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` → 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` 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, - pub cursor: Option>, -} - -pub struct GetAuthorFeedOutput<'a> { - pub cursor: Option>, - pub feed: Vec>, -} - -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 diff --git a/crates/jacquard-api/Cargo.toml b/crates/jacquard-api/Cargo.toml index e8056905..dd2f091d 100644 --- a/crates/jacquard-api/Cargo.toml +++ b/crates/jacquard-api/Cargo.toml @@ -9,7 +9,7 @@ keywords.workspace = true categories.workspace = true readme.workspace = true exclude.workspace = true -license-file.workspace = true +license.workspace = true [features] default = [ "com_atproto"] diff --git a/crates/jacquard-common/Cargo.toml b/crates/jacquard-common/Cargo.toml index c49f7798..58bfd9d2 100644 --- a/crates/jacquard-common/Cargo.toml +++ b/crates/jacquard-common/Cargo.toml @@ -9,10 +9,11 @@ keywords.workspace = true 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" @@ -34,3 +35,28 @@ serde_with.workspace = true 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"] diff --git a/crates/jacquard-common/src/types.rs b/crates/jacquard-common/src/types.rs index f3318b63..f2e47931 100644 --- a/crates/jacquard-common/src/types.rs +++ b/crates/jacquard-common/src/types.rs @@ -12,6 +12,10 @@ pub mod collection; 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) diff --git a/crates/jacquard-common/src/types/crypto.rs b/crates/jacquard-common/src/types/crypto.rs new file mode 100644 index 00000000..047b12b4 --- /dev/null +++ b/crates/jacquard-common/src/types/crypto.rs @@ -0,0 +1,298 @@ +//! 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 { 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, 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 { + 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 { + 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 { + 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) -> Result, 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 { + 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(_))); + } +} diff --git a/crates/jacquard-common/src/types/did_doc.rs b/crates/jacquard-common/src/types/did_doc.rs new file mode 100644 index 00000000..fb910d69 --- /dev/null +++ b/crates/jacquard-common/src/types/did_doc.rs @@ -0,0 +1,264 @@ +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:// + #[serde(borrow)] + pub also_known_as: Option>>, + + /// Verification methods (keys) for this DID + #[serde(borrow)] + pub verification_method: Option>>, + + /// Services associated with this DID (e.g., AtprotoPersonalDataServer) + #[serde(borrow)] + pub service: Option>>, + + /// Forward‑compatible capture of unmodeled fields + #[serde(flatten)] + pub extra_data: BTreeMap>, +} + +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://`. + pub fn handles(&self) -> Vec> { + 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> { + 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 { + 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>, 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>, + /// Multikey `publicKeyMultibase` (base58btc) + #[serde(borrow)] + pub public_key_multibase: Option>, + + /// Forward‑compatible capture of unmodeled fields + #[serde(flatten)] + pub extra_data: BTreeMap>, +} + +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>, + + /// Forward‑compatible capture of unmodeled fields + #[serde(flatten)] + pub extra_data: BTreeMap>, +} + +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 { + 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"); + } +} diff --git a/crates/jacquard-common/src/types/test_did_doc.json b/crates/jacquard-common/src/types/test_did_doc.json new file mode 100644 index 00000000..6f2be75c --- /dev/null +++ b/crates/jacquard-common/src/types/test_did_doc.json @@ -0,0 +1,24 @@ +{ + "@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" + } + ] +} diff --git a/crates/jacquard-common/src/types/value/broken_record.json b/crates/jacquard-common/src/types/value/broken_record.json new file mode 100644 index 00000000..04ddf612 --- /dev/null +++ b/crates/jacquard-common/src/types/value/broken_record.json @@ -0,0 +1,12 @@ +{ + "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" + } +} diff --git a/crates/jacquard-derive/Cargo.toml b/crates/jacquard-derive/Cargo.toml index 999274dc..de174256 100644 --- a/crates/jacquard-derive/Cargo.toml +++ b/crates/jacquard-derive/Cargo.toml @@ -9,7 +9,7 @@ keywords.workspace = true categories.workspace = true readme.workspace = true exclude.workspace = true -license-file.workspace = true +license.workspace = true [lib] proc-macro = true diff --git a/crates/jacquard-lexicon/Cargo.toml b/crates/jacquard-lexicon/Cargo.toml index 6573d331..b0785bcf 100644 --- a/crates/jacquard-lexicon/Cargo.toml +++ b/crates/jacquard-lexicon/Cargo.toml @@ -9,7 +9,7 @@ keywords.workspace = true categories.workspace = true readme.workspace = true exclude.workspace = true -license-file.workspace = true +license.workspace = true [[bin]] name = "jacquard-codegen" diff --git a/crates/jacquard/Cargo.toml b/crates/jacquard/Cargo.toml index bee83132..deb8f9d7 100644 --- a/crates/jacquard/Cargo.toml +++ b/crates/jacquard/Cargo.toml @@ -9,13 +9,14 @@ keywords.workspace = true 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" @@ -26,6 +27,8 @@ name = "jacquard" path = "src/main.rs" [dependencies] +bon = "3" +async-trait = "0.1" bytes.workspace = true clap.workspace = true http.workspace = true @@ -40,3 +43,8 @@ serde_ipld_dagcbor.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" diff --git a/crates/jacquard/src/client.rs b/crates/jacquard/src/client.rs index 0c857fbc..15a85c55 100644 --- a/crates/jacquard/src/client.rs +++ b/crates/jacquard/src/client.rs @@ -69,10 +69,10 @@ pub trait HttpClient { fn send_http( &self, request: Request>, - ) -> impl Future>, Self::Error>>; + ) -> impl Future>, 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 @@ -80,21 +80,21 @@ pub trait XrpcClient: HttpClient { fn authorization_token( &self, is_refresh: bool, - ) -> impl Future>> { + ) -> impl Future>> + Send { async { None } } /// Get the `atproto-proxy` header. - fn atproto_proxy_header(&self) -> impl Future> { + fn atproto_proxy_header(&self) -> impl Future> + Send { async { None } } /// Get the `atproto-accept-labelers` header. - fn atproto_accept_labelers_header(&self) -> impl Future>> { + fn atproto_accept_labelers_header(&self) -> impl Future>> + Send { async { None } } /// Send an XRPC request and get back a response - fn send(&self, request: R) -> impl Future>> + fn send(&self, request: R) -> impl Future>> + Send where - Self: Sized, + Self: Sized + Sync, { send_xrpc(self, request) } @@ -149,8 +149,8 @@ impl From
for HeaderName { /// Generic XRPC send implementation that uses HttpClient async fn send_xrpc(client: &C, request: R) -> Result> 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); @@ -314,7 +314,7 @@ impl HttpClient for AuthenticatedClient { } } -impl XrpcClient for AuthenticatedClient { +impl XrpcClient for AuthenticatedClient { fn base_uri(&self) -> CowStr<'_> { self.base_uri.clone() } diff --git a/crates/jacquard/src/identity/mod.rs b/crates/jacquard/src/identity/mod.rs new file mode 100644 index 00000000..5f8e5724 --- /dev/null +++ b/crates/jacquard/src/identity/mod.rs @@ -0,0 +1,3 @@ +//! Identity resolution utilities: DID and handle resolution, DID document fetch, +//! and helpers for PDS endpoint discovery. See `identity::resolver` for details. +pub mod resolver; diff --git a/crates/jacquard/src/identity/resolver.rs b/crates/jacquard/src/identity/resolver.rs new file mode 100644 index 00000000..4e4a9507 --- /dev/null +++ b/crates/jacquard/src/identity/resolver.rs @@ -0,0 +1,960 @@ +//! 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>, +} + +impl DidDocResponse { + /// Parse as borrowed DidDocument<'_> + pub fn parse<'b>(&'b self) -> Result, IdentityError> { + if self.status.is_success() { + serde_json::from_slice::>(&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, 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, IdentityError> { + if self.status.is_success() { + serde_json::from_slice::>(&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. + DnsTxt, + /// HTTPS GET https:///.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, + /// Order of attempts for handle → DID resolution + pub handle_order: Vec, + /// Order of attempts for DID → Doc resolution + pub did_order: Vec, + /// 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.`) 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, IdentityError>; + + /// Resolve DID document + async fn resolve_did_doc(&self, did: &Did<'_>) -> Result; + async fn resolve_did_doc_owned( + &self, + did: &Did<'_>, + ) -> Result, IdentityError> { + self.resolve_did_doc(did).await?.into_owned() + } + async fn pds_for_did(&self, did: &Did<'_>) -> Result { + 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 { + http: reqwest::Client, + xrpc: C, + opts: ResolverOptions, + #[cfg(feature = "dns")] + dns: Option, +} + +impl DefaultResolver { + 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 { + // 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 { + 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, 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, 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 DefaultResolver { + /// Resolve handle to DID via a PDS XRPC client (auth-aware path) + pub async fn resolve_handle_via_pds( + &self, + handle: &Handle<'_>, + ) -> Result, 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, 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 { + 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 IdentityResolver for DefaultResolver { + fn options(&self) -> &ResolverOptions { + &self.opts + } + async fn resolve_handle(&self, handle: &Handle<'_>) -> Result, 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::(&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::(&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 { + 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 DefaultResolver { + /// 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), 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 { + 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 { + 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, IdentityError> { + if self.status.is_success() { + serde_json::from_slice::>(&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>, + ) -> Result>, 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>; + +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 +} diff --git a/crates/jacquard/src/lib.rs b/crates/jacquard/src/lib.rs index 40fe3f71..68fb71dd 100644 --- a/crates/jacquard/src/lib.rs +++ b/crates/jacquard/src/lib.rs @@ -19,7 +19,7 @@ //! //! 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; @@ -100,3 +100,6 @@ pub use jacquard_common::*; #[cfg(feature = "derive")] /// if enabled, reexport the attribute macros pub use jacquard_derive::*; + +/// Identity resolution helpers (DIDs, handles, PDS endpoints) +pub mod identity; diff --git a/docs/identity.md b/docs/identity.md new file mode 100644 index 00000000..cc7fe73e --- /dev/null +++ b/docs/identity.md @@ -0,0 +1,14 @@ +# Identity Resolution + +This module provides helpers for resolving AT Protocol identifiers (handles and DIDs) and fetching DID documents. + +Highlights: + +- DNS TXT (`_atproto.`) 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. + -- 2.51.2