import Foundation /// Creates accounts on an Org Social host (e.g. host.org-social.org). /// /// The host exposes a free sign-up endpoint that returns a fresh vfile token URL /// plus the public `social.org` URL for the chosen nickname. public struct HostSignupClient: Sendable { public enum SignupError: Error, LocalizedError { case invalidURL case httpError(statusCode: Int, serverMessage: String?) case nicknameRequired case nicknameTaken(String) case malformedResponse case network(underlying: String) public var errorDescription: String? { switch self { case .invalidURL: return "Invalid host URL." case .httpError(let code, let msg): return msg ?? "HTTP \(code) from the host." case .nicknameRequired: return "Nickname is required." case .nicknameTaken(let nick): return "Nickname '\(nick)' is already taken." case .malformedResponse: return "The host returned a response we could not parse." case .network(let msg): return "Network error: \(msg)" } } } public struct SignupResult: Sendable { public let vfileURL: URL public let publicURL: URL } private let session: URLSession public init(session: URLSession = .shared) { self.session = session } /// Registers `nick` on `host` and returns the resulting vfile token URL and public feed URL. /// /// - Parameters: /// - nick: The nickname to claim. Must be non-empty; the server enforces uniqueness. /// - host: Base URL of the host, e.g. `https://host.org-social.org`. public func signup(nick: String, host: URL = URL(string: "https://host.org-social.org")!) async throws -> SignupResult { let trimmed = nick.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw SignupError.nicknameRequired } guard let url = URL(string: "\(host.absoluteString.trimmingCharacters(in: .init(charactersIn: "/")))/signup") else { throw SignupError.invalidURL } var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 15 request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try? JSONSerialization.data(withJSONObject: ["nick": trimmed]) let data: Data let response: URLResponse do { (data, response) = try await session.data(for: request) } catch { throw SignupError.network(underlying: error.localizedDescription) } let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] // Error shape: {"type": "Error", "errors": ["..."]} if let json, let type = json["type"] as? String, type == "Error" { let messages = (json["errors"] as? [String]) ?? [] let first = messages.first ?? "Unknown error." if first.localizedCaseInsensitiveContains("already taken") { throw SignupError.nicknameTaken(trimmed) } if first.localizedCaseInsensitiveContains("required") { throw SignupError.nicknameRequired } throw SignupError.httpError(statusCode: statusCode, serverMessage: first) } if !(200..<300).contains(statusCode) { throw SignupError.httpError(statusCode: statusCode, serverMessage: nil) } // Success shape: {"type": "Success", "data": {"vfile": "...", "public-url": "..."}} guard let data = json?["data"] as? [String: Any], let vfileStr = data["vfile"] as? String, let publicStr = data["public-url"] as? String, let vfileURL = URL(string: vfileStr), let publicURL = URL(string: publicStr) else { throw SignupError.malformedResponse } return SignupResult(vfileURL: vfileURL, publicURL: publicURL) } }