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};
25
26pub fn main() !void {
27 // use page_allocator for CLI tool - OS reclaims on exit
28 const allocator = std.heap.page_allocator;
29
30 const handle = "zat.dev";
31
32 const password = if (std.c.getenv("ATPROTO_PASSWORD")) |p| std.mem.span(p) else {
33 std.debug.print("error: ATPROTO_PASSWORD not set\n", .{});
34 return error.MissingEnv;
35 };
36
37 const pds = if (std.c.getenv("ATPROTO_PDS")) |p| std.mem.span(p) else "https://bsky.social";
38
39 var client = zat.XrpcClient.init(std.Options.debug_io, allocator, pds);
40 defer client.deinit();
41
42 const session = try createSession(&client, allocator, handle, password);
43 defer {
44 allocator.free(session.did);
45 allocator.free(session.access_token);
46 }
47
48 std.debug.print("authenticated as {s}\n", .{session.did});
49 client.setAuth(session.access_token);
50
51 // generate TID for publication (fixed timestamp for deterministic rkey)
52 // using 2024-01-01 00:00:00 UTC as base timestamp (1704067200 seconds = 1704067200000000 microseconds)
53 const pub_tid = zat.Tid.fromTimestamp(1704067200000000, 0);
54 const pub_record = Publication{
55 .url = "https://zat.dev",
56 .name = "zat",
57 .description = "AT Protocol building blocks for zig",
58 };
59
60 try putRecord(&client, allocator, session.did, "site.standard.publication", pub_tid.str(), pub_record);
61 std.debug.print("created publication: at://{s}/site.standard.publication/{s}\n", .{ session.did, pub_tid.str() });
62
63 var pub_uri_buf: std.ArrayList(u8) = .empty;
64 defer pub_uri_buf.deinit(allocator);
65 try pub_uri_buf.print(allocator, "at://{s}/site.standard.publication/{s}", .{ session.did, pub_tid.str() });
66 const pub_uri = pub_uri_buf.items;
67
68 // publish each doc with deterministic TIDs (same base timestamp, incrementing clock_id)
69 const now = timestamp();
70
71 for (docs, 0..) |doc, i| {
72 const content = std.Io.Dir.readFileAlloc(.cwd(), std.Options.debug_io, doc.file, allocator, .limited(1024 * 1024)) catch |err| {
73 std.debug.print("warning: could not read {s}: {}\n", .{ doc.file, err });
74 continue;
75 };
76 defer allocator.free(content);
77
78 const title = extractTitle(content) orelse doc.file;
79 const tid = zat.Tid.fromTimestamp(1704067200000000, @intCast(i + 1)); // clock_id 1, 2, 3...
80
81 const doc_record = Document{
82 .site = pub_uri,
83 .title = title,
84 .path = doc.path,
85 .textContent = content,
86 .publishedAt = &now,
87 };
88
89 try putRecord(&client, allocator, session.did, "site.standard.document", tid.str(), doc_record);
90 std.debug.print("published: {s} -> at://{s}/site.standard.document/{s}\n", .{ doc.file, session.did, tid.str() });
91 }
92
93 // devlog publication (clock_id 100 to separate from docs)
94 const devlog_tid = zat.Tid.fromTimestamp(1704067200000000, 100);
95 const devlog_pub = Publication{
96 .url = "https://zat.dev",
97 .name = "zat devlog",
98 .description = "building zat in public",
99 };
100
101 try putRecord(&client, allocator, session.did, "site.standard.publication", devlog_tid.str(), devlog_pub);
102 std.debug.print("created publication: at://{s}/site.standard.publication/{s}\n", .{ session.did, devlog_tid.str() });
103
104 var devlog_uri_buf: std.ArrayList(u8) = .empty;
105 defer devlog_uri_buf.deinit(allocator);
106 try devlog_uri_buf.print(allocator, "at://{s}/site.standard.publication/{s}", .{ session.did, devlog_tid.str() });
107 const devlog_uri = devlog_uri_buf.items;
108
109 // publish devlog entries (clock_id 101, 102, ...)
110 for (devlog, 0..) |entry, i| {
111 const content = std.Io.Dir.readFileAlloc(.cwd(), std.Options.debug_io, entry.file, allocator, .limited(1024 * 1024)) catch |err| {
112 std.debug.print("warning: could not read {s}: {}\n", .{ entry.file, err });
113 continue;
114 };
115 defer allocator.free(content);
116
117 const title = extractTitle(content) orelse entry.file;
118 const tid = zat.Tid.fromTimestamp(1704067200000000, @intCast(101 + i));
119
120 const doc_record = Document{
121 .site = devlog_uri,
122 .title = title,
123 .path = entry.path,
124 .textContent = content,
125 .publishedAt = &now,
126 };
127
128 try putRecord(&client, allocator, session.did, "site.standard.document", tid.str(), doc_record);
129 std.debug.print("published: {s} -> at://{s}/site.standard.document/{s}\n", .{ entry.file, session.did, tid.str() });
130 }
131
132 std.debug.print("done\n", .{});
133}
134
135const Publication = struct {
136 @"$type": []const u8 = "site.standard.publication",
137 url: []const u8,
138 name: []const u8,
139 description: ?[]const u8 = null,
140};
141
142const Document = struct {
143 @"$type": []const u8 = "site.standard.document",
144 site: []const u8,
145 title: []const u8,
146 path: ?[]const u8 = null,
147 textContent: ?[]const u8 = null,
148 publishedAt: []const u8,
149};
150
151const Session = struct {
152 did: []const u8,
153 access_token: []const u8,
154};
155
156fn createSession(client: *zat.XrpcClient, allocator: Allocator, handle: []const u8, password: []const u8) !Session {
157 const CreateSessionInput = struct {
158 identifier: []const u8,
159 password: []const u8,
160 };
161
162 var buf: std.ArrayList(u8) = .empty;
163 defer buf.deinit(allocator);
164 try buf.print(allocator, "{f}", .{std.json.fmt(CreateSessionInput{
165 .identifier = handle,
166 .password = password,
167 }, .{})});
168
169 const nsid = zat.Nsid.parse("com.atproto.server.createSession").?;
170 var response = try client.procedure(nsid, buf.items);
171 defer response.deinit();
172
173 if (!response.ok()) {
174 std.debug.print("createSession failed: {s}\n", .{response.body});
175 return error.AuthFailed;
176 }
177
178 var parsed = try response.json();
179 defer parsed.deinit();
180
181 const did = zat.json.getString(parsed.value, "did") orelse return error.MissingDid;
182 const token = zat.json.getString(parsed.value, "accessJwt") orelse return error.MissingToken;
183
184 return .{
185 .did = try allocator.dupe(u8, did),
186 .access_token = try allocator.dupe(u8, token),
187 };
188}
189
190fn putRecord(client: *zat.XrpcClient, allocator: Allocator, repo: []const u8, collection: []const u8, rkey: []const u8, record: anytype) !void {
191 // serialize record to json
192 var record_buf: std.ArrayList(u8) = .empty;
193 defer record_buf.deinit(allocator);
194 try record_buf.print(allocator, "{f}", .{std.json.fmt(record, .{})});
195
196 // build request body
197 var body: std.ArrayList(u8) = .empty;
198 defer body.deinit(allocator);
199
200 try body.appendSlice(allocator, "{\"repo\":\"");
201 try body.appendSlice(allocator, repo);
202 try body.appendSlice(allocator, "\",\"collection\":\"");
203 try body.appendSlice(allocator, collection);
204 try body.appendSlice(allocator, "\",\"rkey\":\"");
205 try body.appendSlice(allocator, rkey);
206 try body.appendSlice(allocator, "\",\"record\":");
207 try body.appendSlice(allocator, record_buf.items);
208 try body.append(allocator, '}');
209
210 const nsid = zat.Nsid.parse("com.atproto.repo.putRecord").?;
211 var response = try client.procedure(nsid, body.items);
212 defer response.deinit();
213
214 if (!response.ok()) {
215 std.debug.print("putRecord failed: {s}\n", .{response.body});
216 return error.PutFailed;
217 }
218}
219
220fn extractTitle(content: []const u8) ?[]const u8 {
221 var lines = std.mem.splitScalar(u8, content, '\n');
222 while (lines.next()) |line| {
223 const trimmed = std.mem.trim(u8, line, " \t\r");
224 if (trimmed.len > 2 and trimmed[0] == '#' and trimmed[1] == ' ') {
225 var title = trimmed[2..];
226 // strip markdown link: [text](url) -> text
227 if (std.mem.indexOf(u8, title, "](")) |bracket| {
228 if (title[0] == '[') {
229 title = title[1..bracket];
230 }
231 }
232 return title;
233 }
234 }
235 return null;
236}
237
238fn timestamp() [20]u8 {
239 const epoch_seconds: i64 = @intCast(@divFloor(std.Io.Timestamp.now(std.Options.debug_io, .real).nanoseconds, std.time.ns_per_s));
240 const days: i32 = @intCast(@divFloor(epoch_seconds, std.time.s_per_day));
241 const day_secs: u32 = @intCast(@mod(epoch_seconds, std.time.s_per_day));
242
243 // calculate year/month/day from days since epoch (1970-01-01)
244 var y: i32 = 1970;
245 var remaining = days;
246 while (true) {
247 const year_days: i32 = if (@mod(y, 4) == 0 and (@mod(y, 100) != 0 or @mod(y, 400) == 0)) 366 else 365;
248 if (remaining < year_days) break;
249 remaining -= year_days;
250 y += 1;
251 }
252
253 const is_leap = @mod(y, 4) == 0 and (@mod(y, 100) != 0 or @mod(y, 400) == 0);
254 const month_days = [12]u8{ 31, if (is_leap) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
255 var m: usize = 0;
256 while (m < 12 and remaining >= month_days[m]) : (m += 1) {
257 remaining -= month_days[m];
258 }
259
260 const hours = day_secs / 3600;
261 const mins = (day_secs % 3600) / 60;
262 const secs = day_secs % 60;
263
264 var buf: [20]u8 = undefined;
265 _ = std.fmt.bufPrint(&buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{
266 @as(u32, @intCast(y)), @as(u32, @intCast(m + 1)), @as(u32, @intCast(remaining + 1)), hours, mins, secs,
267 }) catch unreachable;
268 return buf;
269}