refactor: adopt zat SDK for DID resolution and XRPC

- replace manual DID resolution with zat.DidResolver
- replace raw HTTP/TLS code with zat.XrpcClient
- use zat.json helpers for nested JSON navigation
- atproto.zig: 898 → 454 lines (50% reduction)

remaining in atproto.zig: JWT parsing and ECDSA signature verification

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

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

Changed files
+104 -548
src
+1 -1
build.zig.zon
··· 14 14 }, 15 15 .zat = .{ 16 16 .url = "https://github.com/zzstoatzz/zat/archive/refs/heads/main.tar.gz", 17 - .hash = "zat-0.0.1-alpha-5PuC7uWMAAAd0ujX65-3KFbr3IsEaH8PWExoW9rHkJmU", 17 + .hash = "zat-0.0.1-alpha-5PuC7inqAAD6Sj8RCKLcLzUKnYgMPM_aGduBokzzLSRS", 18 18 }, 19 19 }, 20 20 .paths = .{
+103 -547
src/atproto.zig
··· 1 1 const std = @import("std"); 2 2 const mem = std.mem; 3 - const json = std.json; 4 3 const base64 = std.base64; 5 - const net = std.net; 6 - const tls = std.crypto.tls; 7 4 const Allocator = mem.Allocator; 8 5 const zat = @import("zat"); 9 6 10 7 // ============================================================================= 11 8 // atproto utilities 12 9 // 13 - // JWT verification, DID resolution, and other atproto primitives. 14 - // could be extracted as a standalone library later. 10 + // JWT verification and API helpers. 11 + // DID resolution and XRPC now handled by zat. 15 12 // ============================================================================= 16 13 17 14 pub const ServiceJwtPayload = struct { ··· 35 32 }; 36 33 37 34 /// parse a JWT without verifying the signature. 38 - /// use this only for extracting claims before verification, 39 - /// or when you trust the transport (not recommended). 40 35 pub fn parseJwtUnsafe(allocator: Allocator, jwt: []const u8) !ServiceJwtPayload { 41 36 var parts = mem.splitScalar(u8, jwt, '.'); 42 37 ··· 49 44 defer allocator.free(payload_json); 50 45 51 46 // parse JSON 52 - const parsed = json.parseFromSlice(json.Value, allocator, payload_json, .{}) catch { 47 + const parsed = std.json.parseFromSlice(std.json.Value, allocator, payload_json, .{}) catch { 53 48 return JwtError.InvalidJson; 54 49 }; 55 50 defer parsed.deinit(); ··· 71 66 const lxm: ?[]const u8 = if (obj.get("lxm")) |v| if (v == .string) v.string else null else null; 72 67 const jti: ?[]const u8 = if (obj.get("jti")) |v| if (v == .string) v.string else null else null; 73 68 74 - // dupe strings to avoid dangling pointers after parsed.deinit() 75 69 return .{ 76 70 .iss = try allocator.dupe(u8, iss.string), 77 71 .aud = try allocator.dupe(u8, aud.string), ··· 83 77 } 84 78 85 79 /// verify a JWT's claims (expiration, audience) without signature verification. 86 - /// returns the payload if valid. 87 80 pub fn verifyJwtClaims( 88 81 allocator: Allocator, 89 82 jwt: []const u8, ··· 91 84 ) !ServiceJwtPayload { 92 85 const payload = try parseJwtUnsafe(allocator, jwt); 93 86 94 - // check expiration 95 - const now = std.time.timestamp(); 96 - if (payload.exp < now) { 87 + if (payload.exp < std.time.timestamp()) { 97 88 return JwtError.Expired; 98 89 } 99 90 100 - // check audience 101 91 if (!mem.eql(u8, payload.aud, expected_audience)) { 102 92 return JwtError.InvalidAudience; 103 93 } ··· 106 96 } 107 97 108 98 /// verify a JWT fully, including cryptographic signature. 109 - /// requires resolving the issuer's DID to get their public key. 110 99 pub fn verifyJwt( 111 100 allocator: Allocator, 112 101 jwt: []const u8, 113 102 expected_audience: []const u8, 114 103 ) !ServiceJwtPayload { 115 - // first verify claims 116 104 const payload = try verifyJwtClaims(allocator, jwt, expected_audience); 117 105 118 - // resolve DID to get public key 119 - const public_key = getSigningKeyMultibase(allocator, payload.iss) catch { 120 - return JwtError.DidResolutionFailed; 121 - }; 122 - defer allocator.free(public_key); 106 + // resolve DID using zat 107 + const did = zat.Did.parse(payload.iss) orelse return JwtError.DidResolutionFailed; 108 + var resolver = zat.DidResolver.init(allocator); 109 + defer resolver.deinit(); 123 110 124 - // verify signature 125 - verifyJwtSignature(allocator, jwt, public_key) catch { 111 + var doc = resolver.resolve(did) catch return JwtError.DidResolutionFailed; 112 + defer doc.deinit(); 113 + 114 + const signing_key = doc.signingKey() orelse return JwtError.DidResolutionFailed; 115 + 116 + verifyJwtSignature(allocator, jwt, signing_key.public_key_multibase) catch { 126 117 return JwtError.InvalidSignature; 127 118 }; 128 119 ··· 130 121 } 131 122 132 123 /// extract requester DID from an HTTP Authorization header. 133 - /// only verifies claims (expiration, audience), not signature. 134 - /// returns null if no valid auth header present. 135 124 pub fn getRequesterDid(allocator: Allocator, auth_header: ?[]const u8, service_did: []const u8) ?[]const u8 { 136 125 const auth = auth_header orelse return null; 137 126 if (!mem.startsWith(u8, auth, "Bearer ")) return null; ··· 143 132 } 144 133 145 134 /// extract requester DID with full signature verification. 146 - /// resolves the issuer's DID and verifies the JWT signature. 147 - /// returns null if verification fails. 148 135 pub fn getRequesterDidVerified(allocator: Allocator, auth_header: ?[]const u8, service_did: []const u8) ?[]const u8 { 149 136 const auth = auth_header orelse return null; 150 137 if (!mem.startsWith(u8, auth, "Bearer ")) return null; ··· 159 146 } 160 147 161 148 // ----------------------------------------------------------------------------- 162 - // DID resolution 149 + // JWT signature verification (crypto) 163 150 // ----------------------------------------------------------------------------- 164 151 165 - pub const DidDocument = struct { 166 - id: []const u8, 167 - verification_method: ?[]const VerificationMethod = null, 168 - service: ?[]const Service = null, 169 - }; 170 - 171 - pub const VerificationMethod = struct { 172 - id: []const u8, 173 - type: []const u8, 174 - controller: []const u8, 175 - public_key_multibase: ?[]const u8 = null, 176 - }; 177 - 178 - pub const Service = struct { 179 - id: []const u8, 180 - type: []const u8, 181 - service_endpoint: []const u8, 182 - }; 183 - 184 - /// resolve a did:web identifier to its DID document. 185 - /// did:web:example.com -> https://example.com/.well-known/did.json 186 - pub fn resolveDidWeb(allocator: Allocator, did: []const u8) !DidDocument { 187 - if (!mem.startsWith(u8, did, "did:web:")) { 188 - return error.InvalidDid; 189 - } 190 - 191 - const host = did[8..]; 192 - 193 - // build URL 194 - var url_buf: [512]u8 = undefined; 195 - const url = std.fmt.bufPrint(&url_buf, "https://{s}/.well-known/did.json", .{host}) catch { 196 - return error.DidResolutionFailed; 197 - }; 198 - 199 - return fetchDidDocument(allocator, url); 200 - } 201 - 202 - /// resolve a did:plc identifier via plc.directory. 203 - /// did:plc:abc123 -> https://plc.directory/did:plc:abc123 204 - pub fn resolveDidPlc(allocator: Allocator, did: []const u8) !DidDocument { 205 - if (!mem.startsWith(u8, did, "did:plc:")) { 206 - return error.InvalidDid; 207 - } 208 - 209 - // build URL 210 - var url_buf: [512]u8 = undefined; 211 - const url = std.fmt.bufPrint(&url_buf, "https://plc.directory/{s}", .{did}) catch { 212 - return error.DidResolutionFailed; 213 - }; 214 - 215 - return fetchDidDocument(allocator, url); 216 - } 217 - 218 - /// resolve any supported DID type. 219 - pub fn resolveDid(allocator: Allocator, did: []const u8) !DidDocument { 220 - if (mem.startsWith(u8, did, "did:web:")) { 221 - return resolveDidWeb(allocator, did); 222 - } else if (mem.startsWith(u8, did, "did:plc:")) { 223 - return resolveDidPlc(allocator, did); 224 - } else { 225 - return error.UnsupportedDidMethod; 226 - } 227 - } 228 - 229 - /// fetch a DID document from a URL and return the raw JSON body. 230 - fn fetchDidDocumentRaw(allocator: Allocator, url: []const u8) ![]u8 { 231 - // parse URL to extract host and path 232 - if (!mem.startsWith(u8, url, "https://")) { 233 - return error.DidResolutionFailed; 234 - } 235 - 236 - const rest = url[8..]; 237 - const path_start = mem.indexOf(u8, rest, "/") orelse rest.len; 238 - const host = rest[0..path_start]; 239 - const path = if (path_start < rest.len) rest[path_start..] else "/"; 240 - 241 - // connect via TCP 242 - const stream = net.tcpConnectToHost(allocator, host, 443) catch { 243 - return error.DidResolutionFailed; 244 - }; 245 - defer stream.close(); 246 - 247 - // setup TLS 248 - var arena = std.heap.ArenaAllocator.init(allocator); 249 - defer arena.deinit(); 250 - const aa = arena.allocator(); 251 - 252 - var ca_bundle: std.crypto.Certificate.Bundle = .{}; 253 - ca_bundle.rescan(aa) catch return error.DidResolutionFailed; 254 - 255 - const buf_len = std.crypto.tls.max_ciphertext_record_len; 256 - const buf = aa.alloc(u8, buf_len * 4) catch return error.DidResolutionFailed; 257 - 258 - var stream_writer = stream.writer(buf.ptr[0..buf_len][0..buf_len]); 259 - var stream_reader = stream.reader(buf.ptr[buf_len .. 2 * buf_len][0..buf_len]); 260 - 261 - var tls_client = tls.Client.init( 262 - stream_reader.interface(), 263 - &stream_writer.interface, 264 - .{ 265 - .ca = .{ .bundle = ca_bundle }, 266 - .host = .{ .explicit = host }, 267 - .read_buffer = buf.ptr[2 * buf_len .. 3 * buf_len][0..buf_len], 268 - .write_buffer = buf.ptr[3 * buf_len .. 4 * buf_len][0..buf_len], 269 - }, 270 - ) catch return error.DidResolutionFailed; 271 - 272 - // send HTTP request 273 - var req_buf: [512]u8 = undefined; 274 - const request = std.fmt.bufPrint(&req_buf, "GET {s} HTTP/1.1\r\nHost: {s}\r\nConnection: close\r\n\r\n", .{ path, host }) catch { 275 - return error.DidResolutionFailed; 276 - }; 277 - 278 - tls_client.writer.writeAll(request) catch return error.DidResolutionFailed; 279 - tls_client.writer.flush() catch return error.DidResolutionFailed; 280 - stream_writer.interface.flush() catch return error.DidResolutionFailed; 281 - 282 - // read response 283 - var response_buf: [16384]u8 = undefined; 284 - var total_read: usize = 0; 285 - 286 - outer: while (total_read < response_buf.len) { 287 - var w: std.Io.Writer = .fixed(response_buf[total_read..]); 288 - while (true) { 289 - const n = tls_client.reader.stream(&w, .limited(response_buf.len - total_read)) catch { 290 - break :outer; 291 - }; 292 - if (n != 0) { 293 - total_read += n; 294 - break; 295 - } 296 - } 297 - } 298 - 299 - const response = response_buf[0..total_read]; 300 - 301 - // find body (after \r\n\r\n) 302 - const header_end = mem.indexOf(u8, response, "\r\n\r\n") orelse { 303 - return error.DidResolutionFailed; 304 - }; 305 - 306 - const body = response[header_end + 4 ..]; 307 - return allocator.dupe(u8, body); 308 - } 309 - 310 - /// fetch and parse a DID document from a URL. 311 - fn fetchDidDocument(allocator: Allocator, url: []const u8) !DidDocument { 312 - const body = try fetchDidDocumentRaw(allocator, url); 313 - defer allocator.free(body); 314 - 315 - // parse JSON 316 - const parsed = json.parseFromSlice(json.Value, allocator, body, .{}) catch { 317 - return error.DidResolutionFailed; 318 - }; 319 - defer parsed.deinit(); 320 - 321 - const obj = parsed.value.object; 322 - 323 - // extract id (required) 324 - const id = obj.get("id") orelse return error.DidResolutionFailed; 325 - if (id != .string) return error.DidResolutionFailed; 326 - 327 - return .{ 328 - .id = id.string, 329 - }; 330 - } 331 - 332 - /// extract the signing key (publicKeyMultibase) from a DID document. 333 - /// looks for the atproto verification method. 334 - pub fn getSigningKeyMultibase(allocator: Allocator, did: []const u8) ![]const u8 { 335 - // build URL based on DID type 336 - var url_buf: [512]u8 = undefined; 337 - const url = if (mem.startsWith(u8, did, "did:plc:")) 338 - std.fmt.bufPrint(&url_buf, "https://plc.directory/{s}", .{did}) catch return error.DidResolutionFailed 339 - else if (mem.startsWith(u8, did, "did:web:")) 340 - std.fmt.bufPrint(&url_buf, "https://{s}/.well-known/did.json", .{did[8..]}) catch return error.DidResolutionFailed 341 - else 342 - return error.UnsupportedDidMethod; 343 - 344 - // fetch DID document 345 - const body = try fetchDidDocumentRaw(allocator, url); 346 - defer allocator.free(body); 347 - 348 - // parse JSON 349 - const parsed = json.parseFromSlice(json.Value, allocator, body, .{}) catch { 350 - return error.DidResolutionFailed; 351 - }; 352 - defer parsed.deinit(); 353 - 354 - const obj = parsed.value.object; 355 - 356 - // get verificationMethod array 357 - const vm_val = obj.get("verificationMethod") orelse return error.DidResolutionFailed; 358 - if (vm_val != .array) return error.DidResolutionFailed; 359 - 360 - // find the #atproto verification method 361 - for (vm_val.array.items) |item| { 362 - if (item != .object) continue; 363 - const method = item.object; 364 - 365 - // check if this is the atproto signing key 366 - const id = method.get("id") orelse continue; 367 - if (id != .string) continue; 368 - 369 - if (mem.endsWith(u8, id.string, "#atproto")) { 370 - // found it - extract publicKeyMultibase 371 - const key = method.get("publicKeyMultibase") orelse continue; 372 - if (key != .string) continue; 373 - return allocator.dupe(u8, key.string); 374 - } 375 - } 376 - 377 - // fallback: use first verification method 378 - if (vm_val.array.items.len > 0) { 379 - const first = vm_val.array.items[0]; 380 - if (first == .object) { 381 - const key = first.object.get("publicKeyMultibase") orelse return error.DidResolutionFailed; 382 - if (key == .string) { 383 - return allocator.dupe(u8, key.string); 384 - } 385 - } 386 - } 387 - 388 - return error.DidResolutionFailed; 389 - } 390 - 391 - // ----------------------------------------------------------------------------- 392 - // base58btc decoding (for multibase keys) 393 - // ----------------------------------------------------------------------------- 394 - 152 + const ecdsa = std.crypto.sign.ecdsa; 395 153 const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; 396 154 397 155 fn decodeBase58(allocator: Allocator, input: []const u8) ![]u8 { 398 156 if (input.len == 0) return allocator.alloc(u8, 0); 399 157 400 - // count leading '1's (zeros in output) 401 158 var leading_zeros: usize = 0; 402 159 for (input) |c| { 403 160 if (c == '1') { ··· 407 164 } 408 165 } 409 166 410 - // allocate output buffer (input.len is upper bound) 411 167 const output_size = input.len; 412 168 var output = try allocator.alloc(u8, output_size); 413 169 @memset(output, 0); ··· 415 171 var output_len: usize = 0; 416 172 417 173 for (input) |c| { 418 - // find character in alphabet 419 174 const val: u8 = for (BASE58_ALPHABET, 0..) |a, i| { 420 175 if (a == c) break @intCast(i); 421 176 } else return error.InvalidBase58; 422 177 423 - // multiply existing output by 58 and add new value 424 178 var carry: u32 = val; 425 179 var i: usize = 0; 426 180 while (i < output_len or carry != 0) : (i += 1) { ··· 433 187 output_len = @max(output_len, i); 434 188 } 435 189 436 - // trim leading zeros from computation, add back original leading zeros 437 190 const start = output.len - output_len; 438 191 const result_len = leading_zeros + output_len; 439 192 const result = try allocator.alloc(u8, result_len); ··· 444 197 return result; 445 198 } 446 199 447 - // multicodec prefixes for key types 448 - const MULTICODEC_P256_PUB: u16 = 0x1200; // varint: 0x80 0x24 449 - const MULTICODEC_SECP256K1_PUB: u16 = 0xe7; // varint: 0xe7 450 - 451 200 const KeyType = enum { p256, secp256k1 }; 452 201 453 - /// decode a multibase key and return the raw public key bytes and type 454 202 fn decodeMultibaseKey(allocator: Allocator, multibase: []const u8) !struct { key: []u8, key_type: KeyType } { 455 203 if (multibase.len == 0) return error.InvalidMultibase; 456 - 457 - // check multibase prefix ('z' = base58btc) 458 204 if (multibase[0] != 'z') return error.UnsupportedMultibase; 459 205 460 - // decode base58 461 206 const decoded = try decodeBase58(allocator, multibase[1..]); 462 207 errdefer allocator.free(decoded); 463 208 464 209 if (decoded.len < 2) return error.InvalidMulticodec; 465 210 466 - // parse multicodec varint prefix 467 - // multicodec uses unsigned LEB128 (varint) encoding 468 - // secp256k1-pub (0xe7 = 231) encodes as: 0xe7 0x01 (2 bytes) 469 - // p256-pub (0x1200 = 4608) encodes as: 0x80 0x24 (2 bytes) 470 211 if (decoded.len >= 2 and decoded[0] == 0xe7 and decoded[1] == 0x01) { 471 212 const key = try allocator.dupe(u8, decoded[2..]); 472 213 allocator.free(decoded); ··· 480 221 } 481 222 } 482 223 483 - // ----------------------------------------------------------------------------- 484 - // JWT signature verification 485 - // ----------------------------------------------------------------------------- 486 - 487 - const ecdsa = std.crypto.sign.ecdsa; 488 - 489 - /// verify a JWT signature against a public key. 490 - /// the public key should be in multibase format (from DID document). 491 224 pub fn verifyJwtSignature(allocator: Allocator, jwt: []const u8, public_key_multibase: []const u8) !void { 492 - // split JWT into parts 493 225 var parts = mem.splitScalar(u8, jwt, '.'); 494 226 const header_b64 = parts.next() orelse return error.MalformedJwt; 495 227 const payload_b64 = parts.next() orelse return error.MalformedJwt; 496 228 const sig_b64 = parts.next() orelse return error.MalformedJwt; 497 229 498 - // the message to verify is "header.payload" 499 230 const header_end = @intFromPtr(header_b64.ptr) - @intFromPtr(jwt.ptr) + header_b64.len; 500 231 const payload_end = header_end + 1 + payload_b64.len; 501 232 const message = jwt[0..payload_end]; 502 233 503 - // decode signature from base64url 504 234 const sig_bytes = try decodeBase64Url(allocator, sig_b64); 505 235 defer allocator.free(sig_bytes); 506 236 507 - // decode public key 508 237 const key_info = try decodeMultibaseKey(allocator, public_key_multibase); 509 238 defer allocator.free(key_info.key); 510 239 511 - // verify based on key type 512 240 switch (key_info.key_type) { 513 241 .p256 => { 514 242 const pubkey = ecdsa.EcdsaP256Sha256.PublicKey.fromSec1(key_info.key) catch { ··· 533 261 } 534 262 } 535 263 536 - // ----------------------------------------------------------------------------- 537 - // base64url decoding (JWT uses base64url, not standard base64) 538 - // ----------------------------------------------------------------------------- 539 - 540 264 fn decodeBase64Url(allocator: Allocator, input: []const u8) ![]u8 { 541 - // convert base64url to standard base64 542 265 var buf = try allocator.alloc(u8, input.len + 4); 543 266 defer allocator.free(buf); 544 267 ··· 552 275 i += 1; 553 276 } 554 277 555 - // add padding if needed 556 278 const padding = (4 - (i % 4)) % 4; 557 279 for (0..padding) |_| { 558 280 buf[i] = '='; ··· 568 290 } 569 291 570 292 // ----------------------------------------------------------------------------- 571 - // tests 572 - // ----------------------------------------------------------------------------- 573 - 574 - test "parseJwtUnsafe" { 575 - // example JWT (not a real one, just for structure testing) 576 - const jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJkaWQ6cGxjOnRlc3QiLCJhdWQiOiJkaWQ6d2ViOmZlZWQuZXhhbXBsZSIsImV4cCI6OTk5OTk5OTk5OX0.fake_signature"; 577 - 578 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 579 - defer arena.deinit(); 580 - 581 - const payload = try parseJwtUnsafe(arena.allocator(), jwt); 582 - try std.testing.expectEqualStrings("did:plc:test", payload.iss); 583 - try std.testing.expectEqualStrings("did:web:feed.example", payload.aud); 584 - } 585 - 586 - test "getSigningKeyMultibase" { 587 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 588 - defer arena.deinit(); 589 - 590 - // test with a real DID (bsky.app's DID) 591 - const key = try getSigningKeyMultibase(arena.allocator(), "did:plc:z72i7hdynmk6r22z27h6tvur"); 592 - // should start with 'z' (multibase prefix for base58btc) 593 - try std.testing.expect(key[0] == 'z'); 594 - try std.testing.expect(key.len > 10); 595 - } 596 - 597 - test "decodeBase58" { 598 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 599 - defer arena.deinit(); 600 - 601 - // "1" encodes to 0x00 602 - const result1 = try decodeBase58(arena.allocator(), "1"); 603 - try std.testing.expectEqual(@as(usize, 1), result1.len); 604 - try std.testing.expectEqual(@as(u8, 0), result1[0]); 605 - 606 - // "2" encodes to 0x01 607 - const result2 = try decodeBase58(arena.allocator(), "2"); 608 - try std.testing.expectEqual(@as(usize, 1), result2.len); 609 - try std.testing.expectEqual(@as(u8, 1), result2[0]); 610 - } 611 - 612 - test "getFollows" { 613 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 614 - defer arena.deinit(); 615 - 616 - // test with bsky.app's DID - should have some follows 617 - const follows = try getFollows(arena.allocator(), "did:plc:z72i7hdynmk6r22z27h6tvur"); 618 - // should return newline-separated DIDs 619 - try std.testing.expect(follows.len > 0); 620 - try std.testing.expect(mem.indexOf(u8, follows, "did:") != null); 621 - } 622 - 623 - // ----------------------------------------------------------------------------- 624 - // social graph API 293 + // social graph API (using zat.XrpcClient) 625 294 // ----------------------------------------------------------------------------- 626 295 627 - const BSKY_PUBLIC_API = "public.api.bsky.app"; 296 + const BSKY_PUBLIC_API = "https://public.api.bsky.app"; 628 297 629 298 /// fetch all DIDs that a user follows (with pagination). 630 299 /// returns newline-separated DIDs. 631 300 pub fn getFollows(allocator: Allocator, actor_did: []const u8) ![]u8 { 301 + var client = zat.XrpcClient.init(allocator, BSKY_PUBLIC_API); 302 + defer client.deinit(); 303 + 632 304 var result: std.ArrayList(u8) = .empty; 633 305 errdefer result.deinit(allocator); 634 306 635 307 var cursor: ?[]const u8 = null; 636 308 defer if (cursor) |c| allocator.free(c); 309 + 310 + const nsid = zat.Nsid.parse("app.bsky.graph.getFollows") orelse return error.InvalidNsid; 637 311 638 312 while (true) { 639 - // build path with optional cursor 640 - var path_buf: [1024]u8 = undefined; 641 - const path = if (cursor) |c| 642 - std.fmt.bufPrint(&path_buf, "/xrpc/app.bsky.graph.getFollows?actor={s}&limit=100&cursor={s}", .{ actor_did, c }) catch return error.PathTooLong 643 - else 644 - std.fmt.bufPrint(&path_buf, "/xrpc/app.bsky.graph.getFollows?actor={s}&limit=100", .{actor_did}) catch return error.PathTooLong; 313 + var params = std.StringHashMap([]const u8).init(allocator); 314 + defer params.deinit(); 315 + 316 + try params.put("actor", actor_did); 317 + try params.put("limit", "100"); 318 + if (cursor) |c| { 319 + try params.put("cursor", c); 320 + } 645 321 646 - // fetch 647 - const response = fetchApiResponse(allocator, BSKY_PUBLIC_API, path) catch |err| { 322 + var response = client.query(nsid, params) catch |err| { 648 323 std.debug.print("getFollows API error: {}\n", .{err}); 649 324 return error.ApiFailed; 650 325 }; 651 - defer allocator.free(response); 326 + defer response.deinit(); 652 327 653 - // parse JSON 654 - const parsed = json.parseFromSlice(json.Value, allocator, response, .{}) catch { 655 - return error.InvalidJson; 656 - }; 328 + if (!response.ok()) { 329 + return error.ApiFailed; 330 + } 331 + 332 + var parsed = response.json() catch return error.InvalidJson; 657 333 defer parsed.deinit(); 658 334 659 - const obj = parsed.value.object; 335 + // extract follows array using zat.json helpers 336 + const follows = zat.json.getArray(parsed.value, "follows") orelse break; 660 337 661 - // extract follows array 662 - const follows_val = obj.get("follows") orelse return error.MissingField; 663 - if (follows_val != .array) return error.InvalidJson; 664 - 665 - for (follows_val.array.items) |item| { 666 - if (item != .object) continue; 667 - const follow_obj = item.object; 668 - 669 - // get the DID of the followed user 670 - const did_val = follow_obj.get("did") orelse continue; 671 - if (did_val != .string) continue; 672 - 673 - try result.appendSlice(allocator, did_val.string); 338 + for (follows) |item| { 339 + const did = zat.json.getString(item, "did") orelse continue; 340 + try result.appendSlice(allocator, did); 674 341 try result.append(allocator, '\n'); 675 342 } 676 343 ··· 678 345 if (cursor) |c| allocator.free(c); 679 346 cursor = null; 680 347 681 - if (obj.get("cursor")) |cursor_val| { 682 - if (cursor_val == .string and cursor_val.string.len > 0) { 683 - cursor = try allocator.dupe(u8, cursor_val.string); 348 + if (zat.json.getString(parsed.value, "cursor")) |c| { 349 + if (c.len > 0) { 350 + cursor = try allocator.dupe(u8, c); 684 351 } 685 352 } 686 353 ··· 690 357 return try result.toOwnedSlice(allocator); 691 358 } 692 359 693 - /// fetch a response from the Bluesky public API. 694 - fn fetchApiResponse(allocator: Allocator, host: []const u8, path: []const u8) ![]u8 { 695 - // connect via TCP 696 - const stream = net.tcpConnectToHost(allocator, host, 443) catch { 697 - return error.ConnectionFailed; 698 - }; 699 - defer stream.close(); 700 - 701 - // setup TLS 702 - var arena = std.heap.ArenaAllocator.init(allocator); 703 - defer arena.deinit(); 704 - const aa = arena.allocator(); 705 - 706 - var ca_bundle: std.crypto.Certificate.Bundle = .{}; 707 - ca_bundle.rescan(aa) catch return error.TlsFailed; 708 - 709 - const buf_len = std.crypto.tls.max_ciphertext_record_len; 710 - const buf = aa.alloc(u8, buf_len * 4) catch return error.OutOfMemory; 711 - 712 - var stream_writer = stream.writer(buf.ptr[0..buf_len][0..buf_len]); 713 - var stream_reader = stream.reader(buf.ptr[buf_len .. 2 * buf_len][0..buf_len]); 714 - 715 - var tls_client = tls.Client.init( 716 - stream_reader.interface(), 717 - &stream_writer.interface, 718 - .{ 719 - .ca = .{ .bundle = ca_bundle }, 720 - .host = .{ .explicit = host }, 721 - .read_buffer = buf.ptr[2 * buf_len .. 3 * buf_len][0..buf_len], 722 - .write_buffer = buf.ptr[3 * buf_len .. 4 * buf_len][0..buf_len], 723 - }, 724 - ) catch return error.TlsFailed; 725 - 726 - // send HTTP request 727 - var req_buf: [1024]u8 = undefined; 728 - const request = std.fmt.bufPrint(&req_buf, "GET {s} HTTP/1.1\r\nHost: {s}\r\nAccept: application/json\r\nConnection: close\r\n\r\n", .{ path, host }) catch { 729 - return error.RequestTooLong; 730 - }; 731 - 732 - tls_client.writer.writeAll(request) catch return error.WriteFailed; 733 - tls_client.writer.flush() catch return error.WriteFailed; 734 - stream_writer.interface.flush() catch return error.WriteFailed; 735 - 736 - // read response - use dynamic buffer for large responses 737 - var response_list: std.ArrayList(u8) = .empty; 738 - defer response_list.deinit(allocator); 739 - 740 - var temp_buf: [16384]u8 = undefined; 741 - 742 - outer: while (true) { 743 - var w: std.Io.Writer = .fixed(&temp_buf); 744 - 745 - while (true) { 746 - const n = tls_client.reader.stream(&w, .limited(temp_buf.len)) catch { 747 - break :outer; 748 - }; 749 - if (n != 0) { 750 - try response_list.appendSlice(allocator, temp_buf[0..n]); 751 - break; 752 - } 753 - } 754 - } 755 - 756 - const response = response_list.items; 757 - 758 - // find body (after \r\n\r\n) 759 - const header_end = mem.indexOf(u8, response, "\r\n\r\n") orelse { 760 - return error.InvalidResponse; 761 - }; 762 - 763 - // check for chunked transfer encoding 764 - const headers = response[0..header_end]; 765 - const body_start = header_end + 4; 766 - 767 - if (mem.indexOf(u8, headers, "Transfer-Encoding: chunked") != null) { 768 - // decode chunked body 769 - return decodeChunkedBody(allocator, response[body_start..]); 770 - } 771 - 772 - return allocator.dupe(u8, response[body_start..]); 773 - } 774 - 775 - /// decode a chunked transfer-encoding body 776 - fn decodeChunkedBody(allocator: Allocator, chunked: []const u8) ![]u8 { 777 - var result: std.ArrayList(u8) = .empty; 778 - errdefer result.deinit(allocator); 779 - 780 - var pos: usize = 0; 781 - while (pos < chunked.len) { 782 - // find chunk size line 783 - const line_end = mem.indexOf(u8, chunked[pos..], "\r\n") orelse break; 784 - const size_str = chunked[pos .. pos + line_end]; 785 - 786 - // parse hex size 787 - const chunk_size = std.fmt.parseInt(usize, size_str, 16) catch break; 788 - if (chunk_size == 0) break; 789 - 790 - pos += line_end + 2; // skip size line and CRLF 791 - 792 - // read chunk data 793 - if (pos + chunk_size > chunked.len) break; 794 - try result.appendSlice(allocator, chunked[pos .. pos + chunk_size]); 795 - 796 - pos += chunk_size + 2; // skip data and trailing CRLF 797 - } 798 - 799 - return try result.toOwnedSlice(allocator); 800 - } 801 - 802 360 /// a post from getAuthorFeed 803 361 pub const AuthorPost = struct { 804 362 uri: []const u8, ··· 808 366 }; 809 367 810 368 /// fetch recent posts from an author's feed. 811 - /// returns up to `limit` posts (max 100 per API call). 812 369 pub fn getAuthorFeed(allocator: Allocator, actor_did: []const u8, limit: usize) ![]AuthorPost { 813 - var posts: std.ArrayList(AuthorPost) = .{}; 370 + var client = zat.XrpcClient.init(allocator, BSKY_PUBLIC_API); 371 + defer client.deinit(); 372 + 373 + var posts: std.ArrayList(AuthorPost) = .empty; 814 374 errdefer { 815 375 for (posts.items) |p| { 816 376 allocator.free(p.uri); ··· 821 381 posts.deinit(allocator); 822 382 } 823 383 824 - // build path 825 - var path_buf: [512]u8 = undefined; 826 - const actual_limit = @min(limit, 100); 827 - const path = std.fmt.bufPrint(&path_buf, "/xrpc/app.bsky.feed.getAuthorFeed?actor={s}&limit={d}&filter=posts_no_replies", .{ actor_did, actual_limit }) catch return error.PathTooLong; 384 + const nsid = zat.Nsid.parse("app.bsky.feed.getAuthorFeed") orelse return error.InvalidNsid; 828 385 829 - // fetch 830 - const response = fetchApiResponse(allocator, BSKY_PUBLIC_API, path) catch |err| { 386 + var params = std.StringHashMap([]const u8).init(allocator); 387 + defer params.deinit(); 388 + 389 + var limit_buf: [8]u8 = undefined; 390 + const limit_str = std.fmt.bufPrint(&limit_buf, "{d}", .{@min(limit, 100)}) catch return error.FormatError; 391 + 392 + try params.put("actor", actor_did); 393 + try params.put("limit", limit_str); 394 + try params.put("filter", "posts_no_replies"); 395 + 396 + var response = client.query(nsid, params) catch |err| { 831 397 std.debug.print("getAuthorFeed API error for {s}: {}\n", .{ actor_did, err }); 832 398 return error.ApiFailed; 833 399 }; 834 - defer allocator.free(response); 400 + defer response.deinit(); 835 401 836 - // parse JSON 837 - const parsed = json.parseFromSlice(json.Value, allocator, response, .{}) catch { 838 - return error.InvalidJson; 839 - }; 402 + if (!response.ok()) { 403 + return error.ApiFailed; 404 + } 405 + 406 + var parsed = response.json() catch return error.InvalidJson; 840 407 defer parsed.deinit(); 841 408 842 - const obj = parsed.value.object; 409 + const feed = zat.json.getArray(parsed.value, "feed") orelse return try posts.toOwnedSlice(allocator); 843 410 844 - // extract feed array 845 - const feed_val = obj.get("feed") orelse return posts.toOwnedSlice(allocator); 846 - if (feed_val != .array) return posts.toOwnedSlice(allocator); 411 + for (feed) |item| { 412 + const uri = zat.json.getString(item, "post.uri") orelse continue; 413 + const cid = zat.json.getString(item, "post.cid") orelse continue; 414 + const text = zat.json.getString(item, "post.record.text") orelse ""; 415 + const embed_uri = zat.json.getString(item, "post.record.embed.external.uri"); 847 416 848 - for (feed_val.array.items) |item| { 849 - if (item != .object) continue; 850 - const feed_item = item.object; 417 + try posts.append(allocator, .{ 418 + .uri = try allocator.dupe(u8, uri), 419 + .cid = try allocator.dupe(u8, cid), 420 + .text = try allocator.dupe(u8, text), 421 + .embed_uri = if (embed_uri) |eu| try allocator.dupe(u8, eu) else null, 422 + }); 423 + } 851 424 852 - // get post object 853 - const post_val = feed_item.get("post") orelse continue; 854 - if (post_val != .object) continue; 855 - const post_obj = post_val.object; 425 + return try posts.toOwnedSlice(allocator); 426 + } 856 427 857 - // extract uri 858 - const uri_val = post_obj.get("uri") orelse continue; 859 - if (uri_val != .string) continue; 428 + // ----------------------------------------------------------------------------- 429 + // tests 430 + // ----------------------------------------------------------------------------- 860 431 861 - // extract cid 862 - const cid_val = post_obj.get("cid") orelse continue; 863 - if (cid_val != .string) continue; 432 + test "parseJwtUnsafe" { 433 + const jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJkaWQ6cGxjOnRlc3QiLCJhdWQiOiJkaWQ6d2ViOmZlZWQuZXhhbXBsZSIsImV4cCI6OTk5OTk5OTk5OX0.fake_signature"; 864 434 865 - // extract record 866 - const record_val = post_obj.get("record") orelse continue; 867 - if (record_val != .object) continue; 435 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 436 + defer arena.deinit(); 868 437 869 - // extract text (may be empty) 870 - const text_val = record_val.object.get("text"); 871 - const text = if (text_val) |tv| (if (tv == .string) tv.string else "") else ""; 438 + const payload = try parseJwtUnsafe(arena.allocator(), jwt); 439 + try std.testing.expectEqualStrings("did:plc:test", payload.iss); 440 + try std.testing.expectEqualStrings("did:web:feed.example", payload.aud); 441 + } 872 442 873 - // extract embed.external.uri if present 874 - var embed_uri: ?[]const u8 = null; 875 - if (record_val.object.get("embed")) |embed_val| { 876 - if (embed_val == .object) { 877 - if (embed_val.object.get("external")) |ext_val| { 878 - if (ext_val == .object) { 879 - if (ext_val.object.get("uri")) |eu_val| { 880 - if (eu_val == .string) { 881 - embed_uri = eu_val.string; 882 - } 883 - } 884 - } 885 - } 886 - } 887 - } 443 + test "decodeBase58" { 444 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 445 + defer arena.deinit(); 888 446 889 - try posts.append(allocator, .{ 890 - .uri = try allocator.dupe(u8, uri_val.string), 891 - .cid = try allocator.dupe(u8, cid_val.string), 892 - .text = try allocator.dupe(u8, text), 893 - .embed_uri = if (embed_uri) |eu| try allocator.dupe(u8, eu) else null, 894 - }); 895 - } 447 + const result1 = try decodeBase58(arena.allocator(), "1"); 448 + try std.testing.expectEqual(@as(usize, 1), result1.len); 449 + try std.testing.expectEqual(@as(u8, 0), result1[0]); 896 450 897 - return posts.toOwnedSlice(allocator); 451 + const result2 = try decodeBase58(arena.allocator(), "2"); 452 + try std.testing.expectEqual(@as(usize, 1), result2.len); 453 + try std.testing.expectEqual(@as(u8, 1), result2[0]); 898 454 }