import Foundation public enum UpstreamRoute: Hashable, Sendable { case health case device case sim case network case signalStrength case stats case statsCpu case connectivity case smsList(limit: Int, offset: Int, direction: String?) case smsSend(phoneNumber: String, content: String) case smsBatchDelete(ids: [Int]) case authLogin(password: String) case authLogout public var method: String { switch self { case .health, .device, .sim, .network, .signalStrength, .stats, .statsCpu, .connectivity, .smsList: return "GET" case .smsSend, .smsBatchDelete, .authLogin, .authLogout: return "POST" } } public var path: String { switch self { case .health: return "/api/health" case .device: return "/api/device" case .sim: return "/api/sim" case .network: return "/api/network" case .signalStrength: return "/api/network/signal-strength" case .stats: return "/api/stats" case .statsCpu: return "/api/stats/cpu" case .connectivity: return "/api/connectivity" case .smsList(let limit, let offset, let direction): var items = ["limit=\(limit)", "offset=\(offset)"] if let direction { items.append("direction=\(direction)") } return "/api/sms/list?\(items.joined(separator: "&"))" case .smsSend: return "/api/sms/send" case .smsBatchDelete: return "/api/sms/batch-delete" case .authLogin: return "/api/auth/login" case .authLogout: return "/api/auth/logout" } } public var body: Data? { switch self { case .smsSend(let phoneNumber, let content): return try? JSONSerialization.data( withJSONObject: ["phone_number": phoneNumber, "content": content] ) case .smsBatchDelete(let ids): return try? JSONSerialization.data(withJSONObject: ["ids": ids]) case .authLogin(let password): return try? JSONSerialization.data(withJSONObject: ["password": password]) default: return nil } } /// Whether the node's agent will demand a confirmation intent for this route. /// /// This is the app's copy of `policy.dangerous` in `contracts/agent-policy.json`, and the two /// are asserted equal by the relay repository's policy test. It is a copy rather than a shared /// file because the app cannot read the relay's repository at build time; the test is what /// keeps it from drifting. public var requiresConfirmation: Bool { switch self { case .smsSend, .smsBatchDelete: return true default: return false } } } public enum UpstreamError: Error, Equatable { case invalidRequest case unauthorized case forbidden case notSupported case timeout case transportFailed case invalidResponse // Relay-mode failures. These are deliberately separate cases rather than a reuse of // transportFailed: in relay mode the user has four genuinely different situations to tell // apart, and a single "请求失败" for all of them is what makes a two-hop architecture // impossible to operate. /// The relay could not be reached at all. case relayUnreachable /// The relay took too long to answer. case relayTimeout /// The relay rejected this phone's bearer token: the pairing is no longer valid there. case relayRejectedDevice /// The relay does not know this node, or this phone is not paired to it. case relayNodeUnknown /// The node's agent is not connected to the relay. case agentOffline /// The node is connected but already has the maximum number of requests in flight. case nodeBusy /// The node accepted the request but did not answer within the relay's deadline. It may or may /// not have executed it, which is why the message must not invite a blind retry. case agentTimeout /// The relay refused the envelope before delivering it. case relayBadEnvelope /// The relay is rate limiting this phone. case relayRateLimited /// The node's agent rejected the request: not on its allowlist, or the device is not paired. case relayPolicyDenied /// The node received the request and the confirmation intent did not match. case confirmationRejected /// The envelope was outside the node's freshness window, or its id had already been used. case envelopeExpired /// The node's own rate limit refused a dangerous write. case nodeRateLimited /// The node's agent could not authenticate to its local SimAdmin. case nodeAuthFailed /// The request body exceeded the node's limit. case payloadTooLarge /// The node signed nothing, or the answer was not for this request. case relayResponseUnverifiable /// The relay answered about a different request or node. case relayResponseMismatch /// The node refused the envelope's signature or shape. case envelopeRejected }