initial commit: Proximity iOS app proposal, full Swift code scaffold, reference backend, testing docs, generated Xcode project
This commit is contained in:
147
Proximity/Services/Network/APIClient.swift
Normal file
147
Proximity/Services/Network/APIClient.swift
Normal file
@@ -0,0 +1,147 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user