1//! Handle Resolver - resolve handles to DIDs
2//!
3//! resolves AT Protocol handles via HTTP:
4//! https://{handle}/.well-known/atproto-did
5//!
6//! note: DNS TXT resolution (_atproto.{handle}) not yet implemented
7//! as zig std doesn't provide TXT record lookup.
8//!
9//! see: https://atproto.com/specs/handle
10
11const std = @import("std");
12const Handle = @import("handle.zig").Handle;
13const Did = @import("did.zig").Did;
14const HttpTransport = @import("transport.zig").HttpTransport;
15
16pub const HandleResolver = struct {
17 allocator: std.mem.Allocator,
18 transport: HttpTransport,
19 doh_endpoint: []const u8,
20
21 pub fn init(allocator: std.mem.Allocator) HandleResolver {
22 return .{
23 .allocator = allocator,
24 .transport = HttpTransport.init(allocator),
25 .doh_endpoint = "https://cloudflare-dns.com/dns-query",
26 };
27 }
28
29 pub fn deinit(self: *HandleResolver) void {
30 self.transport.deinit();
31 }
32
33 /// resolve a handle to a DID via HTTP well-known
34 pub fn resolve(self: *HandleResolver, handle: Handle) ![]const u8 {
35 if (self.resolveHttp(handle)) |did| {
36 return did;
37 } else |_| {
38 return try self.resolveDns(handle);
39 }
40 }
41
42 /// resolve via HTTP at https://{handle}/.well-known/atproto-did
43 fn resolveHttp(self: *HandleResolver, handle: Handle) ![]const u8 {
44 const url = try std.fmt.allocPrint(
45 self.allocator,
46 "https://{s}/.well-known/atproto-did",
47 .{handle.str()},
48 );
49 defer self.allocator.free(url);
50
51 const result = self.transport.fetch(.{ .url = url }) catch return error.HttpResolutionFailed;
52 defer self.allocator.free(result.body);
53
54 if (result.status != .ok) {
55 return error.HttpResolutionFailed;
56 }
57
58 // response body should be the DID as plain text
59 const did_str = std.mem.trim(u8, result.body, &std.ascii.whitespace);
60
61 // validate it's a proper DID
62 if (Did.parse(did_str) == null) {
63 return error.InvalidDidInResponse;
64 }
65
66 return try self.allocator.dupe(u8, did_str);
67 }
68
69 /// resolve via DoH default: https://cloudflare-dns.com/dns-query
70 pub fn resolveDns(self: *HandleResolver, handle: Handle) ![]const u8 {
71 const dns_name = try std.fmt.allocPrint(
72 self.allocator,
73 "_atproto.{s}",
74 .{handle.str()},
75 );
76 defer self.allocator.free(dns_name);
77
78 const url = try std.fmt.allocPrint(
79 self.allocator,
80 "{s}?name={s}&type=TXT",
81 .{ self.doh_endpoint, dns_name },
82 );
83 defer self.allocator.free(url);
84
85 const result = self.transport.fetch(.{
86 .url = url,
87 .accept = "application/dns-json",
88 }) catch return error.DnsResolutionFailed;
89 defer self.allocator.free(result.body);
90
91 if (result.status != .ok) {
92 return error.DnsResolutionFailed;
93 }
94
95 const parsed = std.json.parseFromSlice(
96 DnsResponse,
97 self.allocator,
98 result.body,
99 .{},
100 ) catch return error.InvalidDnsResponse;
101 defer parsed.deinit();
102
103 const dns_response = parsed.value;
104 if (dns_response.Answer == null or dns_response.Answer.?.len == 0) {
105 return error.NoDnsRecordsFound;
106 }
107
108 for (dns_response.Answer.?) |answer| {
109 const data = answer.data orelse continue;
110 const did_str = extractDidFromTxt(data) orelse continue;
111
112 if (Did.parse(did_str) != null) {
113 return try self.allocator.dupe(u8, did_str);
114 }
115 }
116
117 return error.NoValidDidFound;
118 }
119};
120
121fn extractDidFromTxt(txt_data: []const u8) ?[]const u8 {
122 var data = txt_data;
123 if (data.len >= 2 and data[0] == '"' and data[data.len - 1] == '"') {
124 data = data[1 .. data.len - 1];
125 }
126
127 const prefix = "did=";
128 if (std.mem.startsWith(u8, data, prefix)) {
129 return data[prefix.len..];
130 }
131
132 return null;
133}
134
135const DnsResponse = struct {
136 Status: i32,
137 TC: bool,
138 RD: bool,
139 RA: bool,
140 AD: bool,
141 CD: bool,
142 Question: ?[]Question = null,
143 Answer: ?[]Answer = null,
144};
145
146const Question = struct {
147 name: []const u8,
148 type: i32,
149};
150
151const Answer = struct {
152 name: []const u8,
153 type: i32,
154 TTL: i32,
155 data: ?[]const u8 = null,
156};
157
158// === integration tests ===
159// these actually hit the network - run with: zig test src/internal/handle_resolver.zig
160
161test "resolve handle (http) - integration" {
162 // use arena for http client internals that may leak
163 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
164 defer arena.deinit();
165
166 var resolver = HandleResolver.init(arena.allocator());
167 defer resolver.deinit();
168
169 // resolve a known handle that has .well-known/atproto-did
170 const handle = Handle.parse("jay.bsky.social") orelse return error.InvalidHandle;
171 const did = resolver.resolveHttp(handle) catch |err| {
172 // network errors are ok in CI without network access
173 std.debug.print("network error (expected in some CI): {}\n", .{err});
174 return;
175 };
176
177 // should be a valid did:plc
178 try std.testing.expect(Did.parse(did) != null);
179 try std.testing.expect(std.mem.startsWith(u8, did, "did:plc:"));
180}
181
182test "resolve handle (dns over http) - integration" {
183 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
184 defer arena.deinit();
185
186 var resolver = HandleResolver.init(arena.allocator());
187 defer resolver.deinit();
188
189 const handle = Handle.parse("seiso.moe") orelse return error.InvalidHandle;
190 const did = resolver.resolveDns(handle) catch |err| {
191 // network errors are ok in CI without network access
192 std.debug.print("network error (expected in some CI): {}\n", .{err});
193 return;
194 };
195
196 // should be a valid DID
197 try std.testing.expect(Did.parse(did) != null);
198 try std.testing.expect(std.mem.startsWith(u8, did, "did:"));
199}
200
201test "resolve handle - integration" {
202 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
203 defer arena.deinit();
204
205 var resolver = HandleResolver.init(arena.allocator());
206 defer resolver.deinit();
207
208 const handle = Handle.parse("jay.bsky.social") orelse return error.InvalidHandle;
209 const did = resolver.resolve(handle) catch |err| {
210 // network errors are ok in CI without network access
211 std.debug.print("network error (expected in some CI): {}\n", .{err});
212 return;
213 };
214
215 // should be a valid DID
216 try std.testing.expect(Did.parse(did) != null);
217 try std.testing.expect(std.mem.startsWith(u8, did, "did:"));
218}