import Foundation import Network import Observation import Security private let localAPIVersion = "v1" private enum LocalAPIServerError: LocalizedError { case missingSecret case secretPersistenceFailed case serverStartFailed(String) var errorDescription: String? { switch self { case .missingSecret: return "Local API token is unavailable." case .secretPersistenceFailed: return "Could not store the Local API token in Keychain." case .serverStartFailed(let message): return message } } } private final class ListenerResumeState: @unchecked Sendable { private let lock = NSLock() nonisolated(unsafe) private var hasResumed = false nonisolated func beginResume() -> Bool { lock.lock() defer { lock.unlock() } guard !hasResumed else { return false } hasResumed = true return true } } @MainActor @Observable final class LocalAPIService { static let shared = LocalAPIService() private static let configDefaultsKey = "localAPI.config" private static let logsDefaultsKey = "localAPI.requestLogs" private static let maxRequestLogs = 120 private(set) var config: LocalAPIConfig private(set) var requestLogs: [APIRequestLog] private(set) var isRunning = false private(set) var boundPort: Int? private(set) var statusMessage: String? private var server: LocalAPIServer? private init() { config = Self.loadConfig() requestLogs = Self.loadRequestLogs() } var address: String { "http://127.0.0.1:\(boundPort ?? config.port)" } var maskedToken: String { guard !config.token.isEmpty else { return "Unavailable" } let suffix = String(config.token.suffix(8)) return "••••••••\(suffix)" } func refresh() { config = Self.loadConfig() requestLogs = Self.loadRequestLogs() Task { if config.isEnabled { await startServerIfNeeded() } else { stopServer() } } } func setEnabled(_ isEnabled: Bool) { config.isEnabled = isEnabled persistConfig() Task { if isEnabled { await startServer(forceRestart: true) } else { stopServer() } } } func setPort(_ port: Int) { let sanitizedPort = Self.sanitizedPort(port) guard config.port != sanitizedPort else { return } config.port = sanitizedPort persistConfig() Task { guard config.isEnabled else { return } await startServer(forceRestart: true) } } func setRequestLoggingEnabled(_ isEnabled: Bool) { config.requestLoggingEnabled = isEnabled persistConfig() } func rotateToken() { config.token = Self.generateToken() persistConfig() Task { guard config.isEnabled else { return } await startServer(forceRestart: true) } } func stopServer() { server?.stop() server = nil isRunning = false boundPort = nil if config.isEnabled { statusMessage = "Stopped" } else { statusMessage = "Disabled" } } func clearRequestLogs() { requestLogs.removeAll() Self.saveRequestLogs([]) } func copyToken() { guard !config.token.isEmpty else { return } AppClipboard.copy(config.token) } func copyCurlCommand() { guard !config.token.isEmpty else { return } AppClipboard.copy("curl -H \"Authorization: Bearer \(config.token)\" \(address)/portfolio") } func localSecretReferences() -> [String] { [LocalAPISecretStore.reference] } func resetAfterLocalWipe() { stopServer() config = LocalAPIConfig(token: Self.generateToken()) requestLogs = [] persistConfig() clearRequestLogs() } private func startServerIfNeeded() async { guard config.isEnabled else { stopServer() return } guard server == nil else { return } await startServer(forceRestart: false) } private func startServer(forceRestart: Bool) async { if forceRestart { stopServer() } guard config.isEnabled else { stopServer() return } let token = config.token.isEmpty ? Self.generateToken() : config.token if token != config.token { config.token = token persistConfig() } let server = LocalAPIServer( port: Self.sanitizedPort(config.port), token: token, requestLogger: { [weak self] log in guard let self else { return } Task { @MainActor in self.record(log) } }, stateLogger: { [weak self] stateMessage in guard let self else { return } Task { @MainActor in self.statusMessage = stateMessage } } ) do { let activePort = try await server.start() self.server = server boundPort = activePort isRunning = true statusMessage = "Listening on localhost:\(activePort)" } catch { self.server = nil boundPort = nil isRunning = false statusMessage = error.localizedDescription } } private func record(_ log: APIRequestLog) { guard config.requestLoggingEnabled else { return } requestLogs.insert(log, at: 0) requestLogs = Array(requestLogs.prefix(Self.maxRequestLogs)) Self.saveRequestLogs(requestLogs) } private func persistConfig() { config.port = Self.sanitizedPort(config.port) if config.token.isEmpty { config.token = Self.generateToken() } Self.saveConfig(config) } private static func loadConfig(defaults: UserDefaults = .standard) -> LocalAPIConfig { let persisted: PersistedLocalAPIConfig if let data = defaults.data(forKey: configDefaultsKey), let decoded = try? JSONDecoder().decode(PersistedLocalAPIConfig.self, from: data) { persisted = decoded } else { persisted = PersistedLocalAPIConfig() } let token = (try? LocalAPISecretStore.secret(reference: LocalAPISecretStore.reference)) ?? generateToken() if (try? LocalAPISecretStore.secret(reference: LocalAPISecretStore.reference)) == nil { try? LocalAPISecretStore.save(secret: token, reference: LocalAPISecretStore.reference) } return LocalAPIConfig( isEnabled: persisted.isEnabled, port: sanitizedPort(persisted.port), token: token, requestLoggingEnabled: persisted.requestLoggingEnabled ) } private static func saveConfig(_ config: LocalAPIConfig, defaults: UserDefaults = .standard) { let persisted = PersistedLocalAPIConfig( isEnabled: config.isEnabled, port: sanitizedPort(config.port), requestLoggingEnabled: config.requestLoggingEnabled ) if let data = try? JSONEncoder().encode(persisted) { defaults.set(data, forKey: configDefaultsKey) } try? LocalAPISecretStore.save(secret: config.token, reference: LocalAPISecretStore.reference) } private static func loadRequestLogs(defaults: UserDefaults = .standard) -> [APIRequestLog] { guard let data = defaults.data(forKey: logsDefaultsKey), let decoded = try? JSONDecoder().decode([APIRequestLog].self, from: data) else { return [] } return decoded } private static func saveRequestLogs(_ logs: [APIRequestLog], defaults: UserDefaults = .standard) { if let data = try? JSONEncoder().encode(Array(logs.prefix(maxRequestLogs))) { defaults.set(data, forKey: logsDefaultsKey) } } private static func sanitizedPort(_ port: Int) -> Int { min(max(port, 1024), 65535) } private static func generateToken() -> String { let bytes = (0..<24).map { _ in UInt8.random(in: .min ... .max) } let data = Data(bytes) return data .base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } private struct PersistedLocalAPIConfig: Codable { var isEnabled: Bool = false var port: Int = 47821 var requestLoggingEnabled: Bool = true } private enum LocalAPISecretStore { static let reference = "DomainDig.LocalAPI.Token" static func save(secret: String, reference: String) throws { let data = Data(secret.utf8) try? delete(reference: reference) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: reference, kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock ] let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecSuccess else { throw LocalAPIServerError.secretPersistenceFailed } } static func secret(reference: String) throws -> String { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: reference, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess, let data = result as? Data, let secret = String(data: data, encoding: .utf8) else { throw LocalAPIServerError.missingSecret } return secret } static func delete(reference: String) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: reference ] SecItemDelete(query as CFDictionary) } } } private final class LocalAPIServer: @unchecked Sendable { private let port: Int private let token: String private let requestLogger: @Sendable (APIRequestLog) -> Void private let stateLogger: @Sendable (String) -> Void private let handler = LocalAPIRequestHandler() private let queue = DispatchQueue(label: "DomainDig.LocalAPIServer") private var listener: NWListener? init( port: Int, token: String, requestLogger: @escaping @Sendable (APIRequestLog) -> Void, stateLogger: @escaping @Sendable (String) -> Void ) { self.port = port self.token = token self.requestLogger = requestLogger self.stateLogger = stateLogger } func start() async throws -> Int { let parameters = NWParameters.tcp parameters.acceptLocalOnly = true parameters.allowLocalEndpointReuse = true let listener: NWListener do { listener = try NWListener( using: parameters, on: NWEndpoint.Port(rawValue: UInt16(port)) ?? .any ) } catch { stateLogger("Failed: \(error.localizedDescription)") throw LocalAPIServerError.serverStartFailed( "Could not start Local API on port \(port): \(error.localizedDescription)" ) } listener.newConnectionHandler = { [weak self] connection in Task { await self?.handle(connection: connection) } } return try await withCheckedThrowingContinuation { continuation in let resumeState = ListenerResumeState() listener.stateUpdateHandler = { state in switch state { case .ready: guard resumeState.beginResume() else { return } continuation.resume(returning: Int(listener.port?.rawValue ?? UInt16(self.port))) case .failed(let error): self.stateLogger("Failed: \(error.localizedDescription)") guard resumeState.beginResume() else { return } continuation.resume(throwing: LocalAPIServerError.serverStartFailed(error.localizedDescription)) case .cancelled: self.stateLogger("Stopped") default: break } } self.listener = listener listener.start(queue: self.queue) } } func stop() { listener?.cancel() listener = nil } private func handle(connection: NWConnection) async { connection.start(queue: queue) let startedAt = Date() do { let requestData = try await readCompleteRequest(from: connection) let request = try LocalAPIHTTPParser.parseRequest(from: requestData) let response = await handler.handle(request: request, expectedToken: token) let log = APIRequestLog( timestamp: startedAt, method: request.method, path: request.pathWithQuery, statusCode: response.statusCode, duration: Date().timeIntervalSince(startedAt) ) requestLogger(log) try await send(response.serialized(), on: connection) } catch let error as LocalAPIHTTPParser.ParserError { let response = LocalAPIHTTPResponse.error(statusCode: 400, code: "bad_request", message: error.localizedDescription) let log = APIRequestLog( timestamp: startedAt, method: "INVALID", path: "/", statusCode: response.statusCode, duration: Date().timeIntervalSince(startedAt) ) requestLogger(log) try? await send(response.serialized(), on: connection) } catch { let response = LocalAPIHTTPResponse.error(statusCode: 500, code: "internal_error", message: "The Local API request failed.") let log = APIRequestLog( timestamp: startedAt, method: "ERROR", path: "/", statusCode: response.statusCode, duration: Date().timeIntervalSince(startedAt) ) requestLogger(log) try? await send(response.serialized(), on: connection) } connection.cancel() } private func readCompleteRequest(from connection: NWConnection, accumulated: Data = Data()) async throws -> Data { let chunk = try await receiveChunk(from: connection) let combined = accumulated + chunk.data if LocalAPIHTTPParser.isCompleteRequest(combined) || chunk.isComplete { return combined } return try await readCompleteRequest(from: connection, accumulated: combined) } private func receiveChunk(from connection: NWConnection) async throws -> (data: Data, isComplete: Bool) { try await withCheckedThrowingContinuation { continuation in connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in if let error { continuation.resume(throwing: error) return } continuation.resume(returning: (data ?? Data(), isComplete)) } } } private func send(_ data: Data, on connection: NWConnection) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in connection.send(content: data, completion: .contentProcessed { error in if let error { continuation.resume(throwing: error) } else { continuation.resume(returning: ()) } }) } } } private struct LocalAPIRequestHandler { private let encoder: JSONEncoder = { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = [.sortedKeys] return encoder }() private let decoder: JSONDecoder = { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return decoder }() private let inspectionService = DomainInspectionService() private let reportBuilder = DomainReportBuilder() func handle(request: LocalAPIHTTPRequest, expectedToken: String) async -> LocalAPIHTTPResponse { guard isAuthorized(request: request, expectedToken: expectedToken) else { return .error(statusCode: 401, code: "unauthorized", message: "A valid local API token is required.") } if request.method == "GET", request.pathComponents == ["portfolio"] { return successResponse(PortfolioPayload(summary: buildPortfolioSummary())) } if request.method == "GET", request.pathComponents == ["domains"] { return successResponse(DomainListPayload(domains: DomainDataPortabilityService.loadTrackedDomains())) } if request.method == "GET", request.pathComponents.count == 2, request.pathComponents.first == "domains" { let domain = request.pathComponents[1] return domainDetailsResponse(for: domain) } if request.method == "GET", request.pathComponents.count == 3, request.pathComponents.first == "domains", request.pathComponents.last == "history" { let domain = request.pathComponents[1] return successResponse(DomainHistoryPayload(domain: normalizedDomain(domain), history: historyEntries(for: domain))) } if request.method == "GET", request.pathComponents == ["events"] { return successResponse(RecentEventsPayload(events: recentEvents())) } if request.method == "GET", request.pathComponents == ["monitoring"] { return successResponse(monitoringPayload()) } if request.method == "POST", request.pathComponents == ["inspect"] { return await inspectBodyResponse(request.body) } if request.method == "POST", request.pathComponents.count == 2, request.pathComponents.first == "inspect" { let domain = request.pathComponents[1] return await inspectResponse(for: domain) } if request.method == "POST", request.pathComponents.count == 3, request.pathComponents.first == "monitoring", request.pathComponents.last == "enable" { let domain = request.pathComponents[1] return await setMonitoring(enabled: true, for: domain) } if request.method == "POST", request.pathComponents.count == 3, request.pathComponents.first == "monitoring", request.pathComponents.last == "disable" { let domain = request.pathComponents[1] return await setMonitoring(enabled: false, for: domain) } return .error(statusCode: 404, code: "not_found", message: "The requested Local API route does not exist.") } private func isAuthorized(request: LocalAPIHTTPRequest, expectedToken: String) -> Bool { guard !expectedToken.isEmpty else { return false } if request.headers["x-api-token"] == expectedToken { return true } if let authorization = request.headers["authorization"], authorization == "Bearer \(expectedToken)" { return true } return false } private func successResponse(_ value: Value) -> LocalAPIHTTPResponse { let envelope = LocalAPIEnvelope(success: true, data: value, error: nil, version: localAPIVersion) guard let body = try? encoder.encode(envelope) else { return .error(statusCode: 500, code: "encoding_failed", message: "Could not encode the Local API response.") } return LocalAPIHTTPResponse(statusCode: 200, body: body) } private func domainDetailsResponse(for domain: String) -> LocalAPIHTTPResponse { let normalized = normalizedDomain(domain) let trackedDomain = DomainDataPortabilityService.loadTrackedDomains().first { $0.domain.caseInsensitiveCompare(normalized) == .orderedSame } let history = historyEntries(for: normalized) let latestEntry = history.first guard trackedDomain != nil || latestEntry != nil else { return .error(statusCode: 404, code: "domain_not_found", message: "No local data exists for \(normalized).") } let latestReport = latestEntry.map { buildReport(for: $0, from: history) } return successResponse( DomainDetailPayload( domain: normalized, trackedDomain: trackedDomain, latestReport: latestReport ) ) } private func inspectBodyResponse(_ body: Data) async -> LocalAPIHTTPResponse { guard let payload = try? decoder.decode(InspectRequestPayload.self, from: body) else { return .error(statusCode: 400, code: "invalid_body", message: "Expected JSON body: {\"domain\":\"example.com\"}.") } return await inspectResponse(for: payload.domain) } private func inspectResponse(for domain: String) async -> LocalAPIHTTPResponse { let normalized = normalizedDomain(domain) guard !normalized.isEmpty else { return .error(statusCode: 400, code: "invalid_domain", message: "A valid domain is required.") } let previousSnapshot = latestHistoryEntry(for: normalized)?.snapshot let report = await inspectionService.inspect(domain: normalized, previousSnapshot: previousSnapshot) return successResponse(InspectResponsePayload(report: report)) } private func setMonitoring(enabled: Bool, for domain: String) async -> LocalAPIHTTPResponse { let normalized = normalizedDomain(domain) guard !normalized.isEmpty else { return .error(statusCode: 400, code: "invalid_domain", message: "A valid domain is required.") } var trackedDomains = DomainDataPortabilityService.loadTrackedDomains() guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(normalized) == .orderedSame }) else { return .error(statusCode: 404, code: "domain_not_found", message: "Tracked domain \(normalized) was not found.") } trackedDomains[index].monitoringEnabled = enabled trackedDomains[index].updatedAt = Date() DomainDataPortabilityService.saveTrackedDomains(trackedDomains) let sanitizedSettings = MonitoringStorage.sanitizeSettings( MonitoringStorage.loadSettings(), trackedDomains: trackedDomains ) MonitoringStorage.saveSettings(sanitizedSettings) await MainActor.run { _ = DomainMonitoringScheduler.shared.syncSchedule() } return successResponse( MonitoringMutationPayload( domain: trackedDomains[index].domain, monitoringEnabled: trackedDomains[index].monitoringEnabled ) ) } private func buildPortfolioSummary() -> PortfolioSummary { let trackedDomains = DomainDataPortabilityService.loadTrackedDomains() let history = DomainDataPortabilityService.loadHistoryEntries() var healthyCount = 0 var warningCount = 0 var criticalCount = 0 var changedLast24h = 0 var unreachableCount = 0 for trackedDomain in trackedDomains { guard let latestEntry = latestHistoryEntry(for: trackedDomain.domain, history: history) else { continue } let report = buildReport(for: latestEntry, from: history) switch report.health { case .healthy: healthyCount += 1 case .warning: warningCount += 1 case .critical: criticalCount += 1 } if latestEntry.changeSummary?.hasChanges == true, Date().timeIntervalSince(latestEntry.timestamp) <= 24 * 60 * 60 { changedLast24h += 1 } if report.network.reachabilityError != nil || report.lastMonitoringFailure != nil { unreachableCount += 1 } } return PortfolioSummary( totalDomains: trackedDomains.count, healthyCount: healthyCount, warningCount: warningCount, criticalCount: criticalCount, changedLast24h: changedLast24h, expiringSoonCount: trackedDomains.filter { $0.certificateWarningLevel != .none }.count, unreachableCount: unreachableCount ) } private func monitoringPayload() -> MonitoringPayload { let trackedDomains = DomainDataPortabilityService.loadTrackedDomains() let settings = MonitoringStorage.sanitizeSettings(MonitoringStorage.loadSettings(), trackedDomains: trackedDomains) return MonitoringPayload( isEnabled: settings.isEnabled, scope: settings.scope, alertsEnabled: settings.alertsEnabled, monitoredDomains: trackedDomains.map { MonitoringDomainPayload( domain: $0.domain, monitoringEnabled: $0.monitoringEnabled, lastMonitoredAt: $0.lastMonitoredAt, lastAlertAt: $0.lastAlertAt, certificateWarningLevel: $0.certificateWarningLevel ) } ) } private func recentEvents() -> [RecentEventPayload] { MonitoringStorage.loadLogs() .prefix(25) .flatMap { log in log.checkedDomains.map { result in RecentEventPayload( timestamp: result.checkedAt, domain: result.domain, summary: result.summaryMessage, status: result.errorMessage == nil ? "ok" : "error", severity: result.alertSeverity?.title ?? "Info" ) } } } private func historyEntries(for domain: String) -> [HistoryEntry] { let normalized = normalizedDomain(domain) return DomainDataPortabilityService.loadHistoryEntries().filter { $0.domain.caseInsensitiveCompare(normalized) == .orderedSame } } private func latestHistoryEntry(for domain: String, history: [HistoryEntry]? = nil) -> HistoryEntry? { let entries = history ?? DomainDataPortabilityService.loadHistoryEntries() let normalized = normalizedDomain(domain) return entries.first { $0.domain.caseInsensitiveCompare(normalized) == .orderedSame } } private func buildReport(for entry: HistoryEntry, from history: [HistoryEntry]) -> DomainReport { let previousSnapshot = history .dropFirst(history.firstIndex(where: { $0.id == entry.id }).map { $0 + 1 } ?? history.count) .first { $0.domain.caseInsensitiveCompare(entry.domain) == .orderedSame }? .snapshot return reportBuilder.build(from: entry, previousSnapshot: previousSnapshot) } private func normalizedDomain(_ value: String) -> String { value .trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: "https://", with: "") .replacingOccurrences(of: "http://", with: "") .components(separatedBy: "/") .first? .lowercased() ?? "" } } private struct LocalAPIHTTPRequest { let method: String let path: String let queryItems: [URLQueryItem] let headers: [String: String] let body: Data var pathComponents: [String] { path.split(separator: "/").map(String.init) } var pathWithQuery: String { guard !queryItems.isEmpty else { return path } let query = queryItems.compactMap { item in guard let value = item.value else { return item.name } return "\(item.name)=\(value)" } .joined(separator: "&") return "\(path)?\(query)" } } private struct LocalAPIHTTPResponse { let statusCode: Int let body: Data init(statusCode: Int, body: Data) { self.statusCode = statusCode self.body = body } static func error(statusCode: Int, code: String, message: String) -> LocalAPIHTTPResponse { let payload = LocalAPIEnvelope( success: false, data: nil, error: LocalAPIErrorPayload(code: code, message: message), version: localAPIVersion ) let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] let body = (try? encoder.encode(payload)) ?? Data() return LocalAPIHTTPResponse(statusCode: statusCode, body: body) } func serialized() -> Data { var response = Data() response.append("HTTP/1.1 \(statusCode) \(Self.reasonPhrase(for: statusCode))\r\n".data(using: .utf8) ?? Data()) response.append("Content-Type: application/json\r\n".data(using: .utf8) ?? Data()) response.append("Content-Length: \(body.count)\r\n".data(using: .utf8) ?? Data()) response.append("Connection: close\r\n\r\n".data(using: .utf8) ?? Data()) response.append(body) return response } private static func reasonPhrase(for statusCode: Int) -> String { switch statusCode { case 200: return "OK" case 400: return "Bad Request" case 401: return "Unauthorized" case 404: return "Not Found" default: return "Internal Server Error" } } } private enum LocalAPIHTTPParser { enum ParserError: LocalizedError { case invalidRequestLine case invalidPath var errorDescription: String? { switch self { case .invalidRequestLine: return "The HTTP request line is invalid." case .invalidPath: return "The HTTP request path is invalid." } } } static func isCompleteRequest(_ data: Data) -> Bool { guard let separatorRange = data.range(of: Data("\r\n\r\n".utf8)) else { return false } let headerData = data[..= contentLength } static func parseRequest(from data: Data) throws -> LocalAPIHTTPRequest { guard let separatorRange = data.range(of: Data("\r\n\r\n".utf8)) else { throw ParserError.invalidRequestLine } let headerData = data[..= 2 else { throw ParserError.invalidRequestLine } let method = String(requestParts[0]).uppercased() let rawPath = String(requestParts[1]) guard let components = URLComponents(string: rawPath) else { throw ParserError.invalidPath } var headers: [String: String] = [:] for line in lines.dropFirst() { let parts = line.split(separator: ":", maxSplits: 1) guard parts.count == 2 else { continue } headers[String(parts[0]).lowercased()] = String(parts[1]).trimmingCharacters(in: .whitespaces) } return LocalAPIHTTPRequest( method: method, path: components.path, queryItems: components.queryItems ?? [], headers: headers, body: Data(bodyData) ) } } private struct LocalAPIEnvelope: Encodable { let success: Bool let data: DataPayload? let error: LocalAPIErrorPayload? let version: String } private struct LocalAPIErrorPayload: Encodable { let code: String let message: String } private struct EmptyPayload: Encodable {} private struct PortfolioPayload: Encodable { let summary: PortfolioSummary } private struct PortfolioSummary: Encodable { let totalDomains: Int let healthyCount: Int let warningCount: Int let criticalCount: Int let changedLast24h: Int let expiringSoonCount: Int let unreachableCount: Int } private struct DomainListPayload: Encodable { let domains: [TrackedDomain] } private struct DomainDetailPayload: Encodable { let domain: String let trackedDomain: TrackedDomain? let latestReport: DomainReport? } private struct DomainHistoryPayload: Encodable { let domain: String let history: [HistoryEntry] } private struct RecentEventsPayload: Encodable { let events: [RecentEventPayload] } private struct RecentEventPayload: Encodable { let timestamp: Date let domain: String let summary: String let status: String let severity: String } private struct MonitoringPayload: Encodable { let isEnabled: Bool let scope: MonitoringScope let alertsEnabled: Bool let monitoredDomains: [MonitoringDomainPayload] } private struct MonitoringDomainPayload: Encodable { let domain: String let monitoringEnabled: Bool let lastMonitoredAt: Date? let lastAlertAt: Date? let certificateWarningLevel: CertificateWarningLevel } private struct MonitoringMutationPayload: Encodable { let domain: String let monitoringEnabled: Bool } private struct InspectRequestPayload: Decodable { let domain: String } private struct InspectResponsePayload: Encodable { let report: DomainReport }