zat #
AT Protocol building blocks for zig.
this readme is an ATProto record
zat publishes these docs as site.standard.document records, signed by its DID.
install #
requires zig 0.16+.
zig fetch --save https://tangled.org/zat.dev/zat/archive/main
then in build.zig:
const zat = b.dependency("zat", .{}).module("zat");
exe.root_module.addImport("zat", zat);
what's here #
string primitives - parsing and validation for atproto identifiers
- Tid - timestamp identifiers (base32-sortable)
- Did - decentralized identifiers
- Handle - domain-based handles
- Nsid - namespaced identifiers (lexicon types)
- Rkey - record keys
- AtUri -
at://URIs
const zat = @import("zat");
if (zat.AtUri.parse(uri_string)) |uri| {
const authority = uri.authority();
const collection = uri.collection();
const rkey = uri.rkey();
}
identity resolution - resolve handles and DIDs to documents
// handle → DID
var handle_resolver = zat.HandleResolver.init(io, allocator);
defer handle_resolver.deinit();
const did = try handle_resolver.resolve(zat.Handle.parse("bsky.app").?);
defer allocator.free(did);
// DID → document
var did_resolver = zat.DidResolver.init(io, allocator);
defer did_resolver.deinit();
var doc = try did_resolver.resolve(zat.Did.parse("did:plc:z72i7hdynmk6r22z27h6tvur").?);
defer doc.deinit();
const pds = doc.pdsEndpoint(); // "https://..."
const key = doc.signingKey(); // verification method
supports did:plc (via plc.directory) and did:web. handle resolution via HTTP well-known and DNS TXT.
CBOR codec - DAG-CBOR encoding and decoding
// decode
const decoded = try zat.cbor.decode(allocator, bytes);
defer decoded.deinit();
// navigate values
const text = decoded.value.getStr("text");
const cid = decoded.value.getCid("data");
// encode (deterministic key ordering)
const encoded = try zat.cbor.encodeAlloc(allocator, value);
defer allocator.free(encoded);
full DAG-CBOR support: maps, arrays, byte strings, text strings, integers, floats, booleans, null, CID tags (tag 42). deterministic encoding with sorted keys for signature verification.
CAR codec - Content Addressable aRchive parsing with CID verification
// parse with SHA-256 CID verification (default)
const parsed = try zat.car.read(allocator, car_bytes);
defer parsed.deinit();
const root_cid = parsed.roots[0];
for (parsed.blocks.items) |block| {
// block.cid_raw, block.data
}
// skip verification for trusted local data
const fast = try zat.car.readWithOptions(allocator, car_bytes, .{
.verify_block_hashes = false,
});
enforces size limits (configurable max_size, max_blocks) matching indigo's production defaults.
STAR v1 - streaming archives and public repository verification
var reader = try zat.star.PublicRepoReader.init(allocator, input, .{});
defer reader.deinit();
try reader.verifyCommit(public_key, expected_did);
while (try reader.next()) |record| {
// accumulate provisional results; slices borrow the reader
}
// EOF confirms the reconstructed MST root; publish results now
implements the STAR v1 draft and public-repo profiles.
star.Reader validates canonical metadata and reads arbitrary byte pairs;
next() supplies a key and value length, and readValue(buffer) consumes the
value in caller-sized chunks. Limits can be lowered for an application.
PublicRepoReader adds path, order, commit, and root validation, buffering at
most one 1 MiB record. Its unsigned-subtree profile verifies integrity without
claiming account authenticity. Neither profile establishes freshness.
zig build example-verify-star -- repository.star checks repository integrity.
MST - Merkle Search Tree
var tree = zat.mst.Mst.init(allocator);
defer tree.deinit();
try tree.put(allocator, "app.bsky.feed.post/abc123", value_cid);
const found = tree.get("app.bsky.feed.post/abc123");
try tree.delete(allocator, "app.bsky.feed.post/abc123");
// compute root CID (serialize → hash → CID)
const root = try tree.rootCid(allocator);
the core data structure of an atproto repo. key layer derived from leading zero bits of SHA-256(key), nodes serialized with prefix compression.
crypto - signing, verification, key encoding
// JWT verification
var token = try zat.Jwt.parse(allocator, token_string);
defer token.deinit();
try token.verify(public_key_multibase);
// ECDSA signature verification (P-256 and secp256k1)
try zat.jwt.verifySecp256k1(hash, signature, public_key);
try zat.jwt.verifyP256(hash, signature, public_key);
// multibase/multicodec key parsing
const key_bytes = try zat.multibase.decode(allocator, "zQ3sh...");
defer allocator.free(key_bytes);
const parsed = try zat.multicodec.parsePublicKey(key_bytes);
// parsed.key_type: .secp256k1 or .p256
// parsed.raw: 33-byte compressed public key
ES256 (P-256) and ES256K (secp256k1) with low-S normalization. RFC 6979 deterministic signing. did:key construction and multibase encoding.
OAuth client helpers - ATProto OAuth ceremony without app storage policy
var transport = zat.HttpTransport.initWithUserAgent(io, allocator, "myapp/1.0 (+https://example.com)");
defer transport.deinit();
const authserver = try zat.oauth.discoverAuthorizationServer(allocator, &transport, pds_url);
defer allocator.free(authserver);
var metadata = try zat.oauth.fetchAuthorizationServerMetadata(allocator, &transport, authserver);
defer metadata.deinit(allocator);
var secrets = try zat.oauth.prepareAuthRequestSecrets(allocator, io);
defer secrets.deinit(allocator);
var par = try zat.oauth.sendParRequest(allocator, io, &transport, .{
.par_url = metadata.pushed_authorization_request_endpoint,
.authserver_issuer = metadata.issuer,
.client_id = client_id,
.redirect_uri = redirect_uri,
.scope = "atproto repo:example.app.record",
.state = secrets.state,
.pkce_challenge = secrets.pkce_challenge,
.login_hint = handle,
.client_keypair = &client_keypair,
.dpop_keypair = &secrets.dpop_keypair,
});
defer par.deinit(allocator);
also includes client metadata JSON generation, authorization URL formatting, code/refresh token exchange, DPoP nonce retry, and DPoP-authenticated resource requests. cookies, sessions, redirects, and persistence stay with your application or web framework.
commit verification - check a repo CAR against the account's signing key
// given CAR bytes and the account's public key (see identity resolution)
const result = try zat.verifyCommitCar(allocator, car_bytes, public_key, .{
.expected_did = did,
.require_complete_repo = true, // reject CARs with missing MST/record blocks
});
// result.commit_did, result.commit_rev, result.commit_cid, result.record_count
atproto repos are self-authenticating, so anything that accepts one from the network verifies it before trusting it: checks the commit signature, confirms the MST root matches the commit's data CID, and validates every block hash. verifyCommitDiff does the same for the partial CARs on firehose #commit events. this is how zds guards com.atproto.repo.importRepo — an uploaded CAR is verified against the account's DID document before the PDS accepts it.
signCommit is the produce side: it builds and signs the canonical commit block, and round-trips through verifyCommitCar in the test suite.
firehose client - raw CBOR event stream from relay
var client = zat.FirehoseClient.init(io, allocator, .{});
defer client.deinit();
const Handler = struct {
pub fn onEvent(_: *@This(), event: zat.FirehoseClient.Event) void {
switch (event.header.type) {
.commit => {
// event.body.blocks, event.body.ops, ...
},
else => {},
}
}
};
var handler: Handler = .{};
try client.subscribe(&handler);
connects to com.atproto.sync.subscribeRepos via WebSocket. decodes binary CBOR frames into typed events. round-robin host rotation with backoff.
jetstream client - typed JSON event stream
var client = zat.JetstreamClient.init(io, allocator, .{
.wanted_collections = &.{"app.bsky.feed.post"},
});
defer client.deinit();
const Handler = struct {
pub fn onEvent(_: *@This(), event: zat.JetstreamClient.Event) void {
if (event.commit) |commit| {
const record = commit.record;
// process...
_ = record;
}
}
};
var handler: Handler = .{};
try client.subscribe(&handler);
connects to jetstream (bluesky's JSON event stream). typed events, automatic reconnection with cursor tracking, round-robin across community relays.
xrpc client - call AT Protocol endpoints
var client = zat.XrpcClient.init(io, allocator, "https://bsky.social");
defer client.deinit();
const nsid = zat.Nsid.parse("app.bsky.actor.getProfile").?;
var response = try client.query(nsid, params);
defer response.deinit();
if (response.ok()) {
var json = try response.json();
defer json.deinit();
// use json.value
}
json helpers - navigate nested json without verbose if-chains
Use the getters when you need a few fields or want to decide what to do with each missing value. They do not allocate: strings, arrays, and objects borrow the parsed JSON's storage, so keep it alive while using the results.
const uri = zat.json.getString(value, "embed.external.uri");
const count = zat.json.getInt(value, "meta.count");
const first_alt = zat.json.getString(value, "embed.images.0.alt");
Paths can be string literals or runtime strings. Dots separate object keys, and
numeric segments index arrays. A missing path or wrong type returns null; use
orelse to skip, supply a default, or return an application error. When a present
value of the wrong type must be rejected separately, use getPath and inspect its
tag. For a literal key containing dots, use the object's get method instead.
For a coherent group of fields, extractAt decodes into a local struct. Its tuple
path selects an object subtree; .{} selects the root. JSON is still validated
at runtime, and unknown fields are ignored.
const FeedPost = struct {
uri: []const u8,
cid: []const u8,
record: struct {
text: []const u8 = "",
},
};
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const post = try zat.json.extractAt(FeedPost, arena.allocator(), value, .{"post"});
// use post before arena.deinit()
Unlike the getters, this uses std.json.parseFromValueLeaky: decoding ordinary
string and slice fields allocates, and allocations belong to the supplied
allocator, including allocations made before a parse failure. An arena makes
cleanup straightforward. Struct defaults apply to absent fields, not wrong-type
values; a missing optional field still needs a default such as = null.
extractAtOptional catches every decoding error and returns null, including
allocation failures, so use extractAt when you need to distinguish errors.
If you already know the shape before parsing the response body, decode directly
with std.json.parseFromSlice(T, allocator, body, .{ .ignore_unknown_fields = true })
and defer parsed.deinit() rather than building an intermediate std.json.Value.
used by #
downstream projects — what's building on zat
protocol infrastructure #
| project | what it does |
|---|---|
| zds | AT Protocol PDS — repos, blobs, OAuth, passkeys, firehose, permissioned spaces (live) |
| zlay | AT Protocol relay — crawls every PDS directly, verifies commit signatures, serves the merged event stream (live) |
| stream | Jetstream V2 service with the whole network archived behind it — 16.3M repos, sealed segments back to seq 1 (live) |
| jetstream | the zig SDK for Jetstream v2 services — subscribe, archive backfill, failover |
applications #
| project | what it does |
|---|---|
| weir | orchestration server whose database is an atproto repo — every run and state transition a record under signed commits, exportable as one CAR |
| coral | named-entity recognition over the firehose (live) |
| pensieve | public memory search over a repo — semantic artifacts extracted from arbitrary record shapes |
| ken | semantic search over your atproto repo — vector index of records, search by meaning (live) |
benchmarks #
zat is benchmarked against the Go (indigo, atmos), Rust (rsky, shrike, jacquard), Python, and Elixir ecosystems in atproto-bench:
- firehose decode (CID-verified): 226k frames/sec (zat) vs 62k (atmos) vs 53k (rsky) vs 20k (indigo)
- MST insert + root: 5.7M records/sec, producing the same root bytes as atmos and shrike
- signature verify: 21.6k verifies/sec — ahead of Go (12k–19k), behind Rust (~27k); ECDSA dominates every SDK's pipeline
- end-to-end repo verification (handle → signature-checked repo): ~300ms compute (zat) vs ~410ms (indigo) vs ~422ms (rsky crates)
specs #
validation follows atproto.com/specs. passes the atproto interop test suite (syntax, crypto, MST vectors).
versioning #
pre-1.0 semver:
0.x.0- new features (backwards compatible)0.x.y- bug fixes
breaking changes bump the minor version and are documented in commit messages.