diff --git a/README.md b/README.md index 93341f3..cb2f7c2 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ ZDS_PORT=2583 \ ZDS_DB=/var/lib/zds/zds.sqlite3 \ ZDS_BLOB_UPLOAD_LIMIT=100000000 \ ZDS_BLOBSTORE_PATH=/var/lib/zds/blobs \ -ZDS_HANDLE_DOMAINS='.example.com,example.com' \ +ZDS_HANDLE_DOMAINS='.pds.example.com' \ ZDS_CRAWLERS='https://bsky.network,https://vsky.network' \ ZDS_MAX_CONCURRENT_REPO_EXPORTS=4 \ ZDS_PLC_ROTATION_KEY='64-hex-secp256k1-secret-or-private-multikey' \ diff --git a/docs/operations.md b/docs/operations.md index 8c38581..cb543cf 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -86,8 +86,11 @@ Common deployment settings: unset, ZDS uses `ZDS_JWT_SECRET`. A separate value is recommended for production. Multi-node deployments must configure every node with the same effective DPoP secret so each node accepts the same nonce window. -- `ZDS_HANDLE_DOMAINS`: comma-separated domains advertised by - `describeServer`. +- `ZDS_HANDLE_DOMAINS`: comma-separated leading-dot hosted-handle suffixes + advertised by `describeServer`, for example `.pds.example.com`. Each suffix + needs wildcard DNS and TLS (`*.pds.example.com`) routed to ZDS so hosted + handles can answer `/.well-known/atproto-did`. Do not include the bare PDS + hostname. - `ZDS_MAIL_PROVIDER`: email delivery provider. Default: `comail`. Supported: `comail`, `resend`. - `ZDS_EMAIL_FROM`: sender address for account and PLC email tokens. The diff --git a/fly.toml b/fly.toml index 14b3493..6c558e0 100644 --- a/fly.toml +++ b/fly.toml @@ -10,7 +10,7 @@ primary_region = "ord" ZDS_DB = "/data/zds.sqlite3" ZDS_PUBLIC_URL = "https://pds.zat.dev" ZDS_SERVER_DID = "did:web:pds.zat.dev" - ZDS_HANDLE_DOMAINS = ".pds.zat.dev,pds.zat.dev" + ZDS_HANDLE_DOMAINS = ".pds.zat.dev" ZDS_CRAWLERS = "https://bsky.network,https://vsky.network" ZDS_BLOB_UPLOAD_LIMIT = "100000000" ZDS_BLOBSTORE_PATH = "/data/blobs" diff --git a/src/atproto/identity.zig b/src/atproto/identity.zig index 6e8fa59..9b8ea4f 100644 --- a/src/atproto/identity.zig +++ b/src/atproto/identity.zig @@ -1,9 +1,11 @@ const std = @import("std"); const auth = @import("../auth/tokens.zig"); const config = @import("../core/config.zig"); +const log = @import("../core/log.zig"); const mail = @import("../core/mail.zig"); const http_api = @import("../http/api.zig"); const email_tokens = @import("../internal/email_tokens.zig"); +const handles = @import("../internal/handles.zig"); const scopes = @import("../internal/scopes.zig"); const plc = @import("plc.zig"); const store = @import("../storage/store.zig"); @@ -182,6 +184,91 @@ pub fn resolveHandle(request: *http_api.Request) !void { return http_api.json(request, .ok, body); } +pub fn updateHandle(request: *http_api.Request) !void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + const auth_ctx = requireAccount(request, allocator) catch return; + try requireIdentityScope(request, auth_ctx.oauth_scope, .handle); + const account = auth_ctx.account; + + const body = try http_api.readBodyAlloc(request, allocator, 16 * 1024); + const parsed = try http_api.parseJsonBody(request, allocator, body); + const raw_handle = zat.json.getString(parsed.value, "handle") orelse { + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing handle"); + }; + const handle = handles.normalize(allocator, raw_handle) catch { + return http_api.xrpcError(request, .bad_request, "InvalidHandle", "Invalid handle"); + }; + + if (handles.isHosted(handle, config.handleDomains())) { + handles.validateHosted(handle, config.handleDomains()) catch |err| switch (err) { + error.InvalidHostedHandle => return http_api.xrpcError(request, .bad_request, "InvalidHandle", "Invalid hosted handle"), + else => return http_api.xrpcError(request, .bad_request, "UnsupportedDomain", "Unsupported handle domain"), + }; + } + + if (std.ascii.eqlIgnoreCase(handle, account.handle)) { + store.sequenceIdentityEvent(allocator, account.did, handle) catch |err| { + log.err("failed to resequence identity event did={s} handle={s} error={s}\n", .{ account.did, handle, @errorName(err) }); + }; + return http_api.json(request, .ok, "{}"); + } + + if (!handles.isHosted(handle, config.handleDomains())) { + const parsed_handle = zat.Handle.parse(handle) orelse unreachable; + var resolver = zat.HandleResolver.init(store.currentIo(), allocator); + defer resolver.deinit(); + const resolved_did = resolver.resolve(parsed_handle) catch { + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "External handle did not resolve to account DID"); + }; + if (!std.mem.eql(u8, resolved_did, account.did)) { + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "External handle did not resolve to account DID"); + } + } + + if (try store.findAccount(allocator, handle)) |existing| { + if (!std.mem.eql(u8, existing.did, account.did)) { + return http_api.xrpcError(request, .bad_request, "HandleNotAvailable", "Handle is already in use"); + } + } + + if (std.mem.startsWith(u8, account.did, "did:plc:")) { + const last_op = plc.fetchLastOperation(allocator, account.did) catch { + return http_api.xrpcError(request, .bad_gateway, "PlcUpdateFailed", "Failed to fetch current PLC operation"); + }; + if (jsonObjectStringEquals(last_op, "type", "plc_tombstone")) { + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "DID is tombstoned"); + } + + var rotation_keypair = plc.configuredRotationKeypair() catch { + return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is not configured"); + }; + const rotation_did_key = try rotation_keypair.did(allocator); + if (!jsonArrayContainsString(zat.json.getPath(last_op, "rotationKeys"), rotation_did_key)) { + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PDS rotation key is not authorized by current PLC operation"); + } + + const prev = try plc.cidForJsonValue(allocator, last_op); + const unsigned = try plc.updateHandleOperationJson(allocator, last_op, handle, prev); + const operation = try plc.signOperationJson(allocator, unsigned, &rotation_keypair); + plc.submitOperation(allocator, account.did, operation) catch { + return http_api.xrpcError(request, .bad_gateway, "PlcUpdateFailed", "Failed to update PLC handle"); + }; + } + + store.updateAccountHandle(account.did, handle) catch |err| switch (err) { + error.HandleNotAvailable => return http_api.xrpcError(request, .bad_request, "HandleNotAvailable", "Handle is already in use"), + error.AccountNotFound => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Account not found"), + else => return err, + }; + store.sequenceIdentityEvent(allocator, account.did, handle) catch |err| { + log.err("failed to sequence identity event did={s} handle={s} error={s}\n", .{ account.did, handle, @errorName(err) }); + }; + return http_api.json(request, .ok, "{}"); +} + fn requireAccount(request: *http_api.Request, allocator: std.mem.Allocator) !http_api.BearerAccount { return http_api.requireBearerAccess(request, allocator) catch |err| { switch (err) { diff --git a/src/atproto/plc.zig b/src/atproto/plc.zig index 792bd0f..3febbf8 100644 --- a/src/atproto/plc.zig +++ b/src/atproto/plc.zig @@ -145,6 +145,34 @@ pub fn updateOperationJson( ); } +pub fn updateHandleOperationJson( + allocator: std.mem.Allocator, + last_op: std.json.Value, + handle: []const u8, + prev: []const u8, +) ![]const u8 { + const last = switch (last_op) { + .object => |object| object, + else => return error.InvalidPlcOperation, + }; + const rotation_keys = last.get("rotationKeys") orelse return error.InvalidPlcOperation; + const verification_methods = last.get("verificationMethods") orelse return error.InvalidPlcOperation; + const services = last.get("services") orelse return error.InvalidPlcOperation; + const also_known_as = try std.fmt.allocPrint(allocator, "at://{s}", .{handle}); + + return std.fmt.allocPrint( + allocator, + "{{\"type\":\"plc_operation\",\"rotationKeys\":{s},\"verificationMethods\":{s},\"alsoKnownAs\":[{f}],\"services\":{s},\"prev\":{f}}}", + .{ + try stringifyJson(allocator, rotation_keys), + try stringifyJson(allocator, verification_methods), + std.json.fmt(also_known_as, .{}), + try stringifyJson(allocator, services), + std.json.fmt(prev, .{}), + }, + ); +} + pub fn signOperationJson(allocator: std.mem.Allocator, unsigned_json: []const u8, signing_key: *const zat.Keypair) ![]const u8 { const parsed = try std.json.parseFromSlice(std.json.Value, allocator, unsigned_json, .{}); const cbor_value = try jsonToCbor(allocator, parsed.value); @@ -161,6 +189,13 @@ pub fn cidForJsonValue(allocator: std.mem.Allocator, value: std.json.Value) ![]c return zat.multibase.base32lower.encode(allocator, cid.raw); } +fn stringifyJson(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { + var out: std.Io.Writer.Allocating = .init(allocator); + defer out.deinit(); + try out.writer.print("{f}", .{std.json.fmt(value, .{})}); + return out.toOwnedSlice(); +} + fn unsignedOperationCbor( allocator: std.mem.Allocator, handle: []const u8, @@ -348,3 +383,35 @@ test "PLC private key parser accepts official hex and PDS Moover private multike try std.testing.expectEqualStrings(hex_did, multikey_did); } + +test "handle update operation preserves current PLC authority and services" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + const current = + \\{"type":"plc_operation","rotationKeys":["did:key:recovery","did:key:pds"],"verificationMethods":{"atproto":"did:key:account","extra":"did:key:extra"},"alsoKnownAs":["at://old.example.com"],"services":{"atproto_pds":{"type":"AtprotoPersonalDataServer","endpoint":"https://old-pds.example.com"},"other":{"type":"Other","endpoint":"https://service.example.com"}},"prev":"bafyprev","sig":"old-signature"} + ; + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, current, .{}); + const updated = try updateHandleOperationJson(allocator, parsed.value, "new.example.com", "bafycurrent"); + const operation = try std.json.parseFromSlice(std.json.Value, allocator, updated, .{}); + + try std.testing.expectEqualStrings( + "at://new.example.com", + operation.value.object.get("alsoKnownAs").?.array.items[0].string, + ); + try std.testing.expectEqualStrings( + "did:key:recovery", + operation.value.object.get("rotationKeys").?.array.items[0].string, + ); + try std.testing.expectEqualStrings( + "did:key:extra", + operation.value.object.get("verificationMethods").?.object.get("extra").?.string, + ); + try std.testing.expectEqualStrings( + "https://service.example.com", + operation.value.object.get("services").?.object.get("other").?.object.get("endpoint").?.string, + ); + try std.testing.expectEqualStrings("bafycurrent", operation.value.object.get("prev").?.string); + try std.testing.expect(operation.value.object.get("sig") == null); +} diff --git a/src/atproto/server.zig b/src/atproto/server.zig index eedf2bf..f388b23 100644 --- a/src/atproto/server.zig +++ b/src/atproto/server.zig @@ -7,6 +7,7 @@ const plc = @import("plc.zig"); const sync = @import("sync.zig"); const http_api = @import("../http/api.zig"); const email_tokens = @import("../internal/email_tokens.zig"); +const handles = @import("../internal/handles.zig"); const scopes = @import("../internal/scopes.zig"); const store = @import("../storage/store.zig"); const zat = @import("zat"); @@ -44,6 +45,9 @@ pub fn atprotoDid(request: *http_api.Request) !void { return plain(request, .not_found, "User not found"); }; const handle = stripPort(host); + if (!handles.isHosted(handle, config.handleDomains())) { + return plain(request, .not_found, "User not found"); + } const account = store.findAccount(allocator, handle) catch null orelse { return plain(request, .not_found, "User not found"); }; @@ -104,6 +108,22 @@ pub fn createAccount(request: *http_api.Request) !void { } const existing_did = zat.json.getString(parsed.value, "did"); + if (handles.isHosted(handle, config.handleDomains())) { + handles.validateHosted(handle, config.handleDomains()) catch { + return http_api.xrpcError(request, .bad_request, "InvalidHandle", "invalid hosted handle"); + }; + } else if (existing_did) |did| { + var resolver = zat.HandleResolver.init(store.currentIo(), allocator); + defer resolver.deinit(); + const resolved_did = resolver.resolve(zat.Handle.parse(handle).?) catch { + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "External handle did not resolve to account DID"); + }; + if (!std.mem.eql(u8, resolved_did, did)) { + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "External handle did not resolve to account DID"); + } + } else { + return http_api.xrpcError(request, .bad_request, "UnsupportedDomain", "not a supported handle domain"); + } const plc_op = jsonObjectField(parsed.value, "plcOp"); const signing_key_input = zat.json.getString(parsed.value, "signingKey"); const account = if (existing_did) |did| account: { diff --git a/src/http/router.zig b/src/http/router.zig index 0ccda23..8634446 100644 --- a/src/http/router.zig +++ b/src/http/router.zig @@ -82,6 +82,7 @@ pub const Route = enum { identity_sign_plc_operation, identity_submit_plc_operation, identity_resolve_handle, + identity_update_handle, permissioned_data, proxy_xrpc, not_found, @@ -212,6 +213,7 @@ pub const endpoints = [_]Endpoint{ .{ .route = .identity_sign_plc_operation, .method = "POST", .path = "/xrpc/com.atproto.identity.signPlcOperation", .group = "identity", .auth = "bearer", .summary = "Sign a PLC operation." }, .{ .route = .identity_submit_plc_operation, .method = "POST", .path = "/xrpc/com.atproto.identity.submitPlcOperation", .group = "identity", .auth = "bearer", .summary = "Submit a PLC operation." }, .{ .route = .identity_resolve_handle, .method = "GET", .path = "/xrpc/com.atproto.identity.resolveHandle", .group = "identity", .auth = "public", .summary = "Resolve a handle to a DID.", .params = &.{"handle"} }, + .{ .route = .identity_update_handle, .method = "POST", .path = "/xrpc/com.atproto.identity.updateHandle", .group = "identity", .auth = "bearer", .summary = "Update the signed-in account's handle.", .body = &.{"handle"} }, .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getSpace", .group = "space", .auth = "experimental bearer", .summary = "Read space configuration from its authority host.", .params = &.{"space"}, .notes = permissioned_data_note }, .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.listSpaces", .group = "space", .auth = "experimental bearer", .summary = "List permissioned repos held by the authenticated user, grouped by space.", .params = &.{ "did", "type", "limit", "cursor" }, .notes = permissioned_data_note }, @@ -346,6 +348,7 @@ test "routes pds probes" { try std.testing.expectEqual(Route.permissioned_data, route(.GET, "/xrpc/com.atproto.space.listRecords?space=at%3A%2F%2Fdid%3Aplc%3Aabc%2Fspace%2Fkey")); try std.testing.expectEqual(Route.not_found, route(.GET, "/xrpc/app.bsky.ageassurance.getState?countryCode=US")); try std.testing.expectEqual(Route.identity_resolve_handle, route(.GET, "/xrpc/com.atproto.identity.resolveHandle?handle=alice.test")); + try std.testing.expectEqual(Route.identity_update_handle, route(.POST, "/xrpc/com.atproto.identity.updateHandle")); } test "does not route wrong methods" { diff --git a/src/http/server.zig b/src/http/server.zig index e5db030..a2aacdc 100644 --- a/src/http/server.zig +++ b/src/http/server.zig @@ -151,6 +151,7 @@ const App = struct { .identity_sign_plc_operation => try atproto_identity.signPlcOperation(request), .identity_submit_plc_operation => try atproto_identity.submitPlcOperation(request), .identity_resolve_handle => try atproto_identity.resolveHandle(request), + .identity_update_handle => try atproto_identity.updateHandle(request), .permissioned_data => try atproto_space.dispatch(request), .proxy_xrpc => unreachable, .not_found => try xrpcError(request, .not_found, "UnknownMethod", "Unknown XRPC method"), diff --git a/src/internal/cli.zig b/src/internal/cli.zig index 7167c45..ca51837 100644 --- a/src/internal/cli.zig +++ b/src/internal/cli.zig @@ -120,7 +120,7 @@ pub fn usage() void { \\ \\Runs a local PDS-shaped HTTP server. \\Environment defaults use ZDS_HOST, ZDS_PORT, and ZDS_DB. - \\DOMAINS is a comma-separated list such as ".example.com,example.com". + \\DOMAINS is a comma-separated list of leading-dot suffixes such as ".pds.example.com". \\URLS is a comma-separated crawler list, defaulting to bsky.network and vsky.network. \\ , .{}); diff --git a/src/internal/handles.zig b/src/internal/handles.zig new file mode 100644 index 0000000..3f01982 --- /dev/null +++ b/src/internal/handles.zig @@ -0,0 +1,71 @@ +const std = @import("std"); +const zat = @import("zat"); + +pub const HostedHandleError = error{ + InvalidHandle, + UnsupportedDomain, + InvalidHostedHandle, +}; + +pub fn normalize(allocator: std.mem.Allocator, raw: []const u8) ![]const u8 { + const trimmed = std.mem.trim(u8, raw, &std.ascii.whitespace); + const normalized = try allocator.alloc(u8, trimmed.len); + _ = std.ascii.lowerString(normalized, trimmed); + if (zat.Handle.parse(normalized) == null) return HostedHandleError.InvalidHandle; + return normalized; +} + +pub fn isHosted(handle: []const u8, raw_domains: []const u8) bool { + var domains = std.mem.splitScalar(u8, raw_domains, ','); + while (domains.next()) |raw_domain| { + const domain = std.mem.trim(u8, raw_domain, &std.ascii.whitespace); + if (domain.len == 0 or domain[0] != '.') continue; + if (handle.len > domain.len and std.mem.endsWith(u8, handle, domain)) return true; + } + return false; +} + +pub fn validateHosted(handle: []const u8, raw_domains: []const u8) HostedHandleError!void { + var domains = std.mem.splitScalar(u8, raw_domains, ','); + while (domains.next()) |raw_domain| { + const domain = std.mem.trim(u8, raw_domain, &std.ascii.whitespace); + if (domain.len == 0 or domain[0] != '.') continue; + if (handle.len <= domain.len or !std.mem.endsWith(u8, handle, domain)) continue; + + const name = handle[0 .. handle.len - domain.len]; + if (name.len < 3 or name.len > 18 or std.mem.indexOfScalar(u8, name, '.') != null) { + return HostedHandleError.InvalidHostedHandle; + } + return; + } + return HostedHandleError.UnsupportedDomain; +} + +pub fn validateDomains(raw_domains: []const u8) error{InvalidHandleDomain}!void { + var count: usize = 0; + var domains = std.mem.splitScalar(u8, raw_domains, ','); + while (domains.next()) |raw_domain| { + const domain = std.mem.trim(u8, raw_domain, &std.ascii.whitespace); + if (domain.len < 2 or domain[0] != '.') return error.InvalidHandleDomain; + + var example_buf: [zat.Handle.max_length]u8 = undefined; + const example = std.fmt.bufPrint(&example_buf, "abc{s}", .{domain}) catch return error.InvalidHandleDomain; + if (zat.Handle.parse(example) == null) return error.InvalidHandleDomain; + count += 1; + } + if (count == 0) return error.InvalidHandleDomain; +} + +test "hosted handles require one bounded account label" { + try validateHosted("alice.pds.example.com", ".pds.example.com"); + try std.testing.expectError(error.InvalidHostedHandle, validateHosted("a.pds.example.com", ".pds.example.com")); + try std.testing.expectError(error.InvalidHostedHandle, validateHosted("nested.alice.pds.example.com", ".pds.example.com")); + try std.testing.expectError(error.UnsupportedDomain, validateHosted("alice.example.com", ".pds.example.com")); +} + +test "handle domains are leading-dot suffixes" { + try validateDomains(".pds.example.com,.test.example"); + try std.testing.expectError(error.InvalidHandleDomain, validateDomains("pds.example.com")); + try std.testing.expectError(error.InvalidHandleDomain, validateDomains(".example.com,example.com")); + try std.testing.expectError(error.InvalidHandleDomain, validateDomains("")); +} diff --git a/src/main.zig b/src/main.zig index c5586ec..ae5d5d2 100644 --- a/src/main.zig +++ b/src/main.zig @@ -38,7 +38,10 @@ pub fn main(init: std.process.Init) !void { zds.core.config.setComailDid(options.comail_did); if (options.blob_upload_limit) |value| zds.core.config.setBlobUploadLimit(value); if (options.blobstore_path) |value| zds.core.config.setBlobstorePath(value); - if (options.handle_domains) |value| zds.core.config.setHandleDomains(value); + if (options.handle_domains) |value| { + try zds.internal.handles.validateDomains(value); + zds.core.config.setHandleDomains(value); + } if (options.crawlers) |value| zds.core.config.setCrawlers(value); if (options.max_concurrent_repo_exports) |value| zds.core.config.setMaxConcurrentRepoExports(value); if (options.proxy_service_did) |value| zds.core.config.setProxyServiceDid(value); diff --git a/src/root.zig b/src/root.zig index 958e9c2..6b67515 100644 --- a/src/root.zig +++ b/src/root.zig @@ -37,6 +37,7 @@ pub const internal = struct { pub const client_attestation = @import("internal/client_attestation.zig"); pub const email_tokens = @import("internal/email_tokens.zig"); pub const dpop = @import("internal/dpop.zig"); + pub const handles = @import("internal/handles.zig"); pub const passkeys = @import("internal/passkeys.zig"); pub const permissioned_data = @import("internal/permissioned_data.zig"); pub const scopes = @import("internal/scopes.zig"); @@ -80,6 +81,7 @@ test { internal.client_attestation, internal.email_tokens, internal.dpop, + internal.handles, internal.passkeys, internal.permissioned_data, internal.scopes, diff --git a/src/storage/store.zig b/src/storage/store.zig index 23037cf..e659b60 100644 --- a/src/storage/store.zig +++ b/src/storage/store.zig @@ -27,6 +27,8 @@ pub const Error = error{ InvalidDagCbor, InvalidRefreshSession, InvalidAccountStatus, + AccountNotFound, + HandleNotAvailable, StoreNotInitialized, }; @@ -983,6 +985,37 @@ pub fn signingKeypair(did: []const u8) !zat.Keypair { return signingKeypairLocked(did); } +pub fn updateAccountHandle(did: []const u8, handle: []const u8) !void { + db_mutex.lockUncancelable(store_io); + defer db_mutex.unlock(store_io); + try requireInitialized(); + + const existing = try conn.row( + \\SELECT did + \\FROM accounts + \\WHERE lower(handle) = lower(?) + \\LIMIT 1 + , .{handle}); + if (existing) |row| { + defer row.deinit(); + if (!std.mem.eql(u8, row.text(0), did)) return Error.HandleNotAvailable; + } + + const account = try conn.row( + \\SELECT 1 + \\FROM accounts + \\WHERE did = ? + , .{did}); + if (account == null) return Error.AccountNotFound; + account.?.deinit(); + + try conn.exec( + \\UPDATE accounts + \\SET handle = ? + \\WHERE did = ? + , .{ handle, did }); +} + pub fn setAccountActive(did: []const u8, active: bool) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); @@ -7054,6 +7087,39 @@ test "invite codes gate account creation and record uses" { try std.testing.expectEqualStrings("did:plc:invited", codes[0].uses[0].used_by); } +test "account handle updates are unique and immediately resolvable" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + try init(std.Options.debug_io, ":memory:"); + defer close(); + + const alice = try createAccount( + allocator, + "alice.test", + "alice@test.com", + "password", + "did:plc:alice", + true, + ); + _ = try createAccount( + allocator, + "bob.test", + "bob@test.com", + "password", + "did:plc:bob", + true, + ); + + try updateAccountHandle(alice.did, "alice-new.test"); + try std.testing.expect((try findAccount(allocator, "alice.test")) == null); + const updated = (try findAccount(allocator, "ALICE-NEW.TEST")) orelse return error.AccountNotFound; + try std.testing.expectEqualStrings(alice.did, updated.did); + try std.testing.expectError(error.HandleNotAvailable, updateAccountHandle(alice.did, "BOB.TEST")); + try std.testing.expectError(error.AccountNotFound, updateAccountHandle("did:plc:missing", "missing.test")); +} + test "reserved signing keys are one-use account signing keys" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); diff --git a/tools/smoke.sh b/tools/smoke.sh index 22d86b0..f916f54 100755 --- a/tools/smoke.sh +++ b/tools/smoke.sh @@ -54,6 +54,11 @@ sqlite3 "$db" "insert into oauth_requests (request_id, client_id, redirect_uri, resolved=$(curl -fsS "$base/xrpc/com.atproto.identity.resolveHandle?handle=smoke.test") printf '%s' "$resolved" | grep -q '"did":"did:plc:smoketest"' +well_known=$(curl -fsS -H 'host: smoke.test' "$base/.well-known/atproto-did") +test "$well_known" = "did:plc:smoketest" +unsupported_well_known_status=$(curl -sS -o /tmp/zds-unsupported-handle.txt -w '%{http_code}' \ + -H 'host: smoke.example.com' "$base/.well-known/atproto-did") +test "$unsupported_well_known_status" = "404" session=$(curl -fsS -X POST "$base/xrpc/com.atproto.server.createSession" \ -H 'content-type: application/json' \