+1
-1
build.zig.zon
+1
-1
build.zig.zon
+103
-547
src/atproto.zig
+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
}