summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-26 00:22:01 -0500
committerChristian Cleberg <[email protected]>2026-04-26 00:22:01 -0500
commitcc69cbd7e589ec4b065ce74d8c5e7714a040cfc5 (patch)
tree653eda7655bba21df43f4039915d452110591094
parent535bb0ff0f64d57be1074e33ceb42f598f80205b (diff)
downloaddomain-dig-cc69cbd7e589ec4b065ce74d8c5e7714a040cfc5.tar.gz
domain-dig-cc69cbd7e589ec4b065ce74d8c5e7714a040cfc5.tar.bz2
domain-dig-cc69cbd7e589ec4b065ce74d8c5e7714a040cfc5.zip
DomainDig v4.2.0: Add a local-only HTTP API layer for DomainDig
Expose DomainDig as a programmable local domain intelligence engine over localhost with explicit user opt-in and token-based authentication. Highlights: - add a localhost-only API server with safe start/stop lifecycle - require a local token for every request and store it in Keychain - add read endpoints for portfolio, domains, history, events, and monitoring - add inspection endpoints that reuse the existing inspection engine - add monitoring enable/disable control endpoints - add structured JSON response envelopes with API versioning - add lightweight capped request logging with clear/reset support - add a Settings UI for Local API enablement, token management, status, and logs - start the server on launch only when enabled - include Local API secrets in local data reset cleanup This is the first programmability release for DomainDig and establishes the foundation for Shortcuts, scripts, and other local automation workflows.
-rw-r--r--DomainDig.xcodeproj/project.pbxproj20
-rw-r--r--DomainDig/ContentView.swift121
-rw-r--r--DomainDig/DataResetService.swift3
-rw-r--r--DomainDig/DomainDigApp.swift4
-rw-r--r--LocalAPIModels.swift45
-rw-r--r--LocalAPIService.swift1029
6 files changed, 1217 insertions, 5 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 2de9125..02634ff 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -16,6 +16,10 @@
8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; };
8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; };
8BBFEFCD2F987E8700E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; };
+ 8BCA3CBE2F9C8D57004B742C /* LocalAPIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */; };
+ 8BCA3CBF2F9C8D57004B742C /* LocalAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */; };
+ 8BCA3CC02F9C8D57004B742C /* LocalAPIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */; };
+ 8BCA3CC12F9C8D57004B742C /* LocalAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */; };
8BF9DA862F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; };
8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; };
/* End PBXBuildFile section */
@@ -27,6 +31,8 @@
8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportBuilder.swift; sourceTree = "<group>"; };
8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportExporter.swift; sourceTree = "<group>"; };
8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; };
+ 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIModels.swift; sourceTree = "<group>"; };
+ 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIService.swift; sourceTree = "<group>"; };
8BF124F92F70000100933221 /* domaindig */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = domaindig; sourceTree = BUILT_PRODUCTS_DIR; };
8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -99,6 +105,8 @@
8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */,
8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */,
8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */,
+ 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */,
+ 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */,
);
sourceTree = "<group>";
};
@@ -212,6 +220,8 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 8BCA3CC02F9C8D57004B742C /* LocalAPIModels.swift in Sources */,
+ 8BCA3CC12F9C8D57004B742C /* LocalAPIService.swift in Sources */,
8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */,
8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */,
8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */,
@@ -230,6 +240,8 @@
8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */,
8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */,
8BBFEFCD2F987E8700E8E144 /* LookupSnapshot.swift in Sources */,
+ 8BCA3CBE2F9C8D57004B742C /* LocalAPIModels.swift in Sources */,
+ 8BCA3CBF2F9C8D57004B742C /* LocalAPIService.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -366,7 +378,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 33;
+ CURRENT_PROJECT_VERSION = 34;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -383,7 +395,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 4.1.0;
+ MARKETING_VERSION = 4.2.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -403,7 +415,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 33;
+ CURRENT_PROJECT_VERSION = 34;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -420,7 +432,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 4.1.0;
+ MARKETING_VERSION = 4.2.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index c5c8e50..3716f88 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -2560,6 +2560,10 @@ struct SettingsView: View {
IntegrationsSettingsView()
}
+ NavigationLink("Local API") {
+ LocalAPISettingsView()
+ }
+
NavigationLink("iCloud Sync") {
CloudSyncSettingsView()
}
@@ -2749,6 +2753,123 @@ private struct CloudSyncSettingsView: View {
}
}
+private struct LocalAPISettingsView: View {
+ @Environment(\.appDensity) private var appDensity
+ @State private var localAPIService = LocalAPIService.shared
+ @State private var portText = ""
+
+ var body: some View {
+ Form {
+ Section("Local API") {
+ Toggle(
+ "Enable Local API",
+ isOn: Binding(
+ get: { localAPIService.config.isEnabled },
+ set: { localAPIService.setEnabled($0) }
+ )
+ )
+
+ TextField(
+ "Port",
+ text: Binding(
+ get: { portText },
+ set: { newValue in
+ portText = newValue
+ if let port = Int(newValue) {
+ localAPIService.setPort(port)
+ }
+ }
+ )
+ )
+ .keyboardType(.numberPad)
+
+ LabeledContent("Address", value: localAPIService.address)
+ LabeledContent("Status", value: localAPIService.isRunning ? "Running" : (localAPIService.config.isEnabled ? "Stopped" : "Disabled"))
+ LabeledContent("Token", value: localAPIService.maskedToken)
+
+ if let statusMessage = localAPIService.statusMessage {
+ Text(statusMessage)
+ .font(appDensity.font(.caption, design: .default))
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Section("Authentication") {
+ Button("Copy Token") {
+ localAPIService.copyToken()
+ }
+
+ Button("Rotate Token") {
+ localAPIService.rotateToken()
+ }
+
+ Text("Every request requires either `Authorization: Bearer <token>` or `X-API-Token`. DomainDig stores the token in Keychain and only binds the server to localhost.")
+ .font(appDensity.font(.caption, design: .default))
+ .foregroundStyle(.secondary)
+ }
+
+ Section("Request Logging") {
+ Toggle(
+ "Log Requests",
+ isOn: Binding(
+ get: { localAPIService.config.requestLoggingEnabled },
+ set: { localAPIService.setRequestLoggingEnabled($0) }
+ )
+ )
+
+ if localAPIService.requestLogs.isEmpty {
+ Text("No local API requests logged yet.")
+ .font(appDensity.font(.caption, design: .default))
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(localAPIService.requestLogs.prefix(25)) { log in
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text("\(log.method) \(log.path)")
+ .font(appDensity.font(.callout, design: .monospaced))
+ Spacer()
+ Text("\(log.statusCode)")
+ .font(appDensity.font(.caption, design: .default))
+ .foregroundStyle(log.statusCode >= 400 ? .red : .secondary)
+ }
+
+ Text(log.timestamp.formatted(date: .abbreviated, time: .standard))
+ .font(appDensity.font(.caption2, design: .default))
+ .foregroundStyle(.secondary)
+
+ Text("\(Int(log.duration * 1000)) ms")
+ .font(appDensity.font(.caption2, design: .default))
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ Button("Clear Logs", role: .destructive) {
+ localAPIService.clearRequestLogs()
+ }
+ }
+
+ Section("Control") {
+ Button("Restart Server") {
+ localAPIService.setEnabled(false)
+ localAPIService.setEnabled(true)
+ }
+ .disabled(!localAPIService.config.isEnabled)
+
+ Button("Stop Server") {
+ localAPIService.stopServer()
+ }
+ .disabled(!localAPIService.isRunning)
+ }
+ }
+ .navigationTitle("Local API")
+ .onAppear {
+ portText = String(localAPIService.config.port)
+ localAPIService.refresh()
+ }
+ }
+}
+
private struct MonitoringSettingsView: View {
@Environment(\.appDensity) private var appDensity
@Bindable var viewModel: DomainViewModel
diff --git a/DomainDig/DataResetService.swift b/DomainDig/DataResetService.swift
index e993eba..d4c5bfe 100644
--- a/DomainDig/DataResetService.swift
+++ b/DomainDig/DataResetService.swift
@@ -15,7 +15,7 @@ enum DataResetService {
static func wipeAllLocalData(viewModel: DomainViewModel) async throws {
let secretReferences = await MainActor.run {
- IntegrationService.shared.localSecretReferences()
+ IntegrationService.shared.localSecretReferences() + LocalAPIService.shared.localSecretReferences()
}
try await Task.detached(priority: .userInitiated) {
@@ -28,6 +28,7 @@ enum DataResetService {
await MainActor.run {
IntegrationService.shared.resetAfterLocalWipe()
+ LocalAPIService.shared.resetAfterLocalWipe()
CloudSyncService.shared.resetLocalStateAfterWipe()
PurchaseService.shared.resetCachedStateAfterLocalWipe()
_ = DomainMonitoringScheduler.shared.syncSchedule()
diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift
index 2071213..ff40caf 100644
--- a/DomainDig/DomainDigApp.swift
+++ b/DomainDig/DomainDigApp.swift
@@ -15,6 +15,7 @@ struct DomainDigApp: App {
@State private var viewModel = DomainViewModel()
@State private var purchaseService = PurchaseService.shared
@State private var cloudSyncService = CloudSyncService.shared
+ @State private var localAPIService = LocalAPIService.shared
init() {
LocalNotificationService.shared.configureForegroundPresentation()
@@ -28,11 +29,13 @@ struct DomainDigApp: App {
.task {
let _ = purchaseService.currentTier
let _ = cloudSyncService.status
+ let _ = localAPIService.isRunning
let _ = IntegrationService.shared.targets.count
await purchaseService.refreshEntitlements()
viewModel.refreshMonitoringState()
await viewModel.refreshMonitoringAuthorizationStatus()
await cloudSyncService.refreshAvailability()
+ localAPIService.refresh()
cloudSyncService.scheduleSyncIfNeeded(trigger: .launch)
viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
IntegrationService.shared.processQueueNow()
@@ -50,6 +53,7 @@ struct DomainDigApp: App {
await viewModel.refreshMonitoringAuthorizationStatus()
await cloudSyncService.refreshAvailability()
}
+ localAPIService.refresh()
cloudSyncService.scheduleSyncIfNeeded(trigger: .launch)
viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
IntegrationService.shared.processQueueNow()
diff --git a/LocalAPIModels.swift b/LocalAPIModels.swift
new file mode 100644
index 0000000..5a18f11
--- /dev/null
+++ b/LocalAPIModels.swift
@@ -0,0 +1,45 @@
+import Foundation
+
+struct LocalAPIConfig: Codable, Equatable, Sendable {
+ var isEnabled: Bool
+ var port: Int
+ var token: String
+ var requestLoggingEnabled: Bool
+
+ init(
+ isEnabled: Bool = false,
+ port: Int = 47821,
+ token: String = "",
+ requestLoggingEnabled: Bool = true
+ ) {
+ self.isEnabled = isEnabled
+ self.port = port
+ self.token = token
+ self.requestLoggingEnabled = requestLoggingEnabled
+ }
+}
+
+struct APIRequestLog: Codable, Identifiable, Equatable, Sendable {
+ let id: UUID
+ let timestamp: Date
+ let method: String
+ let path: String
+ let statusCode: Int
+ let duration: TimeInterval
+
+ init(
+ id: UUID = UUID(),
+ timestamp: Date,
+ method: String,
+ path: String,
+ statusCode: Int,
+ duration: TimeInterval
+ ) {
+ self.id = id
+ self.timestamp = timestamp
+ self.method = method
+ self.path = path
+ self.statusCode = statusCode
+ self.duration = duration
+ }
+}
diff --git a/LocalAPIService.swift b/LocalAPIService.swift
new file mode 100644
index 0000000..ad1e07d
--- /dev/null
+++ b/LocalAPIService.swift
@@ -0,0 +1,1029 @@
+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 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: { log in
+ Task { @MainActor [weak self] in
+ self?.record(log)
+ }
+ },
+ stateLogger: { stateMessage in
+ Task { @MainActor [weak self] 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
+ parameters.requiredLocalEndpoint = .hostPort(
+ host: .ipv4(IPv4Address.loopback),
+ port: NWEndpoint.Port(rawValue: UInt16(port)) ?? .any
+ )
+
+ let listener: NWListener
+ do {
+ listener = try NWListener(
+ using: parameters,
+ on: NWEndpoint.Port(rawValue: UInt16(port)) ?? .any
+ )
+ } catch {
+ throw LocalAPIServerError.serverStartFailed("Could not start Local API on port \(port).")
+ }
+
+ 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<Void, Error>) 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: Encodable>(_ 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<EmptyPayload>(
+ 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[..<separatorRange.lowerBound]
+ guard let headerString = String(data: headerData, encoding: .utf8) else {
+ return false
+ }
+
+ let contentLength = headerString
+ .split(separator: "\r\n")
+ .first { $0.lowercased().hasPrefix("content-length:") }
+ .flatMap { Int($0.split(separator: ":", maxSplits: 1).last?.trimmingCharacters(in: .whitespaces) ?? "") }
+ ?? 0
+
+ let bodyLength = data.count - separatorRange.upperBound
+ return bodyLength >= 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[..<separatorRange.lowerBound]
+ let bodyData = data[separatorRange.upperBound...]
+
+ guard let headerString = String(data: headerData, encoding: .utf8) else {
+ throw ParserError.invalidRequestLine
+ }
+
+ let lines = headerString.split(separator: "\r\n", omittingEmptySubsequences: false)
+ guard let requestLine = lines.first else {
+ throw ParserError.invalidRequestLine
+ }
+
+ let requestParts = requestLine.split(separator: " ")
+ guard requestParts.count >= 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<DataPayload: Encodable>: 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
+}