atproto pds in zig pds.zat.dev
pds atproto
25

Configure Feed

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

Close deployment migration gaps

zzstoatzz (May 21, 2026, 9:03 AM -0500) d7044bb9 7dbd78ef

+268 -22
+7 -1
README.md
··· 49 49 The local server currently exposes: 50 50 51 51 - `GET /` 52 + - `GET /.well-known/did.json` 53 + - `GET /.well-known/atproto-did` 52 54 - `GET /xrpc/_health` 53 55 - `GET /xrpc/com.atproto.server.describeServer` 54 56 - `POST /xrpc/com.atproto.server.createAccount` 55 57 - `POST /xrpc/com.atproto.server.createSession` 58 + - `POST /xrpc/com.atproto.server.refreshSession` 56 59 - `GET /xrpc/com.atproto.server.getSession` 57 60 - `GET /xrpc/com.atproto.server.getServiceAuth` 58 61 - migration-oriented repo, blob, account-status, email, and PLC submission endpoints ··· 71 74 ZDS_RESEND_API_KEY=... \ 72 75 ZDS_EMAIL_FROM='ZDS <pds@example.com>' \ 73 76 ZDS_BLOB_UPLOAD_LIMIT=100000000 \ 77 + ZDS_HANDLE_DOMAINS='.example.com,example.com' \ 78 + ZDS_JWT_SECRET='at-least-32-random-bytes-here' \ 74 79 zig build run -- \ 75 80 --port 2583 \ 76 81 --db /var/lib/zds/zds.sqlite3 \ ··· 82 87 DIDs, real repo writes with per-account signing keys, verified repo CAR import, 83 88 repo export, blob import checks, account activation state, recommended DID 84 89 credentials, PLC operation signing/submission, and email delivery via Resend 85 - with local log fallback. 90 + with local log fallback. Deployment also serves the PDS `did:web` document and 91 + handle `/.well-known/atproto-did` resolution. 86 92 87 93 The next production risks are operational rather than fake core shortcuts: 88 94 sequenced subscription events, durable session/recovery flows, and a filesystem
+11 -1
docs/production-migration.md
··· 27 27 - `com.atproto.server.checkAccountStatus` 28 28 - `com.atproto.server.activateAccount` 29 29 - `com.atproto.server.deactivateAccount` 30 + - `com.atproto.server.refreshSession` 30 31 - `com.atproto.repo.importRepo` 31 32 - `com.atproto.repo.listMissingBlobs` 32 33 - `com.atproto.sync.getRepo` ··· 42 43 The migration-critical implementation details are also in place: 43 44 44 45 - `createAccount` verifies migration service JWTs against the migrating DID 46 + - session tokens are signed with the configured `ZDS_JWT_SECRET` 47 + - stored passwords use salted PBKDF2-SHA256 hashes 45 48 - repo writes produce signed commit blocks and MST updates, not synthetic refs 46 49 - imported repo CARs are verified against the account DID and signing key 47 50 - each account has its own signing key for repo commits and PLC credentials 48 51 - `uploadBlob` defaults to 100,000,000 bytes and is configurable with 49 52 `ZDS_BLOB_UPLOAD_LIMIT` or `--blob-upload-limit` 53 + - the server serves `/.well-known/did.json` for the PDS service DID and 54 + `/.well-known/atproto-did` for hosted handles 50 55 51 56 Still to harden after a throwaway migration: 52 57 53 58 - `com.atproto.sync.subscribeRepos` and real sequenced account/identity events 54 - - durable session storage, password reset, and account recovery paths 59 + - password reset and account recovery paths 55 60 - filesystem or object-store blob backend with backup/restore tooling 56 61 57 62 ## Deployment Requirements ··· 62 67 ZDS_RESEND_API_KEY=... \ 63 68 ZDS_EMAIL_FROM='ZDS <pds@example.com>' \ 64 69 ZDS_BLOB_UPLOAD_LIMIT=100000000 \ 70 + ZDS_HANDLE_DOMAINS='.example.com,example.com' \ 71 + ZDS_JWT_SECRET='at-least-32-random-bytes-here' \ 65 72 zig build run -- \ 66 73 --port 2583 \ 67 74 --db /var/lib/zds/zds.sqlite3 \ ··· 73 80 and `ZDS_EMAIL_FROM` enable real email delivery; without them, token emails are 74 81 logged to stdout for local development. `ZDS_BLOB_UPLOAD_LIMIT` controls the 75 82 generic `com.atproto.repo.uploadBlob` request body ceiling. 83 + `ZDS_HANDLE_DOMAINS` is what `describeServer` advertises to clients. 84 + `ZDS_JWT_SECRET` signs app-session access and refresh JWTs; keep it stable 85 + across deploys or existing sessions will be invalidated. 76 86 77 87 The AT Protocol uploadBlob lexicon does not define one universal byte limit; it 78 88 says blob restrictions are enforced when a record references the uploaded blob.
+91 -2
src/atproto/server.zig
··· 11 11 pub fn describeServer(request: *http.Server.Request) !void { 12 12 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 13 13 defer arena.deinit(); 14 + const domains = try jsonStringArray(arena.allocator(), config.handleDomains()); 14 15 const body = try std.fmt.allocPrint( 15 16 arena.allocator(), 16 - "{{\"did\":{f},\"availableUserDomains\":[\".test\"],\"inviteCodeRequired\":false}}", 17 - .{std.json.fmt(config.serverDid(), .{})}, 17 + "{{\"did\":{f},\"availableUserDomains\":{s},\"inviteCodeRequired\":false}}", 18 + .{ std.json.fmt(config.serverDid(), .{}), domains }, 18 19 ); 19 20 return http_api.json(request, .ok, body); 21 + } 22 + 23 + pub fn didJson(request: *http.Server.Request) !void { 24 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 25 + defer arena.deinit(); 26 + const body = try std.fmt.allocPrint( 27 + arena.allocator(), 28 + "{{\"@context\":[\"https://www.w3.org/ns/did/v1\"],\"id\":{f},\"service\":[{{\"id\":\"#atproto_pds\",\"type\":\"AtprotoPersonalDataServer\",\"serviceEndpoint\":{f}}}]}}", 29 + .{ std.json.fmt(config.serverDid(), .{}), std.json.fmt(config.publicUrl(), .{}) }, 30 + ); 31 + return http_api.json(request, .ok, body); 32 + } 33 + 34 + pub fn atprotoDid(request: *http.Server.Request) !void { 35 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 36 + defer arena.deinit(); 37 + const allocator = arena.allocator(); 38 + const host = requestHost(request) orelse { 39 + return plain(request, .not_found, "User not found"); 40 + }; 41 + const handle = stripPort(host); 42 + const account = store.findAccount(allocator, handle) catch null orelse { 43 + return plain(request, .not_found, "User not found"); 44 + }; 45 + return plain(request, .ok, account.did); 20 46 } 21 47 22 48 pub fn createAccount(request: *http.Server.Request) !void { ··· 69 95 try http_api.json(request, .ok, body_out); 70 96 } 71 97 98 + fn jsonStringArray(allocator: std.mem.Allocator, raw: []const u8) ![]const u8 { 99 + var out: std.Io.Writer.Allocating = .init(allocator); 100 + defer out.deinit(); 101 + try out.writer.writeByte('['); 102 + var parts = std.mem.splitScalar(u8, raw, ','); 103 + var index: usize = 0; 104 + while (parts.next()) |part| { 105 + const trimmed = std.mem.trim(u8, part, " \t\r\n"); 106 + if (trimmed.len == 0) continue; 107 + if (index != 0) try out.writer.writeByte(','); 108 + try out.writer.print("{f}", .{std.json.fmt(trimmed, .{})}); 109 + index += 1; 110 + } 111 + try out.writer.writeByte(']'); 112 + return out.toOwnedSlice(); 113 + } 114 + 115 + fn requestHost(request: *const http.Server.Request) ?[]const u8 { 116 + return http_api.headerValue(request, "host") orelse http_api.headerValue(request, ":authority"); 117 + } 118 + 119 + fn stripPort(host: []const u8) []const u8 { 120 + if (std.mem.startsWith(u8, host, "[")) return host; 121 + if (std.mem.lastIndexOfScalar(u8, host, ':')) |idx| return host[0..idx]; 122 + return host; 123 + } 124 + 125 + fn plain(request: *http.Server.Request, status: http.Status, body: []const u8) !void { 126 + try request.respond(body, .{ 127 + .status = status, 128 + .extra_headers = &plain_headers, 129 + }); 130 + } 131 + 132 + const plain_headers = [_]http.Header{ 133 + .{ .name = "content-type", .value = "text/plain; charset=utf-8" }, 134 + .{ .name = "access-control-allow-origin", .value = "*" }, 135 + .{ .name = "connection", .value = "close" }, 136 + }; 137 + 72 138 fn verifyCreateAccountServiceAuth(request: *http.Server.Request, allocator: std.mem.Allocator, did: []const u8) !void { 73 139 const raw_header = http_api.headerValue(request, "authorization") orelse { 74 140 return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "service auth required to migrate an existing did"); ··· 151 217 152 218 const body_out = try std.fmt.allocPrint( 153 219 arena.allocator(), 220 + "{{\"did\":\"{s}\",\"handle\":\"{s}\",\"email\":{f},\"emailConfirmed\":{},\"accessJwt\":\"{s}\",\"refreshJwt\":\"{s}\",\"active\":{}}}", 221 + .{ account.did, account.handle, std.json.fmt(info.email, .{}), info.email_confirmed, access, refresh, active }, 222 + ); 223 + try http_api.json(request, .ok, body_out); 224 + } 225 + 226 + pub fn refreshSession(request: *http.Server.Request) !void { 227 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 228 + defer arena.deinit(); 229 + const allocator = arena.allocator(); 230 + 231 + const account = http_api.requireBearerAccountWithScope(request, allocator, "com.atproto.refresh") catch |err| switch (err) { 232 + error.AuthRequired => return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"), 233 + error.InvalidToken => return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), 234 + }; 235 + const access = try auth.createDevJwt(allocator, "access", account); 236 + const refresh = try auth.createDevJwt(allocator, "refresh", account); 237 + const info = store.getEmailInfo(allocator, account.did) orelse { 238 + return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); 239 + }; 240 + const active = store.isAccountActive(account.did); 241 + const body_out = try std.fmt.allocPrint( 242 + allocator, 154 243 "{{\"did\":\"{s}\",\"handle\":\"{s}\",\"email\":{f},\"emailConfirmed\":{},\"accessJwt\":\"{s}\",\"refreshJwt\":\"{s}\",\"active\":{}}}", 155 244 .{ account.did, account.handle, std.json.fmt(info.email, .{}), info.email_confirmed, access, refresh, active }, 156 245 );
+87 -10
src/auth/dev.zig
··· 1 1 const std = @import("std"); 2 + const config = @import("../core/config.zig"); 2 3 const zat = @import("zat"); 3 4 4 5 pub const Account = struct { ··· 6 7 did: []const u8, 7 8 email: []const u8, 8 9 password: []const u8, 10 + }; 11 + 12 + pub const TokenClaims = struct { 13 + did: []const u8, 14 + scope: []const u8, 9 15 }; 10 16 11 17 pub const accounts = [_]Account{ ··· 23 29 }, 24 30 }; 25 31 32 + var jwt_counter: usize = 0; 33 + 26 34 pub fn findAccount(identifier: []const u8) ?Account { 27 35 for (accounts) |account| { 28 36 if (std.ascii.eqlIgnoreCase(identifier, account.handle) or ··· 36 44 } 37 45 38 46 pub fn passwordMatches(account: Account, password: []const u8) bool { 47 + if (verifyPasswordHash(account.password, password)) return true; 39 48 return std.mem.eql(u8, password, account.password); 40 49 } 41 50 51 + pub fn hashPassword(allocator: std.mem.Allocator, password: []const u8, salt: [16]u8) ![]const u8 { 52 + var derived: [32]u8 = undefined; 53 + try std.crypto.pwhash.pbkdf2(&derived, password, &salt, password_hash_rounds, std.crypto.auth.hmac.sha2.HmacSha256); 54 + const salt_hex = std.fmt.bytesToHex(salt, .lower); 55 + const derived_hex = std.fmt.bytesToHex(derived, .lower); 56 + return std.fmt.allocPrint(allocator, "pbkdf2-sha256:{d}:{s}:{s}", .{ password_hash_rounds, &salt_hex, &derived_hex }); 57 + } 58 + 59 + fn verifyPasswordHash(stored: []const u8, password: []const u8) bool { 60 + var parts = std.mem.splitScalar(u8, stored, ':'); 61 + const scheme = parts.next() orelse return false; 62 + if (!std.mem.eql(u8, scheme, "pbkdf2-sha256")) return false; 63 + const rounds_text = parts.next() orelse return false; 64 + const salt_hex = parts.next() orelse return false; 65 + const derived_hex = parts.next() orelse return false; 66 + if (parts.next() != null or salt_hex.len != 32 or derived_hex.len != 64) return false; 67 + const rounds = std.fmt.parseInt(u32, rounds_text, 10) catch return false; 68 + var salt: [16]u8 = undefined; 69 + _ = std.fmt.hexToBytes(&salt, salt_hex) catch return false; 70 + var expected: [32]u8 = undefined; 71 + _ = std.fmt.hexToBytes(&expected, derived_hex) catch return false; 72 + var actual: [32]u8 = undefined; 73 + std.crypto.pwhash.pbkdf2(&actual, password, &salt, rounds, std.crypto.auth.hmac.sha2.HmacSha256) catch return false; 74 + return std.crypto.timing_safe.eql([32]u8, actual, expected); 75 + } 76 + 77 + const password_hash_rounds: u32 = 100_000; 78 + 42 79 pub fn createDevJwt(allocator: std.mem.Allocator, comptime kind: []const u8, account: Account) ![]const u8 { 43 - const header = "{\"alg\":\"none\",\"typ\":\"JWT\"}"; 80 + const header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; 81 + const jti = @atomicRmw(usize, &jwt_counter, .Add, 1, .monotonic); 44 82 const payload = try std.fmt.allocPrint( 45 83 allocator, 46 - "{{\"sub\":\"{s}\",\"scope\":\"com.atproto.{s}\",\"iat\":0,\"exp\":4102444800}}", 47 - .{ account.did, kind }, 84 + "{{\"sub\":\"{s}\",\"scope\":\"com.atproto.{s}\",\"iat\":0,\"exp\":4102444800,\"jti\":\"zds-{d}\"}}", 85 + .{ account.did, kind, jti }, 48 86 ); 49 87 defer allocator.free(payload); 50 88 ··· 53 91 const payload_b64 = try zat.jwt.base64UrlEncode(allocator, payload); 54 92 defer allocator.free(payload_b64); 55 93 56 - return std.fmt.allocPrint(allocator, "{s}.{s}.", .{ header_b64, payload_b64 }); 94 + const signing_input = try std.fmt.allocPrint(allocator, "{s}.{s}", .{ header_b64, payload_b64 }); 95 + defer allocator.free(signing_input); 96 + var signature: [std.crypto.auth.hmac.sha2.HmacSha256.mac_length]u8 = undefined; 97 + std.crypto.auth.hmac.sha2.HmacSha256.create(&signature, signing_input, config.jwtSecret()); 98 + const signature_b64 = try zat.jwt.base64UrlEncode(allocator, &signature); 99 + defer allocator.free(signature_b64); 100 + return std.fmt.allocPrint(allocator, "{s}.{s}", .{ signing_input, signature_b64 }); 57 101 } 58 102 59 103 pub fn createServiceJwt( ··· 94 138 } 95 139 96 140 pub fn accountFromDevJwt(allocator: std.mem.Allocator, token: []const u8) ?Account { 97 - const did = subjectFromDevJwt(allocator, token) orelse return null; 98 - defer allocator.free(did); 141 + const claims = claimsFromDevJwt(allocator, token) orelse return null; 142 + defer allocator.free(claims.did); 143 + defer allocator.free(claims.scope); 99 144 100 145 for (accounts) |account| { 101 - if (std.mem.eql(u8, did, account.did)) return account; 146 + if (std.mem.eql(u8, claims.did, account.did)) return account; 102 147 } 103 148 return null; 104 149 } 105 150 106 151 pub fn subjectFromDevJwt(allocator: std.mem.Allocator, token: []const u8) ?[]const u8 { 152 + const claims = claimsFromDevJwt(allocator, token) orelse return null; 153 + allocator.free(claims.scope); 154 + return claims.did; 155 + } 156 + 157 + pub fn claimsFromDevJwt(allocator: std.mem.Allocator, token: []const u8) ?TokenClaims { 107 158 var parts = std.mem.splitScalar(u8, token, '.'); 108 - _ = parts.next() orelse return null; 159 + const header_part = parts.next() orelse return null; 109 160 const payload_part = parts.next() orelse return null; 110 - if (payload_part.len == 0) return null; 161 + const signature_part = parts.next() orelse return null; 162 + if (payload_part.len == 0 or signature_part.len == 0 or parts.next() != null) return null; 163 + 164 + const signing_input_len = header_part.len + 1 + payload_part.len; 165 + if (token.len < signing_input_len or token[header_part.len] != '.') return null; 166 + const signing_input = token[0..signing_input_len]; 167 + 168 + const header_json = zat.jwt.base64UrlDecode(allocator, header_part) catch return null; 169 + defer allocator.free(header_json); 170 + const header = std.json.parseFromSlice(std.json.Value, allocator, header_json, .{}) catch return null; 171 + defer header.deinit(); 172 + const alg = zat.json.getString(header.value, "alg") orelse return null; 173 + if (!std.mem.eql(u8, alg, "HS256")) return null; 174 + 175 + const signature = zat.jwt.base64UrlDecode(allocator, signature_part) catch return null; 176 + defer allocator.free(signature); 177 + if (signature.len != std.crypto.auth.hmac.sha2.HmacSha256.mac_length) return null; 178 + var expected: [std.crypto.auth.hmac.sha2.HmacSha256.mac_length]u8 = undefined; 179 + std.crypto.auth.hmac.sha2.HmacSha256.create(&expected, signing_input, config.jwtSecret()); 180 + var actual: [std.crypto.auth.hmac.sha2.HmacSha256.mac_length]u8 = undefined; 181 + @memcpy(&actual, signature); 182 + if (!std.crypto.timing_safe.eql([std.crypto.auth.hmac.sha2.HmacSha256.mac_length]u8, actual, expected)) return null; 111 183 112 184 const payload = zat.jwt.base64UrlDecode(allocator, payload_part) catch return null; 113 185 defer allocator.free(payload); ··· 116 188 defer parsed.deinit(); 117 189 const subject = zat.json.getString(parsed.value, "sub") orelse return null; 118 190 if (zat.Did.parse(subject) == null) return null; 119 - return allocator.dupe(u8, subject) catch null; 191 + const scope = zat.json.getString(parsed.value, "scope") orelse return null; 192 + if (!std.mem.startsWith(u8, scope, "com.atproto.")) return null; 193 + return .{ 194 + .did = allocator.dupe(u8, subject) catch return null, 195 + .scope = allocator.dupe(u8, scope) catch return null, 196 + }; 120 197 } 121 198 122 199 test "finds dev accounts by handle and did" {
+18
src/core/config.zig
··· 6 6 var resend_api_key_value: ?[]const u8 = null; 7 7 var email_from_value: ?[]const u8 = null; 8 8 var blob_upload_limit_value: usize = 100_000_000; 9 + var handle_domains_value: []const u8 = ".test"; 10 + var jwt_secret_value: []const u8 = "zds-local-development-jwt-secret-change-me"; 9 11 10 12 pub fn publicUrl() []const u8 { 11 13 return public_url_value; ··· 31 33 return blob_upload_limit_value; 32 34 } 33 35 36 + pub fn handleDomains() []const u8 { 37 + return handle_domains_value; 38 + } 39 + 40 + pub fn jwtSecret() []const u8 { 41 + return jwt_secret_value; 42 + } 43 + 34 44 pub fn setPublicUrl(value: []const u8) void { 35 45 public_url_value = trimTrailingSlash(value); 36 46 } ··· 53 63 54 64 pub fn setBlobUploadLimit(value: usize) void { 55 65 blob_upload_limit_value = value; 66 + } 67 + 68 + pub fn setHandleDomains(value: []const u8) void { 69 + handle_domains_value = value; 70 + } 71 + 72 + pub fn setJwtSecret(value: []const u8) void { 73 + jwt_secret_value = value; 56 74 } 57 75 58 76 fn trimTrailingSlash(value: []const u8) []const u8 {
+10 -4
src/http/api.zig
··· 15 15 } 16 16 17 17 pub fn requireBearerAccount(request: *const http.Server.Request, allocator: std.mem.Allocator) !auth.Account { 18 + return requireBearerAccountWithScope(request, allocator, "com.atproto.access"); 19 + } 20 + 21 + pub fn requireBearerAccountWithScope(request: *const http.Server.Request, allocator: std.mem.Allocator, scope: []const u8) !auth.Account { 18 22 const auth_header = headerValue(request, "authorization") orelse { 19 23 return error.AuthRequired; 20 24 }; ··· 23 27 } 24 28 25 29 const token = std.mem.trim(u8, auth_header["bearer ".len..], " \t"); 26 - const did = auth.subjectFromDevJwt(allocator, token) orelse return error.InvalidToken; 27 - defer allocator.free(did); 28 - if (store.findAccount(allocator, did) catch null) |account| return account; 29 - return auth.findAccount(did) orelse error.InvalidToken; 30 + const claims = auth.claimsFromDevJwt(allocator, token) orelse return error.InvalidToken; 31 + defer allocator.free(claims.did); 32 + defer allocator.free(claims.scope); 33 + if (!std.mem.eql(u8, claims.scope, scope)) return error.InvalidToken; 34 + if (store.findAccount(allocator, claims.did) catch null) |account| return account; 35 + return auth.findAccount(claims.did) orelse error.InvalidToken; 30 36 } 31 37 32 38 pub fn optionalBearerAccount(request: *const http.Server.Request, allocator: std.mem.Allocator) ?auth.Account {
+9
src/http/router.zig
··· 7 7 cors_preflight, 8 8 root, 9 9 health, 10 + did_json, 11 + atproto_did, 10 12 describe_server, 11 13 create_account, 12 14 create_session, 15 + refresh_session, 13 16 get_session, 14 17 get_service_auth, 15 18 activate_account, ··· 79 82 const path = stripQuery(target); 80 83 if (method == .GET and std.mem.eql(u8, path, "/")) return .root; 81 84 if (method == .GET and std.mem.eql(u8, path, xrpc.healthPath)) return .health; 85 + if (method == .GET and std.mem.eql(u8, path, "/.well-known/did.json")) return .did_json; 86 + if (method == .GET and std.mem.eql(u8, path, "/.well-known/atproto-did")) return .atproto_did; 82 87 if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.describeServer")) return .describe_server; 83 88 if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createAccount")) return .create_account; 84 89 if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createSession")) return .create_session; 90 + if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.refreshSession")) return .refresh_session; 85 91 if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.getSession")) return .get_session; 86 92 if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.getServiceAuth")) return .get_service_auth; 87 93 if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.activateAccount")) return .activate_account; ··· 157 163 try std.testing.expectEqual(Route.root, route(.GET, "/")); 158 164 try std.testing.expectEqual(Route.health, route(.GET, "/xrpc/_health")); 159 165 try std.testing.expectEqual(Route.health, route(.GET, "/xrpc/_health?x=1")); 166 + try std.testing.expectEqual(Route.did_json, route(.GET, "/.well-known/did.json")); 167 + try std.testing.expectEqual(Route.atproto_did, route(.GET, "/.well-known/atproto-did")); 160 168 try std.testing.expectEqual(Route.describe_server, route(.GET, "/xrpc/com.atproto.server.describeServer")); 161 169 try std.testing.expectEqual(Route.create_account, route(.POST, "/xrpc/com.atproto.server.createAccount")); 162 170 try std.testing.expectEqual(Route.create_session, route(.POST, "/xrpc/com.atproto.server.createSession")); 171 + try std.testing.expectEqual(Route.refresh_session, route(.POST, "/xrpc/com.atproto.server.refreshSession")); 163 172 try std.testing.expectEqual(Route.get_session, route(.GET, "/xrpc/com.atproto.server.getSession")); 164 173 try std.testing.expectEqual(Route.get_service_auth, route(.GET, "/xrpc/com.atproto.server.getServiceAuth?aud=did%3Aplc%3Aservice")); 165 174 try std.testing.expectEqual(Route.repo_list_records, route(.GET, "/xrpc/com.atproto.repo.listRecords?repo=alice.test&collection=app.bsky.feed.post"));
+3
src/http/server.zig
··· 66 66 .health => try json(request, .ok, 67 67 \\{"version":"0.0.0","status":"ok"} 68 68 ), 69 + .did_json => try atproto_server.didJson(request), 70 + .atproto_did => try atproto_server.atprotoDid(request), 69 71 .describe_server => try atproto_server.describeServer(request), 70 72 .create_account => try atproto_server.createAccount(request), 71 73 .create_session => try atproto_server.createSession(request), 74 + .refresh_session => try atproto_server.refreshSession(request), 72 75 .get_session => try atproto_server.getSession(request), 73 76 .get_service_auth => try atproto_server.getServiceAuth(request), 74 77 .activate_account => try atproto_server.activateAccount(request),
+22 -1
src/main.zig
··· 10 10 var email_from: ?[]const u8 = cGetenv("ZDS_EMAIL_FROM"); 11 11 var resend_api_key: ?[]const u8 = cGetenv("ZDS_RESEND_API_KEY"); 12 12 var blob_upload_limit: ?usize = try envUsize("ZDS_BLOB_UPLOAD_LIMIT"); 13 + var handle_domains: ?[]const u8 = cGetenv("ZDS_HANDLE_DOMAINS"); 14 + var jwt_secret: ?[]const u8 = cGetenv("ZDS_JWT_SECRET"); 13 15 14 16 var args = std.process.Args.Iterator.init(init.minimal.args); 15 17 _ = args.next(); ··· 52 54 blob_upload_limit = try std.fmt.parseInt(usize, value, 10); 53 55 continue; 54 56 } 57 + if (std.mem.eql(u8, arg, "--handle-domains")) { 58 + handle_domains = args.next() orelse return error.MissingHandleDomains; 59 + continue; 60 + } 61 + if (std.mem.eql(u8, arg, "--jwt-secret")) { 62 + jwt_secret = args.next() orelse return error.MissingJwtSecret; 63 + continue; 64 + } 55 65 if (std.mem.startsWith(u8, arg, "--port=")) { 56 66 port = try std.fmt.parseInt(u16, arg["--port=".len..], 10); 57 67 continue; ··· 84 94 blob_upload_limit = try std.fmt.parseInt(usize, arg["--blob-upload-limit=".len..], 10); 85 95 continue; 86 96 } 97 + if (std.mem.startsWith(u8, arg, "--handle-domains=")) { 98 + handle_domains = arg["--handle-domains=".len..]; 99 + continue; 100 + } 101 + if (std.mem.startsWith(u8, arg, "--jwt-secret=")) { 102 + jwt_secret = arg["--jwt-secret=".len..]; 103 + continue; 104 + } 87 105 return error.UnknownArgument; 88 106 } 89 107 ··· 93 111 zds.core.config.setEmailFrom(email_from); 94 112 zds.core.config.setResendApiKey(resend_api_key); 95 113 if (blob_upload_limit) |value| zds.core.config.setBlobUploadLimit(value); 114 + if (handle_domains) |value| zds.core.config.setHandleDomains(value); 115 + if (jwt_secret) |value| zds.core.config.setJwtSecret(value); 96 116 97 117 try zds.storage.store.init(init.io, db_path); 98 118 try zds.http.server.listen(init.io, .{ .port = port }); ··· 101 121 fn usage() void { 102 122 std.debug.print( 103 123 \\usage: zds [--port PORT] [--db PATH] [--public-url URL] [--server-did DID] 104 - \\ [--blob-upload-limit BYTES] 124 + \\ [--blob-upload-limit BYTES] [--handle-domains DOMAINS] [--jwt-secret SECRET] 105 125 \\ 106 126 \\Runs a local PDS-shaped HTTP server. 127 + \\DOMAINS is a comma-separated list such as ".example.com,example.com". 107 128 \\ 108 129 , .{}); 109 130 }
+10 -3
src/storage/store.zig
··· 177 177 defer mutex.unlock(store_io); 178 178 try requireInitialized(); 179 179 180 + var salt: [16]u8 = undefined; 181 + store_io.random(&salt); 182 + const password_hash = try auth.hashPassword(allocator, password, salt); 180 183 const signing_key = try generateSigningKey(); 181 184 try conn.exec( 182 185 \\INSERT INTO accounts (did, handle, email, password_hash, activated_at, signing_key_type, signing_key) ··· 189 192 \\ deactivated_at = NULL, 190 193 \\ signing_key_type = COALESCE(accounts.signing_key_type, excluded.signing_key_type), 191 194 \\ signing_key = COALESCE(accounts.signing_key, excluded.signing_key) 192 - , .{ did, handle, email, password, activated, zqlite.blob(&signing_key) }); 195 + , .{ did, handle, email, password_hash, activated, zqlite.blob(&signing_key) }); 193 196 return .{ 194 197 .handle = try allocator.dupe(u8, handle), 195 198 .did = try allocator.dupe(u8, did), 196 199 .email = try allocator.dupe(u8, email), 197 - .password = try allocator.dupe(u8, password), 200 + .password = try allocator.dupe(u8, password_hash), 198 201 }; 199 202 } 200 203 ··· 1023 1026 fn seedAccounts() !void { 1024 1027 for (auth.accounts) |account| { 1025 1028 const signing_key = try generateSigningKey(); 1029 + var salt: [16]u8 = undefined; 1030 + store_io.random(&salt); 1031 + const password_hash = try auth.hashPassword(std.heap.page_allocator, account.password, salt); 1032 + defer std.heap.page_allocator.free(password_hash); 1026 1033 try conn.exec( 1027 1034 \\INSERT INTO accounts (did, handle, email, password_hash, signing_key_type, signing_key) 1028 1035 \\VALUES (?, ?, ?, ?, 'secp256k1', ?) ··· 1034 1041 \\ deactivated_at = NULL, 1035 1042 \\ signing_key_type = COALESCE(accounts.signing_key_type, excluded.signing_key_type), 1036 1043 \\ signing_key = COALESCE(accounts.signing_key, excluded.signing_key) 1037 - , .{ account.did, account.handle, account.email, account.password, zqlite.blob(&signing_key) }); 1044 + , .{ account.did, account.handle, account.email, password_hash, zqlite.blob(&signing_key) }); 1038 1045 } 1039 1046 } 1040 1047