import Foundation import CryptoKit /// Backend client for the Proximity relay. /// /// The server's role is deliberately narrow and privacy-preserving: /// - It **relays anonymous tokens** so two present devices can discover each /// other (especially for UWB, which needs a peer token), /// - It **correlates mutual encounters** (token A near token B) without ever /// learning identities, /// - It **delivers E2E-encrypted messages** (ciphertext only). /// /// The server never stores precise location history or identity by default. struct APIClient { private let baseURL: URL private let session = URLSession.shared /// Point at the production API. init() { self.baseURL = URL(string: "https://api.proximity.app")! } /// Point at a custom backend (e.g. the local reference server). init(baseURL: URL) { self.baseURL = baseURL } // MARK: - Auth /// Exchange a provider ID token for a Proximity session. func exchangeToken(_ result: AuthResult) async throws -> Session { var request = URLRequest(url: baseURL.appendingPathComponent("v1/auth/exchange")) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode([ "provider": result.providerID.rawValue, "idToken": result.idToken, "nonce": result.nonce ?? "" ]) let (data, _) = try await session.data(for: request) return try JSONDecoder().decode(Session.self, from: data) } /// Revoke a Proximity session. func revokeSession(_ session: Session) async { var request = URLRequest(url: baseURL.appendingPathComponent("v1/auth/revoke")) request.httpMethod = "POST" request.setValue("Bearer \(session.token)", forHTTPHeaderField: "Authorization") _ = try? await session.data(for: request) } // MARK: - Token relay /// Register our current anonymous token so nearby peers can find us. func registerToken(_ token: String, tier: DistanceTier) async throws { var request = URLRequest(url: baseURL.appendingPathComponent("v1/token")) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode([ "token": token, "tier": tier.rawValue ]) _ = try await session.data(for: request) } /// Fetch anonymous tokens of present users near our coarse location. func fetchNearbyTokens(geohash: String, tier: DistanceTier) async throws -> [String] { var components = URLComponents( url: baseURL.appendingPathComponent("v1/nearby"), resolvingAgainstBaseURL: false )! components.queryItems = [ URLQueryItem(name: "geohash", value: geohash), URLQueryItem(name: "tier", value: String(tier.rawValue)) ] let (data, _) = try await session.data(from: components.url!) return try JSONDecoder().decode([String].self, from: data) } // MARK: - Encounters /// Send a "wave" to a silhouette — signals interest in mutual reveal. func wave(to remoteToken: String) async { var request = URLRequest(url: baseURL.appendingPathComponent("v1/wave")) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try? JSONEncoder().encode(["token": remoteToken]) _ = try? await session.data(for: request) } /// Block a token — permanently removes it from the graph. func block(token: String) async { var request = URLRequest(url: baseURL.appendingPathComponent("v1/block")) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try? JSONEncoder().encode(["token": token]) _ = try? await session.data(for: request) } /// Report a local encounter so the server can correlate the mutual side. func reportEncounter(_ encounter: Encounter) async { // Fire-and-forget; failures are non-critical. var request = URLRequest(url: baseURL.appendingPathComponent("v1/encounter")) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try? JSONEncoder().encode(encounter) _ = try? await session.data(for: request) } // MARK: - Reveal / key exchange /// Upload our E2E public key so a mutual peer can fetch it. func uploadPublicKey(_ key: Curve25519.KeyAgreement.PublicKey) async throws { var request = URLRequest(url: baseURL.appendingPathComponent("v1/peer-key")) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode([ "publicKey": key.rawRepresentation.base64EncodedString() ]) _ = try await session.data(for: request) } /// Fetch the peer's public key for a mutual encounter, via the relay. func fetchPeerPublicKey(for remoteToken: String) async throws -> Curve25519.KeyAgreement.PublicKey { var components = URLComponents( url: baseURL.appendingPathComponent("v1/peer-key"), resolvingAgainstBaseURL: false )! components.queryItems = [URLQueryItem(name: "token", value: remoteToken)] let (data, _) = try await session.data(from: components.url!) struct KeyResponse: Decodable { let publicKey: Data } let response = try JSONDecoder().decode(KeyResponse.self, from: data) return try Curve25519.KeyAgreement.PublicKey(rawRepresentation: response.publicKey) } // MARK: - Messaging (E2E) /// Send an encrypted message. Only ciphertext leaves the device. func sendMessage(_ message: ChatMessage) async throws { var request = URLRequest(url: baseURL.appendingPathComponent("v1/message")) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode(message) _ = try await session.data(for: request) } }