atproto pds in zig pds.zat.dev
pds atproto
24

Configure Feed

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

Add configurable debug logging

zzstoatzz (May 21, 2026, 12:46 PM -0500) b8ddac90 73c6e750

+106 -25
+21 -20
src/atproto/server.zig
··· 1 1 const std = @import("std"); 2 2 const auth = @import("../auth/dev.zig"); 3 3 const config = @import("../core/config.zig"); 4 + const log = @import("../core/log.zig"); 4 5 const mail = @import("../core/mail.zig"); 5 6 const http_api = @import("../http/api.zig"); 6 7 const store = @import("../storage/store.zig"); ··· 79 80 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "new DID creation is not wired yet"); 80 81 }; 81 82 if (zat.Did.parse(did) == null) { 82 - std.debug.print("xrpc createAccount rejected invalid_did did={s} handle={s}\n", .{ did, handle }); 83 + log.debug("xrpc createAccount rejected invalid_did did={s} handle={s}\n", .{ did, handle }); 83 84 return http_api.xrpcError(request, .bad_request, "InvalidDid", "invalid did"); 84 85 } 85 - std.debug.print("xrpc createAccount attempt did={s} handle={s}\n", .{ did, handle }); 86 + log.debug("xrpc createAccount attempt did={s} handle={s}\n", .{ did, handle }); 86 87 try verifyCreateAccountServiceAuth(request, allocator, authorization, did); 87 88 88 89 const account = store.createAccount(allocator, handle, email, password, did, false) catch { 89 - std.debug.print("xrpc createAccount rejected account_exists_or_store_error did={s} handle={s}\n", .{ did, handle }); 90 + log.debug("xrpc createAccount rejected account_exists_or_store_error did={s} handle={s}\n", .{ did, handle }); 90 91 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "account already exists"); 91 92 }; 92 - std.debug.print("xrpc createAccount ok did={s} handle={s}\n", .{ account.did, account.handle }); 93 + log.debug("xrpc createAccount ok did={s} handle={s}\n", .{ account.did, account.handle }); 93 94 const access = try auth.createDevJwt(allocator, "access", account); 94 95 const refresh = try auth.createDevJwt(allocator, "refresh", account); 95 96 const body_out = try std.fmt.allocPrint( ··· 142 143 143 144 fn verifyCreateAccountServiceAuth(request: *http.Server.Request, allocator: std.mem.Allocator, maybe_authorization: ?[]const u8, did: []const u8) !void { 144 145 const raw_header = maybe_authorization orelse { 145 - std.debug.print("xrpc createAccount service_auth missing did={s}\n", .{did}); 146 + log.debug("xrpc createAccount service_auth missing did={s}\n", .{did}); 146 147 return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "service auth required to migrate an existing did"); 147 148 }; 148 149 if (!std.ascii.startsWithIgnoreCase(raw_header, "bearer ")) { 149 - std.debug.print("xrpc createAccount service_auth malformed did={s}\n", .{did}); 150 + log.debug("xrpc createAccount service_auth malformed did={s}\n", .{did}); 150 151 return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "service auth required to migrate an existing did"); 151 152 } 152 153 const token = std.mem.trim(u8, raw_header["bearer ".len..], " \t"); 153 154 var jwt = zat.Jwt.parse(allocator, token) catch { 154 - std.debug.print("xrpc createAccount service_auth invalid_jwt did={s}\n", .{did}); 155 + log.debug("xrpc createAccount service_auth invalid_jwt did={s}\n", .{did}); 155 156 return http_api.xrpcError(request, .unauthorized, "InvalidToken", "invalid service auth token"); 156 157 }; 157 158 defer jwt.deinit(); 158 159 159 160 const issuer_did = issuerDid(jwt.payload.iss); 160 161 if (!std.mem.eql(u8, issuer_did, did)) { 161 - std.debug.print("xrpc createAccount service_auth issuer_mismatch did={s} issuer={s}\n", .{ did, issuer_did }); 162 + log.debug("xrpc createAccount service_auth issuer_mismatch did={s} issuer={s}\n", .{ did, issuer_did }); 162 163 return http_api.xrpcError(request, .unauthorized, "InvalidToken", "jwt issuer does not match did"); 163 164 } 164 165 if (!std.mem.eql(u8, jwt.payload.aud, config.serverDid())) { 165 - std.debug.print("xrpc createAccount service_auth aud_mismatch did={s} aud={s} expected={s}\n", .{ did, jwt.payload.aud, config.serverDid() }); 166 + log.debug("xrpc createAccount service_auth aud_mismatch did={s} aud={s} expected={s}\n", .{ did, jwt.payload.aud, config.serverDid() }); 166 167 return http_api.xrpcError(request, .unauthorized, "InvalidToken", "jwt audience does not match service did"); 167 168 } 168 169 if (jwt.isExpired(store.currentIo())) { 169 - std.debug.print("xrpc createAccount service_auth expired did={s}\n", .{did}); 170 + log.debug("xrpc createAccount service_auth expired did={s}\n", .{did}); 170 171 return http_api.xrpcError(request, .bad_request, "ExpiredToken", "token expired"); 171 172 } 172 173 if (jwt.payload.lxm) |lxm| { 173 174 if (!std.mem.eql(u8, lxm, "com.atproto.server.createAccount") and !std.mem.eql(u8, lxm, "*")) { 174 - std.debug.print("xrpc createAccount service_auth lxm_mismatch did={s} lxm={s}\n", .{ did, lxm }); 175 + log.debug("xrpc createAccount service_auth lxm_mismatch did={s} lxm={s}\n", .{ did, lxm }); 175 176 return http_api.xrpcError(request, .unauthorized, "InvalidToken", "jwt lxm does not match com.atproto.server.createAccount"); 176 177 } 177 178 } else { 178 - std.debug.print("xrpc createAccount service_auth missing_lxm did={s}\n", .{did}); 179 + log.debug("xrpc createAccount service_auth missing_lxm did={s}\n", .{did}); 179 180 return http_api.xrpcError(request, .unauthorized, "InvalidToken", "jwt lxm is required"); 180 181 } 181 182 182 183 var resolver = zat.DidResolver.init(store.currentIo(), allocator); 183 184 defer resolver.deinit(); 184 185 var doc = resolver.resolve(zat.Did.parse(issuer_did).?) catch { 185 - std.debug.print("xrpc createAccount service_auth did_resolution_failed did={s}\n", .{did}); 186 + log.debug("xrpc createAccount service_auth did_resolution_failed did={s}\n", .{did}); 186 187 return http_api.xrpcError(request, .bad_gateway, "DidResolutionFailed", "could not resolve jwt issuer did"); 187 188 }; 188 189 defer doc.deinit(); 189 190 const signing_key = doc.signingKey() orelse { 190 - std.debug.print("xrpc createAccount service_auth missing_signing_key did={s}\n", .{did}); 191 + log.debug("xrpc createAccount service_auth missing_signing_key did={s}\n", .{did}); 191 192 return http_api.xrpcError(request, .bad_gateway, "DidResolutionFailed", "missing signing key in issuer did doc"); 192 193 }; 193 194 jwt.verify(signing_key.public_key_multibase) catch { 194 - std.debug.print("xrpc createAccount service_auth signature_mismatch did={s}\n", .{did}); 195 + log.debug("xrpc createAccount service_auth signature_mismatch did={s}\n", .{did}); 195 196 return http_api.xrpcError(request, .unauthorized, "InvalidToken", "jwt signature does not match jwt issuer"); 196 197 }; 197 - std.debug.print("xrpc createAccount service_auth ok did={s}\n", .{did}); 198 + log.debug("xrpc createAccount service_auth ok did={s}\n", .{did}); 198 199 } 199 200 200 201 fn issuerDid(iss: []const u8) []const u8 { ··· 218 219 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing password"); 219 220 }; 220 221 221 - std.debug.print("xrpc createSession attempt identifier={s}\n", .{identifier}); 222 + log.debug("xrpc createSession attempt identifier={s}\n", .{identifier}); 222 223 const account = (store.findAccount(arena.allocator(), identifier) catch null) orelse { 223 - std.debug.print("xrpc createSession rejected account_not_found identifier={s}\n", .{identifier}); 224 + log.debug("xrpc createSession rejected account_not_found identifier={s}\n", .{identifier}); 224 225 return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Invalid identifier or password"); 225 226 }; 226 227 if (!auth.passwordMatches(account, password)) { 227 - std.debug.print("xrpc createSession rejected password_mismatch identifier={s} did={s} handle={s}\n", .{ identifier, account.did, account.handle }); 228 + log.debug("xrpc createSession rejected password_mismatch identifier={s} did={s} handle={s}\n", .{ identifier, account.did, account.handle }); 228 229 return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Invalid identifier or password"); 229 230 } 230 - std.debug.print("xrpc createSession ok identifier={s} did={s} handle={s}\n", .{ identifier, account.did, account.handle }); 231 + log.debug("xrpc createSession ok identifier={s} did={s} handle={s}\n", .{ identifier, account.did, account.handle }); 231 232 232 233 const access = try auth.createDevJwt(arena.allocator(), "access", account); 233 234 const refresh = try auth.createDevJwt(arena.allocator(), "refresh", account);
+27
src/core/config.zig
··· 8 8 var blob_upload_limit_value: usize = 100_000_000; 9 9 var handle_domains_value: []const u8 = ".test"; 10 10 var jwt_secret_value: []const u8 = "zds-local-development-jwt-secret-change-me"; 11 + var log_level_value: LogLevel = .info; 12 + 13 + pub const LogLevel = enum(u2) { 14 + err = 0, 15 + info = 1, 16 + debug = 2, 17 + }; 11 18 12 19 pub fn publicUrl() []const u8 { 13 20 return public_url_value; ··· 41 48 return jwt_secret_value; 42 49 } 43 50 51 + pub fn logLevel() LogLevel { 52 + return log_level_value; 53 + } 54 + 55 + pub fn logEnabled(level: LogLevel) bool { 56 + return @intFromEnum(level) <= @intFromEnum(log_level_value); 57 + } 58 + 44 59 pub fn setPublicUrl(value: []const u8) void { 45 60 public_url_value = trimTrailingSlash(value); 46 61 } ··· 71 86 72 87 pub fn setJwtSecret(value: []const u8) void { 73 88 jwt_secret_value = value; 89 + } 90 + 91 + pub fn setLogLevel(value: LogLevel) void { 92 + log_level_value = value; 93 + } 94 + 95 + pub fn parseLogLevel(value: []const u8) ?LogLevel { 96 + if (std.ascii.eqlIgnoreCase(value, "error")) return .err; 97 + if (std.ascii.eqlIgnoreCase(value, "err")) return .err; 98 + if (std.ascii.eqlIgnoreCase(value, "info")) return .info; 99 + if (std.ascii.eqlIgnoreCase(value, "debug")) return .debug; 100 + return null; 74 101 } 75 102 76 103 fn trimTrailingSlash(value: []const u8) []const u8 {
+19
src/core/log.zig
··· 1 + const std = @import("std"); 2 + const config = @import("config.zig"); 3 + 4 + pub fn debug(comptime fmt: []const u8, args: anytype) void { 5 + write(.debug, "debug", fmt, args); 6 + } 7 + 8 + pub fn info(comptime fmt: []const u8, args: anytype) void { 9 + write(.info, "info", fmt, args); 10 + } 11 + 12 + pub fn err(comptime fmt: []const u8, args: anytype) void { 13 + write(.err, "error", fmt, args); 14 + } 15 + 16 + fn write(level: config.LogLevel, comptime label: []const u8, comptime fmt: []const u8, args: anytype) void { 17 + if (!config.logEnabled(level)) return; 18 + std.debug.print(label ++ " " ++ fmt, args); 19 + }
+2 -1
src/core/mail.zig
··· 1 1 const std = @import("std"); 2 2 const config = @import("config.zig"); 3 + const log = @import("log.zig"); 3 4 const zat = @import("zat"); 4 5 5 6 pub fn send( ··· 56 57 } 57 58 58 59 fn logMail(subject: []const u8, email: []const u8, handle: []const u8, text: []const u8) void { 59 - std.debug.print("[zds mail] {s} for {s} <{s}>: {s}\n", .{ subject, handle, email, text }); 60 + log.info("[zds mail] {s} for {s} <{s}>: {s}\n", .{ subject, handle, email, text }); 60 61 }
+9 -4
src/http/server.zig
··· 5 5 const atproto_server = @import("../atproto/server.zig"); 6 6 const atproto_sync = @import("../atproto/sync.zig"); 7 7 const bsky_compat = @import("../bsky/compat.zig"); 8 + const log = @import("../core/log.zig"); 8 9 const http_api = @import("api.zig"); 9 10 const router = @import("router.zig"); 10 11 ··· 27 28 var tcp_server = try address.listen(io, .{ .reuse_address = true }); 28 29 defer tcp_server.deinit(io); 29 30 30 - std.debug.print("zds listening on http://{s}:{d}\n", .{ options.host, options.port }); 31 + log.info("zds listening on http://{s}:{d}\n", .{ options.host, options.port }); 31 32 while (true) { 32 33 const stream = try tcp_server.accept(io); 33 34 serveConnection(io, stream); ··· 49 50 var request = http_server.receiveHead() catch |err| switch (err) { 50 51 error.HttpConnectionClosing => return, 51 52 else => { 52 - std.debug.print("failed to receive request: {s}\n", .{@errorName(err)}); 53 + log.err("failed to receive request: {s}\n", .{@errorName(err)}); 53 54 return; 54 55 }, 55 56 }; 56 57 serveRequest(&request) catch |err| { 57 - std.debug.print("failed to serve {s}: {s}\n", .{ request.head.target, @errorName(err) }); 58 + log.err("failed to serve {s}: {s}\n", .{ request.head.target, @errorName(err) }); 58 59 return; 59 60 }; 60 61 } 61 62 62 63 fn serveRequest(request: *http.Server.Request) !void { 63 - switch (router.route(request.head.method, request.head.target)) { 64 + const route = router.route(request.head.method, request.head.target); 65 + log.debug("http {s} {s} route={s} start\n", .{ @tagName(request.head.method), request.head.target, @tagName(route) }); 66 + defer log.debug("http {s} {s} route={s} done\n", .{ @tagName(request.head.method), request.head.target, @tagName(route) }); 67 + 68 + switch (route) { 64 69 .cors_preflight => try corsPreflight(request), 65 70 .root => try text(request, .ok, 66 71 \\ __
+27
src/main.zig
··· 13 13 var blob_upload_limit: ?usize = try envUsize("ZDS_BLOB_UPLOAD_LIMIT"); 14 14 var handle_domains: ?[]const u8 = cGetenv("ZDS_HANDLE_DOMAINS"); 15 15 var jwt_secret: ?[]const u8 = cGetenv("ZDS_JWT_SECRET"); 16 + var log_level: ?[]const u8 = cGetenv("ZDS_LOG_LEVEL"); 17 + if (envBool("ZDS_DEBUG")) log_level = "debug"; 16 18 17 19 var args = std.process.Args.Iterator.init(init.minimal.args); 18 20 _ = args.next(); ··· 67 69 jwt_secret = args.next() orelse return error.MissingJwtSecret; 68 70 continue; 69 71 } 72 + if (std.mem.eql(u8, arg, "--log-level")) { 73 + log_level = args.next() orelse return error.MissingLogLevel; 74 + continue; 75 + } 76 + if (std.mem.eql(u8, arg, "--debug")) { 77 + log_level = "debug"; 78 + continue; 79 + } 70 80 if (std.mem.startsWith(u8, arg, "--port=")) { 71 81 port = try std.fmt.parseInt(u16, arg["--port=".len..], 10); 72 82 continue; ··· 109 119 } 110 120 if (std.mem.startsWith(u8, arg, "--jwt-secret=")) { 111 121 jwt_secret = arg["--jwt-secret=".len..]; 122 + continue; 123 + } 124 + if (std.mem.startsWith(u8, arg, "--log-level=")) { 125 + log_level = arg["--log-level=".len..]; 112 126 continue; 113 127 } 114 128 return error.UnknownArgument; ··· 122 136 if (blob_upload_limit) |value| zds.core.config.setBlobUploadLimit(value); 123 137 if (handle_domains) |value| zds.core.config.setHandleDomains(value); 124 138 if (jwt_secret) |value| zds.core.config.setJwtSecret(value); 139 + if (log_level) |value| { 140 + const parsed = zds.core.config.parseLogLevel(value) orelse return error.InvalidLogLevel; 141 + zds.core.config.setLogLevel(parsed); 142 + } 125 143 126 144 try zds.storage.store.init(init.io, db_path); 127 145 try zds.http.server.listen(init.io, .{ .host = host, .port = port }); ··· 131 149 std.debug.print( 132 150 \\usage: zds [--host HOST] [--port PORT] [--db PATH] [--public-url URL] [--server-did DID] 133 151 \\ [--blob-upload-limit BYTES] [--handle-domains DOMAINS] [--jwt-secret SECRET] 152 + \\ [--log-level error|info|debug] [--debug] 134 153 \\ 135 154 \\Runs a local PDS-shaped HTTP server. 136 155 \\DOMAINS is a comma-separated list such as ".example.com,example.com". ··· 146 165 const value = cGetenv(name) orelse return null; 147 166 return try std.fmt.parseInt(usize, value, 10); 148 167 } 168 + 169 + fn envBool(name: [*:0]const u8) bool { 170 + const value = cGetenv(name) orelse return false; 171 + return std.ascii.eqlIgnoreCase(value, "1") or 172 + std.ascii.eqlIgnoreCase(value, "true") or 173 + std.ascii.eqlIgnoreCase(value, "yes") or 174 + std.ascii.eqlIgnoreCase(value, "on"); 175 + }
+1
src/root.zig
··· 19 19 pub const core = struct { 20 20 pub const atid = @import("core/atid.zig"); 21 21 pub const config = @import("core/config.zig"); 22 + pub const log = @import("core/log.zig"); 22 23 pub const mail = @import("core/mail.zig"); 23 24 pub const repo = @import("core/repo.zig"); 24 25 pub const syntax = @import("core/syntax.zig");