atproto utils for zig zat.dev
atproto sdk zig

add handle resolution via HTTP well-known

resolves handles to DIDs using https://{handle}/.well-known/atproto-did

note: DNS TXT resolution (_atproto.{handle}) not implemented as zig std
doesn't provide TXT record lookup. HTTP method is the primary fallback
per atproto spec.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+91
src
+90
src/internal/handle_resolver.zig
··· 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 + 11 + const std = @import("std"); 12 + const Handle = @import("handle.zig").Handle; 13 + const Did = @import("did.zig").Did; 14 + 15 + pub const HandleResolver = struct { 16 + allocator: std.mem.Allocator, 17 + http_client: std.http.Client, 18 + 19 + pub fn init(allocator: std.mem.Allocator) HandleResolver { 20 + return .{ 21 + .allocator = allocator, 22 + .http_client = .{ .allocator = allocator }, 23 + }; 24 + } 25 + 26 + pub fn deinit(self: *HandleResolver) void { 27 + self.http_client.deinit(); 28 + } 29 + 30 + /// resolve a handle to a DID via HTTP well-known 31 + pub fn resolve(self: *HandleResolver, handle: Handle) ![]const u8 { 32 + return try self.resolveHttp(handle); 33 + } 34 + 35 + /// resolve via HTTP at https://{handle}/.well-known/atproto-did 36 + fn resolveHttp(self: *HandleResolver, handle: Handle) ![]const u8 { 37 + const url = try std.fmt.allocPrint( 38 + self.allocator, 39 + "https://{s}/.well-known/atproto-did", 40 + .{handle.str()}, 41 + ); 42 + defer self.allocator.free(url); 43 + 44 + var aw: std.Io.Writer.Allocating = .init(self.allocator); 45 + defer aw.deinit(); 46 + 47 + const result = self.http_client.fetch(.{ 48 + .location = .{ .url = url }, 49 + .response_writer = &aw.writer, 50 + }) catch return error.HttpResolutionFailed; 51 + 52 + if (result.status != .ok) { 53 + return error.HttpResolutionFailed; 54 + } 55 + 56 + // response body should be the DID as plain text 57 + const did_str = std.mem.trim(u8, aw.toArrayList().items, &std.ascii.whitespace); 58 + 59 + // validate it's a proper DID 60 + if (Did.parse(did_str) == null) { 61 + return error.InvalidDidInResponse; 62 + } 63 + 64 + return try self.allocator.dupe(u8, did_str); 65 + } 66 + }; 67 + 68 + // === integration tests === 69 + // these actually hit the network - run with: zig test src/internal/handle_resolver.zig 70 + 71 + test "resolve handle - integration" { 72 + // use arena for http client internals that may leak 73 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 74 + defer arena.deinit(); 75 + 76 + var resolver = HandleResolver.init(arena.allocator()); 77 + defer resolver.deinit(); 78 + 79 + // resolve a known handle that has .well-known/atproto-did 80 + const handle = Handle.parse("jay.bsky.social") orelse return error.InvalidHandle; 81 + const did = resolver.resolve(handle) catch |err| { 82 + // network errors are ok in CI without network access 83 + std.debug.print("network error (expected in some CI): {}\n", .{err}); 84 + return; 85 + }; 86 + 87 + // should be a valid did:plc 88 + try std.testing.expect(Did.parse(did) != null); 89 + try std.testing.expect(std.mem.startsWith(u8, did, "did:plc:")); 90 + }
+1
src/root.zig
··· 14 14 // did resolution 15 15 pub const DidDocument = @import("internal/did_document.zig").DidDocument; 16 16 pub const DidResolver = @import("internal/did_resolver.zig").DidResolver; 17 + pub const HandleResolver = @import("internal/handle_resolver.zig").HandleResolver; 17 18 18 19 // xrpc 19 20 pub const XrpcClient = @import("internal/xrpc.zig").XrpcClient;