An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(MM-97): implement GET /xrpc/com.atproto.identity.resolveHandle

Resolves an ATProto handle to a DID via local DB lookup with DNS TXT
fallback. Adds TxtResolver trait + HickoryTxtResolver production impl,
HandleNotFound error code, and txt_resolver field on AppState.

authored by

Malpercio and committed by
Tangled
e9384b65 aa9ee61d

+661 -89
+215 -18
Cargo.lock
··· 489 489 checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" 490 490 491 491 [[package]] 492 + name = "critical-section" 493 + version = "1.2.0" 494 + source = "registry+https://github.com/rust-lang/crates.io-index" 495 + checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" 496 + 497 + [[package]] 498 + name = "crossbeam-channel" 499 + version = "0.5.15" 500 + source = "registry+https://github.com/rust-lang/crates.io-index" 501 + checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" 502 + dependencies = [ 503 + "crossbeam-utils", 504 + ] 505 + 506 + [[package]] 507 + name = "crossbeam-epoch" 508 + version = "0.9.18" 509 + source = "registry+https://github.com/rust-lang/crates.io-index" 510 + checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" 511 + dependencies = [ 512 + "crossbeam-utils", 513 + ] 514 + 515 + [[package]] 492 516 name = "crossbeam-queue" 493 517 version = "0.3.12" 494 518 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 519 543 "data-encoding", 520 544 "multibase", 521 545 "p256", 522 - "rand_core", 546 + "rand_core 0.6.4", 523 547 "serde", 524 548 "serde_json", 525 549 "sha2", ··· 534 558 checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" 535 559 dependencies = [ 536 560 "generic-array", 537 - "rand_core", 561 + "rand_core 0.6.4", 538 562 "subtle", 539 563 "zeroize", 540 564 ] ··· 546 570 checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" 547 571 dependencies = [ 548 572 "generic-array", 549 - "rand_core", 573 + "rand_core 0.6.4", 550 574 "typenum", 551 575 ] 552 576 ··· 680 704 "group", 681 705 "pem-rfc7468", 682 706 "pkcs8", 683 - "rand_core", 707 + "rand_core 0.6.4", 684 708 "sec1", 685 709 "subtle", 686 710 "zeroize", ··· 693 717 checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" 694 718 dependencies = [ 695 719 "cfg-if", 720 + ] 721 + 722 + [[package]] 723 + name = "enum-as-inner" 724 + version = "0.6.1" 725 + source = "registry+https://github.com/rust-lang/crates.io-index" 726 + checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" 727 + dependencies = [ 728 + "heck", 729 + "proc-macro2", 730 + "quote", 731 + "syn", 696 732 ] 697 733 698 734 [[package]] ··· 745 781 source = "registry+https://github.com/rust-lang/crates.io-index" 746 782 checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" 747 783 dependencies = [ 748 - "rand_core", 784 + "rand_core 0.6.4", 749 785 "subtle", 750 786 ] 751 787 ··· 925 961 926 962 [[package]] 927 963 name = "getrandom" 964 + version = "0.3.4" 965 + source = "registry+https://github.com/rust-lang/crates.io-index" 966 + checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 967 + dependencies = [ 968 + "cfg-if", 969 + "libc", 970 + "r-efi 5.3.0", 971 + "wasip2", 972 + ] 973 + 974 + [[package]] 975 + name = "getrandom" 928 976 version = "0.4.2" 929 977 source = "registry+https://github.com/rust-lang/crates.io-index" 930 978 checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" 931 979 dependencies = [ 932 980 "cfg-if", 933 981 "libc", 934 - "r-efi", 982 + "r-efi 6.0.0", 935 983 "wasip2", 936 984 "wasip3", 937 985 ] ··· 959 1007 checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" 960 1008 dependencies = [ 961 1009 "ff", 962 - "rand_core", 1010 + "rand_core 0.6.4", 963 1011 "subtle", 964 1012 ] 965 1013 ··· 1042 1090 version = "0.4.3" 1043 1091 source = "registry+https://github.com/rust-lang/crates.io-index" 1044 1092 checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 1093 + 1094 + [[package]] 1095 + name = "hickory-proto" 1096 + version = "0.25.2" 1097 + source = "registry+https://github.com/rust-lang/crates.io-index" 1098 + checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" 1099 + dependencies = [ 1100 + "async-trait", 1101 + "cfg-if", 1102 + "data-encoding", 1103 + "enum-as-inner", 1104 + "futures-channel", 1105 + "futures-io", 1106 + "futures-util", 1107 + "idna", 1108 + "ipnet", 1109 + "once_cell", 1110 + "rand 0.9.2", 1111 + "ring", 1112 + "thiserror", 1113 + "tinyvec", 1114 + "tokio", 1115 + "tracing", 1116 + "url", 1117 + ] 1118 + 1119 + [[package]] 1120 + name = "hickory-resolver" 1121 + version = "0.25.2" 1122 + source = "registry+https://github.com/rust-lang/crates.io-index" 1123 + checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" 1124 + dependencies = [ 1125 + "cfg-if", 1126 + "futures-util", 1127 + "hickory-proto", 1128 + "ipconfig", 1129 + "moka", 1130 + "once_cell", 1131 + "parking_lot", 1132 + "rand 0.9.2", 1133 + "resolv-conf", 1134 + "smallvec", 1135 + "thiserror", 1136 + "tokio", 1137 + "tracing", 1138 + ] 1045 1139 1046 1140 [[package]] 1047 1141 name = "hkdf" ··· 1348 1442 ] 1349 1443 1350 1444 [[package]] 1445 + name = "ipconfig" 1446 + version = "0.3.2" 1447 + source = "registry+https://github.com/rust-lang/crates.io-index" 1448 + checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" 1449 + dependencies = [ 1450 + "socket2 0.5.10", 1451 + "widestring", 1452 + "windows-sys 0.48.0", 1453 + "winreg", 1454 + ] 1455 + 1456 + [[package]] 1351 1457 name = "ipnet" 1352 1458 version = "2.12.0" 1353 1459 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1531 1637 ] 1532 1638 1533 1639 [[package]] 1640 + name = "moka" 1641 + version = "0.12.14" 1642 + source = "registry+https://github.com/rust-lang/crates.io-index" 1643 + checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b" 1644 + dependencies = [ 1645 + "crossbeam-channel", 1646 + "crossbeam-epoch", 1647 + "crossbeam-utils", 1648 + "equivalent", 1649 + "parking_lot", 1650 + "portable-atomic", 1651 + "smallvec", 1652 + "tagptr", 1653 + "uuid", 1654 + ] 1655 + 1656 + [[package]] 1534 1657 name = "multibase" 1535 1658 version = "0.9.2" 1536 1659 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1579 1702 "num-integer", 1580 1703 "num-iter", 1581 1704 "num-traits", 1582 - "rand", 1705 + "rand 0.8.5", 1583 1706 "smallvec", 1584 1707 "zeroize", 1585 1708 ] ··· 1629 1752 version = "1.21.3" 1630 1753 source = "registry+https://github.com/rust-lang/crates.io-index" 1631 1754 checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 1755 + dependencies = [ 1756 + "critical-section", 1757 + "portable-atomic", 1758 + ] 1632 1759 1633 1760 [[package]] 1634 1761 name = "once_cell_polyfill" ··· 1760 1887 "glob", 1761 1888 "opentelemetry", 1762 1889 "percent-encoding", 1763 - "rand", 1890 + "rand 0.8.5", 1764 1891 "serde_json", 1765 1892 "thiserror", 1766 1893 "tokio", ··· 1902 2029 ] 1903 2030 1904 2031 [[package]] 2032 + name = "portable-atomic" 2033 + version = "1.13.1" 2034 + source = "registry+https://github.com/rust-lang/crates.io-index" 2035 + checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" 2036 + 2037 + [[package]] 1905 2038 name = "potential_utf" 1906 2039 version = "0.1.4" 1907 2040 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1981 2114 1982 2115 [[package]] 1983 2116 name = "r-efi" 2117 + version = "5.3.0" 2118 + source = "registry+https://github.com/rust-lang/crates.io-index" 2119 + checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 2120 + 2121 + [[package]] 2122 + name = "r-efi" 1984 2123 version = "6.0.0" 1985 2124 source = "registry+https://github.com/rust-lang/crates.io-index" 1986 2125 checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" ··· 1992 2131 checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 1993 2132 dependencies = [ 1994 2133 "libc", 1995 - "rand_chacha", 1996 - "rand_core", 2134 + "rand_chacha 0.3.1", 2135 + "rand_core 0.6.4", 2136 + ] 2137 + 2138 + [[package]] 2139 + name = "rand" 2140 + version = "0.9.2" 2141 + source = "registry+https://github.com/rust-lang/crates.io-index" 2142 + checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 2143 + dependencies = [ 2144 + "rand_chacha 0.9.0", 2145 + "rand_core 0.9.5", 1997 2146 ] 1998 2147 1999 2148 [[package]] ··· 2003 2152 checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 2004 2153 dependencies = [ 2005 2154 "ppv-lite86", 2006 - "rand_core", 2155 + "rand_core 0.6.4", 2156 + ] 2157 + 2158 + [[package]] 2159 + name = "rand_chacha" 2160 + version = "0.9.0" 2161 + source = "registry+https://github.com/rust-lang/crates.io-index" 2162 + checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 2163 + dependencies = [ 2164 + "ppv-lite86", 2165 + "rand_core 0.9.5", 2007 2166 ] 2008 2167 2009 2168 [[package]] ··· 2016 2175 ] 2017 2176 2018 2177 [[package]] 2178 + name = "rand_core" 2179 + version = "0.9.5" 2180 + source = "registry+https://github.com/rust-lang/crates.io-index" 2181 + checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" 2182 + dependencies = [ 2183 + "getrandom 0.3.4", 2184 + ] 2185 + 2186 + [[package]] 2019 2187 name = "redox_syscall" 2020 2188 version = "0.5.18" 2021 2189 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2072 2240 "clap", 2073 2241 "common", 2074 2242 "crypto", 2243 + "hickory-resolver", 2075 2244 "opentelemetry", 2076 2245 "opentelemetry-otlp", 2077 2246 "opentelemetry_sdk", 2078 - "rand_core", 2247 + "rand_core 0.6.4", 2079 2248 "reqwest", 2080 2249 "serde", 2081 2250 "serde_json", ··· 2142 2311 ] 2143 2312 2144 2313 [[package]] 2314 + name = "resolv-conf" 2315 + version = "0.7.6" 2316 + source = "registry+https://github.com/rust-lang/crates.io-index" 2317 + checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" 2318 + 2319 + [[package]] 2145 2320 name = "rfc6979" 2146 2321 version = "0.4.0" 2147 2322 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2178 2353 "num-traits", 2179 2354 "pkcs1", 2180 2355 "pkcs8", 2181 - "rand_core", 2356 + "rand_core 0.6.4", 2182 2357 "signature", 2183 2358 "spki", 2184 2359 "subtle", ··· 2430 2605 checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 2431 2606 dependencies = [ 2432 2607 "digest", 2433 - "rand_core", 2608 + "rand_core 0.6.4", 2434 2609 ] 2435 2610 2436 2611 [[package]] ··· 2601 2776 "memchr", 2602 2777 "once_cell", 2603 2778 "percent-encoding", 2604 - "rand", 2779 + "rand 0.8.5", 2605 2780 "rsa", 2606 2781 "serde", 2607 2782 "sha1", ··· 2639 2814 "md-5", 2640 2815 "memchr", 2641 2816 "once_cell", 2642 - "rand", 2817 + "rand 0.8.5", 2643 2818 "serde", 2644 2819 "serde_json", 2645 2820 "sha2", ··· 2757 2932 ] 2758 2933 2759 2934 [[package]] 2935 + name = "tagptr" 2936 + version = "0.2.0" 2937 + source = "registry+https://github.com/rust-lang/crates.io-index" 2938 + checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" 2939 + 2940 + [[package]] 2760 2941 name = "tempfile" 2761 2942 version = "3.26.0" 2762 2943 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2977 3158 "indexmap 1.9.3", 2978 3159 "pin-project", 2979 3160 "pin-project-lite", 2980 - "rand", 3161 + "rand 0.8.5", 2981 3162 "slab", 2982 3163 "tokio", 2983 3164 "tokio-util", ··· 3406 3587 ] 3407 3588 3408 3589 [[package]] 3590 + name = "widestring" 3591 + version = "1.2.1" 3592 + source = "registry+https://github.com/rust-lang/crates.io-index" 3593 + checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" 3594 + 3595 + [[package]] 3409 3596 name = "windows-link" 3410 3597 version = "0.2.1" 3411 3598 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3595 3782 checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" 3596 3783 dependencies = [ 3597 3784 "memchr", 3785 + ] 3786 + 3787 + [[package]] 3788 + name = "winreg" 3789 + version = "0.50.0" 3790 + source = "registry+https://github.com/rust-lang/crates.io-index" 3791 + checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" 3792 + dependencies = [ 3793 + "cfg-if", 3794 + "windows-sys 0.48.0", 3598 3795 ] 3599 3796 3600 3797 [[package]]
+3
Cargo.toml
··· 69 69 subtle = "2" 70 70 uuid = { version = "1", features = ["v4"] } 71 71 72 + # DNS resolver (relay — handle resolution fallback via TXT records) 73 + hickory-resolver = { version = "0.25", features = ["tokio", "system-config"] } 74 + 72 75 # Testing 73 76 tempfile = "3" 74 77
+16
bruno/resolve_handle.bru
··· 1 + meta { 2 + name: Resolve Handle 3 + type: http 4 + seq: 10 5 + } 6 + 7 + get { 8 + url: {{baseUrl}}/xrpc/com.atproto.identity.resolveHandle?handle={{handle}} 9 + body: none 10 + auth: none 11 + } 12 + 13 + vars:pre-request { 14 + baseUrl: http://localhost:8080 15 + handle: alice.example.com 16 + }
+5 -1
crates/common/src/error.rs
··· 44 44 PlcDirectoryError, 45 45 /// A configured DNS provider returned an error when creating a subdomain record. 46 46 DnsError, 47 + /// The requested handle does not resolve to a known DID locally or via DNS. 48 + HandleNotFound, 47 49 // TODO: add remaining codes from Appendix A as endpoints are implemented: 48 50 // 400: INVALID_DOCUMENT, INVALID_PROOF, INVALID_ENDPOINT, INVALID_CONFIRMATION 49 51 // 401: INVALID_CREDENTIALS 50 52 // 403: TIER_RESTRICTED, DIDWEB_REQUIRES_DOMAIN, SINGLE_DEVICE_TIER 51 - // 404: DEVICE_NOT_FOUND, DID_NOT_FOUND, HANDLE_NOT_FOUND, NOT_IN_GRACE_PERIOD 53 + // 404: DEVICE_NOT_FOUND, DID_NOT_FOUND, NOT_IN_GRACE_PERIOD 52 54 // 409: ACCOUNT_NOT_FOUND, DEVICE_LIMIT, DID_EXISTS, 53 55 // ROTATION_IN_PROGRESS, LEASE_HELD, MIGRATION_IN_PROGRESS, ACTIVE_MIGRATION 54 56 // 410: ALREADY_DELETED ··· 78 80 ErrorCode::DidAlreadyExists => 409, 79 81 ErrorCode::PlcDirectoryError => 502, 80 82 ErrorCode::DnsError => 502, 83 + ErrorCode::HandleNotFound => 404, 81 84 } 82 85 } 83 86 } ··· 232 235 (ErrorCode::DidAlreadyExists, 409), 233 236 (ErrorCode::PlcDirectoryError, 502), 234 237 (ErrorCode::DnsError, 502), 238 + (ErrorCode::HandleNotFound, 404), 235 239 ]; 236 240 for (code, expected) in cases { 237 241 assert_eq!(code.status_code(), expected, "wrong status for {code:?}");
+1
crates/relay/Cargo.toml
··· 35 35 subtle = { workspace = true } 36 36 uuid = { workspace = true } 37 37 zeroize = { workspace = true } 38 + hickory-resolver = { workspace = true } 38 39 39 40 [dev-dependencies] 40 41 tower = { workspace = true }
+11 -1
crates/relay/src/app.rs
··· 12 12 use tower_http::{cors::CorsLayer, trace::TraceLayer}; 13 13 use tracing_opentelemetry::OpenTelemetrySpanExt; 14 14 15 - use crate::dns::DnsProvider; 15 + use crate::dns::{DnsProvider, TxtResolver}; 16 16 use crate::routes::claim_codes::claim_codes; 17 17 use crate::routes::create_account::create_account; 18 18 use crate::routes::create_did::create_did_handler; ··· 22 22 use crate::routes::describe_server::describe_server; 23 23 use crate::routes::health::health; 24 24 use crate::routes::register_device::register_device; 25 + use crate::routes::resolve_handle::resolve_handle_handler; 25 26 26 27 /// Wraps an `axum::http::HeaderMap` as an OTel text-map [`Extractor`] so that 27 28 /// the W3C `traceparent` and `tracestate` headers can be read by the global propagator. ··· 85 86 /// `None` in v0.1 — operators manage DNS records manually. 86 87 /// Wired in by MM-142 (DNS provider integration). 87 88 pub dns_provider: Option<Arc<dyn DnsProvider>>, 89 + /// Optional DNS TXT resolver for handle resolution fallback. 90 + /// When `None`, `resolveHandle` skips DNS and returns `HandleNotFound` for 91 + /// handles not present in the local database. 92 + pub txt_resolver: Option<Arc<dyn TxtResolver>>, 88 93 } 89 94 90 95 /// Build the Axum router with middleware and routes. ··· 97 102 .route( 98 103 "/xrpc/com.atproto.server.describeServer", 99 104 get(describe_server), 105 + ) 106 + .route( 107 + "/xrpc/com.atproto.identity.resolveHandle", 108 + get(resolve_handle_handler), 100 109 ) 101 110 .route("/xrpc/:method", get(xrpc_handler).post(xrpc_handler)) 102 111 .route("/v1/accounts", post(create_account)) ··· 165 174 db, 166 175 http_client, 167 176 dns_provider: None, 177 + txt_resolver: None, 168 178 } 169 179 } 170 180
+67 -5
crates/relay/src/dns.rs
··· 1 - // DNS provider abstraction for subdomain record management. 1 + // DNS abstractions for handle management. 2 2 // 3 - // Implementations create DNS records when handles are registered (POST /v1/handles). 4 - // For v0.1, AppState carries `dns_provider: None` and no records are created 5 - // automatically — operators manage DNS manually. 3 + // DnsProvider — creates DNS records when handles are registered (POST /v1/handles). 4 + // For v0.1, AppState carries `dns_provider: None`; operators manage DNS manually. 5 + // MM-142 wires in real provider implementations (Cloudflare, Route53). 6 6 // 7 - // MM-142 wires in real provider implementations (Cloudflare, Route53). 7 + // TxtResolver — resolves DNS TXT records for handle lookup fallback 8 + // (GET /xrpc/com.atproto.identity.resolveHandle). 9 + // HickoryTxtResolver is the production implementation; tests inject mocks. 8 10 9 11 use std::future::Future; 10 12 use std::pin::Pin; ··· 13 15 #[derive(Debug, thiserror::Error)] 14 16 #[error("DNS provider error: {0}")] 15 17 pub struct DnsError(pub String); 18 + 19 + /// Abstraction over DNS TXT record resolution. 20 + /// 21 + /// Used by `resolveHandle` to perform the DNS-based handle fallback lookup. 22 + /// `AppState.txt_resolver` holds `None` when DNS resolution is not needed (tests 23 + /// exercising only the local-DB path, or configurations without DNS fallback). 24 + /// 25 + /// Object-safe: uses `Pin<Box<dyn Future>>` so `dyn TxtResolver` works with `Arc`. 26 + pub trait TxtResolver: Send + Sync { 27 + /// Look up TXT records for `name` (e.g. `"_atproto.alice.example.com"`). 28 + /// 29 + /// Returns the string values from all TXT records, or an empty vec if the 30 + /// name does not exist. The caller is responsible for filtering by prefix 31 + /// (e.g. `did=`). 32 + fn txt_lookup<'a>( 33 + &'a self, 34 + name: &'a str, 35 + ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, DnsError>> + Send + 'a>>; 36 + } 37 + 38 + /// Production [`TxtResolver`] backed by `hickory-resolver` using the system DNS config. 39 + pub struct HickoryTxtResolver { 40 + inner: hickory_resolver::Resolver<hickory_resolver::name_server::TokioConnectionProvider>, 41 + } 42 + 43 + impl HickoryTxtResolver { 44 + /// Create a resolver using system `/etc/resolv.conf` (or platform equivalent). 45 + pub fn from_system_conf() -> anyhow::Result<Self> { 46 + Ok(Self { 47 + inner: hickory_resolver::Resolver::builder_tokio() 48 + .map_err(|e| anyhow::anyhow!("failed to read system DNS config: {e}"))? 49 + .build(), 50 + }) 51 + } 52 + } 53 + 54 + impl TxtResolver for HickoryTxtResolver { 55 + fn txt_lookup<'a>( 56 + &'a self, 57 + name: &'a str, 58 + ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, DnsError>> + Send + 'a>> { 59 + Box::pin(async move { 60 + let lookup = self 61 + .inner 62 + .txt_lookup(name) 63 + .await 64 + .map_err(|e| DnsError(e.to_string()))?; 65 + 66 + let mut results = Vec::new(); 67 + for record in lookup.iter() { 68 + for part in record.txt_data() { 69 + if let Ok(s) = std::str::from_utf8(part) { 70 + results.push(s.to_string()); 71 + } 72 + } 73 + } 74 + Ok(results) 75 + }) 76 + } 77 + } 16 78 17 79 /// Abstraction over DNS record management. 18 80 ///
+16
crates/relay/src/main.rs
··· 103 103 .build() 104 104 .expect("failed to build HTTP client"); 105 105 106 + let txt_resolver: Option<Arc<dyn dns::TxtResolver>> = 107 + match dns::HickoryTxtResolver::from_system_conf() { 108 + Ok(r) => { 109 + tracing::info!("DNS TXT resolver initialised (handle resolution fallback enabled)"); 110 + Some(Arc::new(r)) 111 + } 112 + Err(e) => { 113 + tracing::warn!( 114 + error = %e, 115 + "failed to initialise DNS TXT resolver; handle resolution will be local-only" 116 + ); 117 + None 118 + } 119 + }; 120 + 106 121 let state = app::AppState { 107 122 config: Arc::new(config), 108 123 db: pool, 109 124 http_client, 110 125 dns_provider: None, 126 + txt_resolver, 111 127 }; 112 128 113 129 let listener = tokio::net::TcpListener::bind(&addr)
+15 -14
crates/relay/src/routes/auth.rs
··· 174 174 ) 175 175 })?; 176 176 177 - let token_bytes = URL_SAFE_NO_PAD 178 - .decode(token) 179 - .map_err(|_| { 180 - tracing::debug!("session token is not valid base64url"); 181 - ApiError::new(ErrorCode::Unauthorized, "invalid session token") 182 - })?; 177 + let token_bytes = URL_SAFE_NO_PAD.decode(token).map_err(|_| { 178 + tracing::debug!("session token is not valid base64url"); 179 + ApiError::new(ErrorCode::Unauthorized, "invalid session token") 180 + })?; 183 181 let token_hash: String = Sha256::digest(&token_bytes) 184 182 .iter() 185 183 .map(|b| format!("{b:02x}")) ··· 221 219 db: base.db, 222 220 http_client: base.http_client, 223 221 dns_provider: base.dns_provider, 222 + txt_resolver: base.txt_resolver, 224 223 } 225 224 } 226 225 ··· 507 506 "Bearer not-valid-base64url!!!".parse().unwrap(), 508 507 ); 509 508 let state = test_state().await; 510 - let err = require_session(&headers, &state.db) 511 - .await 512 - .unwrap_err(); 509 + let err = require_session(&headers, &state.db).await.unwrap_err(); 513 510 assert_eq!(err.status_code(), 401); 514 511 } 515 512 ··· 523 520 let state = test_state().await; 524 521 525 522 // Insert an account (required by sessions FK constraint). 526 - let did = format!("did:plc:{}", &Uuid::new_v4().to_string().replace('-', "")[..24]); 523 + let did = format!( 524 + "did:plc:{}", 525 + &Uuid::new_v4().to_string().replace('-', "")[..24] 526 + ); 527 527 sqlx::query( 528 528 "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 529 529 VALUES (?, ?, NULL, datetime('now'), datetime('now'))", ··· 575 575 let state = test_state().await; 576 576 577 577 // Insert an account (required by sessions FK constraint). 578 - let did = format!("did:plc:{}", &Uuid::new_v4().to_string().replace('-', "")[..24]); 578 + let did = format!( 579 + "did:plc:{}", 580 + &Uuid::new_v4().to_string().replace('-', "")[..24] 581 + ); 579 582 sqlx::query( 580 583 "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 581 584 VALUES (?, ?, NULL, datetime('now'), datetime('now'))", ··· 611 614 format!("Bearer {session_token}").parse().unwrap(), 612 615 ); 613 616 614 - let err = require_session(&headers, &state.db) 615 - .await 616 - .unwrap_err(); 617 + let err = require_session(&headers, &state.db).await.unwrap_err(); 617 618 assert_eq!(err.status_code(), 401); 618 619 } 619 620 }
+13 -8
crates/relay/src/routes/create_did.rs
··· 592 592 "did_document should be a JSON object" 593 593 ); 594 594 assert!( 595 - body["session_token"].as_str().map(|s| !s.is_empty()).unwrap_or(false), 595 + body["session_token"] 596 + .as_str() 597 + .map(|s| !s.is_empty()) 598 + .unwrap_or(false), 596 599 "response should include a non-empty session_token" 597 600 ); 598 601 ··· 673 676 assert_eq!(session_did, did, "sessions.did should match response did"); 674 677 675 678 // AC2.4b: handles table should NOT have a row yet (handle created via POST /v1/handles) 676 - let handle_count: i64 = 677 - sqlx::query_scalar("SELECT COUNT(*) FROM handles WHERE did = ?") 678 - .bind(did) 679 - .fetch_one(&db) 680 - .await 681 - .unwrap(); 682 - assert_eq!(handle_count, 0, "handles table should be empty after DID ceremony"); 679 + let handle_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM handles WHERE did = ?") 680 + .bind(did) 681 + .fetch_one(&db) 682 + .await 683 + .unwrap(); 684 + assert_eq!( 685 + handle_count, 0, 686 + "handles table should be empty after DID ceremony" 687 + ); 683 688 684 689 // AC2.5: pending_accounts and pending_sessions deleted 685 690 let pending_count: i64 =
+44 -42
crates/relay/src/routes/create_handle.rs
··· 93 93 .unwrap_or(public_url.as_str()); 94 94 95 95 let dns_status = if let Some(provider) = &state.dns_provider { 96 - provider 97 - .create_record(name, hostname) 98 - .await 99 - .map_err(|e| { 100 - tracing::error!( 101 - error = %e, 102 - handle = %payload.handle, 103 - did = %session.did, 104 - "DNS record creation failed" 105 - ); 106 - ApiError::new(ErrorCode::DnsError, "failed to create DNS record") 107 - })?; 96 + provider.create_record(name, hostname).await.map_err(|e| { 97 + tracing::error!( 98 + error = %e, 99 + handle = %payload.handle, 100 + did = %session.did, 101 + "DNS record creation failed" 102 + ); 103 + ApiError::new(ErrorCode::DnsError, "failed to create DNS record") 104 + })?; 108 105 "propagating" 109 106 } else { 110 107 "not_configured" ··· 233 230 #[test] 234 231 fn validate_handle_accepts_hyphen_in_middle_of_name() { 235 232 let domains = vec!["example.com".to_string()]; 236 - assert_eq!(validate_handle("al-ice.example.com", &domains), Ok("al-ice")); 233 + assert_eq!( 234 + validate_handle("al-ice.example.com", &domains), 235 + Ok("al-ice") 236 + ); 237 237 } 238 238 239 239 #[test] ··· 271 271 _name: &'a str, 272 272 _target: &'a str, 273 273 ) -> Pin<Box<dyn Future<Output = Result<(), crate::dns::DnsError>> + Send + 'a>> { 274 - Box::pin(async { 275 - Err(crate::dns::DnsError("simulated provider error".to_string())) 276 - }) 274 + Box::pin(async { Err(crate::dns::DnsError("simulated provider error".to_string())) }) 277 275 } 278 276 } 279 277 ··· 304 302 /// 305 303 /// Skips the full DID ceremony — sets up only what the create_handle handler needs. 306 304 async fn insert_account_and_session(db: &sqlx::SqlitePool) -> TestSession { 307 - let did = format!("did:plc:{}", &Uuid::new_v4().to_string().replace('-', "")[..24]); 305 + let did = format!( 306 + "did:plc:{}", 307 + &Uuid::new_v4().to_string().replace('-', "")[..24] 308 + ); 308 309 309 310 sqlx::query( 310 311 "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ ··· 338 339 TestSession { did, session_token } 339 340 } 340 341 341 - fn create_handle_request( 342 - session_token: &str, 343 - account_id: &str, 344 - handle: &str, 345 - ) -> Request<Body> { 342 + fn create_handle_request(session_token: &str, account_id: &str, handle: &str) -> Request<Body> { 346 343 let body = serde_json::json!({ 347 344 "accountId": account_id, 348 345 "handle": handle, ··· 384 381 assert_eq!(body["did"].as_str(), Some(ts.did.as_str())); 385 382 386 383 // Verify handles row was inserted. 387 - let row: Option<(String,)> = 388 - sqlx::query_as("SELECT did FROM handles WHERE handle = ?") 389 - .bind(&handle) 390 - .fetch_optional(&db) 391 - .await 392 - .unwrap(); 384 + let row: Option<(String,)> = sqlx::query_as("SELECT did FROM handles WHERE handle = ?") 385 + .bind(&handle) 386 + .fetch_optional(&db) 387 + .await 388 + .unwrap(); 393 389 let (stored_did,) = row.expect("handles row should exist"); 394 390 assert_eq!(stored_did, ts.did); 395 391 } ··· 419 415 .unwrap(); 420 416 assert_eq!(body["dns_status"].as_str(), Some("propagating")); 421 417 422 - let row: Option<(String,)> = 423 - sqlx::query_as("SELECT did FROM handles WHERE handle = ?") 424 - .bind(&handle) 425 - .fetch_optional(&db) 426 - .await 427 - .unwrap(); 418 + let row: Option<(String,)> = sqlx::query_as("SELECT did FROM handles WHERE handle = ?") 419 + .bind(&handle) 420 + .fetch_optional(&db) 421 + .await 422 + .unwrap(); 428 423 assert!(row.is_some(), "handles row must be inserted on DNS success"); 429 424 } 430 425 ··· 453 448 assert_eq!(body["error"]["code"], "DNS_ERROR"); 454 449 455 450 // INSERT precedes the DNS call: the row is durable even when DNS fails. 456 - let row: Option<(String,)> = 457 - sqlx::query_as("SELECT did FROM handles WHERE handle = ?") 458 - .bind(&handle) 459 - .fetch_optional(&db) 460 - .await 461 - .unwrap(); 451 + let row: Option<(String,)> = sqlx::query_as("SELECT did FROM handles WHERE handle = ?") 452 + .bind(&handle) 453 + .fetch_optional(&db) 454 + .await 455 + .unwrap(); 462 456 assert!( 463 457 row.is_some(), 464 458 "handles row is inserted before DNS and persists even when DNS fails" ··· 510 504 511 505 let app = crate::app::app(state); 512 506 let response = app 513 - .oneshot(create_handle_request(&ts.session_token, &ts.did, "nodothandle")) 507 + .oneshot(create_handle_request( 508 + &ts.session_token, 509 + &ts.did, 510 + "nodothandle", 511 + )) 514 512 .await 515 513 .unwrap(); 516 514 ··· 585 583 586 584 let app = crate::app::app(state); 587 585 let response = app 588 - .oneshot(create_handle_request(&ts.session_token, "did:plc:somebodyelse", &handle)) 586 + .oneshot(create_handle_request( 587 + &ts.session_token, 588 + "did:plc:somebodyelse", 589 + &handle, 590 + )) 589 591 .await 590 592 .unwrap(); 591 593
+2
crates/relay/src/routes/create_signing_key.rs
··· 126 126 db: base.db, 127 127 http_client: base.http_client, 128 128 dns_provider: base.dns_provider, 129 + txt_resolver: base.txt_resolver, 129 130 } 130 131 } 131 132 ··· 387 388 db: base.db, 388 389 http_client: base.http_client, 389 390 dns_provider: base.dns_provider, 391 + txt_resolver: base.txt_resolver, 390 392 }; 391 393 392 394 let response = app(state)
+1
crates/relay/src/routes/describe_server.rs
··· 128 128 db: base.db, 129 129 http_client: base.http_client, 130 130 dns_provider: base.dns_provider, 131 + txt_resolver: base.txt_resolver, 131 132 }; 132 133 133 134 let response = app(state)
+1
crates/relay/src/routes/mod.rs
··· 8 8 pub mod describe_server; 9 9 pub mod health; 10 10 pub mod register_device; 11 + pub mod resolve_handle; 11 12 12 13 mod code_gen; 13 14
+250
crates/relay/src/routes/resolve_handle.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: handle from query param, DID from local handles table or DNS TXT record 4 + // Processes: none (resolution priority is local → DNS) 5 + // Returns: JSON { did: "..." } matching com.atproto.identity.resolveHandle Lexicon 6 + 7 + use axum::{ 8 + extract::{Query, State}, 9 + Json, 10 + }; 11 + use common::{ApiError, ErrorCode}; 12 + use serde::{Deserialize, Serialize}; 13 + 14 + use crate::app::AppState; 15 + 16 + #[derive(Deserialize)] 17 + pub struct ResolveHandleQuery { 18 + pub handle: String, 19 + } 20 + 21 + #[derive(Serialize)] 22 + pub struct ResolveHandleResponse { 23 + pub did: String, 24 + } 25 + 26 + pub async fn resolve_handle_handler( 27 + State(state): State<AppState>, 28 + Query(params): Query<ResolveHandleQuery>, 29 + ) -> Result<Json<ResolveHandleResponse>, ApiError> { 30 + // 1. Check local handles table. 31 + let row: Option<(String,)> = sqlx::query_as("SELECT did FROM handles WHERE handle = ?") 32 + .bind(&params.handle) 33 + .fetch_optional(&state.db) 34 + .await 35 + .map_err(|e| { 36 + tracing::error!(error = %e, handle = %params.handle, "failed to query handle"); 37 + ApiError::new(ErrorCode::InternalError, "handle lookup failed") 38 + })?; 39 + 40 + if let Some((did,)) = row { 41 + return Ok(Json(ResolveHandleResponse { did })); 42 + } 43 + 44 + // 2. DNS TXT fallback: look for `did=<did>` in `_atproto.<handle>` records. 45 + if let Some(resolver) = &state.txt_resolver { 46 + let name = format!("_atproto.{}", params.handle); 47 + let records = resolver.txt_lookup(&name).await.map_err(|e| { 48 + tracing::error!(error = %e, handle = %params.handle, "DNS TXT lookup failed"); 49 + ApiError::new(ErrorCode::InternalError, "handle resolution failed") 50 + })?; 51 + 52 + for record in records { 53 + if let Some(did) = record.strip_prefix("did=") { 54 + return Ok(Json(ResolveHandleResponse { 55 + did: did.to_string(), 56 + })); 57 + } 58 + } 59 + } 60 + 61 + Err(ApiError::new(ErrorCode::HandleNotFound, "handle not found")) 62 + } 63 + 64 + #[cfg(test)] 65 + mod tests { 66 + use std::{future::Future, pin::Pin, sync::Arc}; 67 + 68 + use axum::{ 69 + body::Body, 70 + http::{Request, StatusCode}, 71 + }; 72 + use tower::ServiceExt; 73 + 74 + use crate::app::{app, test_state, AppState}; 75 + use crate::dns::{DnsError, TxtResolver}; 76 + 77 + // ── Test doubles ────────────────────────────────────────────────────────── 78 + 79 + /// Returns a fixed list of TXT records for any lookup. 80 + struct FixedTxtResolver { 81 + records: Vec<String>, 82 + } 83 + 84 + impl TxtResolver for FixedTxtResolver { 85 + fn txt_lookup<'a>( 86 + &'a self, 87 + _name: &'a str, 88 + ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, DnsError>> + Send + 'a>> { 89 + let records = self.records.clone(); 90 + Box::pin(async move { Ok(records) }) 91 + } 92 + } 93 + 94 + fn state_with_dns(state: AppState, records: Vec<String>) -> AppState { 95 + AppState { 96 + txt_resolver: Some(Arc::new(FixedTxtResolver { records })), 97 + ..state 98 + } 99 + } 100 + 101 + fn resolve_handle_request(handle: &str) -> Request<Body> { 102 + Request::builder() 103 + .uri(format!( 104 + "/xrpc/com.atproto.identity.resolveHandle?handle={handle}" 105 + )) 106 + .body(Body::empty()) 107 + .unwrap() 108 + } 109 + 110 + async fn seed_handle(db: &sqlx::SqlitePool, handle: &str, did: &str) { 111 + sqlx::query( 112 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 113 + VALUES (?, ?, NULL, datetime('now'), datetime('now'))", 114 + ) 115 + .bind(did) 116 + .bind(format!("{did}@test.example.com")) 117 + .execute(db) 118 + .await 119 + .expect("insert account"); 120 + 121 + sqlx::query("INSERT INTO handles (handle, did, created_at) VALUES (?, ?, datetime('now'))") 122 + .bind(handle) 123 + .bind(did) 124 + .execute(db) 125 + .await 126 + .expect("insert handle"); 127 + } 128 + 129 + // ── Local DB lookup ─────────────────────────────────────────────────────── 130 + 131 + #[tokio::test] 132 + async fn local_handle_resolves_to_did() { 133 + let state = test_state().await; 134 + let did = "did:plc:localuser123456789012345678"; 135 + seed_handle(&state.db, "alice.test.example.com", did).await; 136 + 137 + let response = app(state) 138 + .oneshot(resolve_handle_request("alice.test.example.com")) 139 + .await 140 + .unwrap(); 141 + 142 + assert_eq!(response.status(), StatusCode::OK); 143 + let body: serde_json::Value = serde_json::from_slice( 144 + &axum::body::to_bytes(response.into_body(), usize::MAX) 145 + .await 146 + .unwrap(), 147 + ) 148 + .unwrap(); 149 + assert_eq!(body["did"], did); 150 + } 151 + 152 + // ── DNS fallback ────────────────────────────────────────────────────────── 153 + 154 + #[tokio::test] 155 + async fn dns_fallback_resolves_did_from_txt_record() { 156 + let state = test_state().await; 157 + let external_did = "did:plc:externaluser12345678901234"; 158 + let state = state_with_dns(state, vec![format!("did={external_did}")]); 159 + 160 + let response = app(state) 161 + .oneshot(resolve_handle_request("alice.external.example.com")) 162 + .await 163 + .unwrap(); 164 + 165 + assert_eq!(response.status(), StatusCode::OK); 166 + let body: serde_json::Value = serde_json::from_slice( 167 + &axum::body::to_bytes(response.into_body(), usize::MAX) 168 + .await 169 + .unwrap(), 170 + ) 171 + .unwrap(); 172 + assert_eq!(body["did"], external_did); 173 + } 174 + 175 + #[tokio::test] 176 + async fn dns_fallback_returns_404_when_txt_record_has_no_did_prefix() { 177 + let state = test_state().await; 178 + let state = state_with_dns(state, vec!["v=spf1 include:example.com ~all".to_string()]); 179 + 180 + let response = app(state) 181 + .oneshot(resolve_handle_request("nobody.external.example.com")) 182 + .await 183 + .unwrap(); 184 + 185 + assert_eq!(response.status(), StatusCode::NOT_FOUND); 186 + let body: serde_json::Value = serde_json::from_slice( 187 + &axum::body::to_bytes(response.into_body(), usize::MAX) 188 + .await 189 + .unwrap(), 190 + ) 191 + .unwrap(); 192 + assert_eq!(body["error"]["code"], "HANDLE_NOT_FOUND"); 193 + } 194 + 195 + // ── Not found ───────────────────────────────────────────────────────────── 196 + 197 + #[tokio::test] 198 + async fn unknown_handle_without_dns_resolver_returns_404() { 199 + let state = test_state().await; // txt_resolver is None 200 + 201 + let response = app(state) 202 + .oneshot(resolve_handle_request("nobody.example.com")) 203 + .await 204 + .unwrap(); 205 + 206 + assert_eq!(response.status(), StatusCode::NOT_FOUND); 207 + let body: serde_json::Value = serde_json::from_slice( 208 + &axum::body::to_bytes(response.into_body(), usize::MAX) 209 + .await 210 + .unwrap(), 211 + ) 212 + .unwrap(); 213 + assert_eq!(body["error"]["code"], "HANDLE_NOT_FOUND"); 214 + } 215 + 216 + #[tokio::test] 217 + async fn unknown_handle_with_empty_dns_response_returns_404() { 218 + let state = state_with_dns(test_state().await, vec![]); 219 + 220 + let response = app(state) 221 + .oneshot(resolve_handle_request("nobody.example.com")) 222 + .await 223 + .unwrap(); 224 + 225 + assert_eq!(response.status(), StatusCode::NOT_FOUND); 226 + } 227 + 228 + // ── Response shape ──────────────────────────────────────────────────────── 229 + 230 + #[tokio::test] 231 + async fn resolve_handle_returns_json_content_type() { 232 + let state = test_state().await; 233 + seed_handle( 234 + &state.db, 235 + "alice.test.example.com", 236 + "did:plc:abcdef123456789012345678", 237 + ) 238 + .await; 239 + 240 + let response = app(state) 241 + .oneshot(resolve_handle_request("alice.test.example.com")) 242 + .await 243 + .unwrap(); 244 + 245 + assert_eq!( 246 + response.headers().get("content-type").unwrap(), 247 + "application/json" 248 + ); 249 + } 250 + }
+1
crates/relay/src/routes/test_utils.rs
··· 16 16 db: base.db, 17 17 http_client: base.http_client, 18 18 dns_provider: base.dns_provider, 19 + txt_resolver: base.txt_resolver, 19 20 } 20 21 }