atproto utils for zig
zat.dev
atproto
sdk
zig
1const std = @import("std");
2const zat = @import("zat");
3
4const Allocator = std.mem.Allocator;
5
6const DocEntry = struct { path: []const u8, file: []const u8 };
7
8/// docs to publish as site.standard.document records
9const docs = [_]DocEntry{
10 .{ .path = "/", .file = "README.md" },
11 .{ .path = "/roadmap", .file = "docs/roadmap.md" },
12 .{ .path = "/changelog", .file = "CHANGELOG.md" },
13};
14
15/// devlog entries
16const devlog = [_]DocEntry{
17 .{ .path = "/devlog/001", .file = "devlog/001-self-publishing-docs.md" },
18 .{ .path = "/devlog/002", .file = "devlog/002-firehose-and-benchmarks.md" },
19 .{ .path = "/devlog/003", .file = "devlog/003-trust-chain.md" },
20 .{ .path = "/devlog/004", .file = "devlog/004-sig-verify.md" },
21 .{ .path = "/devlog/005", .file = "devlog/005-three-way-verify.md" },
22 .{ .path = "/devlog/006", .file = "devlog/006-building-a-relay.md" },
23 .{ .path = "/devlog/007", .file = "devlog/007-up-and-to-the-right.md" },
24 .{ .path = "/devlog/008", .file = "devlog/008-the-io-migration.md" },
25};
26
27pub fn main() !void {
28 // use page_allocator for CLI tool - OS reclaims on exit
29 const allocator = std.heap.page_allocator;
30
31 const handle = "zat.dev";
32
33 const password = if (std.c.getenv("ATPROTO_PASSWORD")) |p| std.mem.span(p) else {
34 std.debug.print("error: ATPROTO_PASSWORD not set\n", .{});
35 return error.MissingEnv;
36 };
37
38 const pds = if (std.c.getenv("ATPROTO_PDS")) |p| std.mem.span(p) else "https://bsky.social";
39
40 var client = zat.XrpcClient.init(std.Options.debug_io, allocator, pds);
41 defer client.deinit();
42
43 const session = try createSession(&client, allocator, handle, password);
44 defer {
45 allocator.free(session.did);
46 allocator.free(session.access_token);
47 }
48
49 std.debug.print("authenticated as {s}\n", .{session.did});
50 client.setAuth(session.access_token);
51
52 // generate TID for publication (fixed timestamp for deterministic rkey)
53 // using 2024-01-01 00:00:00 UTC as base timestamp (1704067200 seconds = 1704067200000000 microseconds)
54 const pub_tid = zat.Tid.fromTimestamp(1704067200000000, 0);
55 const pub_record = Publication{
56 .url = "https://zat.dev",
57 .name = "zat",
58 .description = "AT Protocol building blocks for zig",
59 };
60
61 try putRecord(&client, allocator, session.did, "site.standard.publication", pub_tid.str(), pub_record);
62 std.debug.print("created publication: at://{s}/site.standard.publication/{s}\n", .{ session.did, pub_tid.str() });
63
64 var pub_uri_buf: std.ArrayList(u8) = .empty;
65 defer pub_uri_buf.deinit(allocator);
66 try pub_uri_buf.print(allocator, "at://{s}/site.standard.publication/{s}", .{ session.did, pub_tid.str() });
67 const pub_uri = pub_uri_buf.items;
68
69 // publish each doc with deterministic TIDs (same base timestamp, incrementing clock_id)
70 const now = timestamp();
71
72 try publishEntries(&client, allocator, session.did, &docs, pub_uri, 1, &now);
73
74 // devlog publication (clock_id 100 to separate from docs)
75 const devlog_tid = zat.Tid.fromTimestamp(1704067200000000, 100);
76 const devlog_pub = Publication{
77 .url = "https://zat.dev",
78 .name = "zat devlog",
79 .description = "building zat in public",
80 };
81
82 try putRecord(&client, allocator, session.did, "site.standard.publication", devlog_tid.str(), devlog_pub);
83 std.debug.print("created publication: at://{s}/site.standard.publication/{s}\n", .{ session.did, devlog_tid.str() });
84
85 var devlog_uri_buf: std.ArrayList(u8) = .empty;
86 defer devlog_uri_buf.deinit(allocator);
87 try devlog_uri_buf.print(allocator, "at://{s}/site.standard.publication/{s}", .{ session.did, devlog_tid.str() });
88 const devlog_uri = devlog_uri_buf.items;
89
90 try publishEntries(&client, allocator, session.did, &devlog, devlog_uri, 101, &now);
91
92 std.debug.print("done\n", .{});
93}
94
95const Publication = struct {
96 @"$type": []const u8 = "site.standard.publication",
97 url: []const u8,
98 name: []const u8,
99 description: ?[]const u8 = null,
100};
101
102const Document = struct {
103 @"$type": []const u8 = "site.standard.document",
104 site: []const u8,
105 title: []const u8,
106 path: ?[]const u8 = null,
107 textContent: ?[]const u8 = null,
108 publishedAt: []const u8,
109};
110
111const Session = struct {
112 did: []const u8,
113 access_token: []const u8,
114};
115
116fn createSession(client: *zat.XrpcClient, allocator: Allocator, handle: []const u8, password: []const u8) !Session {
117 const CreateSessionInput = struct {
118 identifier: []const u8,
119 password: []const u8,
120 };
121
122 var buf: std.ArrayList(u8) = .empty;
123 defer buf.deinit(allocator);
124 try buf.print(allocator, "{f}", .{std.json.fmt(CreateSessionInput{
125 .identifier = handle,
126 .password = password,
127 }, .{})});
128
129 const nsid = zat.Nsid.parse("com.atproto.server.createSession").?;
130 var response = try client.procedure(nsid, buf.items);
131 defer response.deinit();
132
133 if (!response.ok()) {
134 std.debug.print("createSession failed: {s}\n", .{response.body});
135 return error.AuthFailed;
136 }
137
138 var parsed = try response.json();
139 defer parsed.deinit();
140
141 const did = zat.json.getString(parsed.value, "did") orelse return error.MissingDid;
142 const token = zat.json.getString(parsed.value, "accessJwt") orelse return error.MissingToken;
143
144 return .{
145 .did = try allocator.dupe(u8, did),
146 .access_token = try allocator.dupe(u8, token),
147 };
148}
149
150fn putRecord(client: *zat.XrpcClient, allocator: Allocator, repo: []const u8, collection: []const u8, rkey: []const u8, record: anytype) !void {
151 const PutRecordInput = struct {
152 repo: []const u8,
153 collection: []const u8,
154 rkey: []const u8,
155 record: @TypeOf(record),
156 };
157
158 var buf: std.ArrayList(u8) = .empty;
159 defer buf.deinit(allocator);
160 try buf.print(allocator, "{f}", .{std.json.fmt(PutRecordInput{
161 .repo = repo,
162 .collection = collection,
163 .rkey = rkey,
164 .record = record,
165 }, .{})});
166
167 const nsid = zat.Nsid.parse("com.atproto.repo.putRecord").?;
168 var response = try client.procedure(nsid, buf.items);
169 defer response.deinit();
170
171 if (!response.ok()) {
172 std.debug.print("putRecord failed: {s}\n", .{response.body});
173 return error.PutFailed;
174 }
175}
176
177fn extractTitle(content: []const u8) ?[]const u8 {
178 var lines = std.mem.splitScalar(u8, content, '\n');
179 while (lines.next()) |line| {
180 const trimmed = std.mem.trim(u8, line, " \t\r");
181 if (trimmed.len > 2 and trimmed[0] == '#' and trimmed[1] == ' ') {
182 var title = trimmed[2..];
183 // strip markdown link: [text](url) -> text
184 if (std.mem.indexOf(u8, title, "](")) |bracket| {
185 if (title[0] == '[') {
186 title = title[1..bracket];
187 }
188 }
189 return title;
190 }
191 }
192 return null;
193}
194
195fn publishEntries(
196 client: *zat.XrpcClient,
197 allocator: Allocator,
198 did: []const u8,
199 entries: []const DocEntry,
200 site_uri: []const u8,
201 clock_id_base: usize,
202 now: *const [20]u8,
203) !void {
204 for (entries, 0..) |entry, i| {
205 const content = std.Io.Dir.readFileAlloc(.cwd(), std.Options.debug_io, entry.file, allocator, .limited(1024 * 1024)) catch |err| {
206 std.debug.print("warning: could not read {s}: {}\n", .{ entry.file, err });
207 continue;
208 };
209 defer allocator.free(content);
210
211 const title = extractTitle(content) orelse entry.file;
212 const tid = zat.Tid.fromTimestamp(1704067200000000, @intCast(clock_id_base + i));
213
214 const record = Document{
215 .site = site_uri,
216 .title = title,
217 .path = entry.path,
218 .textContent = content,
219 .publishedAt = now,
220 };
221
222 try putRecord(client, allocator, did, "site.standard.document", tid.str(), record);
223 std.debug.print("published: {s} -> at://{s}/site.standard.document/{s}\n", .{ entry.file, did, tid.str() });
224 }
225}
226
227fn timestamp() [20]u8 {
228 const ns = std.Io.Timestamp.now(std.Options.debug_io, .real).nanoseconds;
229 const secs: u64 = @intCast(@divFloor(ns, std.time.ns_per_s));
230 const es = std.time.epoch.EpochSeconds{ .secs = secs };
231 const yd = es.getEpochDay().calculateYearDay();
232 const md = yd.calculateMonthDay();
233 const ds = es.getDaySeconds();
234
235 var buf: [20]u8 = undefined;
236 _ = std.fmt.bufPrint(&buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{
237 yd.year,
238 @as(u32, md.month.numeric()),
239 @as(u32, md.day_index) + 1,
240 @as(u32, ds.getHoursIntoDay()),
241 @as(u32, ds.getMinutesIntoHour()),
242 @as(u32, ds.getSecondsIntoMinute()),
243 }) catch unreachable;
244 return buf;
245}