From a52dee116d4066d1b59bd90b4ebc4def4e1597d6 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 24 Jul 2026 23:14:23 -0500 Subject: feat: stabilize and document the Local API v1 response contract (v5 step 3) Second v5.0.0 roadmap item: make the Local API's public JSON contract explicit, documented, and regression-locked, so external consumers (Shortcuts, scripts, integrations) have a stable surface with a defined compatibility promise. - LocalAPIContract: new single source of truth for the wire-format version ("v1") and the canonical JSON encoder (ISO-8601 dates, sorted keys). Both the success and error paths in LocalAPIService now route through it, so the format can't drift between them, and the ad-hoc per-call-site encoders are gone. - The response envelope and every payload struct are promoted from `private` to internal so the contract is a first-class, testable part of the module. The transport/handler internals (request parser, HTTP response, secret store) stay private. - Docs/local-api.md documents the base URL/auth, the envelope, the encoding conventions (notably: absent optionals are omitted, not null), every endpoint and its payload fields, the error codes, and the semantic-version-style compatibility policy (additive changes keep v1; renames/removals/type changes bump the version). Linked from the README. - LocalAPIContractTests: 16 structure/"golden" tests pinning the envelope shape, each payload's field names, the enum encodings, and the ISO-8601 date format. They assert structure, not values, so ordinary behavior changes don't churn them but a renamed or dropped field fails CI. Full unit suite: 52 passing. --- Docs/local-api.md | 113 ++++++++++++++++++ DomainDig.xcodeproj/project.pbxproj | 8 ++ DomainDigTests/LocalAPIContractTests.swift | 179 +++++++++++++++++++++++++++++ LocalAPIContract.swift | 39 +++++++ LocalAPIService.swift | 47 +++----- README.md | 2 +- 6 files changed, 359 insertions(+), 29 deletions(-) create mode 100644 Docs/local-api.md create mode 100644 DomainDigTests/LocalAPIContractTests.swift create mode 100644 LocalAPIContract.swift diff --git a/Docs/local-api.md b/Docs/local-api.md new file mode 100644 index 0000000..a0cebdc --- /dev/null +++ b/Docs/local-api.md @@ -0,0 +1,113 @@ +# DomainDig Local API — `v1` + +The Local API exposes DomainDig's canonical report data to on-device automation +(Shortcuts, scripts, integrations). It is **off by default** and, when enabled, +binds only to loopback. + +- **Base URL:** `http://127.0.0.1:` (default port `47821`, configurable in + Settings → Local API) +- **Binding:** loopback only (`acceptLocalOnly`); never reachable off-device +- **Content type:** every response is `application/json` +- **Version:** `v1` (reported in every response envelope) + +This document is the stable contract. The response shape is pinned by +`DomainDigTests/LocalAPIContractTests.swift`; `LocalAPIContract` (in +`LocalAPIContract.swift`) is the single source of truth for the version string +and the JSON encoder. + +## Authentication + +Every request requires the token shown in Settings → Local API, supplied either +way: + +``` +Authorization: Bearer +``` +``` +X-API-Token: +``` + +A missing or wrong token returns `401 unauthorized`. Settings → Local API has a +**Copy cURL Command** button that emits a ready-to-run authenticated request. + +## Response envelope + +Every response — success or error — is wrapped in the same envelope: + +```json +{ + "success": true, + "version": "v1", + "data": { "...": "payload, present on success" } +} +``` +```json +{ + "success": false, + "version": "v1", + "error": { "code": "not_found", "message": "The requested Local API route does not exist." } +} +``` + +- On success, `data` holds the endpoint payload and `error` is **omitted**. +- On failure, `error` holds a machine `code` plus a human `message`, and `data` + is **omitted**. + +### Encoding conventions + +- **Dates** are ISO-8601 UTC strings, e.g. `"2023-11-14T22:13:20Z"`. +- **Absent optional fields are omitted, not `null`.** Consumers must treat a + missing key as "not present." +- Object keys are emitted in sorted order (deterministic output; not + contractually meaningful — do not depend on key order). + +## Endpoints + +| Method | Path | Payload (`data`) fields | +|--------|------|-------------------------| +| GET | `/portfolio` | `summary` → `{ totalDomains, healthyCount, warningCount, criticalCount, changedLast24h, expiringSoonCount, unreachableCount }` | +| GET | `/domains` | `domains: [TrackedDomain]` | +| GET | `/domains/{domain}` | `domain`, `trackedDomain?` (`TrackedDomain`), `latestReport?` (`DomainReport`) | +| GET | `/domains/{domain}/history` | `domain`, `history: [HistoryEntry]` | +| GET | `/events` | `events: [{ timestamp, domain, summary, status, severity }]` | +| GET | `/monitoring` | `isEnabled`, `scope` (`"allTracked"` \| `"selectedOnly"`), `alertsEnabled`, `monitoredDomains: [{ domain, monitoringEnabled, lastMonitoredAt?, lastAlertAt?, certificateWarningLevel }]` | +| POST | `/inspect` | body `{ "domain": "example.com" }` → `report` (`DomainReport`) | +| POST | `/inspect/{domain}` | `report` (`DomainReport`) | +| POST | `/monitoring/{domain}/enable` | `domain`, `monitoringEnabled` | +| POST | `/monitoring/{domain}/disable` | `domain`, `monitoringEnabled` | + +`certificateWarningLevel` encodes as `"none"`, `"warning"`, or `"critical"`. + +`DomainReport` is the app's canonical report model (the same shape the JSON +export produces); see `DomainReportBuilder.swift` for its fields. It is a large +object and is treated as an additive contract: new fields may appear without a +version bump. + +## Error codes + +| HTTP | `code` | When | +|------|--------|------| +| 400 | `bad_request` | The HTTP request line/path could not be parsed | +| 400 | `invalid_body` | `POST /inspect` body was not `{ "domain": "…" }` | +| 400 | `invalid_domain` | A path/body domain was empty or invalid | +| 401 | `unauthorized` | Missing or incorrect token | +| 404 | `not_found` | No such route | +| 404 | `domain_not_found` | No local data / tracked domain for the given name | +| 500 | `encoding_failed` | The response could not be encoded | +| 500 | `internal_error` | The request handler failed unexpectedly | + +## Compatibility policy + +The `version` field follows a semantic-version-style promise: + +- **Backward-compatible changes keep `version` at `v1`.** Adding a new endpoint, + or adding a new field to an existing payload, is non-breaking. **Consumers + must ignore unknown fields.** +- **Breaking changes bump `version`.** Renaming or removing a field, changing a + field's type, or changing the meaning/units of an existing field requires a new + version, an update to this document, and an update to + `LocalAPIContractTests.swift`. + +There are currently no deprecated fields or endpoints. When a field is +deprecated, it will be listed here with the version in which it becomes eligible +for removal, and will remain present for at least one subsequent version. diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj index 37e5bea..e65c63a 100644 --- a/DomainDig.xcodeproj/project.pbxproj +++ b/DomainDig.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 05F775C7E0743AF727B008EE /* LocalAPIContract.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7572DA0A838E0A044F045260 /* LocalAPIContract.swift */; }; 38316D90539394C2CC7C12BE /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE8B269A01CC5E593DA3DFC2 /* Foundation.framework */; }; 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */; }; 81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E82320955416797CFE59464A /* DomainReportBuilderTests.swift */; }; @@ -22,6 +23,7 @@ A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA789D3607B55E3612E6FFA /* SnapshotFixture.swift */; }; C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */; }; E959C4D24DAAB3CB80D854B2 /* DiffServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15A25DF2B8BB52589D49986B /* DiffServiceTests.swift */; }; + EA12012CDB4C21ABB4217D86 /* LocalAPIContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -71,9 +73,11 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalAPIContractTests.swift; sourceTree = ""; }; 15A25DF2B8BB52589D49986B /* DiffServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DiffServiceTests.swift; sourceTree = ""; }; 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainReportExporterTests.swift; sourceTree = ""; }; 5CA789D3607B55E3612E6FFA /* SnapshotFixture.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnapshotFixture.swift; sourceTree = ""; }; + 7572DA0A838E0A044F045260 /* LocalAPIContract.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalAPIContract.swift; sourceTree = ""; }; 8B6472EF300EBFA30018E10A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = ""; }; 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; }; 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = ""; }; @@ -224,6 +228,7 @@ E82320955416797CFE59464A /* DomainReportBuilderTests.swift */, 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */, CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */, + 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */, ); name = DomainDigTests; path = DomainDigTests; @@ -248,6 +253,7 @@ 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */, 12026CD045DFB45E9E37D207 /* Frameworks */, 7CC29A88387286DE8B4D17B3 /* DomainDigTests */, + 7572DA0A838E0A044F045260 /* LocalAPIContract.swift */, ); sourceTree = ""; }; @@ -480,6 +486,7 @@ 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */, C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */, A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */, + EA12012CDB4C21ABB4217D86 /* LocalAPIContractTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -494,6 +501,7 @@ 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */, 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */, 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */, + 05F775C7E0743AF727B008EE /* LocalAPIContract.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/DomainDigTests/LocalAPIContractTests.swift b/DomainDigTests/LocalAPIContractTests.swift new file mode 100644 index 0000000..0e6515e --- /dev/null +++ b/DomainDigTests/LocalAPIContractTests.swift @@ -0,0 +1,179 @@ +import XCTest +@testable import DomainDig + +/// Locks the Local API wire contract: the envelope shape, each payload's field +/// names, and the encoding conventions (`v1`, ISO-8601 dates, nil omission). +/// A rename or removed field fails here instead of silently breaking an external +/// consumer. Values are deliberately not asserted — only structure — so ordinary +/// behavior changes don't churn these tests. +final class LocalAPIContractTests: XCTestCase { + private let encoder = LocalAPIContract.makeEncoder() + + // MARK: - Helpers + + private func json(_ value: T) throws -> [String: Any] { + let data = try encoder.encode(value) + let object = try JSONSerialization.jsonObject(with: data) + return try XCTUnwrap(object as? [String: Any]) + } + + private func keys(_ value: T) throws -> Set { + Set(try json(value).keys) + } + + // MARK: - Envelope + + func testVersionIsV1() { + XCTAssertEqual(LocalAPIContract.version, "v1") + } + + func testSuccessEnvelopeOmitsErrorAndReportsVersion() throws { + let envelope = LocalAPIEnvelope( + success: true, + data: PortfolioPayload(summary: Self.sampleSummary), + error: nil, + version: LocalAPIContract.version + ) + let object = try json(envelope) + + XCTAssertEqual(Set(object.keys), ["success", "data", "version"], "nil error must be omitted from the envelope") + XCTAssertEqual(object["success"] as? Bool, true) + XCTAssertEqual(object["version"] as? String, "v1") + } + + func testErrorEnvelopeOmitsDataAndCarriesCodeAndMessage() throws { + let envelope = LocalAPIEnvelope( + success: false, + data: nil, + error: LocalAPIErrorPayload(code: "not_found", message: "The requested Local API route does not exist."), + version: LocalAPIContract.version + ) + let object = try json(envelope) + + XCTAssertEqual(Set(object.keys), ["success", "error", "version"], "nil data must be omitted from the envelope") + XCTAssertEqual(object["success"] as? Bool, false) + let error = try XCTUnwrap(object["error"] as? [String: Any]) + XCTAssertEqual(Set(error.keys), ["code", "message"]) + XCTAssertEqual(error["code"] as? String, "not_found") + } + + // MARK: - Payload field names + + func testPortfolioSummaryFields() throws { + XCTAssertEqual( + try keys(Self.sampleSummary), + ["totalDomains", "healthyCount", "warningCount", "criticalCount", + "changedLast24h", "expiringSoonCount", "unreachableCount"] + ) + } + + func testPortfolioPayloadFields() throws { + XCTAssertEqual(try keys(PortfolioPayload(summary: Self.sampleSummary)), ["summary"]) + } + + func testDomainListPayloadFields() throws { + let payload = DomainListPayload(domains: [TrackedDomain(domain: "example.com")]) + XCTAssertEqual(try keys(payload), ["domains"]) + } + + func testDomainDetailPayloadFieldsWhenPopulated() throws { + let payload = DomainDetailPayload( + domain: "example.com", + trackedDomain: TrackedDomain(domain: "example.com"), + latestReport: SnapshotFixture.report(domain: "example.com") + ) + XCTAssertEqual(try keys(payload), ["domain", "trackedDomain", "latestReport"]) + } + + func testDomainDetailPayloadOmitsAbsentOptionals() throws { + let payload = DomainDetailPayload(domain: "example.com", trackedDomain: nil, latestReport: nil) + XCTAssertEqual(try keys(payload), ["domain"], "absent trackedDomain/latestReport are omitted, not null") + } + + func testDomainHistoryPayloadFields() throws { + let payload = DomainHistoryPayload(domain: "example.com", history: []) + XCTAssertEqual(try keys(payload), ["domain", "history"]) + } + + func testRecentEventPayloadFields() throws { + XCTAssertEqual(try keys(Self.sampleEvent), ["timestamp", "domain", "summary", "status", "severity"]) + } + + func testRecentEventsPayloadFields() throws { + XCTAssertEqual(try keys(RecentEventsPayload(events: [Self.sampleEvent])), ["events"]) + } + + func testMonitoringPayloadFieldsAndEnumEncoding() throws { + let payload = MonitoringPayload( + isEnabled: true, + scope: .allTracked, + alertsEnabled: true, + monitoredDomains: [Self.sampleMonitoringDomain] + ) + let object = try json(payload) + XCTAssertEqual(Set(object.keys), ["isEnabled", "scope", "alertsEnabled", "monitoredDomains"]) + XCTAssertEqual(object["scope"] as? String, "allTracked", "MonitoringScope encodes as its String raw value") + } + + func testMonitoringDomainPayloadFieldsAndEnumEncoding() throws { + let object = try json(Self.sampleMonitoringDomain) + XCTAssertEqual( + Set(object.keys), + ["domain", "monitoringEnabled", "lastMonitoredAt", "lastAlertAt", "certificateWarningLevel"] + ) + XCTAssertEqual(object["certificateWarningLevel"] as? String, "none") + } + + func testMonitoringMutationPayloadFields() throws { + XCTAssertEqual( + try keys(MonitoringMutationPayload(domain: "example.com", monitoringEnabled: true)), + ["domain", "monitoringEnabled"] + ) + } + + func testInspectResponsePayloadFields() throws { + XCTAssertEqual(try keys(InspectResponsePayload(report: SnapshotFixture.report())), ["report"]) + } + + // MARK: - Encoding conventions + + func testDatesEncodeAsISO8601() throws { + let event = RecentEventPayload( + timestamp: Date(timeIntervalSince1970: 1_700_000_000), + domain: "example.com", + summary: "changed", + status: "changed", + severity: "medium" + ) + let object = try json(event) + XCTAssertEqual(object["timestamp"] as? String, "2023-11-14T22:13:20Z") + } + + // MARK: - Fixtures + + private static let sampleSummary = PortfolioSummary( + totalDomains: 3, + healthyCount: 1, + warningCount: 1, + criticalCount: 1, + changedLast24h: 2, + expiringSoonCount: 1, + unreachableCount: 0 + ) + + private static let sampleEvent = RecentEventPayload( + timestamp: Date(timeIntervalSince1970: 1_700_000_000), + domain: "example.com", + summary: "Certificate is approaching expiry", + status: "changed", + severity: "medium" + ) + + private static let sampleMonitoringDomain = MonitoringDomainPayload( + domain: "example.com", + monitoringEnabled: true, + lastMonitoredAt: Date(timeIntervalSince1970: 1_700_000_000), + lastAlertAt: Date(timeIntervalSince1970: 1_700_000_000), + certificateWarningLevel: .none + ) +} diff --git a/LocalAPIContract.swift b/LocalAPIContract.swift new file mode 100644 index 0000000..f5f2463 --- /dev/null +++ b/LocalAPIContract.swift @@ -0,0 +1,39 @@ +import Foundation + +/// The versioned wire contract for the Local API. +/// +/// External consumers — Shortcuts, scripts, and third-party integrations — depend +/// on the JSON this API produces: the response envelope, the field names of every +/// payload, and the encoding conventions. This type is the single source of truth +/// for the parts that must stay stable, and `LocalAPIContractTests` pins them so +/// an accidental rename or shape change fails CI instead of silently breaking a +/// consumer. +/// +/// ## Compatibility policy +/// +/// The `version` string reported in every envelope follows a semantic-version-style +/// promise: +/// +/// - **Backward-compatible** changes keep `version` at `"v1"`: adding a new +/// endpoint, or adding a new field to a payload. Consumers must ignore unknown +/// fields, so additions never require a bump. +/// - **Breaking** changes require bumping `version` (and updating `Docs/local-api.md` +/// plus the contract tests): renaming or removing a field, changing a field's +/// type, or changing the meaning/units of an existing field. +/// +/// See `Docs/local-api.md` for the full endpoint and schema reference. +enum LocalAPIContract { + /// Wire-format version reported in every envelope's `version` field. + static let version = "v1" + + /// The canonical encoder for every Local API response. ISO-8601 dates and + /// sorted keys keep the output deterministic, which is what lets the contract + /// tests pin the shape. Both the success and error paths route through this so + /// the wire format can never drift between them. + static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + return encoder + } +} diff --git a/LocalAPIService.swift b/LocalAPIService.swift index 0a5f8a2..0ce7033 100644 --- a/LocalAPIService.swift +++ b/LocalAPIService.swift @@ -3,8 +3,6 @@ import Network import Observation import Security -private let localAPIVersion = "v1" - private enum LocalAPIServerError: LocalizedError { case missingSecret case secretPersistenceFailed @@ -508,12 +506,7 @@ private final class LocalAPIServer: @unchecked Sendable { } private struct LocalAPIRequestHandler { - private let encoder: JSONEncoder = { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = [.sortedKeys] - return encoder - }() + private let encoder = LocalAPIContract.makeEncoder() private let decoder: JSONDecoder = { let decoder = JSONDecoder() @@ -606,7 +599,7 @@ private struct LocalAPIRequestHandler { } private func successResponse(_ value: Value) -> LocalAPIHTTPResponse { - let envelope = LocalAPIEnvelope(success: true, data: value, error: nil, version: localAPIVersion) + let envelope = LocalAPIEnvelope(success: true, data: value, error: nil, version: LocalAPIContract.version) guard let body = try? encoder.encode(envelope) else { return .error(statusCode: 500, code: "encoding_failed", message: "Could not encode the Local API response.") } @@ -836,12 +829,10 @@ private struct LocalAPIHTTPResponse { success: false, data: nil, error: LocalAPIErrorPayload(code: code, message: message), - version: localAPIVersion + version: LocalAPIContract.version ) - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let body = (try? encoder.encode(payload)) ?? Data() + let body = (try? LocalAPIContract.makeEncoder().encode(payload)) ?? Data() return LocalAPIHTTPResponse(statusCode: statusCode, body: body) } @@ -951,25 +942,25 @@ private enum LocalAPIHTTPParser { } } -private struct LocalAPIEnvelope: Encodable { +struct LocalAPIEnvelope: Encodable { let success: Bool let data: DataPayload? let error: LocalAPIErrorPayload? let version: String } -private struct LocalAPIErrorPayload: Encodable { +struct LocalAPIErrorPayload: Encodable { let code: String let message: String } -private struct EmptyPayload: Encodable {} +struct EmptyPayload: Encodable {} -private struct PortfolioPayload: Encodable { +struct PortfolioPayload: Encodable { let summary: PortfolioSummary } -private struct PortfolioSummary: Encodable { +struct PortfolioSummary: Encodable { let totalDomains: Int let healthyCount: Int let warningCount: Int @@ -979,26 +970,26 @@ private struct PortfolioSummary: Encodable { let unreachableCount: Int } -private struct DomainListPayload: Encodable { +struct DomainListPayload: Encodable { let domains: [TrackedDomain] } -private struct DomainDetailPayload: Encodable { +struct DomainDetailPayload: Encodable { let domain: String let trackedDomain: TrackedDomain? let latestReport: DomainReport? } -private struct DomainHistoryPayload: Encodable { +struct DomainHistoryPayload: Encodable { let domain: String let history: [HistoryEntry] } -private struct RecentEventsPayload: Encodable { +struct RecentEventsPayload: Encodable { let events: [RecentEventPayload] } -private struct RecentEventPayload: Encodable { +struct RecentEventPayload: Encodable { let timestamp: Date let domain: String let summary: String @@ -1006,14 +997,14 @@ private struct RecentEventPayload: Encodable { let severity: String } -private struct MonitoringPayload: Encodable { +struct MonitoringPayload: Encodable { let isEnabled: Bool let scope: MonitoringScope let alertsEnabled: Bool let monitoredDomains: [MonitoringDomainPayload] } -private struct MonitoringDomainPayload: Encodable { +struct MonitoringDomainPayload: Encodable { let domain: String let monitoringEnabled: Bool let lastMonitoredAt: Date? @@ -1021,15 +1012,15 @@ private struct MonitoringDomainPayload: Encodable { let certificateWarningLevel: CertificateWarningLevel } -private struct MonitoringMutationPayload: Encodable { +struct MonitoringMutationPayload: Encodable { let domain: String let monitoringEnabled: Bool } -private struct InspectRequestPayload: Decodable { +struct InspectRequestPayload: Decodable { let domain: String } -private struct InspectResponsePayload: Encodable { +struct InspectResponsePayload: Encodable { let report: DomainReport } diff --git a/README.md b/README.md index 6d89ceb..3e61467 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Network inspection requests are made only to perform the requested domain checks xcodebuild -project DomainDig.xcodeproj -scheme DomainDig -destination 'platform=iOS Simulator,name=iPhone 16' build ``` -The app and local API share the canonical report pipeline through `DomainInspectionService`, `DomainReportBuilder`, and `DomainReportExporter`. +The app and local API share the canonical report pipeline through `DomainInspectionService`, `DomainReportBuilder`, and `DomainReportExporter`. The Local API's endpoints, response envelope, and `v1` compatibility policy are documented in [Docs/local-api.md](Docs/local-api.md). ### Accessibility Audit -- cgit v1.2.3