atproto utils for zig zat.dev
atproto sdk zig

add readme, fix tid first-char validation

- add minimal README with install/usage
- tid: first char must have high bit 0x40 unset (only 2-7 allowed)
- add test for rejecting tids starting with a-z

ref: MarshalX/atproto string_formats.py

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

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

Changed files
+55 -2
src
internal
+45
README.md
··· 1 + # zat 2 + 3 + zig atproto primitives. parsing utilities for TID, AT-URI, and DID. 4 + 5 + ## status 6 + 7 + alpha (`0.0.1-alpha`). APIs are in `internal` module while we iterate. 8 + 9 + ## install 10 + 11 + ```zig 12 + // build.zig.zon 13 + .dependencies = .{ 14 + .zat = .{ 15 + .url = "https://tangled.sh/@zzstoatzz.io/zat/archive/main.tar.gz", 16 + .hash = "...", // zig build will tell you 17 + }, 18 + }, 19 + ``` 20 + 21 + ## usage 22 + 23 + ```zig 24 + const zat = @import("zat"); 25 + 26 + // TID - timestamp identifiers 27 + const tid = zat.internal.Tid.parse("3jui7kze2c22s") orelse return error.InvalidTid; 28 + const ts = tid.timestamp(); // microseconds since epoch 29 + const clock = tid.clockId(); // 10-bit clock id 30 + 31 + // AT-URI - at://did/collection/rkey 32 + const uri = zat.internal.AtUri.parse("at://did:plc:xyz/app.bsky.feed.post/abc123") orelse return error.InvalidUri; 33 + const did = uri.did(); // "did:plc:xyz" 34 + const collection = uri.collection(); // "app.bsky.feed.post" 35 + const rkey = uri.rkey(); // "abc123" 36 + 37 + // DID - did:plc and did:web 38 + const d = zat.internal.Did.parse("did:plc:z72i7hdynmk6r22z27h6tvur") orelse return error.InvalidDid; 39 + const id = d.identifier(); // "z72i7hdynmk6r22z27h6tvur" 40 + const is_plc = d.isPlc(); // true 41 + ``` 42 + 43 + ## why internal? 44 + 45 + new APIs start in `internal` and get promoted to root when stable. if you need bleeding edge, use `zat.internal.*` and expect breakage.
+10 -2
src/internal/tid.zig
··· 2 2 //! 3 3 //! tids encode a timestamp and clock id in a base32-sortable format. 4 4 //! format: 13 characters using alphabet "234567abcdefghijklmnopqrstuvwxyz" 5 - //! - first 11 chars: 53-bit timestamp (microseconds since epoch) 6 - //! - last 2 chars: 10-bit clock identifier 5 + //! - first char must be 2-7 (high bit 0x40 must be 0) 6 + //! - remaining chars encode 53-bit timestamp + 10-bit clock id 7 7 //! 8 8 //! the encoding is designed to be lexicographically sortable by time. 9 + //! see: https://atproto.com/specs/record-key#record-key-type-tid 9 10 10 11 const std = @import("std"); 11 12 ··· 17 18 /// parse a tid string. returns null if invalid. 18 19 pub fn parse(s: []const u8) ?Tid { 19 20 if (s.len != 13) return null; 21 + 22 + // first char high bit (0x40) must be 0, meaning only '2'-'7' allowed 23 + if (s[0] & 0x40 != 0) return null; 20 24 21 25 var result: Tid = undefined; 22 26 for (s, 0..) |c, i| { ··· 101 105 // invalid chars 102 106 try std.testing.expect(Tid.parse("0000000000000") == null); 103 107 try std.testing.expect(Tid.parse("1111111111111") == null); 108 + 109 + // first char must be 2-7 (high bit 0x40 must be 0) 110 + try std.testing.expect(Tid.parse("a222222222222") == null); 111 + try std.testing.expect(Tid.parse("z222222222222") == null); 104 112 } 105 113 106 114 test "roundtrip" {