atproto utils for zig zat.dev
atproto sdk zig
26
fork

Configure Feed

Select the types of activity you want to include in your feed.

devlog 010: the network is input

+138
+136
devlog/010-the-network-is-input.md
··· 1 + # the network is input 2 + 3 + devlog 009 ended with "zat is v0.3.0-alpha. no API changes from this." the next release is different. `v0.3.1` is small in surface area, but it changes how the library treats two pieces of AT Protocol reality: 4 + 5 + 1. identity strings are not just syntax. resolving them crosses a network boundary. 6 + 2. failed XRPC calls are not just failed HTTP. the body is protocol data. 7 + 8 + the release is three commits: 9 + 10 + - [`8287ff2`](https://tangled.org/zat.dev/zat/commit/8287ff2) - harden identity network resolution 11 + - [`8ba4cc0`](https://tangled.org/zat.dev/zat/commit/8ba4cc0) - add checked xrpc errors and retries 12 + - [`8de5f40`](https://tangled.org/zat.dev/zat/commit/8de5f40) - release: v0.3.1 13 + 14 + ## identity resolution is a fetch 15 + 16 + AT Protocol makes identity resolution look friendly: 17 + 18 + - `did:plc:...` goes to `plc.directory` 19 + - `did:web:example.com` goes to `https://example.com/.well-known/did.json` 20 + - `handle.example.com` goes to `https://handle.example.com/.well-known/atproto-did` or `_atproto.handle.example.com` TXT 21 + 22 + that last sentence hides the problem. handles and DIDs are user-controlled strings that can make the library issue HTTP requests. validating the syntax is not enough. `did:web:127.0.0.1` is syntactically ordinary and operationally not something a server should fetch on behalf of an untrusted caller. 23 + 24 + the first chunk adds `src/internal/identity/network_safety.zig`. before `did:web` or handle HTTP resolution fetches anything, zat now checks the host and the resolved addresses. the obvious unsafe cases are rejected directly: 25 + 26 + ```zig 27 + try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("localhost")); 28 + try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("127.0.0.1")); 29 + try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("10.1.2.3")); 30 + try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("192.168.1.1")); 31 + try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("[::1]")); 32 + try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("::ffff:127.0.0.1")); 33 + ``` 34 + 35 + the less obvious case is DNS. `evil.example` can be a public name that resolves to `127.0.0.1`, `10.0.0.5`, `fc00::1`, or a link-local address. so the resolver does a DoH preflight before the HTTP fetch. it asks for `A` and `AAAA`, rejects non-routable answers, and only then dials. 36 + 37 + for `did:web` and handle well-known HTTP, redirects are disabled. redirecting from a safe-looking public URL to a private address is the same bug with one extra step. 38 + 39 + ## dialing one host while speaking for another 40 + 41 + the DoH preflight created a second constraint: once we have checked an address, the actual HTTP request should use that checked address, not resolve the hostname again underneath `std.http.Client`. 42 + 43 + `HttpTransport` now has an internal `ResolvedConnection` path: 44 + 45 + ```zig 46 + pub const ResolvedConnection = struct { 47 + dial_host: []const u8, 48 + logical_host: []const u8, 49 + }; 50 + ``` 51 + 52 + the transport connects to `dial_host`, but keeps `logical_host` for HTTP/TLS identity. that preserves the thing callers intended to fetch while avoiding a second unchecked resolver hop. it also checks that the request URL still matches the logical host, so the preflight result cannot be accidentally reused for a different URL. 53 + 54 + this is not meant to be a general proxy API. it is just enough machinery for identity resolution to say: "I already checked where this name points; use that." 55 + 56 + ## XRPC errors are data 57 + 58 + the second chunk came from downstream use. the original XRPC API made this easy: 59 + 60 + ```zig 61 + var response = try client.query(nsid, params); 62 + if (!response.ok()) return error.ApiFailed; 63 + ``` 64 + 65 + that is fine for a prototype. it is not enough for a client that needs to understand AT Protocol behavior. a non-2xx response can still contain a structured XRPC envelope: 66 + 67 + ```json 68 + {"error":"RateLimitExceeded","message":"slow down"} 69 + ``` 70 + 71 + throwing that away means callers lose the difference between `InvalidRequest`, `ExpiredToken`, `RateLimitExceeded`, and an arbitrary 500. it also makes retry behavior hard to centralize because the transport sees the status and headers, while application code sees only a boolean. 72 + 73 + `v0.3.1` adds checked XRPC calls: 74 + 75 + ```zig 76 + var result = try client.queryChecked(nsid, params, .{}); 77 + defer result.deinit(); 78 + 79 + switch (result) { 80 + .ok => |response| { 81 + // parse success body 82 + _ = response; 83 + }, 84 + .err => |xrpc_error| { 85 + // status, error_name, message, body, rate_limit 86 + _ = xrpc_error; 87 + }, 88 + } 89 + ``` 90 + 91 + the old `query` and `procedure` calls stay. the checked calls are additive, and the return type forces the caller to decide what to do with protocol errors. 92 + 93 + ## retries belong with the client 94 + 95 + the same change adds `XrpcClient.RetryPolicy`. the default is conservative: retry transient transport errors and HTTP `429`, `500`, `502`, `503`, `504`; do not retry ordinary client errors. the delay is exponential, capped, and jittered. if the server sends `retry-after`, that wins. if a rate-limit reset timestamp is available, the policy can use that too. 96 + 97 + `HttpTransport.fetch` now preserves: 98 + 99 + - `ratelimit-limit` 100 + - `ratelimit-remaining` 101 + - `ratelimit-reset` 102 + - `retry-after` 103 + 104 + those fields are present on both successful responses and `XrpcError`. that matters because a caller might need to surface the error immediately but still update local rate-limit state. 105 + 106 + ## proof belongs downstream 107 + 108 + we did not put the smoke harness in zat. zat has unit tests for the pieces: 109 + 110 + - unsafe identity hosts 111 + - unsafe DNS answers 112 + - resolved-host mismatch 113 + - rate-limit header parsing 114 + - XRPC error-envelope parsing 115 + - deterministic retry delay behavior 116 + 117 + the end-to-end smoke went into [`atproto-bench`](https://tangled.org/zzstoatzz.io/atproto-bench), where this kind of protocol harness belongs. the harness runs a local HTTP fixture, makes `queryChecked` hit a `429`, verifies the retry succeeds, then verifies a structured `400` comes back as `XrpcError` with rate-limit headers intact. 118 + 119 + then [`music-atmosphere-feed`](https://tangled.org/zzstoatzz.io/music-atmosphere-feed) adopted `queryChecked` for its public AppView calls. that is the useful downstream shape: application code still returns its own `ApiFailed`, but it logs the actual XRPC status, error name, and message instead of flattening everything to "not ok." 120 + 121 + ## the release 122 + 123 + `v0.3.1` is a patch release because the existing API remains available. the new calls are additive, and the identity hardening is a fix to behavior that should not have been allowed by default. 124 + 125 + there is one practical compatibility note: if someone was intentionally resolving `did:web` or handles to private infrastructure through zat's identity resolvers, that now fails with `error.UnsafeIdentityHost`. that is the correct default for a public AT Protocol library. private-network fetching needs an explicit escape hatch, not accidental behavior. 126 + 127 + the local release checks are boring, which is what a patch release should be: 128 + 129 + ```text 130 + zig build 131 + zig build test --summary all # 427/427 132 + just check 133 + just test # 427/427 134 + ``` 135 + 136 + zat is `v0.3.1`.
+2
scripts/publish-docs.zig
··· 22 22 .{ .path = "/devlog/006", .file = "devlog/006-building-a-relay.md" }, 23 23 .{ .path = "/devlog/007", .file = "devlog/007-up-and-to-the-right.md" }, 24 24 .{ .path = "/devlog/008", .file = "devlog/008-the-io-migration.md" }, 25 + .{ .path = "/devlog/009", .file = "devlog/009-back-to-threads.md" }, 26 + .{ .path = "/devlog/010", .file = "devlog/010-the-network-is-input.md" }, 25 27 }; 26 28 27 29 pub fn main() !void {