From 241c318ecae45dfd8155800b26a1085feb78b1f5 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 31 Aug 2026 09:37:19 -0400 Subject: [PATCH 01/25] Model zone payload fields confirmed live in issue #444. Decode ownerRecordName, zoneType, and deleted on zone responses so change feeds surface tombstones and shared-zone owners round-trip correctly. Co-authored-by: Cursor --- .claude/memory/MEMORY.md | 2 +- .../reference_cloudkit_zone_dictionary.md | 33 +++-- .../SubscriptionInfo+Schema.swift | 2 +- .../Models/Zones/ZoneChangeResult.swift | 7 +- Sources/MistKit/Models/Zones/ZoneID.swift | 14 ++- Sources/MistKit/Models/Zones/ZoneInfo.swift | 27 +++- Sources/MistKitOpenAPI/Types.swift | 53 ++++++-- ...erviceTests.CreateZone+ErrorHandling.swift | 2 +- ...erviceTests.DeleteZone+ErrorHandling.swift | 2 +- ...ceTests.FetchDatabaseChanges+Helpers.swift | 2 +- ...ts.FetchDatabaseChanges+SuccessCases.swift | 6 +- ...Tests.FetchRecordZoneChanges+Helpers.swift | 4 +- ...erviceTests.FetchZoneChanges+Helpers.swift | 4 +- ...dKitServiceTests.LookupZones+Helpers.swift | 6 +- ...rviceTests.ModifyZones+ErrorHandling.swift | 8 +- ...dKitServiceTests.ModifyZones+Helpers.swift | 2 +- .../CloudKitServiceTests.Query+ZoneID.swift | 6 +- ...CloudKitServiceTests.Sharing+Helpers.swift | 2 +- ...itServiceTests.Subscriptions+Helpers.swift | 2 +- ...iceTests.ZoneOwnerWireKey+WireFormat.swift | 115 ++++++++++++++++++ ...loudKitServiceTests.ZoneOwnerWireKey.swift | 32 +++++ .../SubscriptionConversionTests.swift | 4 +- .../Zones/ZoneMetadataTests+Responses.swift | 2 +- ...ZoneMetadataTests+ZoneInfoConversion.swift | 74 ++++++++++- openapi.yaml | 36 ++++-- 25 files changed, 381 insertions(+), 66 deletions(-) create mode 100644 Tests/MistKitTests/CloudKitService/Zones/CloudKitServiceTests.ZoneOwnerWireKey+WireFormat.swift create mode 100644 Tests/MistKitTests/CloudKitService/Zones/CloudKitServiceTests.ZoneOwnerWireKey.swift diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 889ab144..1712204e 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -12,7 +12,7 @@ Project-scoped agent memory for MistKit. This directory **replaces** any native ## Index - [CloudKit archived endpoints not in local docs](reference_cloudkit_archived_endpoints.md) — Verify CloudKit endpoints (e.g. assets/rereference) against Apple's archived reference, not just .claude/docs/webservices.md -- [CloudKit Zone Dictionary has exactly 3 keys](reference_cloudkit_zone_dictionary.md) — zoneID/syncToken/atomic only; isEager, modify-request `atomic`, and zone create options do NOT exist +- [CloudKit Zone Dictionary has exactly 3 keys](reference_cloudkit_zone_dictionary.md) — archived docs: zoneID/syncToken/atomic; live change feeds also carry `deleted` + `zoneID.zoneType`; wire owner key is `ownerRecordName` (issue #444) - [wasm CI failure signatures](reference_wasm_ci_signatures.md) — Two distinct wasm failures: silent exit-1 (OOM on big test target) vs curl exit-7 (SDK download flake, just re-run) - [Swift Testing availability guard](feedback_swift_testing_availability.md) — Never annotate @Suite types with @available; use guard #available inside @Test functions instead - [GitHub Action pinning preference](feedback_action_pinning.md) — Use @v for brightdigit-owned actions; pin third-party actions explicitly diff --git a/.claude/memory/reference_cloudkit_zone_dictionary.md b/.claude/memory/reference_cloudkit_zone_dictionary.md index aeed8b09..d8b10c3a 100644 --- a/.claude/memory/reference_cloudkit_zone_dictionary.md +++ b/.claude/memory/reference_cloudkit_zone_dictionary.md @@ -1,14 +1,14 @@ --- name: reference_cloudkit_zone_dictionary -description: "CloudKit's Zone Dictionary has exactly three keys (zoneID, syncToken, atomic) — isEager and zone create options do not exist" +description: "CloudKit Zone Dictionary keys — archived docs plus live-confirmed extensions (issue #444)" metadata: node_type: memory type: reference --- -Verified against Apple's archived CloudKit Web Services Reference during issue #386 / PR #427. +Verified against Apple's archived CloudKit Web Services Reference during issue #386 / PR #427, with live-response confirmation for additional keys in issue #444. -**Zone Dictionary documents exactly three keys** ([Types.html](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/Types.html)): +**Zone Dictionary — archived docs** ([Types.html](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/Types.html)): | Key | Apple's wording | |-----|-----------------| @@ -16,7 +16,24 @@ Verified against Apple's archived CloudKit Web Services Reference during issue # | `syncToken` | "The current point in the zone's change history." | | `atomic` | "A Boolean value indicating whether this zone supports atomic operations." | -All four zone endpoints (`zones/list`, `zones/lookup`, `zones/modify`, `zones/changes`) route their **success** payload through this dictionary, and their **failure** payload through the "Zone Fetch Error Dictionary" (`zoneID`, `reason`, `serverErrorCode`, `retryAfter`, `redirectURL`). +**Live-confirmed additions (issue #444)** — present on change feeds (`zones/changes`, `changes/database`) but absent from the archived Zone Dictionary page: + +| Key | Location | Notes | +|-----|----------|-------| +| `deleted` | Zone object (not inside `zoneID`) | `true` = tombstone; sync clients must observe this | +| `zoneType` | Inside `zoneID` | Observed values: `REGULAR_CUSTOM_ZONE`, `DEFAULT_ZONE` | + +**Zone ID Dictionary** — wire key is `ownerRecordName`, **not** `ownerName`: + +| Key | Description | +|-----|-------------| +| `zoneName` | Required. Default `_defaultZone`. | +| `ownerRecordName` | Zone owner's user record name (shared zones). | +| `zoneType` | Optional. `REGULAR_CUSTOM_ZONE` or `DEFAULT_ZONE` on live responses. | + +MistKit's domain `ZoneID` keeps the Swift property name `ownerName`; only the wire key is `ownerRecordName`. + +All four zone endpoints (`zones/list`, `zones/lookup`, `zones/modify`, `zones/changes`) route their **success** payload through the Zone dictionary, and their **failure** payload through the "Zone Fetch Error Dictionary" (`zoneID`, `reason`, `serverErrorCode`, `retryAfter`, `redirectURL`). **Things that do NOT exist — do not add them speculatively:** @@ -24,9 +41,9 @@ All four zone endpoints (`zones/list`, `zones/lookup`, `zones/modify`, `zones/ch - **`atomic` on the `zones/modify` request** — the request body is `operations` only. `records/modify` *does* document `atomic`; the asymmetry is deliberate. - **Zone create options on `ZoneOperation`** — the operation's `zone` is documented as having "a single `zoneID` key". -**Open discrepancies (unresolved, see issue #386 comment):** +**Resolved discrepancies:** -- `zones/changes` documents its token key as **`metaSyncToken`** in both request and response; MistKit sends/reads `syncToken`. Apple's page contradicts itself (the `moreComing` description refers back to "the included `syncToken` key"), so this needs a live-response check before changing. -- `zones/changes` is documented as **deprecated** in favor of `changes/database`. +- `zones/changes` token key is `metaSyncToken` on the wire (issue #430); Swift-facing names unchanged. +- `ownerRecordName` is the wire key for the zone owner (issue #444); the mistaken `ownerName` key never decoded live responses. -Note `ZoneID`'s owner key: Apple documents `ownerRecordName`, while MistKit's `ZoneID` domain type calls it `ownerName` and the wire schema uses `ownerName`. Related: [[reference_cloudkit_archived_endpoints]]. +Note `zones/changes` is documented as **deprecated** in favor of `changes/database`. Related: [[reference_cloudkit_archived_endpoints]]. diff --git a/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Schema.swift b/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Schema.swift index 08481608..a18b5afe 100644 --- a/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Schema.swift +++ b/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Schema.swift @@ -138,6 +138,6 @@ extension SubscriptionInfo { guard let zoneName = payload.zoneName else { try ConversionError.zoneMissingName.reportAndThrow() } - return .zone(ZoneID(zoneName: zoneName, ownerName: payload.ownerName)) + return .zone(ZoneID(zoneName: zoneName, ownerName: payload.ownerRecordName)) } } diff --git a/Sources/MistKit/Models/Zones/ZoneChangeResult.swift b/Sources/MistKit/Models/Zones/ZoneChangeResult.swift index 4acd0d76..42c4ae83 100644 --- a/Sources/MistKit/Models/Zones/ZoneChangeResult.swift +++ b/Sources/MistKit/Models/Zones/ZoneChangeResult.swift @@ -45,7 +45,12 @@ extension OperationResult where Success == ZoneInfo, Target == ZoneTarget { case .ZoneFetchFailure(let failure): self = .failure(try ZoneOperationFailure(from: failure)) case .DatabaseChangedZone(let zone): - self = .success(try ZoneInfo(fromZoneID: zone.zoneID)) + self = .success( + try ZoneInfo( + fromZoneID: zone.zoneID, + deleted: zone.deleted ?? false + ) + ) } } diff --git a/Sources/MistKit/Models/Zones/ZoneID.swift b/Sources/MistKit/Models/Zones/ZoneID.swift index 9b503333..bced6bcf 100644 --- a/Sources/MistKit/Models/Zones/ZoneID.swift +++ b/Sources/MistKit/Models/Zones/ZoneID.swift @@ -42,14 +42,20 @@ public struct ZoneID: Codable, Sendable, Equatable, Hashable { public let zoneName: String /// The owner's record name (optional, nil for current user) public let ownerName: String? + /// The zone's type (e.g. `REGULAR_CUSTOM_ZONE`, `DEFAULT_ZONE`). + /// + /// `nil` when the server omits the key. + public let zoneType: String? /// Initialize a zone identifier /// - Parameters: /// - zoneName: The zone name /// - ownerName: Optional owner record name (nil = current user) - public init(zoneName: String, ownerName: String? = nil) { + /// - zoneType: Optional zone type from the wire payload + public init(zoneName: String, ownerName: String? = nil, zoneType: String? = nil) { self.zoneName = zoneName self.ownerName = ownerName + self.zoneType = zoneType } /// Lift a zone identifier from the wire schema. @@ -59,7 +65,8 @@ public struct ZoneID: Codable, Sendable, Equatable, Hashable { internal init(from schema: Components.Schemas.ZoneID) { self.init( zoneName: schema.zoneName ?? ZoneID.defaultZone.zoneName, - ownerName: schema.ownerName + ownerName: schema.ownerRecordName, + zoneType: schema.zoneType ) } } @@ -69,7 +76,8 @@ extension Components.Schemas.ZoneID { internal init(from zoneID: ZoneID) { self.init( zoneName: zoneID.zoneName, - ownerName: zoneID.ownerName + ownerRecordName: zoneID.ownerName, + zoneType: zoneID.zoneType ) } } diff --git a/Sources/MistKit/Models/Zones/ZoneInfo.swift b/Sources/MistKit/Models/Zones/ZoneInfo.swift index 18934413..abc6e69a 100644 --- a/Sources/MistKit/Models/Zones/ZoneInfo.swift +++ b/Sources/MistKit/Models/Zones/ZoneInfo.swift @@ -39,6 +39,10 @@ public struct ZoneInfo: Codable, Sendable { /// Note: always empty — CloudKit Web Services zone responses do not include /// capabilities in the current OpenAPI schema. public let capabilities: [String] + /// The zone's type (e.g. `REGULAR_CUSTOM_ZONE`, `DEFAULT_ZONE`). + /// + /// `nil` when the server omits the key. + public let zoneType: String? /// The current point in the zone's change history. /// /// Present on zone responses that carry Apple's "Zone Dictionary" payload; @@ -49,20 +53,27 @@ public struct ZoneInfo: Codable, Sendable { /// `nil` when the server omits the key — deliberately *not* defaulted to /// `false`, so "absent" stays distinguishable from "explicitly not atomic". public let atomic: Bool? + /// When `true`, this is a tombstone entry from a zone change feed — + /// the zone was deleted and should be removed from local storage. + public let deleted: Bool /// Initialize zone information public init( zoneName: String, ownerRecordName: String?, capabilities: [String], + zoneType: String? = nil, syncToken: String? = nil, - atomic: Bool? = nil + atomic: Bool? = nil, + deleted: Bool = false ) { self.zoneName = zoneName self.ownerRecordName = ownerRecordName self.capabilities = capabilities + self.zoneType = zoneType self.syncToken = syncToken self.atomic = atomic + self.deleted = deleted } /// Convert a CloudKit zone payload's `zoneID` into a `ZoneInfo`. @@ -81,7 +92,8 @@ public struct ZoneInfo: Codable, Sendable { internal init( fromZoneID zoneID: Components.Schemas.ZoneID?, syncToken: String? = nil, - atomic: Bool? = nil + atomic: Bool? = nil, + deleted: Bool = false ) throws(ConversionError) { guard let zoneID else { try ConversionError.zoneMissingID.reportAndThrow() @@ -91,20 +103,23 @@ public struct ZoneInfo: Codable, Sendable { } self.init( zoneName: zoneName, - ownerRecordName: zoneID.ownerName, + ownerRecordName: zoneID.ownerRecordName, capabilities: [], + zoneType: zoneID.zoneType, syncToken: syncToken, - atomic: atomic + atomic: atomic, + deleted: deleted ) } /// Convert a CloudKit `Zone` payload into a `ZoneInfo`, carrying the - /// zone-level metadata (`syncToken`, `atomic`) alongside the identity. + /// zone-level metadata (`syncToken`, `atomic`, `deleted`) alongside the identity. internal init(from zone: Components.Schemas.Zone) throws(ConversionError) { try self.init( fromZoneID: zone.zoneID, syncToken: zone.syncToken, - atomic: zone.atomic + atomic: zone.atomic, + deleted: zone.deleted ?? false ) } } diff --git a/Sources/MistKitOpenAPI/Types.swift b/Sources/MistKitOpenAPI/Types.swift index d18e7bf7..c86073a9 100644 --- a/Sources/MistKitOpenAPI/Types.swift +++ b/Sources/MistKitOpenAPI/Types.swift @@ -765,23 +765,35 @@ public enum Components { public struct ZoneID: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ZoneID/zoneName`. public var zoneName: Swift.String? - /// - Remark: Generated from `#/components/schemas/ZoneID/ownerName`. - public var ownerName: Swift.String? + /// The zone owner's user record name. Use this key to identify a zone owned by another user (e.g. a shared zone). + /// + /// + /// - Remark: Generated from `#/components/schemas/ZoneID/ownerRecordName`. + public var ownerRecordName: Swift.String? + /// The zone's type. Live responses carry values such as `REGULAR_CUSTOM_ZONE` and `DEFAULT_ZONE`. + /// + /// + /// - Remark: Generated from `#/components/schemas/ZoneID/zoneType`. + public var zoneType: Swift.String? /// Creates a new `ZoneID`. /// /// - Parameters: /// - zoneName: - /// - ownerName: + /// - ownerRecordName: The zone owner's user record name. Use this key to identify a zone owned by another user (e.g. a shared zone). + /// - zoneType: The zone's type. Live responses carry values such as `REGULAR_CUSTOM_ZONE` and `DEFAULT_ZONE`. public init( zoneName: Swift.String? = nil, - ownerName: Swift.String? = nil + ownerRecordName: Swift.String? = nil, + zoneType: Swift.String? = nil ) { self.zoneName = zoneName - self.ownerName = ownerName + self.ownerRecordName = ownerRecordName + self.zoneType = zoneType } public enum CodingKeys: String, CodingKey { case zoneName - case ownerName + case ownerRecordName + case zoneType } } /// - Remark: Generated from `#/components/schemas/Filter`. @@ -2254,7 +2266,7 @@ public enum Components { case moreComing } } - /// A record zone as returned by the zone endpoints (`zones/list`, `zones/lookup`, `zones/modify`, `zones/changes`). Matches the "Zone Dictionary" in Apple's archived CloudKit Web Services Reference, which documents exactly three keys: `zoneID`, `syncToken`, and `atomic`. `isEager` is deliberately absent — it appears in no primary Apple source (see issue #386). + /// A record zone as returned by the zone endpoints (`zones/list`, `zones/lookup`, `zones/modify`, `zones/changes`). The archived "Zone Dictionary" documents `zoneID`, `syncToken`, and `atomic`; live change feeds also carry `deleted` (issue #444). `isEager` is deliberately absent — it appears in no primary Apple source (see issue #386). /// /// /// - Remark: Generated from `#/components/schemas/Zone`. @@ -2270,25 +2282,34 @@ public enum Components { /// /// - Remark: Generated from `#/components/schemas/Zone/atomic`. public var atomic: Swift.Bool? + /// When `true`, the zone was deleted. Present on change-feed responses (`zones/changes`); absent on list/lookup/modify success payloads. + /// + /// + /// - Remark: Generated from `#/components/schemas/Zone/deleted`. + public var deleted: Swift.Bool? /// Creates a new `Zone`. /// /// - Parameters: /// - zoneID: /// - syncToken: The current point in the zone's change history. /// - atomic: A Boolean value indicating whether this zone supports atomic operations. + /// - deleted: When `true`, the zone was deleted. Present on change-feed responses (`zones/changes`); absent on list/lookup/modify success payloads. public init( zoneID: Components.Schemas.ZoneID? = nil, syncToken: Swift.String? = nil, - atomic: Swift.Bool? = nil + atomic: Swift.Bool? = nil, + deleted: Swift.Bool? = nil ) { self.zoneID = zoneID self.syncToken = syncToken self.atomic = atomic + self.deleted = deleted } public enum CodingKeys: String, CodingKey { case zoneID case syncToken case atomic + case deleted } } /// - Remark: Generated from `#/components/schemas/ZonesListResponse`. @@ -2495,21 +2516,33 @@ public enum Components { case moreComing } } - /// A zone that changed, as returned by `changes/database`. + /// A zone that changed, as returned by `changes/database`. Carries the same tombstone shape as `zones/changes` — `deleted: true` when the zone was removed (issue #444). + /// /// /// - Remark: Generated from `#/components/schemas/DatabaseChangedZone`. public struct DatabaseChangedZone: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/DatabaseChangedZone/zoneID`. public var zoneID: Components.Schemas.ZoneID? + /// When `true`, the zone was deleted and should be removed from local storage. + /// + /// + /// - Remark: Generated from `#/components/schemas/DatabaseChangedZone/deleted`. + public var deleted: Swift.Bool? /// Creates a new `DatabaseChangedZone`. /// /// - Parameters: /// - zoneID: - public init(zoneID: Components.Schemas.ZoneID? = nil) { + /// - deleted: When `true`, the zone was deleted and should be removed from local storage. + public init( + zoneID: Components.Schemas.ZoneID? = nil, + deleted: Swift.Bool? = nil + ) { self.zoneID = zoneID + self.deleted = deleted } public enum CodingKeys: String, CodingKey { case zoneID + case deleted } } /// Per-zone error returned inline in the `zones` array of a 200 zone diff --git a/Tests/MistKitTests/CloudKitService/CreateZone/CloudKitServiceTests.CreateZone+ErrorHandling.swift b/Tests/MistKitTests/CloudKitService/CreateZone/CloudKitServiceTests.CreateZone+ErrorHandling.swift index 20018c0f..3966cfbc 100644 --- a/Tests/MistKitTests/CloudKitService/CreateZone/CloudKitServiceTests.CreateZone+ErrorHandling.swift +++ b/Tests/MistKitTests/CloudKitService/CreateZone/CloudKitServiceTests.CreateZone+ErrorHandling.swift @@ -59,7 +59,7 @@ extension CloudKitServiceTests.CreateZone { } let service = try CloudKitServiceTests.ModifyZones.makeService(zones: [ [ - "zoneID": ["zoneName": "Articles", "ownerName": "_defaultOwner"], + "zoneID": ["zoneName": "Articles", "ownerRecordName": "_defaultOwner"], "serverErrorCode": "EXISTS", "reason": "Zone already exists", ] diff --git a/Tests/MistKitTests/CloudKitService/DeleteZone/CloudKitServiceTests.DeleteZone+ErrorHandling.swift b/Tests/MistKitTests/CloudKitService/DeleteZone/CloudKitServiceTests.DeleteZone+ErrorHandling.swift index 8e22b790..72d6237a 100644 --- a/Tests/MistKitTests/CloudKitService/DeleteZone/CloudKitServiceTests.DeleteZone+ErrorHandling.swift +++ b/Tests/MistKitTests/CloudKitService/DeleteZone/CloudKitServiceTests.DeleteZone+ErrorHandling.swift @@ -43,7 +43,7 @@ extension CloudKitServiceTests.DeleteZone { } let service = try CloudKitServiceTests.ModifyZones.makeService(zones: [ [ - "zoneID": ["zoneName": "Missing", "ownerName": "_defaultOwner"], + "zoneID": ["zoneName": "Missing", "ownerRecordName": "_defaultOwner"], "serverErrorCode": "ZONE_NOT_FOUND", "reason": "Zone does not exist", ] diff --git a/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges+Helpers.swift b/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges+Helpers.swift index 1b2778d3..8603cdfd 100644 --- a/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges+Helpers.swift +++ b/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges+Helpers.swift @@ -115,7 +115,7 @@ extension ResponseConfig { [ "zoneID": [ "zoneName": "test-zone-\(index)", - "ownerName": "_defaultOwner", + "ownerRecordName": "_defaultOwner", ] ] } diff --git a/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges+SuccessCases.swift b/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges+SuccessCases.swift index 0a722882..236a8be6 100644 --- a/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges+SuccessCases.swift +++ b/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges+SuccessCases.swift @@ -106,9 +106,9 @@ extension CloudKitServiceTests.FetchDatabaseChanges { let service = try Harness.makeService( provider: ResponseProvider( defaultResponse: try .databaseChangesResponse(zones: [ - ["zoneID": ["zoneName": "good-zone", "ownerName": "_defaultOwner"]], + ["zoneID": ["zoneName": "good-zone", "ownerRecordName": "_defaultOwner"]], [ - "zoneID": ["zoneName": "bad-zone", "ownerName": "_defaultOwner"], + "zoneID": ["zoneName": "bad-zone", "ownerRecordName": "_defaultOwner"], "serverErrorCode": "ZONE_NOT_FOUND", "reason": "Zone does not exist", ], @@ -137,7 +137,7 @@ extension CloudKitServiceTests.FetchDatabaseChanges { provider: ResponseProvider( defaultResponse: try .databaseChangesResponse(zones: [ [ - "zoneID": ["zoneName": "bad-zone", "ownerName": "_defaultOwner"], + "zoneID": ["zoneName": "bad-zone", "ownerRecordName": "_defaultOwner"], "serverErrorCode": "ZONE_NOT_FOUND", ] ]) diff --git a/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+Helpers.swift b/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+Helpers.swift index 7d7dd6c2..20870990 100644 --- a/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+Helpers.swift +++ b/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+Helpers.swift @@ -66,7 +66,7 @@ extension CloudKitServiceTests.FetchRecordZoneChanges { ] } return [ - "zoneID": ["zoneName": zoneName, "ownerName": "_defaultOwner"], + "zoneID": ["zoneName": zoneName, "ownerRecordName": "_defaultOwner"], "records": records, "syncToken": syncToken, "moreComing": moreComing, @@ -80,7 +80,7 @@ extension CloudKitServiceTests.FetchRecordZoneChanges { reason: String = "Zone does not exist" ) -> [String: Any] { [ - "zoneID": ["zoneName": zoneName, "ownerName": "_defaultOwner"], + "zoneID": ["zoneName": zoneName, "ownerRecordName": "_defaultOwner"], "serverErrorCode": serverErrorCode, "reason": reason, ] diff --git a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Helpers.swift b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Helpers.swift index cab4cd15..81581969 100644 --- a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Helpers.swift +++ b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Helpers.swift @@ -95,7 +95,7 @@ extension ResponseConfig { zones.append([ "zoneID": [ "zoneName": "test-zone-\(index)", - "ownerName": "_defaultOwner", + "ownerRecordName": "_defaultOwner", ] ]) } @@ -143,7 +143,7 @@ extension ResponseConfig { { "zoneID": { "zoneName": "valid-zone", - "ownerName": "_defaultOwner" + "ownerRecordName": "_defaultOwner" } }, {} diff --git a/Tests/MistKitTests/CloudKitService/LookupZones/CloudKitServiceTests.LookupZones+Helpers.swift b/Tests/MistKitTests/CloudKitService/LookupZones/CloudKitServiceTests.LookupZones+Helpers.swift index 53e622fe..63553cf4 100644 --- a/Tests/MistKitTests/CloudKitService/LookupZones/CloudKitServiceTests.LookupZones+Helpers.swift +++ b/Tests/MistKitTests/CloudKitService/LookupZones/CloudKitServiceTests.LookupZones+Helpers.swift @@ -53,8 +53,8 @@ extension CloudKitServiceTests.LookupZones { let responseJSON = """ { "zones": [ - { "zoneID": { "zoneName": "valid-zone", "ownerName": "_defaultOwner" } }, - { "zoneID": { "ownerName": "_defaultOwner" } } + { "zoneID": { "zoneName": "valid-zone", "ownerRecordName": "_defaultOwner" } }, + { "zoneID": { "ownerRecordName": "_defaultOwner" } } ] } """ @@ -90,7 +90,7 @@ extension ResponseConfig { zones.append([ "zoneID": [ "zoneName": "test-zone-\(index)", - "ownerName": "_defaultOwner", + "ownerRecordName": "_defaultOwner", ] ]) } diff --git a/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+ErrorHandling.swift b/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+ErrorHandling.swift index 03ab4e08..ce942ab2 100644 --- a/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+ErrorHandling.swift +++ b/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+ErrorHandling.swift @@ -44,9 +44,9 @@ extension CloudKitServiceTests.ModifyZones { return } let service = try Harness.makeService(zones: [ - ["zoneID": ["zoneName": "good-zone", "ownerName": "_defaultOwner"]], + ["zoneID": ["zoneName": "good-zone", "ownerRecordName": "_defaultOwner"]], [ - "zoneID": ["zoneName": "bad-zone", "ownerName": "_defaultOwner"], + "zoneID": ["zoneName": "bad-zone", "ownerRecordName": "_defaultOwner"], "serverErrorCode": "ZONE_NOT_FOUND", "reason": "Zone does not exist", ], @@ -77,7 +77,7 @@ extension CloudKitServiceTests.ModifyZones { } let service = try Harness.makeService(zones: [ [ - "zoneID": ["zoneName": "bad-zone", "ownerName": "_defaultOwner"], + "zoneID": ["zoneName": "bad-zone", "ownerRecordName": "_defaultOwner"], "serverErrorCode": "ZONE_NOT_FOUND", ] ]) @@ -108,7 +108,7 @@ extension CloudKitServiceTests.ModifyZones { } let service = try Harness.makeService(zones: [ [ - "zoneID": ["zoneName": "good-zone", "ownerName": "_defaultOwner"], + "zoneID": ["zoneName": "good-zone", "ownerRecordName": "_defaultOwner"], "syncToken": "zone-token", "atomic": true, ] diff --git a/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+Helpers.swift b/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+Helpers.swift index b9b6b51c..93e63a11 100644 --- a/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+Helpers.swift +++ b/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+Helpers.swift @@ -81,7 +81,7 @@ extension ResponseConfig { [ "zoneID": [ "zoneName": "modified-zone-\(index)", - "ownerName": "_defaultOwner", + "ownerRecordName": "_defaultOwner", ] ] } diff --git a/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ZoneID.swift b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ZoneID.swift index bc24f40a..61a38c6f 100644 --- a/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ZoneID.swift +++ b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ZoneID.swift @@ -94,7 +94,7 @@ extension CloudKitServiceTests.Query { let body = try await Self.sentBody(for: "queryRecords", from: provider) let zoneID = try #require(body["zoneID"] as? [String: Any]) #expect(zoneID["zoneName"] as? String == "CustomZone") - #expect(zoneID["ownerName"] == nil) + #expect(zoneID["ownerRecordName"] == nil) } @Test("queryRecords() forwards a shared zone's ownerName") @@ -111,7 +111,7 @@ extension CloudKitServiceTests.Query { let body = try await Self.sentBody(for: "queryRecords", from: provider) let zoneID = try #require(body["zoneID"] as? [String: Any]) #expect(zoneID["zoneName"] as? String == "SharedZone") - #expect(zoneID["ownerName"] as? String == "_owner-record-name") + #expect(zoneID["ownerRecordName"] as? String == "_owner-record-name") } @Test("queryRecords() forwards ZoneID.defaultZone explicitly when asked") @@ -156,7 +156,7 @@ extension CloudKitServiceTests.Query { let body = try await Self.sentBody(for: "queryRecords", from: provider, at: index) let zoneID = try #require(body["zoneID"] as? [String: Any]) #expect(zoneID["zoneName"] as? String == "CustomZone") - #expect(zoneID["ownerName"] as? String == "_owner-record-name") + #expect(zoneID["ownerRecordName"] as? String == "_owner-record-name") } } diff --git a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Helpers.swift b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Helpers.swift index cc3ea470..d31292ec 100644 --- a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Helpers.swift +++ b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Helpers.swift @@ -88,7 +88,7 @@ extension CloudKitServiceTests.Sharing { "containerIdentifier": TestConstants.serviceContainerIdentifier, "databaseScope": "SHARED", "environment": "development", - "zoneID": ["zoneName": "SharedZone", "ownerName": "_owner"], + "zoneID": ["zoneName": "SharedZone", "ownerRecordName": "_owner"], "rootRecordName": "root-\(value)", "share": shareRecord(for: value), "ownerIdentity": [ diff --git a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+Helpers.swift b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+Helpers.swift index 316e9e0b..f99e9f9a 100644 --- a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+Helpers.swift +++ b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+Helpers.swift @@ -47,7 +47,7 @@ extension CloudKitServiceTests.Subscriptions { { "subscriptionID": "zone-sub", "subscriptionType": "zone", - "zoneID": { "zoneName": "Photos", "ownerName": "_defaultOwner" }, + "zoneID": { "zoneName": "Photos", "ownerRecordName": "_defaultOwner" }, "firesOn": ["create", "update", "delete"] } ] diff --git a/Tests/MistKitTests/CloudKitService/Zones/CloudKitServiceTests.ZoneOwnerWireKey+WireFormat.swift b/Tests/MistKitTests/CloudKitService/Zones/CloudKitServiceTests.ZoneOwnerWireKey+WireFormat.swift new file mode 100644 index 00000000..b175d457 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Zones/CloudKitServiceTests.ZoneOwnerWireKey+WireFormat.swift @@ -0,0 +1,115 @@ +// +// CloudKitServiceTests.ZoneOwnerWireKey+WireFormat.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import Testing + +@testable import MistKit + +extension CloudKitServiceTests.ZoneOwnerWireKey { + /// Pins the on-the-wire owner key inside `zoneID` objects (issue #444). + /// + /// Apple's Zone ID Dictionary and live responses use `ownerRecordName`, not + /// the mistaken `ownerName` key MistKit previously emitted. + @Suite("ZoneID Wire Format") + internal struct WireFormat { + private static let database: Database = .private + + private static func makeService( + _ provider: ResponseProvider + ) throws -> CloudKitService { + try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials( + apiAuth: APICredentials( + apiToken: TestConstants.apiToken, + webAuthToken: TestConstants.webAuthToken + ) + ), + transport: MockTransport(responseProvider: provider) + ) + } + + private static func sentBody( + for operationID: String, + from provider: ResponseProvider, + at index: Int = 0 + ) async throws -> [String: Any] { + let bodies = await provider.bodies(for: operationID).compactMap { $0 } + let data = try #require(bodies.dropFirst(index).first) + return try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + } + + @Test("queryRecords() encodes ownerRecordName, never ownerName") + internal func queryEncodesOwnerRecordName() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let provider = ResponseProvider.successfulQuery() + let service = try Self.makeService(provider) + + _ = try await service.queryRecords( + MistKit.Query(recordType: "TestRecord"), + zoneID: ZoneID(zoneName: "SharedZone", ownerName: "_owner-record-name"), + database: Self.database + ) + + let body = try await Self.sentBody(for: "queryRecords", from: provider) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["ownerRecordName"] as? String == "_owner-record-name") + #expect(zoneID["ownerName"] == nil) + } + + @Test("modifyZones() encodes ownerRecordName inside each operation's zoneID") + internal func modifyZonesEncodesOwnerRecordName() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let provider = try ResponseProvider.successfulModifyZones(zoneCount: 1) + let service = try Self.makeService(provider) + + _ = try await service.modifyZones( + [.create(ZoneID(zoneName: "Shared", ownerName: "other-user"))], + database: Self.database + ) + + let body = try await Self.sentBody(for: "modifyZones", from: provider) + let operations = try #require(body["operations"] as? [[String: Any]]) + let operation = try #require(operations.first) + let zone = try #require(operation["zone"] as? [String: Any]) + let zoneID = try #require(zone["zoneID"] as? [String: Any]) + #expect(zoneID["ownerRecordName"] as? String == "other-user") + #expect(zoneID["ownerName"] == nil) + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/Zones/CloudKitServiceTests.ZoneOwnerWireKey.swift b/Tests/MistKitTests/CloudKitService/Zones/CloudKitServiceTests.ZoneOwnerWireKey.swift new file mode 100644 index 00000000..4c43e5c1 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Zones/CloudKitServiceTests.ZoneOwnerWireKey.swift @@ -0,0 +1,32 @@ +// +// CloudKitServiceTests.ZoneOwnerWireKey.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension CloudKitServiceTests { + internal enum ZoneOwnerWireKey {} +} diff --git a/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift b/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift index c1b0e775..f0c97e91 100644 --- a/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift +++ b/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift @@ -88,7 +88,7 @@ internal struct SubscriptionConversionTests { let schema = info.schema #expect(schema.subscriptionType == .zone) #expect(schema.zoneID?.zoneName == "Photos") - #expect(schema.zoneID?.ownerName == "_owner") + #expect(schema.zoneID?.ownerRecordName == "_owner") #expect(schema.query == nil) // Zone subscriptions don't carry firesOn — only `.query` does. #expect(schema.firesOn == nil) @@ -143,7 +143,7 @@ internal struct SubscriptionConversionTests { let payload = Components.Schemas.Subscription( subscriptionID: "z", subscriptionType: .zone, - zoneID: Components.Schemas.ZoneID(ownerName: "_owner") + zoneID: Components.Schemas.ZoneID(ownerRecordName: "_owner") ) expectThrow(ConversionError.zoneMissingName) { _ = try SubscriptionInfo(from: payload) diff --git a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+Responses.swift b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+Responses.swift index 851abb84..938b23a1 100644 --- a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+Responses.swift +++ b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+Responses.swift @@ -129,7 +129,7 @@ extension ZoneMetadataTests { { "zones": [ { - "zoneID": { "zoneName": "Articles", "ownerName": "_defaultOwner" }, + "zoneID": { "zoneName": "Articles", "ownerRecordName": "_defaultOwner" }, "syncToken": "lookup-token", "atomic": true } diff --git a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift index 6ad759f2..315badca 100644 --- a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift +++ b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift @@ -47,7 +47,7 @@ extension ZoneMetadataTests { let zone = try Self.decodeZone( """ { - "zoneID": { "zoneName": "Articles", "ownerName": "_defaultOwner" }, + "zoneID": { "zoneName": "Articles", "ownerRecordName": "_defaultOwner" }, "syncToken": "AQAAAAAAAAAB", "atomic": true } @@ -64,7 +64,7 @@ extension ZoneMetadataTests { let zone = try Self.decodeZone( """ { - "zoneID": { "zoneName": "Articles", "ownerName": "_defaultOwner" }, + "zoneID": { "zoneName": "Articles", "ownerRecordName": "_defaultOwner" }, "syncToken": "AQAAAAAAAAAB", "atomic": true } @@ -77,6 +77,74 @@ extension ZoneMetadataTests { #expect(info.ownerRecordName == "_defaultOwner") #expect(info.syncToken == "AQAAAAAAAAAB") #expect(info.atomic == true) + #expect(info.deleted == false) + } + + @Test( + "ZoneInfo decodes live-shaped change-feed payload with ownerRecordName, zoneType, and deleted" + ) + internal func zoneInfoDecodesLiveChangeFeedShape() throws { + let zone = try Self.decodeZone( + """ + { + "zoneID": { + "zoneName": "WebChangeTest", + "ownerRecordName": "_aca0fa3547ae9f9cd1f7e25fed948a20", + "zoneType": "REGULAR_CUSTOM_ZONE" + }, + "deleted": true + } + """ + ) + + let info = try ZoneInfo(from: zone) + + #expect(info.zoneName == "WebChangeTest") + #expect(info.ownerRecordName == "_aca0fa3547ae9f9cd1f7e25fed948a20") + #expect(info.zoneType == "REGULAR_CUSTOM_ZONE") + #expect(info.deleted == true) + } + + @Test("Absent deleted defaults to false rather than collapsing into nil") + internal func absentDeletedDefaultsFalse() throws { + let zone = try Self.decodeZone( + """ + { + "zoneID": { "zoneName": "Articles", "ownerRecordName": "_defaultOwner" } + } + """ + ) + + let info = try ZoneInfo(from: zone) + + #expect(info.deleted == false) + } + + @Test("DatabaseChangedZone tombstone surfaces deleted on ZoneInfo") + internal func databaseChangedZoneDeletedSurfaces() throws { + let item = try JSONDecoder().decode( + Components.Schemas.DatabaseChangesResponse.zonesPayloadPayload.self, + from: Data( + """ + { + "zoneID": { + "zoneName": "WebChangeTest", + "ownerRecordName": "_aca0fa3547ae9f9cd1f7e25fed948a20", + "zoneType": "REGULAR_CUSTOM_ZONE" + }, + "deleted": true + } + """.utf8 + ) + ) + + let result = try ZoneChangeResult(from: item) + let zone = try result.get() + + #expect(zone.zoneName == "WebChangeTest") + #expect(zone.ownerRecordName == "_aca0fa3547ae9f9cd1f7e25fed948a20") + #expect(zone.zoneType == "REGULAR_CUSTOM_ZONE") + #expect(zone.deleted == true) } @Test("Absent metadata stays nil rather than defaulting") @@ -113,7 +181,7 @@ extension ZoneMetadataTests { internal func missingZoneNameThrows() throws { let zone = try Self.decodeZone( """ - { "zoneID": { "ownerName": "_defaultOwner" }, "atomic": true } + { "zoneID": { "ownerRecordName": "_defaultOwner" }, "atomic": true } """ ) diff --git a/openapi.yaml b/openapi.yaml index 856bce20..98180514 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1352,8 +1352,16 @@ components: properties: zoneName: type: string - ownerName: + ownerRecordName: type: string + description: > + The zone owner's user record name. Use this key to identify a zone + owned by another user (e.g. a shared zone). + zoneType: + type: string + description: > + The zone's type. Live responses carry values such as + `REGULAR_CUSTOM_ZONE` and `DEFAULT_ZONE`. Filter: type: object @@ -1848,11 +1856,11 @@ components: type: object description: > A record zone as returned by the zone endpoints (`zones/list`, - `zones/lookup`, `zones/modify`, `zones/changes`). Matches the - "Zone Dictionary" in Apple's archived CloudKit Web Services - Reference, which documents exactly three keys: `zoneID`, - `syncToken`, and `atomic`. `isEager` is deliberately absent — it - appears in no primary Apple source (see issue #386). + `zones/lookup`, `zones/modify`, `zones/changes`). The archived + "Zone Dictionary" documents `zoneID`, `syncToken`, and `atomic`; + live change feeds also carry `deleted` (issue #444). `isEager` is + deliberately absent — it appears in no primary Apple source (see + issue #386). properties: zoneID: $ref: '#/components/schemas/ZoneID' @@ -1864,6 +1872,12 @@ components: description: > A Boolean value indicating whether this zone supports atomic operations. + deleted: + type: boolean + description: > + When `true`, the zone was deleted. Present on change-feed + responses (`zones/changes`); absent on list/lookup/modify + success payloads. ZonesListResponse: type: object @@ -1946,10 +1960,18 @@ components: DatabaseChangedZone: type: object - description: A zone that changed, as returned by `changes/database`. + description: > + A zone that changed, as returned by `changes/database`. Carries the + same tombstone shape as `zones/changes` — `deleted: true` when the + zone was removed (issue #444). properties: zoneID: $ref: '#/components/schemas/ZoneID' + deleted: + type: boolean + description: > + When `true`, the zone was deleted and should be removed from + local storage. ZoneFetchFailure: type: object From 6f66f393f1a5b78ee968c8a2497a0b9250134760 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 31 Aug 2026 09:39:46 -0400 Subject: [PATCH 02/25] Add MistDemo integration phase for zone payload metadata. Exercise ownerRecordName, zoneType, and deleted tombstones live via listZones, lookupZones, and fetchDatabaseChanges on the private database pipeline. Co-authored-by: Cursor --- .../Phases/ZonePayloadMetadataPhase.swift | 205 ++++++++++++++++++ .../Tests/PrivateDatabaseTest.swift | 1 + 2 files changed, 206 insertions(+) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ZonePayloadMetadataPhase.swift diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ZonePayloadMetadataPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ZonePayloadMetadataPhase.swift new file mode 100644 index 00000000..ada31f03 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ZonePayloadMetadataPhase.swift @@ -0,0 +1,205 @@ +// +// ZonePayloadMetadataPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKit + +/// Live verification for issue #444: zone list/lookup responses carry +/// `ownerRecordName` and `zoneType`, and `changes/database` surfaces zone +/// deletions via `deleted`. +/// +/// Creates a uniquely-named custom zone, asserts the metadata round-trips on +/// `lookupZones` / `listZones`, then deletes the zone and polls +/// `fetchDatabaseChanges` for a `deleted: true` tombstone. +internal struct ZonePayloadMetadataPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "Zone payload metadata (ownerRecordName, zoneType, deleted)" + internal static let emoji = "🏷️" + internal static let apiName = "zonePayloadMetadata" + + private static let customZoneType = "REGULAR_CUSTOM_ZONE" + private static let changeFeedPollAttempts = 5 + private static let changeFeedPollDelay: Duration = .seconds(2) + + internal func run(input: NoState, context: PhaseContext) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + let zoneName = "mistkit-zone-payload-\(UUID().uuidString.lowercased())" + let zoneID = ZoneID(zoneName: zoneName) + + let baseline = try await context.service.fetchDatabaseChanges( + database: context.database + ) + try ChangeTrackingVerification.requireNoZoneFailures( + baseline.failures, + operation: "fetchDatabaseChanges (baseline)" + ) + + do { + let created = try await context.service.createZone( + zoneName: zoneName, + database: context.database + ) + if context.verbose { + print(" ✅ Created zone: \(created.zoneName)") + } + + let lookedUp = try await context.service.lookupZones( + zoneIDs: [zoneID], + database: context.database + ) + guard let lookupZone = lookedUp.first(where: { $0.zoneName == zoneName }) else { + throw IntegrationTestError.verificationFailed( + "lookupZones did not return the created zone '\(zoneName)'" + ) + } + try Self.requireLiveZoneMetadata(lookupZone, source: "lookupZones") + + let listed = try await context.service.listZones(database: context.database) + guard let listZone = listed.first(where: { $0.zoneName == zoneName }) else { + throw IntegrationTestError.verificationFailed( + "listZones did not include the created zone '\(zoneName)'" + ) + } + try Self.requireLiveZoneMetadata(listZone, source: "listZones") + + let (createdChange, afterCreateDatabaseToken) = try await Self.pollDatabaseChanges( + syncToken: baseline.syncToken, + zoneName: zoneName, + context: context, + description: "zone creation" + ) { !$0.deleted } + try Self.requireLiveZoneMetadata(createdChange, source: "fetchDatabaseChanges (create)") + + try await context.service.deleteZone( + zoneName: zoneName, + database: context.database + ) + if context.verbose { + print(" ✅ Deleted zone: \(zoneName)") + } + + let (deletedChange, _) = try await Self.pollDatabaseChanges( + syncToken: afterCreateDatabaseToken ?? baseline.syncToken, + zoneName: zoneName, + context: context, + description: "zone deletion tombstone" + ) { $0.deleted } + + guard deletedChange.deleted else { + throw IntegrationTestError.verificationFailed( + "fetchDatabaseChanges did not mark '\(zoneName)' deleted after deleteZone" + ) + } + + if context.verbose { + print(" ✅ Change feed tombstone: deleted=true for \(zoneName)") + if let owner = deletedChange.ownerRecordName { + print(" Owner: \(owner)") + } + if let zoneType = deletedChange.zoneType { + print(" Zone type: \(zoneType)") + } + } + + print("✅ Zone payload metadata verified for '\(zoneName)'") + return NoState() + } catch { + try? await context.service.deleteZone( + zoneName: zoneName, + database: context.database + ) + throw error + } + } + + private static func requireLiveZoneMetadata( + _ zone: ZoneInfo, + source: String + ) throws { + guard let owner = zone.ownerRecordName, !owner.isEmpty else { + throw IntegrationTestError.verificationFailed( + "\(source) omitted ownerRecordName for zone '\(zone.zoneName)'" + ) + } + guard zone.zoneType == customZoneType else { + throw IntegrationTestError.verificationFailed( + "\(source) reported zoneType '\(zone.zoneType ?? "nil")' for '\(zone.zoneName)' " + + "(expected \(customZoneType))" + ) + } + guard !zone.deleted else { + throw IntegrationTestError.verificationFailed( + "\(source) reported deleted=true for live zone '\(zone.zoneName)'" + ) + } + } + + private static func pollDatabaseChanges( + syncToken: String?, + zoneName: String, + context: PhaseContext, + description: String, + matching predicate: (ZoneInfo) -> Bool + ) async throws -> (zone: ZoneInfo, databaseSyncToken: String?) { + for attempt in 1...changeFeedPollAttempts { + let result = try await context.service.fetchDatabaseChanges( + syncToken: syncToken, + database: context.database + ) + try ChangeTrackingVerification.requireNoZoneFailures( + result.failures, + operation: "fetchDatabaseChanges (\(description))" + ) + + if let zone = result.changedZones.first(where: { + $0.zoneName == zoneName && predicate($0) + }) { + return (zone, result.syncToken) + } + + if attempt < changeFeedPollAttempts { + if context.verbose { + print( + " ⏳ Change feed empty for '\(zoneName)' (\(description)); " + + "retrying (\(attempt)/\(changeFeedPollAttempts))…" + ) + } + try await Task.sleep(for: changeFeedPollDelay) + } + } + + throw IntegrationTestError.verificationFailed( + "fetchDatabaseChanges never reported \(description) for zone '\(zoneName)' " + + "after \(changeFeedPollAttempts) attempts" + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift index 5a75644c..1b46ab2e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift @@ -45,6 +45,7 @@ internal struct PrivateDatabaseTest: PhasedIntegrationTest { ModifyZonesPhase(), LookupZonePhase(), ZoneRoundtripPhase(), + ZonePayloadMetadataPhase(), UploadAssetPhase(), CreateRecordsPhase(), RereferenceAssetPhase(), From a54baaae16aa2a51ed27dff682733fd70357e636 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 31 Aug 2026 09:17:40 -0400 Subject: [PATCH 03/25] Restore local MistKit path deps in example subrepos After re-syncing from upstream mistkit branches, reapply the monorepo overlay so Examples/* build against the parent MistKit checkout. Co-authored-by: Cursor --- .../.github/actions/cloudkit-sync/action.yml | 10 + .../.github/workflows/bushel-cloud-build.yml | 5 + .../.github/workflows/dependency-policy.yml | 47 ---- Examples/BushelCloud/.gitrepo | 4 +- Examples/BushelCloud/CLAUDE.md | 6 +- Examples/BushelCloud/Package.resolved | 2 +- Examples/BushelCloud/README.md | 2 +- .../rebase-integration-branches/SKILL.md | 252 ++++++++++++++++++ .../agents/openai.yaml | 5 + .../rebase-integration-branches/projects.md | 35 +++ .../skills/rebase-integration-branches | 1 + .../.github/workflows/CelestraCloud.yml | 2 +- .../.github/workflows/dependency-policy.yml | 47 ---- Examples/CelestraCloud/.gitrepo | 4 +- Examples/CelestraCloud/Package.resolved | 186 ++++++------- Scripts/update-subrepo.sh | 62 ++++- 16 files changed, 461 insertions(+), 209 deletions(-) delete mode 100644 Examples/BushelCloud/.github/workflows/dependency-policy.yml create mode 100644 Examples/CelestraCloud/.agents/skills/rebase-integration-branches/SKILL.md create mode 100644 Examples/CelestraCloud/.agents/skills/rebase-integration-branches/agents/openai.yaml create mode 100644 Examples/CelestraCloud/.agents/skills/rebase-integration-branches/projects.md create mode 120000 Examples/CelestraCloud/.claude/skills/rebase-integration-branches delete mode 100644 Examples/CelestraCloud/.github/workflows/dependency-policy.yml diff --git a/Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml b/Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml index 83cbef3b..63614d9a 100644 --- a/Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml +++ b/Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml @@ -130,6 +130,10 @@ inputs: description: 'Run export after sync and generate reports' required: false default: 'true' + mistkit-branch: + description: 'MistKit ref to check out when falling back to a fresh build' + required: false + default: 'v1.0.0-beta.4' runs: using: "composite" @@ -145,6 +149,12 @@ runs: path: ./binary branch: ${{ github.ref_name }} + - name: Setup MistKit + if: steps.download-binary.outcome != 'success' + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: ${{ inputs.mistkit-branch }} + - name: Build binary (fallback if artifact unavailable) if: steps.download-binary.outcome != 'success' shell: bash diff --git a/Examples/BushelCloud/.github/workflows/bushel-cloud-build.yml b/Examples/BushelCloud/.github/workflows/bushel-cloud-build.yml index cdd3307b..266f077d 100644 --- a/Examples/BushelCloud/.github/workflows/bushel-cloud-build.yml +++ b/Examples/BushelCloud/.github/workflows/bushel-cloud-build.yml @@ -37,6 +37,11 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Setup MistKit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: v1.0.0-beta.4 + - name: Verify Swift version run: | swift --version diff --git a/Examples/BushelCloud/.github/workflows/dependency-policy.yml b/Examples/BushelCloud/.github/workflows/dependency-policy.yml deleted file mode 100644 index 17c38b63..00000000 --- a/Examples/BushelCloud/.github/workflows/dependency-policy.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Dependency Policy - -# Gate for PRs targeting `main`: Package.swift may only use tagged (version) -# dependencies. Branch, revision, or local-path dependencies are integration-only -# and must be bumped to a released tag before merging into `main`. - -on: - pull_request: - branches: - - main - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} - cancel-in-progress: true - -jobs: - tagged-dependencies: - name: Verify tagged dependencies - runs-on: ubuntu-latest - container: swiftlang/swift:nightly-6.4.x-noble - steps: - - uses: actions/checkout@v4 - - - name: Install jq - run: | - apt-get update - apt-get install -y --no-install-recommends jq - - - name: Verify Package.swift uses only tagged dependencies - run: | - swift package dump-package > package.json - - bad=$(jq -r ' - ([ .dependencies[] | (.sourceControl // [])[] - | select((.requirement | keys[0]) as $k | $k == "branch" or $k == "revision") - | "\(.identity): \(.requirement | keys[0]) \(.requirement | to_entries[0].value)" ] - + [ .dependencies[] | (.fileSystem // [])[] - | "\(.identity): local path (\(.path))" ])[]' package.json) - - if [ -n "$bad" ]; then - echo "::error::Package.swift must use only tagged (version) dependencies on PRs targeting main." - echo "Non-tagged dependencies found:" - echo "$bad" - exit 1 - fi - - echo "✅ All dependencies use tagged (version) requirements." diff --git a/Examples/BushelCloud/.gitrepo b/Examples/BushelCloud/.gitrepo index 1d334dda..2e018315 100644 --- a/Examples/BushelCloud/.gitrepo +++ b/Examples/BushelCloud/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = git@github.com:brightdigit/BushelCloud.git branch = mistkit - commit = 72a1a6825d9188c4a9e062c33bf15ff369e07a8c - parent = 9c5b292931b81e61ca219ef4f04cbdd0afd35013 + commit = 20430cac63e6dfe929dcb431e6ff63f2e8241e63 + parent = 687b5324f7470e007e7aa9aa39b7fe50cd9206cf method = merge cmdver = 0.4.9 diff --git a/Examples/BushelCloud/CLAUDE.md b/Examples/BushelCloud/CLAUDE.md index 15967726..967000f5 100644 --- a/Examples/BushelCloud/CLAUDE.md +++ b/Examples/BushelCloud/CLAUDE.md @@ -442,7 +442,7 @@ CloudKit enforces a **200 operations per request** limit. Operations are automat let batchSize = 200 for start in stride(from: 0, to: operations.count, by: batchSize) { let batch = Array(operations[start ..< min(start + batchSize, operations.count)]) - // MistKit (beta.3+) partitions results using a pre-computed classification and + // MistKit (beta.4+) partitions results using a pre-computed classification and // returns a structured batch result — no manual .success/.failure switching. let batchResult = try await service.modifyRecords( batch, @@ -620,13 +620,13 @@ Note: The `.xcodeproj` is in `.gitignore` - always regenerate rather than commit ## Dependencies -- **MistKit** (local path: `../MistKit`) - CloudKit Web Services client with S2S auth +- **MistKit** (1.0.0-beta.4+) - CloudKit Web Services client with S2S auth - **IPSWDownloads** - ipsw.me API wrapper for restore images - **SwiftSoup** - HTML parsing for web scraping - **ArgumentParser** - CLI framework - **swift-log** - Logging infrastructure -MistKit is the parent package; BushelCloud is an example in `Examples/Bushel/`. +BushelCloud consumes MistKit as a tagged Swift Package Manager dependency from GitHub. ## CI/CD and Code Quality diff --git a/Examples/BushelCloud/Package.resolved b/Examples/BushelCloud/Package.resolved index 265324b9..a0b1ae4d 100644 --- a/Examples/BushelCloud/Package.resolved +++ b/Examples/BushelCloud/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a54730827c3e3ca47018c9b427835bd627e21338b440ba0d3f3defc4f44ff94f", + "originHash" : "3c7d07d5315530d1c529dcc7daee1416f65395f6a3adbfe70dd188c9db4f0ae2", "pins" : [ { "identity" : "bushelkit", diff --git a/Examples/BushelCloud/README.md b/Examples/BushelCloud/README.md index 406c22b4..77d16f5a 100644 --- a/Examples/BushelCloud/README.md +++ b/Examples/BushelCloud/README.md @@ -535,7 +535,7 @@ bushel-cloud sync ## Dependencies -- **MistKit** - CloudKit Web Services client (local path dependency) +- **MistKit** (1.0.0-beta.4+) - CloudKit Web Services client - **IPSWDownloads** - ipsw.me API wrapper - **SwiftSoup** - HTML parsing for web scraping - **ArgumentParser** - CLI argument parsing diff --git a/Examples/CelestraCloud/.agents/skills/rebase-integration-branches/SKILL.md b/Examples/CelestraCloud/.agents/skills/rebase-integration-branches/SKILL.md new file mode 100644 index 00000000..9adc21e2 --- /dev/null +++ b/Examples/CelestraCloud/.agents/skills/rebase-integration-branches/SKILL.md @@ -0,0 +1,252 @@ +--- +name: rebase-integration-branches +description: >- + Rebase mistkit and subrepo integration branches onto a release branch in + this git-trees repo. Squashes each branch to one commit ahead of the release + branch with CI-only integration changes. Use when rebasing integration + branches, squashing mistkit/subrepo onto v1.0.0-dev.1, or syncing + path-dependency CI for MistKit/CelestraKit. +disable-model-invocation: true +--- + +# Rebase Integration Branches (git-trees) + +Squash `mistkit` and `subrepo` so each is **exactly one commit ahead** of the +release branch. Integration commits change **CI/workflow files only** unless +the user explicitly asks to change `Package.swift` / `Package.resolved`. + +## Prerequisites + +- Read `AGENTS.md` at the container root (sibling of worktree directories). +- Run git commands from the **container root** (parent of this worktree), + not from inside a worktree, unless editing files. +- Confirm `RELEASE_BRANCH` with the user if unsure. + +## Project config + +Read [projects.md](projects.md). Defaults for this repo: + +| Key | Value | +|-----|-------| +| `RELEASE_BRANCH` | `v1.0.0-dev.1` | +| `MAIN_BRANCH` | `main` | +| `MISTKIT_REF` | `1.0.0-beta.4` | +| `KIT_PACKAGE` | `CelestraKit` | +| `KIT_PATH` | `../CelestraKit` | +| Primary workflow | `.github/workflows/CelestraCloud.yml` | +| Feed update workflow | `.github/workflows/update-feeds.yml` | +| MistKit path (if used) | `../..` | + +## Target end state + +``` +main + └── RELEASE_BRANCH (+N commits ahead of main, often 1) + └── mistkit (+1 integration commit, CI only by default) + └── subrepo (+1 integration commit, CI only by default) +``` + +Verify after push: + +```bash +git rev-list --count origin/$MAIN_BRANCH..origin/mistkit # expect: N+1 +git rev-list --count $RELEASE_BRANCH..origin/mistkit # expect: 1 +git rev-list --count origin/mistkit..origin/$MAIN_BRANCH # expect: 0 +``` + +## Workflow checklist + +``` +- [ ] Step 0: Pre-flight — inspect branch divergence +- [ ] Step 1: Backup current branch tips +- [ ] Step 2: Rebase mistkit (squash onto RELEASE_BRANCH) +- [ ] Step 3: Push mistkit +- [ ] Step 4: Rebase subrepo (squash onto RELEASE_BRANCH) +- [ ] Step 5: Push subrepo +- [ ] Step 6: Cleanup worktrees, restore local branch refs +- [ ] Step 7: Report commit counts and diff stats +``` + +--- + +## Step 0: Pre-flight + +```bash +cd # e.g. .../CelestraCloud +git fetch origin + +git rev-list --count $RELEASE_BRANCH..mistkit +git rev-list --count mistkit..$RELEASE_BRANCH +git rev-list --count origin/$MAIN_BRANCH..mistkit +git diff --stat $RELEASE_BRANCH mistkit +git diff --stat $RELEASE_BRANCH subrepo +``` + +**Stop and ask** if force-push was not requested, backups don't exist, or the +user wants to preserve full branch history. + +--- + +## Step 1: Backup branches + +```bash +git branch -f backup/mistkit-pre-squash mistkit +git branch -f backup/subrepo-pre-squash subrepo +git push -u origin backup/mistkit-pre-squash backup/subrepo-pre-squash +``` + +Existing backups `mistkit-backup` / `subrepo-backup` may also be present — update +or create `backup/*-pre-squash` for consistency with BushelCloud. + +--- + +## Step 2: Squash mistkit onto release branch + +```bash +git trees add mistkit --no-push +cd mistkit +git reset --hard $RELEASE_BRANCH +``` + +### mistkit — CI changes + +1. **Delete** `.github/workflows/dependency-policy.yml`. + +2. **Primary workflow** (`.github/workflows/CelestraCloud.yml`) — release branch + may already have `setup-mistkit`. Add only where missing: + - `env`: `MISTKIT_BRANCH: 1.0.0-beta.4` (match existing tag format in repo) + - After checkout in each build job (`build-ubuntu`, `build-macos`, + `build-macos-platforms`): + + ```yaml + - name: Setup MistKit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: ${{ env.MISTKIT_BRANCH }} + ``` + +3. **Feed update workflow** (`.github/workflows/update-feeds.yml`) — add + setup-mistkit after checkout in the `build` job if missing (needed when + Package.swift uses a local MistKit path). + +4. **Do not carry over** stale drift: source changes, Swift version + downgrades, etc. + +### mistkit — Package changes (only if user requests) + +```swift +.package(name: "MistKit", path: "../.."), +``` + +Remove the `mistkit` pin from `Package.resolved`. + +### Commit and push mistkit + +```bash +git add -A && git diff --stat $RELEASE_BRANCH +git commit -m "$(cat <<'EOF' +chore: wire setup-mistkit CI for mistkit integration branch + +Add setup-mistkit to build workflows (pinned to 1.0.0-beta.4) and remove +dependency-policy workflow since integration branches use local path deps. +EOF +)" +git log --oneline $RELEASE_BRANCH..HEAD # exactly 1 commit +git push --force-with-lease -u origin HEAD +cd .. && git trees rm mistkit --apply +``` + +--- + +## Step 4: Squash subrepo onto release branch + +```bash +git trees add subrepo --no-push +cd subrepo +git reset --hard $RELEASE_BRANCH +``` + +### subrepo — CI changes + +1. **Delete** `.github/workflows/dependency-policy.yml`. + +2. After checkout in each build job of `CelestraCloud.yml`, add sed override + before setup-mistkit / swift-build: + + Ubuntu: + ```yaml + - name: Update Package.swift to use remote CelestraKit branch + run: | + sed -i 's|\.package(path: "\.\./CelestraKit")|.package(url: "https://github.com/brightdigit/CelestraKit.git", branch: "subrepo")|g' Package.swift + rm -f Package.resolved + ``` + + macOS: + ```yaml + - name: Update Package.swift to use remote CelestraKit branch + run: | + sed -i '' 's|\.package(path: "\.\./CelestraKit")|.package(url: "https://github.com/brightdigit/CelestraKit.git", branch: "subrepo")|g' Package.swift + rm -f Package.resolved + ``` + +### subrepo — Package changes (only if user requests) + +```swift +.package(path: "../CelestraKit"), +``` + +Remove the `celestrakit` pin from `Package.resolved`. + +### Commit and push subrepo + +```bash +git add -A && git diff --stat $RELEASE_BRANCH +git commit -m "$(cat <<'EOF' +chore: wire subrepo CI branch override for CelestraKit integration branch + +Add CI sed steps to substitute the subrepo branch during builds. Remove +dependency-policy workflow since integration branches use local path deps. +EOF +)" +git push --force-with-lease -u origin HEAD +cd .. && git trees rm subrepo --apply +``` + +--- + +## Step 6: Cleanup + +```bash +git branch -f mistkit origin/mistkit +git branch -f subrepo origin/subrepo +``` + +--- + +## Optional: revert Package files only + +```bash +git trees add --no-push +cd +git checkout $RELEASE_BRANCH -- Package.swift Package.resolved +git commit -m "revert: restore Package.swift and Package.resolved from $RELEASE_BRANCH" +git push origin +cd .. && git trees rm --apply && git branch -f origin/ +``` + +Warn: subrepo sed steps require `.package(path: "../CelestraKit")` in Package.swift. + +--- + +## Rules + +1. Never modify `CelestraCloud.git/` directly. +2. Never touch the release-branch worktree while rebasing integration branches. +3. Use `git trees add --no-push`. +4. Always `--force-with-lease`. +5. Ask before force-push if not confirmed in this session. + +## Related + +BushelCloud carries the same skill pattern with `BushelKit` and `BushelCloud.yml`. +See `brightdigit/BushelCloud` `.agents/skills/rebase-integration-branches/`. diff --git a/Examples/CelestraCloud/.agents/skills/rebase-integration-branches/agents/openai.yaml b/Examples/CelestraCloud/.agents/skills/rebase-integration-branches/agents/openai.yaml new file mode 100644 index 00000000..706ce4ca --- /dev/null +++ b/Examples/CelestraCloud/.agents/skills/rebase-integration-branches/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Rebase Integration Branches" + short_description: "Squash mistkit/subrepo onto a release branch" +policy: + allow_implicit_invocation: false diff --git a/Examples/CelestraCloud/.agents/skills/rebase-integration-branches/projects.md b/Examples/CelestraCloud/.agents/skills/rebase-integration-branches/projects.md new file mode 100644 index 00000000..86600137 --- /dev/null +++ b/Examples/CelestraCloud/.agents/skills/rebase-integration-branches/projects.md @@ -0,0 +1,35 @@ +# CelestraCloud config + +| Key | Value | +|-----|-------| +| Container root | Parent of worktree dirs (contains `.git`, `CelestraCloud.git/`) | +| Bare store | `CelestraCloud.git` | +| `RELEASE_BRANCH` | `v1.0.0-dev.1` | +| `MAIN_BRANCH` | `main` | +| `MISTKIT_REF` | `1.0.0-beta.4` | +| `KIT_PACKAGE` | `CelestraKit` | +| `KIT_PATH` | `../CelestraKit` | +| Primary workflow | `.github/workflows/CelestraCloud.yml` | +| Feed update workflow | `.github/workflows/update-feeds.yml` | +| MistKit path (if used) | `../..` (confirm monorepo layout) | + +## Expected commit counts + +- `v1.0.0-dev.1` is typically 1 commit ahead of `main` +- `mistkit` / `subrepo` should be 1 commit ahead of release, 2 ahead of `main` + +## Backup branches + +Prefer `backup/mistkit-pre-squash` and `backup/subrepo-pre-squash`. +Legacy backups `mistkit-backup` / `subrepo-backup` may also exist. + +## Differences from BushelCloud + +| BushelCloud | CelestraCloud | +|-------------|---------------| +| `v1.0.0-alpha.3` | `v1.0.0-dev.1` | +| `BushelKit` | `CelestraKit` | +| `BushelCloud.yml` | `CelestraCloud.yml` | +| `bushel-cloud-build.yml` | `update-feeds.yml` | +| `cloudkit-sync/action.yml` | _(not present)_ | +| `MISTKIT_BRANCH: v1.0.0-beta.4` | `MISTKIT_BRANCH: 1.0.0-beta.4` | diff --git a/Examples/CelestraCloud/.claude/skills/rebase-integration-branches b/Examples/CelestraCloud/.claude/skills/rebase-integration-branches new file mode 120000 index 00000000..24589df6 --- /dev/null +++ b/Examples/CelestraCloud/.claude/skills/rebase-integration-branches @@ -0,0 +1 @@ +../../.agents/skills/rebase-integration-branches \ No newline at end of file diff --git a/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml b/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml index 30cb5594..86c646e1 100644 --- a/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml +++ b/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml @@ -21,7 +21,7 @@ concurrency: env: PACKAGE_NAME: CelestraCloud - MISTKIT_BRANCH: v1.0.0-beta.4 + MISTKIT_BRANCH: 1.0.0-beta.4 jobs: configure: diff --git a/Examples/CelestraCloud/.github/workflows/dependency-policy.yml b/Examples/CelestraCloud/.github/workflows/dependency-policy.yml deleted file mode 100644 index 17c38b63..00000000 --- a/Examples/CelestraCloud/.github/workflows/dependency-policy.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Dependency Policy - -# Gate for PRs targeting `main`: Package.swift may only use tagged (version) -# dependencies. Branch, revision, or local-path dependencies are integration-only -# and must be bumped to a released tag before merging into `main`. - -on: - pull_request: - branches: - - main - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} - cancel-in-progress: true - -jobs: - tagged-dependencies: - name: Verify tagged dependencies - runs-on: ubuntu-latest - container: swiftlang/swift:nightly-6.4.x-noble - steps: - - uses: actions/checkout@v4 - - - name: Install jq - run: | - apt-get update - apt-get install -y --no-install-recommends jq - - - name: Verify Package.swift uses only tagged dependencies - run: | - swift package dump-package > package.json - - bad=$(jq -r ' - ([ .dependencies[] | (.sourceControl // [])[] - | select((.requirement | keys[0]) as $k | $k == "branch" or $k == "revision") - | "\(.identity): \(.requirement | keys[0]) \(.requirement | to_entries[0].value)" ] - + [ .dependencies[] | (.fileSystem // [])[] - | "\(.identity): local path (\(.path))" ])[]' package.json) - - if [ -n "$bad" ]; then - echo "::error::Package.swift must use only tagged (version) dependencies on PRs targeting main." - echo "Non-tagged dependencies found:" - echo "$bad" - exit 1 - fi - - echo "✅ All dependencies use tagged (version) requirements." diff --git a/Examples/CelestraCloud/.gitrepo b/Examples/CelestraCloud/.gitrepo index bb6e5cb4..11968e4a 100644 --- a/Examples/CelestraCloud/.gitrepo +++ b/Examples/CelestraCloud/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = git@github.com:brightdigit/CelestraCloud.git branch = mistkit - commit = 3a6a0afdf26669c2e6107bd880c55dad9fc736a5 - parent = 9c5b292931b81e61ca219ef4f04cbdd0afd35013 + commit = 77fbea734d50add6b36330aa4d7facf467e585e6 + parent = 3f9116c6c72b5c83b854898a6dc6e4f3231f54aa method = merge cmdver = 0.4.9 diff --git a/Examples/CelestraCloud/Package.resolved b/Examples/CelestraCloud/Package.resolved index 0b5243d2..f7938a88 100644 --- a/Examples/CelestraCloud/Package.resolved +++ b/Examples/CelestraCloud/Package.resolved @@ -1,141 +1,141 @@ { - "originHash": "db7ccb8af3f28002014d2d7bcc61ef143ec2f25a0cd3e9873cb86b35a5353570", - "pins": [ + "originHash" : "f27a89c0d96b0be480821ad8378b3900758538ddb1232c8dde2c0a272a5cfcee", + "pins" : [ { - "identity": "celestrakit", - "kind": "remoteSourceControl", - "location": "https://github.com/brightdigit/CelestraKit.git", - "state": { - "revision": "ca9dae2b20c12a4e73b48b2c245ed0cd1dbebcc4", - "version": "0.0.3" + "identity" : "celestrakit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/brightdigit/CelestraKit.git", + "state" : { + "revision" : "ca9dae2b20c12a4e73b48b2c245ed0cd1dbebcc4", + "version" : "0.0.3" } }, { - "identity": "configkeykit", - "kind": "remoteSourceControl", - "location": "https://github.com/brightdigit/ConfigKeyKit.git", - "state": { - "revision": "6949abb4b7f3e50f7f81d668e6c11aa4721e97fd", - "version": "1.0.0-beta.2" + "identity" : "configkeykit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/brightdigit/ConfigKeyKit.git", + "state" : { + "revision" : "6949abb4b7f3e50f7f81d668e6c11aa4721e97fd", + "version" : "1.0.0-beta.2" } }, { - "identity": "swift-asn1", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-asn1.git", - "state": { - "revision": "a9a5efd40eaf558a2bcd48d64b1d1646be686008", - "version": "1.7.1" + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" } }, { - "identity": "swift-async-algorithms", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-async-algorithms.git", - "state": { - "revision": "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", - "version": "1.1.5" + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", + "version" : "1.1.5" } }, { - "identity": "swift-collections", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-collections", - "state": { - "revision": "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", - "version": "1.6.0" + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" } }, { - "identity": "swift-configuration", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-configuration.git", - "state": { - "revision": "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", - "version": "1.2.0" + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" } }, { - "identity": "swift-crypto", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-crypto.git", - "state": { - "revision": "47d3869a7291f085c1fb9fb1e6d3b97a793f45c6", - "version": "4.5.1" + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "47d3869a7291f085c1fb9fb1e6d3b97a793f45c6", + "version" : "4.5.1" } }, { - "identity": "swift-http-types", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-http-types", - "state": { - "revision": "db774a277f60063a32d854f2980299caf06da041", - "version": "1.6.0" + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types", + "state" : { + "revision" : "db774a277f60063a32d854f2980299caf06da041", + "version" : "1.6.0" } }, { - "identity": "swift-log", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-log.git", - "state": { - "revision": "3ffafb9722d5d918c614feb496c8789a3b59d222", - "version": "1.15.0" + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "3ffafb9722d5d918c614feb496c8789a3b59d222", + "version" : "1.15.0" } }, { - "identity": "swift-openapi-runtime", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-openapi-runtime", - "state": { - "revision": "3d3a8457661daf7fb260ceeb9f0e24e5204ba5fb", - "version": "1.12.0" + "identity" : "swift-openapi-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-runtime", + "state" : { + "revision" : "3d3a8457661daf7fb260ceeb9f0e24e5204ba5fb", + "version" : "1.12.0" } }, { - "identity": "swift-openapi-urlsession", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-openapi-urlsession", - "state": { - "revision": "08796d36c99ad2318929bfa1d1e40f82194b65cc", - "version": "1.3.1" + "identity" : "swift-openapi-urlsession", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-urlsession", + "state" : { + "revision" : "08796d36c99ad2318929bfa1d1e40f82194b65cc", + "version" : "1.3.1" } }, { - "identity": "swift-service-lifecycle", - "kind": "remoteSourceControl", - "location": "https://github.com/swift-server/swift-service-lifecycle", - "state": { - "revision": "7f9326b0326ff86e3646295ea6e891f68c471c5e", - "version": "2.12.0" + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle", + "state" : { + "revision" : "7f9326b0326ff86e3646295ea6e891f68c471c5e", + "version" : "2.12.0" } }, { - "identity": "swift-system", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-system", - "state": { - "revision": "869129b7bf4ecc57b97d0193ad29690ca2134750", - "version": "1.8.1" + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" } }, { - "identity": "syndikit", - "kind": "remoteSourceControl", - "location": "https://github.com/brightdigit/SyndiKit.git", - "state": { - "revision": "bf0315dc6f9a3d72bdf66bb726b86e3ebab6e9ea", - "version": "0.8.1" + "identity" : "syndikit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/brightdigit/SyndiKit.git", + "state" : { + "revision" : "bf0315dc6f9a3d72bdf66bb726b86e3ebab6e9ea", + "version" : "0.8.1" } }, { - "identity": "xmlcoder", - "kind": "remoteSourceControl", - "location": "https://github.com/CoreOffice/XMLCoder", - "state": { - "revision": "42f62383dbcd074440cb1f6a750b9c02df9e7325", - "version": "0.18.2" + "identity" : "xmlcoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CoreOffice/XMLCoder", + "state" : { + "revision" : "42f62383dbcd074440cb1f6a750b9c02df9e7325", + "version" : "0.18.2" } } ], - "version": 3 + "version" : 3 } diff --git a/Scripts/update-subrepo.sh b/Scripts/update-subrepo.sh index 2023c370..652ab2ef 100755 --- a/Scripts/update-subrepo.sh +++ b/Scripts/update-subrepo.sh @@ -3,7 +3,7 @@ set -e # Generic script to update any example subrepo # Usage: ./Scripts/update-subrepo.sh Examples/BushelCloud -# ./Scripts/update-subrepo.sh Examples/Celestra +# ./Scripts/update-subrepo.sh Examples/CelestraCloud if [ $# -eq 0 ]; then echo "Usage: $0 " @@ -13,6 +13,35 @@ fi SUBREPO_PATH="$1" SUBREPO_NAME=$(basename "$SUBREPO_PATH") +REPO_ROOT="$(git rev-parse --show-toplevel)" + +MISTKIT_URL_DEP='.package(url: "https://github.com/brightdigit/MistKit.git", from: "1.0.0-beta.4")' +MISTKIT_PATH_DEP='.package(name: "MistKit", path: "../..")' + +restore_local_mistkit_path_dep() { + local package_swift="$SUBREPO_PATH/Package.swift" + + if [ ! -f "$package_swift" ]; then + return 0 + fi + + if grep -qF "$MISTKIT_URL_DEP" "$package_swift"; then + echo "🔧 Restoring local MistKit path dependency for monorepo development..." + if [ "$SUBREPO_NAME" = "BushelCloud" ]; then + sed -i '' "s|$MISTKIT_URL_DEP| // Local path: BushelCloud develops as Examples/BushelCloud inside the\\ + // MistKit repo, so ../.. resolves to the parent MistKit checkout. On main\\ + // this is a tagged remote release; this one-line overlay is reapplied when\\ + // the branch is recreated from main (never merged, so it never conflicts).\\ + $MISTKIT_PATH_DEP|" "$package_swift" + else + sed -i '' "s|$MISTKIT_URL_DEP|$MISTKIT_PATH_DEP|" "$package_swift" + fi + elif grep -q '\.package(name: "MistKit", path:' "$package_swift"; then + echo "✓ Local MistKit path dependency already present" + else + echo "✓ No MistKit dependency to restore" + fi +} if [ ! -d "$SUBREPO_PATH" ]; then echo "❌ Error: Directory $SUBREPO_PATH does not exist" @@ -28,23 +57,32 @@ echo "🔄 Updating $SUBREPO_NAME subrepo..." echo "" # Extract current branch from .gitrepo -CURRENT_BRANCH=$(grep -E '^\s*branch\s*=' "$SUBREPO_PATH/.gitrepo" | sed 's/.*=\s*//') +CURRENT_BRANCH=$(grep -E '^\s*branch\s*=' "$SUBREPO_PATH/.gitrepo" | sed 's/.*=\s*//' | tr -d '[:space:]') echo "📍 Current branch: $CURRENT_BRANCH" -# Pull latest from subrepo +# Pull latest from subrepo; retry with --force after upstream squash invalidates .gitrepo commit echo "" echo "📥 Pulling latest from remote..." -git subrepo pull "$SUBREPO_PATH" --branch="$CURRENT_BRANCH" +set +e +PULL_OUTPUT=$(git subrepo pull "$SUBREPO_PATH" --branch="$CURRENT_BRANCH" 2>&1) +PULL_STATUS=$? +set -e -# Handle local MistKit dependencies (for BushelCloud and CelestraCloud) -echo "" -echo "🔄 Checking for local MistKit dependencies..." -if grep -q '\.package(name: "MistKit", path:' "$SUBREPO_PATH/Package.swift"; then - echo "✓ Found local MistKit dependency - preserving for local development" +if [ "$PULL_STATUS" -ne 0 ]; then + if echo "$PULL_OUTPUT" | grep -q 'Local repository does not contain'; then + echo "⚠️ Stale subrepo commit reference detected; force-pulling from $CURRENT_BRANCH..." + git subrepo pull "$SUBREPO_PATH" --force --branch="$CURRENT_BRANCH" --update \ + -m "Re-sync $SUBREPO_NAME subrepo after upstream branch squash" + else + echo "$PULL_OUTPUT" + exit "$PULL_STATUS" + fi else - echo "✓ No local MistKit dependency found" + echo "$PULL_OUTPUT" fi +restore_local_mistkit_path_dep + # Resolve dependencies echo "" echo "📦 Resolving Swift package dependencies..." @@ -57,13 +95,13 @@ echo "🔨 Building to verify changes..." swift build # Go back to project root -cd - > /dev/null +cd "$REPO_ROOT" echo "" echo "✅ Update complete!" echo "" echo "📊 Subrepo status:" -cat "$SUBREPO_PATH/.gitrepo" | grep -E "commit|branch|remote" +grep -E "commit|branch|remote" "$SUBREPO_PATH/.gitrepo" echo "" echo "🎯 Next steps:" From b2478d177074f98f115b16871c2d5105b753fca8 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 31 Aug 2026 09:30:58 -0400 Subject: [PATCH 04/25] git subrepo push Examples/BushelCloud subrepo: subdir: "Examples/BushelCloud" merged: "cf22ab7" upstream: origin: "git@github.com:brightdigit/BushelCloud.git" branch: "mistkit" commit: "cf22ab7" git-subrepo: version: "0.4.9" origin: "https://github.com/Homebrew/brew" commit: "4710c526b3" --- Examples/BushelCloud/.gitrepo | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Examples/BushelCloud/.gitrepo b/Examples/BushelCloud/.gitrepo index 2e018315..5ca2cb19 100644 --- a/Examples/BushelCloud/.gitrepo +++ b/Examples/BushelCloud/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = git@github.com:brightdigit/BushelCloud.git branch = mistkit - commit = 20430cac63e6dfe929dcb431e6ff63f2e8241e63 - parent = 687b5324f7470e007e7aa9aa39b7fe50cd9206cf + commit = cf22ab754e1294b2f4cb065c79008c29e000166d + parent = 690a370bb4489d3b8ca0f52b93b5b599a323a14b method = merge cmdver = 0.4.9 From e7a4b5d30193a87df8809dadffe5ae35dfa0f1a3 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 31 Aug 2026 09:31:02 -0400 Subject: [PATCH 05/25] git subrepo push Examples/CelestraCloud subrepo: subdir: "Examples/CelestraCloud" merged: "11609b9" upstream: origin: "git@github.com:brightdigit/CelestraCloud.git" branch: "mistkit" commit: "11609b9" git-subrepo: version: "0.4.9" origin: "https://github.com/Homebrew/brew" commit: "4710c526b3" --- Examples/CelestraCloud/.gitrepo | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Examples/CelestraCloud/.gitrepo b/Examples/CelestraCloud/.gitrepo index 11968e4a..f2d49725 100644 --- a/Examples/CelestraCloud/.gitrepo +++ b/Examples/CelestraCloud/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = git@github.com:brightdigit/CelestraCloud.git branch = mistkit - commit = 77fbea734d50add6b36330aa4d7facf467e585e6 - parent = 3f9116c6c72b5c83b854898a6dc6e4f3231f54aa + commit = 11609b9ca672826b184462f983d69044b2b9cb07 + parent = eba45563f4cd45b480c3bf85546040f591462f12 method = merge cmdver = 0.4.9 From 88969860509208eb9e8829ae8c2538f8a3492f18 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 31 Aug 2026 10:07:15 -0400 Subject: [PATCH 06/25] Promote zoneType to a closed ZoneType enum with fail-loud conversion. Replace String? with DEFAULT_ZONE/REGULAR_CUSTOM_ZONE cases so unrecognized wire values surface as ConversionError during zone lifting. Co-authored-by: Cursor --- .../reference_cloudkit_zone_dictionary.md | 4 +- .../Phases/ZonePayloadMetadataPhase.swift | 6 +- Sources/MistKit/Models/ConversionError.swift | 4 ++ .../Models/Sharing/ShareRecordInfo.swift | 6 +- Sources/MistKit/Models/Zones/ZoneID.swift | 12 ++-- Sources/MistKit/Models/Zones/ZoneInfo.swift | 8 +-- Sources/MistKit/Models/Zones/ZoneType.swift | 55 +++++++++++++++++++ ...ZoneMetadataTests+ZoneInfoConversion.swift | 45 ++++++++++++++- 8 files changed, 122 insertions(+), 18 deletions(-) create mode 100644 Sources/MistKit/Models/Zones/ZoneType.swift diff --git a/.claude/memory/reference_cloudkit_zone_dictionary.md b/.claude/memory/reference_cloudkit_zone_dictionary.md index d8b10c3a..c1338f75 100644 --- a/.claude/memory/reference_cloudkit_zone_dictionary.md +++ b/.claude/memory/reference_cloudkit_zone_dictionary.md @@ -21,7 +21,7 @@ Verified against Apple's archived CloudKit Web Services Reference during issue # | Key | Location | Notes | |-----|----------|-------| | `deleted` | Zone object (not inside `zoneID`) | `true` = tombstone; sync clients must observe this | -| `zoneType` | Inside `zoneID` | Observed values: `REGULAR_CUSTOM_ZONE`, `DEFAULT_ZONE` | +| `zoneType` | Inside `zoneID` | ``ZoneType`` — `DEFAULT_ZONE` or `REGULAR_CUSTOM_ZONE`; key optional | **Zone ID Dictionary** — wire key is `ownerRecordName`, **not** `ownerName`: @@ -29,7 +29,7 @@ Verified against Apple's archived CloudKit Web Services Reference during issue # |-----|-------------| | `zoneName` | Required. Default `_defaultZone`. | | `ownerRecordName` | Zone owner's user record name (shared zones). | -| `zoneType` | Optional. `REGULAR_CUSTOM_ZONE` or `DEFAULT_ZONE` on live responses. | +| `zoneType` | Optional. ``ZoneType`` (`DEFAULT_ZONE` / `REGULAR_CUSTOM_ZONE`); unrecognized values throw at conversion. | MistKit's domain `ZoneID` keeps the Swift property name `ownerName`; only the wire key is `ownerRecordName`. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ZonePayloadMetadataPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ZonePayloadMetadataPhase.swift index ada31f03..dc545c62 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ZonePayloadMetadataPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ZonePayloadMetadataPhase.swift @@ -45,7 +45,7 @@ internal struct ZonePayloadMetadataPhase: IntegrationPhase { internal static let emoji = "🏷️" internal static let apiName = "zonePayloadMetadata" - private static let customZoneType = "REGULAR_CUSTOM_ZONE" + private static let customZoneType: ZoneType = .regularCustom private static let changeFeedPollAttempts = 5 private static let changeFeedPollDelay: Duration = .seconds(2) @@ -152,8 +152,8 @@ internal struct ZonePayloadMetadataPhase: IntegrationPhase { } guard zone.zoneType == customZoneType else { throw IntegrationTestError.verificationFailed( - "\(source) reported zoneType '\(zone.zoneType ?? "nil")' for '\(zone.zoneName)' " - + "(expected \(customZoneType))" + "\(source) reported zoneType '\(zone.zoneType?.rawValue ?? "nil")' for '\(zone.zoneName)' " + + "(expected \(customZoneType.rawValue))" ) } guard !zone.deleted else { diff --git a/Sources/MistKit/Models/ConversionError.swift b/Sources/MistKit/Models/ConversionError.swift index 5ff567ce..b0e2cd1b 100644 --- a/Sources/MistKit/Models/ConversionError.swift +++ b/Sources/MistKit/Models/ConversionError.swift @@ -62,6 +62,8 @@ public enum ConversionError: LocalizedError, Sendable, Equatable { case zoneMissingID /// A zone response was missing its `zoneName`. case zoneMissingName + /// A zone response carried an unrecognized `zoneType` wire value. + case unrecognizedZoneType(String) /// A user response was missing its `userRecordName`. case userMissingRecordName /// A subscription response was missing its `subscriptionID`. @@ -113,6 +115,8 @@ public enum ConversionError: LocalizedError, Sendable, Equatable { return "Zone entry missing zoneID" case .zoneMissingName: return "Zone entry missing zoneName" + case .unrecognizedZoneType(let wireValue): + return "Zone entry has unrecognized zoneType '\(wireValue)'" case .userMissingRecordName: return "UserResponse missing userRecordName" case .subscriptionMissingID: diff --git a/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift b/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift index 11b30669..cf9ab420 100644 --- a/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift +++ b/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift @@ -85,7 +85,11 @@ public struct ShareRecordInfo: Codable, Sendable { self.containerIdentifier = schema.containerIdentifier self.databaseScope = schema.databaseScope.map(ShareDatabaseScope.init(from:)) self.environment = schema.environment.map(Environment.init(from:)) - self.zoneID = schema.zoneID.map(ZoneID.init(from:)) + if let schemaZoneID = schema.zoneID { + self.zoneID = try ZoneID(from: schemaZoneID) + } else { + self.zoneID = nil + } self.rootRecordName = schema.rootRecordName if let rootRecord = schema.rootRecord { self.rootRecord = try RecordInfo(from: rootRecord) diff --git a/Sources/MistKit/Models/Zones/ZoneID.swift b/Sources/MistKit/Models/Zones/ZoneID.swift index bced6bcf..692273e9 100644 --- a/Sources/MistKit/Models/Zones/ZoneID.swift +++ b/Sources/MistKit/Models/Zones/ZoneID.swift @@ -42,17 +42,17 @@ public struct ZoneID: Codable, Sendable, Equatable, Hashable { public let zoneName: String /// The owner's record name (optional, nil for current user) public let ownerName: String? - /// The zone's type (e.g. `REGULAR_CUSTOM_ZONE`, `DEFAULT_ZONE`). + /// The zone's type. /// /// `nil` when the server omits the key. - public let zoneType: String? + public let zoneType: ZoneType? /// Initialize a zone identifier /// - Parameters: /// - zoneName: The zone name /// - ownerName: Optional owner record name (nil = current user) /// - zoneType: Optional zone type from the wire payload - public init(zoneName: String, ownerName: String? = nil, zoneType: String? = nil) { + public init(zoneName: String, ownerName: String? = nil, zoneType: ZoneType? = nil) { self.zoneName = zoneName self.ownerName = ownerName self.zoneType = zoneType @@ -62,11 +62,11 @@ public struct ZoneID: Codable, Sendable, Equatable, Hashable { /// /// A missing `zoneName` falls back to ``defaultZone``'s name — share /// results may omit it when CloudKit only returns an owner. - internal init(from schema: Components.Schemas.ZoneID) { + internal init(from schema: Components.Schemas.ZoneID) throws(ConversionError) { self.init( zoneName: schema.zoneName ?? ZoneID.defaultZone.zoneName, ownerName: schema.ownerRecordName, - zoneType: schema.zoneType + zoneType: try ZoneType.fromWire(schema.zoneType) ) } } @@ -77,7 +77,7 @@ extension Components.Schemas.ZoneID { self.init( zoneName: zoneID.zoneName, ownerRecordName: zoneID.ownerName, - zoneType: zoneID.zoneType + zoneType: zoneID.zoneType?.rawValue ) } } diff --git a/Sources/MistKit/Models/Zones/ZoneInfo.swift b/Sources/MistKit/Models/Zones/ZoneInfo.swift index abc6e69a..002a319f 100644 --- a/Sources/MistKit/Models/Zones/ZoneInfo.swift +++ b/Sources/MistKit/Models/Zones/ZoneInfo.swift @@ -39,10 +39,10 @@ public struct ZoneInfo: Codable, Sendable { /// Note: always empty — CloudKit Web Services zone responses do not include /// capabilities in the current OpenAPI schema. public let capabilities: [String] - /// The zone's type (e.g. `REGULAR_CUSTOM_ZONE`, `DEFAULT_ZONE`). + /// The zone's type. /// /// `nil` when the server omits the key. - public let zoneType: String? + public let zoneType: ZoneType? /// The current point in the zone's change history. /// /// Present on zone responses that carry Apple's "Zone Dictionary" payload; @@ -62,7 +62,7 @@ public struct ZoneInfo: Codable, Sendable { zoneName: String, ownerRecordName: String?, capabilities: [String], - zoneType: String? = nil, + zoneType: ZoneType? = nil, syncToken: String? = nil, atomic: Bool? = nil, deleted: Bool = false @@ -105,7 +105,7 @@ public struct ZoneInfo: Codable, Sendable { zoneName: zoneName, ownerRecordName: zoneID.ownerRecordName, capabilities: [], - zoneType: zoneID.zoneType, + zoneType: try ZoneType.fromWire(zoneID.zoneType), syncToken: syncToken, atomic: atomic, deleted: deleted diff --git a/Sources/MistKit/Models/Zones/ZoneType.swift b/Sources/MistKit/Models/Zones/ZoneType.swift new file mode 100644 index 00000000..9ca4892b --- /dev/null +++ b/Sources/MistKit/Models/Zones/ZoneType.swift @@ -0,0 +1,55 @@ +// +// ZoneType.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// The zone's type as reported on the CloudKit Web Services wire. +/// +/// Live responses use exactly two values: the database default zone +/// (``defaultZone``) and every other zone (``regularCustom``). The key is +/// optional — callers may omit it on requests. +public enum ZoneType: String, Codable, Sendable, Equatable, Hashable, CaseIterable { + /// `_defaultZone` in public or private databases. + case defaultZone = "DEFAULT_ZONE" + /// User-created custom zones, Core Data mirroring zones, and shared-database zones. + case regularCustom = "REGULAR_CUSTOM_ZONE" +} + +// MARK: - Internal Conversion +extension ZoneType { + /// Maps an optional wire string to a domain value. + /// + /// `nil` stays `nil`. Any other unrecognized string throws + /// ``ConversionError/unrecognizedZoneType(_:)``. + internal static func fromWire(_ wire: String?) throws(ConversionError) -> ZoneType? { + guard let wire else { return nil } + guard let value = ZoneType(rawValue: wire) else { + throw ConversionError.unrecognizedZoneType(wire) + } + return value + } +} diff --git a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift index 315badca..08c67a85 100644 --- a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift +++ b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift @@ -101,7 +101,7 @@ extension ZoneMetadataTests { #expect(info.zoneName == "WebChangeTest") #expect(info.ownerRecordName == "_aca0fa3547ae9f9cd1f7e25fed948a20") - #expect(info.zoneType == "REGULAR_CUSTOM_ZONE") + #expect(info.zoneType == .regularCustom) #expect(info.deleted == true) } @@ -143,10 +143,51 @@ extension ZoneMetadataTests { #expect(zone.zoneName == "WebChangeTest") #expect(zone.ownerRecordName == "_aca0fa3547ae9f9cd1f7e25fed948a20") - #expect(zone.zoneType == "REGULAR_CUSTOM_ZONE") + #expect(zone.zoneType == .regularCustom) #expect(zone.deleted == true) } + @Test("Unrecognized zoneType throws ConversionError") + internal func unrecognizedZoneTypeThrows() throws { + let zone = try Self.decodeZone( + """ + { + "zoneID": { + "zoneName": "Articles", + "zoneType": "SHARED_ZONE" + } + } + """ + ) + + ConversionFailureReporter.$assertionHandler.withValue( + { _, _, _ in }, + operation: { + #expect(throws: ConversionError.unrecognizedZoneType("SHARED_ZONE")) { + _ = try ZoneInfo(from: zone) + } + } + ) + } + + @Test("DEFAULT_ZONE decodes to ZoneType.defaultZone") + internal func defaultZoneTypeDecodes() throws { + let zone = try Self.decodeZone( + """ + { + "zoneID": { + "zoneName": "_defaultZone", + "zoneType": "DEFAULT_ZONE" + } + } + """ + ) + + let info = try ZoneInfo(from: zone) + + #expect(info.zoneType == .defaultZone) + } + @Test("Absent metadata stays nil rather than defaulting") internal func absentMetadataStaysNil() throws { let zone = try Self.decodeZone( From 6e4f658d81818beeac391f348e9775ac272c7d23 Mon Sep 17 00:00:00 2001 From: leogdion Date: Mon, 31 Aug 2026 14:17:42 -0400 Subject: [PATCH 07/25] MistDemo web: zoneName/zoneOwner on query panel (#438, #453) --- .../Sources/MistDemoKit/Resources/index.html | 8 ++++ .../Sources/MistDemoKit/Resources/js/app.js | 15 +++++++ .../Server/CloudKitService+WebBackend.swift | 8 +--- .../MistDemoKit/Server/WebBackend.swift | 3 +- .../MistDemoKit/Server/WebRequests.swift | 39 +++++++++++++++---- .../MistDemoKit/Server/WebServer+CRUD.swift | 3 +- .../Server/MockBackend+Calls.swift | 3 +- .../Server/MockBackend+RecordOperations.swift | 6 +-- .../Server/WebServerTests+Index.swift | 17 ++++++++ .../Server/WebServerTests+QueryZone.swift | 4 +- ...oudKitService+RecordWriteConvenience.swift | 21 ++++++++-- ...tServiceTests.RecordWriteConvenience.swift | 30 ++++++++++++++ 12 files changed, 129 insertions(+), 28 deletions(-) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html index 6a6ed0d3..4bf37d53 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html @@ -64,6 +64,14 @@

Notes MistKit Limit +
+ + +
+
+ + +
diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/app.js b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/app.js index 83f575fc..c0900888 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/app.js +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/app.js @@ -42,6 +42,8 @@ const deleteBtn = document.getElementById('delete-btn'); const refreshBtn = document.getElementById('refresh-btn'); const recordTypeInput = document.getElementById('record-type'); const queryLimitInput = document.getElementById('query-limit'); +const queryZoneInput = document.getElementById('query-zone'); +const queryZoneOwnerInput = document.getElementById('query-zone-owner'); const rawResponseEl = document.getElementById('raw-response'); const formImageGenerateBtn = document.getElementById('form-image-generate'); const formImageClearBtn = document.getElementById('form-image-clear'); @@ -497,6 +499,10 @@ async function queryNotes() { if (queryInFlight) return; const recordType = recordTypeInput.value.trim(); const limit = parseInt(queryLimitInput.value, 10); + const zoneName = queryZoneInput.value.trim() || undefined; + // The server rejects a zoneOwner without a zoneName, so drop a stray owner + // rather than sending a body that is guaranteed to 400. + const zoneOwner = zoneName ? (queryZoneOwnerInput.value.trim() || undefined) : undefined; queryInFlight = true; setQueryControlsDisabled(true); const dbLabel = currentDatabase === 'public' ? 'public' : 'private'; @@ -512,6 +518,8 @@ async function queryNotes() { sortBy: currentSort ? [{ field: currentSort.field, ascending: currentSort.ascending }] : undefined, + zoneName, + zoneOwner, }); } else { const query = { recordType }; @@ -521,6 +529,12 @@ async function queryNotes() { ascending: currentSort.ascending, }]; } + // CloudKit JS names the owner `ownerRecordName` inside zoneID. + if (zoneName) { + query.zoneID = zoneOwner + ? { zoneName, ownerRecordName: zoneOwner } + : { zoneName }; + } payload = await ckJsDatabase().performQuery(query, { resultsLimit: isFinite(limit) ? limit : undefined, }); @@ -552,6 +566,7 @@ function setQueryControlsDisabled(disabled) { 'refresh-btn', 'db-private', 'db-public', 'mode-mistkit', 'mode-cloudkitjs', 'save-btn', 'delete-btn', + 'query-zone', 'query-zone-owner', ]; for (const id of ids) { const el = document.getElementById(id); diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift index 02b8ab3f..b294ea6e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift @@ -35,22 +35,18 @@ extension CloudKitService: WebBackend { recordType: String, limit: Int?, sortBy: [WebRequests.QuerySortField]?, - zoneName: String?, - zoneOwner: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> [RecordInfo] { let querySorts = sortBy?.map { sort in QuerySort.sort(sort.field, ascending: sort.ascending) } - let zoneID = zoneName.map { - ZoneID(zoneName: $0, ownerName: zoneOwner) - } let result = try await queryRecords( Query(recordType: recordType, sortBy: querySorts ?? []), limit: limit, desiredKeys: nil, continuationMarker: nil, - zoneID: zoneID, + zoneID: zone?.zoneID, database: database ) return result.records diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift index b5970795..efcb5cce 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift @@ -43,8 +43,7 @@ internal protocol WebBackend: Sendable { recordType: String, limit: Int?, sortBy: [WebRequests.QuerySortField]?, - zoneName: String?, - zoneOwner: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> [RecordInfo] diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift index 9f26597a..e8eee6a9 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift @@ -51,7 +51,31 @@ internal enum WebRequests { internal let ascending: Bool } + /// Zone target for a web request: default zone is `nil`, own custom zone is + /// name-only, shared zone is name + owner. A lone owner is unrepresentable. + /// + /// Kept separate from MistKit's ``ZoneID`` so request selectors never carry + /// response-only fields like `zoneType`. + internal struct ZoneSelector: Sendable, Equatable { + internal let zoneName: String + internal let zoneOwner: String? + + internal init(zoneName: String, zoneOwner: String? = nil) { + self.zoneName = zoneName + self.zoneOwner = zoneOwner + } + + /// MistKit zone identity for `queryRecords` / `modifyRecords`. + internal var zoneID: ZoneID { + ZoneID(zoneName: zoneName, ownerName: zoneOwner) + } + } + /// `POST /api/records/query` + /// + /// Wire format stays flat (`zoneName` / `zoneOwner` at the top level) so + /// `app.js` and existing tests keep working; decode folds them into + /// ``zone``. internal struct Query: Decodable { private enum CodingKeys: String, CodingKey { case recordType @@ -66,10 +90,8 @@ internal enum WebRequests { internal let limit: Int? internal let sortBy: [QuerySortField]? internal let database: MistKit.Database - /// Optional zone name for custom/shared zone queries. - internal let zoneName: String? - /// Optional zone owner (ownerName) for shared zones. - internal let zoneOwner: String? + /// `nil` = default zone; otherwise a custom or shared zone. + internal let zone: ZoneSelector? internal init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -81,19 +103,22 @@ internal enum WebRequests { self.database = try WebRequests.decodeDatabase( from: container, forKey: .database ) - self.zoneName = try container.decodeIfPresent( + let zoneName = try container.decodeIfPresent( String.self, forKey: .zoneName ) - self.zoneOwner = try container.decodeIfPresent( + let zoneOwner = try container.decodeIfPresent( String.self, forKey: .zoneOwner ) - if self.zoneOwner != nil, self.zoneName == nil { + if zoneOwner != nil, zoneName == nil { throw DecodingError.dataCorruptedError( forKey: .zoneOwner, in: container, debugDescription: "zoneOwner requires zoneName" ) } + self.zone = zoneName.map { + ZoneSelector(zoneName: $0, zoneOwner: zoneOwner) + } } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift index 18208e81..a9acbdeb 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift @@ -51,8 +51,7 @@ recordType: body.recordType, limit: body.limit, sortBy: body.sortBy, - zoneName: body.zoneName, - zoneOwner: body.zoneOwner, + zone: body.zone, database: body.database ) return try WebJSON.encoder().encode( diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift index c0af44f4..3da08866 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift @@ -39,8 +39,7 @@ internal let recordType: String internal let limit: Int? internal let sortBy: [WebRequests.QuerySortField]? - internal let zoneName: String? - internal let zoneOwner: String? + internal let zone: WebRequests.ZoneSelector? internal let database: MistKit.Database } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift index cbcdf717..02af4739 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift @@ -37,16 +37,14 @@ recordType: String, limit: Int?, sortBy: [WebRequests.QuerySortField]?, - zoneName: String?, - zoneOwner: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> [RecordInfo] { lastQuery = QueryCall( recordType: recordType, limit: limit, sortBy: sortBy, - zoneName: zoneName, - zoneOwner: zoneOwner, + zone: zone, database: database ) try consumePendingError() diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift index 675b891e..3fa7471b 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift @@ -107,5 +107,22 @@ ) ) } + + @Test("Query panel exposes zone name and owner inputs") + internal func indexExposesQueryZoneInputs() async throws { + let html = try await body(at: "/") + #expect(html.contains(#"id="query-zone""#)) + #expect(html.contains(#"id="query-zone-owner""#)) + + let appJs = try await body(at: "/js/app.js") + // Both backends read the same two inputs... + #expect(appJs.contains("queryZoneInput")) + #expect(appJs.contains("queryZoneOwnerInput")) + // ...the MistKit path forwards them as-is on the query body, + // and the CloudKit JS path maps the owner to `ownerRecordName`. + #expect(appJs.contains("ownerRecordName: zoneOwner")) + // The inputs join the controls disabled while a query is in flight. + #expect(appJs.contains("'query-zone', 'query-zone-owner'")) + } } #endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QueryZone.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QueryZone.swift index 4e64c6ee..76ed15c6 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QueryZone.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QueryZone.swift @@ -75,8 +75,8 @@ } let captured = await fixture.backend.lastQuery - #expect(captured?.zoneName == "Articles") - #expect(captured?.zoneOwner == "_abc123") + #expect(captured?.zone?.zoneName == "Articles") + #expect(captured?.zone?.zoneOwner == "_abc123") } } #endif diff --git a/Sources/MistKit/CloudKitService/CloudKitService+RecordWriteConvenience.swift b/Sources/MistKit/CloudKitService/CloudKitService+RecordWriteConvenience.swift index 0e918d58..298120d4 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+RecordWriteConvenience.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+RecordWriteConvenience.swift @@ -35,6 +35,8 @@ extension CloudKitService { /// - recordType: The type of record to create /// - recordName: Optional unique record name /// - fields: Dictionary of field names to FieldValue + /// - zoneID: Optional target zone (defaults to the request's zone / + /// `_defaultZone` when omitted) /// - database: The CloudKit database scope to write to (`.public`, `.private`, `.shared`) /// - Returns: RecordInfo for the created record /// - Throws: CloudKitError if the operation fails @@ -51,6 +53,7 @@ extension CloudKitService { recordType: String, recordName: String? = nil, fields: [String: FieldValue], + zoneID: ZoneID? = nil, database: Database ) async throws(CloudKitError) -> RecordInfo { let operation = RecordOperation.create( @@ -59,7 +62,9 @@ extension CloudKitService { fields: fields ) - let results = try await modifyRecords([operation], database: database) + let results = try await modifyRecords( + [operation], zoneID: zoneID, database: database + ) guard let result = results.first else { throw CloudKitError.invalidResponse } @@ -72,6 +77,8 @@ extension CloudKitService { /// - recordName: The unique record name /// - fields: Dictionary of field names to FieldValue /// - recordChangeTag: Optional change tag for optimistic locking + /// - zoneID: Optional target zone (defaults to the request's zone / + /// `_defaultZone` when omitted) /// - database: The CloudKit database scope to write to (`.public`, `.private`, `.shared`) /// - Returns: RecordInfo for the updated record /// - Throws: CloudKitError if the operation fails @@ -91,6 +98,7 @@ extension CloudKitService { recordName: String, fields: [String: FieldValue], recordChangeTag: String? = nil, + zoneID: ZoneID? = nil, database: Database ) async throws(CloudKitError) -> RecordInfo { let operation = RecordOperation.update( @@ -100,7 +108,9 @@ extension CloudKitService { recordChangeTag: recordChangeTag ) - let results = try await modifyRecords([operation], database: database) + let results = try await modifyRecords( + [operation], zoneID: zoneID, database: database + ) guard let result = results.first else { throw CloudKitError.invalidResponse } @@ -112,12 +122,15 @@ extension CloudKitService { /// - recordType: The type of record to delete /// - recordName: The unique record name /// - recordChangeTag: Optional change tag for optimistic locking + /// - zoneID: Optional target zone (defaults to the request's zone / + /// `_defaultZone` when omitted) /// - database: The CloudKit database scope to delete from (`.public`, `.private`, `.shared`) /// - Throws: CloudKitError if the operation fails public func deleteRecord( recordType: String, recordName: String, recordChangeTag: String? = nil, + zoneID: ZoneID? = nil, database: Database ) async throws(CloudKitError) { let operation = RecordOperation.delete( @@ -126,7 +139,9 @@ extension CloudKitService { recordChangeTag: recordChangeTag ) - let results = try await modifyRecords([operation], database: database) + let results = try await modifyRecords( + [operation], zoneID: zoneID, database: database + ) for result in results { // `get()` rethrows a per-record failure as `recordOperationFailed`. _ = try result.get() diff --git a/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience.swift b/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience.swift index e283bfa0..4638aa8d 100644 --- a/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience.swift +++ b/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience.swift @@ -146,5 +146,35 @@ extension CloudKitServiceTests { #expect(await provider.callCount(for: "modifyRecords") == 1) } + + @Test("createRecord forwards zoneID into the modifyRecords request body") + internal func createForwardsZoneID() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let (service, provider) = try Helper.makeServiceWithProvider(responsesByOperation: [ + "modifyRecords": try Helper.recordsResponse([ + Helper.noteRecord(recordName: "note-1", changeTag: "tag-1") + ]) + ]) + + _ = try await service.createRecord( + recordType: "Note", + recordName: "note-1", + fields: ["title": .string("Hello")], + zoneID: ZoneID(zoneName: "Articles", ownerName: "_abc123"), + database: Helper.publicDatabase + ) + + let bodies = await provider.bodies(for: "modifyRecords").compactMap { $0 } + let data = try #require(bodies.first) + let body = try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["zoneName"] as? String == "Articles") + #expect(zoneID["ownerRecordName"] as? String == "_abc123") + } } } From 525e3511ba458f3bea0ec6ae6aafab655a98b2ec Mon Sep 17 00:00:00 2001 From: leogdion Date: Mon, 31 Aug 2026 19:15:23 -0400 Subject: [PATCH 08/25] Extract MistKitConfiguration; converge all three examples on typed config keys (#455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the CloudKit credential configuration glue shared by MistKit's server-side applications into its own package, and converges BushelCloud, CelestraCloud and MistDemo on typed configuration keys. ## MistKitConfiguration Lives at `Packages/MistKitConfiguration` as a subrepo of brightdigit/MistKitConfiguration, published as `1.0.0-beta.1`. Three layers, separated so that *reading* configuration cannot fail: raw `CloudKitConfiguration` → `validated()` → `ValidatedCloudKitConfiguration`, whose throwing initializer runs the key-ID and PEM validators. There is no way to hold the validated type with credentials that skipped format validation, which is what lets the examples delete their hand-rolled checks. ## Examples All three rewired onto the package; local validators, `ConfigValueReading` and duplicated CloudKit config types removed (~180 lines that were character-for-character identical between BushelCloud and CelestraCloud). App-specific error mapping stays with each app, since each already owns its own wording and key names. ## Dependencies ConfigKeyKit is pinned to `1.0.0-beta.3`, which carries ConfigKeyKit#8 (boolean resolution via a bool primitive) — the fix the branch pin was tracking. ## Note `Packages/MistKitConfiguration` is scaffolding for the beta.5 line. It must be removed in the `v1.0.0-beta.5` → `main` release PR: shipping it would be circular, since its `.gitrepo` tracks a branch pinning that same unreleased release. Downstream consumers use the published tag instead. codecov/patch and codecov/project fail as expected — this PR deletes validator suites whose tests moved to the MistKitConfiguration repo. --- .claude/agent-notes.md | 5 + .claude/docs/mistdemo/configuration.md | 10 +- .claude/docs/mistdemo/overview.md | 14 +- .../mistdemo/swift-configuration-reference.md | 22 +- .claude/memory/MEMORY.md | 9 +- ..._examples_workflow_tracks_tools_version.md | 6 +- .../memory/project_beta4_worktree_layout.md | 2 +- ...t_dogfood_pins_are_branch_pins_not_tags.md | 41 +++ ...ct_mistkitconfiguration_subrepo_overlay.md | 27 ++ .../memory/project_mkc_integration_branch.md | 47 +++ ...project_path_package_identity_collision.md | 29 ++ .../reference_configkey_cli_flag_dash_case.md | 38 +++ ...ference_configkeykit_configvaluereading.md | 34 ++ ...e_draft_gated_ci_needs_ready_for_review.md | 34 ++ .github/workflows/MistDemo.yml | 74 ++--- .github/workflows/examples.yml | 21 +- Examples/BushelCloud/.env.example | 2 +- .../.github/CLOUDKIT_SYNC_SETUP.md | 4 +- Examples/BushelCloud/.github/SECRETS_SETUP.md | 6 +- Examples/BushelCloud/Package.resolved | 6 +- Examples/BushelCloud/Package.swift | 10 +- .../Commands/ClearCommand.swift | 16 +- .../Commands/ExportCommand.swift | 16 +- .../BushelCloudCLI/Commands/ListCommand.swift | 18 +- .../Commands/StatusCommand.swift | 18 +- .../BushelCloudCLI/Commands/SyncCommand.swift | 14 +- .../CloudKit/BushelCloudKitService.swift | 91 +---- .../CloudKit/KeyIDValidator.swift | 89 ----- .../CloudKit/PEMValidator.swift | 99 ------ .../BushelCloudKit/CloudKit/SyncEngine.swift | 46 +-- .../Configuration/BushelConfiguration.swift | 27 +- .../Configuration/CloudKitConfiguration.swift | 150 --------- .../CloudKitConfigurationError+Mapping.swift | 77 +++++ .../Configuration/ConfigurationKeys.swift | 66 ++-- .../ConfigurationLoader+Loading.swift | 64 ++-- .../Configuration/ConfigurationLoader.swift | 145 +------- .../VirtualBuddyConfiguration.swift | 37 +++ .../CloudKit/KeyIDValidatorTests.swift | 122 ------- .../CloudKit/PEMValidatorTests.swift | 113 ------- .../ConfigurationLoaderTests+Fixtures.swift | 48 +++ .../ConfigurationLoaderTests.swift | 131 ++++---- Examples/CelestraCloud/Package.resolved | 6 +- Examples/CelestraCloud/Package.swift | 7 +- .../Commands/AddFeedCommand.swift | 5 +- .../CelestraCloud/Commands/ClearCommand.swift | 5 +- .../Commands/UpdateCommand+Reporting.swift | 4 +- .../Commands/UpdateCommand.swift | 5 +- .../Configuration/CelestraConfiguration.swift | 1 + .../Configuration/CloudKitConfiguration.swift | 95 ------ .../CloudKitConfigurationError+Mapping.swift | 88 +++++ .../Configuration/ConfigurationKeys.swift | 24 +- .../Configuration/ConfigurationLoader.swift | 154 ++------- .../ValidatedCloudKitConfiguration.swift | 64 ---- .../CloudKitConfigurationTests.swift | 68 ++-- Examples/MistDemo/Package.resolved | 6 +- Examples/MistDemo/Package.swift | 5 + Examples/MistDemo/README.md | 19 +- .../Configuration/AcceptConfig.swift | 13 +- .../Configuration/AuthTokenConfig.swift | 18 +- .../Configuration/AuthTokensConfig.swift | 18 +- .../Configuration/BrowserFlagResolver.swift | 4 +- .../CloudKitConfigurationError+Mapping.swift | 68 ++++ .../Configuration/CreateConfig.swift | 30 +- .../Configuration/CreateTokenConfig.swift | 11 +- .../Configuration/CreateZoneConfig.swift | 7 +- .../Configuration/CurrentUserConfig.swift | 5 +- .../Configuration/DeleteConfig.swift | 26 +- .../Configuration/DeleteZoneConfig.swift | 7 +- .../Configuration/DemoErrorsConfig.swift | 3 +- .../Configuration/DiscoverConfig.swift | 17 +- .../Configuration/FetchChangesConfig.swift | 18 +- .../FetchDatabaseChangesConfig.swift | 9 +- .../FetchZoneRecordChangesConfig.swift | 18 +- .../Keys/MistDemoKeys+Asset.swift | 65 ++++ .../Keys/MistDemoKeys+Auth.swift | 87 +++++ .../Keys/MistDemoKeys+AuthModes.swift | 55 +++ .../Keys/MistDemoKeys+Changes.swift | 50 +++ .../Keys/MistDemoKeys+Integration.swift | 81 +++++ .../Keys/MistDemoKeys+Output.swift | 52 +++ .../Keys/MistDemoKeys+Query.swift | 104 ++++++ .../Keys/MistDemoKeys+Record.swift | 118 +++++++ .../Keys/MistDemoKeys+Server.swift | 81 +++++ .../Keys/MistDemoKeys+Sharing.swift} | 39 +-- .../Keys/MistDemoKeys+Subscription.swift | 70 ++++ .../Configuration/Keys/MistDemoKeys.swift | 57 ++++ .../ListSubscriptionsConfig.swift | 5 +- .../Configuration/ListZonesConfig.swift | 10 +- .../Configuration/LookupConfig.swift | 20 +- .../LookupSubscriptionConfig.swift | 7 +- .../Configuration/LookupZonesConfig.swift | 8 +- .../MistDemoConfig+Parsing.swift | 77 +---- .../Configuration/MistDemoConfig.swift | 2 +- .../Configuration/MistDemoConfiguration.swift | 155 ++++----- .../Configuration/ModifyConfig.swift | 31 +- .../ModifySubscriptionsConfig.swift | 13 +- .../Configuration/ModifyZonesConfig.swift | 14 +- .../ProbeDuplicateSubscriptionConfig.swift | 6 +- .../Configuration/QueryConfig+Parsing.swift | 42 +-- .../Configuration/QueryConfig.swift | 8 +- .../Configuration/RegisterTokenConfig.swift | 11 +- .../RereferenceAssetConfig.swift | 13 +- .../Configuration/ResolveConfig.swift | 17 +- .../Configuration/TestPrivateConfig.swift | 19 +- .../Configuration/TestPublicConfig.swift | 12 +- .../Configuration/UpdateConfig.swift | 37 +-- .../Configuration/UploadAssetConfig.swift | 11 +- .../Configuration/ValidateConfig.swift | 15 +- .../MistDemoKit/Configuration/WebConfig.swift | 25 +- .../MistDemoConstants+Defaults.swift | 7 +- .../Constants/MistDemoConstants.swift | 66 ---- .../Configuration/AuthTokenConfigTests.swift | 50 ++- .../Configuration/AuthTokensConfigTests.swift | 19 +- .../Keys/MistDemoKeysTests.swift | 94 ++++++ .../MistDemoConfigurationBoolTests.swift | 94 ++++++ .../TestPrivateConfigTests.swift | 49 ++- .../MistDemoConfig+Testing.swift | 85 +++-- .../MistDemoConfiguration+Testing.swift | 73 ++++ Package.swift | 1 + .../.claude/hooks/session-start.sh | 127 +++++++ .../.claude/settings.json | 14 + .../setup-mistkitconfiguration/action.yml | 87 +++++ .../.github/actions/setup-tools/action.yml | 29 ++ .../workflows/MistKitConfiguration.yml | 313 ++++++++++++++++++ .../.github/workflows/claude-code-review.yml | 44 +++ .../.github/workflows/claude.yml | 50 +++ .../.github/workflows/cleanup-caches.yml | 29 ++ .../.github/workflows/dependency-policy.yml | 49 +++ Packages/MistKitConfiguration/.gitignore | 84 +++++ Packages/MistKitConfiguration/.gitrepo | 11 + Packages/MistKitConfiguration/.periphery.yml | 3 + Packages/MistKitConfiguration/.spi.yml | 5 + Packages/MistKitConfiguration/.swift-format | 70 ++++ Packages/MistKitConfiguration/.swift-version | 1 + Packages/MistKitConfiguration/.swiftlint.yml | 141 ++++++++ Packages/MistKitConfiguration/CLAUDE.md | 47 +++ Packages/MistKitConfiguration/LICENSE | 21 ++ Packages/MistKitConfiguration/Makefile | 22 ++ Packages/MistKitConfiguration/Package.swift | 75 +++++ Packages/MistKitConfiguration/README.md | 106 ++++++ .../Scripts/cloud-setup.sh | 187 +++++++++++ .../MistKitConfiguration/Scripts/header.sh | 113 +++++++ Packages/MistKitConfiguration/Scripts/lint.sh | 122 +++++++ .../CloudKitConfiguration.swift | 118 +++++++ .../CloudKitConfigurationError.swift | 69 ++++ .../CloudKitConfigurationField.swift | 49 +++ .../CloudKitConfigurationKeys.swift | 123 +++++++ .../ConfigReader+ConfigValueReading.swift | 45 +++ .../ConfigValueReading+CloudKit.swift | 55 +++ .../ConfigurationError.swift | 17 +- .../ConfigurationSources.swift | 79 +++++ .../KeyIDValidationFailure.swift | 44 +++ .../MistKitConfiguration/KeyIDValidator.swift | 66 ++++ .../PEMValidationFailure.swift | 42 +++ .../MistKitConfiguration/PEMValidator.swift | 64 ++++ .../ValidatedCloudKitConfiguration.swift | 101 ++++++ .../CloudKitConfigurationKeysTests.swift | 103 ++++++ .../CloudKitConfigurationReadingTests.swift | 120 +++++++ .../CloudKitConfigurationTests.swift | 130 ++++++++ .../ConfigurationSourcesTests.swift | 65 ++++ .../KeyIDValidatorTests.swift | 77 +++++ .../PEMValidatorTests.swift | 65 ++++ .../TestFixtures.swift | 34 +- Packages/MistKitConfiguration/codecov.yml | 9 + Packages/MistKitConfiguration/mise.toml | 7 + .../Documentation.docc/ConfiguringMistKit.md | 14 +- 165 files changed, 5600 insertions(+), 2257 deletions(-) create mode 100644 .claude/memory/project_dogfood_pins_are_branch_pins_not_tags.md create mode 100644 .claude/memory/project_mistkitconfiguration_subrepo_overlay.md create mode 100644 .claude/memory/project_mkc_integration_branch.md create mode 100644 .claude/memory/project_path_package_identity_collision.md create mode 100644 .claude/memory/reference_configkey_cli_flag_dash_case.md create mode 100644 .claude/memory/reference_configkeykit_configvaluereading.md create mode 100644 .claude/memory/reference_draft_gated_ci_needs_ready_for_review.md delete mode 100644 Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/KeyIDValidator.swift delete mode 100644 Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/PEMValidator.swift delete mode 100644 Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfiguration.swift create mode 100644 Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfigurationError+Mapping.swift create mode 100644 Examples/BushelCloud/Sources/BushelCloudKit/Configuration/VirtualBuddyConfiguration.swift delete mode 100644 Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/KeyIDValidatorTests.swift delete mode 100644 Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/PEMValidatorTests.swift create mode 100644 Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests+Fixtures.swift delete mode 100644 Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfiguration.swift create mode 100644 Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfigurationError+Mapping.swift delete mode 100644 Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ValidatedCloudKitConfiguration.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Configuration/CloudKitConfigurationError+Mapping.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Asset.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Auth.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+AuthModes.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Changes.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Integration.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Output.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Query.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Record.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Server.swift rename Examples/{CelestraCloud/Sources/CelestraCloudKit/CelestraConfig.swift => MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Sharing.swift} (57%) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Subscription.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Configuration/Keys/MistDemoKeysTests.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Configuration/MistDemoConfigurationBoolTests.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/MistDemoConfiguration+Testing.swift create mode 100755 Packages/MistKitConfiguration/.claude/hooks/session-start.sh create mode 100644 Packages/MistKitConfiguration/.claude/settings.json create mode 100644 Packages/MistKitConfiguration/.github/actions/setup-mistkitconfiguration/action.yml create mode 100644 Packages/MistKitConfiguration/.github/actions/setup-tools/action.yml create mode 100644 Packages/MistKitConfiguration/.github/workflows/MistKitConfiguration.yml create mode 100644 Packages/MistKitConfiguration/.github/workflows/claude-code-review.yml create mode 100644 Packages/MistKitConfiguration/.github/workflows/claude.yml create mode 100644 Packages/MistKitConfiguration/.github/workflows/cleanup-caches.yml create mode 100644 Packages/MistKitConfiguration/.github/workflows/dependency-policy.yml create mode 100644 Packages/MistKitConfiguration/.gitignore create mode 100644 Packages/MistKitConfiguration/.gitrepo create mode 100644 Packages/MistKitConfiguration/.periphery.yml create mode 100644 Packages/MistKitConfiguration/.spi.yml create mode 100644 Packages/MistKitConfiguration/.swift-format create mode 100644 Packages/MistKitConfiguration/.swift-version create mode 100644 Packages/MistKitConfiguration/.swiftlint.yml create mode 100644 Packages/MistKitConfiguration/CLAUDE.md create mode 100644 Packages/MistKitConfiguration/LICENSE create mode 100644 Packages/MistKitConfiguration/Makefile create mode 100644 Packages/MistKitConfiguration/Package.swift create mode 100644 Packages/MistKitConfiguration/README.md create mode 100755 Packages/MistKitConfiguration/Scripts/cloud-setup.sh create mode 100755 Packages/MistKitConfiguration/Scripts/header.sh create mode 100755 Packages/MistKitConfiguration/Scripts/lint.sh create mode 100644 Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfiguration.swift create mode 100644 Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationError.swift create mode 100644 Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationField.swift create mode 100644 Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationKeys.swift create mode 100644 Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigReader+ConfigValueReading.swift create mode 100644 Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigValueReading+CloudKit.swift rename Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/EnhancedConfigurationError.swift => Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigurationError.swift (75%) create mode 100644 Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigurationSources.swift create mode 100644 Packages/MistKitConfiguration/Sources/MistKitConfiguration/KeyIDValidationFailure.swift create mode 100644 Packages/MistKitConfiguration/Sources/MistKitConfiguration/KeyIDValidator.swift create mode 100644 Packages/MistKitConfiguration/Sources/MistKitConfiguration/PEMValidationFailure.swift create mode 100644 Packages/MistKitConfiguration/Sources/MistKitConfiguration/PEMValidator.swift create mode 100644 Packages/MistKitConfiguration/Sources/MistKitConfiguration/ValidatedCloudKitConfiguration.swift create mode 100644 Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationKeysTests.swift create mode 100644 Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationReadingTests.swift create mode 100644 Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationTests.swift create mode 100644 Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/ConfigurationSourcesTests.swift create mode 100644 Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/KeyIDValidatorTests.swift create mode 100644 Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/PEMValidatorTests.swift rename Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/CloudKitAuthMethod.swift => Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/TestFixtures.swift (59%) create mode 100644 Packages/MistKitConfiguration/codecov.yml create mode 100644 Packages/MistKitConfiguration/mise.toml diff --git a/.claude/agent-notes.md b/.claude/agent-notes.md index 9096740d..965f23db 100644 --- a/.claude/agent-notes.md +++ b/.claude/agent-notes.md @@ -19,3 +19,8 @@ Standing always/never directives and corrections from the human. Agents must rea - Repo-wide CI version bumps (Xcode/simulator/toolchain) DO extend into the `Examples/` subrepos — update BushelCloud and CelestraCloud in the same pass rather than deferring them to their own repos. - Follow sibling brightdigit repos (e.g. ConfigKeyKit) for current CI workflow shape before inventing a new one. - Feature-branch PRs: NEVER merge-commit. ALWAYS rebase if the PR has exactly 1 commit (`gh pr merge --rebase`); ALWAYS squash if it has 2+ commits (`gh pr merge --squash`). Count commits before merging. +- MistKitConfiguration#1 is merged and `1.0.0-beta.1` is tagged (ConfigKeyKit `1.0.0-beta.3`). The published `main` manifest must stay tag-only — `dependency-policy.yml` enforces this on non-draft PRs to `main`. Branch pins live on the `mistkit-beta.5` integration branch, which must NEVER be merged to `main` or tagged. +- MistDemo's own `resolveBool` may now be replaced by ConfigKeyKit `read(_:)`: the boolean fix (ConfigKeyKit#8) shipped in `1.0.0-beta.3` (2026-08-31). Verify behaviour before swapping; this supersedes the earlier "keep resolveBool until tagged" directive. +- `git subrepo push Packages/MistKitConfiguration` refuses ("new changes upstream") and would clobber the standalone `url:` MistKit line with the monorepo `path:` overlay; push subrepo-only changes by applying them to a clone of the standalone repo instead, then record the pushed SHA in `.gitrepo`'s `commit =`. +- NEVER switch `Examples/*/Package.swift` to a tagged `from:` just because a monorepo package was released — the Examples dogfood UNRELEASED MistKit and must keep `path:` for every in-monorepo package, with CI rewriting them to branch-HEAD `revision:` pins via `setup-mistkitconfiguration` (takes both `mistkit-branch` and `mistkitconfiguration-branch`). A published tag is for downstream consumers, not for the Examples. +- `Packages/MistKitConfiguration` is scaffolding for the `v1.0.0-beta.5` release line ONLY. Feature PRs merge into `v1.0.0-beta.5` with the subrepo intact; the release PR `v1.0.0-beta.5` → `main` MUST delete it (and its `examples.yml` lane) so no MistKit release ships a package tracking that same unreleased release. diff --git a/.claude/docs/mistdemo/configuration.md b/.claude/docs/mistdemo/configuration.md index 469855f7..ee0fb20d 100644 --- a/.claude/docs/mistdemo/configuration.md +++ b/.claude/docs/mistdemo/configuration.md @@ -1,5 +1,9 @@ # Configuration Management +> **Source of truth:** configuration keys now live as typed values in +> `Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/` (`MistDemoKeys`). +> The five CloudKit credential keys share their names with `MistKitConfiguration`, +> BushelCloud and CelestraCloud; every other key carries `envPrefix: "CLOUDKIT"`. MistDemo supports flexible configuration through files, profiles, environment variables, and command-line arguments using Apple's Swift Configuration package. ## Configuration Sources @@ -102,12 +106,12 @@ profiles: **Note**: Swift Configuration automatically transforms keys: - Dots (`.`) become underscores (`_`) for environment variables -- Example: `container.identifier` → `CONTAINER_IDENTIFIER` environment variable -- When CommandLineArgumentsProvider is added: dots become hyphens for CLI args (`container.identifier` → `--container-identifier`) +- Example: `cloudkit.container-id` → `CLOUDKIT_CONTAINER_ID` environment variable +- CLI args: dots and dashes both become hyphens (`cloudkit.container-id` → `--cloudkit-container-id`) | Key | Type | Environment Variable (Auto-transformed) | Default | Description | |-----|------|---------------------|---------|-------------| -| `container.identifier` | String | `CONTAINER_IDENTIFIER` | `iCloud.com.brightdigit.MistDemo` | Container identifier | +| `cloudkit.container-id` | String | `CLOUDKIT_CONTAINER_ID` | `iCloud.com.brightdigit.MistDemo` | Container identifier | | `api.token` | String | `API_TOKEN` | Empty string | API token (secret) | | `environment` | String | `ENVIRONMENT` | `development` | CloudKit environment | | `database` | String | `DATABASE` | Varies by auth method | Database type | diff --git a/.claude/docs/mistdemo/overview.md b/.claude/docs/mistdemo/overview.md index 914af7f2..cf8a2724 100644 --- a/.claude/docs/mistdemo/overview.md +++ b/.claude/docs/mistdemo/overview.md @@ -1,5 +1,9 @@ # MistDemo Overview +> **Source of truth:** configuration keys now live as typed values in +> `Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/` (`MistDemoKeys`). +> The five CloudKit credential keys share their names with `MistKitConfiguration`, +> BushelCloud and CelestraCloud; every other key carries `envPrefix: "CLOUDKIT"`. ## Architecture MistDemo is a CLI tool with a subcommand architecture where each CloudKit Web Services operation maps to a dedicated subcommand. The tool demonstrates MistKit's capabilities while providing a practical interface for CloudKit operations. @@ -40,7 +44,7 @@ All subcommands accept these global options: | Option | Short | Environment Variable | Default | Description | |--------|-------|---------------------|---------|-------------| -| `--environment` | `-e` | `CLOUDKIT_ENVIRONMENT` | `development` | CloudKit environment | +| `--cloudkit-environment` | | `CLOUDKIT_ENVIRONMENT` | `development` | CloudKit environment | | `--database` | `-d` | `CLOUDKIT_DATABASE` | `public` | Database type | Valid values: @@ -53,9 +57,9 @@ Valid values: | Option | Environment Variable | Description | |--------|---------------------|-------------| -| `--key-id` | `CLOUDKIT_KEY_ID` | Server-to-server key ID (required for public database) | -| `--private-key-file` | `CLOUDKIT_PRIVATE_KEY_PATH` | Path to ECDSA private key PEM file | -| `--private-key` | `CLOUDKIT_PRIVATE_KEY` | ECDSA private key as inline string | +| `--cloudkit-key-id` | `CLOUDKIT_KEY_ID` | Server-to-server key ID (required for public database) | +| `--cloudkit-private-key-path` | `CLOUDKIT_PRIVATE_KEY_PATH` | Path to ECDSA private key PEM file | +| `--cloudkit-private-key` | `CLOUDKIT_PRIVATE_KEY` | ECDSA private key as inline string | **Private/shared database** — web authentication: @@ -113,7 +117,7 @@ For server-side applications using key-based authentication. ```bash mistdemo query \ - --key-id YOUR_KEY_ID \ + --cloudkit-key-id YOUR_KEY_ID \ --private-key-file path/to/key.pem ``` diff --git a/.claude/docs/mistdemo/swift-configuration-reference.md b/.claude/docs/mistdemo/swift-configuration-reference.md index e6e9c5c5..1935c140 100644 --- a/.claude/docs/mistdemo/swift-configuration-reference.md +++ b/.claude/docs/mistdemo/swift-configuration-reference.md @@ -1,5 +1,9 @@ # Swift Configuration Reference for MistDemo +> **Source of truth:** configuration keys now live as typed values in +> `Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/` (`MistDemoKeys`). +> The five CloudKit credential keys share their names with `MistKitConfiguration`, +> BushelCloud and CelestraCloud; every other key carries `envPrefix: "CLOUDKIT"`. ## Overview MistDemo uses [Swift Configuration](https://github.com/apple/swift-configuration) (v1.0.0+) for hierarchical configuration management. This replaced ArgumentParser during Phase 1 (issue #212) to provide a more flexible, cross-platform configuration system. @@ -48,12 +52,12 @@ Swift Configuration uses different naming conventions for different sources: | Swift Key | CLI Flag | Environment Variable | |-----------|----------|---------------------| -| `container.identifier` | `--container-identifier` | `CONTAINER_IDENTIFIER` | -| `api.token` | `--api-token` | `API_TOKEN` | -| `web.auth.token` | `--web-auth-token` | `WEB_AUTH_TOKEN` | -| `environment` | `--environment` | `ENVIRONMENT` | -| `database` | `--database` | `DATABASE` | -| `output.format` | `--output-format` | `OUTPUT_FORMAT` | +| `cloudkit.container-id` | `--cloudkit-container-id` | `CLOUDKIT_CONTAINER_ID` | +| `api.token` | `--api-token` | `CLOUDKIT_API_TOKEN` | +| `web.auth.token` | `--web-auth-token` | `CLOUDKIT_WEB_AUTH_TOKEN` | +| `cloudkit.environment` | `--cloudkit-environment` | `CLOUDKIT_ENVIRONMENT` | +| `database` | `--database` | `CLOUDKIT_DATABASE` | +| `output.format` | `--output-format` | `CLOUDKIT_OUTPUT_FORMAT` | | `query.limit` | `--query-limit` | `QUERY_LIMIT` | | `query.zone` | `--query-zone` | `QUERY_ZONE` | @@ -276,7 +280,7 @@ let package = Package( **Problem**: Configuration key returns `nil` even though it's set. **Solution**: Check key name transformation: -- CLI: `--container-identifier` → Key: `"container.identifier"` +- CLI: `--cloudkit-container-id` → Key: `"cloudkit.container-id"` - ENV: `CONTAINER_IDENTIFIER` → Key: `"container.identifier"` ### Environment Variable Not Working @@ -312,10 +316,10 @@ export CONTAINER_IDENTIFIER="iCloud.com.example.App" **Solution**: Verify the exact flag format: ```bash # Wrong (uses =) -mistdemo --container-identifier=iCloud.com.example.App +mistdemo --cloudkit-container-id=iCloud.com.example.App # Correct (uses space) -mistdemo --container-identifier iCloud.com.example.App +mistdemo --cloudkit-container-id iCloud.com.example.App # Also correct (short form if defined) mistdemo -c iCloud.com.example.App diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 1712204e..1184cbee 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -40,4 +40,11 @@ Project-scoped agent memory for MistKit. This directory **replaces** any native - [Never git stash in this multi-worktree repo](feedback_never_git_stash_multiworktree.md) — The stash stack is shared across worktrees; a pop in one can bury a sibling branch's WIP. Commit instead. - [cloudkit.share wire casing](project_cloudkit_share_record_type_casing.md) — Live API wants `cloudkit.share` (lowercase k); archived docs' `cloudKit.share` yields "Cannot share - no such record exists to share" - [MISTKIT_BRANCH pin resolves tags too](project_mistkit_branch_pin_resolves_tags.md) — `git ls-remote` matches tags; a tag value silently pins the old release and greens example CI without testing the branch -- [Examples workflow tracks subrepo tools-version](feedback_examples_workflow_tracks_tools_version.md) — `examples.yml` must use a Swift container that can parse each example's Package.swift (6.4 nightly for Bushel/Celestra; 6.3 for MistDemo) +- [Examples workflow tracks subrepo tools-version](feedback_examples_workflow_tracks_tools_version.md) — `examples.yml` / MistDemo.yml must use a Swift 6.4 toolchain wherever MistKitConfiguration is a dependency (6.4 nightly / Xcode 27) +- [ConfigKey bases must be dash-case](reference_configkey_cli_flag_dash_case.md) — snake_case silently breaks CLI flags and secret redaction; ENV works either way, so it hides the bug +- [ConfigKeyKit ConfigValueReading](reference_configkeykit_configvaluereading.md) — ConfigKeyKit#1 shipped in-core in 1.0.0-beta.2; there is no ConfigKeyKitConfiguration package +- [Path-package identity is the directory name](project_path_package_identity_collision.md) — a `path:` MistKit + a transitive `url:` MistKit = duplicate-target build failure; resolve still succeeds +- [MistKitConfiguration subrepo overlay](project_mistkitconfiguration_subrepo_overlay.md) — Package.swift differs by one line between monorepo and standalone; `git subrepo push` clobbers it +- [Dogfood pins are branch pins, not tags](project_dogfood_pins_are_branch_pins_not_tags.md) — tagging a monorepo package does NOT mean Examples switch to `from:`; they keep `path:` and CI pins both deps to branch HEADs +- [MistKitConfiguration integration branch](project_mkc_integration_branch.md) — subrepo tracks `mistkit-beta.5`; it is beta.5-line scaffolding and must be DELETED in the `→ main` release PR +- [Draft-gated CI needs ready_for_review](reference_draft_gated_ci_needs_ready_for_review.md) — an `if: draft == false` job stays `skipped` forever unless `types:` lists `ready_for_review`; re-running replays the stale payload diff --git a/.claude/memory/feedback_examples_workflow_tracks_tools_version.md b/.claude/memory/feedback_examples_workflow_tracks_tools_version.md index ce10945b..38c8e543 100644 --- a/.claude/memory/feedback_examples_workflow_tracks_tools_version.md +++ b/.claude/memory/feedback_examples_workflow_tracks_tools_version.md @@ -4,8 +4,8 @@ description: MistKit examples.yml must use a Swift container that can parse each type: feedback --- -`BushelCloud` and `CelestraCloud` declare `swift-tools-version: 6.4`. Their own CI already runs on `swiftlang/swift:nightly-6.4.x-noble`. MistKit's parent `.github/workflows/examples.yml` must use a matching container for those matrix cells — a shared `swift:6.3` container fails immediately with "using Swift tools version 6.4.0 but the installed version is 6.3.x". +`BushelCloud`, `CelestraCloud`, and `MistKitConfiguration` declare `swift-tools-version: 6.4`. Their own CI already runs on `swiftlang/swift:nightly-6.4.x-noble`. MistKit's parent `.github/workflows/examples.yml` must use a matching container for those matrix cells — a shared `swift:6.3` container fails immediately with "using Swift tools version 6.4.0 but the installed version is 6.3.x". -`MistDemo` stays on tools-version 6.2 / container `swift:6.3`. Prefer a per-example `matrix.include` with `container:` rather than one image for all three. +`MistDemo` stays on tools-version 6.2 in its own Package.swift, but it now **depends on MistKitConfiguration**, so `.github/workflows/MistDemo.yml` must also use only the 6.4 nightly on Linux and Xcode 27 on macOS (Windows/Android lanes stay disabled until a 6.4 toolchain exists there). Prefer a per-example `matrix.include` with `container:` in `examples.yml` rather than one image for all four. -When bumping an example's tools-version (or adopting a nightly-only toolchain), update `examples.yml` in the same pass. +When bumping an example's tools-version (or adopting a nightly-only toolchain / a 6.4-only path dependency), update `examples.yml` and that example's dedicated workflow in the same pass. diff --git a/.claude/memory/project_beta4_worktree_layout.md b/.claude/memory/project_beta4_worktree_layout.md index 2fa3b36c..52daf03e 100644 --- a/.claude/memory/project_beta4_worktree_layout.md +++ b/.claude/memory/project_beta4_worktree_layout.md @@ -19,6 +19,6 @@ Layout: `MistKit.git/wt-/`, branch named `##-issue-slug` (multi-iss Grouping rule used: issues touching the same `openapi.yaml` path family share a branch, since each one requires a `./Scripts/generate-openapi.sh` regeneration and separate branches would collide in `Sources/MistKitOpenAPI/`. -**#407 (MistKitConfiguration package) was deliberately excluded** — it is blocked on brightdigit/ConfigKeyKit#1 shipping and tagging, and it creates a new `Packages/` subrepo, which is not safe to do unattended. +**#407 (MistKitConfiguration package)** — the stated ConfigKeyKit#1 blocker is resolved: that bridge shipped in-core as `ConfigValueReading` in ConfigKeyKit 1.0.0-beta.2, and the `ConfigKeyKitConfiguration` package its diagram assumed never existed (see [[reference-configkeykit-configvaluereading]]). Verification also showed most of #407's other premises stale — `Environment` parsing, the credential types and the service factory are all already in MistKit. Reframed as: unify BushelCloud + CelestraCloud first (PR 1), then extract the now-identical surface into a new `brightdigit/MistKitConfiguration` repo + `Packages/` subrepo (PR 2). Creating that repo and the `git subrepo push`es stay user actions. Related: [[project_examples_dir_is_for_mistkit_dev]], [[feedback_subrepo_fixes_belong_in_subrepo]] diff --git a/.claude/memory/project_dogfood_pins_are_branch_pins_not_tags.md b/.claude/memory/project_dogfood_pins_are_branch_pins_not_tags.md new file mode 100644 index 00000000..e47089b2 --- /dev/null +++ b/.claude/memory/project_dogfood_pins_are_branch_pins_not_tags.md @@ -0,0 +1,41 @@ +--- +name: project-dogfood-pins-are-branch-pins-not-tags +description: "Examples keep path: deps for BOTH MistKit and MistKitConfiguration; CI rewrites both to branch-HEAD revision pins. Releasing MistKitConfiguration does NOT mean the Examples switch to its tag." +metadata: + type: project +--- + +Tagging MistKitConfiguration does **not** mean `Examples/*/Package.swift` should +switch to `from: ""`. The Examples exist to dogfood **unreleased** MistKit, so +they must reach every in-monorepo package the same way: `path:` locally, rewritten +to a **branch-HEAD `revision:` pin** in CI. + +`.github/actions/setup-mistkitconfiguration` (in the MistKitConfiguration repo) +already does exactly this — it takes `mistkit-branch` **and** +`mistkitconfiguration-branch` and rewrites *both* path deps in one pass. Its own +description states the constraint: *"a path: MistKit and a url: MistKit cannot +coexist."* + +**Why a tag is the wrong lever here.** Pointing only MistKitConfiguration at a tag +breaks the Examples two ways, both verified 2026-08-31: + +1. Tagged MistKitConfiguration depends on MistKit by `url:`, which collides with the + Examples' sibling `path:` MistKit — the duplicate-target failure in + [[project_path_package_identity_collision]]. Appears at `swift build`, not + `resolve`. +2. Resolving that by *also* moving MistKit to its released tag makes MistDemo fail to + compile: it uses `ZoneType` and `ZoneInfo.deleted` (issue #444), which exist only + on the development branch. Building an Example against the last release defeats + the point of `Examples/`. + +So the release train and the dogfood wiring are independent. A published tag is for +**downstream consumers**; the Examples stay on branch pins until the feature they +exercise has actually shipped. + +**Apply:** when a monorepo package gets tagged, leave `Examples/*/Package.swift` +alone. If an Example lane needs the collision resolved in CI, add +`setup-mistkitconfiguration` with both branch inputs rather than rewriting manifests. + +Related: [[project_examples_dir_is_for_mistkit_dev]], +[[project_mistkitconfiguration_subrepo_overlay]], +[[feedback_setup_action_lives_in_owned_repo]] diff --git a/.claude/memory/project_mistkitconfiguration_subrepo_overlay.md b/.claude/memory/project_mistkitconfiguration_subrepo_overlay.md new file mode 100644 index 00000000..9bdaf3fb --- /dev/null +++ b/.claude/memory/project_mistkitconfiguration_subrepo_overlay.md @@ -0,0 +1,27 @@ +# MistKitConfiguration subrepo carries a never-merged Package.swift overlay + +`Packages/MistKitConfiguration` is a `git subrepo` of +`git@github.com:brightdigit/MistKitConfiguration.git` (branch `initial-extraction`). +Its `Package.swift` **deliberately differs between the two repos on one line**: + +| Where | MistKit dependency | +|---|---| +| Monorepo (`Packages/MistKitConfiguration`) | `.package(name: "MistKit", path: "../..")` | +| Standalone repo | `.package(url: "…/MistKit.git", from: "1.0.0-beta.4")` | + +Both forms are required. The `path:` form is forced by +[[project_path_package_identity_collision]] — a `path:` package's identity is the +directory name, so mixing it with a transitive `url:` MistKit fails the monorepo +build. The `url:` form is what makes a *tag* of MistKitConfiguration usable +downstream; a tag carrying `path: "../.."` resolves nowhere. + +**`git subrepo push` does not know this.** It copies the subdir verbatim, so a push +from the monorepo overwrites the standalone `url:` line with `path:` and breaks the +published package. Re-apply the swap after every push. Same discipline +`Examples/BushelCloud/Package.swift:94-98` documents for its own MistKit line. + +The seeding was done by hand rather than by `git subrepo push`, because the repo was +empty and GitHub cannot open a PR between unrelated histories: `main` was seeded with +a LICENSE-only initial commit first so `initial-extraction` had a merge base. As a +result `.gitrepo` records an empty `commit =`, so the *first* `git subrepo push` will +believe nothing has been pushed — check the remote before running it. diff --git a/.claude/memory/project_mkc_integration_branch.md b/.claude/memory/project_mkc_integration_branch.md new file mode 100644 index 00000000..fbd20c20 --- /dev/null +++ b/.claude/memory/project_mkc_integration_branch.md @@ -0,0 +1,47 @@ +--- +name: project-mkc-integration-branch +description: "MistKitConfiguration's mistkit-beta.5 branch pins the unreleased MistKit release branch; the subrepo tracks it and it must never be merged to main or tagged." +metadata: + type: project +--- + +`brightdigit/MistKitConfiguration` carries two long-lived refs with different +dependency policies: + +| Ref | MistKit dependency | Purpose | +|---|---|---| +| `main` | `from: "1.0.0-beta.4"` | tag-only; what `1.0.0-beta.1` was cut from | +| `mistkit-beta.5` | `branch: "v1.0.0-beta.5"` | integration; tracks unreleased MistKit | + +`Packages/MistKitConfiguration/.gitrepo` tracks **`mistkit-beta.5`** (created +2026-08-31 at `a70afee`), not `main` and no longer `initial-extraction` — that +branch was squash-merged as `6df3ad4`, so its commits are not on `main` and a +subrepo pull against it would diff against history that no longer exists. + +**`mistkit-beta.5` must never be merged to `main` or tagged.** A `branch:` +requirement in a published tag is unresolvable for downstream consumers; +`dependency-policy.yml` rejects branch/revision/path requirements on non-draft PRs +to `main`, which is the guard. The branch exists so MistKitConfiguration can be +exercised against MistKit features that have not shipped (#444's `ZoneType` / +`ZoneInfo.deleted`). + +**`Packages/MistKitConfiguration` is scaffolding for the beta.5 line and must be +removed before `main`.** It rides the `v1.0.0-beta.5` release branch so MistKit and +MistKitConfiguration can be developed together, but it is deliberately **not** part of +the shipped MistKit repo: the release PR `v1.0.0-beta.5` → `main` deletes the subrepo, +and anything still needing the package consumes it as a normal tagged dependency. + +Leaving it in would be circular — a MistKit release carrying a package whose `.gitrepo` +tracks a branch pinning that same unreleased release. + +**Retirement order:** merge feature PRs into `v1.0.0-beta.5` with the subrepo intact → +before the `→ main` release PR, delete `Packages/MistKitConfiguration` and drop its +`examples.yml` lane → tag MistKit `1.0.0-beta.5` → bump MistKitConfiguration `main` to +`from: "1.0.0-beta.5"`, cut `1.0.0-beta.2`, delete the `mistkit-beta.5` branch. + +Note this is the *standalone* repo's wiring. It does not change how the monorepo +builds: `Examples/` and `Packages/` still reach MistKit by `path: "../.."` — see +[[project_dogfood_pins_are_branch_pins_not_tags]]. + +Related: [[project_mistkitconfiguration_subrepo_overlay]], +[[project_path_package_identity_collision]] diff --git a/.claude/memory/project_path_package_identity_collision.md b/.claude/memory/project_path_package_identity_collision.md new file mode 100644 index 00000000..9dd9c8c2 --- /dev/null +++ b/.claude/memory/project_path_package_identity_collision.md @@ -0,0 +1,29 @@ +# Path-package identity is the directory name, not `name:` + +A local `.package(name: "MistKit", path: "../..")` gets its **package identity from the +resolved directory's basename** — in a worktree that is the worktree folder name (e.g. +`407-mistkitconfiguration`), **not** `mistkit` and not the `name:` argument. + +Consequence: if any package in the graph also depends on MistKit by **URL** +(`https://github.com/brightdigit/MistKit.git`, identity `mistkit`), SPM sees two distinct +packages, resolves **both**, and the build fails: + +``` +error: multiple similar targets 'MistKit', 'MistKitOpenAPI' appear in package +'mistkit' and '407-mistkitconfiguration', this may indicate that the two packages +are the same and can be de-duplicated by using mirrors. +``` + +`swift package resolve` **succeeds** — the failure only appears at `swift build`, so a +green resolve is not evidence the graph is sound. + +**Apply:** every package inside this monorepo that needs MistKit must use the *same* +`path:` dependency as its siblings. Never mix a `path:` MistKit with a transitive `url:` +MistKit. This is why `Packages/MistKitConfiguration` uses `.package(name: "MistKit", +path: "../..")` rather than a tagged URL, and why its `path:` line is a monorepo-local +overlay that must be swapped for a `url:` before the standalone repo is tagged — the same +never-merged-overlay discipline documented at `Examples/BushelCloud/Package.swift:94-98`. + +Verified empirically 2026-08-31 with a two-package scratch fixture: URL-form fails at +build; both-path form builds clean. See [[project_examples_dir_is_for_mistkit_dev]] and +[[project_mistkit_branch_pin_resolves_tags]]. diff --git a/.claude/memory/reference_configkey_cli_flag_dash_case.md b/.claude/memory/reference_configkey_cli_flag_dash_case.md new file mode 100644 index 00000000..78749b6e --- /dev/null +++ b/.claude/memory/reference_configkey_cli_flag_dash_case.md @@ -0,0 +1,38 @@ +--- +name: reference-configkey-cli-flag-dash-case +description: "ConfigKeyKit ConfigKey bases must use dash-case, not snake_case: CLIKeyEncoder joins components verbatim, so an underscore in a base silently produces an unusable CLI flag (ENV is unaffected)." +metadata: + type: reference +--- + +A ConfigKeyKit `ConfigKey`/`OptionalConfigKey` base string must separate words +with **dashes**, not underscores: `"cloudkit.key-id"`, never `"cloudkit.key_id"`. + +**Why:** the two sources normalize differently, and only one is forgiving. + +| base | ENV name | CLI flag | +|---|---|---| +| `cloudkit.key-id` | `CLOUDKIT_KEY-ID` → resolves from `CLOUDKIT_KEY_ID` | `--cloudkit-key-id` ✅ | +| `cloudkit.key_id` | `CLOUDKIT_KEY_ID` ✅ | `--cloudkit-key_id` ❌ | + +- swift-configuration's `EnvironmentVariablesProvider` normalizes **both** `-` + and `.` to `_` when encoding, so either base spelling resolves the same + `CLOUDKIT_*` variable. ENV is not a signal that the base is correct. +- `CLIKeyEncoder.encode` joins the key's components with `-` **verbatim**, so an + underscore inside a component survives into the flag. ConfigKeyKit's + `StandardNamingStyle.dotSeparated` returns the base unchanged, so it does not + intervene. + +**How to apply:** when adding a config key, spell the base in dash-case. Two +symptoms of getting it wrong, both silent — nothing fails at build time and ENV +keeps working: +1. The documented `--flag-name` never resolves; only the undocumented + `--flag_name` does. +2. `CommandLineArgumentsProvider(secretsSpecifier: .specific([...]))` entries are + matched against the *generated* flag, so a mismatch means the value is never + marked secret — a private key passed by flag is logged unredacted. + +Found in BushelCloud (fixed in #407 PR 1); CelestraCloud was already correct. +Verified empirically against swift-configuration 1.2.0, not inferred from source. + +Related: [[reference-configkeykit-configvaluereading]] diff --git a/.claude/memory/reference_configkeykit_configvaluereading.md b/.claude/memory/reference_configkeykit_configvaluereading.md new file mode 100644 index 00000000..35b8c9dc --- /dev/null +++ b/.claude/memory/reference_configkeykit_configvaluereading.md @@ -0,0 +1,34 @@ +--- +name: reference-configkeykit-configvaluereading +description: "ConfigKeyKit#1 shipped the swift-configuration bridge as the in-core ConfigValueReading protocol (1.0.0-beta.2), NOT as a separate ConfigKeyKitConfiguration package." +metadata: + type: reference +--- + +ConfigKeyKit issue #1 ("Remove Need for Extension") was resolved by putting the +`read(_:)` resolution on a protocol in the **dependency-free core**, not by +shipping the separate `ConfigKeyKitConfiguration` product its comments proposed. + +- `Sources/ConfigKeyKit/ConfigValueReading.swift`, tagged `1.0.0-beta.2`. +- The core stays Foundation-only; consumers supply a ~3-line conformance: + ```swift + extension ConfigReader: @retroactive ConfigValueReading { + public func makeConfigKey(_ s: String) -> Configuration.ConfigKey { .init(s) } + } + ``` + `Configuration` must be imported **publicly** for that conformance to compile. +- It supplies `read()` for `ConfigKey`, + `OptionalConfigKey`, plus `read(_:parsing:)`. +- Resolution order is `sourcePriority` (default `[.commandLine, .environment]`), + not `ConfigKeySource.allCases`. + +**There is no `ConfigKeyKitConfiguration` package** — do not look for one, and +treat any issue text that assumes it (e.g. MistKit #407's dependency diagram and +its "blocked by ConfigKeyKit#1" sequencing) as stale. The prefix-factory idea +(`ConfigKeySet(envPrefix:)`) from that thread also did not ship; `envPrefix` +remains a per-key initializer parameter. + +Adopted in BushelCloud + CelestraCloud in #407 PR 1, deleting ~180 lines of +hand-rolled overloads that were character-for-character identical between them. + +Related: [[reference-configkey-cli-flag-dash-case]] diff --git a/.claude/memory/reference_draft_gated_ci_needs_ready_for_review.md b/.claude/memory/reference_draft_gated_ci_needs_ready_for_review.md new file mode 100644 index 00000000..fba644e3 --- /dev/null +++ b/.claude/memory/reference_draft_gated_ci_needs_ready_for_review.md @@ -0,0 +1,34 @@ +--- +name: reference-draft-gated-ci-needs-ready-for-review +description: "A workflow job gated on `draft == false` needs `types: [..., ready_for_review]`, or it stays skipped forever and never guards the transition it exists for." +metadata: + type: reference +--- + +A job guarded by `if: github.event.pull_request.draft == false` evaluates that +condition against **the payload of the event that queued the run**, not against +the PR's state at read time. + +`on: pull_request` without an explicit `types:` defaults to +`[opened, synchronize, reopened]` — **none of which fire when a draft is marked +ready for review**. So a PR opened as a draft keeps its draft-time evaluation +and reports `skipped` indefinitely. + +Two things that do *not* fix it: + +- **Re-running the workflow** — a re-run replays the original event payload, so + the condition re-evaluates to the same stale `draft: true`. +- **Toggling draft → ready → draft** — that emits `ready_for_review` / + `converted_to_draft`, which the default `types:` ignores. + +The failure mode is silent and inverted: the gate shows `skipped`, never +`failure`, so a PR can go draft → merged with exactly the dependencies the gate +exists to reject. Observed on `brightdigit/MistKitConfiguration` +`dependency-policy.yml` (2026-08-31, PR #1); fixed by adding +`types: [opened, synchronize, reopened, ready_for_review]`. + +**Apply:** any `draft == false` gate must list `ready_for_review` in `types:`. +When a required check reads `skipping` on a PR that is *not* a draft, treat it +as a broken gate, not a pass — verify the check's logic locally before merging. + +Related: [[project_mistkitconfiguration_subrepo_overlay]] diff --git a/.github/workflows/MistDemo.yml b/.github/workflows/MistDemo.yml index 932a59bf..f96869ca 100644 --- a/.github/workflows/MistDemo.yml +++ b/.github/workflows/MistDemo.yml @@ -60,17 +60,20 @@ jobs: - id: matrix name: Build matrix values run: | - # MistDemo's Package.swift declares swift-tools-version: 6.2, - # so Swift 6.1 is not supported. + # MistDemo depends on MistKitConfiguration (swift-tools-version: 6.4). + # Swift 6.4 has no release toolchain yet, so only the nightly image can + # parse that dependency — no 6.2/6.3 lanes (same constraint as + # Packages/MistKitConfiguration and examples.yml). + SWIFT='[{"version":"6.4","image":"swiftlang/swift:nightly-6.4.x"}]' if [[ "${{ steps.check.outputs.full }}" == "true" ]]; then echo 'ubuntu-os=["noble","jammy"]' >> "$GITHUB_OUTPUT" - echo 'ubuntu-swift=[{"version":"6.2"},{"version":"6.3"},{"version":"6.4","image":"swiftlang/swift:nightly-6.4.x"}]' >> "$GITHUB_OUTPUT" - echo 'ubuntu-type=["","wasm","wasm-embedded"]' >> "$GITHUB_OUTPUT" + # Wasm SDK snapshots are not published for the 6.4 nightly — SPM only. + echo 'ubuntu-type=[""]' >> "$GITHUB_OUTPUT" else echo 'ubuntu-os=["noble"]' >> "$GITHUB_OUTPUT" - echo 'ubuntu-swift=[{"version":"6.3"}]' >> "$GITHUB_OUTPUT" echo 'ubuntu-type=[""]' >> "$GITHUB_OUTPUT" fi + echo "ubuntu-swift=$SWIFT" >> "$GITHUB_OUTPUT" build-ubuntu: name: Build on Ubuntu @@ -130,11 +133,13 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + # Windows: no Swift 6.4 release toolchain yet. MistDemo → MistKitConfiguration + # needs 6.4; re-enable when a windows-*-release 6.4 build exists (see MKC CI). build-windows: name: Build on Windows needs: configure runs-on: ${{ matrix.runs-on }} - if: ${{ needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} + if: ${{ false && needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} strategy: fail-fast: false matrix: @@ -167,11 +172,12 @@ jobs: os: windows swift_project: MistDemo + # Android: same 6.4 gate as Windows — SDK lanes are 6.2/6.3 only today. build-android: name: Build on Android needs: configure runs-on: ubuntu-latest - if: ${{ needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} + if: ${{ false && needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} strategy: fail-fast: false matrix: @@ -206,7 +212,7 @@ jobs: # Minimal macOS builds — always runs (SPM + iOS) build-macos: name: Build on macOS - runs-on: macos-26 + runs-on: ${{ matrix.runs-on }} if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} # Forward CI=true into the iOS simulator's test-runner process so # `TestPlatform.isFlakyTimeoutSimulator` tolerates the cooperative-executor @@ -217,14 +223,16 @@ jobs: fail-fast: false matrix: include: - # SPM build - - xcode: "/Applications/Xcode_26.6.app" + # MistKitConfiguration is swift-tools-version: 6.4 — Xcode 26.6 cannot + # parse it. Same xcode-27-only gate as Packages/MistKitConfiguration CI. + - runs-on: xcode-27 + xcode: "/Applications/Xcode_27.0.app" - # iOS build - type: ios - xcode: "/Applications/Xcode_26.6.app" + runs-on: xcode-27 + xcode: "/Applications/Xcode_27.0.app" deviceName: "iPhone 17 Pro" - osVersion: "26.5" + osVersion: "27.0" download-platform: true steps: - uses: actions/checkout@v6 @@ -274,47 +282,21 @@ jobs: fail-fast: false matrix: include: - # macOS - - type: macos - runs-on: macos-26 - xcode: "/Applications/Xcode_26.6.app" - - # watchOS - - type: watchos - runs-on: macos-26 - xcode: "/Applications/Xcode_26.6.app" - deviceName: "Apple Watch Ultra 3 (49mm)" - osVersion: "26.5" - download-platform: true - - # tvOS - - type: tvos - runs-on: macos-26 - xcode: "/Applications/Xcode_26.6.app" - deviceName: "Apple TV" - osVersion: "26.5" - download-platform: true - - # visionOS - - type: visionos - runs-on: macos-26 - xcode: "/Applications/Xcode_26.6.app" - deviceName: "Apple Vision Pro" - osVersion: "26.5" - download-platform: true - - # ── Xcode 27 (preview image, beta toolchain) ───────────────────── + # ── Xcode 27 only (MistKitConfiguration requires Swift 6.4) ────── + # macos-26 / Xcode 26.6 cannot parse mistkitconfiguration's + # swift-tools-version: 6.4. Same gate as MistKitConfiguration CI. + # # `runs-on: xcode-27` is its own image label, not a macos-NN one, and # is marked Preview in actions/runner-images. It ships exactly one # Xcode — 27.0 beta — so there is no 26.x fallback on this runner. # # Pin the /Applications/Xcode_27.0.app symlink, NOT the real - # Xcode_27_beta_4.app path: the beta number changes on every image + # Xcode_27_beta_N.app path: the beta number changes on every image # refresh and would silently break these lanes. # # tvOS uses "Apple TV 4K (3rd generation)" — this image has no plain - # "Apple TV" device, unlike macos-26 above. A wrong device name fails - # the simulator boot outright. + # "Apple TV" device. A wrong device name fails the simulator boot + # outright. - type: macos runs-on: xcode-27 xcode: "/Applications/Xcode_27.0.app" diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 0bf75601..52964c58 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -2,7 +2,10 @@ name: Examples on: pull_request: - branches: [main] + # Match MistKit.yml full-matrix bases: main and semver release branches (e.g. v1.0.0-beta.5). + branches: + - main + - 'v*.*.*' concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} @@ -15,7 +18,8 @@ jobs: test-examples: name: Test ${{ matrix.example }} on Ubuntu runs-on: ubuntu-latest - # BushelCloud / CelestraCloud declare swift-tools-version: 6.4; MistDemo is 6.2. + # BushelCloud / CelestraCloud / MistKitConfiguration declare swift-tools-version: 6.4; + # MistDemo is 6.2 but needs a 6.4 toolchain to resolve MistKitConfiguration. # Swift 6.4 has no release image yet — use the nightly (same pin as the subrepos). container: ${{ matrix.container }} if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} @@ -24,10 +28,16 @@ jobs: matrix: include: - example: MistDemo - container: swift:6.3 + path: Examples/MistDemo + container: swiftlang/swift:nightly-6.4.x-noble - example: BushelCloud + path: Examples/BushelCloud container: swiftlang/swift:nightly-6.4.x-noble - example: CelestraCloud + path: Examples/CelestraCloud + container: swiftlang/swift:nightly-6.4.x-noble + - example: MistKitConfiguration + path: Packages/MistKitConfiguration container: swiftlang/swift:nightly-6.4.x-noble steps: @@ -37,4 +47,7 @@ jobs: - name: Build and Test ${{ matrix.example }} uses: brightdigit/swift-build@v1 with: - working-directory: Examples/${{ matrix.example }} + working-directory: ${{ matrix.path }} + # MistKitConfiguration gitignores Package.resolved (standalone CI uses the + # same flag). Examples that commit a lockfile keep the default. + skip-package-resolved: ${{ matrix.example == 'MistKitConfiguration' }} diff --git a/Examples/BushelCloud/.env.example b/Examples/BushelCloud/.env.example index b89a0644..4d71a40a 100644 --- a/Examples/BushelCloud/.env.example +++ b/Examples/BushelCloud/.env.example @@ -27,7 +27,7 @@ CLOUDKIT_DATABASE=public # Server-to-Server Key ID # Get this from: CloudKit Dashboard → API Access → Server-to-Server Keys -# Format: 32-character hexadecimal string +# Format: 64-character hexadecimal string CLOUDKIT_KEY_ID=your-key-id-here # Path to Private Key (.pem file) diff --git a/Examples/BushelCloud/.github/CLOUDKIT_SYNC_SETUP.md b/Examples/BushelCloud/.github/CLOUDKIT_SYNC_SETUP.md index b21183a1..4d4c5e28 100644 --- a/Examples/BushelCloud/.github/CLOUDKIT_SYNC_SETUP.md +++ b/Examples/BushelCloud/.github/CLOUDKIT_SYNC_SETUP.md @@ -18,7 +18,7 @@ This document explains how to configure the scheduled CloudKit sync workflow. 4. Click **+** to create a new key 5. Download the `.pem` file immediately (you can only download it once) 6. Save the file securely (e.g., `~/Downloads/AuthKey_XXXXXXXXXX.pem`) -7. Note the **Key ID** (32-character hex string) +7. Note the **Key ID** (64-character hex string) ### 2. Add GitHub Secrets @@ -31,7 +31,7 @@ This document explains how to configure the scheduled CloudKit sync workflow. #### Add CLOUDKIT_KEY_ID - **Name:** `CLOUDKIT_KEY_ID` -- **Value:** Your 32-character key ID from step 1.7 +- **Value:** Your 64-character key ID from step 1.7 - Click **Add secret** #### Add CLOUDKIT_PRIVATE_KEY diff --git a/Examples/BushelCloud/.github/SECRETS_SETUP.md b/Examples/BushelCloud/.github/SECRETS_SETUP.md index 4eeb847c..bcfac175 100644 --- a/Examples/BushelCloud/.github/SECRETS_SETUP.md +++ b/Examples/BushelCloud/.github/SECRETS_SETUP.md @@ -11,7 +11,7 @@ Before adding secrets, you need a CloudKit Server-to-Server key: 3. Navigate to: **API Access → Server-to-Server Keys** 4. Click **+** to create a new key 5. Download the `.pem` file (you can only download once!) -6. Copy the Key ID (32-character hex string) +6. Copy the Key ID (64-character hex string) ## Required Secrets @@ -24,7 +24,7 @@ You need to add **2 secrets** to your GitHub repository: **Secret configuration:** - **Name:** `CLOUDKIT_KEY_ID` -- **Value:** Your 32-character key ID from CloudKit Dashboard +- **Value:** Your 64-character key ID from CloudKit Dashboard - **Example:** `a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6` ### 2. CLOUDKIT_PRIVATE_KEY @@ -90,7 +90,7 @@ If you see authentication errors, double-check: | Secret Name | Where to Get It | Format | |-------------|-----------------|--------| -| `CLOUDKIT_KEY_ID` | CloudKit Dashboard → API Access → Server-to-Server Keys | 32-character hex (e.g., `a1b2c3...`) | +| `CLOUDKIT_KEY_ID` | CloudKit Dashboard → API Access → Server-to-Server Keys | 64-character hex (e.g., `a1b2c3...`) | | `CLOUDKIT_PRIVATE_KEY` | Downloaded `.pem` file | Multi-line PEM format with headers | ## Next Steps diff --git a/Examples/BushelCloud/Package.resolved b/Examples/BushelCloud/Package.resolved index a0b1ae4d..42551b5f 100644 --- a/Examples/BushelCloud/Package.resolved +++ b/Examples/BushelCloud/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "3c7d07d5315530d1c529dcc7daee1416f65395f6a3adbfe70dd188c9db4f0ae2", + "originHash" : "74ffa954d191ccc211069191b2e6aad419590228dbea160058cae815c9d9236d", "pins" : [ { "identity" : "bushelkit", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/brightdigit/ConfigKeyKit.git", "state" : { - "revision" : "6949abb4b7f3e50f7f81d668e6c11aa4721e97fd", - "version" : "1.0.0-beta.2" + "revision" : "3c8ae3825b4cdcbf60fb1adeaaaf324557e7fd41", + "version" : "1.0.0-beta.3" } }, { diff --git a/Examples/BushelCloud/Package.swift b/Examples/BushelCloud/Package.swift index 7e355388..92048ca0 100644 --- a/Examples/BushelCloud/Package.swift +++ b/Examples/BushelCloud/Package.swift @@ -96,6 +96,9 @@ let package = Package( // this is a tagged remote release; this one-line overlay is reapplied when // the branch is recreated from main (never merged, so it never conflicts). .package(name: "MistKit", path: "../.."), + // Monorepo dogfood overlay — publishable consumers use a tagged `from:` once + // MistKitConfiguration is released. + .package(path: "../../Packages/MistKitConfiguration"), .package(url: "https://github.com/brightdigit/ConfigKeyKit.git", from: "1.0.0-beta.2"), .package(url: "https://github.com/brightdigit/BushelKit.git", from: "3.0.0-alpha.4"), .package(url: "https://github.com/brightdigit/IPSWDownloads.git", from: "1.0.0"), @@ -112,6 +115,7 @@ let package = Package( dependencies: [ .product(name: "ConfigKeyKit", package: "ConfigKeyKit"), .product(name: "MistKit", package: "MistKit"), + .product(name: "MistKitConfiguration", package: "MistKitConfiguration"), .product(name: "BushelLogging", package: "BushelKit"), .product(name: "BushelFoundation", package: "BushelKit"), .product(name: "BushelUtilities", package: "BushelKit"), @@ -125,14 +129,16 @@ let package = Package( .executableTarget( name: "BushelCloudCLI", dependencies: [ - .target(name: "BushelCloudKit") + .target(name: "BushelCloudKit"), + .product(name: "MistKitConfiguration", package: "MistKitConfiguration"), ], swiftSettings: swiftSettings ), .testTarget( name: "BushelCloudKitTests", dependencies: [ - .target(name: "BushelCloudKit") + .target(name: "BushelCloudKit"), + .product(name: "MistKitConfiguration", package: "MistKitConfiguration"), ], swiftSettings: swiftSettings ) diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ClearCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ClearCommand.swift index 8141509c..cd57d961 100644 --- a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ClearCommand.swift +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ClearCommand.swift @@ -31,6 +31,7 @@ internal import BushelCloudKit internal import BushelFoundation internal import BushelUtilities internal import Foundation +internal import MistKitConfiguration internal enum ClearCommand { internal static func run(_ args: [String]) async throws { @@ -59,21 +60,8 @@ internal enum ClearCommand { } } - // Determine authentication method - let authMethod: CloudKitAuthMethod - if let pemString = config.cloudKit.privateKey { - authMethod = .pemString(pemString) - } else { - authMethod = .pemFile(path: config.cloudKit.privateKeyPath) - } - // Create sync engine - let syncEngine = try SyncEngine( - containerIdentifier: config.cloudKit.containerID, - keyID: config.cloudKit.keyID, - authMethod: authMethod, - environment: config.cloudKit.environment - ) + let syncEngine = try SyncEngine(cloudKit: config.cloudKit) // Execute clear do { diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ExportCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ExportCommand.swift index 8dc22875..985f2a98 100644 --- a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ExportCommand.swift +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ExportCommand.swift @@ -30,6 +30,7 @@ internal import BushelCloudKit internal import BushelFoundation internal import Foundation +internal import MistKitConfiguration internal import MistKit internal enum ExportCommand { @@ -70,21 +71,8 @@ internal enum ExportCommand { // Enable verbose console output if requested ConsoleOutput.isVerbose = config.export?.verbose ?? false - // Determine authentication method - let authMethod: CloudKitAuthMethod - if let pemString = config.cloudKit.privateKey { - authMethod = .pemString(pemString) - } else { - authMethod = .pemFile(path: config.cloudKit.privateKeyPath) - } - // Create sync engine - let syncEngine = try SyncEngine( - containerIdentifier: config.cloudKit.containerID, - keyID: config.cloudKit.keyID, - authMethod: authMethod, - environment: config.cloudKit.environment - ) + let syncEngine = try SyncEngine(cloudKit: config.cloudKit) // Execute export do { diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ListCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ListCommand.swift index 51b2aad8..c6cf2006 100644 --- a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ListCommand.swift +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ListCommand.swift @@ -30,6 +30,7 @@ internal import BushelCloudKit internal import BushelFoundation internal import Foundation +internal import MistKitConfiguration internal import MistKit internal enum ListCommand { @@ -40,22 +41,7 @@ internal enum ListCommand { let config = try rawConfig.validated() // Create CloudKit service - let cloudKitService: BushelCloudKitService - if let pemString = config.cloudKit.privateKey { - cloudKitService = try BushelCloudKitService( - containerIdentifier: config.cloudKit.containerID, - keyID: config.cloudKit.keyID, - pemString: pemString, - environment: config.cloudKit.environment - ) - } else { - cloudKitService = try BushelCloudKitService( - containerIdentifier: config.cloudKit.containerID, - keyID: config.cloudKit.keyID, - privateKeyPath: config.cloudKit.privateKeyPath, - environment: config.cloudKit.environment - ) - } + let cloudKitService = try BushelCloudKitService(config.cloudKit) // Determine what to list based on flags let listConfig = config.list diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/StatusCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/StatusCommand.swift index 1ae93ae3..60820cff 100644 --- a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/StatusCommand.swift +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/StatusCommand.swift @@ -30,6 +30,7 @@ internal import BushelCloudKit internal import BushelFoundation internal import Foundation +internal import MistKitConfiguration internal import MistKit internal enum StatusCommand { @@ -40,22 +41,7 @@ internal enum StatusCommand { let config = try rawConfig.validated() // Create CloudKit service - let cloudKitService: BushelCloudKitService - if let pemString = config.cloudKit.privateKey { - cloudKitService = try BushelCloudKitService( - containerIdentifier: config.cloudKit.containerID, - keyID: config.cloudKit.keyID, - pemString: pemString, - environment: config.cloudKit.environment - ) - } else { - cloudKitService = try BushelCloudKitService( - containerIdentifier: config.cloudKit.containerID, - keyID: config.cloudKit.keyID, - privateKeyPath: config.cloudKit.privateKeyPath, - environment: config.cloudKit.environment - ) - } + let cloudKitService = try BushelCloudKitService(config.cloudKit) // Load configuration to show intervals let configuration = config.fetch ?? FetchConfiguration.loadFromEnvironment() diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/SyncCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/SyncCommand.swift index 77665890..84816d1d 100644 --- a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/SyncCommand.swift +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/SyncCommand.swift @@ -31,6 +31,7 @@ internal import BushelCloudKit internal import BushelFoundation internal import BushelUtilities internal import Foundation +internal import MistKitConfiguration internal enum SyncCommand { internal static func run(_ args: [String]) async throws { @@ -48,20 +49,9 @@ internal enum SyncCommand { // Get fetch configuration (already loaded by ConfigurationLoader) let fetchConfiguration = config.fetch ?? FetchConfiguration.loadFromEnvironment() - // Determine authentication method - let authMethod: CloudKitAuthMethod - if let pemString = config.cloudKit.privateKey { - authMethod = .pemString(pemString) - } else { - authMethod = .pemFile(path: config.cloudKit.privateKeyPath) - } - // Create sync engine let syncEngine = try SyncEngine( - containerIdentifier: config.cloudKit.containerID, - keyID: config.cloudKit.keyID, - authMethod: authMethod, - environment: config.cloudKit.environment, + cloudKit: config.cloudKit, configuration: fetchConfiguration ) diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift index 7012ce08..ca587490 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift @@ -32,6 +32,7 @@ public import BushelLogging public import Foundation internal import Logging public import MistKit +public import MistKitConfiguration #if canImport(FelinePineSwift) internal import FelinePineSwift @@ -63,91 +64,15 @@ public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCol // MARK: - Initialization - /// Initialize CloudKit service with Server-to-Server authentication + /// Initialize from already-validated CloudKit credentials. /// - /// **MistKit Pattern**: Server-to-Server authentication requires: - /// 1. Key ID from CloudKit Dashboard → API Access → Server-to-Server Keys - /// 2. Private key .pem file downloaded when creating the key - /// 3. Container identifier (begins with "iCloud.") + /// Validation (presence + key ID / PEM format) happens in + /// ``ValidatedCloudKitConfiguration``; this wrapper only builds the MistKit service. /// - /// - Parameters: - /// - containerIdentifier: CloudKit container ID (e.g., "iCloud.com.company.App") - /// - keyID: Server-to-Server Key ID from CloudKit Dashboard - /// - privateKeyPath: Path to the private key .pem file - /// - environment: CloudKit environment (.development or .production, defaults to .development) - /// - Throws: Error if the private key file cannot be read or is invalid - public init( - containerIdentifier: String, - keyID: String, - privateKeyPath: String, - environment: Environment = .development - ) throws { - // Validate Key ID format before any file IO - try KeyIDValidator.validate(keyID) - - // Read PEM file from disk - guard FileManager.default.fileExists(atPath: privateKeyPath) else { - throw BushelCloudKitError.privateKeyFileNotFound(path: privateKeyPath) - } - - let pemString: String - do { - pemString = try String(contentsOfFile: privateKeyPath, encoding: .utf8) - } catch { - throw BushelCloudKitError.privateKeyFileReadFailed(path: privateKeyPath, error: error) - } - - // Validate PEM format before using it - try PEMValidator.validate(pemString) - - // Create Server-to-Server authentication manager - let tokenManager = try ServerToServerAuthManager( - keyID: keyID, - pemString: pemString - ) - - self.service = CloudKitService( - containerIdentifier: containerIdentifier, - tokenManager: tokenManager, - environment: environment - ) - } - - /// Initialize CloudKit service with Server-to-Server authentication using PEM string - /// - /// **CI/CD Pattern**: This initializer accepts PEM content directly from environment variables, - /// eliminating the need for temporary file creation in GitHub Actions or other CI/CD environments. - /// - /// - Parameters: - /// - containerIdentifier: CloudKit container ID (e.g., "iCloud.com.company.App") - /// - keyID: Server-to-Server Key ID from CloudKit Dashboard - /// - pemString: PEM file content as string (including headers/footers) - /// - environment: CloudKit environment (.development or .production, defaults to .development) - /// - Throws: Error if PEM string is invalid or authentication fails - public init( - containerIdentifier: String, - keyID: String, - pemString: String, - environment: Environment = .development - ) throws { - // Validate Key ID format before any cryptographic work - try KeyIDValidator.validate(keyID) - - // Validate PEM format BEFORE passing to MistKit - // This provides better error messages than MistKit's internal validation - try PEMValidator.validate(pemString) - - // Create Server-to-Server authentication manager directly from PEM string - let tokenManager = try ServerToServerAuthManager( - keyID: keyID, - pemString: pemString - ) - - self.service = CloudKitService( - containerIdentifier: containerIdentifier, - tokenManager: tokenManager, - environment: environment - ) + /// - Parameter cloudKit: Validated server-to-server credentials. + /// - Throws: `CredentialsValidationError` if MistKit rejects the credentials. + public init(_ cloudKit: ValidatedCloudKitConfiguration) throws { + self.service = try cloudKit.makeCloudKitService() } // MARK: - RecordManaging Protocol Requirements diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/KeyIDValidator.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/KeyIDValidator.swift deleted file mode 100644 index 63de7bb8..00000000 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/KeyIDValidator.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// KeyIDValidator.swift -// BushelCloud -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Foundation - -/// Validates CloudKit Server-to-Server Key ID format -internal enum KeyIDValidator { - private static let allowedCharacters = CharacterSet( - charactersIn: "0123456789abcdefABCDEF" - ) - - /// Validates that a key ID has the expected CloudKit S2S format. - /// - /// CloudKit Server-to-Server keys are SHA-256 fingerprints of the public key: - /// 64 lowercase hex characters. We accept upper- or lower-case to be lenient - /// about copy/paste from the dashboard. - /// - /// - Parameter keyID: The key ID to validate. - /// - Throws: `BushelCloudKitError.invalidKeyID` with a specific reason and suggestion. - internal static func validate(_ keyID: String) throws { - let trimmed = keyID.trimmingCharacters(in: .whitespacesAndNewlines) - - guard !trimmed.isEmpty else { - throw BushelCloudKitError.invalidKeyID( - reason: "Key ID is empty", - suggestion: """ - Set CLOUDKIT_KEY_ID to the Server-to-Server key ID from the CloudKit \ - Dashboard (a 64-character hex string). - """ - ) - } - - guard trimmed == keyID else { - throw BushelCloudKitError.invalidKeyID( - reason: "Key ID has surrounding whitespace", - suggestion: """ - Trim leading/trailing whitespace from CLOUDKIT_KEY_ID. Common cause: \ - accidental newline or space when copying from the dashboard. - """ - ) - } - - guard trimmed.count == 64 else { - throw BushelCloudKitError.invalidKeyID( - reason: "Key ID must be 64 characters (got \(trimmed.count))", - suggestion: """ - CloudKit Server-to-Server keys are 64-character hex strings. \ - Re-copy the full key ID from the CloudKit Dashboard. - """ - ) - } - - guard trimmed.unicodeScalars.allSatisfy(allowedCharacters.contains) else { - throw BushelCloudKitError.invalidKeyID( - reason: "Key ID contains non-hex characters", - suggestion: """ - The key ID should be hex (0-9, a-f). Verify you copied the Key ID — \ - not the key name or container ID — from the CloudKit Dashboard. - """ - ) - } - } -} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/PEMValidator.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/PEMValidator.swift deleted file mode 100644 index 6512f581..00000000 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/PEMValidator.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// PEMValidator.swift -// BushelCloud -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Foundation - -/// Validates PEM format for CloudKit Server-to-Server private keys -internal enum PEMValidator { - /// Validates a PEM string has proper structure and encoding - /// - /// **Checks performed:** - /// 1. Contains BEGIN PRIVATE KEY header - /// 2. Contains END PRIVATE KEY footer - /// 3. Has content between headers - /// 4. Content is valid base64 - /// - /// **Why validate?** - /// - Provides clear error messages before attempting CloudKit operations - /// - Catches common copy/paste errors (truncation, missing markers) - /// - Prevents cryptic errors from MistKit's ServerToServerAuthManager - /// - /// - Parameter pemString: The PEM-formatted private key string - /// - Throws: BushelCloudKitError.invalidPEMFormat with specific reason and recovery suggestion - internal static func validate(_ pemString: String) throws { - let trimmed = pemString.trimmingCharacters(in: .whitespacesAndNewlines) - - // Check for BEGIN header - guard trimmed.contains("-----BEGIN") && trimmed.contains("PRIVATE KEY-----") else { - throw BushelCloudKitError.invalidPEMFormat( - reason: "Missing '-----BEGIN PRIVATE KEY-----' header", - suggestion: """ - Ensure you copied the entire PEM file including the header line. \ - Re-download from CloudKit Dashboard if needed. - """ - ) - } - - // Check for END footer - guard trimmed.contains("-----END") && trimmed.contains("PRIVATE KEY-----") else { - throw BushelCloudKitError.invalidPEMFormat( - reason: "Missing '-----END PRIVATE KEY-----' footer", - suggestion: """ - The PEM file may have been truncated during copy/paste. \ - Ensure you copied the entire file including the footer line. - """ - ) - } - - // Extract content between headers - let lines = trimmed.components(separatedBy: .newlines) - let contentLines = lines.filter { line in - !line.contains("BEGIN") && !line.contains("END") && !line.isEmpty - } - - guard !contentLines.isEmpty else { - throw BushelCloudKitError.invalidPEMFormat( - reason: "PEM file contains no key data between headers", - suggestion: "The key file may be corrupted or empty. Re-download from CloudKit Dashboard." - ) - } - - // Validate base64 encoding - let base64Content = contentLines.joined() - guard Data(base64Encoded: base64Content) != nil else { - throw BushelCloudKitError.invalidPEMFormat( - reason: "PEM content is not valid base64 encoding", - suggestion: """ - The key file may be corrupted. \ - Ensure you used a text editor (not binary editor) and the file is UTF-8 encoded. - """ - ) - } - } -} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine.swift index 76ea2efb..03eca034 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine.swift @@ -33,6 +33,7 @@ public import BushelUtilities public import Foundation internal import Logging public import MistKit +public import MistKitConfiguration #if canImport(FelinePineSwift) internal import FelinePineSwift @@ -128,50 +129,17 @@ public struct SyncEngine: Sendable { // MARK: - Initialization - /// Initialize sync engine with CloudKit credentials - /// - /// **Flexible Authentication**: Supports both file-based and string-based PEM content: - /// - `.pemString`: For CI/CD environments (GitHub Actions secrets) - /// - `.pemFile`: For local development (file on disk) - /// - /// **Environment Separation**: Use separate keys for development and production: - /// - Development: Safe for testing, free API calls, can clear data freely - /// - Production: Real user data, requires careful key management + /// Initialize sync engine with validated CloudKit credentials. /// /// - Parameters: - /// - containerIdentifier: CloudKit container ID - /// - keyID: Server-to-Server Key ID - /// - authMethod: Authentication method (`.pemString` or `.pemFile`) - /// - environment: CloudKit environment (.development or .production, defaults to .development) - /// - configuration: Fetch configuration for data sources - /// - Throws: Error if authentication credentials are invalid or missing + /// - cloudKit: Validated server-to-server credentials. + /// - configuration: Fetch configuration for data sources. + /// - Throws: Error if MistKit rejects the credentials. public init( - containerIdentifier: String, - keyID: String, - authMethod: CloudKitAuthMethod, - environment: Environment = .development, + cloudKit: ValidatedCloudKitConfiguration, configuration: FetchConfiguration = FetchConfiguration.loadFromEnvironment() ) throws { - // Initialize CloudKit service based on auth method - let service: BushelCloudKitService - switch authMethod { - case .pemString(let pem): - service = try BushelCloudKitService( - containerIdentifier: containerIdentifier, - keyID: keyID, - pemString: pem, - environment: environment - ) - case .pemFile(let path): - service = try BushelCloudKitService( - containerIdentifier: containerIdentifier, - keyID: keyID, - privateKeyPath: path, - environment: environment - ) - } - - self.cloudKitService = service + self.cloudKitService = try BushelCloudKitService(cloudKit) self.pipeline = DataSourcePipeline( configuration: configuration ) diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/BushelConfiguration.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/BushelConfiguration.swift index 6d3978de..8fcf1e19 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/BushelConfiguration.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/BushelConfiguration.swift @@ -28,21 +28,8 @@ // public import BushelFoundation -internal import Foundation -internal import MistKit - -// MARK: - Configuration Error - -/// Errors that can occur during configuration validation -public struct ConfigurationError: Error, Sendable { - public let message: String - public let key: String? - - public init(_ message: String, key: String? = nil) { - self.message = message - self.key = key - } -} +public import Foundation +public import MistKitConfiguration // MARK: - Root Configuration @@ -79,11 +66,17 @@ public struct BushelConfiguration: Sendable { /// Validate that all required fields are present public func validated() throws -> ValidatedBushelConfiguration { - guard let cloudKit = cloudKit else { + guard let cloudKit else { throw ConfigurationError("CloudKit configuration required", key: "cloudkit") } + let validatedCloudKit: ValidatedCloudKitConfiguration + do { + validatedCloudKit = try cloudKit.validated() + } catch let error as CloudKitConfigurationError { + throw error.map(keys: ConfigurationKeys.cloudKit) + } return ValidatedBushelConfiguration( - cloudKit: try cloudKit.validated(), + cloudKit: validatedCloudKit, virtualBuddy: virtualBuddy, fetch: fetch, sync: sync, diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfiguration.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfiguration.swift deleted file mode 100644 index 077cdb4f..00000000 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfiguration.swift +++ /dev/null @@ -1,150 +0,0 @@ -// -// CloudKitConfiguration.swift -// BushelCloud -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Foundation -public import MistKit - -// MARK: - CloudKit Configuration - -/// CloudKit Server-to-Server authentication configuration -public struct CloudKitConfiguration: Sendable { - public var containerID: String? - public var keyID: String? - public var privateKeyPath: String? - public var privateKey: String? // Raw PEM string for CI/CD - public var environment: String? // "development" or "production" - - public init( - containerID: String? = nil, - keyID: String? = nil, - privateKeyPath: String? = nil, - privateKey: String? = nil, - environment: String? = nil - ) { - self.containerID = containerID - self.keyID = keyID - self.privateKeyPath = privateKeyPath - self.privateKey = privateKey - self.environment = environment - } - - /// Validate that all required CloudKit fields are present - public func validated() throws -> ValidatedCloudKitConfiguration { - try ValidatedCloudKitConfiguration(from: self) - } -} - -/// Validated CloudKit configuration with non-optional fields -public struct ValidatedCloudKitConfiguration: Sendable { - public let containerID: String - public let keyID: String - public let privateKeyPath: String // Can be empty if privateKey is used - public let privateKey: String? // Optional (only one method required) - public let environment: MistKit.Environment - - public init(from config: CloudKitConfiguration) throws { - // Validate container ID - guard let containerID = config.containerID, !containerID.isEmpty else { - throw ConfigurationError( - "CloudKit container ID required. Set CLOUDKIT_CONTAINER_ID or use --cloudkit-container-id", - key: "cloudkit.container_id" - ) - } - - // Validate key ID - guard let keyID = config.keyID, !keyID.isEmpty else { - throw ConfigurationError( - "CloudKit key ID required. Set CLOUDKIT_KEY_ID or use --cloudkit-key-id", - key: "cloudkit.key_id" - ) - } - - // Validate at least ONE credential method is provided (NOT both required) - let trimmedPrivateKey = config.privateKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let trimmedPrivateKeyPath = - config.privateKeyPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - - let hasPrivateKey = !trimmedPrivateKey.isEmpty - let hasPrivateKeyPath = !trimmedPrivateKeyPath.isEmpty - - guard hasPrivateKey || hasPrivateKeyPath else { - throw ConfigurationError( - "Either CLOUDKIT_PRIVATE_KEY or CLOUDKIT_PRIVATE_KEY_PATH must be provided", - key: "cloudkit.private_key" - ) - } - - // Parse environment string to enum (case-insensitive for user convenience) - let environmentString = (config.environment ?? "development") - .lowercased() - .trimmingCharacters(in: .whitespacesAndNewlines) - - guard let parsedEnvironment = MistKit.Environment(rawValue: environmentString) else { - throw ConfigurationError( - """ - Invalid CLOUDKIT_ENVIRONMENT: '\(config.environment ?? "")'. \ - Must be 'development' or 'production' - """, - key: "cloudkit.environment" - ) - } - - self.containerID = containerID - self.keyID = keyID - self.privateKeyPath = hasPrivateKeyPath ? trimmedPrivateKeyPath : "" - self.privateKey = hasPrivateKey ? trimmedPrivateKey : nil - self.environment = parsedEnvironment - } - - // Legacy initializer for backward compatibility (if needed by tests) - public init( - containerID: String, - keyID: String, - privateKeyPath: String, - privateKey: String? = nil, - environment: MistKit.Environment - ) { - self.containerID = containerID - self.keyID = keyID - self.privateKeyPath = privateKeyPath - self.privateKey = privateKey - self.environment = environment - } -} - -// MARK: - VirtualBuddy Configuration - -/// VirtualBuddy TSS API configuration -public struct VirtualBuddyConfiguration: Sendable { - public var apiKey: String? - - public init(apiKey: String? = nil) { - self.apiKey = apiKey - } -} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfigurationError+Mapping.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfigurationError+Mapping.swift new file mode 100644 index 00000000..bd18c58e --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfigurationError+Mapping.swift @@ -0,0 +1,77 @@ +// +// CloudKitConfigurationError+Mapping.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit +public import MistKitConfiguration + +extension CloudKitConfigurationError { + /// Maps a package error onto Bushel's presentation wording and key names. + public func map(keys: CloudKitConfigurationKeys) -> ConfigurationError { + switch self { + case .missing(.containerID): + ConfigurationError( + "CloudKit container ID required. Set CLOUDKIT_CONTAINER_ID or use --cloudkit-container-id", + key: keys.containerID.base + ) + case .missing(.keyID): + ConfigurationError( + "CloudKit key ID required. Set CLOUDKIT_KEY_ID or use --cloudkit-key-id", + key: keys.keyID.base + ) + case .missing(.privateKey), .missing(.privateKeyPath): + ConfigurationError( + "Either CLOUDKIT_PRIVATE_KEY or CLOUDKIT_PRIVATE_KEY_PATH must be provided", + key: keys.privateKey.base + ) + case .missing(.environment): + ConfigurationError( + "CloudKit environment must be 'development' or 'production'", + key: keys.environment.base + ) + case .invalidKeyID(let failure): + ConfigurationError( + "Invalid CloudKit Server-to-Server Key ID: \(String(describing: failure))", + key: keys.keyID.base + ) + case .invalidPrivateKey(let failure): + ConfigurationError( + "Invalid PEM format: \(String(describing: failure))", + key: keys.privateKey.base + ) + case .unrecognizedEnvironment(let raw): + ConfigurationError( + """ + Invalid CLOUDKIT_ENVIRONMENT: '\(raw)'. \ + Must be 'development' or 'production' + """, + key: keys.environment.base + ) + } + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationKeys.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationKeys.swift index ae75d0f7..7ce98b1d 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationKeys.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationKeys.swift @@ -29,37 +29,17 @@ internal import ConfigKeyKit internal import Foundation +internal import MistKitConfiguration /// Configuration keys for reading from providers internal enum ConfigurationKeys { - // MARK: - CloudKit Configuration + /// Default CloudKit container for Bushel. + internal static let defaultContainerID = "iCloud.com.brightdigit.Bushel" - /// CloudKit configuration keys - /// - /// Auto-generates environment variable names from the key path. - /// Example: "cloudkit.container_id" → ENV: CLOUDKIT_CONTAINER_ID - internal enum CloudKit { - internal static let containerID = ConfigKey( - "cloudkit.container_id", - default: "iCloud.com.brightdigit.Bushel" - ) - - internal static let keyID = OptionalConfigKey( - "cloudkit.key_id" - ) - - internal static let privateKeyPath = OptionalConfigKey( - "cloudkit.private_key_path" - ) - - internal static let privateKey = OptionalConfigKey( - "cloudkit.private_key" - ) - - internal static let environment = OptionalConfigKey( - "cloudkit.environment" - ) - } + /// CloudKit credential keys with Bushel's container default. + internal static let cloudKit = CloudKitConfigurationKeys( + defaultContainerID: defaultContainerID + ) // MARK: - VirtualBuddy Configuration @@ -68,7 +48,7 @@ internal enum ConfigurationKeys { /// Auto-generates ENV names (VIRTUALBUDDY_API_KEY). internal enum VirtualBuddy { internal static let apiKey = OptionalConfigKey( - "virtualbuddy.api_key" + "virtualbuddy.api-key" ) } @@ -77,7 +57,7 @@ internal enum ConfigurationKeys { /// Fetch throttling configuration keys /// /// Uses `bushelPrefixed:` to add BUSHEL_ prefix to all environment variables. - /// Example: "fetch.interval_global" → ENV: BUSHEL_FETCH_INTERVAL_GLOBAL + /// Example: "fetch.interval-global" → ENV: BUSHEL_FETCH_INTERVAL_GLOBAL internal enum Fetch { /// Generate per-source interval key dynamically /// - Parameter source: Data source identifier (e.g., "appledb.dev") @@ -96,20 +76,20 @@ internal enum ConfigurationKeys { /// /// Uses `bushelPrefixed:` for BUSHEL_SYNC_* environment variables. internal enum Sync { - internal static let dryRun = ConfigKey(bushelPrefixed: "sync.dry_run") + internal static let dryRun = ConfigKey(bushelPrefixed: "sync.dry-run") internal static let restoreImagesOnly = ConfigKey( - bushelPrefixed: "sync.restore_images_only" + bushelPrefixed: "sync.restore-images-only" ) - internal static let xcodeOnly = ConfigKey(bushelPrefixed: "sync.xcode_only") - internal static let swiftOnly = ConfigKey(bushelPrefixed: "sync.swift_only") - internal static let noBetas = ConfigKey(bushelPrefixed: "sync.no_betas") - internal static let noAppleWiki = ConfigKey(bushelPrefixed: "sync.no_apple_wiki") + internal static let xcodeOnly = ConfigKey(bushelPrefixed: "sync.xcode-only") + internal static let swiftOnly = ConfigKey(bushelPrefixed: "sync.swift-only") + internal static let noBetas = ConfigKey(bushelPrefixed: "sync.no-betas") + internal static let noAppleWiki = ConfigKey(bushelPrefixed: "sync.no-apple-wiki") internal static let verbose = ConfigKey(bushelPrefixed: "sync.verbose") internal static let force = ConfigKey(bushelPrefixed: "sync.force") - internal static let minInterval = OptionalConfigKey(bushelPrefixed: "sync.min_interval") + internal static let minInterval = OptionalConfigKey(bushelPrefixed: "sync.min-interval") internal static let source = OptionalConfigKey(bushelPrefixed: "sync.source") internal static let jsonOutputFile = OptionalConfigKey( - bushelPrefixed: "sync.json_output_file") + bushelPrefixed: "sync.json-output-file") } // MARK: - Export Command Configuration @@ -120,8 +100,8 @@ internal enum ConfigurationKeys { internal enum Export { internal static let output = OptionalConfigKey(bushelPrefixed: "export.output") internal static let pretty = ConfigKey(bushelPrefixed: "export.pretty") - internal static let signedOnly = ConfigKey(bushelPrefixed: "export.signed_only") - internal static let noBetas = ConfigKey(bushelPrefixed: "export.no_betas") + internal static let signedOnly = ConfigKey(bushelPrefixed: "export.signed-only") + internal static let noBetas = ConfigKey(bushelPrefixed: "export.no-betas") internal static let verbose = ConfigKey(bushelPrefixed: "export.verbose") } @@ -131,7 +111,7 @@ internal enum ConfigurationKeys { /// /// Uses `bushelPrefixed:` for BUSHEL_STATUS_* environment variables. internal enum Status { - internal static let errorsOnly = ConfigKey(bushelPrefixed: "status.errors_only") + internal static let errorsOnly = ConfigKey(bushelPrefixed: "status.errors-only") internal static let detailed = ConfigKey(bushelPrefixed: "status.detailed") } @@ -141,9 +121,9 @@ internal enum ConfigurationKeys { /// /// Uses `bushelPrefixed:` for BUSHEL_LIST_* environment variables. internal enum List { - internal static let restoreImages = ConfigKey(bushelPrefixed: "list.restore_images") - internal static let xcodeVersions = ConfigKey(bushelPrefixed: "list.xcode_versions") - internal static let swiftVersions = ConfigKey(bushelPrefixed: "list.swift_versions") + internal static let restoreImages = ConfigKey(bushelPrefixed: "list.restore-images") + internal static let xcodeVersions = ConfigKey(bushelPrefixed: "list.xcode-versions") + internal static let swiftVersions = ConfigKey(bushelPrefixed: "list.swift-versions") } // MARK: - Clear Command Configuration diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader+Loading.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader+Loading.swift index 72fc729f..3214d1b4 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader+Loading.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader+Loading.swift @@ -28,33 +28,27 @@ // internal import BushelFoundation +internal import ConfigKeyKit internal import Foundation +internal import MistKitConfiguration // MARK: - Configuration Loading extension ConfigurationLoader { /// Load the complete configuration from all providers public func loadConfiguration() async throws -> BushelConfiguration { - // CloudKit configuration (automatic CLI → ENV → default fallback) - let cloudKit = CloudKitConfiguration( - containerID: read(ConfigurationKeys.CloudKit.containerID), - keyID: read(ConfigurationKeys.CloudKit.keyID), - privateKeyPath: read(ConfigurationKeys.CloudKit.privateKeyPath), - privateKey: read(ConfigurationKeys.CloudKit.privateKey), - // Default to development - environment: read(ConfigurationKeys.CloudKit.environment) ?? "development" - ) + let cloudKit = configReader.readCloudKitConfiguration(keys: ConfigurationKeys.cloudKit) // VirtualBuddy configuration let virtualBuddy = VirtualBuddyConfiguration( - apiKey: read(ConfigurationKeys.VirtualBuddy.apiKey) + apiKey: configReader.read(ConfigurationKeys.VirtualBuddy.apiKey) ) // Fetch configuration: Start with BushelKit's environment loading, then override with CLI var fetch = FetchConfiguration.loadFromEnvironment() // Override global interval if --min-interval provided - if let minInterval = read(ConfigurationKeys.Sync.minInterval) { + if let minInterval = configReader.read(ConfigurationKeys.Sync.minInterval) { fetch = FetchConfiguration( globalMinimumFetchInterval: TimeInterval(minInterval), perSourceIntervals: fetch.perSourceIntervals, @@ -69,7 +63,7 @@ extension ConfigurationLoader { // Try CLI arg first (e.g., "fetch.interval.appledb_dev") // Then try ENV var (e.g., "BUSHEL_FETCH_INTERVAL_APPLEDB_DEV") let intervalKey = ConfigurationKeys.Fetch.intervalKey(for: source.rawValue) - if let interval = read(intervalKey) { + if let interval = configReader.read(intervalKey) { perSourceIntervals[source.rawValue] = interval } } @@ -85,45 +79,45 @@ extension ConfigurationLoader { // Sync command configuration let sync = SyncConfiguration( - dryRun: read(ConfigurationKeys.Sync.dryRun), - restoreImagesOnly: read(ConfigurationKeys.Sync.restoreImagesOnly), - xcodeOnly: read(ConfigurationKeys.Sync.xcodeOnly), - swiftOnly: read(ConfigurationKeys.Sync.swiftOnly), - noBetas: read(ConfigurationKeys.Sync.noBetas), - noAppleWiki: read(ConfigurationKeys.Sync.noAppleWiki), - verbose: read(ConfigurationKeys.Sync.verbose), - force: read(ConfigurationKeys.Sync.force), - minInterval: read(ConfigurationKeys.Sync.minInterval), - source: read(ConfigurationKeys.Sync.source), - jsonOutputFile: read(ConfigurationKeys.Sync.jsonOutputFile) + dryRun: configReader.read(ConfigurationKeys.Sync.dryRun), + restoreImagesOnly: configReader.read(ConfigurationKeys.Sync.restoreImagesOnly), + xcodeOnly: configReader.read(ConfigurationKeys.Sync.xcodeOnly), + swiftOnly: configReader.read(ConfigurationKeys.Sync.swiftOnly), + noBetas: configReader.read(ConfigurationKeys.Sync.noBetas), + noAppleWiki: configReader.read(ConfigurationKeys.Sync.noAppleWiki), + verbose: configReader.read(ConfigurationKeys.Sync.verbose), + force: configReader.read(ConfigurationKeys.Sync.force), + minInterval: configReader.read(ConfigurationKeys.Sync.minInterval), + source: configReader.read(ConfigurationKeys.Sync.source), + jsonOutputFile: configReader.read(ConfigurationKeys.Sync.jsonOutputFile) ) // Export command configuration let export = ExportConfiguration( - output: read(ConfigurationKeys.Export.output), - pretty: read(ConfigurationKeys.Export.pretty), - signedOnly: read(ConfigurationKeys.Export.signedOnly), - noBetas: read(ConfigurationKeys.Export.noBetas), - verbose: read(ConfigurationKeys.Export.verbose) + output: configReader.read(ConfigurationKeys.Export.output), + pretty: configReader.read(ConfigurationKeys.Export.pretty), + signedOnly: configReader.read(ConfigurationKeys.Export.signedOnly), + noBetas: configReader.read(ConfigurationKeys.Export.noBetas), + verbose: configReader.read(ConfigurationKeys.Export.verbose) ) // Status command configuration let status = StatusConfiguration( - errorsOnly: read(ConfigurationKeys.Status.errorsOnly), - detailed: read(ConfigurationKeys.Status.detailed) + errorsOnly: configReader.read(ConfigurationKeys.Status.errorsOnly), + detailed: configReader.read(ConfigurationKeys.Status.detailed) ) // List command configuration let list = ListConfiguration( - restoreImages: read(ConfigurationKeys.List.restoreImages), - xcodeVersions: read(ConfigurationKeys.List.xcodeVersions), - swiftVersions: read(ConfigurationKeys.List.swiftVersions) + restoreImages: configReader.read(ConfigurationKeys.List.restoreImages), + xcodeVersions: configReader.read(ConfigurationKeys.List.xcodeVersions), + swiftVersions: configReader.read(ConfigurationKeys.List.swiftVersions) ) // Clear command configuration let clear = ClearConfiguration( - yes: read(ConfigurationKeys.Clear.yes), - verbose: read(ConfigurationKeys.Clear.verbose) + yes: configReader.read(ConfigurationKeys.Clear.yes), + verbose: configReader.read(ConfigurationKeys.Clear.verbose) ) return BushelConfiguration( diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader.swift index 1ff76754..72f8ada4 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader.swift @@ -30,148 +30,27 @@ internal import ConfigKeyKit internal import Configuration internal import Foundation +internal import MistKitConfiguration /// Actor responsible for loading configuration from CLI arguments and environment variables public actor ConfigurationLoader { - private let configReader: ConfigReader + internal let configReader: ConfigReader /// Initialize the configuration loader with command-line and environment providers public init() { - var providers: [any ConfigProvider] = [] - - // Priority 1: Command-line arguments (automatically parses all --key value and --flag arguments) - providers.append( - CommandLineArgumentsProvider( - secretsSpecifier: .specific([ - "--cloudkit-key-id", - "--cloudkit-private-key-path", - "--cloudkit-private-key", - "--virtualbuddy-api-key", - ]) - ) + self.configReader = ConfigurationSources.makeConfigReader( + secretCommandLineFlags: ConfigurationKeys.cloudKit.secretCommandLineFlags.union([ + "--virtualbuddy-api-key" + ]) ) - - // Priority 2: Environment variables - providers.append(EnvironmentVariablesProvider()) - - self.configReader = ConfigReader(providers: providers) - } - - #if DEBUG - /// Test-only initializer that accepts a pre-configured ConfigReader - /// - /// This allows tests to inject controlled configuration sources without - /// modifying process-global state (environment variables). - /// - /// - Parameter configReader: Pre-configured ConfigReader for testing - internal init(configReader: ConfigReader) { - self.configReader = configReader - } - #endif - - // MARK: - Helper Methods - - /// Read a string value from configuration - internal func readString(forKey key: String) -> String? { - configReader.string(forKey: ConfigKey(key)) } - /// Read an integer value from configuration - internal func readInt(forKey key: String) -> Int? { - guard let stringValue = configReader.string(forKey: ConfigKey(key)) else { - return nil - } - return Int(stringValue) - } - - /// Read a double value from configuration - internal func readDouble(forKey key: String) -> Double? { - guard let stringValue = configReader.string(forKey: ConfigKey(key)) else { - return nil - } - return Double(stringValue) - } - - // MARK: - Generic Helper Methods for ConfigKey (with defaults) - - /// Read a string value with automatic CLI → ENV → default fallback - /// Returns non-optional since ConfigKey has a required default - internal func read(_ key: ConfigKeyKit.ConfigKey) -> String { - for source in ConfigKeySource.allCases { - guard let keyString = key.key(for: source) else { continue } - if let value = readString(forKey: keyString) { - return value - } - } - return key.defaultValue // Non-optional! - } - - /// Read a boolean value with enhanced ENV variable parsing - /// - /// Returns non-optional since ConfigKey has a required default. - /// - /// Boolean parsing rules: - /// - CLI: Flag presence indicates true (e.g., --verbose) - /// - ENV: Accepts "true", "1", "yes" (case-insensitive) - /// - Empty string in ENV is treated as absent (falls back to default) + /// Creates a loader over a pre-configured reader. /// - /// - Parameter key: Configuration key with boolean type - /// - Returns: Boolean value from CLI/ENV or the key's default - internal func read(_ key: ConfigKeyKit.ConfigKey) -> Bool { - // Try CLI first (presence-based for flags) - if let cliKey = key.key(for: .commandLine), - configReader.string(forKey: ConfigKey(cliKey)) != nil - { - return true - } - - // Try ENV (may have string value like VERBOSE=true) - if let envKey = key.key(for: .environment), - let envValue = configReader.string(forKey: ConfigKey(envKey)) - { - let lowercased = envValue.lowercased().trimmingCharacters(in: .whitespaces) - return lowercased == "true" || lowercased == "1" || lowercased == "yes" - } - - // Use default value (non-optional) - return key.defaultValue - } - - // MARK: - Generic Helper Methods for OptionalConfigKey (without defaults) - - /// Read a string value with automatic CLI → ENV fallback - /// Returns optional since OptionalConfigKey has no default - internal func read(_ key: ConfigKeyKit.OptionalConfigKey) -> String? { - for source in ConfigKeySource.allCases { - guard let keyString = key.key(for: source) else { continue } - if let value = readString(forKey: keyString) { - return value - } - } - return nil // No default available - } - - /// Read an integer value with automatic CLI → ENV fallback - /// Returns optional since OptionalConfigKey has no default - internal func read(_ key: ConfigKeyKit.OptionalConfigKey) -> Int? { - for source in ConfigKeySource.allCases { - guard let keyString = key.key(for: source) else { continue } - if let value = readInt(forKey: keyString) { - return value - } - } - return nil // No default available - } - - /// Read a double value with automatic CLI → ENV fallback - /// Returns optional since OptionalConfigKey has no default - internal func read(_ key: ConfigKeyKit.OptionalConfigKey) -> Double? { - for source in ConfigKeySource.allCases { - guard let keyString = key.key(for: source) else { continue } - if let value = readDouble(forKey: keyString) { - return value - } - } - return nil // No default available + /// Lets tests inject controlled configuration sources instead of mutating + /// process-global state (environment variables). + /// - Parameter configReader: Pre-configured reader to read from. + internal init(configReader: ConfigReader) { + self.configReader = configReader } } diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/VirtualBuddyConfiguration.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/VirtualBuddyConfiguration.swift new file mode 100644 index 00000000..ef2a2a10 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/VirtualBuddyConfiguration.swift @@ -0,0 +1,37 @@ +// +// VirtualBuddyConfiguration.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// VirtualBuddy TSS API configuration +public struct VirtualBuddyConfiguration: Sendable { + public var apiKey: String? + + public init(apiKey: String? = nil) { + self.apiKey = apiKey + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/KeyIDValidatorTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/KeyIDValidatorTests.swift deleted file mode 100644 index 97106794..00000000 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/KeyIDValidatorTests.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// KeyIDValidatorTests.swift -// BushelCloud -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Testing - -@testable import BushelCloudKit - -@Suite("Key ID Validation Tests") -internal struct KeyIDValidatorTests { - // A representative 64-character hex Key ID (SHA-256 fingerprint length). - private static let validLowercase = - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - - @Test("Valid 64-char lowercase hex passes validation") - internal func testValidLowercase() { - #expect(throws: Never.self) { - try KeyIDValidator.validate(Self.validLowercase) - } - } - - @Test("Uppercase and mixed-case hex passes validation") - internal func testCaseInsensitiveHex() { - let uppercase = "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789" - let mixedCase = "AbCdEf0123456789abcdef0123456789ABCDEF0123456789aBcDeF0123456789" - - #expect(throws: Never.self) { - try KeyIDValidator.validate(uppercase) - } - #expect(throws: Never.self) { - try KeyIDValidator.validate(mixedCase) - } - } - - @Test("Empty string throws error") - internal func testEmpty() { - #expect(throws: BushelCloudKitError.self) { - try KeyIDValidator.validate("") - } - } - - @Test("Whitespace-only string throws error") - internal func testWhitespaceOnly() { - #expect(throws: BushelCloudKitError.self) { - try KeyIDValidator.validate(" \n ") - } - } - - @Test("Surrounding whitespace on an otherwise-valid key throws error") - internal func testSurroundingWhitespace() { - #expect(throws: BushelCloudKitError.self) { - try KeyIDValidator.validate(" \(Self.validLowercase)\n") - } - } - - @Test("Too-short key throws error") - internal func testTooShort() { - #expect(throws: BushelCloudKitError.self) { - try KeyIDValidator.validate("0123456789abcdef") - } - } - - @Test("Too-long key throws error") - internal func testTooLong() { - #expect(throws: BushelCloudKitError.self) { - try KeyIDValidator.validate(Self.validLowercase + "00") - } - } - - @Test("Non-hex characters throw error") - internal func testNonHexCharacters() { - // 64 characters, but contains a non-hex letter ('g') and a dash. - let withLetter = "g123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - let withDash = "-123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - - #expect(throws: BushelCloudKitError.self) { - try KeyIDValidator.validate(withLetter) - } - #expect(throws: BushelCloudKitError.self) { - try KeyIDValidator.validate(withDash) - } - } - - @Test("Error messages are helpful") - internal func testErrorMessages() { - do { - try KeyIDValidator.validate("invalid") - Issue.record("Should have thrown error") - } catch let error as BushelCloudKitError { - let description = error.errorDescription ?? "" - #expect(description.contains("Invalid CloudKit Server-to-Server Key ID")) - #expect(error.recoverySuggestion != nil) - } catch { - Issue.record("Wrong error type: \(error)") - } - } -} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/PEMValidatorTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/PEMValidatorTests.swift deleted file mode 100644 index 146282d6..00000000 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/PEMValidatorTests.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// PEMValidatorTests.swift -// BushelCloud -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Testing - -@testable import BushelCloudKit - -@Suite("PEM Validation Tests") -internal struct PEMValidatorTests { - @Test("Valid PEM passes validation") - internal func testValidPEM() throws { - let validPEM = """ - -----BEGIN PRIVATE KEY----- - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg - -----END PRIVATE KEY----- - """ - - #expect(throws: Never.self) { - try PEMValidator.validate(validPEM) - } - } - - @Test("Missing header throws error") - internal func testMissingHeader() { - let invalidPEM = """ - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg - -----END PRIVATE KEY----- - """ - - #expect(throws: BushelCloudKitError.self) { - try PEMValidator.validate(invalidPEM) - } - } - - @Test("Missing footer throws error") - internal func testMissingFooter() { - let invalidPEM = """ - -----BEGIN PRIVATE KEY----- - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg - """ - - #expect(throws: BushelCloudKitError.self) { - try PEMValidator.validate(invalidPEM) - } - } - - @Test("Empty content throws error") - internal func testEmptyContent() { - let invalidPEM = """ - -----BEGIN PRIVATE KEY----- - -----END PRIVATE KEY----- - """ - - #expect(throws: BushelCloudKitError.self) { - try PEMValidator.validate(invalidPEM) - } - } - - @Test("Invalid base64 throws error") - internal func testInvalidBase64() { - let invalidPEM = """ - -----BEGIN PRIVATE KEY----- - not-valid-base64-content!!! - -----END PRIVATE KEY----- - """ - - #expect(throws: BushelCloudKitError.self) { - try PEMValidator.validate(invalidPEM) - } - } - - @Test("Error messages are helpful") - internal func testErrorMessages() { - let invalidPEM = "invalid" - - do { - try PEMValidator.validate(invalidPEM) - Issue.record("Should have thrown error") - } catch let error as BushelCloudKitError { - let description = error.errorDescription ?? "" - #expect(description.contains("BEGIN PRIVATE KEY")) - #expect(error.recoverySuggestion != nil) - } catch { - Issue.record("Wrong error type: \(error)") - } - } -} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests+Fixtures.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests+Fixtures.swift new file mode 100644 index 00000000..640ba489 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests+Fixtures.swift @@ -0,0 +1,48 @@ +// +// ConfigurationLoaderTests+Fixtures.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. + +@testable import BushelCloudKit + +extension ConfigurationLoaderTests { + /// A syntactically valid Server-to-Server key ID: exactly 64 hex characters. + /// + /// ``KeyIDValidator`` checks shape, so fixtures must be well-formed even + /// though no CloudKit request is made. + internal static let validKeyID = + "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + + /// A structurally valid PEM: correct header/footer and base64-decodable body. + /// + /// Not a usable key — ``PEMValidator`` checks structure, not cryptographic + /// content. + internal static let validPEM = """ + -----BEGIN PRIVATE KEY----- + bm90IGEgcmVhbCBrZXksIGJ1dCB2YWxpZCBiYXNlNjQgc28gUEVNVmFsaWRhdG9yIGFjY2VwdHMgaXQ= + -----END PRIVATE KEY----- + """ +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests.swift index 9645ede7..1fffe6a6 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests.swift @@ -30,6 +30,7 @@ internal import Configuration internal import Foundation internal import MistKit +internal import MistKitConfiguration internal import Testing @testable import BushelCloudKit @@ -148,15 +149,17 @@ internal struct ConfigurationLoaderTests { #expect(config.sync?.verbose == false) // Default } - @Test("ENV var with whitespace is trimmed and parsed") + @Test("ENV var with surrounding whitespace is ignored (falls to default)") internal func testEnvWhitespace() async throws { + // ConfigKeyKit#8: only exact "true"/"1"/"yes" (case-insensitive) are truthy; + // padded values are unrecognized and fall through to the key's default. let loader = ConfigurationLoaderTests.createLoader( cliArgs: [], env: ["BUSHEL_SYNC_VERBOSE": " true "] ) let config = try await loader.loadConfiguration() - #expect(config.sync?.verbose == true) + #expect(config.sync?.verbose == false) } } @@ -194,7 +197,7 @@ internal struct ConfigurationLoaderTests { @Test("String value from CLI arguments") internal func testStringFromCLI() async throws { let loader = ConfigurationLoaderTests.createLoader( - cliArgs: ["cloudkit.container_id=iCloud.com.test.App"], + cliArgs: ["cloudkit.container-id=iCloud.com.test.App"], env: [:] ) @@ -216,7 +219,7 @@ internal struct ConfigurationLoaderTests { @Test("CLI string overrides ENV string") internal func testStringCLIPrecedence() async throws { let loader = ConfigurationLoaderTests.createLoader( - cliArgs: ["cloudkit.container_id=iCloud.com.cli.App"], + cliArgs: ["cloudkit.container-id=iCloud.com.cli.App"], env: ["CLOUDKIT_CONTAINER_ID": "iCloud.com.env.App"] ) @@ -243,7 +246,7 @@ internal struct ConfigurationLoaderTests { @Test("Valid integer from CLI") internal func testValidIntFromCLI() async throws { let loader = ConfigurationLoaderTests.createLoader( - cliArgs: ["sync.min_interval=3600"], + cliArgs: ["sync.min-interval=3600"], env: [:] ) @@ -343,7 +346,7 @@ internal struct ConfigurationLoaderTests { cliArgs: [], env: [ "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", - "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_KEY_ID": ConfigurationLoaderTests.validKeyID, // Missing CLOUDKIT_PRIVATE_KEY_PATH ] ) @@ -361,7 +364,7 @@ internal struct ConfigurationLoaderTests { cliArgs: [], env: [ "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", - "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_KEY_ID": ConfigurationLoaderTests.validKeyID, "CLOUDKIT_PRIVATE_KEY_PATH": "/path/to/key.pem", ] ) @@ -370,8 +373,8 @@ internal struct ConfigurationLoaderTests { let validated = try config.validated() #expect(validated.cloudKit.containerID == "iCloud.com.test.App") - #expect(validated.cloudKit.keyID == "test-key-id") - #expect(validated.cloudKit.privateKeyPath == "/path/to/key.pem") + #expect(validated.cloudKit.keyID == ConfigurationLoaderTests.validKeyID) + #expect(validated.cloudKit.privateKey.filePath == "/path/to/key.pem") } @Test("CloudKit privateKey from environment variable") @@ -380,17 +383,20 @@ internal struct ConfigurationLoaderTests { cliArgs: [], env: [ "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", - "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_KEY_ID": ConfigurationLoaderTests.validKeyID, "CLOUDKIT_PRIVATE_KEY": - "-----BEGIN PRIVATE KEY-----\nMIGH...\n-----END PRIVATE KEY-----", + ConfigurationLoaderTests.validPEM, ] ) let config = try await loader.loadConfiguration() let validated = try config.validated() - #expect(validated.cloudKit.privateKey != nil) - #expect(validated.cloudKit.privateKey?.contains("BEGIN PRIVATE KEY") == true) + guard case .raw(let pem) = validated.cloudKit.privateKey else { + Issue.record("expected inline PEM material") + return + } + #expect(pem.contains("BEGIN PRIVATE KEY")) } @Test( @@ -402,7 +408,7 @@ internal struct ConfigurationLoaderTests { cliArgs: [], env: [ "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", - "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_KEY_ID": ConfigurationLoaderTests.validKeyID, "CLOUDKIT_PRIVATE_KEY_PATH": "/path/to/key.pem", "CLOUDKIT_ENVIRONMENT": environment, ] @@ -420,7 +426,7 @@ internal struct ConfigurationLoaderTests { cliArgs: [], env: [ "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", - "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_KEY_ID": ConfigurationLoaderTests.validKeyID, "CLOUDKIT_PRIVATE_KEY_PATH": "/path/to/key.pem", "CLOUDKIT_ENVIRONMENT": "staging", // Invalid ] @@ -439,7 +445,7 @@ internal struct ConfigurationLoaderTests { cliArgs: [], env: [ "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", - "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_KEY_ID": ConfigurationLoaderTests.validKeyID, // Missing both CLOUDKIT_PRIVATE_KEY and CLOUDKIT_PRIVATE_KEY_PATH ] ) @@ -457,9 +463,8 @@ internal struct ConfigurationLoaderTests { cliArgs: [], env: [ "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", - "CLOUDKIT_KEY_ID": "test-key-id", - "CLOUDKIT_PRIVATE_KEY": - "-----BEGIN PRIVATE KEY-----\nfrom-env\n-----END PRIVATE KEY-----", + "CLOUDKIT_KEY_ID": ConfigurationLoaderTests.validKeyID, + "CLOUDKIT_PRIVATE_KEY": ConfigurationLoaderTests.validPEM, "CLOUDKIT_PRIVATE_KEY_PATH": "/path/to/key.pem", ] ) @@ -468,9 +473,8 @@ internal struct ConfigurationLoaderTests { let validated = try config.validated() // Both should be set in validated config - #expect(validated.cloudKit.privateKey != nil) - #expect(!validated.cloudKit.privateKeyPath.isEmpty) - // SyncEngine will prefer privateKey when initializing + // Inline PEM wins over a path when both are supplied. + #expect(validated.cloudKit.privateKey.filePath == nil) } @Test("Empty CLOUDKIT_PRIVATE_KEY is treated as absent") @@ -479,7 +483,7 @@ internal struct ConfigurationLoaderTests { cliArgs: [], env: [ "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", - "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_KEY_ID": ConfigurationLoaderTests.validKeyID, "CLOUDKIT_PRIVATE_KEY": " ", // Whitespace only "CLOUDKIT_PRIVATE_KEY_PATH": "/path/to/key.pem", ] @@ -488,9 +492,8 @@ internal struct ConfigurationLoaderTests { let config = try await loader.loadConfiguration() let validated = try config.validated() - // Should use privateKeyPath since privateKey is effectively empty - #expect(validated.cloudKit.privateKey == nil) - #expect(!validated.cloudKit.privateKeyPath.isEmpty) + // Falls back to the path, since the inline key is effectively empty. + #expect(validated.cloudKit.privateKey.filePath?.isEmpty == false) } @Test("Environment parsing is case-insensitive") @@ -499,7 +502,7 @@ internal struct ConfigurationLoaderTests { cliArgs: [], env: [ "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", - "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_KEY_ID": ConfigurationLoaderTests.validKeyID, "CLOUDKIT_PRIVATE_KEY_PATH": "/path/to/key.pem", "CLOUDKIT_ENVIRONMENT": "Production", // Mixed case ] @@ -517,9 +520,9 @@ internal struct ConfigurationLoaderTests { cliArgs: [], env: [ "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", - "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_KEY_ID": ConfigurationLoaderTests.validKeyID, "CLOUDKIT_PRIVATE_KEY": - "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + ConfigurationLoaderTests.validPEM, "CLOUDKIT_ENVIRONMENT": "production", ] ) @@ -528,8 +531,8 @@ internal struct ConfigurationLoaderTests { let validated = try config.validated() #expect(validated.cloudKit.containerID == "iCloud.com.test.App") - #expect(validated.cloudKit.keyID == "test-key-id") - #expect(validated.cloudKit.privateKey != nil) + #expect(validated.cloudKit.keyID == ConfigurationLoaderTests.validKeyID) + #expect(validated.cloudKit.privateKey.filePath == nil) #expect(validated.cloudKit.environment == .production) } } @@ -556,7 +559,7 @@ internal struct ConfigurationLoaderTests { cliArgs: [ "export.output=/tmp/export.json", "export.pretty", - "export.signed_only", + "export.signed-only", ], env: [:] ) @@ -574,7 +577,7 @@ internal struct ConfigurationLoaderTests { cliArgs: [ "sync.verbose", "export.pretty", - "list.restore_images", + "list.restore-images", ], env: [:] ) @@ -596,8 +599,8 @@ internal struct ConfigurationLoaderTests { let loader = ConfigurationLoaderTests.createLoader( cliArgs: [ "sync.verbose", - "sync.dry_run", - "sync.min_interval=3600", + "sync.dry-run", + "sync.min-interval=3600", ], env: [ "BUSHEL_SYNC_NO_BETAS": "true", @@ -642,42 +645,48 @@ internal struct ConfigurationLoaderTests { // MARK: - Test Utilities - /// Create a ConfigurationLoader with simulated CLI args and environment variables + /// Creates a loader backed by the same providers production uses, with + /// their inputs injected instead of read from the process. + /// + /// Using `CommandLineArgumentsProvider` and `EnvironmentVariablesProvider` + /// rather than `InMemoryProvider` keeps the double faithful on two behaviors + /// the tests depend on: the environment provider normalizes `-` and `.` to + /// `_` when encoding a key (so `CLOUDKIT_KEY-ID`, generated from the + /// dash-case base `cloudkit.key-id`, resolves from a `CLOUDKIT_KEY_ID` + /// variable), and both providers coerce their string-shaped input on demand + /// (so one variable answers `string`, `int` and `double` reads). + /// `InMemoryProvider` does neither — it matches keys literally and serves + /// only the stored case. /// /// - Parameters: - /// - cliArgs: Simulated CLI arguments (format: "key=value" or "key" for flags) - /// - env: Simulated environment variables - /// - Returns: ConfigurationLoader with controlled inputs + /// - cliArgs: Simulated CLI arguments (format: "key=value", or "key" for flags). + /// - env: Simulated environment variables. + /// - Returns: A loader reading from those inputs only. private static func createLoader( cliArgs: [String], env: [String: String] ) -> ConfigurationLoader { - // Parse CLI args: "key=value" or "key" for flags - var cliValues: [AbsoluteConfigKey: ConfigValue] = [:] + // Rebuild an argv from "key=value" / "key" (flag presence) entries. + // + // A bare `--flag` is spelled `--flag true` here. `CommandLineArgumentsProvider` + // reports a valueless flag through `bool(forKey:)` but not `string(forKey:)`, + // and ConfigKeyKit's boolean resolution detects flag presence via the string + // read — so a truly bare flag resolves to its default. That gap predates the + // `ConfigValueReading` migration (the hand-rolled `read(ConfigKey)` + // these tests previously exercised used the same string-based check); it was + // masked because the former `InMemoryProvider` double stored flags as + // `.string("true")`. Passing the value explicitly keeps these tests on the + // path that works. See the follow-up issue on valueless-flag support. + var arguments: [String] = ["bushel-cloud"] for arg in cliArgs { - if arg.contains("=") { - let parts = arg.split(separator: "=", maxSplits: 1) - if parts.count == 2 { - let key = AbsoluteConfigKey(stringLiteral: String(parts[0])) - cliValues[key] = .init(.string(String(parts[1])), isSecret: false) - } - } else { - // Flag presence (boolean) - let key = AbsoluteConfigKey(stringLiteral: arg) - cliValues[key] = .init(.string("true"), isSecret: false) - } - } - - // ENV vars as-is - var envValues: [AbsoluteConfigKey: ConfigValue] = [:] - for (key, value) in env { - let configKey = AbsoluteConfigKey(stringLiteral: key) - envValues[configKey] = .init(.string(value), isSecret: false) + let parts = arg.split(separator: "=", maxSplits: 1) + arguments.append("--" + parts[0].replacingOccurrences(of: ".", with: "-")) + arguments.append(parts.count == 2 ? String(parts[1]) : "true") } let providers: [any ConfigProvider] = [ - InMemoryProvider(values: cliValues), // Priority 1: CLI - InMemoryProvider(values: envValues), // Priority 2: ENV + CommandLineArgumentsProvider(arguments: arguments), // Priority 1: CLI + EnvironmentVariablesProvider(environmentVariables: env), // Priority 2: ENV ] let configReader = ConfigReader(providers: providers) diff --git a/Examples/CelestraCloud/Package.resolved b/Examples/CelestraCloud/Package.resolved index f7938a88..c0c21f62 100644 --- a/Examples/CelestraCloud/Package.resolved +++ b/Examples/CelestraCloud/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f27a89c0d96b0be480821ad8378b3900758538ddb1232c8dde2c0a272a5cfcee", + "originHash" : "87364e7b045e473ed8ad111d20d315a2cbd9c05d42a520e101eddd6505e58ff7", "pins" : [ { "identity" : "celestrakit", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/brightdigit/ConfigKeyKit.git", "state" : { - "revision" : "6949abb4b7f3e50f7f81d668e6c11aa4721e97fd", - "version" : "1.0.0-beta.2" + "revision" : "3c8ae3825b4cdcbf60fb1adeaaaf324557e7fd41", + "version" : "1.0.0-beta.3" } }, { diff --git a/Examples/CelestraCloud/Package.swift b/Examples/CelestraCloud/Package.swift index 4632e72d..c519005a 100644 --- a/Examples/CelestraCloud/Package.swift +++ b/Examples/CelestraCloud/Package.swift @@ -91,6 +91,9 @@ let package = Package( ], dependencies: [ .package(name: "MistKit", path: "../.."), + // Monorepo dogfood overlay — publishable consumers use a tagged `from:` once + // MistKitConfiguration is released. Same never-merged discipline as the MistKit line. + .package(path: "../../Packages/MistKitConfiguration"), .package(url: "https://github.com/brightdigit/ConfigKeyKit.git", from: "1.0.0-beta.2"), .package(url: "https://github.com/brightdigit/CelestraKit.git", from: "0.0.3"), .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), @@ -105,6 +108,7 @@ let package = Package( name: "CelestraCloudKit", dependencies: [ .product(name: "MistKit", package: "MistKit"), + .product(name: "MistKitConfiguration", package: "MistKitConfiguration"), .product(name: "ConfigKeyKit", package: "ConfigKeyKit"), .product(name: "CelestraKit", package: "CelestraKit"), .product(name: "Logging", package: "swift-log"), @@ -115,7 +119,8 @@ let package = Package( .executableTarget( name: "CelestraCloud", dependencies: [ - .target(name: "CelestraCloudKit") + .target(name: "CelestraCloudKit"), + .product(name: "MistKitConfiguration", package: "MistKitConfiguration"), ], swiftSettings: swiftSettings ), diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/AddFeedCommand.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/AddFeedCommand.swift index b3cb333c..7e82209e 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/AddFeedCommand.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/AddFeedCommand.swift @@ -31,6 +31,7 @@ internal import CelestraCloudKit internal import CelestraKit internal import Foundation internal import MistKit +internal import MistKitConfiguration // MARK: - Main Type @@ -65,8 +66,8 @@ internal enum AddFeedCommand { // 3. Load configuration and create CloudKit service let loader = ConfigurationLoader() let config = try await loader.loadConfiguration() - let validatedCloudKit = try config.cloudkit.validated() - let service = try CelestraConfig.createCloudKitService(from: validatedCloudKit) + let validatedCloudKit = try config.cloudkit.validatedForCelestra() + let service = try validatedCloudKit.makeCloudKitService() // 4. Create Feed record with initial metadata let feed = Feed( diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ClearCommand.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ClearCommand.swift index 9af2eba4..01a86422 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ClearCommand.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ClearCommand.swift @@ -31,6 +31,7 @@ internal import CelestraCloudKit internal import CelestraKit internal import Foundation internal import MistKit +internal import MistKitConfiguration internal enum ClearCommand { internal static func run(args: [String]) async throws { @@ -50,8 +51,8 @@ internal enum ClearCommand { // Load configuration and create CloudKit service let loader = ConfigurationLoader() let config = try await loader.loadConfiguration() - let validatedCloudKit = try config.cloudkit.validated() - let service = try CelestraConfig.createCloudKitService(from: validatedCloudKit) + let validatedCloudKit = try config.cloudkit.validatedForCelestra() + let service = try validatedCloudKit.makeCloudKitService() // Delete articles first (to avoid orphans) print("📋 Deleting articles...") diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand+Reporting.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand+Reporting.swift index 64ee2d46..59624fe5 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand+Reporting.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand+Reporting.swift @@ -31,6 +31,7 @@ internal import CelestraCloudKit internal import CelestraKit internal import Foundation internal import MistKit +internal import MistKitConfiguration extension UpdateCommand { internal static func createFeedResult( @@ -99,7 +100,8 @@ extension UpdateCommand { maxFailures: config.update.maxFailures, minPopularity: config.update.minPopularity, limit: config.update.limit, - environment: config.cloudkit.environment == .production ? "production" : "development" + environment: (config.cloudkit.environment ?? "development") + .lowercased() == "production" ? "production" : "development" ), summary: UpdateReport.Summary( totalFeeds: summary.successCount + summary.errorCount diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand.swift index 1336e98e..fe646f20 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand.swift @@ -31,6 +31,7 @@ internal import CelestraCloudKit internal import CelestraKit internal import Foundation internal import MistKit +internal import MistKitConfiguration internal enum UpdateCommand { internal static func run() async throws { @@ -93,8 +94,8 @@ internal enum UpdateCommand { private static func createProcessor( config: CelestraConfiguration ) throws -> FeedUpdateProcessor { - let validatedCloudKit = try config.cloudkit.validated() - let service = try CelestraConfig.createCloudKitService(from: validatedCloudKit) + let validatedCloudKit = try config.cloudkit.validatedForCelestra() + let service = try validatedCloudKit.makeCloudKitService() let fetcher = RSSFetcherService(userAgent: .cloud(build: 1)) let robotsService = RobotsTxtService(userAgent: .cloud(build: 1)) let rateLimiter = RateLimiter(defaultDelay: config.update.delay) diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CelestraConfiguration.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CelestraConfiguration.swift index 4fc69465..1b95613c 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CelestraConfiguration.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CelestraConfiguration.swift @@ -28,6 +28,7 @@ // public import Foundation +public import MistKitConfiguration /// Root configuration for Celestra application public struct CelestraConfiguration: Sendable { diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfiguration.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfiguration.swift deleted file mode 100644 index e386e838..00000000 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfiguration.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// CloudKitConfiguration.swift -// CelestraCloud -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation -public import MistKit - -/// CloudKit credentials and environment settings -public struct CloudKitConfiguration: Sendable { - /// Default CloudKit container identifier for Celestra - public static let defaultContainerID = "iCloud.com.brightdigit.Celestra" - - /// CloudKit container identifier (e.g., iCloud.com.example.App) - public var containerID: String? - - /// Server-to-Server authentication key ID from Apple Developer Console - public var keyID: String? - - /// Absolute path to PEM-encoded private key file - public var privateKeyPath: String? - - /// CloudKit environment (development or production, default: development) - public var environment: MistKit.Environment - - /// Initialize CloudKit configuration - /// - Parameters: - /// - containerID: CloudKit container identifier - /// - keyID: Server-to-Server authentication key ID - /// - privateKeyPath: Absolute path to PEM-encoded private key file - /// - environment: CloudKit environment - public init( - containerID: String? = nil, - keyID: String? = nil, - privateKeyPath: String? = nil, - environment: MistKit.Environment = .development - ) { - self.containerID = containerID - self.keyID = keyID - self.privateKeyPath = privateKeyPath - self.environment = environment - } - - /// Validate that all required fields are present - public func validated() throws -> ValidatedCloudKitConfiguration { - guard let containerID = containerID, !containerID.isEmpty else { - throw EnhancedConfigurationError( - "CloudKit container ID must be non-empty", - key: "cloudkit.container_id" - ) - } - guard let keyID = keyID, !keyID.isEmpty else { - throw EnhancedConfigurationError( - "CloudKit key ID must be non-empty", - key: "cloudkit.key_id" - ) - } - guard let privateKeyPath = privateKeyPath, !privateKeyPath.isEmpty else { - throw EnhancedConfigurationError( - "CloudKit private key path must be non-empty", - key: "cloudkit.private_key_path" - ) - } - return ValidatedCloudKitConfiguration( - containerID: containerID, - keyID: keyID, - privateKeyPath: privateKeyPath, - environment: environment - ) - } -} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfigurationError+Mapping.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfigurationError+Mapping.swift new file mode 100644 index 00000000..d564d910 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfigurationError+Mapping.swift @@ -0,0 +1,88 @@ +// +// CloudKitConfigurationError+Mapping.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit +public import MistKitConfiguration + +extension CloudKitConfigurationError { + /// Maps a package error onto Celestra's presentation wording and key names. + /// + /// - Parameter keys: Key group used to name the flag or environment variable at fault. + /// - Returns: A ``ConfigurationError`` ready to surface to the user. + public func map(keys: CloudKitConfigurationKeys) -> ConfigurationError { + switch self { + case .missing(.containerID): + ConfigurationError( + "CloudKit container ID must be non-empty", + key: keys.containerID.base + ) + case .missing(.keyID): + ConfigurationError( + "CloudKit key ID must be non-empty", + key: keys.keyID.base + ) + case .missing(.privateKey), .missing(.privateKeyPath): + ConfigurationError( + "Either CLOUDKIT_PRIVATE_KEY or CLOUDKIT_PRIVATE_KEY_PATH must be provided", + key: keys.privateKey.base + ) + case .missing(.environment): + ConfigurationError( + "CloudKit environment must be 'development' or 'production'", + key: keys.environment.base + ) + case .invalidKeyID(let failure): + ConfigurationError( + "Invalid CloudKit Server-to-Server Key ID: \(String(describing: failure))", + key: keys.keyID.base + ) + case .invalidPrivateKey(let failure): + ConfigurationError( + "Invalid PEM format: \(String(describing: failure))", + key: keys.privateKey.base + ) + case .unrecognizedEnvironment(let raw): + ConfigurationError( + "Invalid CLOUDKIT_ENVIRONMENT: '\(raw)'. Must be 'development' or 'production'", + key: keys.environment.base + ) + } + } +} + +extension CloudKitConfiguration { + /// Validates credentials, mapping package errors into Celestra ``ConfigurationError``. + public func validatedForCelestra() throws -> ValidatedCloudKitConfiguration { + do { + return try validated() + } catch { + throw error.map(keys: ConfigurationKeys.cloudKit) + } + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationKeys.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationKeys.swift index 9585c2ba..4abae377 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationKeys.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationKeys.swift @@ -29,24 +29,20 @@ internal import ConfigKeyKit internal import Foundation +internal import MistKitConfiguration /// Configuration keys for reading from providers. /// -/// Each option is a single typed `ConfigKey`/`OptionalConfigKey`. ConfigKeyKit's -/// `StandardNamingStyle` derives both spellings from one dash-separated base: -/// the CLI flag (dash-case, e.g. `--cloudkit-container-id`) and the environment -/// variable (`SCREAMING_SNAKE_CASE`, e.g. `CLOUDKIT_CONTAINER_ID`). Bases use -/// dashes throughout for consistency and conventional kebab-case CLI flags. +/// CloudKit credentials come from ``CloudKitConfigurationKeys``; Celestra-only update +/// options stay here as typed `ConfigKey`/`OptionalConfigKey` values. internal enum ConfigurationKeys { - internal enum CloudKit { - internal static let containerID = ConfigKey( - "cloudkit.container-id", - default: CloudKitConfiguration.defaultContainerID - ) - internal static let keyID = OptionalConfigKey("cloudkit.key-id") - internal static let privateKeyPath = OptionalConfigKey("cloudkit.private-key-path") - internal static let environment = OptionalConfigKey("cloudkit.environment") - } + /// Default CloudKit container for Celestra. + internal static let defaultContainerID = "iCloud.com.brightdigit.Celestra" + + /// CloudKit credential keys with Celestra's container default. + internal static let cloudKit = CloudKitConfigurationKeys( + defaultContainerID: defaultContainerID + ) internal enum Update { internal static let delay = ConfigKey("update.delay", default: 2.0) diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationLoader.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationLoader.swift index 69914684..8fa889ec 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationLoader.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationLoader.swift @@ -30,7 +30,7 @@ internal import ConfigKeyKit internal import Configuration internal import Foundation -internal import MistKit +internal import MistKitConfiguration /// Loads and merges configuration from multiple sources public actor ConfigurationLoader { @@ -38,45 +38,32 @@ public actor ConfigurationLoader { /// Creates a new configuration loader with default providers. public init() { - var providers: [any ConfigProvider] = [] - - // Priority 1: Command-line arguments (highest) - providers.append( - CommandLineArgumentsProvider( - secretsSpecifier: .specific( - [ - "--cloudkit-key-id", - "--cloudkit-private-key-path", - ] - ) - ) + self.configReader = ConfigurationSources.makeConfigReader( + secretCommandLineFlags: ConfigurationKeys.cloudKit.secretCommandLineFlags ) + } - // Priority 2: Environment variables - providers.append(EnvironmentVariablesProvider()) - - self.configReader = ConfigReader(providers: providers) + /// Creates a loader over a pre-configured reader. + /// + /// Lets tests inject controlled configuration sources instead of mutating + /// process-global environment variables. + /// - Parameter configReader: Pre-configured reader to read from. + internal init(configReader: ConfigReader) { + self.configReader = configReader } /// Load complete configuration with all defaults applied public func loadConfiguration() async throws -> CelestraConfiguration { - // CloudKit configuration (automatic CLI → ENV → default fallback) - let cloudkit = CloudKitConfiguration( - containerID: read(ConfigurationKeys.CloudKit.containerID), - keyID: read(ConfigurationKeys.CloudKit.keyID), - privateKeyPath: read(ConfigurationKeys.CloudKit.privateKeyPath), - environment: parseEnvironment(read(ConfigurationKeys.CloudKit.environment)) - ) + let cloudkit = configReader.readCloudKitConfiguration(keys: ConfigurationKeys.cloudKit) - // Update command configuration let update = UpdateCommandConfiguration( - delay: read(ConfigurationKeys.Update.delay), - skipRobotsCheck: read(ConfigurationKeys.Update.skipRobotsCheck), - maxFailures: read(ConfigurationKeys.Update.maxFailures), - minPopularity: read(ConfigurationKeys.Update.minPopularity), - lastAttemptedBefore: read(ConfigurationKeys.Update.lastAttemptedBefore), - limit: read(ConfigurationKeys.Update.limit), - jsonOutputPath: read(ConfigurationKeys.Update.jsonOutputPath) + delay: configReader.read(ConfigurationKeys.Update.delay), + skipRobotsCheck: configReader.read(ConfigurationKeys.Update.skipRobotsCheck), + maxFailures: configReader.read(ConfigurationKeys.Update.maxFailures), + minPopularity: configReader.read(ConfigurationKeys.Update.minPopularity), + lastAttemptedBefore: configReader.read(ConfigurationKeys.Update.lastAttemptedBefore), + limit: configReader.read(ConfigurationKeys.Update.limit), + jsonOutputPath: configReader.read(ConfigurationKeys.Update.jsonOutputPath) ) return CelestraConfiguration( @@ -84,107 +71,4 @@ public actor ConfigurationLoader { update: update ) } - - // MARK: - Per-key-string Primitives - - private func readString(forKey key: String) -> String? { - configReader.string(forKey: ConfigKey(key)) - } - - private func readInt(forKey key: String) -> Int? { - configReader.int(forKey: ConfigKey(key)) - } - - private func readDate(forKey key: String) -> Date? { - // Swift Configuration automatically converts ISO8601 strings to Date - configReader.string(forKey: ConfigKey(key), as: Date.self) - } - - private func parseEnvironment(_ value: String?) -> MistKit.Environment { - guard let value = value?.lowercased() else { - return .development - } - return value == "production" ? .production : .development - } - - // MARK: - Generic ConfigKey Helpers (required default → non-optional) - - /// Read a string value with automatic CLI → ENV → default fallback. - private func read(_ key: ConfigKeyKit.ConfigKey) -> String { - for source in ConfigKeySource.allCases { - guard let keyString = key.key(for: source) else { continue } - if let value = readString(forKey: keyString) { - return value - } - } - return key.defaultValue - } - - /// Read a double value with automatic CLI → ENV → default fallback. - private func read(_ key: ConfigKeyKit.ConfigKey) -> Double { - for source in ConfigKeySource.allCases { - guard let keyString = key.key(for: source) else { continue } - if let stringValue = readString(forKey: keyString), let value = Double(stringValue) { - return value - } - } - return key.defaultValue - } - - /// Read a boolean value with CLI flag-presence and ENV string parsing. - /// - /// - CLI: flag presence indicates `true` (e.g. `--update-skip-robots-check`). - /// - ENV: accepts `true`/`1`/`yes` (case-insensitive); anything else is `false`. - /// - Otherwise: the key's default. - private func read(_ key: ConfigKeyKit.ConfigKey) -> Bool { - if let cliKey = key.key(for: .commandLine), - configReader.string(forKey: ConfigKey(cliKey)) != nil - { - return true - } - - if let envKey = key.key(for: .environment), - let envValue = configReader.string(forKey: ConfigKey(envKey)) - { - let lowercased = envValue.lowercased().trimmingCharacters(in: .whitespaces) - return lowercased == "true" || lowercased == "1" || lowercased == "yes" - } - - return key.defaultValue - } - - // MARK: - Generic OptionalConfigKey Helpers (no default → optional) - - /// Read a string value with automatic CLI → ENV fallback. - private func read(_ key: ConfigKeyKit.OptionalConfigKey) -> String? { - for source in ConfigKeySource.allCases { - guard let keyString = key.key(for: source) else { continue } - if let value = readString(forKey: keyString) { - return value - } - } - return nil - } - - /// Read an integer value with automatic CLI → ENV fallback. - private func read(_ key: ConfigKeyKit.OptionalConfigKey) -> Int? { - for source in ConfigKeySource.allCases { - guard let keyString = key.key(for: source) else { continue } - if let value = readInt(forKey: keyString) { - return value - } - } - return nil - } - - /// Read a date value (ISO8601) with automatic CLI → ENV fallback. - private func read(_ key: ConfigKeyKit.OptionalConfigKey) -> Date? { - for source in ConfigKeySource.allCases { - guard let keyString = key.key(for: source) else { continue } - if let value = readDate(forKey: keyString) { - return value - } - } - return nil - } } diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ValidatedCloudKitConfiguration.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ValidatedCloudKitConfiguration.swift deleted file mode 100644 index 3e395a0b..00000000 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ValidatedCloudKitConfiguration.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// ValidatedCloudKitConfiguration.swift -// CelestraCloud -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Foundation -public import MistKit - -/// Validated CloudKit configuration with all required fields -public struct ValidatedCloudKitConfiguration: Sendable { - /// CloudKit container identifier (validated non-empty) - public let containerID: String - - /// Server-to-Server authentication key ID (validated non-empty) - public let keyID: String - - /// Absolute path to PEM-encoded private key file (validated non-empty) - public let privateKeyPath: String - - /// CloudKit environment (development or production) - public let environment: MistKit.Environment - - /// Initialize validated CloudKit configuration - /// - Parameters: - /// - containerID: CloudKit container identifier - /// - keyID: Server-to-Server authentication key ID - /// - privateKeyPath: Absolute path to PEM-encoded private key file - /// - environment: CloudKit environment - public init( - containerID: String, - keyID: String, - privateKeyPath: String, - environment: MistKit.Environment - ) { - self.containerID = containerID - self.keyID = keyID - self.privateKeyPath = privateKeyPath - self.environment = environment - } -} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/CloudKitConfigurationTests.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/CloudKitConfigurationTests.swift index 80d18002..85673e6e 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/CloudKitConfigurationTests.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/CloudKitConfigurationTests.swift @@ -29,26 +29,30 @@ internal import Foundation internal import MistKit +internal import MistKitConfiguration internal import Testing @testable import CelestraCloudKit @Suite("CloudKitConfiguration Tests") internal struct CloudKitConfigurationTests { + /// A syntactically valid Server-to-Server key ID: exactly 64 hex characters. + private static let validKeyID = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + @Test("Valid configuration with all fields") internal func testValidConfigurationWithAllFields() throws { let config = CloudKitConfiguration( containerID: "iCloud.com.example.Test", - keyID: "TEST_KEY_ID", + keyID: Self.validKeyID, privateKeyPath: "/path/to/key.pem", - environment: .production + environment: "production" ) - let validated = try config.validated() + let validated = try config.validatedForCelestra() #expect(validated.containerID == "iCloud.com.example.Test") - #expect(validated.keyID == "TEST_KEY_ID") - #expect(validated.privateKeyPath == "/path/to/key.pem") + #expect(validated.keyID == Self.validKeyID) + #expect(validated.privateKey.filePath == "/path/to/key.pem") #expect(validated.environment == .production) } @@ -56,11 +60,11 @@ internal struct CloudKitConfigurationTests { internal func testValidConfigurationWithDefaultEnvironment() throws { let config = CloudKitConfiguration( containerID: "iCloud.com.example.Test", - keyID: "TEST_KEY_ID", + keyID: Self.validKeyID, privateKeyPath: "/path/to/key.pem" ) - let validated = try config.validated() + let validated = try config.validatedForCelestra() #expect(validated.environment == .development) } @@ -69,12 +73,12 @@ internal struct CloudKitConfigurationTests { internal func testMissingContainerIDThrowsError() { let config = CloudKitConfiguration( containerID: nil, - keyID: "TEST_KEY_ID", + keyID: Self.validKeyID, privateKeyPath: "/path/to/key.pem" ) - #expect(throws: EnhancedConfigurationError.self) { - try config.validated() + #expect(throws: ConfigurationError.self) { + try config.validatedForCelestra() } } @@ -82,16 +86,16 @@ internal struct CloudKitConfigurationTests { internal func testEmptyContainerIDThrowsError() { let config = CloudKitConfiguration( containerID: "", - keyID: "TEST_KEY_ID", + keyID: Self.validKeyID, privateKeyPath: "/path/to/key.pem" ) do { - _ = try config.validated() + _ = try config.validatedForCelestra() Issue.record("Expected error to be thrown for empty containerID") - } catch let error as EnhancedConfigurationError { + } catch let error as ConfigurationError { #expect(error.message == "CloudKit container ID must be non-empty") - #expect(error.key == "cloudkit.container_id") + #expect(error.key == "cloudkit.container-id") } catch { Issue.record("Unexpected error type: \(error)") } @@ -105,8 +109,8 @@ internal struct CloudKitConfigurationTests { privateKeyPath: "/path/to/key.pem" ) - #expect(throws: EnhancedConfigurationError.self) { - try config.validated() + #expect(throws: ConfigurationError.self) { + try config.validatedForCelestra() } } @@ -119,11 +123,11 @@ internal struct CloudKitConfigurationTests { ) do { - _ = try config.validated() + _ = try config.validatedForCelestra() Issue.record("Expected error to be thrown for empty keyID") - } catch let error as EnhancedConfigurationError { + } catch let error as ConfigurationError { #expect(error.message == "CloudKit key ID must be non-empty") - #expect(error.key == "cloudkit.key_id") + #expect(error.key == "cloudkit.key-id") } catch { Issue.record("Unexpected error type: \(error)") } @@ -133,12 +137,12 @@ internal struct CloudKitConfigurationTests { internal func testMissingPrivateKeyPathThrowsError() { let config = CloudKitConfiguration( containerID: "iCloud.com.example.Test", - keyID: "TEST_KEY_ID", + keyID: Self.validKeyID, privateKeyPath: nil ) - #expect(throws: EnhancedConfigurationError.self) { - try config.validated() + #expect(throws: ConfigurationError.self) { + try config.validatedForCelestra() } } @@ -146,16 +150,18 @@ internal struct CloudKitConfigurationTests { internal func testEmptyPrivateKeyPathThrowsError() { let config = CloudKitConfiguration( containerID: "iCloud.com.example.Test", - keyID: "TEST_KEY_ID", + keyID: Self.validKeyID, privateKeyPath: "" ) do { - _ = try config.validated() + _ = try config.validatedForCelestra() Issue.record("Expected error to be thrown for empty privateKeyPath") - } catch let error as EnhancedConfigurationError { - #expect(error.message == "CloudKit private key path must be non-empty") - #expect(error.key == "cloudkit.private_key_path") + } catch let error as ConfigurationError { + #expect( + error.message + == "Either CLOUDKIT_PRIVATE_KEY or CLOUDKIT_PRIVATE_KEY_PATH must be provided") + #expect(error.key == "cloudkit.private-key") } catch { Issue.record("Unexpected error type: \(error)") } @@ -165,18 +171,18 @@ internal struct CloudKitConfigurationTests { internal func testEnvironmentSetToProduction() throws { let config = CloudKitConfiguration( containerID: "iCloud.com.example.Test", - keyID: "TEST_KEY_ID", + keyID: Self.validKeyID, privateKeyPath: "/path/to/key.pem", - environment: .production + environment: "production" ) - let validated = try config.validated() + let validated = try config.validatedForCelestra() #expect(validated.environment == .production) } @Test("Default container ID constant") internal func testDefaultContainerIDConstant() { - #expect(CloudKitConfiguration.defaultContainerID == "iCloud.com.brightdigit.Celestra") + #expect(ConfigurationKeys.defaultContainerID == "iCloud.com.brightdigit.Celestra") } } diff --git a/Examples/MistDemo/Package.resolved b/Examples/MistDemo/Package.resolved index 7a1961f8..880317d7 100644 --- a/Examples/MistDemo/Package.resolved +++ b/Examples/MistDemo/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "323c779f5e9cb02107c3e61f0a8a2b1608eb6e67b7923401f1b2767942516400", + "originHash" : "18ab5c7ce47b802d34f49cbd082522ade7b3e8b125db7298f2ff028e4a016cb3", "pins" : [ { "identity" : "async-http-client", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/brightdigit/ConfigKeyKit.git", "state" : { - "revision" : "6949abb4b7f3e50f7f81d668e6c11aa4721e97fd", - "version" : "1.0.0-beta.2" + "revision" : "3c8ae3825b4cdcbf60fb1adeaaaf324557e7fd41", + "version" : "1.0.0-beta.3" } }, { diff --git a/Examples/MistDemo/Package.swift b/Examples/MistDemo/Package.swift index 94e0d6d0..3be7a9ea 100644 --- a/Examples/MistDemo/Package.swift +++ b/Examples/MistDemo/Package.swift @@ -106,6 +106,9 @@ let package = Package( ], dependencies: [ .package(name: "MistKit", path: "../.."), + // Monorepo dogfood overlay — publishable consumers use a tagged `from:` once + // MistKitConfiguration is released. + .package(path: "../../Packages/MistKitConfiguration"), .package(url: "https://github.com/brightdigit/ConfigKeyKit.git", from: "1.0.0-beta.2"), .package( url: "https://github.com/hummingbird-project/hummingbird.git", @@ -136,6 +139,7 @@ let package = Package( dependencies: [ .product(name: "ConfigKeyKit", package: "ConfigKeyKit"), .product(name: "MistKit", package: "MistKit"), + .product(name: "MistKitConfiguration", package: "MistKitConfiguration"), .product( name: "Hummingbird", package: "hummingbird", @@ -176,6 +180,7 @@ let package = Package( "MistDemoKit", .product(name: "ConfigKeyKit", package: "ConfigKeyKit"), .product(name: "MistKit", package: "MistKit"), + .product(name: "MistKitConfiguration", package: "MistKitConfiguration"), .product( name: "Hummingbird", package: "hummingbird", diff --git a/Examples/MistDemo/README.md b/Examples/MistDemo/README.md index 6a933844..5e4bea0a 100644 --- a/Examples/MistDemo/README.md +++ b/Examples/MistDemo/README.md @@ -42,9 +42,8 @@ swift run mistdemo test-public # integration suite, public DB swift run mistdemo test-private # integration suite, private DB ``` -Configuration comes from `MistDemoConfiguration` — flags, -`CLOUDKIT_*` env vars, or `--config-file ~/.mistdemo/config.json` all -work. `test-private` requires both a sharer and a sharee web-auth +Configuration comes from `MistDemoConfiguration` — command-line flags or +`CLOUDKIT_*` env vars. `test-private` requires both a sharer and a sharee web-auth token (`CLOUDKIT_WEB_AUTH_TOKEN` + `CLOUDKIT_SHAREE_WEB_AUTH_TOKEN`) and the sharee's iCloud email (`CLOUDKIT_SHAREE_EMAIL`); capture them with `mistdemo auth-tokens --sharee-email sharee@example.com`. @@ -86,17 +85,17 @@ sharee credentials for `test-private`. | Flag | Default | Notes | |---|---|---| | `--api-token ` | (required) | Or set `CLOUDKIT_API_TOKEN` | -| `--container-identifier ` | `iCloud.com.brightdigit.MistDemo` | Your CloudKit container | -| `--environment ` | `development` | `development` or `production` | +| `--cloudkit-container-id ` | `iCloud.com.brightdigit.MistDemo` | Your CloudKit container (`CLOUDKIT_CONTAINER_ID`) | +| `--cloudkit-environment ` | `development` | `development` or `production` | | `--host ` | `127.0.0.1` | Bind address | | `--port ` | `8080` | Server port | | `--browser` | on for `auth-token` / `auth-tokens`, off for `web` | Open browser on startup | | `--no-browser` | — | Suppress the open (wins if both flags set) | -Configuration is read via `MistDemoConfiguration`, so the same keys -(`api.token`, `container.identifier`, `environment`, `port`, `host`, -`browser`, `no.browser`) can be supplied through `--config-file ~/.mistdemo/config.json` -or environment variables. +Configuration is read via `MistDemoConfiguration` from typed `MistDemoKeys`, so the +same keys (`api.token`, `cloudkit.container-id`, `cloudkit.environment`, `port`, +`host`, `browser`, `no.browser`) can equally be supplied as environment variables +(`CLOUDKIT_API_TOKEN`, `CLOUDKIT_CONTAINER_ID`, `CLOUDKIT_ENVIRONMENT`, …). ### What the server exposes @@ -148,7 +147,7 @@ The web command's code lives under `Sources/MistDemoKit/`: ``` Sources/MistDemoKit/ ├── Commands/WebCommand.swift # `mistdemo web` entry point -├── Configuration/WebConfig.swift # Flags / env / config-file binding +├── Configuration/WebConfig.swift # Flags / env binding ├── Resources/index.html # Served at GET / └── Server/ ├── WebServer.swift # Hummingbird router + handlers diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AcceptConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AcceptConfig.swift index e2ef62fa..e9674b82 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AcceptConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AcceptConfig.swift @@ -80,18 +80,11 @@ public struct AcceptConfig: Sendable, ConfigurationParseable { } let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: "json" - ) ?? "json" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json - let fetchRootRecord = configuration.optionalBool( - forKey: "fetch.root.record" - ) - let fields = configuration.commaSeparatedList( - forKey: MistDemoConstants.ConfigKeys.fields - ) + let fetchRootRecord = configuration.read(MistDemoKeys.Sharing.fetchRootRecord) + let fields = configuration.commaSeparatedList(MistDemoKeys.Record.fields) let shortGUIDs = ResolveConfig.parseShortGUIDs(from: configuration) guard !shortGUIDs.isEmpty else { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift index 4d89b79e..91c22955 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift @@ -28,6 +28,7 @@ // public import ConfigKeyKit +internal import MistKitConfiguration internal import Foundation public import MistKit @@ -87,7 +88,7 @@ public struct AuthTokenConfig: Sendable, ConfigurationParseable { // Parse command-specific options let apiToken = - configReader.string(forKey: "api.token", isSecret: true) ?? "" + configReader.read(MistDemoKeys.Auth.apiToken) guard !apiToken.isEmpty else { throw ConfigurationError.missingRequired( "api.token", @@ -99,29 +100,24 @@ public struct AuthTokenConfig: Sendable, ConfigurationParseable { // Demo default — override via --container-identifier // or config key "container.identifier" let containerIdentifier = - configReader.string( - forKey: "container.identifier", - default: MistDemoConstants.Defaults.containerIdentifier - ) ?? MistDemoConstants.Defaults.containerIdentifier + configReader.read(MistDemoKeys.cloudKit.containerID) let envString = - configReader.string(forKey: "environment", default: "development") - ?? "development" + configReader.read(MistDemoKeys.cloudKit.environment) ?? MistDemoConstants.Defaults.environment guard let environment = MistKit.Environment(caseInsensitive: envString) else { throw ConfigurationError.invalidEnvironment(envString) } let port = - configReader.int(forKey: "port", default: 8_080) ?? 8_080 + configReader.read(MistDemoKeys.Server.port) let host = - configReader.string(forKey: "host", default: "127.0.0.1") - ?? "127.0.0.1" + configReader.read(MistDemoKeys.Server.host) let openBrowser = BrowserFlagResolver.resolve( configReader: configReader, default: true ) let resetAuth = - configReader.bool(forKey: "reset.auth", default: false) + configReader.read(MistDemoKeys.Auth.resetAuth) self.init( apiToken: apiToken, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokensConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokensConfig.swift index 584dd123..bd80e6a1 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokensConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokensConfig.swift @@ -28,6 +28,7 @@ // public import ConfigKeyKit +internal import MistKitConfiguration internal import Foundation public import MistKit @@ -81,7 +82,7 @@ public struct AuthTokensConfig: Sendable, ConfigurationParseable { let configReader = configuration let apiToken = - configReader.string(forKey: "api.token", isSecret: true) ?? "" + configReader.read(MistDemoKeys.Auth.apiToken) guard !apiToken.isEmpty else { throw ConfigurationError.missingRequired( "api.token", @@ -91,28 +92,23 @@ public struct AuthTokensConfig: Sendable, ConfigurationParseable { } let containerIdentifier = - configReader.string( - forKey: "container.identifier", - default: MistDemoConstants.Defaults.containerIdentifier - ) ?? MistDemoConstants.Defaults.containerIdentifier + configReader.read(MistDemoKeys.cloudKit.containerID) let envString = - configReader.string(forKey: "environment", default: "development") - ?? "development" + configReader.read(MistDemoKeys.cloudKit.environment) ?? MistDemoConstants.Defaults.environment guard let environment = MistKit.Environment(caseInsensitive: envString) else { throw ConfigurationError.invalidEnvironment(envString) } let port = - configReader.int(forKey: "port", default: 8_080) ?? 8_080 + configReader.read(MistDemoKeys.Server.port) let host = - configReader.string(forKey: "host", default: "127.0.0.1") - ?? "127.0.0.1" + configReader.read(MistDemoKeys.Server.host) let openBrowser = BrowserFlagResolver.resolve( configReader: configReader, default: true ) - let shareeEmail = configReader.string(forKey: "sharee.email") + let shareeEmail = configReader.read(MistDemoKeys.Auth.shareeEmail) self.init( apiToken: apiToken, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift index 9a1bdb3c..77c35a93 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift @@ -40,11 +40,11 @@ internal enum BrowserFlagResolver { configReader: MistDemoConfiguration, default defaultValue: Bool ) -> Bool { - let noBrowser = configReader.bool(forKey: "no.browser", default: false) + let noBrowser = configReader.read(MistDemoKeys.Server.noBrowser) if noBrowser { return false } - let browser = configReader.bool(forKey: "browser", default: false) + let browser = configReader.read(MistDemoKeys.Server.browser) if browser { return true } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CloudKitConfigurationError+Mapping.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CloudKitConfigurationError+Mapping.swift new file mode 100644 index 00000000..1cbd1900 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CloudKitConfigurationError+Mapping.swift @@ -0,0 +1,68 @@ +// +// CloudKitConfigurationError+Mapping.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitConfiguration + +extension CloudKitConfigurationError { + /// Maps a package error onto MistDemo's existing ``ConfigurationError`` cases. + internal func map() -> ConfigurationError { + switch self { + case .missing(.containerID): + .missingRequired( + "container.id", + suggestion: "Set CLOUDKIT_CONTAINER_ID or --cloudkit-container-id." + ) + case .missing(.keyID): + .missingRequired( + "key.id", + suggestion: "Set CLOUDKIT_KEY_ID or --cloudkit-key-id." + ) + case .missing(.privateKey), .missing(.privateKeyPath): + .missingRequired( + "private.key", + suggestion: "Set CLOUDKIT_PRIVATE_KEY or CLOUDKIT_PRIVATE_KEY_PATH." + ) + case .missing(.environment): + .invalidEnvironment("") + case .invalidKeyID: + .missingRequired( + "key.id", + suggestion: "Provide a 64-character hexadecimal CloudKit Server-to-Server Key ID." + ) + case .invalidPrivateKey: + .missingRequired( + "private.key", + suggestion: + "Provide a PEM-encoded private key via CLOUDKIT_PRIVATE_KEY or CLOUDKIT_PRIVATE_KEY_PATH." + ) + case .unrecognizedEnvironment(let raw): + .invalidEnvironment(raw) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateConfig.swift index 32a8e27e..1e44ea28 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateConfig.swift @@ -86,28 +86,17 @@ public struct CreateConfig: Sendable, ConfigurationParseable { // Parse create-specific options let zone = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.zone, - default: MistDemoConstants.Defaults.zone - ) ?? MistDemoConstants.Defaults.zone + configReader.read(MistDemoKeys.Query.zone) let recordType = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.recordType, - default: MistDemoConstants.Defaults.recordType - ) ?? MistDemoConstants.Defaults.recordType - let recordName = configReader.string( - forKey: MistDemoConstants.ConfigKeys.recordName - ) + configReader.read(MistDemoKeys.Record.recordType) + let recordName = configReader.read(MistDemoKeys.Record.recordName) // Parse fields from various sources let fields = try Self.parseFieldsFromSources(configReader) // Parse output format let outputString = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: MistDemoConstants.Defaults.outputFormat - ) ?? MistDemoConstants.Defaults.outputFormat + configReader.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( @@ -126,7 +115,7 @@ public struct CreateConfig: Sendable, ConfigurationParseable { var fields: [Field] = [] // 1. Parse inline field definitions - if let fieldString = configReader.string(forKey: "field") { + if let fieldString = configReader.read(MistDemoKeys.Record.field) { let fieldDefinitions = fieldString.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } @@ -135,18 +124,13 @@ public struct CreateConfig: Sendable, ConfigurationParseable { } // 2. Parse from JSON file - if let jsonFile = configReader.string( - forKey: MistDemoConstants.ConfigKeys.jsonFile - ) { + if let jsonFile = configReader.read(MistDemoKeys.Record.jsonFile) { let jsonFields = try parseFieldsFromJSONFile(jsonFile) fields.append(contentsOf: jsonFields) } // 3. Parse from stdin (check if data is available) - if configReader.bool( - forKey: MistDemoConstants.ConfigKeys.stdin, - default: false - ) { + if configReader.read(MistDemoKeys.Record.stdin) { let stdinFields = try parseFieldsFromStdin() fields.append(contentsOf: stdinFields) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateTokenConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateTokenConfig.swift index 076f2d78..33079654 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateTokenConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateTokenConfig.swift @@ -81,17 +81,14 @@ public struct CreateTokenConfig: Sendable, ConfigurationParseable { } let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: "json" - ) ?? "json" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( base: baseConfig, - apnsToken: configuration.string(forKey: "apns-token"), - apnsEnvironment: configuration.string(forKey: "apns-environment"), - clientId: configuration.string(forKey: "client-id"), + apnsToken: configuration.read(MistDemoKeys.Subscription.apnsToken), + apnsEnvironment: configuration.read(MistDemoKeys.Subscription.apnsEnvironment), + clientId: configuration.read(MistDemoKeys.Subscription.clientID), output: output ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateZoneConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateZoneConfig.swift index d00010eb..b7b0735b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateZoneConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateZoneConfig.swift @@ -75,7 +75,7 @@ public struct CreateZoneConfig: Sendable, ConfigurationParseable { } guard - let zoneName = configuration.string(forKey: "zone.name"), + let zoneName = configuration.read(MistDemoKeys.Query.zoneName), !zoneName.isEmpty else { throw ConfigurationError.missingRequired( @@ -84,11 +84,10 @@ public struct CreateZoneConfig: Sendable, ConfigurationParseable { ) } - let ownerRecordName = configuration.string(forKey: "zone.owner") + let ownerRecordName = configuration.read(MistDemoKeys.Query.zoneOwner) let outputString = - configuration.string(forKey: "output.format", default: "table") - ?? "table" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .table self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CurrentUserConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CurrentUserConfig.swift index bc806007..6d91e21d 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CurrentUserConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CurrentUserConfig.swift @@ -73,15 +73,14 @@ public struct CurrentUserConfig: Sendable, ConfigurationParseable { } // Parse fields filter - let fieldsString = configReader.string(forKey: "fields") + let fieldsString = configReader.read(MistDemoKeys.Record.fields) let fields = fieldsString?.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } // Parse output format let outputString = - configReader.string(forKey: "output.format", default: "json") - ?? "json" + configReader.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteConfig.swift index 2a6ed4bf..e2b52876 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteConfig.swift @@ -88,35 +88,21 @@ public struct DeleteConfig: Sendable, ConfigurationParseable { } let zone = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.zone, - default: MistDemoConstants.Defaults.zone - ) ?? MistDemoConstants.Defaults.zone + configReader.read(MistDemoKeys.Query.zone) let recordType = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.recordType, - default: MistDemoConstants.Defaults.recordType - ) ?? MistDemoConstants.Defaults.recordType + configReader.read(MistDemoKeys.Record.recordType) guard - let recordName = configReader.string(forKey: MistDemoConstants.ConfigKeys.recordName) + let recordName = configReader.read(MistDemoKeys.Record.recordName) else { throw DeleteError.recordNameRequired } - let recordChangeTag = configReader.string( - forKey: MistDemoConstants.ConfigKeys.recordChangeTag - ) - let force = configReader.bool( - forKey: MistDemoConstants.ConfigKeys.force, - default: false - ) + let recordChangeTag = configReader.read(MistDemoKeys.Record.recordChangeTag) + let force = configReader.read(MistDemoKeys.Record.force) let outputString = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: MistDemoConstants.Defaults.outputFormat - ) ?? MistDemoConstants.Defaults.outputFormat + configReader.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteZoneConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteZoneConfig.swift index d98988eb..9fb9f0c5 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteZoneConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteZoneConfig.swift @@ -75,7 +75,7 @@ public struct DeleteZoneConfig: Sendable, ConfigurationParseable { } guard - let zoneName = configuration.string(forKey: "zone.name"), + let zoneName = configuration.read(MistDemoKeys.Query.zoneName), !zoneName.isEmpty else { throw ConfigurationError.missingRequired( @@ -84,11 +84,10 @@ public struct DeleteZoneConfig: Sendable, ConfigurationParseable { ) } - let ownerRecordName = configuration.string(forKey: "zone.owner") + let ownerRecordName = configuration.read(MistDemoKeys.Query.zoneOwner) let outputString = - configuration.string(forKey: "output.format", default: "table") - ?? "table" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .table self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsConfig.swift index 8084bb00..5689a90c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsConfig.swift @@ -64,8 +64,7 @@ public struct DemoErrorsConfig: Sendable, ConfigurationParseable { } let scenarioString = - configuration.string(forKey: "scenario", default: "all") - ?? "all" + configuration.read(MistDemoKeys.Integration.scenario) guard let scenario = ErrorScenario(rawValue: scenarioString) else { throw DemoErrorsError.invalidScenario(scenarioString) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DiscoverConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DiscoverConfig.swift index f48fb494..18dce9e1 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DiscoverConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DiscoverConfig.swift @@ -79,17 +79,11 @@ public struct DiscoverConfig: Sendable, ConfigurationParseable { let emails = Self.parseEmails(from: configuration) let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: MistDemoConstants.Defaults.outputFormat - ) ?? MistDemoConstants.Defaults.outputFormat + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json let batchSize = - configuration.int( - forKey: MistDemoConstants.ConfigKeys.batchSize, - default: CloudKitService.maxRecordsPerRequest - ) ?? CloudKitService.maxRecordsPerRequest + configuration.read(MistDemoKeys.Record.batchSize) self.init( base: baseConfig, @@ -104,7 +98,7 @@ public struct DiscoverConfig: Sendable, ConfigurationParseable { internal static func parseEmails( from configuration: MistDemoConfiguration ) -> [String] { - if let raw = configuration.string(forKey: "discover.emails"), + if let raw = configuration.read(MistDemoKeys.Integration.discoverEmails), !raw.isEmpty { return @@ -114,10 +108,7 @@ public struct DiscoverConfig: Sendable, ConfigurationParseable { .filter { !$0.isEmpty } } - if configuration.bool( - forKey: MistDemoConstants.ConfigKeys.stdin, - default: false - ) { + if configuration.read(MistDemoKeys.Record.stdin) { let stdinData = FileHandle.standardInput.readDataToEndOfFile() guard let raw = String(data: stdinData, encoding: .utf8) else { return [] diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchChangesConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchChangesConfig.swift index b7768762..9cb0aa91 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchChangesConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchChangesConfig.swift @@ -89,22 +89,18 @@ public struct FetchChangesConfig: Sendable, ConfigurationParseable { ) } - let syncToken = configuration.string(forKey: "sync.token") + let syncToken = configuration.read(MistDemoKeys.Changes.syncToken) let zone = - configuration.string(forKey: "zone", default: "_defaultZone") - ?? "_defaultZone" + configuration.read(MistDemoKeys.Query.zone) let fetchAll = - configuration.bool(forKey: "fetch.all", default: false) - let limit = configuration.int(forKey: "limit") - let desiredKeys = configuration.commaSeparatedList( - forKey: MistDemoConstants.ConfigKeys.fields - ) + configuration.read(MistDemoKeys.Changes.fetchAll) + let limit = configuration.read(MistDemoKeys.Query.optionalLimit) + let desiredKeys = configuration.commaSeparatedList(MistDemoKeys.Record.fields) let desiredRecordTypes = configuration.commaSeparatedList( - forKey: MistDemoConstants.ConfigKeys.desiredRecordTypes + MistDemoKeys.Changes.desiredRecordTypes ) let outputString = - configuration.string(forKey: "output.format", default: "table") - ?? "table" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .table self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchDatabaseChangesConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchDatabaseChangesConfig.swift index 6147c319..1576a643 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchDatabaseChangesConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchDatabaseChangesConfig.swift @@ -77,13 +77,12 @@ public struct FetchDatabaseChangesConfig: Sendable, ConfigurationParseable { ) } - let syncToken = configuration.string(forKey: "sync.token") + let syncToken = configuration.read(MistDemoKeys.Changes.syncToken) let fetchAll = - configuration.bool(forKey: "fetch.all", default: false) - let limit = configuration.int(forKey: "limit") + configuration.read(MistDemoKeys.Changes.fetchAll) + let limit = configuration.read(MistDemoKeys.Query.optionalLimit) let outputString = - configuration.string(forKey: "output.format", default: "table") - ?? "table" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .table self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchZoneRecordChangesConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchZoneRecordChangesConfig.swift index c4ccb236..de40f3de 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchZoneRecordChangesConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchZoneRecordChangesConfig.swift @@ -91,25 +91,21 @@ public struct FetchZoneRecordChangesConfig: Sendable, ConfigurationParseable { } let zonesString = - configuration.string(forKey: "zone.names", default: "_defaultZone") - ?? "_defaultZone" + configuration.read(MistDemoKeys.Query.zoneNames) let zones = zonesString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } - let syncToken = configuration.string(forKey: "sync.token") + let syncToken = configuration.read(MistDemoKeys.Changes.syncToken) let fetchAll = - configuration.bool(forKey: "fetch.all", default: false) - let limit = configuration.int(forKey: "limit") - let desiredKeys = configuration.commaSeparatedList( - forKey: MistDemoConstants.ConfigKeys.fields - ) + configuration.read(MistDemoKeys.Changes.fetchAll) + let limit = configuration.read(MistDemoKeys.Query.optionalLimit) + let desiredKeys = configuration.commaSeparatedList(MistDemoKeys.Record.fields) let desiredRecordTypes = configuration.commaSeparatedList( - forKey: MistDemoConstants.ConfigKeys.desiredRecordTypes + MistDemoKeys.Changes.desiredRecordTypes ) let outputString = - configuration.string(forKey: "output.format", default: "table") - ?? "table" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .table self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Asset.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Asset.swift new file mode 100644 index 00000000..10419754 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Asset.swift @@ -0,0 +1,65 @@ +// +// MistDemoKeys+Asset.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit + +extension MistDemoKeys { + /// Asset upload and re-reference keys. + internal enum Asset { + /// `--file` / `CLOUDKIT_FILE`. + internal static let file = OptionalConfigKey( + "file", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--field-name` / `CLOUDKIT_FIELD_NAME`. + internal static let fieldName = ConfigKey( + "field-name", envPrefix: MistDemoKeys.envPrefix, default: "image" + ) + + /// `--source-record` / `CLOUDKIT_SOURCE_RECORD`. + internal static let sourceRecord = OptionalConfigKey( + "source-record", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--asset-field` / `CLOUDKIT_ASSET_FIELD`. + internal static let assetField = OptionalConfigKey( + "asset-field", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--target-record` / `CLOUDKIT_TARGET_RECORD`. + internal static let targetRecord = OptionalConfigKey( + "target-record", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--target-asset-field` / `CLOUDKIT_TARGET_ASSET_FIELD`. + internal static let targetAssetField = OptionalConfigKey( + "target-asset-field", envPrefix: MistDemoKeys.envPrefix + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Auth.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Auth.swift new file mode 100644 index 00000000..68d86e2f --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Auth.swift @@ -0,0 +1,87 @@ +// +// MistDemoKeys+Auth.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit + +extension MistDemoKeys { + /// Web-auth and sharee credential keys. + /// + /// These are MistDemo-only — neither BushelCloud nor CelestraCloud models web auth — + /// so they keep their historical bases plus ``MistDemoKeys/envPrefix``. + internal enum Auth { + /// `--api-token` / `CLOUDKIT_API_TOKEN`. + internal static let apiToken = ConfigKey( + "api.token", + envPrefix: MistDemoKeys.envPrefix, + default: "", + isSecret: true + ) + + /// `--web-auth-token` / `CLOUDKIT_WEB_AUTH_TOKEN`. + internal static let webAuthToken = OptionalConfigKey( + "web.auth.token", + envPrefix: MistDemoKeys.envPrefix, + isSecret: true + ) + + /// `--sharee-web-auth-token` / `CLOUDKIT_SHAREE_WEB_AUTH_TOKEN`. + internal static let shareeWebAuthToken = OptionalConfigKey( + "sharee.web.auth.token", + envPrefix: MistDemoKeys.envPrefix, + isSecret: true + ) + + /// `--sharee-email` / `CLOUDKIT_SHAREE_EMAIL`. + internal static let shareeEmail = OptionalConfigKey( + "sharee.email", + envPrefix: MistDemoKeys.envPrefix + ) + + /// `--reset-auth` / `CLOUDKIT_RESET_AUTH`. + internal static let resetAuth = ConfigKey( + "reset.auth", + envPrefix: MistDemoKeys.envPrefix, + default: false + ) + + /// `--skip-auth` / `CLOUDKIT_SKIP_AUTH`. + internal static let skipAuth = ConfigKey( + "skip.auth", + envPrefix: MistDemoKeys.envPrefix, + default: false + ) + + /// `--bad-credentials` / `CLOUDKIT_BAD_CREDENTIALS`. + internal static let badCredentials = ConfigKey( + "bad.credentials", + envPrefix: MistDemoKeys.envPrefix, + default: false + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+AuthModes.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+AuthModes.swift new file mode 100644 index 00000000..94e599a5 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+AuthModes.swift @@ -0,0 +1,55 @@ +// +// MistDemoKeys+AuthModes.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit + +extension MistDemoKeys { + /// Flags selecting which authentication modes the demo exercises. + internal enum AuthModes { + /// `--test-all-auth` / `CLOUDKIT_TEST_ALL_AUTH`. + internal static let testAllAuth = ConfigKey( + "test.all.auth", envPrefix: MistDemoKeys.envPrefix, default: false + ) + + /// `--test-api-only` / `CLOUDKIT_TEST_API_ONLY`. + internal static let testAPIOnly = ConfigKey( + "test.api.only", envPrefix: MistDemoKeys.envPrefix, default: false + ) + + /// `--test-adaptive` / `CLOUDKIT_TEST_ADAPTIVE`. + internal static let testAdaptive = ConfigKey( + "test.adaptive", envPrefix: MistDemoKeys.envPrefix, default: false + ) + + /// `--test-server-to-server` / `CLOUDKIT_TEST_SERVER_TO_SERVER`. + internal static let testServerToServer = ConfigKey( + "test.server.to.server", envPrefix: MistDemoKeys.envPrefix, default: false + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Changes.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Changes.swift new file mode 100644 index 00000000..02279248 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Changes.swift @@ -0,0 +1,50 @@ +// +// MistDemoKeys+Changes.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit + +extension MistDemoKeys { + /// Change-tracking keys for the `fetch-*-changes` commands. + internal enum Changes { + /// `--sync-token` / `CLOUDKIT_SYNC_TOKEN`. + internal static let syncToken = OptionalConfigKey( + "sync.token", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--fetch-all` / `CLOUDKIT_FETCH_ALL`, auto-paginate. + internal static let fetchAll = ConfigKey( + "fetch.all", envPrefix: MistDemoKeys.envPrefix, default: false + ) + + /// `--record-types` / `CLOUDKIT_RECORD_TYPES`, comma separated. + internal static let desiredRecordTypes = OptionalConfigKey( + "record.types", envPrefix: MistDemoKeys.envPrefix + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Integration.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Integration.swift new file mode 100644 index 00000000..d0c90545 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Integration.swift @@ -0,0 +1,81 @@ +// +// MistDemoKeys+Integration.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit + +extension MistDemoKeys { + /// Keys for the `test-public` / `test-private` integration runners and the + /// error/validation demos. + internal enum Integration { + /// `--record-count` / `CLOUDKIT_RECORD_COUNT`. + internal static let recordCount = ConfigKey( + "record.count", envPrefix: MistDemoKeys.envPrefix, default: 10 + ) + + /// `--asset-size` / `CLOUDKIT_ASSET_SIZE`, in bytes. + internal static let assetSize = ConfigKey( + "asset.size", envPrefix: MistDemoKeys.envPrefix, default: 100 + ) + + /// `--skip-cleanup` / `CLOUDKIT_SKIP_CLEANUP`. + internal static let skipCleanup = ConfigKey( + "skip.cleanup", envPrefix: MistDemoKeys.envPrefix, default: false + ) + + /// `--lookup-email` / `CLOUDKIT_LOOKUP_EMAIL`. + internal static let lookupEmail = OptionalConfigKey( + "lookup.email", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--share-short-guid` / `CLOUDKIT_SHARE_SHORT_GUID`. + internal static let shareShortGUID = OptionalConfigKey( + "share.short.guid", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--discover-emails` / `CLOUDKIT_DISCOVER_EMAILS`, comma separated. + internal static let discoverEmails = OptionalConfigKey( + "discover.emails", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--scenario` / `CLOUDKIT_SCENARIO`. + internal static let scenario = ConfigKey( + "scenario", envPrefix: MistDemoKeys.envPrefix, default: "all" + ) + + /// `--validate-skip-network` / `CLOUDKIT_VALIDATE_SKIP_NETWORK`. + internal static let validateSkipNetwork = ConfigKey( + "validate.skip-network", envPrefix: MistDemoKeys.envPrefix, default: false + ) + + /// `--validate-test-query` / `CLOUDKIT_VALIDATE_TEST_QUERY`. + internal static let validateTestQuery = ConfigKey( + "validate.test-query", envPrefix: MistDemoKeys.envPrefix, default: false + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Output.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Output.swift new file mode 100644 index 00000000..7e8633df --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Output.swift @@ -0,0 +1,52 @@ +// +// MistDemoKeys+Output.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit + +extension MistDemoKeys { + /// Presentation keys. + internal enum Output { + /// `--output-format` / `CLOUDKIT_OUTPUT_FORMAT`. + /// + /// One key with **one** default for every command. Previously read at 25 sites with + /// three different defaults (`Defaults.outputFormat`, a literal `"json"`, and a + /// literal `"table"`); `table` is now the single human-facing default and + /// `--output-format json` opts back in. + internal static let format = ConfigKey( + "output.format", + envPrefix: MistDemoKeys.envPrefix, + default: MistDemoConstants.Defaults.outputFormat + ) + + /// `--verbose` / `CLOUDKIT_VERBOSE`. + internal static let verbose = ConfigKey( + "verbose", envPrefix: MistDemoKeys.envPrefix, default: false + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Query.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Query.swift new file mode 100644 index 00000000..f1f3ee86 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Query.swift @@ -0,0 +1,104 @@ +// +// MistDemoKeys+Query.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit + +extension MistDemoKeys { + /// Query, zone-selection and pagination keys. + internal enum Query { + /// `--zone` / `CLOUDKIT_ZONE`. + internal static let zone = ConfigKey( + "zone", envPrefix: MistDemoKeys.envPrefix, default: MistDemoConstants.Defaults.zone + ) + + /// `--zone` without a default, for commands where it is optional. + internal static let optionalZone = OptionalConfigKey( + "zone", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--zone-owner` / `CLOUDKIT_ZONE_OWNER`, the `ownerName` of a shared zone. + internal static let zoneOwner = OptionalConfigKey( + "zone.owner", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--zone-name` / `CLOUDKIT_ZONE_NAME`. + internal static let zoneName = OptionalConfigKey( + "zone.name", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--zone-names` / `CLOUDKIT_ZONE_NAMES`, comma separated. + internal static let zoneNames = ConfigKey( + "zone.names", + envPrefix: MistDemoKeys.envPrefix, + default: MistDemoConstants.Defaults.zone + ) + + /// `--zones-include-default` / `CLOUDKIT_ZONES_INCLUDE_DEFAULT`. + internal static let zonesIncludeDefault = ConfigKey( + "zones.include-default", envPrefix: MistDemoKeys.envPrefix, default: false + ) + + /// `--zone-wide` / `CLOUDKIT_ZONE_WIDE`, query across all zones. + internal static let zoneWide = OptionalConfigKey( + "zone.wide", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--filter` / `CLOUDKIT_FILTER`, pipe separated. + internal static let filter = OptionalConfigKey( + "filter", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--sort` / `CLOUDKIT_SORT`. + internal static let sort = OptionalConfigKey( + "sort", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--limit` / `CLOUDKIT_LIMIT`, with a default for `query`. + internal static let limit = ConfigKey( + "limit", + envPrefix: MistDemoKeys.envPrefix, + default: MistDemoConstants.Defaults.queryLimit + ) + + /// `--limit` without a default, for the change-tracking commands. + internal static let optionalLimit = OptionalConfigKey( + "limit", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--offset` / `CLOUDKIT_OFFSET`. + internal static let offset = ConfigKey( + "offset", envPrefix: MistDemoKeys.envPrefix, default: 0 + ) + + /// `--continuation-marker` / `CLOUDKIT_CONTINUATION_MARKER`. + internal static let continuationMarker = OptionalConfigKey( + "continuation.marker", envPrefix: MistDemoKeys.envPrefix + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Record.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Record.swift new file mode 100644 index 00000000..47d93310 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Record.swift @@ -0,0 +1,118 @@ +// +// MistDemoKeys+Record.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit +internal import MistKit + +extension MistDemoKeys { + /// Record identity and payload keys shared by the CRUD commands. + internal enum Record { + /// `--record-type` / `CLOUDKIT_RECORD_TYPE`. + /// + /// The former `record.type` and `record-type` spellings both encoded to this same + /// flag and variable, so they were already aliases; they are unified here. + internal static let recordType = ConfigKey( + "record-type", + envPrefix: MistDemoKeys.envPrefix, + default: MistDemoConstants.Defaults.recordType + ) + + /// `--record-type` without a default, for commands where it is optional. + internal static let optionalRecordType = OptionalConfigKey( + "record-type", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--alternate-record-type` / `CLOUDKIT_ALTERNATE_RECORD_TYPE`. + internal static let alternateRecordType = ConfigKey( + "alternate-record-type", envPrefix: MistDemoKeys.envPrefix, default: "Article" + ) + + /// `--record-name` / `CLOUDKIT_RECORD_NAME`. + internal static let recordName = OptionalConfigKey( + "record-name", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--record-names` / `CLOUDKIT_RECORD_NAMES`, comma separated. + internal static let recordNames = OptionalConfigKey( + "record.names", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--record-change-tag` / `CLOUDKIT_RECORD_CHANGE_TAG`. + internal static let recordChangeTag = OptionalConfigKey( + "record.change.tag", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--fields` / `CLOUDKIT_FIELDS`. + internal static let fields = OptionalConfigKey( + "fields", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--field` / `CLOUDKIT_FIELD`, repeated or comma separated. + internal static let field = OptionalConfigKey( + "field", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--json-file` / `CLOUDKIT_JSON_FILE`. + internal static let jsonFile = OptionalConfigKey( + "json.file", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--operations-file` / `CLOUDKIT_OPERATIONS_FILE`. + internal static let operationsFile = OptionalConfigKey( + "operations.file", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--stdin` / `CLOUDKIT_STDIN`. + internal static let stdin = ConfigKey( + "stdin", envPrefix: MistDemoKeys.envPrefix, default: false + ) + + /// `--force` / `CLOUDKIT_FORCE`. + internal static let force = ConfigKey( + "force", envPrefix: MistDemoKeys.envPrefix, default: false + ) + + /// `--atomic` / `CLOUDKIT_ATOMIC`. + internal static let atomic = ConfigKey( + "atomic", envPrefix: MistDemoKeys.envPrefix, default: false + ) + + /// `--batch-size` / `CLOUDKIT_BATCH_SIZE`. + internal static let batchSize = ConfigKey( + "batch.size", + envPrefix: MistDemoKeys.envPrefix, + default: CloudKitService.maxRecordsPerRequest + ) + + /// `--numbers-as-strings` / `CLOUDKIT_NUMBERS_AS_STRINGS`. + internal static let numbersAsStrings = OptionalConfigKey( + "numbers.as.strings", envPrefix: MistDemoKeys.envPrefix + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Server.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Server.swift new file mode 100644 index 00000000..03a5ed87 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Server.swift @@ -0,0 +1,81 @@ +// +// MistDemoKeys+Server.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit + +extension MistDemoKeys { + /// Local web-server and browser keys used by `web` and the auth-token flows. + internal enum Server { + /// `--database` / `CLOUDKIT_DATABASE`. + /// + /// Defaults to `public`, matching the historical runtime default. The former + /// `MistDemoConstants.Defaults.database` constant said `private`, contradicted the + /// code, and was never read. + internal static let database = ConfigKey( + "database", + envPrefix: MistDemoKeys.envPrefix, + default: "public" + ) + + /// `--host` / `CLOUDKIT_HOST`. + internal static let host = ConfigKey( + "host", + envPrefix: MistDemoKeys.envPrefix, + default: MistDemoConstants.Defaults.host + ) + + /// `--port` / `CLOUDKIT_PORT`. + internal static let port = ConfigKey( + "port", + envPrefix: MistDemoKeys.envPrefix, + default: MistDemoConstants.Defaults.port + ) + + /// `--auth-timeout` / `CLOUDKIT_AUTH_TIMEOUT`, in seconds. + internal static let authTimeout = ConfigKey( + "auth.timeout", + envPrefix: MistDemoKeys.envPrefix, + default: 300 + ) + + /// `--browser` / `CLOUDKIT_BROWSER`. + internal static let browser = ConfigKey( + "browser", + envPrefix: MistDemoKeys.envPrefix, + default: false + ) + + /// `--no-browser` / `CLOUDKIT_NO_BROWSER`. + internal static let noBrowser = ConfigKey( + "no.browser", + envPrefix: MistDemoKeys.envPrefix, + default: false + ) + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/CelestraConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Sharing.swift similarity index 57% rename from Examples/CelestraCloud/Sources/CelestraCloudKit/CelestraConfig.swift rename to Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Sharing.swift index 6ec33a40..018dace2 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/CelestraConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Sharing.swift @@ -1,6 +1,6 @@ // -// CelestraConfig.swift -// CelestraCloud +// MistDemoKeys+Sharing.swift +// MistDemo // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -27,31 +27,24 @@ // OTHER DEALINGS IN THE SOFTWARE. // -internal import Foundation -public import MistKit +internal import ConfigKeyKit -// MARK: - Shared Configuration - -/// Shared configuration helper for creating CloudKit service -public enum CelestraConfig { - /// Create CloudKit service from validated configuration - public static func createCloudKitService(from config: ValidatedCloudKitConfiguration) throws - -> CloudKitService - { - // Read private key from file - let privateKeyPEM = try String(contentsOfFile: config.privateKeyPath, encoding: .utf8) +extension MistDemoKeys { + /// Share resolve/accept keys. + internal enum Sharing { + /// `--short-guid` / `CLOUDKIT_SHORT_GUID`, comma separated. + internal static let shortGUID = OptionalConfigKey( + "short.guid", envPrefix: MistDemoKeys.envPrefix + ) - // Create token manager for server-to-server authentication - let tokenManager = try ServerToServerAuthManager( - keyID: config.keyID, - pemString: privateKeyPEM + /// `--share-url` / `CLOUDKIT_SHARE_URL`, comma separated. + internal static let shareURL = OptionalConfigKey( + "share.url", envPrefix: MistDemoKeys.envPrefix ) - // Create and return CloudKit service - return CloudKitService( - containerIdentifier: config.containerID, - tokenManager: tokenManager, - environment: config.environment + /// `--fetch-root-record` / `CLOUDKIT_FETCH_ROOT_RECORD`. + internal static let fetchRootRecord = OptionalConfigKey( + "fetch.root.record", envPrefix: MistDemoKeys.envPrefix ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Subscription.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Subscription.swift new file mode 100644 index 00000000..9e68b5e8 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Subscription.swift @@ -0,0 +1,70 @@ +// +// MistDemoKeys+Subscription.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit + +extension MistDemoKeys { + /// Subscription and push-token keys. + internal enum Subscription { + /// `--subscription-id` / `CLOUDKIT_SUBSCRIPTION_ID`. + internal static let subscriptionID = OptionalConfigKey( + "subscription-id", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--subscription-ids` / `CLOUDKIT_SUBSCRIPTION_IDS`, comma separated. + internal static let subscriptionIDs = ConfigKey( + "subscription-ids", envPrefix: MistDemoKeys.envPrefix, default: "" + ) + + /// `--fires-on` / `CLOUDKIT_FIRES_ON`, comma separated. + internal static let firesOn = ConfigKey( + "fires-on", envPrefix: MistDemoKeys.envPrefix, default: "create,update,delete" + ) + + /// `--operation` / `CLOUDKIT_OPERATION`. + internal static let operation = ConfigKey( + "operation", envPrefix: MistDemoKeys.envPrefix, default: "create" + ) + + /// `--apns-token` / `CLOUDKIT_APNS_TOKEN`. + internal static let apnsToken = OptionalConfigKey( + "apns-token", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--apns-environment` / `CLOUDKIT_APNS_ENVIRONMENT`. + internal static let apnsEnvironment = OptionalConfigKey( + "apns-environment", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--client-id` / `CLOUDKIT_CLIENT_ID`. + internal static let clientID = OptionalConfigKey( + "client-id", envPrefix: MistDemoKeys.envPrefix + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys.swift new file mode 100644 index 00000000..c94e5b09 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys.swift @@ -0,0 +1,57 @@ +// +// MistDemoKeys.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit +internal import MistKitConfiguration + +/// Typed configuration keys for MistDemo. +/// +/// Every key resolves to a command-line flag and an environment variable: +/// +/// - **CloudKit credential keys** come from ``CloudKitConfigurationKeys`` (shared with +/// MistKitConfiguration, BushelCloud and CelestraCloud). +/// - **Every other key** keeps its historical base and passes ``envPrefix``, faithfully +/// reproducing the blanket `prefixKeys(with: "cloudkit")` the provider stack used to +/// apply to the whole key space. Bases stay unchanged so no flag or variable moves. +/// +/// Bases must be **dash-case** within a component (`cloudkit.key-id`, never +/// `cloudkit.key_id`): `CLIKeyEncoder` joins components verbatim, so an underscore +/// survives into an unusable flag and silently defeats secret redaction. +internal enum MistDemoKeys { + /// Environment-variable prefix applied to every non-CloudKit key. + internal static let envPrefix = "CLOUDKIT" + + /// CloudKit credential keys with MistDemo's container default. + /// + /// `environment` is optional on the package key; callers apply + /// ``MistDemoConstants/Defaults/environment`` when the value is absent. + internal static let cloudKit = CloudKitConfigurationKeys( + defaultContainerID: MistDemoConstants.Defaults.containerIdentifier + ) +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListSubscriptionsConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListSubscriptionsConfig.swift index f5bc571c..fd2be891 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListSubscriptionsConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListSubscriptionsConfig.swift @@ -67,10 +67,7 @@ public struct ListSubscriptionsConfig: Sendable, ConfigurationParseable { } let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: "table" - ) ?? "table" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .table self.init(base: baseConfig, output: output) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListZonesConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListZonesConfig.swift index d4206f9e..7ebc1ea3 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListZonesConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListZonesConfig.swift @@ -71,16 +71,10 @@ public struct ListZonesConfig: Sendable, ConfigurationParseable { ) } - let includeDefault = configuration.bool( - forKey: "zones.include-default", - default: false - ) + let includeDefault = configuration.read(MistDemoKeys.Query.zonesIncludeDefault) let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: "table" - ) ?? "table" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .table self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupConfig.swift index edc14289..c1b6a381 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupConfig.swift @@ -84,15 +84,13 @@ public struct LookupConfig: Sendable, ConfigurationParseable { // --record-names accepts a comma-separated list. // --record-name (singular) also works for a single name. let recordNames: [String] - if let raw = configReader.string(forKey: MistDemoConstants.ConfigKeys.recordNames) { + if let raw = configReader.read(MistDemoKeys.Record.recordNames) { recordNames = raw .split(separator: ",") .map { String($0).trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } - } else if let single = configReader.string( - forKey: MistDemoConstants.ConfigKeys.recordName - ) { + } else if let single = configReader.read(MistDemoKeys.Record.recordName) { recordNames = [single] } else { recordNames = [] @@ -102,26 +100,18 @@ public struct LookupConfig: Sendable, ConfigurationParseable { throw LookupError.recordNamesRequired } - let fieldsString = configReader.string( - forKey: MistDemoConstants.ConfigKeys.fields - ) + let fieldsString = configReader.read(MistDemoKeys.Record.fields) let fields = fieldsString? .split(separator: ",") .map { String($0).trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } let outputString = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: MistDemoConstants.Defaults.outputFormat - ) ?? MistDemoConstants.Defaults.outputFormat + configReader.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json let batchSize = - configReader.int( - forKey: MistDemoConstants.ConfigKeys.batchSize, - default: CloudKitService.maxRecordsPerRequest - ) ?? CloudKitService.maxRecordsPerRequest + configReader.read(MistDemoKeys.Record.batchSize) self.init( base: baseConfig, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupSubscriptionConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupSubscriptionConfig.swift index ac5e23b2..a17267e2 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupSubscriptionConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupSubscriptionConfig.swift @@ -70,7 +70,7 @@ public struct LookupSubscriptionConfig: Sendable, ConfigurationParseable { ) } - let idsString = configuration.string(forKey: "subscription-ids") ?? "" + let idsString = configuration.read(MistDemoKeys.Subscription.subscriptionIDs) let subscriptionIDs = idsString .split(separator: ",") @@ -78,10 +78,7 @@ public struct LookupSubscriptionConfig: Sendable, ConfigurationParseable { .filter { !$0.isEmpty } let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: "json" - ) ?? "json" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupZonesConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupZonesConfig.swift index f85215ba..d506cada 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupZonesConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupZonesConfig.swift @@ -71,17 +71,13 @@ public struct LookupZonesConfig: Sendable, ConfigurationParseable { } let zoneNamesString = - configuration.string( - forKey: "zone.names", - default: "_defaultZone" - ) ?? "_defaultZone" + configuration.read(MistDemoKeys.Query.zoneNames) let zoneNames = zoneNamesString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } let outputString = - configuration.string(forKey: "output.format", default: "table") - ?? "table" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .table self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+Parsing.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+Parsing.swift index a793b4c6..b9c7a20e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+Parsing.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+Parsing.swift @@ -28,6 +28,7 @@ // internal import MistKit +internal import MistKitConfiguration extension MistDemoConfig { internal struct CoreConfig { @@ -62,21 +63,14 @@ extension MistDemoConfig { _ config: MistDemoConfiguration ) throws -> CoreConfig { let containerIdentifier = - config.string( - forKey: "container.identifier", - default: MistDemoConstants.Defaults.containerIdentifier - ) ?? MistDemoConstants.Defaults.containerIdentifier + config.read(MistDemoKeys.cloudKit.containerID) let apiToken = - config.string( - forKey: "api.token", - default: "", - isSecret: true - ) ?? "" + config.read(MistDemoKeys.Auth.apiToken) - let defaultEnv = MistKit.Environment.development.rawValue + let defaultEnv = MistDemoConstants.Defaults.environment let envString = - config.string(forKey: "environment", default: defaultEnv) ?? defaultEnv + config.read(MistDemoKeys.cloudKit.environment) ?? defaultEnv guard let environment = MistKit.Environment(caseInsensitive: envString) else { throw ConfigurationError.invalidEnvironment(envString) } @@ -92,18 +86,10 @@ extension MistDemoConfig { _ config: MistDemoConfiguration ) -> AuthConfig { AuthConfig( - webAuthToken: config.string( - forKey: "web.auth.token", - isSecret: true - ), - keyID: config.string(forKey: "key.id"), - privateKey: config.string( - forKey: "private.key", - isSecret: true - ), - privateKeyFile: config.string( - forKey: "private.key.path" - ) + webAuthToken: config.read(MistDemoKeys.Auth.webAuthToken), + keyID: config.read(MistDemoKeys.cloudKit.keyID), + privateKey: config.read(MistDemoKeys.cloudKit.privateKey), + privateKeyFile: config.read(MistDemoKeys.cloudKit.privateKeyPath) ) } @@ -111,22 +97,13 @@ extension MistDemoConfig { _ config: MistDemoConfiguration ) -> ServerConfig { let host = - config.string( - forKey: "host", - default: "127.0.0.1" - ) ?? "127.0.0.1" + config.read(MistDemoKeys.Server.host) let port = - config.int( - forKey: "port", - default: 8_080 - ) ?? 8_080 + config.read(MistDemoKeys.Server.port) let authTimeout = Double( - config.int( - forKey: "auth.timeout", - default: 300 - ) ?? 300 + config.read(MistDemoKeys.Server.authTimeout) ) return ServerConfig( @@ -140,30 +117,12 @@ extension MistDemoConfig { _ config: MistDemoConfiguration ) -> FlagConfig { FlagConfig( - skipAuth: config.bool( - forKey: "skip.auth", - default: false - ), - testAllAuth: config.bool( - forKey: "test.all.auth", - default: false - ), - testApiOnly: config.bool( - forKey: "test.api.only", - default: false - ), - testAdaptive: config.bool( - forKey: "test.adaptive", - default: false - ), - testServerToServer: config.bool( - forKey: "test.server.to.server", - default: false - ), - badCredentials: config.bool( - forKey: "bad.credentials", - default: false - ) + skipAuth: config.read(MistDemoKeys.Auth.skipAuth), + testAllAuth: config.read(MistDemoKeys.AuthModes.testAllAuth), + testApiOnly: config.read(MistDemoKeys.AuthModes.testAPIOnly), + testAdaptive: config.read(MistDemoKeys.AuthModes.testAdaptive), + testServerToServer: config.read(MistDemoKeys.AuthModes.testServerToServer), + badCredentials: config.read(MistDemoKeys.Auth.badCredentials) ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift index 858dd4eb..6aff4a40 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift @@ -105,7 +105,7 @@ public struct MistDemoConfig: Sendable, ConfigurationParseable { self.environment = core.environment let databaseString = - config.string(forKey: "database", default: "public") ?? "public" + config.read(MistDemoKeys.Server.database) guard let database = MistDemoConfig.parseDatabase(databaseString) else { throw ConfigurationError.invalidDatabase(databaseString) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfiguration.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfiguration.swift index eee3b02f..2803ca63 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfiguration.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfiguration.swift @@ -27,11 +27,16 @@ // OTHER DEALINGS IN THE SOFTWARE. // +internal import ConfigKeyKit internal import Configuration internal import Foundation internal import SystemPackage /// Swift Configuration-based setup for MistDemo. +/// +/// Wraps a `ConfigReader` and resolves ``MistDemoKeys`` values through ConfigKeyKit's +/// ``ConfigValueReading``, which consults the command line first and then the +/// environment before falling back to each key's own default. public struct MistDemoConfiguration: Sendable { // MARK: Private @@ -46,122 +51,82 @@ public struct MistDemoConfiguration: Sendable { allowMissing: true ) + // No `prefixKeys(with: "cloudkit")`: the `CLOUDKIT_` namespace now rides on each + // key via `envPrefix` (or a `cloudkit.` base), so prefixing here would produce + // `CLOUDKIT_CLOUDKIT_*`. No `InMemoryProvider` either — defaults live on the keys. self.configReader = ConfigReader(providers: [ - // 1. Command line arguments (highest priority) CommandLineArgumentsProvider(), - - // 2. Process environment variables (CLOUDKIT_ prefix) - EnvironmentVariablesProvider().prefixKeys(with: "cloudkit"), - - // 3. .env file variables (CLOUDKIT_ prefix) - envProvider.prefixKeys(with: "cloudkit"), - - // 4. In-memory defaults (lowest priority) - InMemoryProvider(values: [ - "port": 8_080, - "skip.auth": false, - "test.all.auth": false, - "test.api.only": false, - "test.adaptive": false, - "test.server.to.server": false, - ]), + EnvironmentVariablesProvider(), + envProvider, ]) } - /// Internal initializer for testing with InMemoryProvider. - internal init(testProvider: InMemoryProvider) { - self.configReader = ConfigReader(providers: [ - testProvider - ]) + /// Creates an instance over an injected reader. + /// + /// Tests use this with the real providers over injected argv/environment, so key + /// normalization and value coercion behave exactly as they do in production. + internal init(configReader: ConfigReader) { + self.configReader = configReader } - // MARK: Public - - /// Read string value with hierarchy: CLI -> ENV -> defaults. - public func string( - forKey key: String, - default defaultValue: String? = nil, - isSecret: Bool = false - ) -> String? { - if let defaultValue = defaultValue { - return configReader.string( - forKey: Configuration.ConfigKey(key), - isSecret: isSecret, - default: defaultValue - ) - } else { - return configReader.string( - forKey: Configuration.ConfigKey(key), - isSecret: isSecret - ) - } - } + // MARK: Internal - /// Read required string value. - public func requiredString( - forKey key: String, - isSecret: Bool = false - ) throws -> String { - try configReader.requiredString( - forKey: Configuration.ConfigKey(key), - isSecret: isSecret - ) - } + /// Reads a required string value. + internal func read(_ key: ConfigKeyKit.ConfigKey) -> String { configReader.read(key) } - /// Read int value with hierarchy. - public func int( - forKey key: String, - default defaultValue: Int? = nil - ) -> Int? { - if let defaultValue = defaultValue { - return configReader.int( - forKey: Configuration.ConfigKey(key), - default: defaultValue - ) - } else { - return configReader.int( - forKey: Configuration.ConfigKey(key) - ) - } + /// Reads an optional string value. + internal func read(_ key: ConfigKeyKit.OptionalConfigKey) -> String? { + configReader.read(key) } - /// Read required int value. - public func requiredInt(forKey key: String) throws -> Int { - try configReader.requiredInt( - forKey: Configuration.ConfigKey(key) - ) - } + /// Reads a required integer value. + internal func read(_ key: ConfigKeyKit.ConfigKey) -> Int { configReader.read(key) } - /// Read bool value with hierarchy. - public func bool( - forKey key: String, - default defaultValue: Bool = false - ) -> Bool { - configReader.bool( - forKey: Configuration.ConfigKey(key), - default: defaultValue - ) - } + /// Reads an optional integer value. + internal func read(_ key: ConfigKeyKit.OptionalConfigKey) -> Int? { configReader.read(key) } - /// Read an optional bool: `nil` when the key is absent, else its parsed value. + /// Reads a required boolean value. /// - /// Distinguishes "flag not provided" (`nil` → omit from the request) from an - /// explicit `false`, unlike `bool(forKey:default:)` which collapses both. - public func optionalBool(forKey key: String) -> Bool? { - string(forKey: key) != nil ? bool(forKey: key) : nil + /// Deliberately **not** ConfigKeyKit's `read(_:)`: that resolves booleans by probing + /// `string(forKey:)`, and swift-configuration's CLI provider surfaces a valueless flag + /// only through `bool(forKey:)`. Routing through the string path would make every bare + /// flag (`--force`, `--stdin`, `--verbose`, …) silently read as its default. + internal func read(_ key: ConfigKeyKit.ConfigKey) -> Bool { + resolveBool(key) ?? key.defaultValue + } + + // swiftlint:disable:next discouraged_optional_boolean + /// Reads an optional boolean, distinguishing "flag absent" from an explicit `false`. + internal func read(_ key: ConfigKeyKit.OptionalConfigKey) -> Bool? { + resolveBool(key) } - /// Read a comma-separated list of strings, or `nil` when the key is absent. - public func commaSeparatedList(forKey key: String) -> [String]? { - string(forKey: key)? + /// Reads a comma-separated list, or `nil` when the key is absent. + internal func commaSeparatedList(_ key: ConfigKeyKit.OptionalConfigKey) -> [String]? { + read(key)? .split(separator: ",") .map { String($0).trimmingCharacters(in: .whitespaces) } } - /// Read a pipe-separated list of strings from configuration. - public func filterStrings(forKey key: String) -> [String] { - string(forKey: key)? + /// Reads a pipe-separated list, empty when the key is absent. + internal func filterStrings(_ key: ConfigKeyKit.OptionalConfigKey) -> [String] { + read(key)? .split(separator: "|") .map { String($0).trimmingCharacters(in: .whitespaces) } ?? [] } + + // MARK: Private + + // swiftlint:disable:next discouraged_optional_boolean + /// Resolves a boolean across sources via `bool(forKey:)`, which reports a valueless + /// command-line flag as `true`, an absent key as `nil`, and `false`/`no`/`0` as `false`. + private func resolveBool(_ key: any ConfigurationKey) -> Bool? { + for source in ConfigKeySource.priority { + guard let keyString = key.key(for: source) else { continue } + if let value = configReader.bool(forKey: Configuration.ConfigKey(keyString)) { + return value + } + } + return nil + } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyConfig.swift index 9625d144..94572684 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyConfig.swift @@ -92,26 +92,14 @@ public struct ModifyConfig: Sendable, ConfigurationParseable { configReader ) - let atomic = configReader.bool( - forKey: MistDemoConstants.ConfigKeys.atomic, - default: false - ) + let atomic = configReader.read(MistDemoKeys.Record.atomic) - let zone = configReader.string( - forKey: MistDemoConstants.ConfigKeys.zone - ) - let desiredKeys = configReader.commaSeparatedList( - forKey: MistDemoConstants.ConfigKeys.fields - ) - let numbersAsStrings = configReader.optionalBool( - forKey: MistDemoConstants.ConfigKeys.numbersAsStrings - ) + let zone = configReader.read(MistDemoKeys.Query.optionalZone) + let desiredKeys = configReader.commaSeparatedList(MistDemoKeys.Record.fields) + let numbersAsStrings = configReader.read(MistDemoKeys.Record.numbersAsStrings) let outputString = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: MistDemoConstants.Defaults.outputFormat - ) ?? MistDemoConstants.Defaults.outputFormat + configReader.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( @@ -146,9 +134,7 @@ public struct ModifyConfig: Sendable, ConfigurationParseable { private static func parseOperationsFromSources( _ configReader: MistDemoConfiguration ) throws -> [ModifyOperationInput] { - if let path = configReader.string( - forKey: MistDemoConstants.ConfigKeys.operationsFile - ) { + if let path = configReader.read(MistDemoKeys.Record.operationsFile) { do { let data = try Data( contentsOf: URL(fileURLWithPath: path) @@ -164,10 +150,7 @@ public struct ModifyConfig: Sendable, ConfigurationParseable { } } - if configReader.bool( - forKey: MistDemoConstants.ConfigKeys.stdin, - default: false - ) { + if configReader.read(MistDemoKeys.Record.stdin) { let stdinData = FileHandle.standardInput.readDataToEndOfFile() guard !stdinData.isEmpty else { throw ModifyError.emptyStdin diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifySubscriptionsConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifySubscriptionsConfig.swift index fc5c04d8..2bb011b0 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifySubscriptionsConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifySubscriptionsConfig.swift @@ -82,7 +82,7 @@ public struct ModifySubscriptionsConfig: Sendable, ConfigurationParseable { ) } - let firesOnString = configuration.string(forKey: "fires-on") ?? "create,update,delete" + let firesOnString = configuration.read(MistDemoKeys.Subscription.firesOn) let firesOn = firesOnString .split(separator: ",") @@ -90,17 +90,14 @@ public struct ModifySubscriptionsConfig: Sendable, ConfigurationParseable { .filter { !$0.isEmpty } let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: "json" - ) ?? "json" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( base: baseConfig, - operation: configuration.string(forKey: "operation", default: "create") ?? "create", - subscriptionID: configuration.string(forKey: "subscription-id"), - recordType: configuration.string(forKey: "record-type"), + operation: configuration.read(MistDemoKeys.Subscription.operation), + subscriptionID: configuration.read(MistDemoKeys.Subscription.subscriptionID), + recordType: configuration.read(MistDemoKeys.Record.optionalRecordType), firesOn: firesOn, output: output ) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyZonesConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyZonesConfig.swift index b9a9d8f7..359dc026 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyZonesConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyZonesConfig.swift @@ -73,10 +73,7 @@ public struct ModifyZonesConfig: Sendable, ConfigurationParseable { let operations = try Self.parseOperationsFromSources(configuration) let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: MistDemoConstants.Defaults.outputFormat - ) ?? MistDemoConstants.Defaults.outputFormat + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( @@ -108,9 +105,7 @@ public struct ModifyZonesConfig: Sendable, ConfigurationParseable { private static func parseOperationsFromSources( _ configReader: MistDemoConfiguration ) throws -> [ZoneOperationInput] { - if let path = configReader.string( - forKey: MistDemoConstants.ConfigKeys.operationsFile - ) { + if let path = configReader.read(MistDemoKeys.Record.operationsFile) { do { let data = try Data(contentsOf: URL(fileURLWithPath: path)) return try parseOperations(from: data) @@ -124,10 +119,7 @@ public struct ModifyZonesConfig: Sendable, ConfigurationParseable { } } - if configReader.bool( - forKey: MistDemoConstants.ConfigKeys.stdin, - default: false - ) { + if configReader.read(MistDemoKeys.Record.stdin) { let stdinData = FileHandle.standardInput.readDataToEndOfFile() guard !stdinData.isEmpty else { throw ModifyZonesError.emptyStdin diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ProbeDuplicateSubscriptionConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ProbeDuplicateSubscriptionConfig.swift index e0f2bbde..7a666d44 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ProbeDuplicateSubscriptionConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ProbeDuplicateSubscriptionConfig.swift @@ -75,10 +75,10 @@ public struct ProbeDuplicateSubscriptionConfig: Sendable, ConfigurationParseable } let recordType = - configuration.string(forKey: "record-type", default: "Note") ?? "Note" + configuration.read(MistDemoKeys.Record.recordType) let alternateRecordType = - configuration.string(forKey: "alternate-record-type", default: "Article") ?? "Article" - let verbose = configuration.bool(forKey: "verbose", default: false) + configuration.read(MistDemoKeys.Record.alternateRecordType) + let verbose = configuration.read(MistDemoKeys.Output.verbose) self.init( base: baseConfig, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift index 3a0fc3f9..88a12d36 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift @@ -51,26 +51,14 @@ extension QueryConfig { _ configReader: MistDemoConfiguration ) throws -> ParsedOptions { let zone = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.zone, - default: MistDemoConstants.Defaults.zone - ) ?? MistDemoConstants.Defaults.zone - let zoneOwner = configReader.string( - forKey: MistDemoConstants.ConfigKeys.zoneOwner - ) + configReader.read(MistDemoKeys.Query.zone) + let zoneOwner = configReader.read(MistDemoKeys.Query.zoneOwner) let recordType = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.recordType, - default: MistDemoConstants.Defaults.recordType - ) ?? MistDemoConstants.Defaults.recordType + configReader.read(MistDemoKeys.Record.recordType) - let filters = configReader.filterStrings( - forKey: MistDemoConstants.ConfigKeys.filter - ) + let filters = configReader.filterStrings(MistDemoKeys.Query.filter) - let sortString = configReader.string( - forKey: MistDemoConstants.ConfigKeys.sort - ) + let sortString = configReader.read(MistDemoKeys.Query.sort) let sort = try parseSortOption(sortString) let pagination = try parsePagination(configReader) @@ -89,10 +77,7 @@ extension QueryConfig { _ configReader: MistDemoConfiguration ) throws -> ParsedPagination { let limit = - configReader.int( - forKey: MistDemoConstants.ConfigKeys.limit, - default: MistDemoConstants.Defaults.queryLimit - ) ?? MistDemoConstants.Defaults.queryLimit + configReader.read(MistDemoKeys.Query.limit) guard limit >= MistDemoConstants.Limits.minQueryLimit, limit <= MistDemoConstants.Limits.maxQueryLimit @@ -101,24 +86,17 @@ extension QueryConfig { } let offset = - configReader.int(forKey: "offset", default: 0) ?? 0 + configReader.read(MistDemoKeys.Query.offset) - let fieldsString = configReader.string( - forKey: MistDemoConstants.ConfigKeys.fields - ) + let fieldsString = configReader.read(MistDemoKeys.Record.fields) let fields = fieldsString?.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } - let continuationMarker = configReader.string( - forKey: "continuation.marker" - ) + let continuationMarker = configReader.read(MistDemoKeys.Query.continuationMarker) let outputString = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: MistDemoConstants.Defaults.outputFormat - ) ?? MistDemoConstants.Defaults.outputFormat + configReader.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json return ParsedPagination( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift index 6dc91beb..9313987d 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift @@ -125,12 +125,8 @@ public struct QueryConfig: Sendable, ConfigurationParseable { offset: parsed.pagination.offset, fields: parsed.pagination.fields, continuationMarker: parsed.pagination.continuationMarker, - zoneWide: configReader.optionalBool( - forKey: MistDemoConstants.ConfigKeys.zoneWide - ), - numbersAsStrings: configReader.optionalBool( - forKey: MistDemoConstants.ConfigKeys.numbersAsStrings - ), + zoneWide: configReader.read(MistDemoKeys.Query.zoneWide), + numbersAsStrings: configReader.read(MistDemoKeys.Record.numbersAsStrings), output: parsed.pagination.output ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift index 3ddf574c..f0a60cde 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift @@ -83,17 +83,14 @@ public struct RegisterTokenConfig: Sendable, ConfigurationParseable { } let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: "json" - ) ?? "json" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( base: baseConfig, - apnsToken: configuration.string(forKey: "apns-token"), - apnsEnvironment: configuration.string(forKey: "apns-environment"), - clientId: configuration.string(forKey: "client-id"), + apnsToken: configuration.read(MistDemoKeys.Subscription.apnsToken), + apnsEnvironment: configuration.read(MistDemoKeys.Subscription.apnsEnvironment), + clientId: configuration.read(MistDemoKeys.Subscription.clientID), output: output ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/RereferenceAssetConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/RereferenceAssetConfig.swift index 6a1d8e95..b3f96f87 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/RereferenceAssetConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/RereferenceAssetConfig.swift @@ -83,18 +83,15 @@ public struct RereferenceAssetConfig: Sendable, ConfigurationParseable { } let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: "json" - ) ?? "json" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( base: baseConfig, - sourceRecord: configuration.string(forKey: "source-record"), - assetField: configuration.string(forKey: "asset-field"), - targetRecord: configuration.string(forKey: "target-record"), - targetAssetField: configuration.string(forKey: "target-asset-field"), + sourceRecord: configuration.read(MistDemoKeys.Asset.sourceRecord), + assetField: configuration.read(MistDemoKeys.Asset.assetField), + targetRecord: configuration.read(MistDemoKeys.Asset.targetRecord), + targetAssetField: configuration.read(MistDemoKeys.Asset.targetAssetField), output: output ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ResolveConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ResolveConfig.swift index fdae5da7..3347229b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ResolveConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ResolveConfig.swift @@ -80,18 +80,11 @@ public struct ResolveConfig: Sendable, ConfigurationParseable { } let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: "json" - ) ?? "json" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json - let fetchRootRecord = configuration.optionalBool( - forKey: "fetch.root.record" - ) - let fields = configuration.commaSeparatedList( - forKey: MistDemoConstants.ConfigKeys.fields - ) + let fetchRootRecord = configuration.read(MistDemoKeys.Sharing.fetchRootRecord) + let fields = configuration.commaSeparatedList(MistDemoKeys.Record.fields) let shortGUIDs = Self.parseShortGUIDs(from: configuration) guard !shortGUIDs.isEmpty else { @@ -114,13 +107,13 @@ public struct ResolveConfig: Sendable, ConfigurationParseable { internal static func parseShortGUIDs( from configuration: MistDemoConfiguration ) -> [String] { - if let fromGUIDs = configuration.commaSeparatedList(forKey: "short.guid"), + if let fromGUIDs = configuration.commaSeparatedList(MistDemoKeys.Sharing.shortGUID), !fromGUIDs.isEmpty { return fromGUIDs } - guard let shareURLs = configuration.commaSeparatedList(forKey: "share.url") + guard let shareURLs = configuration.commaSeparatedList(MistDemoKeys.Sharing.shareURL) else { return [] } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift index 77e156b5..6617a5b3 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift @@ -116,10 +116,7 @@ public struct TestPrivateConfig: Sendable, ConfigurationParseable { } guard - let shareeWebAuthToken = configuration.string( - forKey: "sharee.web.auth.token", - isSecret: true - ), + let shareeWebAuthToken = configuration.read(MistDemoKeys.Auth.shareeWebAuthToken), !shareeWebAuthToken.isEmpty else { throw ConfigurationError.missingRequired( @@ -130,7 +127,7 @@ public struct TestPrivateConfig: Sendable, ConfigurationParseable { } guard - let shareeEmail = configuration.string(forKey: "sharee.email"), + let shareeEmail = configuration.read(MistDemoKeys.Auth.shareeEmail), !shareeEmail.isEmpty else { throw ConfigurationError.missingRequired( @@ -141,15 +138,15 @@ public struct TestPrivateConfig: Sendable, ConfigurationParseable { } let recordCount = - configuration.int(forKey: "record.count", default: 10) ?? 10 + configuration.read(MistDemoKeys.Integration.recordCount) let assetSizeKB = - configuration.int(forKey: "asset.size", default: 100) ?? 100 + configuration.read(MistDemoKeys.Integration.assetSize) let skipCleanup = - configuration.bool(forKey: "skip.cleanup", default: false) + configuration.read(MistDemoKeys.Integration.skipCleanup) let verbose = - configuration.bool(forKey: "verbose", default: false) - let lookupEmail = configuration.string(forKey: "lookup.email") - let shareShortGUID = configuration.string(forKey: "share.short.guid") + configuration.read(MistDemoKeys.Output.verbose) + let lookupEmail = configuration.read(MistDemoKeys.Integration.lookupEmail) + let shareShortGUID = configuration.read(MistDemoKeys.Integration.shareShortGUID) self.init( base: baseConfig, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPublicConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPublicConfig.swift index 3c8ebb85..a7c52410 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPublicConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPublicConfig.swift @@ -88,15 +88,15 @@ public struct TestPublicConfig: Sendable, ConfigurationParseable { } let recordCount = - configuration.int(forKey: "record.count", default: 10) ?? 10 + configuration.read(MistDemoKeys.Integration.recordCount) let assetSizeKB = - configuration.int(forKey: "asset.size", default: 100) ?? 100 + configuration.read(MistDemoKeys.Integration.assetSize) let skipCleanup = - configuration.bool(forKey: "skip.cleanup", default: false) + configuration.read(MistDemoKeys.Integration.skipCleanup) let verbose = - configuration.bool(forKey: "verbose", default: false) - let lookupEmail = configuration.string(forKey: "lookup.email") - let shareShortGUID = configuration.string(forKey: "share.short.guid") + configuration.read(MistDemoKeys.Output.verbose) + let lookupEmail = configuration.read(MistDemoKeys.Integration.lookupEmail) + let shareShortGUID = configuration.read(MistDemoKeys.Integration.shareShortGUID) self.init( base: baseConfig, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/UpdateConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/UpdateConfig.swift index 214576e3..2e244aa2 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/UpdateConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/UpdateConfig.swift @@ -94,40 +94,26 @@ public struct UpdateConfig: Sendable, ConfigurationParseable { // Parse update-specific options let zone = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.zone, - default: MistDemoConstants.Defaults.zone - ) ?? MistDemoConstants.Defaults.zone + configReader.read(MistDemoKeys.Query.zone) let recordType = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.recordType, - default: MistDemoConstants.Defaults.recordType - ) ?? MistDemoConstants.Defaults.recordType + configReader.read(MistDemoKeys.Record.recordType) // Validate recordName is provided (REQUIRED for update) guard - let recordName = configReader.string(forKey: MistDemoConstants.ConfigKeys.recordName) + let recordName = configReader.read(MistDemoKeys.Record.recordName) else { throw UpdateError.recordNameRequired } - let recordChangeTag = configReader.string( - forKey: MistDemoConstants.ConfigKeys.recordChangeTag - ) - let force = configReader.bool( - forKey: MistDemoConstants.ConfigKeys.force, - default: false - ) + let recordChangeTag = configReader.read(MistDemoKeys.Record.recordChangeTag) + let force = configReader.read(MistDemoKeys.Record.force) // Parse fields from various sources let fields = try Self.parseFieldsFromSources(configReader) // Parse output format let outputString = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: MistDemoConstants.Defaults.outputFormat - ) ?? MistDemoConstants.Defaults.outputFormat + configReader.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( @@ -148,7 +134,7 @@ public struct UpdateConfig: Sendable, ConfigurationParseable { var fields: [Field] = [] // 1. Parse inline field definitions - if let fieldString = configReader.string(forKey: "field") { + if let fieldString = configReader.read(MistDemoKeys.Record.field) { let fieldDefinitions = fieldString.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } @@ -157,18 +143,13 @@ public struct UpdateConfig: Sendable, ConfigurationParseable { } // 2. Parse from JSON file - if let jsonFile = configReader.string( - forKey: MistDemoConstants.ConfigKeys.jsonFile - ) { + if let jsonFile = configReader.read(MistDemoKeys.Record.jsonFile) { let jsonFields = try parseFieldsFromJSONFile(jsonFile) fields.append(contentsOf: jsonFields) } // 3. Parse from stdin (check if data is available) - if configReader.bool( - forKey: MistDemoConstants.ConfigKeys.stdin, - default: false - ) { + if configReader.read(MistDemoKeys.Record.stdin) { let stdinFields = try parseFieldsFromStdin() fields.append(contentsOf: stdinFields) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/UploadAssetConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/UploadAssetConfig.swift index 71f6366a..acc68b71 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/UploadAssetConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/UploadAssetConfig.swift @@ -85,25 +85,24 @@ public struct UploadAssetConfig: Sendable, ConfigurationParseable { } // Get file path from configuration - guard let filePath = configReader.string(forKey: "file") else { + guard let filePath = configReader.read(MistDemoKeys.Asset.file) else { throw UploadAssetError.filePathRequired } // Get record type (defaults to "Note") let recordType = - configReader.string(forKey: "record-type") ?? "Note" + configReader.read(MistDemoKeys.Record.optionalRecordType) ?? "Note" // Get field name (defaults to "image") let fieldName = - configReader.string(forKey: "field-name") ?? "image" + configReader.read(MistDemoKeys.Asset.fieldName) // Parse optional record name - let recordName = configReader.string(forKey: "record-name") + let recordName = configReader.read(MistDemoKeys.Record.recordName) // Parse output format let outputString = - configReader.string(forKey: "output.format", default: "json") - ?? "json" + configReader.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ValidateConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ValidateConfig.swift index 09a361c1..5fb7dacb 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ValidateConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ValidateConfig.swift @@ -76,20 +76,11 @@ public struct ValidateConfig: Sendable, ConfigurationParseable { ) } - let skipNetwork = configuration.bool( - forKey: "validate.skip-network", - default: false - ) - let testQuery = configuration.bool( - forKey: "validate.test-query", - default: false - ) + let skipNetwork = configuration.read(MistDemoKeys.Integration.validateSkipNetwork) + let testQuery = configuration.read(MistDemoKeys.Integration.validateTestQuery) let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: MistDemoConstants.Defaults.outputFormat - ) ?? MistDemoConstants.Defaults.outputFormat + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift index ebfd8557..2ce8f4c1 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift @@ -28,6 +28,7 @@ // public import ConfigKeyKit +internal import MistKitConfiguration internal import Foundation public import MistKit @@ -110,7 +111,7 @@ public struct WebConfig: Sendable, ConfigurationParseable { let configReader = configuration let apiToken = - configReader.string(forKey: "api.token", isSecret: true) ?? "" + configReader.read(MistDemoKeys.Auth.apiToken) guard !apiToken.isEmpty else { throw ConfigurationError.missingRequired( "api.token", @@ -120,34 +121,26 @@ public struct WebConfig: Sendable, ConfigurationParseable { } let containerIdentifier = - configReader.string( - forKey: "container.identifier", - default: MistDemoConstants.Defaults.containerIdentifier - ) ?? MistDemoConstants.Defaults.containerIdentifier + configReader.read(MistDemoKeys.cloudKit.containerID) let envString = - configReader.string(forKey: "environment", default: "development") - ?? "development" + configReader.read(MistDemoKeys.cloudKit.environment) ?? MistDemoConstants.Defaults.environment guard let environment = MistKit.Environment(caseInsensitive: envString) else { throw ConfigurationError.invalidEnvironment(envString) } let port = - configReader.int(forKey: "port", default: 8_080) ?? 8_080 + configReader.read(MistDemoKeys.Server.port) let host = - configReader.string(forKey: "host", default: "127.0.0.1") - ?? "127.0.0.1" + configReader.read(MistDemoKeys.Server.host) let openBrowser = BrowserFlagResolver.resolve( configReader: configReader, default: false ) - let keyID = configReader.string(forKey: "key.id") - let privateKey = configReader.string( - forKey: "private.key", - isSecret: true - ) - let privateKeyFile = configReader.string(forKey: "private.key.path") + let keyID = configReader.read(MistDemoKeys.cloudKit.keyID) + let privateKey = configReader.read(MistDemoKeys.cloudKit.privateKey) + let privateKeyFile = configReader.read(MistDemoKeys.cloudKit.privateKeyPath) self.init( apiToken: apiToken, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Defaults.swift b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Defaults.swift index afdb1044..81bf34ed 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Defaults.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Defaults.swift @@ -41,13 +41,14 @@ extension MistDemoConstants { /// Default port number. public static let port = 8_080 /// Default output format. - public static let outputFormat = "json" + /// + /// `table` is the human-facing default for every command; pass + /// `--output-format json` for machine-readable output. + public static let outputFormat = "table" /// Default query result limit. public static let queryLimit = 20 /// Default CloudKit environment. public static let environment = "development" - /// Default CloudKit database. - public static let database = "private" /// Default container identifier. public static let containerIdentifier = "iCloud.com.brightdigit.MistDemo" diff --git a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift index 5953a4b1..5bef8be5 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift @@ -31,72 +31,6 @@ internal import Foundation /// Central constants for MistDemo application. public enum MistDemoConstants { - // MARK: - Configuration Keys - - /// Configuration key names used throughout the application. - public enum ConfigKeys { - /// API token configuration key. - public static let apiToken = "api.token" - /// Web auth token configuration key. - public static let webAuthToken = "web.auth.token" - /// Sharee web auth token configuration key. - public static let shareeWebAuthToken = "sharee.web.auth.token" - /// Sharee iCloud email configuration key. - public static let shareeEmail = "sharee.email" - /// Container ID configuration key. - public static let containerID = "container.id" - /// Environment configuration key. - public static let environment = "environment" - /// Database configuration key. - public static let database = "database" - /// Record type configuration key. - public static let recordType = "record.type" - /// Record name configuration key. - public static let recordName = "record.name" - /// Zone configuration key. - public static let zone = "zone" - /// Zone owner configuration key (ownerName for shared zones). - public static let zoneOwner = "zone.owner" - /// Limit configuration key. - public static let limit = "limit" - /// Fields configuration key. - public static let fields = "fields" - /// Output format configuration key. - public static let outputFormat = "output.format" - /// Sort configuration key. - public static let sort = "sort" - /// Filter configuration key. - public static let filter = "filter" - /// No-browser configuration key. - public static let noBrowser = "no.browser" - /// Host configuration key. - public static let host = "host" - /// Port configuration key. - public static let port = "port" - /// JSON file configuration key. - public static let jsonFile = "json.file" - /// Stdin configuration key. - public static let stdin = "stdin" - /// Record change tag configuration key. - public static let recordChangeTag = "record.change.tag" - /// Force configuration key. - public static let force = "force" - /// Record names configuration key. - public static let recordNames = "record.names" - /// Operations file configuration key. - public static let operationsFile = "operations.file" - /// Atomic configuration key. - public static let atomic = "atomic" - /// Batch size configuration key for the auto-chunking `*-all` commands. - public static let batchSize = "batch.size" - /// Zone-wide query configuration key (query across all zones). - public static let zoneWide = "zone.wide" - /// Numbers-as-strings configuration key (return numeric fields as strings). - public static let numbersAsStrings = "numbers.as.strings" - /// Desired record types configuration key (limits the change feed). - public static let desiredRecordTypes = "record.types" - } - // MARK: - Field Names /// Standard CloudKit field names. diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift index e2d93944..17f9bde9 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift @@ -27,6 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // +internal import ConfigKeyKit +internal import MistKitConfiguration internal import Configuration internal import Foundation internal import MistKit @@ -36,18 +38,10 @@ internal import Testing @Suite("AuthTokenConfig Tests") internal struct AuthTokenConfigTests { - private static func key(_ path: String) -> AbsoluteConfigKey { - AbsoluteConfigKey(path.split(separator: ".").map(String.init), context: [:]) - } - private static func configuration( - values: [String: ConfigValue] + values: [(key: any ConfigurationKey, value: String)] ) -> MistDemoConfiguration { - var mapped: [AbsoluteConfigKey: ConfigValue] = [:] - for (path, value) in values { - mapped[key(path)] = value - } - return MistDemoConfiguration(testProvider: InMemoryProvider(values: mapped)) + MistDemoConfiguration.forTesting(values) } @Test("Memberwise init applies defaults for port, host, openBrowser, container") @@ -87,9 +81,9 @@ internal struct AuthTokenConfigTests { @Test("Configuration init throws missingRequired when api.token is absent") internal func missingApiTokenThrows() async { - let configuration = Self.configuration(values: [:]) + let configuration = Self.configuration(values: []) - await #expect(throws: ConfigurationError.self) { + await #expect(throws: MistDemoKit.ConfigurationError.self) { _ = try await AuthTokenConfig(configuration: configuration) } } @@ -97,10 +91,10 @@ internal struct AuthTokenConfigTests { @Test("Configuration init throws missingRequired when api.token is empty") internal func emptyApiTokenThrows() async { let configuration = Self.configuration(values: [ - "api.token": .init(stringLiteral: "") + (MistDemoKeys.Auth.apiToken, "") ]) - await #expect(throws: ConfigurationError.self) { + await #expect(throws: MistDemoKit.ConfigurationError.self) { _ = try await AuthTokenConfig(configuration: configuration) } } @@ -108,7 +102,7 @@ internal struct AuthTokenConfigTests { @Test("Configuration init applies all defaults when only api.token is set") internal func parsedDefaults() async throws { let configuration = Self.configuration(values: [ - "api.token": .init(stringLiteral: "tok-xyz") + (MistDemoKeys.Auth.apiToken, "tok-xyz") ]) let config = try await AuthTokenConfig(configuration: configuration) @@ -125,13 +119,13 @@ internal struct AuthTokenConfigTests { @Test("Configuration init honors every override key") internal func parsedOverrides() async throws { let configuration = Self.configuration(values: [ - "api.token": .init(stringLiteral: "tok-xyz"), - "container.identifier": .init(stringLiteral: "iCloud.custom.id"), - "environment": .init(stringLiteral: "production"), - "port": .init(integerLiteral: 9_090), - "host": .init(stringLiteral: "192.168.1.10"), - "no.browser": .init(booleanLiteral: true), - "reset.auth": .init(booleanLiteral: true), + (MistDemoKeys.Auth.apiToken, "tok-xyz"), + (MistDemoKeys.cloudKit.containerID, "iCloud.custom.id"), + (MistDemoKeys.cloudKit.environment, "production"), + (MistDemoKeys.Server.port, String(9_090)), + (MistDemoKeys.Server.host, "192.168.1.10"), + (MistDemoKeys.Server.noBrowser, String(true)), + (MistDemoKeys.Auth.resetAuth, String(true)), ]) let config = try await AuthTokenConfig(configuration: configuration) @@ -148,9 +142,9 @@ internal struct AuthTokenConfigTests { @Test("--no-browser wins when both browser flags are set") internal func noBrowserWinsOverBrowser() async throws { let configuration = Self.configuration(values: [ - "api.token": .init(stringLiteral: "tok-xyz"), - "browser": .init(booleanLiteral: true), - "no.browser": .init(booleanLiteral: true), + (MistDemoKeys.Auth.apiToken, "tok-xyz"), + (MistDemoKeys.Server.browser, String(true)), + (MistDemoKeys.Server.noBrowser, String(true)), ]) let config = try await AuthTokenConfig(configuration: configuration) @@ -161,11 +155,11 @@ internal struct AuthTokenConfigTests { @Test("Configuration init throws on invalid environment") internal func invalidEnvironmentThrows() async { let configuration = Self.configuration(values: [ - "api.token": .init(stringLiteral: "tok-xyz"), - "environment": .init(stringLiteral: "staging"), + (MistDemoKeys.Auth.apiToken, "tok-xyz"), + (MistDemoKeys.cloudKit.environment, "staging"), ]) - await #expect(throws: ConfigurationError.self) { + await #expect(throws: MistDemoKit.ConfigurationError.self) { _ = try await AuthTokenConfig(configuration: configuration) } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokensConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokensConfigTests.swift index 8304615f..0145b9c5 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokensConfigTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokensConfigTests.swift @@ -27,6 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // +internal import ConfigKeyKit internal import Configuration internal import Foundation internal import MistKit @@ -36,18 +37,10 @@ internal import Testing @Suite("AuthTokensConfig Tests") internal struct AuthTokensConfigTests { - private static func key(_ path: String) -> AbsoluteConfigKey { - AbsoluteConfigKey(path.split(separator: ".").map(String.init), context: [:]) - } - private static func configuration( - values: [String: ConfigValue] + values: [(key: any ConfigurationKey, value: String)] ) -> MistDemoConfiguration { - var mapped: [AbsoluteConfigKey: ConfigValue] = [:] - for (path, value) in values { - mapped[key(path)] = value - } - return MistDemoConfiguration(testProvider: InMemoryProvider(values: mapped)) + MistDemoConfiguration.forTesting(values) } @Test("Memberwise init applies defaults") @@ -74,7 +67,7 @@ internal struct AuthTokensConfigTests { @Test("Configuration init throws when api.token is absent") internal func missingApiTokenThrows() async { - let configuration = Self.configuration(values: [:]) + let configuration = Self.configuration(values: []) await #expect(throws: ConfigurationError.self) { _ = try await AuthTokensConfig(configuration: configuration) @@ -84,8 +77,8 @@ internal struct AuthTokensConfigTests { @Test("Configuration init parses sharee.email") internal func parsesShareeEmail() async throws { let configuration = Self.configuration(values: [ - "api.token": .init(stringLiteral: "tok"), - "sharee.email": .init(stringLiteral: "sharee@example.com"), + (MistDemoKeys.Auth.apiToken, "tok"), + (MistDemoKeys.Auth.shareeEmail, "sharee@example.com"), ]) let config = try await AuthTokensConfig(configuration: configuration) diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/Keys/MistDemoKeysTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Keys/MistDemoKeysTests.swift new file mode 100644 index 00000000..9c64cc23 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Keys/MistDemoKeysTests.swift @@ -0,0 +1,94 @@ +// +// MistDemoKeysTests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit +internal import MistKitConfiguration +internal import Configuration +import Testing + +@testable import MistDemoKit + +/// Pins the wire contract of ``MistDemoKeys``: the environment-variable names CI and the +/// deployment docs rely on, and the command-line flags the README documents. +@Suite("MistDemoKeys") +internal struct MistDemoKeysTests { + /// The eight variables `MistDemo-Integration.yml` and `docs/cloudkit-guide/` set. + /// + /// `CLOUDKIT_CONTAINER_ID` is the one that changed: the old `container.identifier` + /// base resolved to `CLOUDKIT_CONTAINER_IDENTIFIER`, so the value CI supplied was + /// silently ignored and every run fell back to the built-in default. + @Test( + "Deployment environment variables resolve", + arguments: [ + (MistDemoKeys.cloudKit.containerID as any ConfigurationKey, "CLOUDKIT_CONTAINER_ID"), + (MistDemoKeys.cloudKit.keyID, "CLOUDKIT_KEY_ID"), + (MistDemoKeys.cloudKit.privateKey, "CLOUDKIT_PRIVATE_KEY"), + (MistDemoKeys.cloudKit.privateKeyPath, "CLOUDKIT_PRIVATE_KEY_PATH"), + (MistDemoKeys.cloudKit.environment, "CLOUDKIT_ENVIRONMENT"), + (MistDemoKeys.Auth.apiToken, "CLOUDKIT_API_TOKEN"), + (MistDemoKeys.Auth.webAuthToken, "CLOUDKIT_WEB_AUTH_TOKEN"), + (MistDemoKeys.Auth.shareeWebAuthToken, "CLOUDKIT_SHAREE_WEB_AUTH_TOKEN"), + (MistDemoKeys.Auth.shareeEmail, "CLOUDKIT_SHAREE_EMAIL"), + ] + ) + internal func environmentVariableNames(key: any ConfigurationKey, expected: String) throws { + let resolved = try #require(key.key(for: .environment)) + let normalized = String(resolved.uppercased().map { $0.isLetter || $0.isNumber ? $0 : "_" }) + #expect(normalized == expected) + } + + /// Every credential key must be dash-case, never snake_case: `CLIKeyEncoder` joins key + /// components verbatim, so an underscore survives into an unusable flag and silently + /// defeats secret redaction. + @Test("Credential key bases are dash-case") + internal func basesAreDashCase() { + let bases = [ + MistDemoKeys.cloudKit.containerID.base, + MistDemoKeys.cloudKit.keyID.base, + MistDemoKeys.cloudKit.privateKey.base, + MistDemoKeys.cloudKit.privateKeyPath.base, + MistDemoKeys.cloudKit.environment.base, + ] + for base in bases { + let unwrapped = base ?? "" + #expect(!unwrapped.contains("_"), "\(unwrapped) must not contain an underscore") + #expect(unwrapped.hasPrefix("cloudkit."), "\(unwrapped) must be cloudkit-namespaced") + } + } + + /// The three credential keys carry `isSecret`, so a value passed by flag is redacted. + @Test("Credential keys are marked secret") + internal func credentialKeysAreSecret() { + #expect(MistDemoKeys.cloudKit.keyID.isSecret) + #expect(MistDemoKeys.cloudKit.privateKey.isSecret) + #expect(MistDemoKeys.cloudKit.privateKeyPath.isSecret) + #expect(MistDemoKeys.Auth.apiToken.isSecret) + #expect(MistDemoKeys.Auth.webAuthToken.isSecret) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/MistDemoConfigurationBoolTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/MistDemoConfigurationBoolTests.swift new file mode 100644 index 00000000..b6724b2e --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/MistDemoConfigurationBoolTests.swift @@ -0,0 +1,94 @@ +// +// MistDemoConfigurationBoolTests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit +internal import MistKitConfiguration +internal import Configuration +import Testing + +@testable import MistDemoKit + +/// Pins boolean resolution through the **real** command-line provider. +/// +/// ``MistDemoConfiguration`` deliberately does not use ConfigKeyKit's `read(_:)` for +/// booleans: that resolves by probing `string(forKey:)`, and swift-configuration reports +/// a valueless flag only through `bool(forKey:)`. Routing booleans through the string +/// path makes every bare flag read as its default — measured directly: +/// `string(forKey: "verbose")` is `nil` while `bool(forKey: "verbose")` is `true`. +@Suite("MistDemoConfiguration booleans") +internal struct MistDemoConfigurationBoolTests { + private static func cli(_ arguments: [String]) -> MistDemoConfiguration { + MistDemoConfiguration( + configReader: ConfigReader( + providers: [CommandLineArgumentsProvider(arguments: ["mistdemo"] + arguments)] + ) + ) + } + + @Test("A bare flag resolves true for a required boolean") + internal func bareFlagIsTrue() { + #expect(Self.cli(["--force"]).read(MistDemoKeys.Record.force)) + #expect(Self.cli(["--stdin"]).read(MistDemoKeys.Record.stdin)) + #expect(Self.cli(["--verbose"]).read(MistDemoKeys.Output.verbose)) + } + + @Test("A bare flag resolves true for an optional boolean") + internal func bareFlagIsTrueWhenOptional() { + #expect(Self.cli(["--zone-wide"]).read(MistDemoKeys.Query.zoneWide) == true) + #expect( + Self.cli(["--numbers-as-strings"]).read(MistDemoKeys.Record.numbersAsStrings) == true + ) + #expect( + Self.cli(["--fetch-root-record"]).read(MistDemoKeys.Sharing.fetchRootRecord) == true + ) + } + + @Test("An absent optional boolean stays nil, distinct from an explicit false") + internal func absentIsNil() { + #expect(Self.cli([]).read(MistDemoKeys.Query.zoneWide) == nil) + #expect(Self.cli(["--zone-wide", "false"]).read(MistDemoKeys.Query.zoneWide) == false) + } + + @Test("A required boolean falls back to its default when absent") + internal func defaultsWhenAbsent() { + #expect(Self.cli([]).read(MistDemoKeys.Record.force) == false) + } + + @Test("Renamed CloudKit credential flags resolve") + internal func renamedCredentialFlags() { + let config = Self.cli([ + "--cloudkit-container-id", "iCloud.com.example.Renamed", + "--cloudkit-key-id", "abc123", + "--cloudkit-environment", "production", + ]) + #expect(config.read(MistDemoKeys.cloudKit.containerID) == "iCloud.com.example.Renamed") + #expect(config.read(MistDemoKeys.cloudKit.keyID) == "abc123") + #expect(config.read(MistDemoKeys.cloudKit.environment) == "production") + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift index 685f8e10..289edfd2 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift @@ -27,6 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // +internal import ConfigKeyKit internal import Configuration internal import Foundation internal import Testing @@ -35,18 +36,10 @@ internal import Testing @Suite("TestPrivateConfig Tests") internal struct TestPrivateConfigTests { - private static func key(_ path: String) -> AbsoluteConfigKey { - AbsoluteConfigKey(path.split(separator: ".").map(String.init), context: [:]) - } - private static func configuration( - values: [String: ConfigValue] + values: [(key: any ConfigurationKey, value: String)] ) -> MistDemoConfiguration { - var mapped: [AbsoluteConfigKey: ConfigValue] = [:] - for (path, value) in values { - mapped[key(path)] = value - } - return MistDemoConfiguration(testProvider: InMemoryProvider(values: mapped)) + MistDemoConfiguration.forTesting(values) } @Test("TestPrivateConfig retains sharee credentials") @@ -65,16 +58,16 @@ internal struct TestPrivateConfigTests { internal func missingShareeTokenThrows() async throws { let base = try await MistDemoConfig( configuration: Self.configuration(values: [ - "api.token": .init(stringLiteral: "api-tok"), - "web.auth.token": .init(stringLiteral: "sharer-tok"), - "sharee.email": .init(stringLiteral: "sharee@example.com"), + (MistDemoKeys.Auth.apiToken, "api-tok"), + (MistDemoKeys.Auth.webAuthToken, "sharer-tok"), + (MistDemoKeys.Auth.shareeEmail, "sharee@example.com"), ]), base: nil ) let configuration = Self.configuration(values: [ - "api.token": .init(stringLiteral: "api-tok"), - "web.auth.token": .init(stringLiteral: "sharer-tok"), - "sharee.email": .init(stringLiteral: "sharee@example.com"), + (MistDemoKeys.Auth.apiToken, "api-tok"), + (MistDemoKeys.Auth.webAuthToken, "sharer-tok"), + (MistDemoKeys.Auth.shareeEmail, "sharee@example.com"), ]) await #expect(throws: ConfigurationError.self) { @@ -86,16 +79,16 @@ internal struct TestPrivateConfigTests { internal func missingShareeEmailThrows() async throws { let base = try await MistDemoConfig( configuration: Self.configuration(values: [ - "api.token": .init(stringLiteral: "api-tok"), - "web.auth.token": .init(stringLiteral: "sharer-tok"), - "sharee.web.auth.token": .init(stringLiteral: "sharee-tok"), + (MistDemoKeys.Auth.apiToken, "api-tok"), + (MistDemoKeys.Auth.webAuthToken, "sharer-tok"), + (MistDemoKeys.Auth.shareeWebAuthToken, "sharee-tok"), ]), base: nil ) let configuration = Self.configuration(values: [ - "api.token": .init(stringLiteral: "api-tok"), - "web.auth.token": .init(stringLiteral: "sharer-tok"), - "sharee.web.auth.token": .init(stringLiteral: "sharee-tok"), + (MistDemoKeys.Auth.apiToken, "api-tok"), + (MistDemoKeys.Auth.webAuthToken, "sharer-tok"), + (MistDemoKeys.Auth.shareeWebAuthToken, "sharee-tok"), ]) await #expect(throws: ConfigurationError.self) { @@ -107,16 +100,16 @@ internal struct TestPrivateConfigTests { internal func parsesShareeCredentials() async throws { let base = try await MistDemoConfig( configuration: Self.configuration(values: [ - "api.token": .init(stringLiteral: "api-tok"), - "web.auth.token": .init(stringLiteral: "sharer-tok"), + (MistDemoKeys.Auth.apiToken, "api-tok"), + (MistDemoKeys.Auth.webAuthToken, "sharer-tok"), ]), base: nil ) let configuration = Self.configuration(values: [ - "api.token": .init(stringLiteral: "api-tok"), - "web.auth.token": .init(stringLiteral: "sharer-tok"), - "sharee.web.auth.token": .init(stringLiteral: "sharee-tok"), - "sharee.email": .init(stringLiteral: "sharee@example.com"), + (MistDemoKeys.Auth.apiToken, "api-tok"), + (MistDemoKeys.Auth.webAuthToken, "sharer-tok"), + (MistDemoKeys.Auth.shareeWebAuthToken, "sharee-tok"), + (MistDemoKeys.Auth.shareeEmail, "sharee@example.com"), ]) let config = try await TestPrivateConfig( diff --git a/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift b/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift index 8cd6adae..79210e4e 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift @@ -1,6 +1,6 @@ // // MistDemoConfig+Testing.swift -// MistDemoTests +// MistDemo // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -27,6 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // +internal import ConfigKeyKit +internal import MistKitConfiguration internal import Configuration internal import Foundation internal import MistKit @@ -49,17 +51,13 @@ extension MistDemoConfig { /// `database` is left unset so it falls through to the production /// parser's default and cannot affect environment-test semantics. internal init(rawEnvironment: String) async throws { - func key(_ path: String) -> AbsoluteConfigKey { - AbsoluteConfigKey(path.split(separator: ".").map(String.init), context: [:]) - } - - let testProvider = InMemoryProvider(values: [ - key("container.identifier"): .init(stringLiteral: "iCloud.com.test.App"), - key("api.token"): .init(stringLiteral: "test-api-token"), - key("environment"): .init(stringLiteral: rawEnvironment), - ]) - let configuration = MistDemoConfiguration(testProvider: testProvider) - self = try await MistDemoConfig(configuration: configuration) + self = try await MistDemoConfig( + configuration: Self.makeConfiguration([ + (MistDemoKeys.cloudKit.containerID, "iCloud.com.test.App"), + (MistDemoKeys.Auth.apiToken, "test-api-token"), + (MistDemoKeys.cloudKit.environment, rawEnvironment), + ]) + ) } /// Create a test configuration with custom values @@ -82,43 +80,40 @@ extension MistDemoConfig { testServerToServer: Bool = false, badCredentials: Bool = false ) async throws { - let envString = environment == .production ? "production" : "development" - - func key(_ path: String) -> AbsoluteConfigKey { - AbsoluteConfigKey(path.split(separator: ".").map(String.init), context: [:]) - } - - var values: [AbsoluteConfigKey: ConfigValue] = [ - key("container.identifier"): .init(stringLiteral: containerIdentifier), - key("api.token"): .init(stringLiteral: apiToken), - key("environment"): .init(stringLiteral: envString), - key("database"): .init(stringLiteral: database.pathSegment), - key("host"): .init(stringLiteral: host), - key("port"): .init(integerLiteral: port), - key("auth.timeout"): .init(integerLiteral: Int(authTimeout)), - key("skip.auth"): .init(booleanLiteral: skipAuth), - key("test.all.auth"): .init(booleanLiteral: testAllAuth), - key("test.api.only"): .init(booleanLiteral: testApiOnly), - key("test.adaptive"): .init(booleanLiteral: testAdaptive), - key("test.server.to.server"): .init(booleanLiteral: testServerToServer), - key("bad.credentials"): .init(booleanLiteral: badCredentials), + var values: [(key: any ConfigurationKey, value: String)] = [ + (MistDemoKeys.cloudKit.containerID, containerIdentifier), + (MistDemoKeys.Auth.apiToken, apiToken), + ( + MistDemoKeys.cloudKit.environment, + environment == .production ? "production" : "development" + ), + (MistDemoKeys.Server.database, database.pathSegment), + (MistDemoKeys.Server.host, host), + (MistDemoKeys.Server.port, String(port)), + (MistDemoKeys.Server.authTimeout, String(Int(authTimeout))), + (MistDemoKeys.Auth.skipAuth, String(skipAuth)), + (MistDemoKeys.AuthModes.testAllAuth, String(testAllAuth)), + (MistDemoKeys.AuthModes.testAPIOnly, String(testApiOnly)), + (MistDemoKeys.AuthModes.testAdaptive, String(testAdaptive)), + (MistDemoKeys.AuthModes.testServerToServer, String(testServerToServer)), + (MistDemoKeys.Auth.badCredentials, String(badCredentials)), ] - if let webAuthToken { - values[key("web.auth.token")] = .init(stringLiteral: webAuthToken) - } - if let keyID { - values[key("key.id")] = .init(stringLiteral: keyID) - } - if let privateKey { - values[key("private.key")] = .init(stringLiteral: privateKey) - } + if let webAuthToken { values.append((MistDemoKeys.Auth.webAuthToken, webAuthToken)) } + if let keyID { values.append((MistDemoKeys.cloudKit.keyID, keyID)) } + if let privateKey { values.append((MistDemoKeys.cloudKit.privateKey, privateKey)) } + // Previously seeded `private.key.file`, a key production never read, so the + // `privateKeyFile:` argument silently did nothing. if let privateKeyFile { - values[key("private.key.file")] = .init(stringLiteral: privateKeyFile) + values.append((MistDemoKeys.cloudKit.privateKeyPath, privateKeyFile)) } - let testProvider = InMemoryProvider(values: values) - let configuration = MistDemoConfiguration(testProvider: testProvider) - self = try await MistDemoConfig(configuration: configuration) + self = try await MistDemoConfig(configuration: Self.makeConfiguration(values)) + } + + private static func makeConfiguration( + _ values: [(key: any ConfigurationKey, value: String)] + ) -> MistDemoConfiguration { + MistDemoConfiguration.forTesting(values) } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfiguration+Testing.swift b/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfiguration+Testing.swift new file mode 100644 index 00000000..9ae79bc7 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfiguration+Testing.swift @@ -0,0 +1,73 @@ +// +// MistDemoConfiguration+Testing.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit +internal import Configuration + +@testable import MistDemoKit + +extension MistDemoConfiguration { + /// Builds a configuration over the **real** environment provider, keyed by each key's + /// own resolved environment-variable name. + /// + /// Takes typed keys rather than key-path strings so a rename cannot silently + /// desynchronise the tests from production — the previous string-keyed double seeded + /// `private.key.file`, which production never read, and nobody noticed. + /// + /// Deliberately not `InMemoryProvider`: it matches keys literally and serves only the + /// type it stored, so it diverged from production on key normalization and on + /// numeric/boolean coercion. + internal static func forTesting( + _ values: [(key: any ConfigurationKey, value: String)] + ) -> MistDemoConfiguration { + var environment: [String: String] = [:] + for entry in values { + guard let name = entry.key.key(for: .environment) else { continue } + environment[Self.environmentVariableName(name)] = entry.value + } + return MistDemoConfiguration( + configReader: ConfigReader( + providers: [EnvironmentVariablesProvider(environmentVariables: environment)] + ) + ) + } + + /// Applies the same normalization swift-configuration's `EnvironmentKeyEncoder` does. + /// + /// ConfigKeyKit's `screamingSnakeCase` only maps `.` to `_`, so a dash-case base such + /// as `cloudkit.container-id` yields `CLOUDKIT_CONTAINER-ID`. In production that string + /// is handed to the reader, whose encoder then maps every non-alphanumeric to `_` and + /// arrives at `CLOUDKIT_CONTAINER_ID`. Seeding a provider directly skips that step, so + /// it has to be reproduced here or the variable never matches. + private static func environmentVariableName(_ resolved: String) -> String { + String( + resolved.uppercased().map { $0.isLetter || $0.isNumber ? $0 : "_" } + ) + } +} diff --git a/Package.swift b/Package.swift index 4cbcf39e..0b266ff2 100644 --- a/Package.swift +++ b/Package.swift @@ -162,3 +162,4 @@ extension Platform { } // swiftlint:enable explicit_acl explicit_top_level_acl + diff --git a/Packages/MistKitConfiguration/.claude/hooks/session-start.sh b/Packages/MistKitConfiguration/.claude/hooks/session-start.sh new file mode 100755 index 00000000..90ff7397 --- /dev/null +++ b/Packages/MistKitConfiguration/.claude/hooks/session-start.sh @@ -0,0 +1,127 @@ +#!/bin/bash +set -euo pipefail + +# SessionStart hook: install a Swift toolchain for Claude Code on the web +# (Linux). Only runs in remote sessions; local sessions are untouched. Runs +# async so the session starts immediately: progress lands in +# ~/.claude-session-setup.log and ~/.claude-session-setup.done marks the end. +# +# The toolchain is all this installs. Lint tooling is deliberately left out to +# keep cold start short: swift-format ships inside the toolchain, and both +# SwiftLint and periphery are skipped in web sessions (Scripts/lint.sh omits +# them when CLAUDE_CODE_REMOTE is set). Run `make lint` locally, where mise +# provides the pinned versions, to get full coverage. +# +# This hook is the second tier of a two-tier setup. The first tier is +# Scripts/cloud-setup.sh, pasted into the cloud environment's "Setup script" +# field: it runs once, then the filesystem is snapshotted and later sessions +# reuse it, so the ~1 GB toolchain download happens once per environment +# instead of once per container. When that snapshot exists, the `command -v +# swift` check below short-circuits and this hook finishes in about a second. +# +# The hook is still required on every session for two reasons: a snapshot +# restores files but not environment variables, so PATH has to be re-exported +# into CLAUDE_ENV_FILE each time; and an environment with no setup script +# configured (a fresh clone, another contributor) still needs the toolchain +# installed from here. +if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + exit 0 +fi + +echo '{"async": true, "asyncTimeout": 2400000}' + +SETUP_LOG="$HOME/.claude-session-setup.log" +SETUP_DONE="$HOME/.claude-session-setup.done" +rm -f "$SETUP_DONE" +exec >> "$SETUP_LOG" 2>&1 + +SWIFTLY_ENV="$HOME/.local/share/swiftly/env.sh" +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" + +# Make swift reachable for the session up front; an entry pointing at a +# not-yet-populated directory is harmless. +if [ -n "${CLAUDE_ENV_FILE:-}" ]; then + { + echo "export SWIFTLY_HOME_DIR=\"$HOME/.local/share/swiftly\"" + echo "export SWIFTLY_BIN_DIR=\"$HOME/.local/share/swiftly/bin\"" + echo "export PATH=\"$HOME/.local/share/swiftly/bin:\$PATH\"" + } >> "$CLAUDE_ENV_FILE" +fi + +install_swift() { + # System dependencies for Swift on Ubuntu 24.04 (per swift.org Linux + # instructions), plus curl for fetching swiftly. Most are already in the + # base image, so only reach for apt when something is genuinely missing -- + # `apt-get update` alone costs ~10s. + local packages missing pkg + packages=( + binutils + curl + git + gnupg2 + libc6-dev + libcurl4-openssl-dev + libedit2 + libgcc-13-dev + libncurses-dev + libpython3-dev + libsqlite3-0 + libstdc++-13-dev + libxml2-dev + libz3-dev + pkg-config + tzdata + zlib1g-dev + ) + missing=() + for pkg in "${packages[@]}"; do + if [ "$(dpkg-query -W -f='${db:Status-Status}' "$pkg" 2> /dev/null)" != "installed" ]; then + missing+=("$pkg") + fi + done + + if [ "${#missing[@]}" -gt 0 ]; then + echo "Installing missing system packages: ${missing[*]}" + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq --no-install-recommends "${missing[@]}" + else + echo "All system packages already present; skipping apt." + fi + + # Install swiftly non-interactively, then the toolchain pinned by the + # repo's .swift-version (falling back to latest if no pin resolves). + local workdir + workdir="$(mktemp -d)" + pushd "$workdir" > /dev/null + curl -fsSLO "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" + tar zxf "swiftly-$(uname -m).tar.gz" + ./swiftly init -y --skip-install + popd > /dev/null + rm -rf "$workdir" + + # shellcheck disable=SC1090 + . "$SWIFTLY_ENV" + + cd "$PROJECT_DIR" + if ! swiftly install -y; then + echo "Pinned toolchain install failed; falling back to latest." >&2 + swiftly install -y latest + swiftly use -y latest + fi +} + +# Pick up a swiftly install from a previous (cached) hook run. +if [ -f "$SWIFTLY_ENV" ]; then + # shellcheck disable=SC1090 + . "$SWIFTLY_ENV" +fi + +if command -v swift > /dev/null 2>&1; then + echo "Swift already installed: $(swift --version 2>&1 | head -1)" +else + install_swift +fi + +swift --version +touch "$SETUP_DONE" diff --git a/Packages/MistKitConfiguration/.claude/settings.json b/Packages/MistKitConfiguration/.claude/settings.json new file mode 100644 index 00000000..6738f065 --- /dev/null +++ b/Packages/MistKitConfiguration/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh\"" + } + ] + } + ] + } +} diff --git a/Packages/MistKitConfiguration/.github/actions/setup-mistkitconfiguration/action.yml b/Packages/MistKitConfiguration/.github/actions/setup-mistkitconfiguration/action.yml new file mode 100644 index 00000000..68d3d939 --- /dev/null +++ b/Packages/MistKitConfiguration/.github/actions/setup-mistkitconfiguration/action.yml @@ -0,0 +1,87 @@ +name: Setup MistKitConfiguration +description: > + Replaces monorepo path dependencies for MistKit and MistKitConfiguration with + remote references pinned to each branch's current commit. Standalone example CI + must rewrite both — a path: MistKit and a url: MistKit cannot coexist. + +inputs: + mistkit-branch: + description: MistKit branch to pin (leave empty to keep the local MistKit path dependency) + required: false + default: "" + mistkitconfiguration-branch: + description: MistKitConfiguration branch to pin (leave empty to keep the local path dependency) + required: false + default: "" + +runs: + using: composite + steps: + # Resolve each branch to its current HEAD and pin by `revision:` so + # `swift package dump-package` (hashed by swift-build@v1) changes when the + # branch advances. Falls back to `branch:` if the ref can't be resolved. + - name: Update Package.swift (Unix) + if: (inputs.mistkit-branch != '' || inputs.mistkitconfiguration-branch != '') && runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + + pin_dep() { + local NAME="$1" + local REPO="$2" + local BRANCH="$3" + local PATH_PATTERN="$4" + local REF + REF=$(git ls-remote "https://github.com/brightdigit/${REPO}.git" "$BRANCH" | head -n1 | cut -f1) + local REQ + if [ -n "$REF" ]; then + REQ="revision: \"$REF\"" + echo "Pinning $NAME to $BRANCH @ $REF" + else + REQ="branch: \"$BRANCH\"" + echo "Could not resolve $BRANCH for $NAME; pinning by branch" + fi + if [ "$RUNNER_OS" = "macOS" ]; then + sed -i '' "s|${PATH_PATTERN}|.package(url: \"https://github.com/brightdigit/${REPO}.git\", ${REQ})|g" Package.swift + else + sed -i "s|${PATH_PATTERN}|.package(url: \"https://github.com/brightdigit/${REPO}.git\", ${REQ})|g" Package.swift + fi + } + + if [ -n '${{ inputs.mistkit-branch }}' ]; then + pin_dep MistKit MistKit '${{ inputs.mistkit-branch }}' \ + '\.package(name: "MistKit", path: "\.\./\.\.")' + fi + + if [ -n '${{ inputs.mistkitconfiguration-branch }}' ]; then + # Monorepo dogfood form used by Examples/*/Package.swift + pin_dep MistKitConfiguration MistKitConfiguration '${{ inputs.mistkitconfiguration-branch }}' \ + '\.package(path: "\.\./\.\./Packages/MistKitConfiguration")' + fi + + rm -f Package.resolved + + - name: Update Package.swift (Windows) + if: (inputs.mistkit-branch != '' || inputs.mistkitconfiguration-branch != '') && runner.os == 'Windows' + shell: pwsh + run: | + function Pin-Dep($Name, $Repo, $Branch, $PathPattern) { + $ref = (git ls-remote "https://github.com/brightdigit/$Repo.git" $Branch | Select-Object -First 1) -split "`t" | Select-Object -First 1 + if ($ref) { + $req = "revision: `"$ref`"" + Write-Host "Pinning $Name to $Branch @ $ref" + } else { + $req = "branch: `"$Branch`"" + Write-Host "Could not resolve $Branch for $Name; pinning by branch" + } + $replacement = ".package(url: `"https://github.com/brightdigit/$Repo.git`", $req)" + (Get-Content Package.swift) -replace $PathPattern, $replacement | Set-Content Package.swift + } + + if ('${{ inputs.mistkit-branch }}' -ne '') { + Pin-Dep MistKit MistKit '${{ inputs.mistkit-branch }}' '\.package\(name: "MistKit", path: "\.\./\.\."\)' + } + if ('${{ inputs.mistkitconfiguration-branch }}' -ne '') { + Pin-Dep MistKitConfiguration MistKitConfiguration '${{ inputs.mistkitconfiguration-branch }}' '\.package\(path: "\.\./\.\./Packages/MistKitConfiguration"\)' + } + Remove-Item -Path Package.resolved -Force -ErrorAction SilentlyContinue diff --git a/Packages/MistKitConfiguration/.github/actions/setup-tools/action.yml b/Packages/MistKitConfiguration/.github/actions/setup-tools/action.yml new file mode 100644 index 00000000..069f32e9 --- /dev/null +++ b/Packages/MistKitConfiguration/.github/actions/setup-tools/action.yml @@ -0,0 +1,29 @@ +name: Setup mise tools +description: >- + Restore (or build + save) the mise tool cache and put the binaries on PATH. + Implemented as a composite action so the cache scope is the caller job's + scope — reusable workflows scope caches separately, which silently breaks + hand-off between a setup job and a consumer lint job. + +runs: + using: composite + steps: + - name: Cache mise tools + id: mise-cache + uses: actions/cache@v4 + with: + path: ~/.local/share/mise/installs + key: mise-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('mise.toml') }} + restore-keys: | + mise-v2-${{ runner.os }}-${{ runner.arch }}- + - name: Install mise tools (cache miss) + if: steps.mise-cache.outputs.cache-hit != 'true' + uses: jdx/mise-action@v4 + with: + cache: false + - name: Configure PATH for cached mise tools + if: steps.mise-cache.outputs.cache-hit == 'true' + uses: jdx/mise-action@v4 + with: + install: false + cache: false diff --git a/Packages/MistKitConfiguration/.github/workflows/MistKitConfiguration.yml b/Packages/MistKitConfiguration/.github/workflows/MistKitConfiguration.yml new file mode 100644 index 00000000..6b49535c --- /dev/null +++ b/Packages/MistKitConfiguration/.github/workflows/MistKitConfiguration.yml @@ -0,0 +1,313 @@ +name: MistKitConfiguration +on: + push: + branches: + - main + paths-ignore: + - '**.md' + - 'docs/**' + - 'LICENSE' + - '.github/ISSUE_TEMPLATE/**' + pull_request: + paths-ignore: + - '**.md' + - 'docs/**' + - 'LICENSE' + - '.github/ISSUE_TEMPLATE/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +env: + PACKAGE_NAME: MistKitConfiguration + MISTKIT_BRANCH: v1.0.0-beta.5 + +jobs: + configure: + name: Configure Matrix + runs-on: ubuntu-latest + outputs: + full-matrix: ${{ steps.check.outputs.full }} + ubuntu-os: ${{ steps.matrix.outputs.ubuntu-os }} + ubuntu-swift: ${{ steps.matrix.outputs.ubuntu-swift }} + steps: + - id: check + name: Determine matrix scope + run: | + FULL=false + REF="${{ github.ref }}" + EVENT="${{ github.event_name }}" + BASE_REF="${{ github.base_ref }}" + + # Full matrix on main + if [[ "$REF" == "refs/heads/main" ]]; then + FULL=true + # Full matrix on semver branches (v1.0.0, 1.2.3-alpha.1, etc.) + elif [[ "$REF" =~ ^refs/heads/v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then + FULL=true + # Full matrix on PRs targeting main or semver branches + elif [[ "$EVENT" == "pull_request" ]]; then + if [[ "$BASE_REF" == "main" || "$BASE_REF" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then + FULL=true + fi + fi + + echo "full=$FULL" >> "$GITHUB_OUTPUT" + echo "Full matrix: $FULL (ref=$REF, event=$EVENT, base_ref=$BASE_REF)" + + - id: matrix + name: Build matrix values + run: | + # MistKitConfiguration's Package.swift declares swift-tools-version: 6.4. + # Swift 6.4 has no release toolchain yet, so the nightly image is the + # only Linux toolchain that can parse the manifest — no 6.3 lanes. + SWIFT='[{"version":"6.4","image":"swiftlang/swift:nightly-6.4.x"}]' + if [[ "${{ steps.check.outputs.full }}" == "true" ]]; then + echo 'ubuntu-os=["noble","jammy"]' >> "$GITHUB_OUTPUT" + else + echo 'ubuntu-os=["noble"]' >> "$GITHUB_OUTPUT" + fi + echo "ubuntu-swift=$SWIFT" >> "$GITHUB_OUTPUT" + + build-ubuntu: + name: Build on Ubuntu + needs: configure + runs-on: ubuntu-latest + # The `||` branch (a plain swift:- image) is unreachable while + # the matrix emits only the 6.4 nightly entry, which always sets `image`. + # Kept deliberately: it is what a release-toolchain lane would use once + # Swift 6.4 ships and can be added back to the matrix. + container: ${{ matrix.swift.image && format('{0}-{1}', matrix.swift.image, matrix.os) || format('swift:{0}-{1}', matrix.swift.version, matrix.os) }} + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(needs.configure.outputs.ubuntu-os) }} + swift: ${{ fromJSON(needs.configure.outputs.ubuntu-swift) }} + + steps: + - uses: actions/checkout@v6 + + - name: Setup MistKit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: ${{ env.MISTKIT_BRANCH }} + + - uses: brightdigit/swift-build@v1 + id: build + with: + skip-package-resolved: true + - name: Install curl (required by Codecov uploader) + if: steps.build.outputs.contains-code-coverage == 'true' + run: | + if command -v apt-get >/dev/null 2>&1; then + apt-get update && apt-get install -y --no-install-recommends curl ca-certificates + fi + - uses: sersoft-gmbh/swift-coverage-action@v5 + if: steps.build.outputs.contains-code-coverage == 'true' + id: coverage-files + with: + fail-on-empty-output: true + - name: Upload coverage to Codecov + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + fail_ci_if_error: false + flags: swift-${{ matrix.swift.version }}-${{ matrix.os }} + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + + # build-windows: + # name: Build on Windows + # needs: configure + # runs-on: ${{ matrix.runs-on }} + # if: ${{ needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} + # strategy: + # fail-fast: false + # matrix: + # runs-on: [windows-2022, windows-2025] + # swift: + # - version: swift-6.3-release + # build: 6.3-RELEASE + # steps: + # - uses: actions/checkout@v6 + # - name: Setup MistKit + # uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + # with: + # branch: ${{ env.MISTKIT_BRANCH }} + # - uses: brightdigit/swift-build@v1 + # id: build + # with: + # windows-swift-version: ${{ matrix.swift.version }} + # windows-swift-build: ${{ matrix.swift.build }} + # skip-package-resolved: true + # - name: Upload coverage to Codecov + # if: steps.build.outputs.contains-code-coverage == 'true' + # uses: codecov/codecov-action@v6 + # with: + # fail_ci_if_error: false + # flags: swift-${{ matrix.swift.version }},windows + # verbose: true + # token: ${{ secrets.CODECOV_TOKEN }} + # os: windows + + # Minimal macOS builds — always runs (SPM + iOS) + build-macos: + name: Build on macOS + runs-on: ${{ matrix.runs-on }} + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + strategy: + fail-fast: false + matrix: + include: + # A swift-tools-version: 6.4 manifest needs a Swift 6.4 toolchain, so + # these lanes run on the xcode-27 image (Xcode 27.0) rather than + # macos-26 / Xcode 26.6, which cannot parse the manifest. + # SPM build + - runs-on: xcode-27 + xcode: "/Applications/Xcode_27.0.app" + + # iOS build + - type: ios + runs-on: xcode-27 + xcode: "/Applications/Xcode_27.0.app" + deviceName: "iPhone 17 Pro" + osVersion: "27.0" + download-platform: true + steps: + - uses: actions/checkout@v6 + + - name: Setup MistKit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: ${{ env.MISTKIT_BRANCH }} + + - name: Build and Test + id: build + uses: brightdigit/swift-build@v1 + with: + type: ${{ matrix.type }} + xcode: ${{ matrix.xcode }} + deviceName: ${{ matrix.deviceName }} + osVersion: ${{ matrix.osVersion }} + download-platform: ${{ matrix.download-platform }} + skip-package-resolved: true + - name: Process Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 + - name: Upload Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ matrix.type && format('{0}{1}', matrix.type, matrix.osVersion) || 'spm' }} + + # Full macOS platform builds — only on main, semver branches, and PRs targeting them + build-macos-platforms: + name: Build on macOS (Platforms) + needs: configure + runs-on: ${{ matrix.runs-on }} + if: ${{ needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} + strategy: + fail-fast: false + matrix: + include: + # ── Xcode 27 (preview image, beta toolchain) ───────────────────── + # A swift-tools-version: 6.4 manifest needs a Swift 6.4 toolchain, so + # every Apple lane runs here; the macos-26 / Xcode 26.6 lanes cannot + # parse the manifest and are gone. + # + # `runs-on: xcode-27` is its own image label, not a macos-NN one, and + # is marked Preview in actions/runner-images. It ships exactly one + # Xcode — 27.0 beta — so there is no 26.x fallback on this runner. + # + # Pin the /Applications/Xcode_27.0.app symlink, NOT the real + # Xcode_27_beta_4.app path: the beta number changes on every image + # refresh and would silently break these lanes. + # + # tvOS uses "Apple TV 4K (3rd generation)" — this image has no plain + # "Apple TV" device. A wrong device name fails the simulator boot + # outright. + - type: macos + runs-on: xcode-27 + xcode: "/Applications/Xcode_27.0.app" + + - type: ios + runs-on: xcode-27 + xcode: "/Applications/Xcode_27.0.app" + deviceName: "iPhone 17 Pro" + osVersion: "27.0" + download-platform: true + + - type: watchos + runs-on: xcode-27 + xcode: "/Applications/Xcode_27.0.app" + deviceName: "Apple Watch Ultra 3 (49mm)" + osVersion: "27.0" + download-platform: true + + - type: tvos + runs-on: xcode-27 + xcode: "/Applications/Xcode_27.0.app" + deviceName: "Apple TV 4K (3rd generation)" + osVersion: "27.0" + download-platform: true + + - type: visionos + runs-on: xcode-27 + xcode: "/Applications/Xcode_27.0.app" + deviceName: "Apple Vision Pro" + osVersion: "27.0" + download-platform: true + steps: + - uses: actions/checkout@v6 + + - name: Setup MistKit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: ${{ env.MISTKIT_BRANCH }} + + - name: Build and Test + id: build + uses: brightdigit/swift-build@v1 + with: + type: ${{ matrix.type }} + xcode: ${{ matrix.xcode }} + deviceName: ${{ matrix.deviceName }} + osVersion: ${{ matrix.osVersion }} + download-platform: ${{ matrix.download-platform }} + skip-package-resolved: true + - name: Process Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 + - name: Upload Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ matrix.type && format('{0}{1}', matrix.type, matrix.osVersion) || 'spm' }} + + lint: + name: Linting + # lint.sh ends with `swift build --build-tests`; the manifest is + # swift-tools-version: 6.4, so this must run on a 6.4 toolchain (ubuntu-latest + # ships 6.3 via mise and fails to parse Package.swift). + runs-on: ubuntu-latest + container: swiftlang/swift:nightly-6.4.x-noble + if: ${{ !cancelled() && !failure() && !contains(github.event.head_commit.message, 'ci skip') }} + needs: [build-ubuntu, build-macos, build-macos-platforms] + steps: + - uses: actions/checkout@v6 + - name: Install mise tooling deps + run: | + apt-get update + apt-get install -y --no-install-recommends curl ca-certificates git + - uses: jdx/mise-action@v4 + with: + cache: true + - name: Lint + run: | + set -e + ./Scripts/lint.sh diff --git a/Packages/MistKitConfiguration/.github/workflows/claude-code-review.yml b/Packages/MistKitConfiguration/.github/workflows/claude-code-review.yml new file mode 100644 index 00000000..b5e8cfd4 --- /dev/null +++ b/Packages/MistKitConfiguration/.github/workflows/claude-code-review.yml @@ -0,0 +1,44 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + diff --git a/Packages/MistKitConfiguration/.github/workflows/claude.yml b/Packages/MistKitConfiguration/.github/workflows/claude.yml new file mode 100644 index 00000000..6b15fac7 --- /dev/null +++ b/Packages/MistKitConfiguration/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr *)' + diff --git a/Packages/MistKitConfiguration/.github/workflows/cleanup-caches.yml b/Packages/MistKitConfiguration/.github/workflows/cleanup-caches.yml new file mode 100644 index 00000000..f0124e2c --- /dev/null +++ b/Packages/MistKitConfiguration/.github/workflows/cleanup-caches.yml @@ -0,0 +1,29 @@ +name: Cleanup Branch Caches +on: + delete: + +jobs: + cleanup: + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - name: Cleanup caches for deleted branch + uses: actions/github-script@v9 + with: + script: | + const ref = `refs/heads/${context.payload.ref}`; + const caches = await github.rest.actions.getActionsCacheList({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: ref, + }); + for (const cache of caches.data.actions_caches) { + console.log(`Deleting cache: ${cache.key}`); + await github.rest.actions.deleteActionsCacheById({ + owner: context.repo.owner, + repo: context.repo.repo, + cache_id: cache.id, + }); + } + console.log(`Deleted ${caches.data.actions_caches.length} cache(s) for ${ref}`); diff --git a/Packages/MistKitConfiguration/.github/workflows/dependency-policy.yml b/Packages/MistKitConfiguration/.github/workflows/dependency-policy.yml new file mode 100644 index 00000000..df9605b9 --- /dev/null +++ b/Packages/MistKitConfiguration/.github/workflows/dependency-policy.yml @@ -0,0 +1,49 @@ +name: Dependency Policy + +# Gate for PRs targeting `main` that are ready to merge: Package.swift may only use +# tagged (version) dependencies. Branch, revision, or local-path dependencies are +# integration-only and must be bumped to a released tag before leaving draft / +# merging into `main`. Draft PRs skip this check so integration pins can stay green. + +on: + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + tagged-dependencies: + name: Verify tagged dependencies + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + container: swiftlang/swift:nightly-6.4.x-noble + steps: + - uses: actions/checkout@v4 + + - name: Install jq + run: | + apt-get update + apt-get install -y --no-install-recommends jq + + - name: Verify Package.swift uses only tagged dependencies + run: | + swift package dump-package > package.json + + bad=$(jq -r ' + ([ .dependencies[] | (.sourceControl // [])[] + | select((.requirement | keys[0]) as $k | $k == "branch" or $k == "revision") + | "\(.identity): \(.requirement | keys[0]) \(.requirement | to_entries[0].value)" ] + + [ .dependencies[] | (.fileSystem // [])[] + | "\(.identity): local path (\(.path))" ])[]' package.json) + + if [ -n "$bad" ]; then + echo "::error::Package.swift must use only tagged (version) dependencies on PRs targeting main." + echo "Non-tagged dependencies found:" + echo "$bad" + exit 1 + fi + + echo "✅ All dependencies use tagged (version) requirements." diff --git a/Packages/MistKitConfiguration/.gitignore b/Packages/MistKitConfiguration/.gitignore new file mode 100644 index 00000000..20909d84 --- /dev/null +++ b/Packages/MistKitConfiguration/.gitignore @@ -0,0 +1,84 @@ +# macOS +.DS_Store + +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +Package.resolved +.swiftpm/ +.build/ +DerivedData/ +.index-build/ + +# Generated Xcode projects/workspaces +*.xcodeproj +*.xcworkspace + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# IDE +.vscode/ +.idea/ + +# mise / mint local installs +.mint/ + +# Editor scratch +*.sw? + +# Claude +.claude/settings.local.json +.claude/scheduled_tasks.lock diff --git a/Packages/MistKitConfiguration/.gitrepo b/Packages/MistKitConfiguration/.gitrepo new file mode 100644 index 00000000..1d26e9ca --- /dev/null +++ b/Packages/MistKitConfiguration/.gitrepo @@ -0,0 +1,11 @@ +; DO NOT EDIT (unless you know what you are doing) +; +; This subdirectory is a git "subrepo", and this file is maintained by the +; git-subrepo command. See https://github.com/ingydotnet/git-subrepo#readme +; +[subrepo] + remote = git@github.com:brightdigit/MistKitConfiguration.git + branch = mistkit-beta.5 + commit = a70afee6b05575a654e442216dad47dc5ea7a672 + method = merge + cmdver = 0.4.9 diff --git a/Packages/MistKitConfiguration/.periphery.yml b/Packages/MistKitConfiguration/.periphery.yml new file mode 100644 index 00000000..963c035a --- /dev/null +++ b/Packages/MistKitConfiguration/.periphery.yml @@ -0,0 +1,3 @@ +retain_public: true +retain_unused_protocol_func_params: true +retain_assign_only_properties: true diff --git a/Packages/MistKitConfiguration/.spi.yml b/Packages/MistKitConfiguration/.spi.yml new file mode 100644 index 00000000..70794bec --- /dev/null +++ b/Packages/MistKitConfiguration/.spi.yml @@ -0,0 +1,5 @@ +version: 1 +builder: + configs: + - documentation_targets: [MistKitConfiguration] + swift_version: 6.4 diff --git a/Packages/MistKitConfiguration/.swift-format b/Packages/MistKitConfiguration/.swift-format new file mode 100644 index 00000000..257f5557 --- /dev/null +++ b/Packages/MistKitConfiguration/.swift-format @@ -0,0 +1,70 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "fileprivate" + }, + "indentation" : { + "spaces" : 2 + }, + "indentConditionalCompilationBlocks" : true, + "indentSwitchCaseLabels" : false, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineLength" : 100, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : true, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : false, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : true, + "NeverUseForceTry" : true, + "NeverUseImplicitlyUnwrappedOptionals" : true, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : true, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : true, + "ValidateDocumentationComments" : true + }, + "spacesAroundRangeFormationOperators" : false, + "tabWidth" : 2, + "version" : 1 +} diff --git a/Packages/MistKitConfiguration/.swift-version b/Packages/MistKitConfiguration/.swift-version new file mode 100644 index 00000000..c596943a --- /dev/null +++ b/Packages/MistKitConfiguration/.swift-version @@ -0,0 +1 @@ +6.4 diff --git a/Packages/MistKitConfiguration/.swiftlint.yml b/Packages/MistKitConfiguration/.swiftlint.yml new file mode 100644 index 00000000..08f1c1fc --- /dev/null +++ b/Packages/MistKitConfiguration/.swiftlint.yml @@ -0,0 +1,141 @@ +opt_in_rules: + - array_init + - closure_body_length + - closure_end_indentation + - closure_spacing + - collection_alignment + - conditional_returns_on_newline + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison + - convenience_type + - discouraged_object_literal + - discouraged_optional_boolean + - empty_collection_literal + - empty_count + - empty_string + - empty_xctest_method + - enum_case_associated_values_count + - expiring_todo + - explicit_acl + - explicit_init + - explicit_top_level_acl + - fatal_error_message + - file_name + - file_name_no_space + - file_types_order + - first_where + - flatmap_over_map_reduce + - force_unwrapping + - ibinspectable_in_extension + - identical_operands + - implicit_return + - implicitly_unwrapped_optional + - indentation_width + - joined_default_parameter + - last_where + - legacy_multiple + - legacy_random + - literal_expression_end_indentation + - lower_acl_than_parent + - missing_docs + - modifier_order + - multiline_arguments + - multiline_arguments_brackets + - multiline_function_chains + - multiline_literal_brackets + - multiline_parameters + - nimble_operator + - nslocalizedstring_key + - nslocalizedstring_require_bundle + - number_separator + - object_literal + - one_declaration_per_file + - operator_usage_whitespace + - optional_enum_case_matching + - overridden_super_call + - override_in_extension + - pattern_matching_keywords + - prefer_self_type_over_type_of_self + - prefer_zero_over_explicit_init + - private_action + - private_outlet + - prohibited_interface_builder + - prohibited_super_call + - quick_discouraged_call + - quick_discouraged_focused_test + - quick_discouraged_pending_test + - reduce_into + - redundant_nil_coalescing + - redundant_type_annotation + - required_enum_case + - single_test_class + - sorted_first_last + - sorted_imports + - static_operator + - strong_iboutlet + - toggle_bool + - type_contents_order + - unavailable_function + - unneeded_parentheses_in_closure_argument + - unowned_variable_capture + - untyped_error_in_catch + - vertical_parameter_alignment_on_call + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + - xct_specific_matcher + - yoda_condition +analyzer_rules: + - unused_import + - unused_declaration +cyclomatic_complexity: + - 6 + - 12 +file_length: + warning: 225 + error: 300 +function_body_length: + - 50 + - 76 +function_parameter_count: 8 +line_length: + - 108 + - 200 +closure_body_length: + - 50 + - 60 +type_name: + min_length: 3 + max_length: + warning: 50 + error: 60 +identifier_name: + excluded: + - id + - no +excluded: + - DerivedData + - .build + - Package.swift +indentation_width: + indentation_width: 2 +file_name: + severity: error +fatal_error_message: + severity: error +disabled_rules: + - nesting + - implicit_getter + - switch_case_alignment + - closure_parameter_position + - trailing_comma + - opening_brace + - optional_data_string_conversion + - pattern_matching_keywords +custom_rules: + no_unchecked_sendable: + name: "No Unchecked Sendable" + regex: '@unchecked\s+Sendable' + message: "Use proper Sendable conformance instead of @unchecked Sendable to maintain strict concurrency safety" + severity: error diff --git a/Packages/MistKitConfiguration/CLAUDE.md b/Packages/MistKitConfiguration/CLAUDE.md new file mode 100644 index 00000000..b8667643 --- /dev/null +++ b/Packages/MistKitConfiguration/CLAUDE.md @@ -0,0 +1,47 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`MistKitConfiguration` is a small Swift 6.4 library (single `MistKitConfiguration` product) holding the CloudKit credential configuration glue shared by MistKit's server-side applications: read → validate → build a `CloudKitService`. It depends on **MistKit**, **ConfigKeyKit** and **apple/swift-configuration**. MistKit must never depend on it — the arrow points one way, which is the whole reason this is a separate repository rather than a MistKit product. + +## Commands + +- `make build` / `swift build` +- `make test` / `swift test` (Swift Testing, not XCTest) +- Run one suite: `swift test --filter KeyIDValidator` +- `make lint` — `Scripts/lint.sh`: swift-format, SwiftLint, license-header check, periphery +- `make clean` + +Lint tooling is pinned via **mise** (`mise.toml`): swift-format 602.0.0, SwiftLint 0.62.2, periphery 3.7.4. Run `mise install` once so `Scripts/lint.sh` finds them outside CI. + +## Architecture + +Three layers, deliberately separated so that *reading* configuration cannot fail: + +1. **`CloudKitConfiguration`** — raw, every field `String?`, no validation. `environment` stays a string so "unspecified" is distinguishable from "explicitly development" and an unrecognized value fails at validation rather than at read time. +2. **`validated()`** — checks presence *before* format, resolves inline PEM over a file path, parses the environment, and returns… +3. **`ValidatedCloudKitConfiguration`** — whose initializer is throwing and runs `KeyIDValidator` (and `PEMValidator` for an inline key). There is therefore **no way to hold this type with credentials that skipped format validation**; that property is what lets callers delete their own hand-rolled checks. Do not add a non-throwing initializer. + +Supporting pieces: `CloudKitConfigurationKeys` (a value type, not a `static` enum, because the container default and env prefix are per-application), `readCloudKitConfiguration(keys:)` on the `ConfigValueReading` protocol, and `ConfigurationSources` for the provider stack. + +## Conventions + +- **Errors carry no prose.** `CloudKitConfigurationError`, `KeyIDValidationFailure` and `PEMValidationFailure` are `Equatable` enums and deliberately do **not** conform to `LocalizedError`. Every consumer already owns an error type with its own wording, remediation advice and key names; package-authored text would contradict all three. `ConfigurationError` is a presentation convenience this package never throws. Keep it that way. +- **`CloudKitConfigurationField`, not key strings.** Consumers spell the same field differently, so errors name a field and `CloudKitConfigurationKeys.subscript(_:)` maps it back to whatever key that application uses. +- **Key bases are dash-case** (`cloudkit.key-id`, never `cloudkit.key_id`). `CLIKeyEncoder` joins components verbatim, so an underscore produces an unusable flag *and* silently defeats secret redaction, since the redaction list matches the generated flag. `secretCommandLineFlags` is derived from `isSecret` so it cannot drift. +- **Swift 6.4** with `ExistentialAny`, `InternalImportsByDefault`, `MemberImportVisibility`, `FullTypedThrows` enabled. Mark every import `internal import` / `public import`; never bare `import`. +- Everything is `Sendable`. Typed throws (`throws(CloudKitConfigurationError)`) are used where the error set is closed. +- SwiftLint runs with `explicit_acl`, `explicit_top_level_acl`, `missing_docs`, `one_declaration_per_file` and `file_name` (severity **error** — the primary type's name must match the filename; extensions use `Type+Feature.swift`). No `!`. +- Every source file carries the MIT header; `Scripts/header.sh` enforces it. + +## The MistKit dependency + +`Package.swift` in the **monorepo** uses `.package(name: "MistKit", path: "../..")`. This is not a preference: a `path:` package takes its identity from the directory name, so mixing it with a sibling that depends on MistKit by `url:` makes SwiftPM resolve two distinct packages and fail with *"multiple similar targets 'MistKit', 'MistKitOpenAPI'"*. Every package in that monorepo must reach MistKit the same way. + +The standalone repository carries the `url:` form instead — and **`git subrepo push` does not know that**. It would copy the `path:` line over and break the standalone repo, so the swap has to be re-applied after every push. That is the same never-merged-overlay discipline `Examples/BushelCloud/Package.swift` documents for its own MistKit line. CI swaps it with `brightdigit/MistKit/.github/actions/setup-mistkit@main`, pinned by `MISTKIT_BRANCH`. **That value must be a branch name, not a tag** — `git ls-remote` matches tags too, and a tag would silently pin an old release. Before cutting a release, the dependency must be a tagged `url:`, or the tag is unusable downstream; `dependency-policy.yml` gates PRs to `main` on exactly that. + +## CI + +Swift 6.4 has no Linux or Windows release toolchain, so `build-ubuntu` runs the single `swiftlang/swift:nightly-6.4.x` matrix entry, `build-windows` is commented out, there is no Android job, and macOS runs on `runs-on: xcode-27`. On that image tvOS must use `"Apple TV 4K (3rd generation)"` — there is no plain `"Apple TV"`. Restore the release lanes once Swift 6.4 ships them. diff --git a/Packages/MistKitConfiguration/LICENSE b/Packages/MistKitConfiguration/LICENSE new file mode 100644 index 00000000..5bf4bad4 --- /dev/null +++ b/Packages/MistKitConfiguration/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 BrightDigit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Packages/MistKitConfiguration/Makefile b/Packages/MistKitConfiguration/Makefile new file mode 100644 index 00000000..96f245d6 --- /dev/null +++ b/Packages/MistKitConfiguration/Makefile @@ -0,0 +1,22 @@ +.PHONY: help build test lint clean + +help: + @echo "Available targets:" + @echo " build - Build the package" + @echo " test - Run the test suite" + @echo " lint - Run swift-format + swiftlint + periphery via Scripts/lint.sh" + @echo " clean - Clean build artifacts" + @echo " help - Show this help message" + +build: + swift build + +test: + swift test + +lint: + @./Scripts/lint.sh + +clean: + swift package clean + rm -rf .build diff --git a/Packages/MistKitConfiguration/Package.swift b/Packages/MistKitConfiguration/Package.swift new file mode 100644 index 00000000..c7ec6b73 --- /dev/null +++ b/Packages/MistKitConfiguration/Package.swift @@ -0,0 +1,75 @@ +// swift-tools-version: 6.4 + +// swiftlint:disable explicit_acl explicit_top_level_acl + +import PackageDescription + +// MARK: - Swift Settings Configuration + +let swiftSettings: [SwiftSetting] = [ + // Swift 6.2 Upcoming Features (not yet enabled by default) + // SE-0335: Introduce existential `any` + .enableUpcomingFeature("ExistentialAny"), + // SE-0409: Access-level modifiers on import declarations + .enableUpcomingFeature("InternalImportsByDefault"), + // SE-0444: Member import visibility (Swift 6.1+) + .enableUpcomingFeature("MemberImportVisibility"), + // SE-0413: Typed throws + .enableUpcomingFeature("FullTypedThrows"), +] + +let package = Package( + name: "MistKitConfiguration", + platforms: [ + .macOS(.v15), + .iOS(.v18), + .tvOS(.v18), + .watchOS(.v11), + .visionOS(.v2), + ], + products: [ + .library(name: "MistKitConfiguration", targets: ["MistKitConfiguration"]) + ], + dependencies: [ + // A local path dependency, not a tagged URL, and deliberately so: a `path:` + // package takes its identity from the directory name, so pairing it with a + // sibling that depends on MistKit by `url:` makes SwiftPM resolve two distinct + // packages and fail with "multiple similar targets 'MistKit', 'MistKitOpenAPI'". + // Every package inside this monorepo must therefore reach MistKit the same way. + // + // ⚠️ This line is a monorepo-local overlay that must NEVER reach the standalone + // repository. `git subrepo push Packages/MistKitConfiguration` would carry it over + // and break `brightdigit/MistKitConfiguration`, whose own manifest deliberately + // carries `.package(url: …MistKit.git, from: "1.0.0-beta.4")` — the form that makes a + // tag of this package usable downstream. Re-apply the swap after every push, the same + // never-merged-overlay discipline `Examples/BushelCloud/Package.swift` documents. + .package(name: "MistKit", path: "../.."), + .package( + url: "https://github.com/brightdigit/ConfigKeyKit.git", + from: "1.0.0-beta.3" + ), + .package( + url: "https://github.com/apple/swift-configuration.git", + from: "1.0.0", + traits: ["CommandLineArguments"] + ), + ], + targets: [ + .target( + name: "MistKitConfiguration", + dependencies: [ + .product(name: "MistKit", package: "MistKit"), + .product(name: "ConfigKeyKit", package: "ConfigKeyKit"), + .product(name: "Configuration", package: "swift-configuration"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "MistKitConfigurationTests", + dependencies: ["MistKitConfiguration"], + swiftSettings: swiftSettings + ), + ] +) + +// swiftlint:enable explicit_acl explicit_top_level_acl diff --git a/Packages/MistKitConfiguration/README.md b/Packages/MistKitConfiguration/README.md new file mode 100644 index 00000000..85faee87 --- /dev/null +++ b/Packages/MistKitConfiguration/README.md @@ -0,0 +1,106 @@ +# MistKitConfiguration + +[![SwiftPM](https://img.shields.io/badge/SPM-Linux%20%7C%20iOS%20%7C%20macOS%20%7C%20watchOS%20%7C%20tvOS-success?logo=swift)](https://swift.org) +[![Swift Versions](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fbrightdigit%2FMistKitConfiguration%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/brightdigit/MistKitConfiguration) +[![Platforms](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fbrightdigit%2FMistKitConfiguration%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/brightdigit/MistKitConfiguration) +[![License](https://img.shields.io/github/license/brightdigit/MistKitConfiguration)](LICENSE) +[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/brightdigit/MistKitConfiguration/MistKitConfiguration.yml?label=actions&logo=github&?branch=main)](https://github.com/brightdigit/MistKitConfiguration/actions) +[![Codecov](https://img.shields.io/codecov/c/github/brightdigit/MistKitConfiguration)](https://codecov.io/gh/brightdigit/MistKitConfiguration) +[![Documentation](https://img.shields.io/badge/docc-read_documentation-blue)](https://swiftpackageindex.com/brightdigit/MistKitConfiguration/documentation) + +The CloudKit credential configuration glue shared by [MistKit][mistkit]'s server-side +applications: read a container ID, key ID and private key from the command line or the +environment, validate them before they reach the network, and build a `CloudKitService`. + +MistKit itself stays configuration-framework-free — the dependency arrow points one way, +into MistKit, so adopting this package never changes MistKit's own surface. + +## What's inside + +- **Raw → validated → service.** `CloudKitConfiguration` (all fields optional, reading + never throws) → `validated()` → `ValidatedCloudKitConfiguration` → `makeCloudKitService()`. +- **Format validation you would otherwise discover at request time.** `KeyIDValidator` + (64 hex characters) and `PEMValidator` (header, footer, base64 body), reachable + standalone. +- **Keys and plumbing.** `CloudKitConfigurationKeys` parameterized by container default + and environment prefix, a `readCloudKitConfiguration(keys:)` seam on any + `ConfigValueReading`, and `ConfigurationSources` for the provider stack. + +## Errors are identifiable, not prose + +Nothing in this package conforms to `LocalizedError`. It throws structured, `Equatable` +enums and leaves every user-facing string to you — because only your application knows +which flag or environment variable supplied the value, and what advice to give. + +```swift +do { + let service = try configuration.validated().makeCloudKitService() +} catch let error as CloudKitConfigurationError { + switch error { + case .missing(let field): + throw MyError.missingRequired(keys[field].key(for: .environment) ?? "") + case .invalidKeyID(.incorrectLength(let actual)): + throw MyError.badKeyID("expected \(KeyIDValidator.expectedLength), got \(actual)") + default: + throw MyError.configuration(String(describing: error)) + } +} +``` + +`CloudKitConfigurationField` names the offending field rather than a key string, because +the same field is spelled differently by different applications. + +## Usage + +```swift +import Configuration +import MistKitConfiguration + +let keys = CloudKitConfigurationKeys(defaultContainerID: "iCloud.com.example.MyApp") + +let reader = ConfigurationSources.makeConfigReader( + secretCommandLineFlags: keys.secretCommandLineFlags +) + +let service = try reader + .readCloudKitConfiguration(keys: keys) + .validated() + .makeCloudKitService() +``` + +That resolves `--cloudkit-container-id` / `CLOUDKIT_CONTAINER_ID`, `--cloudkit-key-id` / +`CLOUDKIT_KEY_ID`, `--cloudkit-private-key[-path]` and `--cloudkit-environment`, with the +command line taking precedence over the environment. + +The redaction list is **derived** from each key's `isSecret`, so it cannot drift from the +keys themselves — the drift that previously let a private key passed by flag be logged in +the clear. + +## Used by + +- [BushelCloud][bushelcloud] and [CelestraCloud][celestracloud]. +- [MistDemo][mistdemo], inside [MistKit][mistkit]. + +## Adding to your `Package.swift` + +```swift +.package(url: "https://github.com/brightdigit/MistKitConfiguration.git", from: "1.0.0-beta.1"), +``` + +```swift +.target( + name: "MyApp", + dependencies: [ + .product(name: "MistKitConfiguration", package: "MistKitConfiguration") + ] +), +``` + +## License + +MIT — see [LICENSE](LICENSE). + +[mistkit]: https://github.com/brightdigit/MistKit +[mistdemo]: https://github.com/brightdigit/MistKit/tree/main/Examples/MistDemo +[bushelcloud]: https://github.com/brightdigit/BushelCloud +[celestracloud]: https://github.com/brightdigit/CelestraCloud diff --git a/Packages/MistKitConfiguration/Scripts/cloud-setup.sh b/Packages/MistKitConfiguration/Scripts/cloud-setup.sh new file mode 100755 index 00000000..252cd0db --- /dev/null +++ b/Packages/MistKitConfiguration/Scripts/cloud-setup.sh @@ -0,0 +1,187 @@ +#!/bin/bash + +# Setup script for Claude Code on the web (cloud environments). +# +# Paste this into the environment dialog's "Setup script" field at +# claude.ai/code. It is committed here so the content stays reviewable and +# versioned, but the platform reads it from that dialog, not from the repo. +# +# Why here and not in the SessionStart hook: a setup script runs once per +# environment, then Anthropic snapshots the filesystem and reuses that snapshot +# for later sessions, which skip the script entirely. SessionStart hooks re-run +# on every session and get no such caching. The Swift toolchain is a ~1 GB +# download, so it belongs in the snapshot. +# +# .claude/hooks/session-start.sh stays as the fallback: it installs the same +# toolchain when an environment has no setup script configured, and on every +# session it wires PATH into CLAUDE_ENV_FILE (a filesystem snapshot restores +# files, not environment variables). +# +# Requirements this script is written around: +# * Must exit 0 -- a non-zero exit makes the session fail to start. +# * Must finish inside ~5 minutes or the environment cache will not build. +# Measured cold install is ~2 minutes. +# * Runs as root on Ubuntu 24.04, before Claude Code launches. +# * Needs download.swift.org on the environment's allowed-domains list +# (Network access: Custom, with the default package-manager list included). + +# No `set -e`: every failure path has to fall through to `exit 0` so a bad +# install degrades to the SessionStart hook rather than bricking the session. +set -uo pipefail + +# Used only when no .swift-version can be found on disk. Keep in sync with the +# repo's .swift-version. +FALLBACK_SWIFT_VERSION="6.4" + +SWIFTLY_ENV="$HOME/.local/share/swiftly/env.sh" + +log() { + # stderr, not stdout: resolve_swift_version's value is read via command + # substitution, so any stdout chatter would be captured into the version. + echo "[cloud-setup] $*" >&2 +} + +# The setup script may run before the repository is checked out, and the +# checkout path is not contractual, so look in the likely places and fall back +# to the pinned literal above rather than failing. +resolve_swift_version() { + local candidate + for candidate in \ + "${CLAUDE_PROJECT_DIR:-/nonexistent}/.swift-version" \ + "$PWD/.swift-version" \ + /home/user/*/.swift-version \ + /workspace/*/.swift-version \ + /root/*/.swift-version; do + if [ -f "$candidate" ]; then + local version + version="$(tr -d '[:space:]' < "$candidate")" + if [ -n "$version" ]; then + log "Using Swift $version pinned by $candidate" + printf '%s' "$version" + return 0 + fi + fi + done + log "No .swift-version found; falling back to Swift $FALLBACK_SWIFT_VERSION" + printf '%s' "$FALLBACK_SWIFT_VERSION" +} + +# System dependencies for Swift on Ubuntu 24.04 (per swift.org's Linux +# instructions), plus curl for fetching swiftly. Most are already in the base +# image, so only reach for apt when something is genuinely missing: apt-get +# update alone costs ~10s and pulls in unrelated upgrades. +install_system_packages() { + local packages missing pkg + packages=( + binutils + curl + git + gnupg2 + libc6-dev + libcurl4-openssl-dev + libedit2 + libgcc-13-dev + libncurses-dev + libpython3-dev + libsqlite3-0 + libstdc++-13-dev + libxml2-dev + libz3-dev + pkg-config + tzdata + zlib1g-dev + ) + missing=() + for pkg in "${packages[@]}"; do + if [ "$(dpkg-query -W -f='${db:Status-Status}' "$pkg" 2> /dev/null)" != "installed" ]; then + missing+=("$pkg") + fi + done + + if [ "${#missing[@]}" -eq 0 ]; then + log "All system packages already present; skipping apt." + return 0 + fi + + log "Installing missing system packages: ${missing[*]}" + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq || return 1 + apt-get install -y -qq --no-install-recommends "${missing[@]}" || return 1 +} + +install_swiftly() { + local workdir + workdir="$(mktemp -d)" || return 1 + ( + cd "$workdir" || exit 1 + curl -fsSLO "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" || exit 1 + tar zxf "swiftly-$(uname -m).tar.gz" || exit 1 + ./swiftly init -y --skip-install || exit 1 + ) + local status=$? + rm -rf "$workdir" + return "$status" +} + +# Make swift resolvable for plain login shells too. This is a file, so the +# environment snapshot carries it; the SessionStart hook still handles +# CLAUDE_ENV_FILE for Claude Code's own process. +write_profile_entry() { + cat > /etc/profile.d/swiftly.sh <<'PROFILE' +# Added by MistKitConfiguration Scripts/cloud-setup.sh +export SWIFTLY_HOME_DIR="$HOME/.local/share/swiftly" +export SWIFTLY_BIN_DIR="$HOME/.local/share/swiftly/bin" +case ":$PATH:" in + *":$SWIFTLY_BIN_DIR:"*) ;; + *) export PATH="$SWIFTLY_BIN_DIR:$PATH" ;; +esac +PROFILE +} + +main() { + if [ -f "$SWIFTLY_ENV" ]; then + # shellcheck disable=SC1090 + . "$SWIFTLY_ENV" + fi + + if command -v swift > /dev/null 2>&1; then + log "Swift already installed: $(swift --version 2>&1 | head -1)" + write_profile_entry + return 0 + fi + + local version + version="$(resolve_swift_version)" + + install_system_packages || { + log "WARNING: system package install failed; continuing anyway." + } + + install_swiftly || { + log "ERROR: swiftly install failed." + return 1 + } + + # shellcheck disable=SC1090 + . "$SWIFTLY_ENV" || return 1 + + if ! swiftly install -y "$version"; then + log "Pinned toolchain $version failed to install; falling back to latest." + swiftly install -y latest || return 1 + swiftly use -y latest || return 1 + else + swiftly use -y "$version" || return 1 + fi + + write_profile_entry + swift --version +} + +if main; then + log "Setup complete." +else + log "Setup did not complete; the SessionStart hook will install Swift instead." +fi + +# Always succeed: a non-zero exit here stops the session from starting. +exit 0 diff --git a/Packages/MistKitConfiguration/Scripts/header.sh b/Packages/MistKitConfiguration/Scripts/header.sh new file mode 100755 index 00000000..809f88ac --- /dev/null +++ b/Packages/MistKitConfiguration/Scripts/header.sh @@ -0,0 +1,113 @@ +#!/bin/bash + +# Function to print usage +usage() { + echo "Usage: $0 -d directory -c creator -o company -p package [-y year]" + echo " -d directory Directory to read from (including subdirectories)" + echo " -c creator Name of the creator" + echo " -o company Name of the company with the copyright" + echo " -p package Package or library name" + echo " -y year Copyright year (optional, defaults to current year)" + exit 1 +} + +# Get the current year if not provided +current_year=$(date +"%Y") + +# Default values +year="$current_year" + +# Parse arguments +while getopts ":d:c:o:p:y:" opt; do + case $opt in + d) directory="$OPTARG" ;; + c) creator="$OPTARG" ;; + o) company="$OPTARG" ;; + p) package="$OPTARG" ;; + y) year="$OPTARG" ;; + *) usage ;; + esac +done + +# Check for mandatory arguments +if [ -z "$directory" ] || [ -z "$creator" ] || [ -z "$company" ] || [ -z "$package" ]; then + usage +fi + +# Define the header template using a heredoc +read -r -d '' header_template <<'EOF' +// +// %s +// %s +// +// Created by %s. +// Copyright © %s %s. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// +EOF + +# Loop through each Swift file in the specified directory and subdirectories +find "$directory" -type f -name "*.swift" | while read -r file; do + # Skip files carrying `// swift-format-ignore-file` anywhere in the leading + # comment block. This is the opt-out used by generated files (e.g. + # swift-openapi-generator emits it via `additionalFileComments`) and lets + # them sit anywhere in the tree without needing a path-based exclusion. + if awk ' + /^\/\/[[:space:]]*swift-format-ignore-file[[:space:]]*$/ { found = 1; exit } + /^[[:space:]]*$/ || /^\/\// { next } + { exit } + END { exit !found } + ' "$file"; then + echo "Skipping $file due to swift-format-ignore directive." + continue + fi + + # Create the header with the current filename + # Escape % characters in user-provided values to prevent format specifier injection + filename=$(basename "$file" | sed 's/%/%%/g') + package_safe=$(printf '%s' "$package" | sed 's/%/%%/g') + creator_safe=$(printf '%s' "$creator" | sed 's/%/%%/g') + year_safe=$(printf '%s' "$year" | sed 's/%/%%/g') + company_safe=$(printf '%s' "$company" | sed 's/%/%%/g') + + header=$(printf "$header_template" "$filename" "$package_safe" "$creator_safe" "$year_safe" "$company_safe") + + # Remove all consecutive lines at the beginning which start with "// ", contain only whitespace, or only "//" + awk ' + BEGIN { skip = 1 } + { + if (skip && ($0 ~ /^\/\/ / || $0 ~ /^\/\/$/ || $0 ~ /^$/)) { + next + } + skip = 0 + print + }' "$file" > temp_file + + # Add the header to the cleaned file + (echo "$header"; echo; cat temp_file) > "$file" + + # Remove the temporary file + rm temp_file +done + +echo "Headers added or files skipped appropriately across all Swift files in the directory and subdirectories." diff --git a/Packages/MistKitConfiguration/Scripts/lint.sh b/Packages/MistKitConfiguration/Scripts/lint.sh new file mode 100755 index 00000000..a18db0e9 --- /dev/null +++ b/Packages/MistKitConfiguration/Scripts/lint.sh @@ -0,0 +1,122 @@ +#!/bin/bash + +# Remove set -e to allow script to continue running +# set -e # Exit on any error + +ERRORS=0 + +run_command() { + "$@" || ERRORS=$((ERRORS + 1)) +} + +if [ "$LINT_MODE" = "INSTALL" ]; then + exit +fi + +echo "LintMode: $LINT_MODE" + +# More portable way to get script directory +if [ -z "$SRCROOT" ]; then + SCRIPT_DIR=$(dirname "$(readlink -f "$0")") + PACKAGE_DIR="${SCRIPT_DIR}/.." +else + PACKAGE_DIR="${SRCROOT}" +fi + +# Ensure mise-managed tools are on PATH outside CI (CI uses jdx/mise-action). +# Skipped in Claude Code web sessions: mise resolves its `spm:`/`aqua:` tools +# through api.github.com, which those sessions cannot reach, so evaluating its +# env would only shadow the toolchain's own swift-format with a broken shim. +if command -v mise >/dev/null 2>&1 && [ -z "$CI" ] \ + && [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + eval "$(mise -C "$PACKAGE_DIR" env -s bash)" +fi + +# SwiftLint and periphery are not installed in Claude Code web sessions (no +# Linux binaries for periphery, and mise is unreachable per above). swift-format +# ships inside the Swift toolchain, so formatting, the header check and the +# build still run there; run ./Scripts/lint.sh locally for full coverage. +if [ "${CLAUDE_CODE_REMOTE:-}" = "true" ]; then + RUN_SWIFTLINT=0 +else + RUN_SWIFTLINT=1 +fi + +if [ "$LINT_MODE" = "NONE" ]; then + exit +elif [ "$LINT_MODE" = "STRICT" ]; then + SWIFTFORMAT_OPTIONS="--configuration .swift-format" + SWIFTLINT_OPTIONS="--strict" +else + SWIFTFORMAT_OPTIONS="--configuration .swift-format" + SWIFTLINT_OPTIONS="" +fi + +pushd "$PACKAGE_DIR" || exit + +if [ -z "$CI" ]; then + run_command swift-format format $SWIFTFORMAT_OPTIONS --recursive --parallel --in-place Sources Tests + if [ "$RUN_SWIFTLINT" -eq 1 ]; then + run_command swiftlint --fix + fi +fi + +if [ -z "$FORMAT_ONLY" ]; then + run_command swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests + if [ "$RUN_SWIFTLINT" -eq 1 ]; then + run_command swiftlint lint $SWIFTLINT_OPTIONS + else + echo "Skipping SwiftLint (Claude Code web session)." + fi + # Check for compilation errors + run_command swift build --build-tests +fi + +$PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "BrightDigit" -p "MistKitConfiguration" + +# Generated files now automatically include ignore directives via OpenAPI generator configuration + +# Periphery cannot find the index store on its own: its location depends on the +# build system. swiftbuild (the SwiftPM default since Swift 6.2) writes +# `.build/out`, the native build system writes +# `.build//debug/index/store`, and older toolchains wrote +# `.build/debug/index/store`. Resolve it here and hand it over explicitly; +# `--index-store-path` implies `--skip-build`, which is what we want because +# `swift build --build-tests` already ran above. Skipped in CI (periphery has +# never run there) and in Claude Code web sessions (no Linux binaries; mise +# unreachable per above). +periphery_index_store() { + local candidate + for candidate in "$PACKAGE_DIR"/.build/*/debug/index/store \ + "$PACKAGE_DIR"/.build/debug/index/store \ + "$PACKAGE_DIR"/.build/out; do + if [ -d "$candidate/v5/units" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +if [ -z "$CI" ] && [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + if INDEX_STORE_PATH=$(periphery_index_store); then + run_command periphery scan $PERIPHERY_OPTIONS \ + --index-store-path "$INDEX_STORE_PATH" --skip-build \ + --disable-update-check + else + echo "Skipping periphery scan (no index store under .build; run swift build first)." + fi +else + echo "Skipping periphery scan (CI or Claude Code web session)." +fi + +popd + +# Exit with error code if any errors occurred +if [ $ERRORS -gt 0 ]; then + echo "Linting completed with $ERRORS error(s)" + exit 1 +else + echo "Linting completed successfully" + exit 0 +fi diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfiguration.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfiguration.swift new file mode 100644 index 00000000..2ec46c07 --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfiguration.swift @@ -0,0 +1,118 @@ +// +// CloudKitConfiguration.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKit + +/// Raw CloudKit server-to-server settings, exactly as supplied. +/// +/// Every field is optional and unparsed: this type is the output of *reading* a +/// configuration source and performs no validation, which is what lets reading itself be +/// non-throwing and composable into any application's own loader. Call ``validated()`` to +/// resolve and check it. +/// +/// `environment` stays a `String?` rather than a `MistKit.Environment` so that +/// "unspecified" remains distinguishable from "explicitly development", and so an +/// unrecognized value fails at validation rather than at read time. +public struct CloudKitConfiguration: Sendable { + /// CloudKit container identifier, e.g. `iCloud.com.example.App`. + public var containerID: String? + /// Server-to-server key ID from the CloudKit Dashboard. + public var keyID: String? + /// Path to a PEM-encoded private key file. + public var privateKeyPath: String? + /// Inline PEM private key, for environments where writing a file is inconvenient. + public var privateKey: String? + /// Unparsed CloudKit environment name; `nil` means unspecified. + public var environment: String? + + /// Creates a raw configuration. + /// + /// - Parameters: + /// - containerID: CloudKit container identifier. + /// - keyID: Server-to-server key ID. + /// - privateKeyPath: Path to a PEM private key file. + /// - privateKey: Inline PEM private key. + /// - environment: Unparsed environment name. + public init( + containerID: String? = nil, + keyID: String? = nil, + privateKeyPath: String? = nil, + privateKey: String? = nil, + environment: String? = nil + ) { + self.containerID = containerID + self.keyID = keyID + self.privateKeyPath = privateKeyPath + self.privateKey = privateKey + self.environment = environment + } + + /// Resolves and checks every field, failing closed on anything missing, empty, or + /// unparseable. + /// + /// Presence is checked before format, so a configuration with both a malformed key ID + /// and no private key reports ``CloudKitConfigurationError/missing(_:)`` first. + /// An inline ``privateKey`` takes precedence over ``privateKeyPath``. + /// + /// - Returns: A validated configuration. + /// - Throws: ``CloudKitConfigurationError``. + public func validated() throws(CloudKitConfigurationError) -> ValidatedCloudKitConfiguration { + guard let containerID, !containerID.isEmpty else { + throw .missing(.containerID) + } + guard let keyID, !keyID.isEmpty else { + throw .missing(.keyID) + } + + let trimmedKey = privateKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let trimmedPath = privateKeyPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let material: PrivateKeyMaterial + if !trimmedKey.isEmpty { + material = .raw(trimmedKey) + } else if !trimmedPath.isEmpty { + material = .file(path: trimmedPath) + } else { + throw .missing(.privateKey) + } + + let rawEnvironment = (environment ?? MistKit.Environment.development.rawValue) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let parsed = MistKit.Environment(caseInsensitive: rawEnvironment) else { + throw .unrecognizedEnvironment(environment ?? "") + } + + return try ValidatedCloudKitConfiguration( + containerID: containerID, + keyID: keyID, + privateKey: material, + environment: parsed + ) + } +} diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationError.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationError.swift new file mode 100644 index 00000000..59b32f26 --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationError.swift @@ -0,0 +1,69 @@ +// +// CloudKitConfigurationError.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// A CloudKit configuration value that is missing or malformed. +/// +/// Deliberately **not** `LocalizedError`: every consumer of this package already owns an +/// error type with its own wording, its own remediation advice, and its own key names. +/// Presenting package-authored prose to a user would contradict all three. Switch over +/// these cases and map them onto your own error instead. +/// +/// ```swift +/// do { +/// let validated = try configuration.validated() +/// } catch let error as CloudKitConfigurationError { +/// throw MyError(describing: error) +/// } +/// ``` +public enum CloudKitConfigurationError: Error, Equatable, Sendable { + /// A required field was absent or empty. + case missing(CloudKitConfigurationField) + /// The key ID was present but malformed. + case invalidKeyID(KeyIDValidationFailure) + /// The inline private key was present but not well-formed PEM. + case invalidPrivateKey(PEMValidationFailure) + /// The environment string matched neither `development` nor `production`. + case unrecognizedEnvironment(String) +} + +extension CloudKitConfigurationError: CustomStringConvertible { + /// A debugging description. **Not** intended for end users — map to your own error. + public var description: String { + switch self { + case .missing(let field): + return "missing(\(field))" + case .invalidKeyID(let failure): + return "invalidKeyID(\(failure))" + case .invalidPrivateKey(let failure): + return "invalidPrivateKey(\(failure))" + case .unrecognizedEnvironment(let raw): + return "unrecognizedEnvironment(\(raw))" + } + } +} diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationField.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationField.swift new file mode 100644 index 00000000..a84e96e8 --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationField.swift @@ -0,0 +1,49 @@ +// +// CloudKitConfigurationField.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// A field of ``CloudKitConfiguration``, named independently of any key spelling. +/// +/// Errors identify the offending field with this enum rather than a key string because +/// consuming applications spell the same field differently — CelestraCloud reads +/// `cloudkit.key-id` while another host might read `key.id` — so a string baked into the +/// package would be wrong for every consumer but one. Map a field to whatever name your +/// application presents, or to the key itself via +/// ``CloudKitConfigurationKeys/subscript(_:)``. +public enum CloudKitConfigurationField: Equatable, Sendable, CaseIterable { + /// The CloudKit container identifier. + case containerID + /// The server-to-server key ID. + case keyID + /// The inline PEM private key. + case privateKey + /// The path to a PEM private key file. + case privateKeyPath + /// The CloudKit environment. + case environment +} diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationKeys.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationKeys.swift new file mode 100644 index 00000000..ba9d242e --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationKeys.swift @@ -0,0 +1,123 @@ +// +// CloudKitConfigurationKeys.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit + +/// The five CloudKit configuration keys, parameterized per application. +/// +/// A value type rather than a `static` enum because the container default and the +/// environment prefix differ per application, and a `static` member cannot take +/// arguments. +/// +/// Bases are **dash-case** (`cloudkit.key-id`, never `cloudkit.key_id`): swift-configuration's +/// `CLIKeyEncoder` joins key components verbatim, so an underscore survives into an +/// unusable flag *and* silently defeats secret redaction, because the redaction list is +/// matched against the generated flag. Build keys only through this type. +public struct CloudKitConfigurationKeys: Sendable { + /// `cloudkit.container-id`, defaulting to the application's own container. + public let containerID: ConfigKey + /// `cloudkit.key-id` — secret. + public let keyID: OptionalConfigKey + /// `cloudkit.private-key-path` — secret. + public let privateKeyPath: OptionalConfigKey + /// `cloudkit.private-key` — secret. + public let privateKey: OptionalConfigKey + /// `cloudkit.environment`. + public let environment: OptionalConfigKey + + /// The command-line flags whose values must be redacted from logs. + /// + /// Derived from each key's `isSecret` rather than hand-listed, so the list cannot drift + /// from the keys themselves — the drift that previously let a private key passed by + /// flag be logged in the clear. Splice in application-specific flags with + /// `union(_:)` before handing the result to + /// `CommandLineArgumentsProvider(secretsSpecifier: .specific(_:))`. + public var secretCommandLineFlags: Set { + let all: [any ConfigurationKey] = [ + containerID, keyID, privateKeyPath, privateKey, environment, + ] + return Set(all.filter(\.isSecret).compactMap(Self.commandLineFlag(for:))) + } + + /// Creates the key group. + /// + /// - Parameters: + /// - defaultContainerID: Container used when neither the command line nor the + /// environment supplies one. + /// - envPrefix: Prefix applied to environment-variable names only, e.g. `"BUSHEL"` + /// yields `BUSHEL_CLOUDKIT_KEY_ID`. Command-line flags are unaffected. Defaults to + /// `nil`, which is what every current consumer uses. + public init(defaultContainerID: String, envPrefix: String? = nil) { + self.containerID = ConfigKey( + "cloudkit.container-id", + envPrefix: envPrefix, + default: defaultContainerID + ) + self.keyID = OptionalConfigKey( + "cloudkit.key-id", + envPrefix: envPrefix, + isSecret: true + ) + self.privateKeyPath = OptionalConfigKey( + "cloudkit.private-key-path", + envPrefix: envPrefix, + isSecret: true + ) + self.privateKey = OptionalConfigKey( + "cloudkit.private-key", + envPrefix: envPrefix, + isSecret: true + ) + self.environment = OptionalConfigKey( + "cloudkit.environment", + envPrefix: envPrefix + ) + } + + private static func commandLineFlag(for key: any ConfigurationKey) -> String? { + guard let base = key.key(for: .commandLine) else { + return nil + } + return "--" + base.split(separator: ".").joined(separator: "-") + } + + /// Looks up the key backing a given field. + /// + /// Lets an application turn a ``CloudKitConfigurationError`` into a message naming the + /// flag or variable *it* uses, without hard-coding key strings. + public subscript(field: CloudKitConfigurationField) -> any ConfigurationKey { + switch field { + case .containerID: return containerID + case .keyID: return keyID + case .privateKey: return privateKey + case .privateKeyPath: return privateKeyPath + case .environment: return environment + } + } +} diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigReader+ConfigValueReading.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigReader+ConfigValueReading.swift new file mode 100644 index 00000000..00f216bf --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigReader+ConfigValueReading.swift @@ -0,0 +1,45 @@ +// +// ConfigReader+ConfigValueReading.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit +public import Configuration + +/// Bridges swift-configuration's `ConfigReader` to ConfigKeyKit's ``ConfigValueReading``, +/// which supplies the CLI → ENV → default resolution for every `ConfigKey` / +/// `OptionalConfigKey` overload. +/// +/// Shipping the conformance here means consuming applications must **not** declare their +/// own — two modules declaring the same retroactive conformance is a duplicate-conformance +/// error. +extension ConfigReader: @retroactive ConfigValueReading { + /// Wraps a resolved per-source key string in swift-configuration's own key type. + public func makeConfigKey(_ string: String) -> Configuration.ConfigKey { + .init(string) + } +} diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigValueReading+CloudKit.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigValueReading+CloudKit.swift new file mode 100644 index 00000000..7b0d45f0 --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigValueReading+CloudKit.swift @@ -0,0 +1,55 @@ +// +// ConfigValueReading+CloudKit.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit + +extension ConfigValueReading { + /// Reads the five CloudKit values from this reader, applying CLI → ENV → default + /// precedence. + /// + /// Reading never fails: an unset value stays `nil`, and an unrecognized environment + /// string is preserved verbatim for ``CloudKitConfiguration/validated()`` to reject. + /// That keeps this composable into an application's own loader without infecting it + /// with a throwing read. + /// + /// - Parameter keys: The key group to read, built with the application's own container + /// default and environment prefix. + /// - Returns: The raw, unvalidated configuration. + public func readCloudKitConfiguration( + keys: CloudKitConfigurationKeys + ) -> CloudKitConfiguration { + CloudKitConfiguration( + containerID: read(keys.containerID), + keyID: read(keys.keyID), + privateKeyPath: read(keys.privateKeyPath), + privateKey: read(keys.privateKey), + environment: read(keys.environment) + ) + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/EnhancedConfigurationError.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigurationError.swift similarity index 75% rename from Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/EnhancedConfigurationError.swift rename to Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigurationError.swift index 394e9040..a688b4b5 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/EnhancedConfigurationError.swift +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigurationError.swift @@ -1,6 +1,6 @@ // -// EnhancedConfigurationError.swift -// CelestraCloud +// ConfigurationError.swift +// MistKitConfiguration // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -29,8 +29,13 @@ public import Foundation -/// Enhanced configuration error with detailed context -public struct EnhancedConfigurationError: LocalizedError { +/// A missing or malformed configuration value, naming the key at fault. +/// +/// A presentation type: this package **never throws it**. It is offered so applications +/// that want a ready-made `LocalizedError` can map ``CloudKitConfigurationError`` onto +/// one shape rather than each inventing their own. An application with richer needs — +/// remediation text, a closed set of domain cases — should use its own type instead. +public struct ConfigurationError: LocalizedError, Sendable { /// The error message describing what went wrong. public let message: String @@ -40,13 +45,13 @@ public struct EnhancedConfigurationError: LocalizedError { /// A localized description of the error. public var errorDescription: String? { var parts = [message] - if let key = key { + if let key { parts.append("(key: \(key))") } return parts.joined(separator: " ") } - /// Creates a new enhanced configuration error. + /// Creates a new configuration error. /// /// - Parameters: /// - message: The error message describing what went wrong. diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigurationSources.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigurationSources.swift new file mode 100644 index 00000000..940daf9d --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigurationSources.swift @@ -0,0 +1,79 @@ +// +// ConfigurationSources.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Configuration + +/// The provider stack this package expects: command-line arguments first, then +/// environment variables. +/// +/// Captures the one piece of loader wiring that is genuinely shared — provider order plus +/// the redaction list — without owning the application-shaped parts. Each application +/// keeps its own loader and its own root configuration type. +public enum ConfigurationSources { + /// Builds a reader over the current process's arguments and environment. + /// + /// - Parameter secretCommandLineFlags: Flags whose values must be redacted; pass + /// ``CloudKitConfigurationKeys/secretCommandLineFlags``, unioned with any of your own. + /// - Returns: A reader over the command line and the process environment. + public static func makeConfigReader( + secretCommandLineFlags: Set + ) -> ConfigReader { + ConfigReader(providers: [ + CommandLineArgumentsProvider( + secretsSpecifier: .specific(secretCommandLineFlags) + ), + EnvironmentVariablesProvider(), + ]) + } + + /// Builds a reader over injected arguments and environment. + /// + /// Use from tests: driving the real providers means key normalization and value + /// coercion behave exactly as they do in production, which an in-memory double does not + /// guarantee. + /// + /// - Parameters: + /// - secretCommandLineFlags: Flags whose values must be redacted. + /// - arguments: A full argument vector, including the executable name at index 0. + /// - environmentVariables: The simulated environment. + /// - Returns: A reader over the injected arguments and environment. + public static func makeConfigReader( + secretCommandLineFlags: Set, + arguments: [String], + environmentVariables: [String: String] + ) -> ConfigReader { + ConfigReader(providers: [ + CommandLineArgumentsProvider( + arguments: arguments, + secretsSpecifier: .specific(secretCommandLineFlags) + ), + EnvironmentVariablesProvider(environmentVariables: environmentVariables), + ]) + } +} diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/KeyIDValidationFailure.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/KeyIDValidationFailure.swift new file mode 100644 index 00000000..20a4bbb0 --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/KeyIDValidationFailure.swift @@ -0,0 +1,44 @@ +// +// KeyIDValidationFailure.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Why a CloudKit server-to-server key ID was rejected. +/// +/// Carries structured facts only — no prose. The consuming application decides how to +/// phrase the failure, because only it knows which environment variable or flag supplied +/// the value. +public enum KeyIDValidationFailure: Error, Equatable, Sendable { + /// The key ID was empty, or contained only whitespace. + case empty + /// The key ID had leading or trailing whitespace, commonly a stray newline from a copy. + case surroundingWhitespace + /// The key ID was not ``KeyIDValidator/expectedLength`` characters long. + case incorrectLength(actual: Int) + /// The key ID contained characters outside `0-9`, `a-f`, `A-F`. + case nonHexCharacters +} diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/KeyIDValidator.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/KeyIDValidator.swift new file mode 100644 index 00000000..e9e626c8 --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/KeyIDValidator.swift @@ -0,0 +1,66 @@ +// +// KeyIDValidator.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +/// Validates the format of a CloudKit Server-to-Server key ID. +/// +/// CloudKit server-to-server keys are SHA-256 fingerprints of the public key: 64 hex +/// characters. Checking locally turns an opaque signing failure at request time into a +/// precise, switchable ``KeyIDValidationFailure`` at configuration time. +public enum KeyIDValidator { + /// The exact number of characters a CloudKit server-to-server key ID has. + public static let expectedLength = 64 + + private static let hexDigits = Set("0123456789abcdefABCDEF") + + /// Validates that a key ID has the expected CloudKit server-to-server format. + /// + /// Upper- and lower-case hex are both accepted, to be lenient about a copy from the + /// CloudKit Dashboard. + /// + /// - Parameter keyID: The key ID to validate. + /// - Throws: ``KeyIDValidationFailure`` describing the first problem found. + public static func validate(_ keyID: String) throws(KeyIDValidationFailure) { + let trimmed = keyID.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmed.isEmpty else { + throw .empty + } + guard trimmed == keyID else { + throw .surroundingWhitespace + } + guard trimmed.count == expectedLength else { + throw .incorrectLength(actual: trimmed.count) + } + guard trimmed.allSatisfy(hexDigits.contains) else { + throw .nonHexCharacters + } + } +} diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/PEMValidationFailure.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/PEMValidationFailure.swift new file mode 100644 index 00000000..b6af5373 --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/PEMValidationFailure.swift @@ -0,0 +1,42 @@ +// +// PEMValidationFailure.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Why a PEM-encoded private key was rejected. +/// +/// Carries structured facts only — no prose; see ``KeyIDValidationFailure`` for why. +public enum PEMValidationFailure: Error, Equatable, Sendable { + /// No `-----BEGIN PRIVATE KEY-----` header was present. + case missingHeader + /// No `-----END PRIVATE KEY-----` footer was present, usually a truncated copy. + case missingFooter + /// The header and footer were present but enclosed no key data. + case emptyContent + /// The enclosed content was not valid base64. + case invalidBase64 +} diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/PEMValidator.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/PEMValidator.swift new file mode 100644 index 00000000..085365a3 --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/PEMValidator.swift @@ -0,0 +1,64 @@ +// +// PEMValidator.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +/// Validates the structure and encoding of a PEM-encoded private key. +/// +/// Catches the common copy/paste failures — truncation, a missing marker, a binary-mangled +/// body — before the key ever reaches MistKit's signing path. +public enum PEMValidator { + /// Validates that a PEM string is well formed. + /// + /// Checks, in order: a `BEGIN … PRIVATE KEY` header, an `END … PRIVATE KEY` footer, + /// non-empty content between them, and that the content is valid base64. + /// + /// - Parameter pemString: The PEM-formatted private key. + /// - Throws: ``PEMValidationFailure`` describing the first problem found. + public static func validate(_ pemString: String) throws(PEMValidationFailure) { + let trimmed = pemString.trimmingCharacters(in: .whitespacesAndNewlines) + + guard trimmed.contains("-----BEGIN"), trimmed.contains("PRIVATE KEY-----") else { + throw .missingHeader + } + guard trimmed.contains("-----END"), trimmed.contains("PRIVATE KEY-----") else { + throw .missingFooter + } + + let contentLines = trimmed.components(separatedBy: .newlines).filter { line in + !line.contains("BEGIN") && !line.contains("END") && !line.isEmpty + } + guard !contentLines.isEmpty else { + throw .emptyContent + } + guard Data(base64Encoded: contentLines.joined()) != nil else { + throw .invalidBase64 + } + } +} diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ValidatedCloudKitConfiguration.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ValidatedCloudKitConfiguration.swift new file mode 100644 index 00000000..9708469f --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ValidatedCloudKitConfiguration.swift @@ -0,0 +1,101 @@ +// +// ValidatedCloudKitConfiguration.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +public import MistKit + +/// CloudKit credentials that have passed presence *and* format validation. +/// +/// The initializer is throwing and runs ``KeyIDValidator`` — and ``PEMValidator`` for an +/// inline key — so there is no way to hold a value of this type whose credentials skipped +/// format checking. That property is what lets callers drop their own hand-rolled +/// validation before constructing a service. +public struct ValidatedCloudKitConfiguration: Sendable { + /// The CloudKit container identifier. + public let containerID: String + /// The server-to-server key ID. + public let keyID: String + /// The resolved signing key, inline or a path to a `.pem` file. + public let privateKey: PrivateKeyMaterial + /// The CloudKit environment. + public let environment: MistKit.Environment + + /// Creates a validated configuration from already-resolved values. + /// + /// - Parameters: + /// - containerID: The CloudKit container identifier. + /// - keyID: The server-to-server key ID; must be 64 hex characters. + /// - privateKey: The signing key; inline PEM is validated, a file path is not read. + /// - environment: The CloudKit environment. + /// - Throws: ``CloudKitConfigurationError/invalidKeyID(_:)`` or + /// ``CloudKitConfigurationError/invalidPrivateKey(_:)``. + public init( + containerID: String, + keyID: String, + privateKey: PrivateKeyMaterial, + environment: MistKit.Environment + ) throws(CloudKitConfigurationError) { + do { + try KeyIDValidator.validate(keyID) + } catch { + throw .invalidKeyID(error) + } + if case .raw(let pem) = privateKey { + do { + try PEMValidator.validate(pem) + } catch { + throw .invalidPrivateKey(error) + } + } + + self.containerID = containerID + self.keyID = keyID + self.privateKey = privateKey + self.environment = environment + } +} + +extension ValidatedCloudKitConfiguration { + /// Builds a `CloudKitService` signing with these server-to-server credentials. + /// + /// `PrivateKeyMaterial` defers reading a `.file(path:)` key until the credentials are + /// consumed, so this performs no file IO. + /// + /// - Returns: A configured service. + /// - Throws: `CredentialsValidationError` if MistKit rejects the credentials. + public func makeCloudKitService() throws -> CloudKitService { + CloudKitService( + containerIdentifier: containerID, + credentials: try Credentials( + serverToServer: ServerToServerCredentials(keyID: keyID, privateKey: privateKey) + ), + environment: environment + ) + } +} diff --git a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationKeysTests.swift b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationKeysTests.swift new file mode 100644 index 00000000..bcdbef85 --- /dev/null +++ b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationKeysTests.swift @@ -0,0 +1,103 @@ +// +// CloudKitConfigurationKeysTests.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit +import Testing + +@testable import MistKitConfiguration + +@Suite("CloudKitConfigurationKeys") +internal struct CloudKitConfigurationKeysTests { + private static let keys = CloudKitConfigurationKeys( + defaultContainerID: "iCloud.com.test.App" + ) + + @Test("Bases are dash-case and cloudkit-namespaced") + internal func basesAreDashCase() { + let bases = CloudKitConfigurationField.allCases.compactMap { + Self.keys[$0].key(for: .commandLine) + } + #expect(bases.count == CloudKitConfigurationField.allCases.count) + for base in bases { + #expect(!base.contains("_"), "\(base) must not contain an underscore") + #expect(base.hasPrefix("cloudkit."), "\(base) must be cloudkit-namespaced") + } + } + + @Test("Command-line flags are dash-joined") + internal func commandLineFlags() { + #expect(Self.keys.keyID.key(for: .commandLine) == "cloudkit.key-id") + #expect(Self.keys.privateKeyPath.key(for: .commandLine) == "cloudkit.private-key-path") + } + + @Test("Environment names ignore envPrefix when none is given") + internal func environmentNamesWithoutPrefix() { + #expect(Self.keys.keyID.key(for: .environment) == "CLOUDKIT_KEY-ID") + #expect(Self.keys.containerID.key(for: .environment) == "CLOUDKIT_CONTAINER-ID") + } + + @Test("envPrefix applies to the environment only, never the command line") + internal func envPrefixIsEnvironmentOnly() { + let prefixed = CloudKitConfigurationKeys( + defaultContainerID: "iCloud.com.test.App", + envPrefix: "BUSHEL" + ) + #expect(prefixed.keyID.key(for: .environment) == "BUSHEL_CLOUDKIT_KEY-ID") + #expect(prefixed.keyID.key(for: .commandLine) == "cloudkit.key-id") + } + + @Test("The three credential keys are secret; the other two are not") + internal func secrecy() { + #expect(Self.keys.keyID.isSecret) + #expect(Self.keys.privateKey.isSecret) + #expect(Self.keys.privateKeyPath.isSecret) + #expect(!Self.keys.containerID.isSecret) + #expect(!Self.keys.environment.isSecret) + } + + @Test("The redaction list is derived from isSecret, so it cannot drift") + internal func secretFlagsAreDerived() { + #expect( + Self.keys.secretCommandLineFlags == [ + "--cloudkit-key-id", "--cloudkit-private-key", "--cloudkit-private-key-path", + ] + ) + } + + @Test("The container default is the one supplied") + internal func containerDefault() { + #expect(Self.keys.containerID.defaultValue == "iCloud.com.test.App") + } + + @Test("Every field maps to its key") + internal func fieldSubscript() { + #expect(Self.keys[.keyID].key(for: .commandLine) == "cloudkit.key-id") + #expect(Self.keys[.environment].key(for: .commandLine) == "cloudkit.environment") + } +} diff --git a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationReadingTests.swift b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationReadingTests.swift new file mode 100644 index 00000000..3484a50a --- /dev/null +++ b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationReadingTests.swift @@ -0,0 +1,120 @@ +// +// CloudKitConfigurationReadingTests.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit +internal import Configuration +internal import MistKit +import Testing + +@testable import MistKitConfiguration + +@Suite("readCloudKitConfiguration") +internal struct CloudKitConfigurationReadingTests { + private static let keys = CloudKitConfigurationKeys( + defaultContainerID: "iCloud.com.test.Default" + ) + + /// Builds a reader over the **real** providers with injected inputs, so key + /// normalization and value coercion behave exactly as they do in production. An + /// in-memory double matches keys literally and serves only the type it stored, which is + /// precisely where such doubles drift from the real stack. + private static func reader( + arguments: [String] = [], + environment: [String: String] = [:] + ) -> ConfigReader { + ConfigurationSources.makeConfigReader( + secretCommandLineFlags: keys.secretCommandLineFlags, + arguments: ["app"] + arguments, + environmentVariables: environment + ) + } + + @Test("Falls back to the container default when nothing supplies one") + internal func containerDefault() { + let config = Self.reader().readCloudKitConfiguration(keys: Self.keys) + #expect(config.containerID == "iCloud.com.test.Default") + #expect(config.keyID == nil) + #expect(config.environment == nil) + } + + @Test("Reads from the environment") + internal func readsEnvironment() { + let reader = Self.reader(environment: [ + "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.FromEnv", + "CLOUDKIT_KEY_ID": TestFixtures.validKeyID, + "CLOUDKIT_ENVIRONMENT": "production", + ]) + let config = reader.readCloudKitConfiguration(keys: Self.keys) + + #expect(config.containerID == "iCloud.com.test.FromEnv") + #expect(config.keyID == TestFixtures.validKeyID) + #expect(config.environment == "production") + } + + @Test("Command line takes precedence over the environment") + internal func commandLineWins() { + let reader = Self.reader( + arguments: ["--cloudkit-container-id", "iCloud.com.test.FromCLI"], + environment: ["CLOUDKIT_CONTAINER_ID": "iCloud.com.test.FromEnv"] + ) + let config = reader.readCloudKitConfiguration(keys: Self.keys) + + #expect(config.containerID == "iCloud.com.test.FromCLI") + } + + @Test("Reading never throws; an unparseable environment surfaces at validation") + internal func readingNeverThrows() { + let reader = Self.reader(environment: [ + "CLOUDKIT_KEY_ID": TestFixtures.validKeyID, + "CLOUDKIT_PRIVATE_KEY_PATH": "/tmp/key.pem", + "CLOUDKIT_ENVIRONMENT": "staging", + ]) + let config = reader.readCloudKitConfiguration(keys: Self.keys) + + #expect(config.environment == "staging") + #expect(throws: CloudKitConfigurationError.unrecognizedEnvironment("staging")) { + try config.validated() + } + } + + @Test("A full command line validates end to end") + internal func endToEnd() throws { + let reader = Self.reader(arguments: [ + "--cloudkit-container-id", "iCloud.com.test.App", + "--cloudkit-key-id", TestFixtures.validKeyID, + "--cloudkit-private-key-path", "/tmp/key.pem", + "--cloudkit-environment", "production", + ]) + let config = reader.readCloudKitConfiguration(keys: Self.keys) + + let validated = try config.validated() + #expect(validated.containerID == "iCloud.com.test.App") + #expect(validated.environment == .production) + } +} diff --git a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationTests.swift b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationTests.swift new file mode 100644 index 00000000..1ddc2781 --- /dev/null +++ b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationTests.swift @@ -0,0 +1,130 @@ +// +// CloudKitConfigurationTests.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKit +import Testing + +@testable import MistKitConfiguration + +@Suite("CloudKitConfiguration.validated") +internal struct CloudKitConfigurationTests { + private static func complete( + containerID: String? = "iCloud.com.test.App", + keyID: String? = TestFixtures.validKeyID, + privateKeyPath: String? = "/tmp/key.pem", + privateKey: String? = nil, + environment: String? = nil + ) -> CloudKitConfiguration { + CloudKitConfiguration( + containerID: containerID, + keyID: keyID, + privateKeyPath: privateKeyPath, + privateKey: privateKey, + environment: environment + ) + } + + @Test("Validates a complete configuration") + internal func validatesComplete() throws { + let validated = try Self.complete().validated() + #expect(validated.containerID == "iCloud.com.test.App") + #expect(validated.keyID == TestFixtures.validKeyID) + #expect(validated.environment == .development) + } + + @Test("Missing or empty required fields report the field") + internal func reportsMissingFields() { + #expect(throws: CloudKitConfigurationError.missing(.containerID)) { + try Self.complete(containerID: nil).validated() + } + #expect(throws: CloudKitConfigurationError.missing(.containerID)) { + try Self.complete(containerID: "").validated() + } + #expect(throws: CloudKitConfigurationError.missing(.keyID)) { + try Self.complete(keyID: nil).validated() + } + #expect(throws: CloudKitConfigurationError.missing(.privateKey)) { + try Self.complete(privateKeyPath: nil).validated() + } + } + + @Test("An inline private key wins over a path") + internal func inlineKeyWinsOverPath() throws { + let validated = try Self.complete(privateKey: TestFixtures.validPEM).validated() + guard case .raw = validated.privateKey else { + Issue.record("expected inline PEM to win, got \(validated.privateKey)") + return + } + } + + @Test("Whitespace-only private-key values count as absent") + internal func whitespaceIsAbsent() { + #expect(throws: CloudKitConfigurationError.missing(.privateKey)) { + try Self.complete(privateKeyPath: " ", privateKey: " \n ").validated() + } + } + + @Test("Environment parses case-insensitively and defaults to development") + internal func parsesEnvironment() throws { + #expect(try Self.complete(environment: "production").validated().environment == .production) + #expect(try Self.complete(environment: "PRODUCTION").validated().environment == .production) + #expect(try Self.complete(environment: nil).validated().environment == .development) + } + + @Test("An unrecognized environment is reported verbatim") + internal func rejectsUnknownEnvironment() { + #expect(throws: CloudKitConfigurationError.unrecognizedEnvironment("staging")) { + try Self.complete(environment: "staging").validated() + } + } + + @Test("Presence is checked before format") + internal func presencePrecedesFormat() { + // Both a malformed key ID and no private key: the missing field is reported first. + #expect(throws: CloudKitConfigurationError.missing(.privateKey)) { + try Self.complete(keyID: "not-a-key", privateKeyPath: nil).validated() + } + } + + @Test("A malformed key ID surfaces the specific validation failure") + internal func surfacesKeyIDFailure() { + #expect( + throws: CloudKitConfigurationError.invalidKeyID(.incorrectLength(actual: 9)) + ) { + try Self.complete(keyID: "not-a-key").validated() + } + } + + @Test("A malformed inline PEM surfaces the specific validation failure") + internal func surfacesPEMFailure() { + #expect(throws: CloudKitConfigurationError.invalidPrivateKey(.missingHeader)) { + try Self.complete(privateKey: "just some text").validated() + } + } +} diff --git a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/ConfigurationSourcesTests.swift b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/ConfigurationSourcesTests.swift new file mode 100644 index 00000000..77c4f8be --- /dev/null +++ b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/ConfigurationSourcesTests.swift @@ -0,0 +1,65 @@ +// +// ConfigurationSourcesTests.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Configuration +import Testing + +@testable import MistKitConfiguration + +@Suite("ConfigurationSources") +internal struct ConfigurationSourcesTests { + private static let keys = CloudKitConfigurationKeys( + defaultContainerID: "iCloud.com.test.App" + ) + + @Test("Command-line arguments outrank environment variables") + internal func providerOrder() { + let reader = ConfigurationSources.makeConfigReader( + secretCommandLineFlags: Self.keys.secretCommandLineFlags, + arguments: ["app", "--cloudkit-environment", "production"], + environmentVariables: ["CLOUDKIT_ENVIRONMENT": "development"] + ) + #expect(reader.readCloudKitConfiguration(keys: Self.keys).environment == "production") + } + + /// Regression test for the redaction bug this package's derived flag list prevents: a + /// snake_case key base generated `--cloudkit-key_id`, which never matched the + /// hand-written `--cloudkit-key-id` in the secrets list, so a private key passed by flag + /// was logged in the clear. + @Test("A private key passed by flag is redacted from the provider's description") + internal func privateKeyIsRedacted() { + let secret = "-----BEGIN PRIVATE KEY-----SUPERSECRET-----END PRIVATE KEY-----" + let provider = CommandLineArgumentsProvider( + arguments: ["app", "--cloudkit-private-key", secret], + secretsSpecifier: .specific(Self.keys.secretCommandLineFlags) + ) + let described = String(describing: provider) + #expect(!described.contains("SUPERSECRET"), "the private key must not appear: \(described)") + } +} diff --git a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/KeyIDValidatorTests.swift b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/KeyIDValidatorTests.swift new file mode 100644 index 00000000..c0b44f85 --- /dev/null +++ b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/KeyIDValidatorTests.swift @@ -0,0 +1,77 @@ +// +// KeyIDValidatorTests.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@testable import MistKitConfiguration + +@Suite("KeyIDValidator") +internal struct KeyIDValidatorTests { + @Test("Accepts a 64-character hex key ID in either case") + internal func acceptsValidKeyID() throws { + try KeyIDValidator.validate(TestFixtures.validKeyID) + try KeyIDValidator.validate(TestFixtures.validKeyID.uppercased()) + } + + @Test("Rejects an empty or whitespace-only key ID") + internal func rejectsEmpty() { + #expect(throws: KeyIDValidationFailure.empty) { try KeyIDValidator.validate("") } + #expect(throws: KeyIDValidationFailure.empty) { try KeyIDValidator.validate(" ") } + } + + @Test("Rejects surrounding whitespace, the classic copy/paste newline") + internal func rejectsSurroundingWhitespace() { + #expect(throws: KeyIDValidationFailure.surroundingWhitespace) { + try KeyIDValidator.validate(" \(TestFixtures.validKeyID)\n") + } + } + + @Test("Reports the actual length when it is wrong") + internal func reportsIncorrectLength() { + #expect(throws: KeyIDValidationFailure.incorrectLength(actual: 3)) { + try KeyIDValidator.validate("abc") + } + #expect(throws: KeyIDValidationFailure.incorrectLength(actual: 65)) { + try KeyIDValidator.validate(TestFixtures.validKeyID + "a") + } + } + + @Test("Rejects non-hex characters") + internal func rejectsNonHex() { + let sameLengthNonHex = String(repeating: "z", count: KeyIDValidator.expectedLength) + #expect(throws: KeyIDValidationFailure.nonHexCharacters) { + try KeyIDValidator.validate(sameLengthNonHex) + } + } + + @Test("Expected length is CloudKit's 64-character fingerprint") + internal func expectedLength() { + #expect(KeyIDValidator.expectedLength == 64) + } +} diff --git a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/PEMValidatorTests.swift b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/PEMValidatorTests.swift new file mode 100644 index 00000000..c973a745 --- /dev/null +++ b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/PEMValidatorTests.swift @@ -0,0 +1,65 @@ +// +// PEMValidatorTests.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +import Testing + +@testable import MistKitConfiguration + +@Suite("PEMValidator") +internal struct PEMValidatorTests { + @Test("Accepts a well-formed PEM") + internal func acceptsValidPEM() throws { + try PEMValidator.validate(TestFixtures.validPEM) + } + + @Test("Rejects a missing header") + internal func rejectsMissingHeader() { + let pem = "\(Data(repeating: 0x41, count: 48).base64EncodedString())\n-----END PRIVATE KEY-----" + #expect(throws: PEMValidationFailure.missingHeader) { try PEMValidator.validate(pem) } + } + + @Test("Rejects a truncated key with no footer") + internal func rejectsMissingFooter() { + let pem = "-----BEGIN PRIVATE KEY-----\nQUFB" + #expect(throws: PEMValidationFailure.missingFooter) { try PEMValidator.validate(pem) } + } + + @Test("Rejects headers enclosing no key data") + internal func rejectsEmptyContent() { + let pem = "-----BEGIN PRIVATE KEY-----\n-----END PRIVATE KEY-----" + #expect(throws: PEMValidationFailure.emptyContent) { try PEMValidator.validate(pem) } + } + + @Test("Rejects content that is not base64") + internal func rejectsInvalidBase64() { + let pem = "-----BEGIN PRIVATE KEY-----\n!!!not base64!!!\n-----END PRIVATE KEY-----" + #expect(throws: PEMValidationFailure.invalidBase64) { try PEMValidator.validate(pem) } + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/CloudKitAuthMethod.swift b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/TestFixtures.swift similarity index 59% rename from Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/CloudKitAuthMethod.swift rename to Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/TestFixtures.swift index 6cf6299d..4edc5763 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/CloudKitAuthMethod.swift +++ b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/TestFixtures.swift @@ -1,6 +1,6 @@ // -// CloudKitAuthMethod.swift -// BushelCloud +// TestFixtures.swift +// MistKitConfiguration // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -29,25 +29,15 @@ internal import Foundation -/// Authentication method for CloudKit Server-to-Server -/// -/// Provides type-safe authentication credential handling with two patterns: -/// - `.pemString`: For CI/CD environments (GitHub Actions secrets) -/// - `.pemFile`: For local development (file on disk) -public enum CloudKitAuthMethod: Sendable { - /// PEM content provided as string (CI/CD pattern) - /// - /// **Usage**: Pass PEM content from environment variables or secrets - /// ```swift - /// let method = .pemString(pemContentFromEnvironment) - /// ``` - case pemString(String) +/// Shared credential fixtures. +internal enum TestFixtures { + /// A syntactically valid 64-character hex key ID. + internal static let validKeyID = String(repeating: "a1b2c3d4", count: 8) - /// PEM content loaded from file path (local development pattern) - /// - /// **Usage**: Pass path to .pem file on disk - /// ```swift - /// let method = .pemFile(path: "~/.cloudkit/bushel-private-key.pem") - /// ``` - case pemFile(path: String) + /// A structurally valid PEM private key with base64-decodable content. + internal static let validPEM = """ + -----BEGIN PRIVATE KEY----- + \(Data(repeating: 0x41, count: 48).base64EncodedString()) + -----END PRIVATE KEY----- + """ } diff --git a/Packages/MistKitConfiguration/codecov.yml b/Packages/MistKitConfiguration/codecov.yml new file mode 100644 index 00000000..d07d53ee --- /dev/null +++ b/Packages/MistKitConfiguration/codecov.yml @@ -0,0 +1,9 @@ +coverage: + status: + patch: + default: + target: auto + threshold: 2% + +ignore: + - "Tests" diff --git a/Packages/MistKitConfiguration/mise.toml b/Packages/MistKitConfiguration/mise.toml new file mode 100644 index 00000000..6df20abb --- /dev/null +++ b/Packages/MistKitConfiguration/mise.toml @@ -0,0 +1,7 @@ +[settings] +experimental = true + +[tools] +"spm:swiftlang/swift-format" = "602.0.0" +"aqua:realm/SwiftLint" = "0.62.2" +"spm:peripheryapp/periphery" = "3.7.4" diff --git a/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md b/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md index 1c5f2486..ae437f85 100644 --- a/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md +++ b/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md @@ -1,6 +1,6 @@ # Configuring MistKit -There is no single `MistKitConfiguration` type — configuration is what you pass to ``CloudKitService``: a container identifier, an ``Environment``, ``Credentials``, and (optionally) a custom transport. +MistKit itself has no configuration package dependency — you pass a container identifier, an ``Environment``, ``Credentials``, and (optionally) a custom transport to ``CloudKitService``. For reading CloudKit credentials from CLI / environment / `.env`, validating them, and building a service, use the separate [MistKitConfiguration](https://github.com/brightdigit/MistKitConfiguration) package (`CloudKitConfigurationKeys` → `validated()` → `makeCloudKitService()`). ## Overview @@ -14,6 +14,18 @@ let service = CloudKitService( ) ``` +Or, with MistKitConfiguration: + +```swift +let keys = CloudKitConfigurationKeys(defaultContainerID: "iCloud.com.example.MyApp") +let service = try ConfigurationSources.makeConfigReader( + secretCommandLineFlags: keys.secretCommandLineFlags +) +.readCloudKitConfiguration(keys: keys) +.validated() +.makeCloudKitService() +``` + Everything else — which ``Database`` to use, which signing method on the public database, which token to refresh — is decided per call. This article covers the construction-time inputs (container, environment, transport, logging). For credentials and per-call database selection, see . ## Container identifier From 9a7096d23900c7f71ae5b078a0b54c4ae6980cb0 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 31 Aug 2026 19:23:54 -0400 Subject: [PATCH 09/25] Add zone-aware MistDemo writes and shared-zone live validation (#454). Thread zone selection through web CRUD/assets, library uploadAssets, and a live sharee round-trip phase so shared-database writes carry ownerRecordName correctly. Co-authored-by: Cursor --- .../Phases/ShareCreateAndAcceptPhase.swift | 18 +- .../Phases/SharedZoneRoundtripPhase.swift | 309 ++++++++++++++++++ .../Tests/PrivateDatabaseTest.swift | 1 + .../Sources/MistDemoKit/Resources/index.html | 4 +- .../Sources/MistDemoKit/Resources/js/app.js | 43 ++- .../Server/CloudKitService+WebBackend.swift | 8 + .../MistDemoKit/Server/WebBackend.swift | 4 + .../Server/WebRequests+Assets.swift | 5 + .../MistDemoKit/Server/WebRequests.swift | 66 +++- .../MistDemoKit/Server/WebServer+Assets.swift | 1 + .../MistDemoKit/Server/WebServer+CRUD.swift | 3 + .../Server/MockBackend+Calls.swift | 4 + .../Server/MockBackend+RecordOperations.swift | 6 + .../MockBackend+ServiceOperations.swift | 2 + .../Server/WebServerTests+Index.swift | 7 +- .../Server/WebServerTests+WriteZone.swift | 219 +++++++++++++ .../CloudKitService+AssetOperations.swift | 4 + Sources/MistKit/Models/Zones/ZoneType.swift | 4 +- ...eTests.RecordWriteConvenience+ZoneID.swift | 132 ++++++++ ...tServiceTests.RecordWriteConvenience.swift | 30 -- ...dKitServiceTests.Upload+SuccessCases.swift | 35 ++ ...ZoneMetadataTests+ZoneInfoConversion.swift | 31 -- ...ataTests+ZoneInfoConversionEdgeCases.swift | 75 +++++ 23 files changed, 920 insertions(+), 91 deletions(-) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/SharedZoneRoundtripPhase.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+WriteZone.swift create mode 100644 Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience+ZoneID.swift create mode 100644 Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversionEdgeCases.swift diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ShareCreateAndAcceptPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ShareCreateAndAcceptPhase.swift index 84b49278..6c66156d 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ShareCreateAndAcceptPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ShareCreateAndAcceptPhase.swift @@ -57,8 +57,22 @@ internal struct ShareCreateAndAcceptPhase: IntegrationPhase { // Distinct Apple IDs are required: inviting yourself is not a useful // create→accept roundtrip. Compare users/caller record names up front. - let sharerIdentity = try await context.service.fetchCaller() - let shareeIdentity = try await shareeService.fetchCaller() + let sharerIdentity: UserInfo + do { + sharerIdentity = try await context.service.fetchCaller() + } catch { + throw IntegrationTestError.verificationFailed( + "sharer users/caller failed: \(error)" + ) + } + let shareeIdentity: UserInfo + do { + shareeIdentity = try await shareeService.fetchCaller() + } catch { + throw IntegrationTestError.verificationFailed( + "sharee users/caller failed: \(error)" + ) + } if sharerIdentity.userRecordName == shareeIdentity.userRecordName { throw IntegrationTestError.shareeSameAsSharer( userRecordName: sharerIdentity.userRecordName diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/SharedZoneRoundtripPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/SharedZoneRoundtripPhase.swift new file mode 100644 index 00000000..2149151c --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/SharedZoneRoundtripPhase.swift @@ -0,0 +1,309 @@ +// +// SharedZoneRoundtripPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKit + +/// Live shared-zone write round-trip: sharer creates a custom zone and share, +/// sharee accepts, then sharee update/lookup/zone-changes and zone-scoped asset +/// upload on the **shared root** (`database: .shared`, `zoneID.ownerName` = +/// sharer's user record name). CloudKit does not allow sharees to create +/// arbitrary new top-level records in the zone (ACCESS_DENIED). +/// +/// Self-cleaning. Requires the same sharee credentials as +/// ``ShareCreateAndAcceptPhase``. +internal struct SharedZoneRoundtripPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "Shared-zone CRUD and asset round-trip" + internal static let emoji = "🔗" + internal static let apiName = "shared zone write+uploadAssets" + + internal func run(input: NoState, context: PhaseContext) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + guard let shareeService = context.shareeService, + let shareeEmail = context.shareeEmail, + !shareeEmail.isEmpty + else { + throw IntegrationTestError.missingShareeCredentials + } + + let sharerIdentity = try await context.service.fetchCaller() + let shareeIdentity = try await shareeService.fetchCaller() + if sharerIdentity.userRecordName == shareeIdentity.userRecordName { + throw IntegrationTestError.shareeSameAsSharer( + userRecordName: sharerIdentity.userRecordName + ) + } + + let zoneName = "mistkit-shared-crud-\(UUID().uuidString.lowercased())" + let privateZoneID = ZoneID(zoneName: zoneName) + let rootRecordName = "mistkit-shared-root-\(UUID().uuidString.lowercased())" + let sharedDatabase: MistKit.Database = .shared + + _ = try await context.service.createZone( + zoneName: zoneName, + database: context.database + ) + if context.verbose { + print(" ✅ Created zone: \(zoneName)") + print(" Sharer: \(sharerIdentity.userRecordName)") + print(" Sharee: \(shareeIdentity.userRecordName)") + } + + var shareRecordName: String? + do { + let created = try await context.service.createShare( + rootRecordType: MistDemoConfig.recordType, + rootRecordName: rootRecordName, + rootFields: [ + "title": .string("Shared zone root"), + "index": .int64(0), + ], + zoneID: privateZoneID, + publicPermission: .none, + participants: [ + ShareParticipant( + userIdentity: UserIdentity( + lookupInfo: UserIdentityLookupInfo(emailAddress: shareeEmail) + ), + permission: .readWrite, + type: .user, + acceptanceStatus: .invited + ) + ], + database: context.database + ) + shareRecordName = created.shareRecordName + print("✅ Created share \(created.shortGUID)") + + let shortGUID = ShortGUIDDictionary( + value: created.shortGUID, + shouldFetchRootRecord: true + ) + _ = try await shareeService.resolveShares([shortGUID]) + let accepted = try await shareeService.acceptShares([shortGUID]) + guard let acceptInfo = accepted.first else { + throw IntegrationTestError.shareAcceptEmpty + } + if let status = acceptInfo.participantStatus, status == .invited { + throw IntegrationTestError.shareStillInvited + } + print( + "✅ Sharee accepted — status: " + + "\(acceptInfo.participantStatus?.rawValue ?? "-")" + ) + + guard let acceptZoneID = acceptInfo.zoneID else { + throw IntegrationTestError.verificationFailed( + "acceptShares omitted zoneID — cannot address shared DB" + ) + } + // Pin owner to the sharer's users/caller name — accept may omit it. + let sharedZoneID = ZoneID( + zoneName: acceptZoneID.zoneName, + ownerName: acceptZoneID.ownerName ?? sharerIdentity.userRecordName + ) + let sharedRootName = acceptInfo.rootRecordName ?? created.rootRecord.recordName + if context.verbose { + print(" Shared zone: \(sharedZoneID.zoneName) owner \(sharedZoneID.ownerName ?? "-")") + print(" Shared root: \(sharedRootName)") + } + + guard let sharedRoot = acceptInfo.rootRecord else { + throw IntegrationTestError.verificationFailed( + "acceptShares omitted rootRecord (shouldFetchRootRecord was true)" + ) + } + + // Sharees can modify records in the share, not create arbitrary new + // top-level records in the zone (CloudKit returns ACCESS_DENIED). + let updatedRoot = try await shareeService.updateRecord( + recordType: MistDemoConfig.recordType, + recordName: sharedRootName, + fields: [ + "title": .string("Shared CRUD updated"), + "index": .int64(42), + ], + recordChangeTag: sharedRoot.recordChangeTag, + zoneID: sharedZoneID, + database: sharedDatabase + ) + if context.verbose { + print(" ✅ Sharee updated shared root \(updatedRoot.recordName)") + } + + guard updatedRoot.fields["title"] == .string("Shared CRUD updated"), + updatedRoot.fields["index"] == .int64(42) + else { + throw IntegrationTestError.verificationFailed( + "shared update did not round-trip title/index on \(sharedRootName)" + ) + } + if context.verbose { + print(" ✅ Sharee read back updated fields from modify response") + } + + let changeResult = try await shareeService.fetchRecordZoneChanges( + zones: [ZoneChangesRequest(zoneID: sharedZoneID)], + database: sharedDatabase + ) + try ChangeTrackingVerification.requireNoZoneFailures( + changeResult.failures, + operation: "fetchRecordZoneChanges (shared)" + ) + let changeNames = ChangeTrackingVerification.recordNames(in: changeResult.changes) + if !changeNames.contains(sharedRootName) { + throw IntegrationTestError.verificationFailed( + "shared changes/zone missing root \(sharedRootName); found \(changeNames.sorted())" + ) + } + + // Asset must request an upload URL in the same zone as the update. + let png = PNGData.generate(withSizeInKB: min(context.assetSizeKB, 8)) + let receipt = try await shareeService.uploadAssets( + data: png, + recordType: MistDemoConfig.recordType, + fieldName: "image", + recordName: sharedRootName, + zoneID: sharedZoneID, + database: sharedDatabase + ) + let withAsset = try await shareeService.updateRecord( + recordType: MistDemoConfig.recordType, + recordName: sharedRootName, + fields: [ + "title": .string("Shared asset"), + "index": .int64(43), + "image": .asset(receipt.asset), + ], + recordChangeTag: updatedRoot.recordChangeTag, + zoneID: sharedZoneID, + database: sharedDatabase + ) + if context.verbose { + print(" ✅ Sharee uploaded+attached asset on shared root") + } + + _ = withAsset // used for verification via successful update + + print("✅ Shared-zone CRUD and asset round-trip succeeded") + + try await cleanup( + sharer: context.service, + database: context.database, + zoneID: privateZoneID, + rootRecordName: sharedRootName, + shareRecordName: created.shareRecordName, + extraRecordNames: [], + verbose: context.verbose, + skipCleanup: context.skipCleanup + ) + } catch { + try? await cleanup( + sharer: context.service, + database: context.database, + zoneID: privateZoneID, + rootRecordName: rootRecordName, + shareRecordName: shareRecordName, + extraRecordNames: [], + verbose: context.verbose, + skipCleanup: context.skipCleanup + ) + throw error + } + + return NoState() + } + + private func cleanup( + sharer: CloudKitService, + database: MistKit.Database, + zoneID: ZoneID, + rootRecordName: String, + shareRecordName: String?, + extraRecordNames: [String], + verbose: Bool, + skipCleanup: Bool + ) async throws { + if skipCleanup { + print(" ⏭️ Skipping shared-zone cleanup — inspect zone '\(zoneID.zoneName)'") + return + } + + var ops = [ + RecordOperation( + operationType: .forceDelete, + recordType: MistDemoConfig.recordType, + recordName: rootRecordName + ) + ] + for name in extraRecordNames { + ops.append( + RecordOperation( + operationType: .forceDelete, + recordType: MistDemoConfig.recordType, + recordName: name + ) + ) + } + if let shareRecordName { + ops.append( + RecordOperation( + operationType: .forceDelete, + recordType: ShareInfo.recordType, + recordName: shareRecordName + ) + ) + } + do { + _ = try await sharer.modifyRecords(ops, zoneID: zoneID, database: database) + if verbose { + print(" ✅ Deleted shared-zone records in \(zoneID.zoneName)") + } + } catch { + if verbose { + print(" ⚠️ Shared record cleanup failed: \(error)") + } + } + + do { + try await sharer.deleteZone(zoneName: zoneID.zoneName, database: database) + if verbose { + print(" ✅ Deleted zone: \(zoneID.zoneName)") + } + } catch { + if verbose { + print(" ⚠️ Zone cleanup failed: \(error)") + } + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift index 1b46ab2e..18602e7c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift @@ -67,6 +67,7 @@ internal struct PrivateDatabaseTest: PhasedIntegrationTest { TokenRoundtripPhase(), NotificationRoundtripPhase(), ShareCreateAndAcceptPhase(), + SharedZoneRoundtripPhase(), CleanupPhase(), ] } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html index 4bf37d53..d5087799 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html @@ -66,11 +66,11 @@

Notes MistKit
- +
- +
diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/app.js b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/app.js index c0900888..ae4a2bf3 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/app.js +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/app.js @@ -495,14 +495,28 @@ function ckJsFields(fields) { // ---- Notes CRUD operations ---- +/** Selected toolbar zone for query and writes. Drops stray owner without name. */ +function selectedZone() { + const zoneName = queryZoneInput.value.trim() || undefined; + const zoneOwner = zoneName + ? (queryZoneOwnerInput.value.trim() || undefined) + : undefined; + return { zoneName, zoneOwner }; +} + +/** CloudKit JS per-record zoneID shape (unlike query's top-level zoneID). */ +function ckJsRecordZoneID(zoneName, zoneOwner) { + if (!zoneName) return undefined; + return zoneOwner + ? { zoneName, ownerRecordName: zoneOwner } + : { zoneName }; +} + async function queryNotes() { if (queryInFlight) return; const recordType = recordTypeInput.value.trim(); const limit = parseInt(queryLimitInput.value, 10); - const zoneName = queryZoneInput.value.trim() || undefined; - // The server rejects a zoneOwner without a zoneName, so drop a stray owner - // rather than sending a body that is guaranteed to 400. - const zoneOwner = zoneName ? (queryZoneOwnerInput.value.trim() || undefined) : undefined; + const { zoneName, zoneOwner } = selectedZone(); queryInFlight = true; setQueryControlsDisabled(true); const dbLabel = currentDatabase === 'public' ? 'public' : 'private'; @@ -531,9 +545,7 @@ async function queryNotes() { } // CloudKit JS names the owner `ownerRecordName` inside zoneID. if (zoneName) { - query.zoneID = zoneOwner - ? { zoneName, ownerRecordName: zoneOwner } - : { zoneName }; + query.zoneID = ckJsRecordZoneID(zoneName, zoneOwner); } payload = await ckJsDatabase().performQuery(query, { resultsLimit: isFinite(limit) ? limit : undefined, @@ -598,6 +610,7 @@ async function saveNote() { // /api/assets/upload, then create/update with the returned descriptor. // CloudKit JS handles upload inline through saveRecords by passing a // Blob in the field value. + const { zoneName, zoneOwner } = selectedZone(); let uploadedRecordName = null; if (hasPendingImage && currentMode === 'mistkit') { setStatus(formStatusEl, 'Uploading image…', 'loading'); @@ -607,6 +620,8 @@ async function saveNote() { recordName: isUpdate ? note.recordName : undefined, database: currentDatabase, data: pendingImage.base64, + zoneName, + zoneOwner, }); uploadedRecordName = receipt.recordName; // FieldValue's Asset case decodes the bare Asset shape directly. @@ -620,6 +635,8 @@ async function saveNote() { recordName: note.recordName, fields, recordChangeTag: note.recordChangeTag, + zoneName, + zoneOwner, }); } else { payload = await postJSON('/api/records/create', { @@ -627,6 +644,8 @@ async function saveNote() { database: currentDatabase, recordName: uploadedRecordName || undefined, fields, + zoneName, + zoneOwner, }); } } else { @@ -641,6 +660,8 @@ async function saveNote() { record.recordName = note.recordName; record.recordChangeTag = note.recordChangeTag; } + const zoneID = ckJsRecordZoneID(zoneName, zoneOwner); + if (zoneID) record.zoneID = zoneID; payload = await ckJsDatabase().saveRecords([record]); if (payload && payload.hasErrors && payload.errors.length) { throw new Error(payload.errors[0].reason || 'CloudKit JS save failed'); @@ -669,15 +690,21 @@ async function deleteNote(note, statusEl = tableStatusEl) { clearStatus(statusEl); try { let payload; + const { zoneName, zoneOwner } = selectedZone(); if (currentMode === 'mistkit') { payload = await postJSON('/api/records/delete', { recordType: note.recordType, database: currentDatabase, recordName: note.recordName, recordChangeTag: note.recordChangeTag, + zoneName, + zoneOwner, }); } else { - payload = await ckJsDatabase().deleteRecords([{ recordName: note.recordName }]); + const deleteSpec = { recordName: note.recordName }; + const zoneID = ckJsRecordZoneID(zoneName, zoneOwner); + if (zoneID) deleteSpec.zoneID = zoneID; + payload = await ckJsDatabase().deleteRecords([deleteSpec]); if (payload && payload.hasErrors && payload.errors.length) { throw new Error(payload.errors[0].reason || 'CloudKit JS delete failed'); } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift index b294ea6e..acffe839 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift @@ -56,12 +56,14 @@ extension CloudKitService: WebBackend { recordType: String, recordName: String?, fields: [String: FieldValue], + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> RecordInfo { try await createRecord( recordType: recordType, recordName: recordName, fields: fields, + zoneID: zone?.zoneID, database: database ) } @@ -71,6 +73,7 @@ extension CloudKitService: WebBackend { recordName: String, fields: [String: FieldValue], recordChangeTag: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> RecordInfo { try await updateRecord( @@ -78,6 +81,7 @@ extension CloudKitService: WebBackend { recordName: recordName, fields: fields, recordChangeTag: recordChangeTag, + zoneID: zone?.zoneID, database: database ) } @@ -86,12 +90,14 @@ extension CloudKitService: WebBackend { recordType: String, recordName: String, recordChangeTag: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws { try await deleteRecord( recordType: recordType, recordName: recordName, recordChangeTag: recordChangeTag, + zoneID: zone?.zoneID, database: database ) } @@ -183,6 +189,7 @@ extension CloudKitService: WebBackend { recordType: String, fieldName: String, recordName: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> AssetUploadReceipt { try await uploadAssets( @@ -190,6 +197,7 @@ extension CloudKitService: WebBackend { recordType: recordType, fieldName: fieldName, recordName: recordName, + zoneID: zone?.zoneID, database: database ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift index efcb5cce..b946614f 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift @@ -51,6 +51,7 @@ internal protocol WebBackend: Sendable { recordType: String, recordName: String?, fields: [String: FieldValue], + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> RecordInfo @@ -59,6 +60,7 @@ internal protocol WebBackend: Sendable { recordName: String, fields: [String: FieldValue], recordChangeTag: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> RecordInfo @@ -66,6 +68,7 @@ internal protocol WebBackend: Sendable { recordType: String, recordName: String, recordChangeTag: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws @@ -153,6 +156,7 @@ internal protocol WebBackend: Sendable { recordType: String, fieldName: String, recordName: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> AssetUploadReceipt diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Assets.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Assets.swift index b66e739d..5723de4b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Assets.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Assets.swift @@ -49,6 +49,8 @@ extension WebRequests { case recordName case data case database + case zoneName + case zoneOwner } internal let recordType: String @@ -56,6 +58,8 @@ extension WebRequests { internal let recordName: String? internal let data: Data internal let database: MistKit.Database + /// Must match the zone on the following create/update that attaches the asset. + internal let zone: ZoneSelector? internal init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -68,6 +72,7 @@ extension WebRequests { self.database = try WebRequests.decodeDatabase( from: container, forKey: .database ) + self.zone = try WebRequests.decodeZoneSelector(from: container) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift index e8eee6a9..1f539938 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift @@ -71,6 +71,12 @@ internal enum WebRequests { } } + /// Coding keys shared by request bodies that accept a flat zone selector. + internal enum ZoneCodingKeys: String, CodingKey { + case zoneName + case zoneOwner + } + /// `POST /api/records/query` /// /// Wire format stays flat (`zoneName` / `zoneOwner` at the top level) so @@ -103,22 +109,7 @@ internal enum WebRequests { self.database = try WebRequests.decodeDatabase( from: container, forKey: .database ) - let zoneName = try container.decodeIfPresent( - String.self, forKey: .zoneName - ) - let zoneOwner = try container.decodeIfPresent( - String.self, forKey: .zoneOwner - ) - if zoneOwner != nil, zoneName == nil { - throw DecodingError.dataCorruptedError( - forKey: .zoneOwner, - in: container, - debugDescription: "zoneOwner requires zoneName" - ) - } - self.zone = zoneName.map { - ZoneSelector(zoneName: $0, zoneOwner: zoneOwner) - } + self.zone = try WebRequests.decodeZoneSelector(from: container) } } @@ -133,12 +124,16 @@ internal enum WebRequests { case recordName case fields case database + case zoneName + case zoneOwner } internal let recordType: String internal let recordName: String? internal let fields: [String: FieldValue] internal let database: MistKit.Database + /// `nil` = default zone; otherwise a custom or shared zone. + internal let zone: ZoneSelector? internal init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -152,6 +147,7 @@ internal enum WebRequests { self.database = try WebRequests.decodeDatabase( from: container, forKey: .database ) + self.zone = try WebRequests.decodeZoneSelector(from: container) } } @@ -167,6 +163,8 @@ internal enum WebRequests { case fields case recordChangeTag case database + case zoneName + case zoneOwner } internal let recordType: String @@ -174,6 +172,8 @@ internal enum WebRequests { internal let fields: [String: FieldValue] internal let recordChangeTag: String? internal let database: MistKit.Database + /// `nil` = default zone; otherwise a custom or shared zone. + internal let zone: ZoneSelector? internal init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -188,6 +188,7 @@ internal enum WebRequests { self.database = try WebRequests.decodeDatabase( from: container, forKey: .database ) + self.zone = try WebRequests.decodeZoneSelector(from: container) } } @@ -202,12 +203,16 @@ internal enum WebRequests { case recordName case recordChangeTag case database + case zoneName + case zoneOwner } internal let recordType: String internal let recordName: String internal let recordChangeTag: String? internal let database: MistKit.Database + /// `nil` = default zone; otherwise a custom or shared zone. + internal let zone: ZoneSelector? internal init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -219,6 +224,7 @@ internal enum WebRequests { self.database = try WebRequests.decodeDatabase( from: container, forKey: .database ) + self.zone = try WebRequests.decodeZoneSelector(from: container) } } @@ -248,4 +254,32 @@ internal enum WebRequests { } return database } + + /// Decode flat `zoneName` / `zoneOwner` into a ``ZoneSelector``. + /// Rejects owner-without-name so shared-zone wire mistakes surface as 400. + internal static func decodeZoneSelector( + from container: KeyedDecodingContainer + ) throws -> ZoneSelector? { + guard let zoneNameKey = Key(stringValue: ZoneCodingKeys.zoneName.rawValue), + let zoneOwnerKey = Key(stringValue: ZoneCodingKeys.zoneOwner.rawValue) + else { + return nil + } + let zoneName = try container.decodeIfPresent( + String.self, forKey: zoneNameKey + ) + let zoneOwner = try container.decodeIfPresent( + String.self, forKey: zoneOwnerKey + ) + if zoneOwner != nil, zoneName == nil { + throw DecodingError.dataCorruptedError( + forKey: zoneOwnerKey, + in: container, + debugDescription: "zoneOwner requires zoneName" + ) + } + return zoneName.map { + ZoneSelector(zoneName: $0, zoneOwner: zoneOwner) + } + } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Assets.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Assets.swift index a8637b2f..1cb01388 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Assets.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Assets.swift @@ -58,6 +58,7 @@ recordType: body.recordType, fieldName: body.fieldName, recordName: body.recordName, + zone: body.zone, database: body.database ) return try WebJSON.encoder().encode(receipt) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift index a9acbdeb..83422612 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift @@ -79,6 +79,7 @@ recordType: body.recordType, recordName: body.recordName, fields: body.fields, + zone: body.zone, database: body.database ) return try WebJSON.encoder().encode( @@ -107,6 +108,7 @@ recordName: body.recordName, fields: body.fields, recordChangeTag: body.recordChangeTag, + zone: body.zone, database: body.database ) return try WebJSON.encoder().encode( @@ -134,6 +136,7 @@ recordType: body.recordType, recordName: body.recordName, recordChangeTag: body.recordChangeTag, + zone: body.zone, database: body.database ) return try WebJSON.encoder().encode( diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift index 3da08866..16b61448 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift @@ -48,6 +48,7 @@ internal let recordType: String internal let recordName: String? internal let fields: [String: String] + internal let zone: WebRequests.ZoneSelector? internal let database: MistKit.Database } @@ -57,6 +58,7 @@ internal let recordName: String internal let fields: [String: String] internal let recordChangeTag: String? + internal let zone: WebRequests.ZoneSelector? internal let database: MistKit.Database } @@ -65,6 +67,7 @@ internal let recordType: String internal let recordName: String internal let recordChangeTag: String? + internal let zone: WebRequests.ZoneSelector? internal let database: MistKit.Database } @@ -160,6 +163,7 @@ internal let recordType: String internal let fieldName: String internal let recordName: String? + internal let zone: WebRequests.ZoneSelector? internal let database: MistKit.Database } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift index 02af4739..7ca5cbf2 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift @@ -57,12 +57,14 @@ recordType: String, recordName: String?, fields: [String: FieldValue], + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> RecordInfo { lastCreate = CreateCall( recordType: recordType, recordName: recordName, fields: Self.flatten(fields), + zone: zone, database: database ) try consumePendingError() @@ -76,6 +78,7 @@ recordName: String, fields: [String: FieldValue], recordChangeTag: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> RecordInfo { lastUpdate = UpdateCall( @@ -83,6 +86,7 @@ recordName: recordName, fields: Self.flatten(fields), recordChangeTag: recordChangeTag, + zone: zone, database: database ) try consumePendingError() @@ -95,12 +99,14 @@ recordType: String, recordName: String, recordChangeTag: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws { lastDelete = DeleteCall( recordType: recordType, recordName: recordName, recordChangeTag: recordChangeTag, + zone: zone, database: database ) try consumePendingError() diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+ServiceOperations.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+ServiceOperations.swift index 0f586ca0..0d1d3004 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+ServiceOperations.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+ServiceOperations.swift @@ -129,6 +129,7 @@ recordType: String, fieldName: String, recordName: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> AssetUploadReceipt { lastUploadAsset = UploadAssetCall( @@ -136,6 +137,7 @@ recordType: recordType, fieldName: fieldName, recordName: recordName, + zone: zone, database: database ) try consumePendingError() diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift index 3fa7471b..78f7c92c 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift @@ -113,14 +113,19 @@ let html = try await body(at: "/") #expect(html.contains(#"id="query-zone""#)) #expect(html.contains(#"id="query-zone-owner""#)) + #expect(html.contains("zone for query")) let appJs = try await body(at: "/js/app.js") - // Both backends read the same two inputs... + // Both backends read the same two inputs for query and writes... + #expect(appJs.contains("selectedZone()")) #expect(appJs.contains("queryZoneInput")) #expect(appJs.contains("queryZoneOwnerInput")) // ...the MistKit path forwards them as-is on the query body, // and the CloudKit JS path maps the owner to `ownerRecordName`. #expect(appJs.contains("ownerRecordName: zoneOwner")) + // Writes set per-record zoneID for CloudKit JS save/delete. + #expect(appJs.contains("record.zoneID = zoneID")) + #expect(appJs.contains("deleteSpec.zoneID = zoneID")) // The inputs join the controls disabled while a query is in flight. #expect(appJs.contains("'query-zone', 'query-zone-owner'")) } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+WriteZone.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+WriteZone.swift new file mode 100644 index 00000000..23802178 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+WriteZone.swift @@ -0,0 +1,219 @@ +// +// WebServerTests+WriteZone.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + internal import Foundation + internal import HTTPTypes + internal import Hummingbird + internal import HummingbirdTesting + internal import MistKit + internal import Testing + + @testable import MistDemoKit + + extension WebServerTests { + @Test("POST /api/records/create rejects zoneOwner without zoneName") + internal func createRejectsZoneOwnerWithoutZoneName() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/create", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer( + string: #"{"recordType":"Note","fields":{"title":"x"},"zoneOwner":"_abc"}"# + ) + ) { response in + #expect(response.status == .badRequest) + } + } + } + + @Test("POST /api/records/create forwards zoneName and zoneOwner to the backend") + internal func createForwardsZoneSelection() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = """ + {"recordType":"Note","fields":{"title":"Hi"},\ + "zoneName":"Articles","zoneOwner":"_abc123"} + """ + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/create", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastCreate + #expect(captured?.zone?.zoneName == "Articles") + #expect(captured?.zone?.zoneOwner == "_abc123") + } + + @Test("POST /api/records/update rejects zoneOwner without zoneName") + internal func updateRejectsZoneOwnerWithoutZoneName() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/update", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer( + string: #"{"recordType":"Note","recordName":"n1","fields":{},"zoneOwner":"_abc"}"# + ) + ) { response in + #expect(response.status == .badRequest) + } + } + } + + @Test("POST /api/records/update forwards zoneName and zoneOwner to the backend") + internal func updateForwardsZoneSelection() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = """ + {"recordType":"Note","recordName":"n1","fields":{"title":"Hi"},\ + "recordChangeTag":"t1","zoneName":"Articles","zoneOwner":"_abc123"} + """ + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/update", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastUpdate + #expect(captured?.zone?.zoneName == "Articles") + #expect(captured?.zone?.zoneOwner == "_abc123") + } + + @Test("POST /api/records/delete rejects zoneOwner without zoneName") + internal func deleteRejectsZoneOwnerWithoutZoneName() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/delete", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer( + string: #"{"recordType":"Note","recordName":"n1","zoneOwner":"_abc"}"# + ) + ) { response in + #expect(response.status == .badRequest) + } + } + } + + @Test("POST /api/records/delete forwards zoneName and zoneOwner to the backend") + internal func deleteForwardsZoneSelection() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = """ + {"recordType":"Note","recordName":"n1","recordChangeTag":"t1",\ + "zoneName":"Articles","zoneOwner":"_abc123"} + """ + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/delete", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastDelete + #expect(captured?.zone?.zoneName == "Articles") + #expect(captured?.zone?.zoneOwner == "_abc123") + } + + @Test("POST /api/assets/upload rejects zoneOwner without zoneName") + internal func uploadRejectsZoneOwnerWithoutZoneName() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + // Minimal base64 payload ("AQ==" = one byte). + let jsonBody = """ + {"recordType":"Note","fieldName":"image","data":"AQ==","zoneOwner":"_abc"} + """ + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/assets/upload", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .badRequest) + } + } + } + + @Test("POST /api/assets/upload forwards zoneName and zoneOwner to the backend") + internal func uploadForwardsZoneSelection() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = """ + {"recordType":"Note","fieldName":"image","data":"AQ==",\ + "zoneName":"Articles","zoneOwner":"_abc123"} + """ + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/assets/upload", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastUploadAsset + #expect(captured?.zone?.zoneName == "Articles") + #expect(captured?.zone?.zoneOwner == "_abc123") + } + } +#endif diff --git a/Sources/MistKit/CloudKitService/CloudKitService+AssetOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+AssetOperations.swift index 99111fee..34bb054b 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+AssetOperations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+AssetOperations.swift @@ -54,6 +54,8 @@ extension CloudKitService { /// - recordType: The type of record that will use this asset /// - fieldName: The name of the asset field /// - recordName: Optional unique record name + /// - zoneID: Optional zone ID (defaults to default zone). Must match the + /// zone used on the subsequent create/update that attaches the asset. /// - uploader: Optional custom upload handler /// - database: The CloudKit database scope to upload to (`.public`, `.private`, `.shared`) /// - Returns: AssetUploadReceipt containing the upload result @@ -76,6 +78,7 @@ extension CloudKitService { recordType: String, fieldName: String, recordName: String? = nil, + zoneID: ZoneID? = nil, using uploader: AssetUploader? = nil, database: Database ) async throws(CloudKitError) -> AssetUploadReceipt { @@ -84,6 +87,7 @@ extension CloudKitService { recordType: recordType, fieldName: fieldName, recordName: recordName, + zoneID: zoneID, database: database ) diff --git a/Sources/MistKit/Models/Zones/ZoneType.swift b/Sources/MistKit/Models/Zones/ZoneType.swift index 9ca4892b..497543ff 100644 --- a/Sources/MistKit/Models/Zones/ZoneType.swift +++ b/Sources/MistKit/Models/Zones/ZoneType.swift @@ -46,7 +46,9 @@ extension ZoneType { /// `nil` stays `nil`. Any other unrecognized string throws /// ``ConversionError/unrecognizedZoneType(_:)``. internal static func fromWire(_ wire: String?) throws(ConversionError) -> ZoneType? { - guard let wire else { return nil } + guard let wire else { + return nil + } guard let value = ZoneType(rawValue: wire) else { throw ConversionError.unrecognizedZoneType(wire) } diff --git a/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience+ZoneID.swift b/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience+ZoneID.swift new file mode 100644 index 00000000..8b2db1b0 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience+ZoneID.swift @@ -0,0 +1,132 @@ +// +// CloudKitServiceTests.RecordWriteConvenience+ZoneID.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import Testing + +@testable import MistKit + +extension CloudKitServiceTests.RecordWriteConvenience { + /// Pins zoneID forwarding on the single-record write conveniences (#454). + @Suite("Record Write Convenience ZoneID") + internal struct ZoneIDForwarding { + private typealias Helper = CloudKitServiceTests.Rereference + + @Test("createRecord forwards zoneID into the modifyRecords request body") + internal func createForwardsZoneID() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let (service, provider) = try Helper.makeServiceWithProvider(responsesByOperation: [ + "modifyRecords": try Helper.recordsResponse([ + Helper.noteRecord(recordName: "note-1", changeTag: "tag-1") + ]) + ]) + + _ = try await service.createRecord( + recordType: "Note", + recordName: "note-1", + fields: ["title": .string("Hello")], + zoneID: ZoneID(zoneName: "Articles", ownerName: "_abc123"), + database: Helper.publicDatabase + ) + + let bodies = await provider.bodies(for: "modifyRecords").compactMap { $0 } + let data = try #require(bodies.first) + let body = try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["zoneName"] as? String == "Articles") + #expect(zoneID["ownerRecordName"] as? String == "_abc123") + } + + @Test("updateRecord forwards zoneID into the modifyRecords request body") + internal func updateForwardsZoneID() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let (service, provider) = try Helper.makeServiceWithProvider(responsesByOperation: [ + "modifyRecords": try Helper.recordsResponse([ + Helper.noteRecord(recordName: "note-1", changeTag: "tag-2") + ]) + ]) + + _ = try await service.updateRecord( + recordType: "Note", + recordName: "note-1", + fields: ["title": .string("Renamed")], + recordChangeTag: "tag-1", + zoneID: ZoneID(zoneName: "Articles", ownerName: "_abc123"), + database: Helper.publicDatabase + ) + + let bodies = await provider.bodies(for: "modifyRecords").compactMap { $0 } + let data = try #require(bodies.first) + let body = try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["zoneName"] as? String == "Articles") + #expect(zoneID["ownerRecordName"] as? String == "_abc123") + } + + @Test("deleteRecord forwards zoneID into the modifyRecords request body") + internal func deleteForwardsZoneID() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let (service, provider) = try Helper.makeServiceWithProvider(responsesByOperation: [ + "modifyRecords": try Helper.recordsResponse([ + Helper.noteRecord(recordName: "note-1", changeTag: "tag-2") + ]) + ]) + + try await service.deleteRecord( + recordType: "Note", + recordName: "note-1", + recordChangeTag: "tag-1", + zoneID: ZoneID(zoneName: "Articles", ownerName: "_abc123"), + database: Helper.publicDatabase + ) + + let bodies = await provider.bodies(for: "modifyRecords").compactMap { $0 } + let data = try #require(bodies.first) + let body = try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["zoneName"] as? String == "Articles") + #expect(zoneID["ownerRecordName"] as? String == "_abc123") + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience.swift b/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience.swift index 4638aa8d..e283bfa0 100644 --- a/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience.swift +++ b/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience.swift @@ -146,35 +146,5 @@ extension CloudKitServiceTests { #expect(await provider.callCount(for: "modifyRecords") == 1) } - - @Test("createRecord forwards zoneID into the modifyRecords request body") - internal func createForwardsZoneID() async throws { - guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let (service, provider) = try Helper.makeServiceWithProvider(responsesByOperation: [ - "modifyRecords": try Helper.recordsResponse([ - Helper.noteRecord(recordName: "note-1", changeTag: "tag-1") - ]) - ]) - - _ = try await service.createRecord( - recordType: "Note", - recordName: "note-1", - fields: ["title": .string("Hello")], - zoneID: ZoneID(zoneName: "Articles", ownerName: "_abc123"), - database: Helper.publicDatabase - ) - - let bodies = await provider.bodies(for: "modifyRecords").compactMap { $0 } - let data = try #require(bodies.first) - let body = try #require( - try JSONSerialization.jsonObject(with: data) as? [String: Any] - ) - let zoneID = try #require(body["zoneID"] as? [String: Any]) - #expect(zoneID["zoneName"] as? String == "Articles") - #expect(zoneID["ownerRecordName"] as? String == "_abc123") - } } } diff --git a/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+SuccessCases.swift b/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+SuccessCases.swift index e7c30692..933d0b51 100644 --- a/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+SuccessCases.swift +++ b/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+SuccessCases.swift @@ -161,5 +161,40 @@ extension CloudKitServiceTests.Upload { let count = await tracker.callCount #expect(count == 1, "Custom uploader should have been called exactly once") } + + @Test("uploadAssets() forwards zoneID into the uploadAssets request body") + internal func uploadAssetsForwardsZoneID() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let responseProvider = ResponseProvider( + defaultResponse: .successfulUploadResponse(tokenCount: 1) + ) + let transport = MockTransport(responseProvider: responseProvider) + let service = try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: TestConstants.apiToken)), + transport: transport + ) + + _ = try await service.uploadAssets( + data: Data(count: 256), + recordType: "Note", + fieldName: "image", + zoneID: ZoneID(zoneName: "Articles", ownerName: "_abc123"), + using: CloudKitServiceTests.Upload.makeMockAssetUploader(), + database: .public(.prefers(.serverToServer)) + ) + + let bodies = await responseProvider.bodies(for: "uploadAssets").compactMap { $0 } + let data = try #require(bodies.first) + let body = try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["zoneName"] as? String == "Articles") + #expect(zoneID["ownerRecordName"] as? String == "_abc123") + } } } diff --git a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift index 08c67a85..c33c121e 100644 --- a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift +++ b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift @@ -204,36 +204,5 @@ extension ZoneMetadataTests { #expect(info.atomic == nil) #expect(info.zoneName == "Articles") } - - @Test("atomic decodes false without collapsing into nil") - internal func atomicFalseIsPreserved() throws { - let zone = try Self.decodeZone( - """ - { "zoneID": { "zoneName": "Articles" }, "atomic": false } - """ - ) - - let info = try ZoneInfo(from: zone) - - #expect(try #require(info.atomic) == false) - } - - @Test("ZoneInfo still throws when the zone payload has no zoneName") - internal func missingZoneNameThrows() throws { - let zone = try Self.decodeZone( - """ - { "zoneID": { "ownerRecordName": "_defaultOwner" }, "atomic": true } - """ - ) - - ConversionFailureReporter.$assertionHandler.withValue( - { _, _, _ in }, - operation: { - #expect(throws: ConversionError.self) { - _ = try ZoneInfo(from: zone) - } - } - ) - } } } diff --git a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversionEdgeCases.swift b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversionEdgeCases.swift new file mode 100644 index 00000000..1a24e866 --- /dev/null +++ b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversionEdgeCases.swift @@ -0,0 +1,75 @@ +// +// ZoneMetadataTests+ZoneInfoConversionEdgeCases.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import Testing + +@testable import MistKit +@testable import MistKitOpenAPI + +extension ZoneMetadataTests { + /// Edge-case decoding for ``ZoneInfo`` conversion. + @Suite("ZoneInfo Conversion Edge Cases") + internal struct ZoneInfoConversionEdgeCases { + private static func decodeZone(_ json: String) throws -> Components.Schemas.Zone { + try JSONDecoder().decode(Components.Schemas.Zone.self, from: Data(json.utf8)) + } + + @Test("atomic decodes false without collapsing into nil") + internal func atomicFalseIsPreserved() throws { + let zone = try Self.decodeZone( + """ + { "zoneID": { "zoneName": "Articles" }, "atomic": false } + """ + ) + + let info = try ZoneInfo(from: zone) + + #expect(try #require(info.atomic) == false) + } + + @Test("ZoneInfo still throws when the zone payload has no zoneName") + internal func missingZoneNameThrows() throws { + let zone = try Self.decodeZone( + """ + { "zoneID": { "ownerRecordName": "_defaultOwner" }, "atomic": true } + """ + ) + + ConversionFailureReporter.$assertionHandler.withValue( + { _, _, _ in }, + operation: { + #expect(throws: ConversionError.self) { + _ = try ZoneInfo(from: zone) + } + } + ) + } + } +} From 00d74ba817c452abc1d232b34032140ec515b23a Mon Sep 17 00:00:00 2001 From: codefactor-io Date: Tue, 1 Sep 2026 00:04:01 +0000 Subject: [PATCH 10/25] [CodeFactor] Apply fixes --- Package.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Package.swift b/Package.swift index 0b266ff2..4cbcf39e 100644 --- a/Package.swift +++ b/Package.swift @@ -162,4 +162,3 @@ extension Platform { } // swiftlint:enable explicit_acl explicit_top_level_acl - From 269b7e2b11925ada7fe931b115a90df8af2b5883 Mon Sep 17 00:00:00 2001 From: leogdion Date: Wed, 2 Sep 2026 11:12:04 -0400 Subject: [PATCH 11/25] Add a repeatable release runbook (#461) --- .agents/skills/release/SKILL.md | 171 +++++ .agents/skills/release/agents/openai.yaml | 5 + .claude/agent-notes.md | 5 + .claude/memory/MEMORY.md | 3 + ...feedback_use_git_trees_not_git_worktree.md | 31 + .claude/memory/project_release_process.md | 40 ++ .claude/skills/release | 1 + .github/workflows/release.yml | 42 ++ AGENTS.md | 39 ++ Makefile | 37 +- Scripts/release.sh | 632 ++++++++++++++++++ 11 files changed, 1001 insertions(+), 5 deletions(-) create mode 100644 .agents/skills/release/SKILL.md create mode 100644 .agents/skills/release/agents/openai.yaml create mode 100644 .claude/memory/feedback_use_git_trees_not_git_worktree.md create mode 100644 .claude/memory/project_release_process.md create mode 120000 .claude/skills/release create mode 100644 .github/workflows/release.yml create mode 100755 Scripts/release.sh diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md new file mode 100644 index 00000000..22ad80d1 --- /dev/null +++ b/.agents/skills/release/SKILL.md @@ -0,0 +1,171 @@ +--- +name: release +description: Run the MistKit release runbook — verify the vX.Y.Z release branch is green, assemble the ReleaseNotes.md section and README roadmap entry, merge to main, tag as X.Y.Z, publish the GitHub pre-release, and roll the Examples' MISTKIT_BRANCH pins. Use when asked to cut, prepare, or ship a release. +argument-hint: "Which release? e.g. v1.0.0-beta.5" +disable-model-invocation: true +--- + +# MistKit Release Runbook + +## The naming rule + +```text +release branch v1.0.0-beta.5 ← with v +release tag 1.0.0-beta.5 ← without v +``` + +This asymmetry is deliberate. `setup-mistkit` resolves `MISTKIT_BRANCH` with +`git ls-remote`, which matches **tags as well as branches**, so pinning the wrong +kind of ref succeeds silently and greens example CI without ever compiling the +code under release. The pin requirement therefore *inverts* at release time: + +| Phase | `MISTKIT_BRANCH` must be | +|---|---| +| Before the release merge | the **branch** `v1.0.0-beta.5` | +| After publishing | the **tag** `1.0.0-beta.5` | + +`Scripts/release.sh pins --expect-branch` / `--expect-tag` asserts the ref *kind*, +which is the check `setup-mistkit` itself cannot make. + +## Stop conditions + +Abort and ask the user if: + +- `preflight` fails for any reason other than notes-not-yet-written. +- The milestone still has open issues (it warns; confirm the release is intended). +- The named branch is not the branch you are on. +- `main` has commits the release branch lacks. +- Any `git subrepo push` would be needed but the Example subrepos have local changes. + +## Phases + +Run everything from the release branch's own worktree +(`git trees add v1.0.0-beta.5 main` if it does not exist — never raw `git worktree`). + +### 1. Preflight + +```bash +./Scripts/release.sh preflight v1.0.0-beta.5 +``` + +Checks branch shape, clean tree, tag availability, gating CI (`MistKit`, +`MistDemo Integration`, `Examples` — *not* `Claude Code Review`, which is advisory), +pins, open milestone issues, then local `swift build`/`swift test`/`Scripts/lint.sh`. +Use `--skip-local` only when re-running after a green local pass. + +### 2. Fix the pre-release pins + +Must happen **before** the merge, so example CI actually tests this branch. + +```bash +./Scripts/release.sh pins --roll-to v1.0.0-beta.5 +git add Examples/*/.github/workflows/*.yml +git commit -m "ci(examples): pin MISTKIT_BRANCH to v1.0.0-beta.5" +git subrepo push Examples/BushelCloud +git subrepo push Examples/CelestraCloud +git push +``` + +When `Packages/MistKitConfiguration` lands (#407), add its subrepo push here too. + +Then confirm from a build log that `Setup MistKit` printed +`Pinning MistKit to v1.0.0-beta.5 @ ` with a sha matching the branch tip, +and re-run `./Scripts/release.sh pins --expect-branch v1.0.0-beta.5`. + +### 3. Assemble the notes + +Release notes are a **flat bullet list** — no `###` category subsections. + +```bash +./Scripts/release.sh notes-draft v1.0.0-beta.5 +``` + +This writes the `## 1.0.0-beta.5` section to the top of `ReleaseNotes.md` and +prints README roadmap candidates. Then, by hand: + +- Edit bullet wording and add issue refs: `* (#41, #42) by @user in `. +- Add a `### v1.0.0-beta.5` section to the README Roadmap, above `### Backlog / Post-beta`. +- Bump the README `from:` snippet to the **currently released** tag (the new one does not exist yet). + +```bash +./Scripts/release.sh check v1.0.0-beta.5 # must pass before proceeding +swift build && swift test && ./Scripts/lint.sh +git commit -am "docs: 1.0.0-beta.5 release notes and roadmap" && git push +``` + +### 4. Archive before merging + +Load-bearing under squash: once squashed commits are no longer reachable from +`main`, the archive tag is the only preservation mechanism. Do this even if you +plan a merge commit. + +```bash +git tag "backup/v1.0.0-beta.5-pre-merge" v1.0.0-beta.5 +git push origin "backup/v1.0.0-beta.5-pre-merge" +``` + +### 5. Release PR — ask before merging + +```bash +gh pr create --base main --head v1.0.0-beta.5 --title "v1.0.0 beta.5" \ + --body-file <(./Scripts/release.sh publish 1.0.0-beta.5 --dry-run 2>/dev/null) +``` + +**Ask the user which merge shape to use.** Release branches are the documented +merge-commit case (`gh pr merge --merge`) — unlike feature PRs, which are always +rebase (1 commit) or squash (2+). Squash keeps `main` linear but makes the +archive tag from phase 4 the *only* preservation mechanism. + +### 6. Tag — only after notes have landed + +Locate the existing `main` worktree (`git trees list`) — do not `git checkout main` +from the release worktree. From that `main` worktree: + +```bash +git pull --ff-only +./Scripts/release.sh verify-tag 1.0.0-beta.5 --at HEAD # must pass first +git tag 1.0.0-beta.5 # lightweight, no -a, no v +git push origin 1.0.0-beta.5 +``` + +`verify-tag --at HEAD` reads `ReleaseNotes.md` in the tree about to be tagged. +This is the guardrail for beta.3 and beta.4, both of which were tagged without +their own notes section. The `Release Check` workflow re-asserts it after the push. + +### 7. Publish + +```bash +./Scripts/release.sh publish 1.0.0-beta.5 +``` + +Builds the body from `ReleaseNotes.md`, swapping `## 1.0.0-beta.5` for +`## What's Changed`. Add the intro blurb by hand afterwards if wanted. + +### 8. Roll the pins to the tag + +```bash +./Scripts/release.sh pins --roll-to 1.0.0-beta.5 +./Scripts/release.sh pins --expect-tag 1.0.0-beta.5 +git commit -am "ci(examples): pin MISTKIT_BRANCH to released 1.0.0-beta.5" +git subrepo push Examples/BushelCloud +git subrepo push Examples/CelestraCloud +git push +``` + +When `Packages/MistKitConfiguration` lands (#407), add its subrepo push here too. + +### 9. Open the next beta + +Keep the released `v1.0.0-beta.5` branch and its worktree — do not delete either. +Create the next beta branch from `main`: + +```bash +git trees add v1.0.0-beta.6 main +``` + +## This runbook will not + +- Push the Example subrepos for you — it prints the `git subrepo push` commands. +- Delete a release branch after publication. +- Tag before `check` / `verify-tag` passes. +- Choose the merge shape without asking. diff --git a/.agents/skills/release/agents/openai.yaml b/.agents/skills/release/agents/openai.yaml new file mode 100644 index 00000000..bbd4fd5d --- /dev/null +++ b/.agents/skills/release/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Release" + short_description: "Run the MistKit release runbook" +policy: + allow_implicit_invocation: false diff --git a/.claude/agent-notes.md b/.claude/agent-notes.md index 965f23db..7473de45 100644 --- a/.claude/agent-notes.md +++ b/.claude/agent-notes.md @@ -19,6 +19,11 @@ Standing always/never directives and corrections from the human. Agents must rea - Repo-wide CI version bumps (Xcode/simulator/toolchain) DO extend into the `Examples/` subrepos — update BushelCloud and CelestraCloud in the same pass rather than deferring them to their own repos. - Follow sibling brightdigit repos (e.g. ConfigKeyKit) for current CI workflow shape before inventing a new one. - Feature-branch PRs: NEVER merge-commit. ALWAYS rebase if the PR has exactly 1 commit (`gh pr merge --rebase`); ALWAYS squash if it has 2+ commits (`gh pr merge --squash`). Count commits before merging. +- Release PRs (a `v*` release branch into `main`) are the one merge-commit case — the "never merge-commit" rule above covers FEATURE PRs only; still confirm the shape with the human before merging. +- NEVER tag a release before its `ReleaseNotes.md` section exists in the tree being tagged — run `./Scripts/release.sh verify-tag --at HEAD` first (beta.3 and beta.4 both shipped tags with no notes). +- Release notes are a FLAT bullet list for **new** entries — do not add `###` category subsections; preserve the existing beta.1–beta.4 sections. +- NEVER delete a released beta branch — after publication, create the next beta branch from `main` with `git trees add`. +- ALWAYS manage worktrees with `git trees` (`add`/`rm`/`list`/`clean`), never raw `git worktree`. - MistKitConfiguration#1 is merged and `1.0.0-beta.1` is tagged (ConfigKeyKit `1.0.0-beta.3`). The published `main` manifest must stay tag-only — `dependency-policy.yml` enforces this on non-draft PRs to `main`. Branch pins live on the `mistkit-beta.5` integration branch, which must NEVER be merged to `main` or tagged. - MistDemo's own `resolveBool` may now be replaced by ConfigKeyKit `read(_:)`: the boolean fix (ConfigKeyKit#8) shipped in `1.0.0-beta.3` (2026-08-31). Verify behaviour before swapping; this supersedes the earlier "keep resolveBool until tagged" directive. - `git subrepo push Packages/MistKitConfiguration` refuses ("new changes upstream") and would clobber the standalone `url:` MistKit line with the monorepo `path:` overlay; push subrepo-only changes by applying them to a clone of the standalone repo instead, then record the pushed SHA in `.gitrepo`'s `commit =`. diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 1184cbee..9f4c91b2 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -40,6 +40,9 @@ Project-scoped agent memory for MistKit. This directory **replaces** any native - [Never git stash in this multi-worktree repo](feedback_never_git_stash_multiworktree.md) — The stash stack is shared across worktrees; a pop in one can bury a sibling branch's WIP. Commit instead. - [cloudkit.share wire casing](project_cloudkit_share_record_type_casing.md) — Live API wants `cloudkit.share` (lowercase k); archived docs' `cloudKit.share` yields "Cannot share - no such record exists to share" - [MISTKIT_BRANCH pin resolves tags too](project_mistkit_branch_pin_resolves_tags.md) — `git ls-remote` matches tags; a tag value silently pins the old release and greens example CI without testing the branch +- [MistKit release process](project_release_process.md) — Branch `vX` vs tag `X`; pins invert at release; retain release branches; notes are a flat bullet list for new entries +- [Use git trees, not git worktree](feedback_use_git_trees_not_git_worktree.md) — Manage worktrees with `git trees add/rm/list/clean`; `add` pushes to origin unless `--no-push` +- [Examples workflow tracks subrepo tools-version](feedback_examples_workflow_tracks_tools_version.md) — `examples.yml` must use a Swift container that can parse each example's Package.swift (6.4 nightly for Bushel/Celestra; 6.3 for MistDemo) - [Examples workflow tracks subrepo tools-version](feedback_examples_workflow_tracks_tools_version.md) — `examples.yml` / MistDemo.yml must use a Swift 6.4 toolchain wherever MistKitConfiguration is a dependency (6.4 nightly / Xcode 27) - [ConfigKey bases must be dash-case](reference_configkey_cli_flag_dash_case.md) — snake_case silently breaks CLI flags and secret redaction; ENV works either way, so it hides the bug - [ConfigKeyKit ConfigValueReading](reference_configkeykit_configvaluereading.md) — ConfigKeyKit#1 shipped in-core in 1.0.0-beta.2; there is no ConfigKeyKitConfiguration package diff --git a/.claude/memory/feedback_use_git_trees_not_git_worktree.md b/.claude/memory/feedback_use_git_trees_not_git_worktree.md new file mode 100644 index 00000000..a5cc36ff --- /dev/null +++ b/.claude/memory/feedback_use_git_trees_not_git_worktree.md @@ -0,0 +1,31 @@ +--- +name: feedback_use_git_trees_not_git_worktree +description: Create and remove worktrees in this repo with `git trees add/rm`, never raw `git worktree` +metadata: + type: feedback +--- + +Always manage worktrees in this repo with **`git trees`** (brightdigit's tool, installed +at `~/.local/bin/git-trees`) — never raw `git worktree add` / `git worktree remove`. + +**Why:** On 2026-09-01, while planning the release runbook, I proposed +`git worktree add ../release-tooling -b release-tooling main`. Leo corrected me: "use git +trees instead of git worktree." The project is a bare-repo + sibling-worktree layout +(`MistKit.git/` alongside `main/`, `v1.0.0-beta.5/`, …) that `git trees` created and +maintains; raw `git worktree` skips the upstream/push wiring and the layout metadata. + +**How to apply** (`git trees --help` for the full list): + +| Task | Command | +|---|---| +| New branch + worktree off a base | `git trees add [base]` | +| Without pushing to origin | `git trees add [base] --no-push` | +| Print path (to `cd`) | `git trees add --print-path` | +| List worktrees | `git trees list [--json]` | +| Remove worktree + branch | `git trees rm --apply` | +| Sweep merged/gone branches | `git trees clean --merged \| --gone [--apply]` | +| Fetch / update worktrees | `git trees sync [worktree] [--pull]` | + +`add` creates the branch on origin via `git push -u origin HEAD` unless `--no-push` (or +`TREES_NO_PUSH`) is set, and prints the worktree path but cannot `cd` your shell. +Related: [[feedback_never_git_stash_multiworktree]], [[project_beta4_worktree_layout]]. diff --git a/.claude/memory/project_release_process.md b/.claude/memory/project_release_process.md new file mode 100644 index 00000000..01f5106e --- /dev/null +++ b/.claude/memory/project_release_process.md @@ -0,0 +1,40 @@ +--- +name: project_release_process +description: MistKit release mechanics — v-prefixed branch vs bare tag, pins invert at release, notes must exist in the tagged tree, notes are a flat bullet list +metadata: + type: project +--- + +The MistKit release process is encoded in `.claude/skills/release/SKILL.md` (invoke as +`/release`) with mechanical checks in `Scripts/release.sh`. The facts that are **not** +derivable from the code: + +**Naming is asymmetric on purpose.** Release branch `v1.0.0-beta.5`; release tag +`1.0.0-beta.5` (lightweight, no `v`). All 26 release tags follow this. + +**Pin semantics invert at release.** `setup-mistkit` resolves `MISTKIT_BRANCH` with +`git ls-remote`, which matches tags *and* branches, so the wrong kind of ref pins +silently and greens example CI without compiling the code under release. Before the +release merge the pin must be the **branch**; after publishing, the **tag**. Only +`./Scripts/release.sh pins --expect-branch|--expect-tag` asserts the ref *kind* — see +[[project_mistkit_branch_pin_resolves_tags]]. + +**A tag must contain its own notes.** Both `1.0.0-beta.3` and `1.0.0-beta.4` were tagged +with no `ReleaseNotes.md` section of their own (beta.3's tagged tree heads at +`## 1.0.0-beta.2`; beta.4's notes landed a day later in `687b532`). `verify-tag` reads +the *tagged tree* to catch this, and `.github/workflows/release.yml` re-asserts it after +any tag push. + +**Release notes are a flat bullet list** — `* (#refs) by @user in ` — with +no `###` category subsections. Sections for beta.1–beta.4 predate this decision (made +2026-09-01) and were deliberately left un-flattened. + +**The release PR is the merge-commit case**, unlike feature PRs — but the shape is +confirmed with the human each time rather than assumed. See +[[feedback_feature_pr_merge_squash_or_rebase]] and +[[feedback_check_merge_strategy_before_release_deletions]] (the archive tag is +load-bearing under squash). + +**Release branches are retained after publication.** Do not delete `v*` release +branches or remove their worktrees; open the next beta from `main` with +`git trees add vX.Y.Z main`. diff --git a/.claude/skills/release b/.claude/skills/release new file mode 120000 index 00000000..14f8a38a --- /dev/null +++ b/.claude/skills/release @@ -0,0 +1 @@ +../../.agents/skills/release \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..31d7dd04 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: Release Check + +# Verifies a release tag; deliberately does NOT create the GitHub release. +# Publishing stays a human step (Scripts/release.sh publish) so that +# `git push --tags` is not the point of no return. +on: + push: + tags: + - '[0-9]+.[0-9]+.[0-9]+*' + - 'v[0-9]*' + +concurrency: + group: release-check-${{ github.ref }} + cancel-in-progress: true + +jobs: + # MistKit branches are v-prefixed; MistKit tags are not. A v-prefixed tag is + # always a mistake, and catching it here makes the convention self-enforcing. + reject-v-prefix: + name: Reject v-prefixed tag + if: startsWith(github.ref_name, 'v') + runs-on: ubuntu-latest + steps: + - name: Fail + run: | + echo "::error::MistKit tags do not use a 'v' prefix." + echo "Branches are v-prefixed (v${GITHUB_REF_NAME#v}); tags are not (${GITHUB_REF_NAME#v})." + echo "Delete this tag: git push origin --delete ${GITHUB_REF_NAME}" + exit 1 + + verify: + name: Verify release tag + if: ${{ !startsWith(github.ref_name, 'v') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + # Full history so the tag/main ancestry check can resolve. + fetch-depth: 0 + + - name: Verify tag + run: ./Scripts/release.sh verify-tag "${GITHUB_REF_NAME}" diff --git a/AGENTS.md b/AGENTS.md index 9255c729..81baa336 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -554,6 +554,45 @@ The convention is not lint-enforced (SwiftLint has no rule for import visibility - type order is based on the default in swiftlint: https://realm.github.io/SwiftLint/type_contents_order.html - Anything inside [CONTENT] [/CONTENT] is written by me +## Release Process + +Full runbook: `.claude/skills/release/SKILL.md` (invoke as `/release`). Mechanical +checks live in `Scripts/release.sh`; `make release-preflight` / `release-check` wrap +the common ones. + +**Naming — the one rule to internalize:** + +```text +release branch v1.0.0-beta.5 ← with v +release tag 1.0.0-beta.5 ← without v +``` + +Tags are lightweight and unprefixed; branches are prefixed. `setup-mistkit` resolves +`MISTKIT_BRANCH` via `git ls-remote`, which matches **tags as well as branches**, so +pinning the wrong kind of ref succeeds silently and greens example CI without ever +compiling the code under release. The requirement inverts at release time: before the +merge the pin must be the **branch**, after publishing it must be the **tag**. Assert +with `./Scripts/release.sh pins --expect-branch v1.0.0-beta.5` before the merge and +`./Scripts/release.sh pins --expect-tag 1.0.0-beta.5` after publishing. + +**Three standing guardrails:** + +1. **Never tag without notes.** `./Scripts/release.sh verify-tag --at HEAD` must + pass before `git tag`. Both 1.0.0-beta.3 and 1.0.0-beta.4 were tagged with no + `ReleaseNotes.md` section of their own; the `Release Check` workflow re-asserts this + after any tag push. +2. **Archive before merging under squash.** Under squash-merge, squashed commits can + become unreachable from `main` — the pre-merge archive tag is the preservation + mechanism. Release branches themselves are retained after publication. +3. **Release notes are a flat bullet list for new entries.** No `###` category + subsections; sections from beta.1–beta.4 predate this and are left as they are. + +The release PR is the documented merge-commit case (feature PRs are always rebase or +squash), but confirm the shape with the human before merging. + +Worktrees are managed with `git trees` (`add` / `rm` / `list` / `clean`), never raw +`git worktree`. + ## Memory & Corrections Convention Versioned, in-repo agent memory is the source of truth for how to work in this repo. Read both stores at the start of every session before doing work: diff --git a/Makefile b/Makefile index 6f517dd7..ed48203c 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,24 @@ -.PHONY: help example-server clean build +.PHONY: help example-server clean build test lint \ + release-preflight release-check release-notes-draft # Default target help: @echo "Available targets:" - @echo " example-server - Run the MistKit example server" - @echo " build - Build the MistDemo example" - @echo " clean - Clean build artifacts" - @echo " help - Show this help message" + @echo " example-server - Run the MistKit example server" + @echo " build - Build the MistDemo example" + @echo " test - Run the test suite" + @echo " lint - Run swift-format + swiftlint + periphery via Scripts/lint.sh" + @echo " clean - Clean build artifacts" + @echo " help - Show this help message" + @echo "" + @echo "Release (BRANCH defaults to the current branch, e.g. v1.0.0-beta.5):" + @echo " release-preflight - Gate a release branch: CI, pins, build/test/lint" + @echo " release-notes-draft - Write the ReleaseNotes.md section for the release" + @echo " release-check - Validate notes, README and pins before the release PR" + @echo " See .claude/skills/release/SKILL.md for the full runbook." + @echo "" + @echo "Lint tooling is pinned in mise.toml. Run 'mise install' once so" + @echo "Scripts/lint.sh can find swift-format, swiftlint, and periphery locally." # Run the example server example-server: build @@ -18,6 +30,21 @@ build: @echo "🔨 Building MistDemo example..." @cd Examples && swift build +test: + swift test + +lint: + @mise exec -- ./Scripts/lint.sh + +release-preflight: + @./Scripts/release.sh preflight $(BRANCH) + +release-notes-draft: + @./Scripts/release.sh notes-draft $(BRANCH) + +release-check: + @./Scripts/release.sh check $(BRANCH) + # Clean build artifacts clean: @echo "🧹 Cleaning build artifacts..." diff --git a/Scripts/release.sh b/Scripts/release.sh new file mode 100755 index 00000000..62aa6661 --- /dev/null +++ b/Scripts/release.sh @@ -0,0 +1,632 @@ +#!/bin/bash + +# MistKit release tooling. +# +# Naming convention (deliberate, asserted throughout): +# release branch = v e.g. v1.0.0-beta.5 +# release tag = e.g. 1.0.0-beta.5 +# The `v` is stripped in exactly one place — tag_for() — so the rest of the +# system never re-derives it. +# +# Most subcommands are read-only checks that accumulate into ERRORS and exit +# non-zero, following Scripts/lint.sh rather than blanket `set -e`. + +REPO="brightdigit/MistKit" +REPO_URL="https://github.com/${REPO}.git" + +# Workflows that gate a release. Explicitly listed rather than "all green": +# Claude Code Review is advisory and is routinely red, so an all-green rule +# would make preflight unpassable and get bypassed. No env-var override — +# a release gate is policy, not configuration. +REQUIRED_WORKFLOWS=("MistKit" "MistDemo Integration" "Examples") + +# Example workflows carrying a MISTKIT_BRANCH pin. Both are git subrepos. +# When Packages/MistKitConfiguration lands (#407), add its workflow here and +# to the git subrepo push hints in cmd_pins. +PIN_FILES=( + "Examples/BushelCloud/.github/workflows/BushelCloud.yml" + "Examples/CelestraCloud/.github/workflows/CelestraCloud.yml" +) + +ERRORS=0 +DRY_RUN=false +QUIET_TRAILER=false + +pass() { echo "✅ $*"; } +warn() { echo "⚠️ $*"; } +fail() { echo "❌ $*"; ERRORS=$((ERRORS + 1)); } +info() { echo "→ $*"; } + +die() { + echo "❌ $*" >&2 + exit 1 +} + +# Run a mutating command, or print it under --dry-run. +run() { + if [ "$DRY_RUN" = true ]; then + echo " [dry-run] $*" + else + "$@" || fail "command failed: $*" + fi +} + +repo_root() { + git rev-parse --show-toplevel 2>/dev/null +} + +# The single place `v` is stripped. +tag_for() { + echo "${1#v}" +} + +branch_for() { + echo "v${1#v}" +} + +current_branch() { + git rev-parse --abbrev-ref HEAD 2>/dev/null +} + +# Newest release already recorded in ReleaseNotes.md, e.g. 1.0.0-beta.4. +previous_tag() { + sed -n 's/^## \(.*\)$/\1/p' ReleaseNotes.md | head -1 +} + +# The release immediately before $1 — skips $1 when notes-draft prepended it. +prior_released_tag() { + local excluding="$1" + sed -n 's/^## \(.*\)$/\1/p' ReleaseNotes.md \ + | awk -v skip="$excluding" '$0 != skip { print; exit }' +} + +validate_release_tag() { + local tag="$1" + if [[ "$tag" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-z]+\.[0-9]+)?$ ]]; then + return 0 + fi + die "tag '$tag' does not match the release pattern (expected e.g. 1.0.0-beta.5)" +} + +# Print the ReleaseNotes.md section for a tag (heading included). +notes_section() { + awk -v tag="## $1" ' + $0 == tag { found = 1; print; next } + found && /^## / { exit } + found { print } + ' ReleaseNotes.md +} + +# Same, but from a given git tree-ish rather than the working tree. +notes_section_at() { + git show "$1:ReleaseNotes.md" 2>/dev/null | awk -v tag="## $2" ' + $0 == tag { found = 1; print; next } + found && /^## / { exit } + found { print } + ' +} + +usage() { + cat <<'EOF' +usage: ./Scripts/release.sh [args] + + preflight [branch] read-only gate: worktree, CI, build/test/lint + notes-draft [branch] write the ReleaseNotes.md section (--stdout to preview) + check [branch] validate the prepared tree before the release PR + pins [--expect-branch | --expect-tag | --roll-to ] + publish create the GitHub pre-release from ReleaseNotes.md + verify-tag [--at ] assert a tag (or a candidate commit) is releasable + +Common flags: --dry-run, --skip-local (preflight), --stdout (notes-draft) + +Branch defaults to the current branch. Tags are derived by stripping `v`. +EOF +} + +# ---------------------------------------------------------------- preflight + +cmd_preflight() { + local branch="${1:-$(current_branch)}" + local skip_local="$2" + local tag + tag=$(tag_for "$branch") + + echo "🔍 Preflight for $branch (tag: $tag)" + echo + + # Branch shape. The `v` prefix is the convention, so assert it rather than + # silently accepting a tag-shaped value. + if [[ "$branch" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-z]+\.[0-9]+)?$ ]]; then + pass "branch name '$branch' matches the release pattern" + else + fail "branch '$branch' is not a release branch (expected v, e.g. v1.0.0-beta.5)" + fi + + # Right worktree. Never suggest `git stash` — the stack is shared across + # every worktree in this layout. + if [ "$(current_branch)" = "$branch" ]; then + pass "running in the '$branch' worktree" + else + fail "current branch is '$(current_branch)', not '$branch'" + info "worktrees:" + git worktree list | sed 's/^/ /' + fi + + if [ -z "$(git status --porcelain)" ]; then + pass "working tree is clean" + else + fail "working tree is dirty (commit first — do NOT git stash in this repo)" + fi + + # The tag must not exist yet, locally or on the remote. + if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then + fail "tag '$tag' already exists locally" + elif [ -n "$(git ls-remote --tags origin "$tag" 2>/dev/null)" ]; then + fail "tag '$tag' already exists on origin" + else + pass "tag '$tag' does not exist yet" + fi + + # Notes for this release must NOT be written yet at preflight time; they + # are authored in the notes phase. Warn rather than fail if already there. + if [ -n "$(notes_section "$tag")" ]; then + warn "ReleaseNotes.md already has a '## $tag' section (re-running preflight?)" + else + info "ReleaseNotes.md has no '## $tag' section yet — author it in the notes phase" + fi + + # CI on the branch tip, limited to the gating workflows. + local sha + sha=$(git rev-parse HEAD) + if command -v gh >/dev/null 2>&1; then + local runs + runs=$(gh run list --branch "$branch" --commit "$sha" \ + --json workflowName,conclusion,status 2>/dev/null) + if [ -z "$runs" ] || [ "$runs" = "[]" ]; then + fail "no CI runs found for $branch @ ${sha:0:7}" + else + local wf conclusion + for wf in "${REQUIRED_WORKFLOWS[@]}"; do + conclusion=$(echo "$runs" | jq -r --arg w "$wf" \ + '[.[] | select(.workflowName == $w)] | first | .conclusion // "missing"') + case "$conclusion" in + success) pass "CI '$wf' is green" ;; + missing) fail "CI '$wf' has no run for this commit" ;; + *) fail "CI '$wf' is '$conclusion'" ;; + esac + done + fi + else + fail "gh CLI not found; cannot verify CI" + fi + + # Pins must point at THIS branch before the release merge, so example CI + # actually compiles the code being released. + check_pins --expect-branch "$branch" + + # Open milestone issues are a warning, not a gate. + if command -v gh >/dev/null 2>&1; then + local open + open=$(gh issue list --milestone "$branch" --state open \ + --json number,title 2>/dev/null) + if [ -n "$open" ] && [ "$open" != "[]" ]; then + warn "milestone '$branch' still has open issues:" + echo "$open" | jq -r '.[] | " #\(.number) \(.title)"' + else + pass "no open issues in milestone '$branch'" + fi + fi + + # Local build/test/lint. Default is to run them. + if [ "$skip_local" = "--skip-local" ]; then + warn "skipping local build/test/lint (--skip-local)" + else + info "running swift build" + swift build >/dev/null 2>&1 && pass "swift build" || fail "swift build" + info "running swift test" + swift test >/dev/null 2>&1 && pass "swift test" || fail "swift test" + info "running Scripts/lint.sh" + ./Scripts/lint.sh >/dev/null 2>&1 && pass "lint" || fail "lint" + fi +} + +# --------------------------------------------------------------------- pins + +pin_value() { + sed -n 's/^[[:space:]]*MISTKIT_BRANCH:[[:space:]]*\(.*\)$/\1/p' "$1" | head -1 +} + +# Assert each pin is the expected ref AND resolves as the right KIND of ref. +# setup-mistkit resolves MISTKIT_BRANCH with `git ls-remote`, which matches +# tags as well as branches — so a tag pins silently and greens example CI +# without ever compiling the branch. That is the check gh cannot do for us. +check_pins() { + local mode="$1" expected="$2" + local file value + + for file in "${PIN_FILES[@]}"; do + [ -f "$file" ] || { fail "missing $file"; continue; } + value=$(pin_value "$file") + local name="${file#Examples/}" + name="${name%%/*}" + + if [ -z "$value" ]; then + fail "$name: no MISTKIT_BRANCH found" + continue + fi + + if [ "$value" != "$expected" ]; then + fail "$name: MISTKIT_BRANCH is '$value', expected '$expected'" + continue + fi + + case "$mode" in + --expect-branch) + if [ -n "$(git ls-remote --heads "$REPO_URL" "$value" 2>/dev/null)" ]; then + pass "$name: pinned to branch '$value'" + else + fail "$name: '$value' does not resolve as a BRANCH (a tag here would green CI without testing the branch)" + fi + ;; + --expect-tag) + if [ -n "$(git ls-remote --tags "$REPO_URL" "$value" 2>/dev/null)" ]; then + pass "$name: pinned to tag '$value'" + else + fail "$name: '$value' does not resolve as a TAG" + fi + ;; + esac + done +} + +cmd_pins() { + local mode="$1" ref="$2" + + case "$mode" in + --expect-branch|--expect-tag) + [ -n "$ref" ] || die "$mode needs a ref" + echo "🔍 Checking MISTKIT_BRANCH pins against '$ref'" + check_pins "$mode" "$ref" + ;; + --roll-to) + [ -n "$ref" ] || die "--roll-to needs a ref" + echo "🔄 Rolling MISTKIT_BRANCH pins to '$ref'" + local file + for file in "${PIN_FILES[@]}"; do + [ -f "$file" ] || { fail "missing $file"; continue; } + if [ "$DRY_RUN" = true ]; then + echo " [dry-run] $file: $(pin_value "$file") → $ref" + else + # BSD/GNU sed compatible: write to a temp file. + sed "s|^\([[:space:]]*MISTKIT_BRANCH:[[:space:]]*\).*$|\1$ref|" \ + "$file" > "$file.tmp" && mv "$file.tmp" "$file" + pass "$file → $ref" + fi + done + echo + # These files live in git subrepos; pushing them is a separate, + # deliberate step the human runs. + info "Examples are git subrepos — after committing, push each:" + echo " git subrepo push Examples/BushelCloud" + echo " git subrepo push Examples/CelestraCloud" + ;; + *) + # Report-only. + echo "🔍 Current MISTKIT_BRANCH pins" + local file + for file in "${PIN_FILES[@]}"; do + [ -f "$file" ] && echo " $file: $(pin_value "$file")" + done + ;; + esac +} + +# -------------------------------------------------------------- notes-draft + +cmd_notes_draft() { + local branch="${1:-$(current_branch)}" + local to_stdout="$2" + local tag prev + tag=$(tag_for "$branch") + prev=$(previous_tag) + + [ -n "$prev" ] || die "could not read the previous tag from ReleaseNotes.md" + command -v gh >/dev/null 2>&1 || die "gh CLI is required" + + if [ -n "$(notes_section "$tag")" ]; then + die "ReleaseNotes.md already has a '## $tag' section; edit it by hand" + fi + + echo "📝 Drafting notes for $tag (since $prev)" >&2 + + local body + body=$(gh api "repos/${REPO}/releases/generate-notes" \ + -f tag_name="$tag" \ + -f target_commitish="$branch" \ + -f previous_tag_name="$prev" \ + --jq .body 2>/dev/null) || die "generate-notes failed" + + # Keep the flat bullet list only: drop GitHub's own headings and its + # trailing compare line, then re-add ours in the house format. + local section + section=$(printf '## %s\n\n%s\n\n**Full Changelog**: https://github.com/%s/compare/%s...%s\n' \ + "$tag" \ + "$(echo "$body" | grep '^\* ' )" \ + "$REPO" "$prev" "$tag") + + if [ "$to_stdout" = "--stdout" ] || [ "$DRY_RUN" = true ]; then + echo "$section" + QUIET_TRAILER=true + else + printf '%s\n\n%s' "$section" "$(cat ReleaseNotes.md)" > ReleaseNotes.md.tmp \ + && mv ReleaseNotes.md.tmp ReleaseNotes.md + pass "wrote '## $tag' to the top of ReleaseNotes.md" + info "edit the bullet wording and add issue refs, then run: check $branch" + fi + + # Raw material for the README roadmap checklist. + local closed + closed=$(gh issue list --milestone "$branch" --state closed \ + --json number,title,url 2>/dev/null) + if [ -n "$closed" ] && [ "$closed" != "[]" ]; then + echo >&2 + echo "README roadmap candidates (closed in milestone $branch):" >&2 + echo "$closed" | jq -r '.[] | "- [x] [\(.title)](\(.url)) ✅"' >&2 + fi +} + +# -------------------------------------------------------------------- check + +cmd_check() { + local branch="${1:-$(current_branch)}" + local tag prev + tag=$(tag_for "$branch") + prev=$(prior_released_tag "$tag") + + echo "🔍 Checking the prepared tree for $tag" + echo + + # The notes section must be present and be the newest one. + local first + first=$(sed -n 's/^## \(.*\)$/\1/p' ReleaseNotes.md | head -1) + if [ "$first" = "$tag" ]; then + pass "ReleaseNotes.md leads with '## $tag'" + else + fail "ReleaseNotes.md leads with '## $first', expected '## $tag'" + fi + + local section + section=$(notes_section "$tag") + if [ -z "$section" ]; then + fail "no '## $tag' section in ReleaseNotes.md" + else + local bullets + bullets=$(echo "$section" | grep -c '^\* ') + if [ "$bullets" -gt 0 ]; then + pass "section has $bullets bullet(s)" + else + fail "section has no '* ' bullets" + fi + + # Notes are a flat bullet list; subsections are no longer used. + if echo "$section" | grep -q '^### '; then + warn "section contains '###' subheadings; releases now use a flat bullet list" + fi + + local expected_compare="**Full Changelog**: https://github.com/${REPO}/compare/" + if echo "$section" | grep -qF "${expected_compare}"; then + if echo "$section" | grep -qF "${expected_compare}...${tag}" \ + || echo "$section" | grep -qE "compare/.+\.\.\.${tag//./\\.}$"; then + pass "Full Changelog line targets $tag" + else + fail "Full Changelog line does not end at '...$tag'" + fi + else + fail "section has no '**Full Changelog**' compare line" + fi + fi + + # README roadmap section for this release. + if grep -q "^### $branch\$" README.md; then + pass "README.md has a '### $branch' roadmap section" + else + fail "README.md has no '### $branch' roadmap section" + fi + + # The SwiftPM snippet should name the currently-released tag; the new one + # does not exist yet at check time. + local snippet + snippet=$(grep -o 'from: "[^"]*"' README.md | head -1 | sed 's/from: "//;s/"//') + if [ -z "$snippet" ]; then + warn "no 'from:' snippet found in README.md" + elif [ -z "$prev" ]; then + fail "could not determine the prior release tag from ReleaseNotes.md" + elif [ "$snippet" = "$prev" ]; then + pass "README 'from:' snippet names the currently released tag ($snippet)" + elif [ "$snippet" = "$tag" ]; then + fail "README 'from:' snippet is '$snippet' but the tag does not exist yet; expected '$prev'" + else + fail "README 'from:' snippet is '$snippet'; expected '$prev' (the currently released tag)" + fi + + check_pins --expect-branch "$branch" +} + +# ------------------------------------------------------------------ publish + +cmd_publish() { + local tag="$1" + [ -n "$tag" ] || die "publish needs a tag" + [ "$tag" = "${tag#v}" ] || die "tags carry no 'v' prefix; use '${tag#v}'" + validate_release_tag "$tag" + + local section + section=$(notes_section "$tag") + [ -n "$section" ] || die "no '## $tag' section in ReleaseNotes.md" + + # The release body is the notes section with the version heading swapped + # for GitHub's conventional one. + local body + body=$(echo "$section" | sed "1s|^## ${tag}$|## What's Changed|") + + local prerelease_flag="" + if [[ "$tag" == *-* ]]; then + prerelease_flag="--prerelease" + else + # A stable release is a policy call, not a default. + read -r -p "Tag '$tag' looks stable. Publish as a full release (not a pre-release)? [y/N] " reply + [[ "$reply" =~ ^[Yy]$ ]] || prerelease_flag="--prerelease" + fi + + if [ "$DRY_RUN" = true ]; then + echo " [dry-run] gh release create $tag --title $tag $prerelease_flag --verify-tag --notes-file -" >&2 + echo "--- body ---" >&2 + echo "$body" + QUIET_TRAILER=true + return + fi + + echo "$body" | gh release create "$tag" \ + --title "$tag" \ + $prerelease_flag \ + --verify-tag \ + --notes-file - \ + && pass "published $tag" \ + || fail "gh release create failed" +} + +# --------------------------------------------------------------- verify-tag + +# Assert a tag — or a candidate commit, via --at — is releasable. Reads the +# TAGGED TREE, which is what catches notes that landed after the tag. +cmd_verify_tag() { + local tag="$1" + local ref="${2:-$1}" + + [ -n "$tag" ] || die "verify-tag needs a tag" + + echo "🔍 Verifying $tag (at ${ref})" + echo + + if [ "$tag" = "${tag#v}" ]; then + pass "tag '$tag' has no 'v' prefix" + else + fail "tag '$tag' must not carry a 'v' prefix (branches do, tags do not)" + fi + + if [[ "$tag" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-z]+\.[0-9]+)?$ ]]; then + pass "tag '$tag' matches the release pattern" + else + fail "tag '$tag' does not match the release pattern (expected e.g. 1.0.0-beta.5)" + fi + + git rev-parse -q --verify "$ref" >/dev/null 2>&1 || die "ref '$ref' not found" + + local head_line + head_line=$(git show "$ref:ReleaseNotes.md" 2>/dev/null | head -1) + if [ "$head_line" = "## $tag" ]; then + pass "ReleaseNotes.md at $ref leads with '## $tag'" + else + fail "ReleaseNotes.md at $ref leads with '$head_line', expected '## $tag'" + fi + + if [ -n "$(notes_section_at "$ref" "$tag")" ]; then + pass "notes section for $tag is present in the tagged tree" + else + fail "no '## $tag' section in the tree at $ref" + fi + + if git show "$ref:README.md" 2>/dev/null | grep -q "^### v${tag}\$"; then + pass "README.md at $ref has the '### v$tag' roadmap section" + else + fail "README.md at $ref has no '### v$tag' roadmap section" + fi + + # Resolve main however it is available: a tag-triggered CI checkout may + # have no origin/main remote-tracking ref, so fetch it if needed. + local main_ref="" + if git rev-parse -q --verify origin/main >/dev/null 2>&1; then + main_ref="origin/main" + elif git rev-parse -q --verify refs/heads/main >/dev/null 2>&1; then + main_ref="refs/heads/main" + elif git fetch --quiet origin main 2>/dev/null && git rev-parse -q --verify FETCH_HEAD >/dev/null 2>&1; then + main_ref="FETCH_HEAD" + fi + + if [ -z "$main_ref" ]; then + fail "could not resolve main to check tag ancestry" + elif git merge-base --is-ancestor "$ref" "$main_ref" 2>/dev/null; then + pass "$ref is an ancestor of main ($main_ref)" + else + fail "$ref is not an ancestor of main ($main_ref)" + fi +} + +# ----------------------------------------------------------------- dispatch + +ROOT=$(repo_root) || die "not in a git worktree" +cd "$ROOT" || die "could not cd to $ROOT" + +COMMAND="$1" +shift 2>/dev/null || true + +ARGS=() +AT_REF="" +SKIP_LOCAL=false +STDOUT=false + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=true ;; + --skip-local) + [ "$COMMAND" = preflight ] || die "--skip-local is only valid for preflight" + SKIP_LOCAL=true + ;; + --stdout) + [ "$COMMAND" = notes-draft ] || die "--stdout is only valid for notes-draft" + STDOUT=true + ;; + --at) + [ "$COMMAND" = verify-tag ] || die "--at is only valid for verify-tag" + shift + [ -n "${1:-}" ] || die "--at requires a ref" + AT_REF="$1" + ;; + *) ARGS+=("$1") ;; + esac + shift +done + +case "$COMMAND" in + preflight) + if [ "$SKIP_LOCAL" = true ]; then + cmd_preflight "${ARGS[0]}" "--skip-local" + else + cmd_preflight "${ARGS[0]}" + fi + ;; + notes-draft) + if [ "$STDOUT" = true ]; then + cmd_notes_draft "${ARGS[0]}" "--stdout" + else + cmd_notes_draft "${ARGS[0]}" + fi + ;; + check) cmd_check "${ARGS[0]}" ;; + pins) cmd_pins "${ARGS[0]}" "${ARGS[1]}" ;; + publish) cmd_publish "${ARGS[0]}" ;; + verify-tag) cmd_verify_tag "${ARGS[0]}" "${AT_REF:-${ARGS[0]}}" ;; + -h|--help|help|"") usage; exit 0 ;; + *) usage; die "unknown command: $COMMAND" ;; +esac + +if [ "${QUIET_TRAILER:-false}" != true ]; then + echo + if [ $ERRORS -gt 0 ]; then + echo "Completed with $ERRORS error(s)" + exit 1 + fi + echo "OK" +fi +[ $ERRORS -gt 0 ] && exit 1 +exit 0 From 1f53458565640aab8cfe0c245f8b0bd719b71fdf Mon Sep 17 00:00:00 2001 From: leogdion Date: Wed, 2 Sep 2026 12:49:34 -0400 Subject: [PATCH 12/25] Address code review: fixes, coverage, and lint tooling (#460) --- .claude/agent-notes.md | 4 + .claude/skills/fix-lint/SKILL.md | 58 ++++ .../fix-lint/scripts/compile-lint-report.py | 250 +++++++++++++++ Examples/BushelCloud/Package.swift | 70 +---- Examples/CelestraCloud/Package.swift | 70 +---- Examples/MistDemo/Package.swift | 70 +---- .../Phases/ZonePayloadMetadataPhase.swift | 8 +- .../Keys/MistDemoKeysTests.swift | 2 +- .../MistDemoConfigurationBoolTests.swift | 2 +- Package.swift | 77 +---- Packages/MistKitConfiguration/Package.swift | 10 - .../CloudKitConfigurationKeysTests.swift | 2 +- .../CloudKitConfigurationReadingTests.swift | 2 +- .../CloudKitConfigurationTests.swift | 2 +- .../ConfigurationSourcesTests.swift | 2 +- .../KeyIDValidatorTests.swift | 2 +- .../PEMValidatorTests.swift | 2 +- Scripts/lint.sh | 286 ++++++++++++++++-- .../CloudKitService/CloudKitService.swift | 2 + .../MistKit/Models/Sharing/CreatedShare.swift | 2 + .../Models/Sharing/ShareTargetReference.swift | 5 - .../Models/Zones/ZoneChangeResult.swift | 2 +- Sources/MistKit/Models/Zones/ZoneInfo.swift | 12 +- .../Models/Notifications/CourierTests.swift | 69 +++++ .../Subscriptions/NotificationInfoTests.swift | 77 +++++ ...ZoneMetadataTests+ZoneInfoConversion.swift | 15 +- ...ataTests+ZoneInfoConversionEdgeCases.swift | 2 +- .../OpenAPI/CloudKitResponseTypeTests.swift | 163 ++++++++++ .../RecordManagement/AltTestRecord.swift | 56 ++++ .../MockCollectionRecordManagingService.swift | 58 ++++ ...RecordManagingTests+RecordCollection.swift | 123 ++++++++ .../RecordManagement/RecordTypeSetTests.swift | 53 ++++ codecov.yml | 1 + 33 files changed, 1214 insertions(+), 345 deletions(-) create mode 100644 .claude/skills/fix-lint/SKILL.md create mode 100755 .claude/skills/fix-lint/scripts/compile-lint-report.py create mode 100644 Tests/MistKitTests/Models/Subscriptions/NotificationInfoTests.swift create mode 100644 Tests/MistKitTests/OpenAPI/CloudKitResponseTypeTests.swift create mode 100644 Tests/MistKitTests/RecordManagement/AltTestRecord.swift create mode 100644 Tests/MistKitTests/RecordManagement/MockCollectionRecordManagingService.swift create mode 100644 Tests/MistKitTests/RecordManagement/RecordManagingTests+RecordCollection.swift create mode 100644 Tests/MistKitTests/RecordManagement/RecordTypeSetTests.swift diff --git a/.claude/agent-notes.md b/.claude/agent-notes.md index 7473de45..43e09a16 100644 --- a/.claude/agent-notes.md +++ b/.claude/agent-notes.md @@ -29,3 +29,7 @@ Standing always/never directives and corrections from the human. Agents must rea - `git subrepo push Packages/MistKitConfiguration` refuses ("new changes upstream") and would clobber the standalone `url:` MistKit line with the monorepo `path:` overlay; push subrepo-only changes by applying them to a clone of the standalone repo instead, then record the pushed SHA in `.gitrepo`'s `commit =`. - NEVER switch `Examples/*/Package.swift` to a tagged `from:` just because a monorepo package was released — the Examples dogfood UNRELEASED MistKit and must keep `path:` for every in-monorepo package, with CI rewriting them to branch-HEAD `revision:` pins via `setup-mistkitconfiguration` (takes both `mistkit-branch` and `mistkitconfiguration-branch`). A published tag is for downstream consumers, not for the Examples. - `Packages/MistKitConfiguration` is scaffolding for the `v1.0.0-beta.5` release line ONLY. Feature PRs merge into `v1.0.0-beta.5` with the subrepo intact; the release PR `v1.0.0-beta.5` → `main` MUST delete it (and its `examples.yml` lane) so no MistKit release ships a package tracking that same unreleased release. +- MistKitConfiguration test gaps belong in `Packages/MistKitConfiguration/` (subrepo) or the standalone `brightdigit/MistKitConfiguration` repo — NEVER add MKC unit tests to the monorepo root `Tests/` or chase MKC patch coverage in MistKit's codecov upload; `codecov.yml` already ignores that path. +- Lint findings for agents: use `LINT_REPORT=1 ./Scripts/lint.sh` (human summary on stderr + JSON between `### MISTKIT_LINT_REPORT_* ###` markers on stdout) or `LINT_REPORT=json ./Scripts/lint.sh` (JSON only on stdout). Report mode is read-only and walks swift-format, SwiftLint, `swift build`, and Periphery; all four lint tools run with `--strict` so any warning or error fails the pipeline; success requires exit 0 and `summary.totalFindings == 0`. See `.claude/skills/fix-lint/SKILL.md`. +- Prefer force unwraps (`!`) over verbose `guard let … else { preconditionFailure }` for compile-time-known-safe values (constant URL literals, test fixtures); suppress lint with `swift-format-ignore: NeverForceUnwrap` plus a `swiftlint:disable force_unwrapping` / `swiftlint:enable force_unwrapping` block when a doc comment sits between the disable and the unwrap (`:next` only when the unwrap is the immediate next line). +- NEVER use `Task.sleep(for:)` / `Duration` clocks in MistKitTests — package deployment target is iOS 14 (and peers); use `Task.sleep(nanoseconds:)` instead (CI iOS simulator build failed on CourierTests for this). diff --git a/.claude/skills/fix-lint/SKILL.md b/.claude/skills/fix-lint/SKILL.md new file mode 100644 index 00000000..5a0a4b8a --- /dev/null +++ b/.claude/skills/fix-lint/SKILL.md @@ -0,0 +1,58 @@ +--- +name: fix-lint +description: >- + Triage and fix MistKit lint findings from ./Scripts/lint.sh (swift-format, + SwiftLint, swift build, Periphery). Use when the user reports lint errors or + warnings, asks to fix lint, or before commit/push when lint must be clean. +--- + +# Fix Lint + +## Success criteria + +`./Scripts/lint.sh` exits **0** and `summary.totalFindings == 0`. Any warning or error from any lint tool is blocking — all four tools run with `--strict`. + +## Discover findings + +```bash +LINT_REPORT=1 ./Scripts/lint.sh +``` + +`lint.sh` invokes [scripts/compile-lint-report.py](scripts/compile-lint-report.py) in report mode to produce: + +- Human summary on **stderr** +- JSON between `### MISTKIT_LINT_REPORT_BEGIN ###` / `### MISTKIT_LINT_REPORT_END ###` on stdout + +Read `summary.totalFindings`, `summary.failedSteps`, and each tool's `findings[]` (`file`, `line`, `rule`, `message`, `severity`). + +Report mode is **read-only** (no auto-format or `swiftlint --fix`). + +## Fix loop + +1. Group findings by tool, then file. +2. Apply fixes (see table below). +3. Re-run `./Scripts/lint.sh` or `LINT_REPORT=1 ./Scripts/lint.sh` until clean. +4. Run targeted `swift test --filter …` for touched areas. + +## Tool-specific guidance + +| Tool | Typical fix | +|------|-------------| +| **swift-format** | Edit source to match rule (e.g. `for-in` over `.forEach`). Outside report mode, `lint.sh` auto-formats first. | +| **SwiftLint** | Fix the code; use `// swiftlint:disable:next ` only for documented intentional exceptions. Constant URLs can use `guard let` + `preconditionFailure` instead of `!`. | +| **Periphery** | Remove dead code — do not add no-op usages to silence. | +| **swift build** | Fix compiler errors from the build step log. | + +## Constraints + +- Run swift-format, SwiftLint, periphery, and swift-openapi-generator through **mise** (`mise exec -- …`) when invoking outside `lint.sh`. +- Do not manually edit `Sources/MistKitOpenAPI/` — regenerate from `openapi.yaml`. +- Periphery needs a prior `swift build --build-tests` index store under `.build/`; `lint.sh` handles this ordering. + +## Verify + +```bash +./Scripts/lint.sh +LINT_REPORT=1 ./Scripts/lint.sh # confirm totalFindings: 0 +swift test --filter +``` diff --git a/.claude/skills/fix-lint/scripts/compile-lint-report.py b/.claude/skills/fix-lint/scripts/compile-lint-report.py new file mode 100755 index 00000000..f486ccb4 --- /dev/null +++ b/.claude/skills/fix-lint/scripts/compile-lint-report.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Compile lint tool outputs into a unified MistKit lint report.""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Any + +SWIFT_FORMAT_RE = re.compile( + r"^(?P[^:]+):(?P\d+):(?P\d+): " + r"(?Pwarning|error): \[(?P[^\]]+)\] (?P.*)$" +) + +SWIFT_BUILD_RE = re.compile( + r"^(?P[^:]+):(?P\d+):(?P\d+): " + r"(?Pwarning|error): (?P.*)$" +) + + +def read_text(path: Path | None) -> str: + if path is None or not path.is_file(): + return "" + return path.read_text(encoding="utf-8", errors="replace") + + +def read_json(path: Path | None) -> Any: + if path is None or not path.is_file(): + return None + text = path.read_text(encoding="utf-8", errors="replace").strip() + if not text: + return None + return json.loads(text) + + +def parse_swift_format(text: str) -> list[dict[str, Any]]: + findings: list[dict[str, Any]] = [] + for line in text.splitlines(): + match = SWIFT_FORMAT_RE.match(line.strip()) + if not match: + continue + findings.append( + { + "file": match.group("file"), + "line": int(match.group("line")), + "column": int(match.group("column")), + "severity": match.group("severity"), + "rule": match.group("rule"), + "message": match.group("message"), + } + ) + return findings + + +def parse_swift_build(text: str) -> list[dict[str, Any]]: + findings: list[dict[str, Any]] = [] + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("[") or ": warning:" not in stripped and ": error:" not in stripped: + continue + match = SWIFT_BUILD_RE.match(stripped) + if not match: + continue + findings.append( + { + "file": match.group("file"), + "line": int(match.group("line")), + "column": int(match.group("column")), + "severity": match.group("severity"), + "rule": "compiler", + "message": match.group("message"), + } + ) + return findings + + +def normalize_swiftlint(raw: list[dict[str, Any]] | None) -> tuple[list[dict[str, Any]], int]: + if not raw: + return [], 0 + findings: list[dict[str, Any]] = [] + for violation in raw: + severity = str(violation.get("severity", "")).lower() + findings.append( + { + "file": violation.get("file", ""), + "line": violation.get("line"), + "column": violation.get("character"), + "severity": severity, + "rule": violation.get("rule_id", ""), + "message": violation.get("reason", ""), + "type": violation.get("type"), + } + ) + serious_count = sum(1 for finding in findings if finding["severity"] == "error") + return findings, serious_count + + +def normalize_periphery(raw: list[dict[str, Any]] | None) -> list[dict[str, Any]]: + if not raw: + return [] + findings: list[dict[str, Any]] = [] + for item in raw: + location = item.get("location", "") + file_path = location + line: int | None = None + column: int | None = None + if location.count(":") >= 2: + file_path, line_text, column_text = location.rsplit(":", 2) + line = int(line_text) + column = int(column_text) + hints = item.get("hints", []) + rule = hints[0] if hints else "unused" + kind = item.get("kind", "symbol") + name = item.get("name", "") + findings.append( + { + "file": file_path, + "line": line, + "column": column, + "severity": "warning", + "rule": rule, + "message": f"Unused {kind} '{name}'", + } + ) + return findings + + +def tool_section( + *, + skipped: bool, + skip_reason: str | None, + exit_code: int | None, + findings: list[dict[str, Any]], + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + section: dict[str, Any] = { + "skipped": skipped, + "exitCode": exit_code, + "findingCount": len(findings), + "findings": findings, + } + if skip_reason: + section["skipReason"] = skip_reason + if extra: + section.update(extra) + return section + + +def human_summary(report: dict[str, Any]) -> str: + lines = ["=== MistKit lint report ==="] + for tool_name, tool in report["tools"].items(): + label = tool_name.replace("-", " ") + if tool["skipped"]: + reason = tool.get("skipReason", "skipped") + lines.append(f"{label}: skipped ({reason})") + continue + count = tool["findingCount"] + suffix = "" + if tool_name == "swiftlint" and "seriousCount" in tool: + suffix = f" ({tool['seriousCount']} serious)" + elif tool_name == "swift-build": + errors = sum(1 for f in tool["findings"] if f["severity"] == "error") + warnings = sum(1 for f in tool["findings"] if f["severity"] == "warning") + if errors or warnings: + suffix = f" ({errors} errors, {warnings} warnings)" + noun = "finding" if count == 1 else "findings" + lines.append(f"{label}: {count} {noun}{suffix}") + lines.append("---") + lines.append(f"total: {report['summary']['totalFindings']} findings") + failed_steps = report["summary"]["failedSteps"] + if failed_steps: + lines.append(f"failed steps: {', '.join(failed_steps)}") + else: + lines.append("failed steps: none") + return "\n".join(lines) + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: compile-lint-report.py ", file=sys.stderr) + return 2 + + manifest = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + report_dir = Path(manifest["reportDir"]) + + swift_format_findings = parse_swift_format( + read_text(report_dir / "swift-format.log") + ) + swiftlint_raw = read_json(report_dir / "swiftlint.json") + swiftlint_findings, serious_count = normalize_swiftlint(swiftlint_raw) + swift_build_findings = parse_swift_build(read_text(report_dir / "swift-build.log")) + periphery_findings = normalize_periphery(read_json(report_dir / "periphery.json")) + + tools: dict[str, Any] = { + "swift-format": tool_section( + skipped=False, + skip_reason=None, + exit_code=manifest["steps"].get("swift-format", {}).get("exitCode"), + findings=swift_format_findings, + ), + "swiftlint": tool_section( + skipped=manifest["steps"].get("swiftlint", {}).get("skipped", False), + skip_reason=manifest["steps"].get("swiftlint", {}).get("skipReason"), + exit_code=manifest["steps"].get("swiftlint", {}).get("exitCode"), + findings=swiftlint_findings, + extra={"seriousCount": serious_count}, + ), + "swift-build": tool_section( + skipped=manifest["steps"].get("swift-build", {}).get("skipped", False), + skip_reason=manifest["steps"].get("swift-build", {}).get("skipReason"), + exit_code=manifest["steps"].get("swift-build", {}).get("exitCode"), + findings=swift_build_findings, + ), + "periphery": tool_section( + skipped=manifest["steps"].get("periphery", {}).get("skipped", False), + skip_reason=manifest["steps"].get("periphery", {}).get("skipReason"), + exit_code=manifest["steps"].get("periphery", {}).get("exitCode"), + findings=periphery_findings, + ), + } + + total_findings = sum(tool["findingCount"] for tool in tools.values()) + failed_steps = manifest.get("failedSteps", []) + + report = { + "summary": { + "totalFindings": total_findings, + "failedSteps": failed_steps, + "failedStepCount": len(failed_steps), + }, + "tools": tools, + } + + output_format = manifest.get("outputFormat", "json") + json_text = json.dumps(report, indent=2) + + if output_format in {"both", "summary"}: + print(human_summary(report), file=sys.stderr) + + print("### MISTKIT_LINT_REPORT_BEGIN ###") + print(json_text) + print("### MISTKIT_LINT_REPORT_END ###") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Examples/BushelCloud/Package.swift b/Examples/BushelCloud/Package.swift index 92048ca0..e3650ec3 100644 --- a/Examples/BushelCloud/Package.swift +++ b/Examples/BushelCloud/Package.swift @@ -5,76 +5,8 @@ import PackageDescription -// MARK: - Swift Settings Configuration - let swiftSettings: [SwiftSetting] = [ - // Swift 6.4 Upcoming Features (not yet enabled by default) - // SE-0335: Introduce existential `any` - .enableUpcomingFeature("ExistentialAny"), - // SE-0409: Access-level modifiers on import declarations .enableUpcomingFeature("InternalImportsByDefault"), - // SE-0444: Member import visibility (Swift 6.1+) - .enableUpcomingFeature("MemberImportVisibility"), - // SE-0413: Typed throws - .enableUpcomingFeature("FullTypedThrows"), - - // Experimental Features (stable enough for use) - // SE-0426: BitwiseCopyable protocol - .enableExperimentalFeature("BitwiseCopyable"), - // SE-0432: Borrowing and consuming pattern matching for noncopyable types - .enableExperimentalFeature("BorrowingSwitch"), - // Extension macros - .enableExperimentalFeature("ExtensionMacros"), - // Freestanding expression macros - .enableExperimentalFeature("FreestandingExpressionMacros"), - // Init accessors - .enableExperimentalFeature("InitAccessors"), - // Isolated any types - .enableExperimentalFeature("IsolatedAny"), - // Move-only classes - .enableExperimentalFeature("MoveOnlyClasses"), - // Move-only enum deinits - .enableExperimentalFeature("MoveOnlyEnumDeinits"), - // SE-0429: Partial consumption of noncopyable values - .enableExperimentalFeature("MoveOnlyPartialConsumption"), - // Move-only resilient types - .enableExperimentalFeature("MoveOnlyResilientTypes"), - // Move-only tuples - .enableExperimentalFeature("MoveOnlyTuples"), - // SE-0427: Noncopyable generics - .enableExperimentalFeature("NoncopyableGenerics"), - // One-way closure parameters - // .enableExperimentalFeature("OneWayClosureParameters"), - // Raw layout types - .enableExperimentalFeature("RawLayout"), - // Reference bindings - .enableExperimentalFeature("ReferenceBindings"), - // SE-0430: sending parameter and result values - .enableExperimentalFeature("SendingArgsAndResults"), - // Symbol linkage markers - .enableExperimentalFeature("SymbolLinkageMarkers"), - // Transferring args and results - .enableExperimentalFeature("TransferringArgsAndResults"), - // SE-0393: Value and Type Parameter Packs - .enableExperimentalFeature("VariadicGenerics"), - // Warn unsafe reflection - .enableExperimentalFeature("WarnUnsafeReflection"), - - // Enhanced compiler checking - // .unsafeFlags([ - // // Enable concurrency warnings - // "-warn-concurrency", - // // Enable actor data race checks - // "-enable-actor-data-race-checks", - // // Complete strict concurrency checking - // "-strict-concurrency=complete", - // // Enable testing support - // "-enable-testing", - // // Warn about functions with >100 lines - // "-Xfrontend", "-warn-long-function-bodies=100", - // // Warn about slow type checking expressions - // "-Xfrontend", "-warn-long-expression-type-checking=100" - // ]) ] let package = Package( @@ -99,7 +31,7 @@ let package = Package( // Monorepo dogfood overlay — publishable consumers use a tagged `from:` once // MistKitConfiguration is released. .package(path: "../../Packages/MistKitConfiguration"), - .package(url: "https://github.com/brightdigit/ConfigKeyKit.git", from: "1.0.0-beta.2"), + .package(url: "https://github.com/brightdigit/ConfigKeyKit.git", from: "1.0.0-beta.3"), .package(url: "https://github.com/brightdigit/BushelKit.git", from: "3.0.0-alpha.4"), .package(url: "https://github.com/brightdigit/IPSWDownloads.git", from: "1.0.0"), .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0"), diff --git a/Examples/CelestraCloud/Package.swift b/Examples/CelestraCloud/Package.swift index c519005a..1739a827 100644 --- a/Examples/CelestraCloud/Package.swift +++ b/Examples/CelestraCloud/Package.swift @@ -4,76 +4,8 @@ import PackageDescription -// MARK: - Swift Settings Configuration - let swiftSettings: [SwiftSetting] = [ - // Swift 6.4 Upcoming Features (not yet enabled by default) - // SE-0335: Introduce existential `any` - .enableUpcomingFeature("ExistentialAny"), - // SE-0409: Access-level modifiers on import declarations .enableUpcomingFeature("InternalImportsByDefault"), - // SE-0444: Member import visibility (Swift 6.1+) - .enableUpcomingFeature("MemberImportVisibility"), - // SE-0413: Typed throws - .enableUpcomingFeature("FullTypedThrows"), - - // Experimental Features (stable enough for use) - // SE-0426: BitwiseCopyable protocol - .enableExperimentalFeature("BitwiseCopyable"), - // SE-0432: Borrowing and consuming pattern matching for noncopyable types - .enableExperimentalFeature("BorrowingSwitch"), - // Extension macros - .enableExperimentalFeature("ExtensionMacros"), - // Freestanding expression macros - .enableExperimentalFeature("FreestandingExpressionMacros"), - // Init accessors - .enableExperimentalFeature("InitAccessors"), - // Isolated any types - .enableExperimentalFeature("IsolatedAny"), - // Move-only classes - .enableExperimentalFeature("MoveOnlyClasses"), - // Move-only enum deinits - .enableExperimentalFeature("MoveOnlyEnumDeinits"), - // SE-0429: Partial consumption of noncopyable values - .enableExperimentalFeature("MoveOnlyPartialConsumption"), - // Move-only resilient types - .enableExperimentalFeature("MoveOnlyResilientTypes"), - // Move-only tuples - .enableExperimentalFeature("MoveOnlyTuples"), - // SE-0427: Noncopyable generics - .enableExperimentalFeature("NoncopyableGenerics"), - // One-way closure parameters - // .enableExperimentalFeature("OneWayClosureParameters"), - // Raw layout types - .enableExperimentalFeature("RawLayout"), - // Reference bindings - .enableExperimentalFeature("ReferenceBindings"), - // SE-0430: sending parameter and result values - .enableExperimentalFeature("SendingArgsAndResults"), - // Symbol linkage markers - .enableExperimentalFeature("SymbolLinkageMarkers"), - // Transferring args and results - .enableExperimentalFeature("TransferringArgsAndResults"), - // SE-0393: Value and Type Parameter Packs - .enableExperimentalFeature("VariadicGenerics"), - // Warn unsafe reflection - .enableExperimentalFeature("WarnUnsafeReflection"), - - // Enhanced compiler checking - // .unsafeFlags([ - // // Enable concurrency warnings - // "-warn-concurrency", - // // Enable actor data race checks - // "-enable-actor-data-race-checks", - // // Complete strict concurrency checking - // "-strict-concurrency=complete", - // // Enable testing support - // "-enable-testing", - // // Warn about functions with >100 lines - // "-Xfrontend", "-warn-long-function-bodies=100", - // // Warn about slow type checking expressions - // "-Xfrontend", "-warn-long-expression-type-checking=100" - // ]) ] let package = Package( @@ -94,7 +26,7 @@ let package = Package( // Monorepo dogfood overlay — publishable consumers use a tagged `from:` once // MistKitConfiguration is released. Same never-merged discipline as the MistKit line. .package(path: "../../Packages/MistKitConfiguration"), - .package(url: "https://github.com/brightdigit/ConfigKeyKit.git", from: "1.0.0-beta.2"), + .package(url: "https://github.com/brightdigit/ConfigKeyKit.git", from: "1.0.0-beta.3"), .package(url: "https://github.com/brightdigit/CelestraKit.git", from: "0.0.3"), .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), .package( diff --git a/Examples/MistDemo/Package.swift b/Examples/MistDemo/Package.swift index 3be7a9ea..780c2708 100644 --- a/Examples/MistDemo/Package.swift +++ b/Examples/MistDemo/Package.swift @@ -19,76 +19,8 @@ let asyncAlgorithmsCondition: TargetDependencyCondition? = .when( ) #endif -// MARK: - Swift Settings Configuration - let swiftSettings: [SwiftSetting] = [ - // Swift 6.2 Upcoming Features (not yet enabled by default) - // SE-0335: Introduce existential `any` - .enableUpcomingFeature("ExistentialAny"), - // SE-0409: Access-level modifiers on import declarations .enableUpcomingFeature("InternalImportsByDefault"), - // SE-0444: Member import visibility (Swift 6.1+) - .enableUpcomingFeature("MemberImportVisibility"), - // SE-0413: Typed throws - .enableUpcomingFeature("FullTypedThrows"), - - // Experimental Features (stable enough for use) - // SE-0426: BitwiseCopyable protocol - .enableExperimentalFeature("BitwiseCopyable"), - // SE-0432: Borrowing and consuming pattern matching for noncopyable types - .enableExperimentalFeature("BorrowingSwitch"), - // Extension macros - .enableExperimentalFeature("ExtensionMacros"), - // Freestanding expression macros - .enableExperimentalFeature("FreestandingExpressionMacros"), - // Init accessors - .enableExperimentalFeature("InitAccessors"), - // Isolated any types - .enableExperimentalFeature("IsolatedAny"), - // Move-only classes - .enableExperimentalFeature("MoveOnlyClasses"), - // Move-only enum deinits - .enableExperimentalFeature("MoveOnlyEnumDeinits"), - // SE-0429: Partial consumption of noncopyable values - .enableExperimentalFeature("MoveOnlyPartialConsumption"), - // Move-only resilient types - .enableExperimentalFeature("MoveOnlyResilientTypes"), - // Move-only tuples - .enableExperimentalFeature("MoveOnlyTuples"), - // SE-0427: Noncopyable generics - .enableExperimentalFeature("NoncopyableGenerics"), - // One-way closure parameters - // .enableExperimentalFeature("OneWayClosureParameters"), - // Raw layout types - .enableExperimentalFeature("RawLayout"), - // Reference bindings - .enableExperimentalFeature("ReferenceBindings"), - // SE-0430: sending parameter and result values - .enableExperimentalFeature("SendingArgsAndResults"), - // Symbol linkage markers - .enableExperimentalFeature("SymbolLinkageMarkers"), - // Transferring args and results - .enableExperimentalFeature("TransferringArgsAndResults"), - // SE-0393: Value and Type Parameter Packs - .enableExperimentalFeature("VariadicGenerics"), - // Warn unsafe reflection - .enableExperimentalFeature("WarnUnsafeReflection"), - - // Enhanced compiler checking - .unsafeFlags([ - // Enable concurrency warnings - "-warn-concurrency", - // Enable actor data race checks - "-enable-actor-data-race-checks", - // Complete strict concurrency checking - "-strict-concurrency=complete", - // Enable testing support - "-enable-testing", - // Warn about functions with >100 lines - "-Xfrontend", "-warn-long-function-bodies=100", - // Warn about slow type checking expressions - "-Xfrontend", "-warn-long-expression-type-checking=100", - ]), ] let package = Package( @@ -109,7 +41,7 @@ let package = Package( // Monorepo dogfood overlay — publishable consumers use a tagged `from:` once // MistKitConfiguration is released. .package(path: "../../Packages/MistKitConfiguration"), - .package(url: "https://github.com/brightdigit/ConfigKeyKit.git", from: "1.0.0-beta.2"), + .package(url: "https://github.com/brightdigit/ConfigKeyKit.git", from: "1.0.0-beta.3"), .package( url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0" diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ZonePayloadMetadataPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ZonePayloadMetadataPhase.swift index dc545c62..b917161c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ZonePayloadMetadataPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ZonePayloadMetadataPhase.swift @@ -96,7 +96,7 @@ internal struct ZonePayloadMetadataPhase: IntegrationPhase { zoneName: zoneName, context: context, description: "zone creation" - ) { !$0.deleted } + ) { $0.deleted != true } try Self.requireLiveZoneMetadata(createdChange, source: "fetchDatabaseChanges (create)") try await context.service.deleteZone( @@ -112,9 +112,9 @@ internal struct ZonePayloadMetadataPhase: IntegrationPhase { zoneName: zoneName, context: context, description: "zone deletion tombstone" - ) { $0.deleted } + ) { $0.deleted == true } - guard deletedChange.deleted else { + guard deletedChange.deleted == true else { throw IntegrationTestError.verificationFailed( "fetchDatabaseChanges did not mark '\(zoneName)' deleted after deleteZone" ) @@ -156,7 +156,7 @@ internal struct ZonePayloadMetadataPhase: IntegrationPhase { + "(expected \(customZoneType.rawValue))" ) } - guard !zone.deleted else { + guard zone.deleted != true else { throw IntegrationTestError.verificationFailed( "\(source) reported deleted=true for live zone '\(zone.zoneName)'" ) diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/Keys/MistDemoKeysTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Keys/MistDemoKeysTests.swift index 9c64cc23..e77287af 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/Keys/MistDemoKeysTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Keys/MistDemoKeysTests.swift @@ -30,7 +30,7 @@ internal import ConfigKeyKit internal import MistKitConfiguration internal import Configuration -import Testing +internal import Testing @testable import MistDemoKit diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/MistDemoConfigurationBoolTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/MistDemoConfigurationBoolTests.swift index b6724b2e..adb70645 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/MistDemoConfigurationBoolTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/MistDemoConfigurationBoolTests.swift @@ -30,7 +30,7 @@ internal import ConfigKeyKit internal import MistKitConfiguration internal import Configuration -import Testing +internal import Testing @testable import MistDemoKit diff --git a/Package.swift b/Package.swift index 4cbcf39e..db365ad5 100644 --- a/Package.swift +++ b/Package.swift @@ -5,80 +5,10 @@ import PackageDescription -// MARK: - Swift Settings Configuration - -// Swift settings for the generated OpenAPI target. swift-openapi-generator -// emits bare `import Foundation` / `import OpenAPIRuntime`; under SE-0409 -// (InternalImportsByDefault) those flip to `internal`, which breaks the -// public initializers on the generated `Client`. Leave InternalImportsByDefault -// off for the generated target. -let generatedSwiftSettings: [SwiftSetting] = [ - .enableUpcomingFeature("ExistentialAny"), - .enableUpcomingFeature("MemberImportVisibility"), - .enableUpcomingFeature("FullTypedThrows"), -] - -// Base Swift settings for all platforms +// Bare imports in swift-openapi-generator output must stay public; do not apply +// InternalImportsByDefault to the generated MistKitOpenAPI target. let swiftSettings: [SwiftSetting] = [ - // Swift 6.2 Upcoming Features (not yet enabled by default) - // SE-0335: Introduce existential `any` - .enableUpcomingFeature("ExistentialAny"), - // SE-0409: Access-level modifiers on import declarations .enableUpcomingFeature("InternalImportsByDefault"), - // SE-0444: Member import visibility (Swift 6.1+) - .enableUpcomingFeature("MemberImportVisibility"), - // SE-0413: Typed throws - .enableUpcomingFeature("FullTypedThrows"), - - // Experimental Features (stable enough for use) - // SE-0426: BitwiseCopyable protocol - .enableExperimentalFeature("BitwiseCopyable"), - // SE-0432: Borrowing and consuming pattern matching for noncopyable types - .enableExperimentalFeature("BorrowingSwitch"), - // Extension macros - .enableExperimentalFeature("ExtensionMacros"), - // Freestanding expression macros - .enableExperimentalFeature("FreestandingExpressionMacros"), - // Init accessors - .enableExperimentalFeature("InitAccessors"), - // Isolated any types - .enableExperimentalFeature("IsolatedAny"), - // Move-only classes - .enableExperimentalFeature("MoveOnlyClasses"), - // Move-only enum deinits - .enableExperimentalFeature("MoveOnlyEnumDeinits"), - // SE-0429: Partial consumption of noncopyable values - .enableExperimentalFeature("MoveOnlyPartialConsumption"), - // Move-only resilient types - .enableExperimentalFeature("MoveOnlyResilientTypes"), - // Move-only tuples - .enableExperimentalFeature("MoveOnlyTuples"), - // SE-0427: Noncopyable generics - .enableExperimentalFeature("NoncopyableGenerics"), - // One-way closure parameters - // .enableExperimentalFeature("OneWayClosureParameters"), - // Raw layout types - .enableExperimentalFeature("RawLayout"), - // Reference bindings - .enableExperimentalFeature("ReferenceBindings"), - // SE-0430: sending parameter and result values - .enableExperimentalFeature("SendingArgsAndResults"), - // Symbol linkage markers - .enableExperimentalFeature("SymbolLinkageMarkers"), - // Transferring args and results - .enableExperimentalFeature("TransferringArgsAndResults"), - // SE-0393: Value and Type Parameter Packs - .enableExperimentalFeature("VariadicGenerics"), - // Warn unsafe reflection - .enableExperimentalFeature("WarnUnsafeReflection"), - - // Enhanced compiler checking -// .unsafeFlags([ -// // Warn about functions with >100 lines -// "-Xfrontend", "-warn-long-function-bodies=100", -// // Warn about slow type checking expressions -// "-Xfrontend", "-warn-long-expression-type-checking=100" -// ]) ] let package = Package( @@ -120,8 +50,7 @@ let package = Package( name: "MistKitOpenAPI", dependencies: [ .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), - ], - swiftSettings: generatedSwiftSettings + ] ), .target( name: "MistKit", diff --git a/Packages/MistKitConfiguration/Package.swift b/Packages/MistKitConfiguration/Package.swift index c7ec6b73..05f7ef1a 100644 --- a/Packages/MistKitConfiguration/Package.swift +++ b/Packages/MistKitConfiguration/Package.swift @@ -4,18 +4,8 @@ import PackageDescription -// MARK: - Swift Settings Configuration - let swiftSettings: [SwiftSetting] = [ - // Swift 6.2 Upcoming Features (not yet enabled by default) - // SE-0335: Introduce existential `any` - .enableUpcomingFeature("ExistentialAny"), - // SE-0409: Access-level modifiers on import declarations .enableUpcomingFeature("InternalImportsByDefault"), - // SE-0444: Member import visibility (Swift 6.1+) - .enableUpcomingFeature("MemberImportVisibility"), - // SE-0413: Typed throws - .enableUpcomingFeature("FullTypedThrows"), ] let package = Package( diff --git a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationKeysTests.swift b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationKeysTests.swift index bcdbef85..3a0a7115 100644 --- a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationKeysTests.swift +++ b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationKeysTests.swift @@ -28,7 +28,7 @@ // internal import ConfigKeyKit -import Testing +internal import Testing @testable import MistKitConfiguration diff --git a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationReadingTests.swift b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationReadingTests.swift index 3484a50a..329df99e 100644 --- a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationReadingTests.swift +++ b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationReadingTests.swift @@ -30,7 +30,7 @@ internal import ConfigKeyKit internal import Configuration internal import MistKit -import Testing +internal import Testing @testable import MistKitConfiguration diff --git a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationTests.swift b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationTests.swift index 1ddc2781..b6ab78fa 100644 --- a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationTests.swift +++ b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationTests.swift @@ -28,7 +28,7 @@ // internal import MistKit -import Testing +internal import Testing @testable import MistKitConfiguration diff --git a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/ConfigurationSourcesTests.swift b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/ConfigurationSourcesTests.swift index 77c4f8be..3499445c 100644 --- a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/ConfigurationSourcesTests.swift +++ b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/ConfigurationSourcesTests.swift @@ -28,7 +28,7 @@ // internal import Configuration -import Testing +internal import Testing @testable import MistKitConfiguration diff --git a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/KeyIDValidatorTests.swift b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/KeyIDValidatorTests.swift index c0b44f85..a1d35528 100644 --- a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/KeyIDValidatorTests.swift +++ b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/KeyIDValidatorTests.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Testing +internal import Testing @testable import MistKitConfiguration diff --git a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/PEMValidatorTests.swift b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/PEMValidatorTests.swift index c973a745..2d3615eb 100644 --- a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/PEMValidatorTests.swift +++ b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/PEMValidatorTests.swift @@ -28,7 +28,7 @@ // internal import Foundation -import Testing +internal import Testing @testable import MistKitConfiguration diff --git a/Scripts/lint.sh b/Scripts/lint.sh index 6246245c..16e060c4 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -3,17 +3,180 @@ # Remove set -e to allow script to continue running # set -e # Exit on any error +# Report mode (read-only, structured output for humans and agents): +# LINT_REPORT=1 human summary on stderr + JSON between delimiter markers +# LINT_REPORT=json JSON report only (delimiters on stdout) +# +# Exit code is non-zero when any pipeline step fails. Each lint tool runs with +# --strict so warnings/findings fail their step. summary.totalFindings in the +# JSON counts individual findings across tools (must be 0 for a clean run). + ERRORS=0 +FAILED_STEP_NAMES=() +LINT_REPORT_MODE=0 +LINT_REPORT_OUTPUT="" +REPORT_DIR="" +MANIFEST_PATH="" run_command() { - "$@" || ERRORS=$((ERRORS + 1)) + "$@" || ERRORS=$((ERRORS + 1)) +} + +record_failed_step() { + local step="$1" + FAILED_STEP_NAMES+=("$step") +} + +log_status() { + if [ "$LINT_REPORT_MODE" -eq 1 ]; then + echo "$@" >&2 + else + echo "$@" + fi +} + +run_report_step() { + local step="$1" + shift + local exit_code=0 + + "$@" + exit_code=$? + + if [ "$exit_code" -ne 0 ]; then + ERRORS=$((ERRORS + 1)) + record_failed_step "$step" + fi + + printf '%s' "$exit_code" >"$REPORT_DIR/${step}.exit" + return "$exit_code" +} + +init_report_mode() { + case "${LINT_REPORT:-}" in + 1 | yes | true | TRUE | YES) + LINT_REPORT_OUTPUT=both + LINT_REPORT_MODE=1 + ;; + json | JSON) + LINT_REPORT_OUTPUT=json + LINT_REPORT_MODE=1 + ;; + *) + LINT_REPORT_OUTPUT="" + LINT_REPORT_MODE=0 + ;; + esac + + if [ "$LINT_REPORT_MODE" -eq 1 ]; then + REPORT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/mistkit-lint-report.XXXXXX") + MANIFEST_PATH="$REPORT_DIR/manifest.json" + fi +} + +cleanup_report_mode() { + if [ -n "$REPORT_DIR" ] && [ -d "$REPORT_DIR" ]; then + rm -rf "$REPORT_DIR" + fi +} + +write_manifest() { + local swiftlint_skipped="${1:-0}" + local swiftlint_skip_reason="${2:-}" + local periphery_skipped="${3:-0}" + local periphery_skip_reason="${4:-}" + local swift_build_skipped="${5:-0}" + local swift_build_skip_reason="${6:-}" + local failed_steps_csv="" + + if [ "${#FAILED_STEP_NAMES[@]}" -gt 0 ]; then + local IFS=, + failed_steps_csv="${FAILED_STEP_NAMES[*]}" + fi + + REPORT_DIR="$REPORT_DIR" \ + MANIFEST_PATH="$MANIFEST_PATH" \ + LINT_REPORT_OUTPUT="$LINT_REPORT_OUTPUT" \ + FAILED_STEPS_CSV="$failed_steps_csv" \ + SWIFTLINT_SKIPPED="$swiftlint_skipped" \ + SWIFTLINT_SKIP_REASON="$swiftlint_skip_reason" \ + PERIPHERY_SKIPPED="$periphery_skipped" \ + PERIPHERY_SKIP_REASON="$periphery_skip_reason" \ + SWIFT_BUILD_SKIPPED="$swift_build_skipped" \ + SWIFT_BUILD_SKIP_REASON="$swift_build_skip_reason" \ + python3 - <<'PY' +import json +import os +from pathlib import Path + +report_dir = Path(os.environ["REPORT_DIR"]) +manifest_path = Path(os.environ["MANIFEST_PATH"]) +failed_steps = [ + step for step in os.environ.get("FAILED_STEPS_CSV", "").split(",") if step +] + + +def read_exit(step: str) -> int | None: + exit_path = report_dir / f"{step}.exit" + if not exit_path.is_file(): + return None + return int(exit_path.read_text(encoding="utf-8")) + + +manifest = { + "reportDir": str(report_dir), + "outputFormat": os.environ["LINT_REPORT_OUTPUT"], + "failedSteps": failed_steps, + "steps": { + "swift-format": {"exitCode": read_exit("swift-format")}, + "swiftlint": { + "skipped": os.environ.get("SWIFTLINT_SKIPPED") == "1", + "skipReason": os.environ.get("SWIFTLINT_SKIP_REASON") or None, + "exitCode": read_exit("swiftlint"), + }, + "swift-build": { + "skipped": os.environ.get("SWIFT_BUILD_SKIPPED") == "1", + "skipReason": os.environ.get("SWIFT_BUILD_SKIP_REASON") or None, + "exitCode": read_exit("swift-build"), + }, + "periphery": { + "skipped": os.environ.get("PERIPHERY_SKIPPED") == "1", + "skipReason": os.environ.get("PERIPHERY_SKIP_REASON") or None, + "exitCode": read_exit("periphery"), + }, + }, +} +manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") +PY +} + +emit_lint_report() { + local swiftlint_skipped="$1" + local swiftlint_skip_reason="$2" + local periphery_skipped="$3" + local periphery_skip_reason="$4" + local swift_build_skipped="$5" + local swift_build_skip_reason="$6" + + write_manifest \ + "$swiftlint_skipped" "$swiftlint_skip_reason" \ + "$periphery_skipped" "$periphery_skip_reason" \ + "$swift_build_skipped" "$swift_build_skip_reason" + + python3 "$PACKAGE_DIR/.claude/skills/fix-lint/scripts/compile-lint-report.py" "$MANIFEST_PATH" } if [ "$LINT_MODE" = "INSTALL" ]; then exit fi -echo "LintMode: $LINT_MODE" +init_report_mode +trap cleanup_report_mode EXIT + +echo "LintMode: $LINT_MODE" >&2 +if [ "$LINT_REPORT_MODE" -eq 1 ]; then + echo "LintReport: $LINT_REPORT_OUTPUT (read-only)" >&2 +fi # More portable way to get script directory if [ -z "$SRCROOT" ]; then @@ -44,35 +207,69 @@ fi if [ "$LINT_MODE" = "NONE" ]; then exit -elif [ "$LINT_MODE" = "STRICT" ]; then - SWIFTFORMAT_OPTIONS="--configuration .swift-format" - SWIFTLINT_OPTIONS="--strict" -else - SWIFTFORMAT_OPTIONS="--configuration .swift-format" - SWIFTLINT_OPTIONS="" fi -pushd "$PACKAGE_DIR" || exit +SWIFTFORMAT_FORMAT_OPTIONS="--configuration .swift-format" +SWIFTFORMAT_LINT_OPTIONS="--configuration .swift-format --strict" +SWIFTLINT_OPTIONS="--strict" +PERIPHERY_OPTIONS="--strict" -if [ -z "$CI" ]; then - run_command swift-format format $SWIFTFORMAT_OPTIONS --recursive --parallel --in-place Sources Tests +SWIFTLINT_SKIP_REASON="" +PERIPHERY_SKIP_REASON="" +SWIFT_BUILD_SKIP_REASON="" + +pushd "$PACKAGE_DIR" >/dev/null || exit + +if [ -z "$CI" ] && [ "$LINT_REPORT_MODE" -eq 0 ]; then + run_command swift-format format $SWIFTFORMAT_FORMAT_OPTIONS --recursive --parallel --in-place Sources Tests if [ "$RUN_SWIFTLINT" -eq 1 ]; then run_command swiftlint --fix fi fi -if [ -z "$FORMAT_ONLY" ]; then - run_command swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests +if [ -z "$FORMAT_ONLY" ] || [ "$LINT_REPORT_MODE" -eq 1 ]; then + if [ "$LINT_REPORT_MODE" -eq 1 ]; then + run_report_step swift-format \ + swift-format lint --recursive --parallel \ + $SWIFTFORMAT_LINT_OPTIONS Sources Tests \ + >"$REPORT_DIR/swift-format.log" 2>&1 + else + run_command swift-format lint --recursive --parallel \ + $SWIFTFORMAT_LINT_OPTIONS Sources Tests + fi + if [ "$RUN_SWIFTLINT" -eq 1 ]; then - run_command swiftlint lint $SWIFTLINT_OPTIONS + if [ "$LINT_REPORT_MODE" -eq 1 ]; then + run_report_step swiftlint \ + swiftlint lint --quiet --reporter json $SWIFTLINT_OPTIONS \ + >"$REPORT_DIR/swiftlint.json" 2>"$REPORT_DIR/swiftlint.stderr" + else + run_command swiftlint lint $SWIFTLINT_OPTIONS + fi else - echo "Skipping SwiftLint (Claude Code web session)." + SWIFTLINT_SKIP_REASON="Claude Code web session" + if [ "$LINT_REPORT_MODE" -eq 0 ]; then + echo "Skipping SwiftLint (Claude Code web session)." + fi + fi + + if [ "$LINT_REPORT_MODE" -eq 1 ]; then + run_report_step swift-build \ + swift build --build-tests \ + >"$REPORT_DIR/swift-build.log" 2>&1 + else + run_command swift build --build-tests fi - # Check for compilation errors - run_command swift build --build-tests fi -$PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "BrightDigit" -p "MistKit" +if [ "$LINT_REPORT_MODE" -eq 1 ]; then + if ! "$PACKAGE_DIR/Scripts/header.sh" -d "$PACKAGE_DIR/Sources" -c "Leo Dion" -o "BrightDigit" -p "MistKit" >&2; then + ERRORS=$((ERRORS + 1)) + record_failed_step header + fi +else + "$PACKAGE_DIR/Scripts/header.sh" -d "$PACKAGE_DIR/Sources" -c "Leo Dion" -o "BrightDigit" -p "MistKit" +fi # Generated files now automatically include ignore directives via OpenAPI generator configuration @@ -98,25 +295,60 @@ periphery_index_store() { return 1 } -if [ -z "$CI" ] && [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then +if { [ -z "$FORMAT_ONLY" ] || [ "$LINT_REPORT_MODE" -eq 1 ]; } \ + && [ -z "$CI" ] && [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then if INDEX_STORE_PATH=$(periphery_index_store); then - run_command periphery scan $PERIPHERY_OPTIONS \ - --index-store-path "$INDEX_STORE_PATH" --skip-build \ - --disable-update-check + if [ "$LINT_REPORT_MODE" -eq 1 ]; then + run_report_step periphery \ + periphery scan $PERIPHERY_OPTIONS \ + --index-store-path "$INDEX_STORE_PATH" --skip-build \ + --disable-update-check --format json --quiet \ + >"$REPORT_DIR/periphery.json" 2>"$REPORT_DIR/periphery.stderr" + else + run_command periphery scan $PERIPHERY_OPTIONS \ + --index-store-path "$INDEX_STORE_PATH" --skip-build \ + --disable-update-check + fi else - echo "Skipping periphery scan (no index store under .build; run swift build first)." + PERIPHERY_SKIP_REASON="no index store under .build; run swift build first" + if [ "$LINT_REPORT_MODE" -eq 0 ]; then + echo "Skipping periphery scan ($PERIPHERY_SKIP_REASON)." + fi fi else - echo "Skipping periphery scan (CI or Claude Code web session)." + if [ -n "$CI" ]; then + PERIPHERY_SKIP_REASON="CI" + elif [ "${CLAUDE_CODE_REMOTE:-}" = "true" ]; then + PERIPHERY_SKIP_REASON="Claude Code web session" + fi + if [ "$LINT_REPORT_MODE" -eq 0 ]; then + echo "Skipping periphery scan (${PERIPHERY_SKIP_REASON:-CI or Claude Code web session})." + fi +fi + +if [ "$LINT_REPORT_MODE" -eq 1 ]; then + swiftlint_skipped=0 + if [ "$RUN_SWIFTLINT" -eq 0 ]; then + swiftlint_skipped=1 + fi + periphery_skipped=0 + if [ -n "$PERIPHERY_SKIP_REASON" ]; then + periphery_skipped=1 + fi + swift_build_skipped=0 + emit_lint_report \ + "$swiftlint_skipped" "$SWIFTLINT_SKIP_REASON" \ + "$periphery_skipped" "$PERIPHERY_SKIP_REASON" \ + "$swift_build_skipped" "$SWIFT_BUILD_SKIP_REASON" fi -popd +popd >/dev/null # Exit with error code if any errors occurred if [ $ERRORS -gt 0 ]; then - echo "Linting completed with $ERRORS error(s)" + log_status "Linting completed with $ERRORS error(s)" exit 1 else - echo "Linting completed successfully" + log_status "Linting completed successfully" exit 0 fi diff --git a/Sources/MistKit/CloudKitService/CloudKitService.swift b/Sources/MistKit/CloudKitService/CloudKitService.swift index 390c55fa..b9dde402 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService.swift @@ -54,8 +54,10 @@ internal import OpenAPIRuntime /// `fetchCaller` via web-auth from one fully-populated `Credentials`. public struct CloudKitService: Sendable { // swift-format-ignore: NeverForceUnwrap + // swiftlint:disable force_unwrapping /// The base URL for CloudKit Web Services. public static let baseURL = URL(string: "https://api.apple-cloudkit.com")! + // swiftlint:enable force_unwrapping /// CloudKit's maximum number of items (records, lookups, or operations) /// accepted or returned per batch request. The auto-chunking convenience diff --git a/Sources/MistKit/Models/Sharing/CreatedShare.swift b/Sources/MistKit/Models/Sharing/CreatedShare.swift index 423050d1..5d06b725 100644 --- a/Sources/MistKit/Models/Sharing/CreatedShare.swift +++ b/Sources/MistKit/Models/Sharing/CreatedShare.swift @@ -36,10 +36,12 @@ public import Foundation /// rather than on ``RecordInfo``, which models a plain record. public struct CreatedShare: Sendable { // swift-format-ignore: NeverForceUnwrap + // swiftlint:disable force_unwrapping /// Base URL for iCloud share invite links (`https://www.icloud.com/share`). /// /// Append a ``ShortGUID`` path component to build a full invite URL. public static let shareURLBase = URL(string: "https://www.icloud.com/share")! + // swiftlint:enable force_unwrapping /// The short GUID CloudKit assigned to the share (and shared root). public let shortGUID: ShortGUID diff --git a/Sources/MistKit/Models/Sharing/ShareTargetReference.swift b/Sources/MistKit/Models/Sharing/ShareTargetReference.swift index 3aeda54c..7fa94096 100644 --- a/Sources/MistKit/Models/Sharing/ShareTargetReference.swift +++ b/Sources/MistKit/Models/Sharing/ShareTargetReference.swift @@ -45,11 +45,6 @@ public struct ShareTargetReference: Codable, Sendable, Equatable, Hashable { self.recordName = recordName self.recordChangeTag = recordChangeTag } - - internal init(from schema: Components.Schemas.ShareTargetReference) { - self.recordName = schema.recordName - self.recordChangeTag = schema.recordChangeTag - } } extension Components.Schemas.ShareTargetReference { diff --git a/Sources/MistKit/Models/Zones/ZoneChangeResult.swift b/Sources/MistKit/Models/Zones/ZoneChangeResult.swift index 42c4ae83..17008eab 100644 --- a/Sources/MistKit/Models/Zones/ZoneChangeResult.swift +++ b/Sources/MistKit/Models/Zones/ZoneChangeResult.swift @@ -48,7 +48,7 @@ extension OperationResult where Success == ZoneInfo, Target == ZoneTarget { self = .success( try ZoneInfo( fromZoneID: zone.zoneID, - deleted: zone.deleted ?? false + deleted: zone.deleted ) ) } diff --git a/Sources/MistKit/Models/Zones/ZoneInfo.swift b/Sources/MistKit/Models/Zones/ZoneInfo.swift index 002a319f..e2ffddb6 100644 --- a/Sources/MistKit/Models/Zones/ZoneInfo.swift +++ b/Sources/MistKit/Models/Zones/ZoneInfo.swift @@ -55,7 +55,11 @@ public struct ZoneInfo: Codable, Sendable { public let atomic: Bool? /// When `true`, this is a tombstone entry from a zone change feed — /// the zone was deleted and should be removed from local storage. - public let deleted: Bool + /// + /// `nil` when the server omits the key — deliberately *not* defaulted to + /// `false`, so list/lookup responses stay distinguishable from change-feed + /// tombstones. + public let deleted: Bool? /// Initialize zone information public init( @@ -65,7 +69,7 @@ public struct ZoneInfo: Codable, Sendable { zoneType: ZoneType? = nil, syncToken: String? = nil, atomic: Bool? = nil, - deleted: Bool = false + deleted: Bool? = nil ) { self.zoneName = zoneName self.ownerRecordName = ownerRecordName @@ -93,7 +97,7 @@ public struct ZoneInfo: Codable, Sendable { fromZoneID zoneID: Components.Schemas.ZoneID?, syncToken: String? = nil, atomic: Bool? = nil, - deleted: Bool = false + deleted: Bool? = nil ) throws(ConversionError) { guard let zoneID else { try ConversionError.zoneMissingID.reportAndThrow() @@ -119,7 +123,7 @@ public struct ZoneInfo: Codable, Sendable { fromZoneID: zone.zoneID, syncToken: zone.syncToken, atomic: zone.atomic, - deleted: zone.deleted ?? false + deleted: zone.deleted ) } } diff --git a/Tests/MistKitTests/Models/Notifications/CourierTests.swift b/Tests/MistKitTests/Models/Notifications/CourierTests.swift index 8d8c87e7..0fba4350 100644 --- a/Tests/MistKitTests/Models/Notifications/CourierTests.swift +++ b/Tests/MistKitTests/Models/Notifications/CourierTests.swift @@ -74,5 +74,74 @@ let notification = try #require(result) #expect(notification.reason == .recordCreated) } + @Test("notifications yields a decoded notification from the stream") + internal func notificationsYieldsDecodedNotification() async throws { + let url = try Self.courierURL() + let body = #"{"ck":{"qry":{"fo":1,"sid":"s","rid":"r"}}}"# + actor PollCounter { + private(set) var count = 0 + func increment() -> Int { + count += 1 + return count + } + } + let polls = PollCounter() + let transport: Courier.Transport = { _, _ in + let pollCount = await polls.increment() + if pollCount == 1 { + return (statusCode: 200, data: Data(body.utf8)) + } + // Would hang the test if Courier.notifications keeps polling after cancel. + // nanoseconds API: package deployment target is below iOS 16 / Duration clocks. + try await Task.sleep(nanoseconds: 60_000_000_000) + return (statusCode: 200, data: Data()) + } + + actor FirstNotificationGate { + private var notification: CourierNotification? + private var continuation: CheckedContinuation? + + func store(_ notification: CourierNotification) { + self.notification = notification + continuation?.resume() + continuation = nil + } + + func waitForFirst() async { + if notification != nil { + return + } + await withCheckedContinuation { continuation = $0 } + } + + func firstNotification() -> CourierNotification? { + notification + } + } + + let stream = Courier.notifications(courierURL: url, perPollTimeout: 1, transport: transport) + let gate = FirstNotificationGate() + let consumeTask = Task { + do { + for try await notification in stream { + await gate.store(notification) + } + } catch { + // Expected when cancellation tears down the stream mid-poll. + } + } + defer { consumeTask.cancel() } + + await gate.waitForFirst() + // Cancel while blocked on the next stream element so onTermination stops polling. + consumeTask.cancel() + + let decoded = try #require(await gate.firstNotification()) + #expect(decoded.reason == CourierNotification.Reason.recordCreated) + + try await Task.sleep(nanoseconds: 100_000_000) + let finalCount = await polls.count + #expect(finalCount <= 2) + } } #endif diff --git a/Tests/MistKitTests/Models/Subscriptions/NotificationInfoTests.swift b/Tests/MistKitTests/Models/Subscriptions/NotificationInfoTests.swift new file mode 100644 index 00000000..36e00458 --- /dev/null +++ b/Tests/MistKitTests/Models/Subscriptions/NotificationInfoTests.swift @@ -0,0 +1,77 @@ +// +// NotificationInfoTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI +internal import Testing + +@testable import MistKit + +@Suite("NotificationInfo Conversion") +internal struct NotificationInfoTests { + @Test("NotificationInfo round-trips through the OpenAPI schema") + internal func roundTrip() { + let info = NotificationInfo( + alertBody: "Hello", + alertLocalizationKey: "GREETING", + alertLocalizationArgs: ["Leo"], + soundName: "default", + shouldBadge: true, + shouldSendContentAvailable: false, + additionalFields: ["title"], + category: "article" + ) + + let schema = info.schema + #expect(schema.alertBody == "Hello") + #expect(schema.alertLocalizationKey == "GREETING") + #expect(schema.alertLocalizationArgs == ["Leo"]) + #expect(schema.soundName == "default") + #expect(schema.shouldBadge == true) + #expect(schema.shouldSendContentAvailable == false) + #expect(schema.additionalFields == ["title"]) + #expect(schema.category == "article") + + let recovered = NotificationInfo(from: schema) + #expect(recovered == info) + } + + @Test("Absent notification flags stay nil through the schema") + internal func absentFlagsStayNil() { + let info = NotificationInfo(alertBody: "Ping") + let schema = info.schema + + #expect(schema.shouldBadge == nil) + #expect(schema.shouldSendContentAvailable == nil) + + let recovered = NotificationInfo(from: schema) + #expect(recovered.shouldBadge == nil) + #expect(recovered.shouldSendContentAvailable == nil) + #expect(recovered.alertBody == "Ping") + } +} diff --git a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift index c33c121e..98520bd6 100644 --- a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift +++ b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift @@ -77,7 +77,7 @@ extension ZoneMetadataTests { #expect(info.ownerRecordName == "_defaultOwner") #expect(info.syncToken == "AQAAAAAAAAAB") #expect(info.atomic == true) - #expect(info.deleted == false) + #expect(info.deleted == nil) } @Test( @@ -105,8 +105,8 @@ extension ZoneMetadataTests { #expect(info.deleted == true) } - @Test("Absent deleted defaults to false rather than collapsing into nil") - internal func absentDeletedDefaultsFalse() throws { + @Test("Absent deleted stays nil rather than defaulting to false") + internal func absentDeletedStaysNil() throws { let zone = try Self.decodeZone( """ { @@ -117,7 +117,7 @@ extension ZoneMetadataTests { let info = try ZoneInfo(from: zone) - #expect(info.deleted == false) + #expect(info.deleted == nil) } @Test("DatabaseChangedZone tombstone surfaces deleted on ZoneInfo") @@ -147,6 +147,12 @@ extension ZoneMetadataTests { #expect(zone.deleted == true) } + @Test("unrecognizedZoneType exposes a localized description") + internal func unrecognizedZoneTypeDescription() { + let error = ConversionError.unrecognizedZoneType("SHARED_ZONE") + #expect(error.errorDescription == "Zone entry has unrecognized zoneType 'SHARED_ZONE'") + } + @Test("Unrecognized zoneType throws ConversionError") internal func unrecognizedZoneTypeThrows() throws { let zone = try Self.decodeZone( @@ -202,6 +208,7 @@ extension ZoneMetadataTests { // an explicit `false`. #expect(info.syncToken == nil) #expect(info.atomic == nil) + #expect(info.deleted == nil) #expect(info.zoneName == "Articles") } } diff --git a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversionEdgeCases.swift b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversionEdgeCases.swift index 1a24e866..387200d3 100644 --- a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversionEdgeCases.swift +++ b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversionEdgeCases.swift @@ -51,7 +51,7 @@ extension ZoneMetadataTests { let info = try ZoneInfo(from: zone) - #expect(try #require(info.atomic) == false) + #expect(info.atomic == false) } @Test("ZoneInfo still throws when the zone payload has no zoneName") diff --git a/Tests/MistKitTests/OpenAPI/CloudKitResponseTypeTests.swift b/Tests/MistKitTests/OpenAPI/CloudKitResponseTypeTests.swift new file mode 100644 index 00000000..86db0166 --- /dev/null +++ b/Tests/MistKitTests/OpenAPI/CloudKitResponseTypeTests.swift @@ -0,0 +1,163 @@ +// +// CloudKitResponseTypeTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKitOpenAPI +internal import OpenAPIRuntime +internal import Testing + +@testable import MistKit + +@Suite("CloudKit Response Mapping") +internal struct CloudKitResponseTypeTests { + private typealias ServerCode = Components.Schemas.ErrorResponse.serverErrorCodePayload + + private static func undocumentedPayload() -> UndocumentedPayload { + UndocumentedPayload(body: HTTPBody(Data())) + } + + private static func sampleFailure( + code: ServerCode = .BAD_REQUEST, + reason: String = "failure" + ) -> Components.Responses.Failure { + Components.Responses.Failure( + body: .json( + .init( + serverErrorCode: code, + reason: reason + ) + ) + ) + } + + private static func assertMapsToStatusCode( + _ output: T, + statusCode: Int + ) { + guard let mapped = output.toCloudKitError() else { + Issue.record("expected non-nil CloudKitError for HTTP \(statusCode)") + return + } + #expect(mapped.httpStatusCode == statusCode) + } + + @Test("listZones maps documented HTTP failures") + internal func listZonesMapsFailures() { + let mappings: [(() -> Operations.listZones.Output, Int)] = [ + ({ .badRequest(Self.sampleFailure(code: .BAD_REQUEST)) }, 400), + ({ .unauthorized(Self.sampleFailure(code: .AUTHENTICATION_FAILED)) }, 401), + ({ .forbidden(Self.sampleFailure(code: .ACCESS_DENIED)) }, 403), + ({ .notFound(Self.sampleFailure(code: .NOT_FOUND)) }, 404), + ({ .conflict(Self.sampleFailure(code: .CONFLICT)) }, 409), + ( + { + .preconditionFailed(Self.sampleFailure(code: .VALIDATING_REFERENCE_ERROR)) + }, 412 + ), + ({ .contentTooLarge(Self.sampleFailure(code: .QUOTA_EXCEEDED)) }, 413), + ( + { + .misdirectedRequest(Self.sampleFailure(code: .AUTHENTICATION_REQUIRED)) + }, 421 + ), + ({ .tooManyRequests(Self.sampleFailure(code: .THROTTLED)) }, 429), + ({ .internalServerError(Self.sampleFailure(code: .INTERNAL_ERROR)) }, 500), + ({ .serviceUnavailable(Self.sampleFailure(code: .TRY_AGAIN_LATER)) }, 503), + ({ .undocumented(statusCode: 418, Self.undocumentedPayload()) }, 418), + ] + for (makeOutput, statusCode) in mappings { + Self.assertMapsToStatusCode(makeOutput(), statusCode: statusCode) + } + } + + @Test("lookupZones maps documented HTTP failures") + internal func lookupZonesMapsFailures() { + Self.assertMapsToStatusCode( + Operations.lookupZones.Output.badRequest(Self.sampleFailure(code: .BAD_REQUEST)), + statusCode: 400 + ) + Self.assertMapsToStatusCode( + Operations.lookupZones.Output.unauthorized(Self.sampleFailure(code: .AUTHENTICATION_FAILED)), + statusCode: 401 + ) + Self.assertMapsToStatusCode( + Operations.lookupZones.Output.undocumented(statusCode: 418, Self.undocumentedPayload()), + statusCode: 418 + ) + } + + @Test("modifyZones maps documented HTTP failures") + internal func modifyZonesMapsFailures() { + Self.assertMapsToStatusCode( + Operations.modifyZones.Output.badRequest(Self.sampleFailure(code: .BAD_REQUEST)), + statusCode: 400 + ) + Self.assertMapsToStatusCode( + Operations.modifyZones.Output.unauthorized(Self.sampleFailure(code: .AUTHENTICATION_FAILED)), + statusCode: 401 + ) + Self.assertMapsToStatusCode( + Operations.modifyZones.Output.undocumented(statusCode: 418, Self.undocumentedPayload()), + statusCode: 418 + ) + } + + @Test("subscription list/lookup/modify outputs map HTTP failures") + internal func subscriptionOutputsMapFailures() { + Self.assertMapsToStatusCode( + Operations.listSubscriptions.Output.unauthorized( + Self.sampleFailure(code: .AUTHENTICATION_FAILED) + ), + statusCode: 401 + ) + Self.assertMapsToStatusCode( + Operations.lookupSubscriptions.Output.badRequest(Self.sampleFailure(code: .BAD_REQUEST)), + statusCode: 400 + ) + Self.assertMapsToStatusCode( + Operations.modifySubscriptions.Output.badRequest(Self.sampleFailure(code: .BAD_REQUEST)), + statusCode: 400 + ) + } + + @Test("fetchRecordChanges maps documented HTTP failures") + internal func fetchRecordChangesMapsFailures() { + Self.assertMapsToStatusCode( + Operations.fetchRecordChanges.Output.tooManyRequests(Self.sampleFailure(code: .THROTTLED)), + statusCode: 429 + ) + Self.assertMapsToStatusCode( + Operations.fetchRecordChanges.Output.undocumented( + statusCode: 418, + Self.undocumentedPayload() + ), + statusCode: 418 + ) + } +} diff --git a/Tests/MistKitTests/RecordManagement/AltTestRecord.swift b/Tests/MistKitTests/RecordManagement/AltTestRecord.swift new file mode 100644 index 00000000..d38fdf73 --- /dev/null +++ b/Tests/MistKitTests/RecordManagement/AltTestRecord.swift @@ -0,0 +1,56 @@ +// +// AltTestRecord.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +@testable import MistKit + +/// Second CloudKit record type for collection-operation tests. +internal struct AltTestRecord: CloudKitRecord { + internal static var cloudKitRecordType: String { "AltTestRecord" } + + internal var recordName: String + internal var title: String + + internal static func from(recordInfo: RecordInfo) -> AltTestRecord? { + guard let title = recordInfo.fields["title"]?.stringValue else { + return nil + } + return AltTestRecord(recordName: recordInfo.recordName, title: title) + } + + internal static func formatForDisplay(_ recordInfo: RecordInfo) -> String { + let title = recordInfo.fields["title"]?.stringValue ?? "Unknown" + return " \(recordInfo.recordName): \(title)" + } + + internal func toCloudKitFields() -> [String: FieldValue] { + ["title": .string(title)] + } +} diff --git a/Tests/MistKitTests/RecordManagement/MockCollectionRecordManagingService.swift b/Tests/MistKitTests/RecordManagement/MockCollectionRecordManagingService.swift new file mode 100644 index 00000000..f8fa7a12 --- /dev/null +++ b/Tests/MistKitTests/RecordManagement/MockCollectionRecordManagingService.swift @@ -0,0 +1,58 @@ +// +// MockCollectionRecordManagingService.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +@testable import MistKit + +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +internal actor MockCollectionRecordManagingService: RecordManaging, CloudKitRecordCollection { + internal static let recordTypes = RecordTypeSet(TestRecord.self, AltTestRecord.self) + + internal var queryCallCount = 0 + internal var executeCallCount = 0 + internal var lastExecutedOperations: [RecordOperation] = [] + internal var batchSizes: [Int] = [] + internal var recordsByType: [String: [RecordInfo]] = [:] + + internal func queryAllRecords(recordType: String) async throws -> [RecordInfo] { + queryCallCount += 1 + return recordsByType[recordType] ?? [] + } + + internal func executeBatchOperations(_ operations: [RecordOperation]) async throws { + executeCallCount += 1 + batchSizes.append(operations.count) + lastExecutedOperations.append(contentsOf: operations) + } + + internal func setRecords(_ records: [RecordInfo], forRecordType recordType: String) { + recordsByType[recordType] = records + } +} diff --git a/Tests/MistKitTests/RecordManagement/RecordManagingTests+RecordCollection.swift b/Tests/MistKitTests/RecordManagement/RecordManagingTests+RecordCollection.swift new file mode 100644 index 00000000..49d042c7 --- /dev/null +++ b/Tests/MistKitTests/RecordManagement/RecordManagingTests+RecordCollection.swift @@ -0,0 +1,123 @@ +// +// RecordManagingTests+RecordCollection.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import Testing + +@testable import MistKit + +extension RecordManagingTests { + @Suite("Record Collection Operations") + internal struct RecordCollection { + @Test("syncAllRecords batches each non-empty record type") + internal func syncAllRecordsBatchesPerType() async throws { + guard #available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) else { + Issue.record("Record collection operations are not available on this operating system.") + return + } + + let service = MockCollectionRecordManagingService() + let testRecords = [ + TestRecord( + recordName: "test-1", + name: "One", + count: 1, + isActive: true, + score: nil, + lastUpdated: nil + ) + ] + let altRecords = [ + AltTestRecord(recordName: "alt-1", title: "Alt One"), + AltTestRecord(recordName: "alt-2", title: "Alt Two"), + ] + + try await service.syncAllRecords(testRecords, altRecords) + + let executeCount = await service.executeCallCount + let batchSizes = await service.batchSizes + let operations = await service.lastExecutedOperations + + #expect(executeCount == 2) + #expect(batchSizes == [1, 2]) + #expect(operations.count == 3) + #expect(operations.filter { $0.recordType == "TestRecord" }.count == 1) + #expect(operations.filter { $0.recordType == "AltTestRecord" }.count == 2) + #expect(operations.allSatisfy { $0.operationType == .forceReplace }) + } + + @Test("deleteAllRecords issues delete operations for every managed type") + internal func deleteAllRecordsDeletesAcrossTypes() async throws { + guard #available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) else { + Issue.record("Record collection operations are not available on this operating system.") + return + } + + let service = MockCollectionRecordManagingService() + await service.setRecords( + [RecordInfo(recordName: "test-1", recordType: "TestRecord", fields: [:])], + forRecordType: "TestRecord" + ) + await service.setRecords( + [RecordInfo(recordName: "alt-1", recordType: "AltTestRecord", fields: [:])], + forRecordType: "AltTestRecord" + ) + + try await service.deleteAllRecords() + + let executeCount = await service.executeCallCount + let operations = await service.lastExecutedOperations + let queryCount = await service.queryCallCount + + #expect(queryCount == 2) + #expect(executeCount == 2) + #expect(operations.count == 2) + #expect(operations.allSatisfy { $0.operationType == .delete }) + } + + @Test("listAllRecords queries every managed type") + internal func listAllRecordsQueriesEveryType() async throws { + guard #available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) else { + Issue.record("Record collection operations are not available on this operating system.") + return + } + + let service = MockCollectionRecordManagingService() + await service.setRecords( + [RecordInfo(recordName: "test-1", recordType: "TestRecord", fields: [:])], + forRecordType: "TestRecord" + ) + + try await service.listAllRecords() + + let queryCount = await service.queryCallCount + #expect(queryCount == 2) + } + } +} diff --git a/Tests/MistKitTests/RecordManagement/RecordTypeSetTests.swift b/Tests/MistKitTests/RecordManagement/RecordTypeSetTests.swift new file mode 100644 index 00000000..450024ab --- /dev/null +++ b/Tests/MistKitTests/RecordManagement/RecordTypeSetTests.swift @@ -0,0 +1,53 @@ +// +// RecordTypeSetTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Testing + +@testable import MistKit + +@Suite("RecordTypeSet") +internal struct RecordTypeSetTests { + @Test("forEach visits every record type in the pack") + internal func forEachVisitsAllTypes() async { + guard #available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) else { + Issue.record("RecordTypeSet is not available on this operating system.") + return + } + + let recordTypes = RecordTypeSet(TestRecord.self, AltTestRecord.self) + var visited: [String] = [] + + // swift-format-ignore: ReplaceForEachWithForLoop + await recordTypes.forEach { recordType in + visited.append(recordType.cloudKitRecordType) + } + + #expect(visited.sorted() == ["AltTestRecord", "TestRecord"]) + } +} diff --git a/codecov.yml b/codecov.yml index d3d2ff4f..43bbfc8d 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,3 +9,4 @@ ignore: - "Tests" - "Sources/MistKitOpenAPI" - "Examples" + - "Packages/MistKitConfiguration" From 6a86bf1dacd4720893f4fe81a5443697d41fbcc9 Mon Sep 17 00:00:00 2001 From: leogdion Date: Wed, 2 Sep 2026 17:16:31 -0400 Subject: [PATCH 13/25] Consume CloudKit web auth token rotation from response headers (#462, #463) --- .../research/windows-6.2-ci-failure-462.md | 50 ++++ .claude/memory/MEMORY.md | 4 +- ..._examples_workflow_tracks_tools_version.md | 4 +- ...ence_windows_62_mistkittests_emit_abort.md | 11 + .github/workflows/MistDemo.yml | 21 +- .github/workflows/examples.yml | 6 +- Examples/MistDemo/.swift-version | 1 + Examples/MistDemo/Package.swift | 36 +-- Examples/MistDemo/project.yml | 2 +- .../AdaptiveTokenManager+Transitions.swift | 23 ++ .../AuthenticationMiddleware.swift | 13 +- .../HTTPField.Name+CloudKit.swift | 5 + .../RotatedWebAuthTokenFailureReporter.swift | 35 +++ .../TokenManager+Rotation.swift | 37 +++ .../MistKit/Authentication/TokenManager.swift | 4 + .../Authentication/WebAuthTokenManager.swift | 10 +- ...icationMiddlewareTests+TokenRotation.swift | 224 ++++++++++++++++++ .../WebAuthTokenManagerTests+Basic.swift | 12 +- ...WebAuthTokenManagerTests+Performance.swift | 5 +- ...thTokenManagerTests+WebAuthEdgeCases.swift | 5 +- ...erviceTests.CreateZone+ErrorHandling.swift | 2 +- ...erviceTests.DeleteZone+ErrorHandling.swift | 2 +- ...ests.FetchDatabaseChanges+Pagination.swift | 2 +- ...ts.FetchDatabaseChanges+SuccessCases.swift | 2 +- ...chRecordZoneChanges+PaginationLimits.swift | 2 +- ...Tests.FetchZoneChanges+DeprecatedAPI.swift | 83 +++++++ ...Tests.FetchZoneChanges+ErrorHandling.swift | 12 +- ...eTests.FetchZoneChanges+SuccessCases.swift | 23 +- ...iceTests.FetchZoneChanges+Validation.swift | 5 +- ...iceTests.FetchZoneChanges+WireFormat.swift | 18 +- ...hZoneChanges.SuccessCases+Pagination.swift | 13 +- ...rviceTests.ModifyZones+ErrorHandling.swift | 2 +- ...eTests.RecordWriteConvenience+ZoneID.swift | 154 ++++++------ ...erverErrorCodes+ForwardCompatibility.swift | 2 +- ...viceTests.ServerErrorCodes+Roundtrip.swift | 2 +- ...viceTests.Subscriptions+FailureCases.swift | 2 +- ...s.Subscriptions+LikelyDuplicateCases.swift | 2 +- ...dKitServiceTests.Tokens+FailureCases.swift | 6 +- ...KitServiceTests.Upload+ErrorHandling.swift | 8 +- ...iceTests.ZoneOwnerWireKey+WireFormat.swift | 74 +++--- Tests/MistKitTests/Helpers/Platform.swift | 11 + .../MockTokenManagerWithRotation.swift | 47 ++++ .../MockTokenManagerWithRotationFailure.swift | 27 +++ .../FieldValueConversionTests+EdgeCases.swift | 22 +- .../Models/Notifications/CourierTests.swift | 60 ++--- ...ataTests+ZoneInfoConversionEdgeCases.swift | 48 ++-- 46 files changed, 871 insertions(+), 268 deletions(-) create mode 100644 .claude/docs/research/windows-6.2-ci-failure-462.md create mode 100644 .claude/memory/reference_windows_62_mistkittests_emit_abort.md create mode 100644 Examples/MistDemo/.swift-version create mode 100644 Sources/MistKit/Authentication/RotatedWebAuthTokenFailureReporter.swift create mode 100644 Sources/MistKit/Authentication/TokenManager+Rotation.swift create mode 100644 Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests+TokenRotation.swift create mode 100644 Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+DeprecatedAPI.swift create mode 100644 Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRotation.swift create mode 100644 Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRotationFailure.swift diff --git a/.claude/docs/research/windows-6.2-ci-failure-462.md b/.claude/docs/research/windows-6.2-ci-failure-462.md new file mode 100644 index 00000000..5c1f9065 --- /dev/null +++ b/.claude/docs/research/windows-6.2-ci-failure-462.md @@ -0,0 +1,50 @@ +# Windows Swift 6.2 CI failure — `462-web-auth-token-rotation` + +**Date:** 2026-09-02 +**Failing job:** `Build on Windows (windows-2022, swift-6.2-release, 6.2-RELEASE)` +**Primary log:** `/Users/leo/Downloads/windows-fialure.txt` (run `33640879394`) + +## Verdict + +**Swift 6.2 Windows toolchain silently aborts while emitting `MistKitTests`.** Library targets succeed. Same commit is green on Windows 6.1 and 6.3. Tip-over is MistKitTests size/complexity on this branch relative to `main`. Reproducible (not a flake). Closest match: wasm silent exit-1 signature, but toolchain-version-specific. + +## Evidence + +| Run | SHA | Windows 6.2 | +|-----|-----|-------------| +| `33523875168` | `4ccaa624` | fail | +| `33640879394` | `a6c50236` | fail | +| rerun | `a6c50236` | fail (not flake) | +| `33656677094` | `a36fddd` (actor→class) | fail — class workaround did **not** help | +| `33657978639` | `4605cf7`/`77d707a` (Package.swift Windows exclude) | success | + +Dies after compiling MistKitTests sources with exit 1, **no** `error:` / stack dump, and **no** `Emitting module MistKitTests`. On success paths, emit appears then wrap/link. + +## Mitigation (current) + +Keep `@Suite` / `@Test` / mocks always compiled. Omit only each tip-over **test body** on **Windows × Swift 6.2**, with `Issue.record` in the `#else` (Swift Testing `#if canImport(…)` style), plus `.disabled(if: Platform.isWindowsSwift62)` on the `@Suite` so CI does not fail if tests run: + +```swift +@Suite("…", .disabled(if: Platform.isWindowsSwift62)) +internal struct Example { + @Test("…") + internal func example() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + // real body + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } +} +``` + +`Platform.isWindowsSwift62` lives in `Tests/MistKitTests/Helpers/Platform.swift`. Traits alone cannot fix emit-module — the `#if` is load-bearing. + +Windows 6.1/6.3 and all non-Windows platforms still compile and run the real bodies. + +## Dead ends + +- Restoring `WebAuthTokenManager` as a locked class (vs actor) — red herring. +- `Package.swift` `#if os(Windows)` `exclude:` — worked but was broader than needed (all Windows). +- Swift Testing `.disabled(if:)` alone — execution-only; does not shrink emit-module. +- Wrapping whole `@Suite` / files in `#if` — works but coarser than body gates. diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 9f4c91b2..a5b011af 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -14,6 +14,7 @@ Project-scoped agent memory for MistKit. This directory **replaces** any native - [CloudKit archived endpoints not in local docs](reference_cloudkit_archived_endpoints.md) — Verify CloudKit endpoints (e.g. assets/rereference) against Apple's archived reference, not just .claude/docs/webservices.md - [CloudKit Zone Dictionary has exactly 3 keys](reference_cloudkit_zone_dictionary.md) — archived docs: zoneID/syncToken/atomic; live change feeds also carry `deleted` + `zoneID.zoneType`; wire owner key is `ownerRecordName` (issue #444) - [wasm CI failure signatures](reference_wasm_ci_signatures.md) — Two distinct wasm failures: silent exit-1 (OOM on big test target) vs curl exit-7 (SDK download flake, just re-run) +- [Windows 6.2 MistKitTests emit abort](reference_windows_62_mistkittests_emit_abort.md) — Swift 6.2 Windows silent exit-1 while emitting MistKitTests; gate tip-over test bodies with `#if` + `Issue.record` (not actor revert) - [Swift Testing availability guard](feedback_swift_testing_availability.md) — Never annotate @Suite types with @available; use guard #available inside @Test functions instead - [GitHub Action pinning preference](feedback_action_pinning.md) — Use @v for brightdigit-owned actions; pin third-party actions explicitly - [CI Swift matrix preferences](feedback_ci_swift_matrix.md) — Keep Swift 6.1 in full matrix; in-dev Swift branches (6.4 snapshots) ride in the build-ubuntu matrix via an `image` override (ConfigKeyKit pattern), never a separate job @@ -42,8 +43,7 @@ Project-scoped agent memory for MistKit. This directory **replaces** any native - [MISTKIT_BRANCH pin resolves tags too](project_mistkit_branch_pin_resolves_tags.md) — `git ls-remote` matches tags; a tag value silently pins the old release and greens example CI without testing the branch - [MistKit release process](project_release_process.md) — Branch `vX` vs tag `X`; pins invert at release; retain release branches; notes are a flat bullet list for new entries - [Use git trees, not git worktree](feedback_use_git_trees_not_git_worktree.md) — Manage worktrees with `git trees add/rm/list/clean`; `add` pushes to origin unless `--no-push` -- [Examples workflow tracks subrepo tools-version](feedback_examples_workflow_tracks_tools_version.md) — `examples.yml` must use a Swift container that can parse each example's Package.swift (6.4 nightly for Bushel/Celestra; 6.3 for MistDemo) -- [Examples workflow tracks subrepo tools-version](feedback_examples_workflow_tracks_tools_version.md) — `examples.yml` / MistDemo.yml must use a Swift 6.4 toolchain wherever MistKitConfiguration is a dependency (6.4 nightly / Xcode 27) +- [Examples workflow tracks subrepo tools-version](feedback_examples_workflow_tracks_tools_version.md) — MistDemo/Bushel/Celestra/MKC are tools-version 6.4; examples.yml + MistDemo.yml use 6.4 nightly / Xcode 27 - [ConfigKey bases must be dash-case](reference_configkey_cli_flag_dash_case.md) — snake_case silently breaks CLI flags and secret redaction; ENV works either way, so it hides the bug - [ConfigKeyKit ConfigValueReading](reference_configkeykit_configvaluereading.md) — ConfigKeyKit#1 shipped in-core in 1.0.0-beta.2; there is no ConfigKeyKitConfiguration package - [Path-package identity is the directory name](project_path_package_identity_collision.md) — a `path:` MistKit + a transitive `url:` MistKit = duplicate-target build failure; resolve still succeeds diff --git a/.claude/memory/feedback_examples_workflow_tracks_tools_version.md b/.claude/memory/feedback_examples_workflow_tracks_tools_version.md index 38c8e543..24cc2913 100644 --- a/.claude/memory/feedback_examples_workflow_tracks_tools_version.md +++ b/.claude/memory/feedback_examples_workflow_tracks_tools_version.md @@ -4,8 +4,8 @@ description: MistKit examples.yml must use a Swift container that can parse each type: feedback --- -`BushelCloud`, `CelestraCloud`, and `MistKitConfiguration` declare `swift-tools-version: 6.4`. Their own CI already runs on `swiftlang/swift:nightly-6.4.x-noble`. MistKit's parent `.github/workflows/examples.yml` must use a matching container for those matrix cells — a shared `swift:6.3` container fails immediately with "using Swift tools version 6.4.0 but the installed version is 6.3.x". +`MistDemo`, `BushelCloud`, `CelestraCloud`, and `MistKitConfiguration` declare `swift-tools-version: 6.4`. Their CI runs on `swiftlang/swift:nightly-6.4.x-noble` (Linux) and `runs-on: xcode-27` (Apple). MistKit's parent `.github/workflows/examples.yml` must use a matching container for those matrix cells — a shared `swift:6.3` container fails immediately with "using Swift tools version 6.4.0 but the installed version is 6.3.x". -`MistDemo` stays on tools-version 6.2 in its own Package.swift, but it now **depends on MistKitConfiguration**, so `.github/workflows/MistDemo.yml` must also use only the 6.4 nightly on Linux and Xcode 27 on macOS (Windows/Android lanes stay disabled until a 6.4 toolchain exists there). Prefer a per-example `matrix.include` with `container:` in `examples.yml` rather than one image for all four. +`.github/workflows/MistDemo.yml` likewise uses only the 6.4 nightly on Linux and Xcode 27 on macOS (Windows/Android lanes stay disabled until a 6.4 toolchain exists there). Prefer a per-example `matrix.include` with `container:` in `examples.yml` rather than one image for all four. When bumping an example's tools-version (or adopting a nightly-only toolchain / a 6.4-only path dependency), update `examples.yml` and that example's dedicated workflow in the same pass. diff --git a/.claude/memory/reference_windows_62_mistkittests_emit_abort.md b/.claude/memory/reference_windows_62_mistkittests_emit_abort.md new file mode 100644 index 00000000..51379673 --- /dev/null +++ b/.claude/memory/reference_windows_62_mistkittests_emit_abort.md @@ -0,0 +1,11 @@ +--- +name: windows-6.2-mistkittests-emit-abort +description: Swift 6.2 Windows silently aborts emitting MistKitTests; gate tip-over test bodies with #if + Issue.record +metadata: + node_type: memory + type: reference +--- + +On Windows + Swift **6.2 only**, `swift build --build-tests` can die with exit 1 and **no** `error:`/stack dump after compiling `MistKitTests` — and **never** print `Emitting module MistKitTests`. Same commit is green on Windows 6.1/6.3; main’s Windows 6.2 emits successfully. Reproducible. + +Tip-over is MistKitTests size/complexity — converting `WebAuthTokenManager` actor→class did **not** fix it. Mitigation: omit tip-over **test bodies** at compile time with `#if !(os(Windows) && compiler(>=6.2) && compiler(<6.3))` / `#else Issue.record`, keep `@Test`/`@Suite`/mocks compiled, and `.disabled(if: Platform.isWindowsSwift62)` for runtime. Do not use `.disabled(if:)` alone — that still compiles. See `.claude/docs/research/windows-6.2-ci-failure-462.md`. diff --git a/.github/workflows/MistDemo.yml b/.github/workflows/MistDemo.yml index f96869ca..be8e74f6 100644 --- a/.github/workflows/MistDemo.yml +++ b/.github/workflows/MistDemo.yml @@ -60,9 +60,9 @@ jobs: - id: matrix name: Build matrix values run: | - # MistDemo depends on MistKitConfiguration (swift-tools-version: 6.4). + # MistDemo declares swift-tools-version: 6.4 (MistKitConfiguration). # Swift 6.4 has no release toolchain yet, so only the nightly image can - # parse that dependency — no 6.2/6.3 lanes (same constraint as + # parse the manifest — no 6.2/6.3 lanes (same constraint as # Packages/MistKitConfiguration and examples.yml). SWIFT='[{"version":"6.4","image":"swiftlang/swift:nightly-6.4.x"}]' if [[ "${{ steps.check.outputs.full }}" == "true" ]]; then @@ -144,15 +144,10 @@ jobs: fail-fast: false matrix: runs-on: [windows-2022, windows-2025] - # MistDemo's Package.swift declares swift-tools-version: 6.2, - # so Swift 6.1 is not supported. - # TODO: re-add swift-6.2-release once the swift-testing 6.2 + - # Windows parallel-runner crash is resolved (test process exits - # 1 with no diagnostic output after fanning out parallel tests; - # 6.3 is unaffected). + # Re-enable when a windows-*-release 6.4 toolchain exists. swift: - - version: swift-6.3-release - build: 6.3-RELEASE + - version: swift-6.4-release + build: 6.4-RELEASE steps: - uses: actions/checkout@v6 - uses: brightdigit/swift-build@v1 @@ -181,11 +176,9 @@ jobs: strategy: fail-fast: false matrix: - # MistDemo's Package.swift declares swift-tools-version: 6.2, - # so Swift 6.1 is not supported. + # Re-enable when an Android Swift 6.4 SDK lane exists. swift: - - version: "6.2" - - version: "6.3" + - version: "6.4" android-api-level: [33, 34] steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 52964c58..e6ab02c0 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -18,9 +18,9 @@ jobs: test-examples: name: Test ${{ matrix.example }} on Ubuntu runs-on: ubuntu-latest - # BushelCloud / CelestraCloud / MistKitConfiguration declare swift-tools-version: 6.4; - # MistDemo is 6.2 but needs a 6.4 toolchain to resolve MistKitConfiguration. - # Swift 6.4 has no release image yet — use the nightly (same pin as the subrepos). + # MistDemo / BushelCloud / CelestraCloud / MistKitConfiguration all declare + # swift-tools-version: 6.4. Swift 6.4 has no release image yet — use the + # nightly (same pin as the subrepos). container: ${{ matrix.container }} if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} strategy: diff --git a/Examples/MistDemo/.swift-version b/Examples/MistDemo/.swift-version new file mode 100644 index 00000000..c596943a --- /dev/null +++ b/Examples/MistDemo/.swift-version @@ -0,0 +1 @@ +6.4 diff --git a/Examples/MistDemo/Package.swift b/Examples/MistDemo/Package.swift index 780c2708..20cb6d87 100644 --- a/Examples/MistDemo/Package.swift +++ b/Examples/MistDemo/Package.swift @@ -1,24 +1,9 @@ -// swift-tools-version: 6.2 +// swift-tools-version: 6.4 // swiftlint:disable explicit_acl explicit_top_level_acl import PackageDescription -// MARK: - AsyncAlgorithms wasi gating -// -// AsyncAlgorithms 1.0.x's Locking.swift references pthread_mutex_*. The Swift 6.2 -// wasm32-unknown-wasip1 SDK doesn't ship libwasi-emulated-pthread.a, so linking -// fails. Swift 6.3+ wasi SDKs link cleanly. Gate the wasi exclusion to 6.2 only; -// the `#else` self-deletes when the floor moves to 6.3. - -#if compiler(>=6.3) -let asyncAlgorithmsCondition: TargetDependencyCondition? = nil -#else -let asyncAlgorithmsCondition: TargetDependencyCondition? = .when( - platforms: Platform.without(.wasi) -) -#endif - let swiftSettings: [SwiftSetting] = [ .enableUpcomingFeature("InternalImportsByDefault"), ] @@ -86,8 +71,7 @@ let package = Package( ), .product( name: "AsyncAlgorithms", - package: "swift-async-algorithms", - condition: asyncAlgorithmsCondition + package: "swift-async-algorithms" ), ], resources: [ @@ -129,8 +113,7 @@ let package = Package( ), .product( name: "AsyncAlgorithms", - package: "swift-async-algorithms", - condition: asyncAlgorithmsCondition + package: "swift-async-algorithms" ), ], swiftSettings: swiftSettings @@ -138,17 +121,4 @@ let package = Package( ] ) -extension Platform { - static let all: [Platform] = [ - .macOS, .iOS, .tvOS, .watchOS, .visionOS, .macCatalyst, - .linux, .windows, .android, .driverKit, .wasi, - ] - - static func without(_ platform: Platform) -> [Platform] { - var result = all - result.removeAll { $0 == platform } - return result - } -} - // swiftlint:enable explicit_acl explicit_top_level_acl diff --git a/Examples/MistDemo/project.yml b/Examples/MistDemo/project.yml index 3e9f6b46..e5206660 100644 --- a/Examples/MistDemo/project.yml +++ b/Examples/MistDemo/project.yml @@ -14,7 +14,7 @@ packages: settings: base: - SWIFT_VERSION: "6.0" + SWIFT_VERSION: "6.4" MARKETING_VERSION: "1.0" CURRENT_PROJECT_VERSION: "1" PRODUCT_NAME: MistDemoApp diff --git a/Sources/MistKit/Authentication/AdaptiveTokenManager+Transitions.swift b/Sources/MistKit/Authentication/AdaptiveTokenManager+Transitions.swift index f54be88f..0a7ba196 100644 --- a/Sources/MistKit/Authentication/AdaptiveTokenManager+Transitions.swift +++ b/Sources/MistKit/Authentication/AdaptiveTokenManager+Transitions.swift @@ -61,4 +61,27 @@ extension AdaptiveTokenManager { return authenticator } + + /// Adopts a rotated web auth token from a CloudKit response header. + public func didReceiveRotatedWebAuthToken(_ token: String) async throws(TokenManagerError) { + guard webAuthToken != nil else { + return + } + + let authenticator = try WebAuthTokenAuthenticator( + apiToken: apiToken, + webAuthToken: token + ) + self.webAuthToken = token + + if let storage = storage { + do { + try await storage.store(authenticator, identifier: apiToken) + } catch { + Logger(subsystem: .auth).warning( + "Failed to store credentials after token rotation: \(error.localizedDescription)" + ) + } + } + } } diff --git a/Sources/MistKit/Authentication/AuthenticationMiddleware.swift b/Sources/MistKit/Authentication/AuthenticationMiddleware.swift index 3ffe8401..acbbc200 100644 --- a/Sources/MistKit/Authentication/AuthenticationMiddleware.swift +++ b/Sources/MistKit/Authentication/AuthenticationMiddleware.swift @@ -29,6 +29,7 @@ internal import Foundation internal import HTTPTypes +internal import Logging internal import OpenAPIRuntime /// Authentication middleware that delegates request mutation to whichever @@ -50,6 +51,16 @@ internal struct AuthenticationMiddleware: ClientMiddleware { var modifiedRequest = request var modifiedBody = body try await authenticator.authenticate(request: &modifiedRequest, body: &modifiedBody) - return try await next(modifiedRequest, modifiedBody, baseURL) + let (response, responseBody) = try await next(modifiedRequest, modifiedBody, baseURL) + if let rotated = response.headerFields[.cloudKitWebAuthToken] { + do { + try await tokenManager.didReceiveRotatedWebAuthToken(rotated) + } catch { + let message = "Failed to consume rotated web auth token: \(error.localizedDescription)" + Logger(subsystem: .auth).warning("\(message)") + RotatedWebAuthTokenFailureReporter.assertionHandler(message) + } + } + return (response, responseBody) } } diff --git a/Sources/MistKit/Authentication/HTTPField.Name+CloudKit.swift b/Sources/MistKit/Authentication/HTTPField.Name+CloudKit.swift index 90506ddf..65770e6c 100644 --- a/Sources/MistKit/Authentication/HTTPField.Name+CloudKit.swift +++ b/Sources/MistKit/Authentication/HTTPField.Name+CloudKit.swift @@ -49,6 +49,11 @@ extension HTTPField.Name { "X-Apple-CloudKit-Request-SignatureV1" ) + /// Rotated web authentication token returned on every CloudKit response. + internal static let cloudKitWebAuthToken = Self.knownFieldName( + "X-Apple-CloudKit-Web-Auth-Token" + ) + private static func knownFieldName(_ name: String) -> HTTPField.Name { guard let fieldName = HTTPField.Name(name) else { preconditionFailure("Invalid HTTP field name: \(name)") diff --git a/Sources/MistKit/Authentication/RotatedWebAuthTokenFailureReporter.swift b/Sources/MistKit/Authentication/RotatedWebAuthTokenFailureReporter.swift new file mode 100644 index 00000000..23bc4968 --- /dev/null +++ b/Sources/MistKit/Authentication/RotatedWebAuthTokenFailureReporter.swift @@ -0,0 +1,35 @@ +// +// RotatedWebAuthTokenFailureReporter.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Trap hook when rotated web auth token adoption fails in middleware. +internal enum RotatedWebAuthTokenFailureReporter { + @TaskLocal internal static var assertionHandler: @Sendable (String) -> Void = { message in + assertionFailure(message) + } +} diff --git a/Sources/MistKit/Authentication/TokenManager+Rotation.swift b/Sources/MistKit/Authentication/TokenManager+Rotation.swift new file mode 100644 index 00000000..36734601 --- /dev/null +++ b/Sources/MistKit/Authentication/TokenManager+Rotation.swift @@ -0,0 +1,37 @@ +// +// TokenManager+Rotation.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +extension TokenManager { + /// Adopts a rotated web auth token from the `X-Apple-CloudKit-Web-Auth-Token` + /// response header. Default implementation is a no-op for managers that do + /// not use web authentication. + public func didReceiveRotatedWebAuthToken(_ token: String) async throws(TokenManagerError) {} +} diff --git a/Sources/MistKit/Authentication/TokenManager.swift b/Sources/MistKit/Authentication/TokenManager.swift index 06713352..7a8802f7 100644 --- a/Sources/MistKit/Authentication/TokenManager.swift +++ b/Sources/MistKit/Authentication/TokenManager.swift @@ -46,4 +46,8 @@ public protocol TokenManager: Sendable { /// Returns the authenticator that should be used for the next request, /// or `nil` if no credentials are available. func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? + + /// Adopts a rotated web auth token from the `X-Apple-CloudKit-Web-Auth-Token` + /// response header. + func didReceiveRotatedWebAuthToken(_ token: String) async throws(TokenManagerError) } diff --git a/Sources/MistKit/Authentication/WebAuthTokenManager.swift b/Sources/MistKit/Authentication/WebAuthTokenManager.swift index 474563e6..1277d8ca 100644 --- a/Sources/MistKit/Authentication/WebAuthTokenManager.swift +++ b/Sources/MistKit/Authentication/WebAuthTokenManager.swift @@ -31,9 +31,9 @@ internal import Foundation /// Token manager for web authentication with API token + web auth token. /// Provides user-specific access to CloudKit Web Services. -public final class WebAuthTokenManager: TokenManager, Sendable { +public actor WebAuthTokenManager: TokenManager { internal let apiToken: String - internal let webAuthToken: String + internal var webAuthToken: String // MARK: - TokenManager Protocol @@ -66,4 +66,10 @@ public final class WebAuthTokenManager: TokenManager, Sendable { public func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { try WebAuthTokenAuthenticator(apiToken: apiToken, webAuthToken: webAuthToken) } + + /// Adopts a rotated web auth token from a CloudKit response header. + public func didReceiveRotatedWebAuthToken(_ token: String) async throws(TokenManagerError) { + _ = try WebAuthTokenAuthenticator(apiToken: apiToken, webAuthToken: token) + self.webAuthToken = token + } } diff --git a/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests+TokenRotation.swift b/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests+TokenRotation.swift new file mode 100644 index 00000000..5c356b5b --- /dev/null +++ b/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests+TokenRotation.swift @@ -0,0 +1,224 @@ +internal import Foundation +internal import HTTPTypes +internal import OpenAPIRuntime +internal import Testing + +@testable import MistKit + +extension AuthenticationMiddlewareTests { + @Suite("Token Rotation", .disabled(if: Platform.isWindowsSwift62)) + internal struct TokenRotation { + private static let validAPIToken = TestConstants.apiToken + private static let validWebAuthToken = TestConstants.webAuthToken + private static let rotatedWebAuthToken = + "rotatedwebauthtokenabcdef0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "abcdefghijklmnopqrstuvwxyz0123456789AB==" + private static let testOperationID = TestConstants.operationID + + private static func makeRequest() -> HTTPRequest { + HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/database/1/iCloud.com.example.app/private/records/query" + ) + } + + private static func responseWithRotatedToken(_ token: String) -> HTTPResponse { + var response = HTTPResponse(status: .ok) + response.headerFields[.cloudKitWebAuthToken] = token + return response + } + + @Test("Middleware forwards rotated token to token manager") + internal func middlewareForwardsRotatedToken() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let mockManager = MockTokenManagerWithRotation() + let middleware = AuthenticationMiddleware(tokenManager: mockManager) + + _ = try await middleware.intercept( + Self.makeRequest(), + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: { _, _, _ in + (Self.responseWithRotatedToken(Self.rotatedWebAuthToken), nil) + } + ) + + let received = await mockManager.receivedRotatedTokens + #expect(received == [Self.rotatedWebAuthToken]) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + + @Test("Middleware skips rotation hook when header is absent") + internal func middlewareSkipsRotationWithoutHeader() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let mockManager = MockTokenManagerWithRotation() + let middleware = AuthenticationMiddleware(tokenManager: mockManager) + + _ = try await middleware.intercept( + Self.makeRequest(), + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: { _, _, _ in + (HTTPResponse(status: .ok), nil) + } + ) + + let received = await mockManager.receivedRotatedTokens + #expect(received.isEmpty) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + + @Test("Middleware returns response when rotation adoption fails") + internal func middlewareReturnsResponseWhenRotationFails() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let mockManager = MockTokenManagerWithRotationFailure() + let middleware = AuthenticationMiddleware(tokenManager: mockManager) + + let (response, _) = try await RotatedWebAuthTokenFailureReporter.$assertionHandler + .withValue( + { _ in }, + operation: { + try await middleware.intercept( + Self.makeRequest(), + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: { _, _, _ in + (Self.responseWithRotatedToken("short"), nil) + } + ) + } + ) + + #expect(response.status == .ok) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + + @Test("WebAuthTokenManager adopts rotated token from middleware") + internal func webAuthTokenManagerAdoptsRotatedToken() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let tokenManager = WebAuthTokenManager( + apiToken: Self.validAPIToken, + webAuthToken: Self.validWebAuthToken + ) + let middleware = AuthenticationMiddleware(tokenManager: tokenManager) + + _ = try await middleware.intercept( + Self.makeRequest(), + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: { _, _, _ in + (Self.responseWithRotatedToken(Self.rotatedWebAuthToken), nil) + } + ) + + #expect(await tokenManager.webAuthToken == Self.rotatedWebAuthToken) + let authenticator = try await tokenManager.currentAuthenticator() + let web = try #require(authenticator as? WebAuthTokenAuthenticator) + #expect(web.webAuthToken == Self.rotatedWebAuthToken) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + + @Test("AdaptiveTokenManager adopts rotated token from middleware") + internal func adaptiveTokenManagerAdoptsRotatedToken() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let tokenManager = AdaptiveTokenManager(apiToken: Self.validAPIToken) + try await tokenManager.upgradeToWebAuthentication(webAuthToken: Self.validWebAuthToken) + let middleware = AuthenticationMiddleware(tokenManager: tokenManager) + + _ = try await middleware.intercept( + Self.makeRequest(), + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: { _, _, _ in + (Self.responseWithRotatedToken(Self.rotatedWebAuthToken), nil) + } + ) + + #expect(await tokenManager.webAuthToken == Self.rotatedWebAuthToken) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + + @Test("APITokenManager ignores rotated token via default implementation") + internal func apiTokenManagerIgnoresRotation() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let tokenManager = APITokenManager(apiToken: Self.validAPIToken) + try await tokenManager.didReceiveRotatedWebAuthToken(Self.rotatedWebAuthToken) + + let authenticator = try await tokenManager.currentAuthenticator() + #expect(authenticator is APITokenAuthenticator) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + + @Test("AdaptiveTokenManager ignores rotation before web auth upgrade") + internal func adaptiveTokenManagerIgnoresRotationWithoutWebAuth() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let tokenManager = AdaptiveTokenManager(apiToken: Self.validAPIToken) + try await tokenManager.didReceiveRotatedWebAuthToken(Self.rotatedWebAuthToken) + + #expect(await tokenManager.webAuthToken == nil) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + + @Test("AdaptiveTokenManager ignores rotation from middleware before web auth upgrade") + internal func adaptiveTokenManagerIgnoresMiddlewareRotationWithoutWebAuth() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let tokenManager = AdaptiveTokenManager(apiToken: Self.validAPIToken) + let middleware = AuthenticationMiddleware(tokenManager: tokenManager) + + _ = try await middleware.intercept( + Self.makeRequest(), + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: { _, _, _ in + (Self.responseWithRotatedToken(Self.rotatedWebAuthToken), nil) + } + ) + + #expect(await tokenManager.webAuthToken == nil) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + + @Test("AdaptiveTokenManager persists rotated token to storage") + internal func adaptiveTokenManagerPersistsRotatedTokenToStorage() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let storage = InMemoryTokenStorage() + let tokenManager = AdaptiveTokenManager( + apiToken: Self.validAPIToken, + storage: storage + ) + try await tokenManager.upgradeToWebAuthentication(webAuthToken: Self.validWebAuthToken) + try await tokenManager.didReceiveRotatedWebAuthToken(Self.rotatedWebAuthToken) + + let stored = try await storage.retrieve(identifier: Self.validAPIToken) + let web = try #require(stored as? WebAuthTokenAuthenticator) + #expect(web.webAuthToken == Self.rotatedWebAuthToken) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + } +} diff --git a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Basic.swift b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Basic.swift index 50958e94..e34a54ce 100644 --- a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Basic.swift +++ b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Basic.swift @@ -22,26 +22,26 @@ extension WebAuthTokenManagerTests { /// Tests WebAuthTokenManager initialization with valid tokens @Test("WebAuthTokenManager initialization with valid tokens") - internal func initializationWithValidTokens() { + internal func initializationWithValidTokens() async { let manager = WebAuthTokenManager( apiToken: Self.validAPIToken, webAuthToken: Self.validWebAuthToken ) - #expect(manager.apiToken == Self.validAPIToken) - #expect(manager.webAuthToken == Self.validWebAuthToken) + #expect(await manager.apiToken == Self.validAPIToken) + #expect(await manager.webAuthToken == Self.validWebAuthToken) } /// Tests WebAuthTokenManager initialization with storage @Test("WebAuthTokenManager initialization with storage") - internal func initializationWithStorage() { + internal func initializationWithStorage() async { let manager = WebAuthTokenManager( apiToken: Self.validAPIToken, webAuthToken: Self.validWebAuthToken ) - #expect(manager.apiToken == Self.validAPIToken) - #expect(manager.webAuthToken == Self.validWebAuthToken) + #expect(await manager.apiToken == Self.validAPIToken) + #expect(await manager.webAuthToken == Self.validWebAuthToken) } // MARK: - TokenManager Protocol Tests diff --git a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Performance.swift b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Performance.swift index c433f3ae..4838b361 100644 --- a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Performance.swift +++ b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Performance.swift @@ -25,8 +25,11 @@ extension WebAuthTokenManagerTests { let startTime = Date() + // WASM cooperative executor traps under heavy actor churn; keep a smoke count. + let iterations = Platform.isWasm ? 5 : 1_000 + // Perform many operations - for _ in 0..<1_000 { + for _ in 0.. ZoneChangesResult + + func fetchAllZoneChanges( + syncToken: String?, + maxPages: Int, + database: Database + ) async throws(CloudKitError) -> (zones: [ZoneInfo], syncToken: String?) +} + +extension CloudKitService: FetchZoneChangesAPI {} + +extension CloudKitServiceTests.FetchZoneChanges { + /// Invokes ``CloudKitService/fetchZoneChanges(syncToken:database:)`` via + /// ``FetchZoneChangesAPI`` so the deprecated concrete symbol isn't named at + /// the test call site. + internal static func fetchZoneChanges( + _ service: CloudKitService, + syncToken: String? = nil, + database: Database = .private + ) async throws(CloudKitError) -> ZoneChangesResult { + try await (service as any FetchZoneChangesAPI).fetchZoneChanges( + syncToken: syncToken, + database: database + ) + } + + /// Invokes ``CloudKitService/fetchAllZoneChanges(syncToken:maxPages:database:)`` + /// via ``FetchZoneChangesAPI`` so the deprecated concrete symbol isn't named + /// at the test call site. + internal static func fetchAllZoneChanges( + _ service: CloudKitService, + syncToken: String? = nil, + maxPages: Int = 1_000, + database: Database = .private + ) async throws(CloudKitError) -> (zones: [ZoneInfo], syncToken: String?) { + try await (service as any FetchZoneChangesAPI).fetchAllZoneChanges( + syncToken: syncToken, + maxPages: maxPages, + database: database + ) + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+ErrorHandling.swift b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+ErrorHandling.swift index 611f3b95..188cab0f 100644 --- a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+ErrorHandling.swift +++ b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+ErrorHandling.swift @@ -51,7 +51,10 @@ extension CloudKitServiceTests.FetchZoneChanges { let service = try CloudKitServiceTests.makeService(provider: provider) await #expect { - _ = try await service.fetchZoneChanges(syncToken: "garbage-token") + _ = try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges( + service, + syncToken: "garbage-token" + ) } throws: { error in guard let ckError = error as? CloudKitError, case .badRequest(let reason) = ckError @@ -78,7 +81,10 @@ extension CloudKitServiceTests.FetchZoneChanges { let service = try CloudKitServiceTests.makeService(provider: provider) await #expect { - _ = try await service.fetchZoneChanges(syncToken: "expired-token") + _ = try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges( + service, + syncToken: "expired-token" + ) } throws: { error in guard let ckError = error as? CloudKitError, case .badRequest(let reason) = ckError @@ -98,7 +104,7 @@ extension CloudKitServiceTests.FetchZoneChanges { ) await #expect { - _ = try await service.fetchZoneChanges() + _ = try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges(service) } throws: { error in guard let ckError = error as? CloudKitError, case .networkError(let urlError) = ckError diff --git a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+SuccessCases.swift b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+SuccessCases.swift index c76b52d3..03e68534 100644 --- a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+SuccessCases.swift +++ b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+SuccessCases.swift @@ -46,7 +46,10 @@ extension CloudKitServiceTests.FetchZoneChanges { syncToken: "zone-token-xyz" ) - let result = try await service.fetchZoneChanges(database: .public(.prefers(.serverToServer))) + let result = try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges( + service, + database: .public(.prefers(.serverToServer)) + ) #expect(result.zones.count == 2) #expect(result.syncToken == "zone-token-xyz") @@ -62,7 +65,10 @@ extension CloudKitServiceTests.FetchZoneChanges { zoneCount: 1 ) - let result = try await service.fetchZoneChanges(database: .public(.prefers(.serverToServer))) + let result = try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges( + service, + database: .public(.prefers(.serverToServer)) + ) #expect(result.zones.first?.zoneName == "test-zone-0") } @@ -77,7 +83,10 @@ extension CloudKitServiceTests.FetchZoneChanges { zoneCount: 0 ) - let result = try await service.fetchZoneChanges(database: .public(.prefers(.serverToServer))) + let result = try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges( + service, + database: .public(.prefers(.serverToServer)) + ) #expect(result.zones.isEmpty) #expect(result.syncToken != nil) @@ -94,7 +103,8 @@ extension CloudKitServiceTests.FetchZoneChanges { syncToken: "new-token" ) - let result = try await service.fetchZoneChanges( + let result = try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges( + service, syncToken: "previous-token", database: .public(.prefers(.serverToServer)) ) @@ -124,7 +134,10 @@ extension CloudKitServiceTests.FetchZoneChanges { { _, _, _ in }, operation: { await #expect(throws: CloudKitError.self) { - _ = try await service.fetchZoneChanges(database: .public(.prefers(.serverToServer))) + _ = try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges( + service, + database: .public(.prefers(.serverToServer)) + ) } } ) diff --git a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Validation.swift b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Validation.swift index 32ac39e9..aa54b91e 100644 --- a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Validation.swift +++ b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Validation.swift @@ -44,7 +44,10 @@ extension CloudKitServiceTests.FetchZoneChanges { let service = try await CloudKitServiceTests.FetchZoneChanges.makeAuthErrorService() await #expect(throws: CloudKitError.self) { - try await service.fetchZoneChanges(database: .public(.prefers(.serverToServer))) + try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges( + service, + database: .public(.prefers(.serverToServer)) + ) } } } diff --git a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+WireFormat.swift b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+WireFormat.swift index 8d62d86a..88c51c2d 100644 --- a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+WireFormat.swift +++ b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+WireFormat.swift @@ -79,7 +79,11 @@ extension CloudKitServiceTests.FetchZoneChanges { let provider = try ResponseProvider.successfulFetchZoneChanges(zoneCount: 1) let service = try Self.makeService(provider: provider) - _ = try await service.fetchZoneChanges(syncToken: "baseline-token", database: .private) + _ = try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges( + service, + syncToken: "baseline-token", + database: .private + ) let sent = try await Self.sentBodies(provider) #expect(sent.count == 1) @@ -97,7 +101,7 @@ extension CloudKitServiceTests.FetchZoneChanges { // The decoy `syncToken` is what MistKit used to read; the live container // never sends it. let provider = ResponseProvider( - defaultResponse: try .zoneChangesRawResponse( + defaultResponse: .zoneChangesRawResponse( body: """ { "zones": [], @@ -110,7 +114,10 @@ extension CloudKitServiceTests.FetchZoneChanges { ) let service = try Self.makeService(provider: provider) - let result = try await service.fetchZoneChanges(database: .private) + let result = try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges( + service, + database: .private + ) #expect(result.syncToken == "real-token") } @@ -136,7 +143,10 @@ extension CloudKitServiceTests.FetchZoneChanges { ) let service = try Self.makeService(provider: provider) - _ = try await service.fetchAllZoneChanges(database: .private) + _ = try await CloudKitServiceTests.FetchZoneChanges.fetchAllZoneChanges( + service, + database: .private + ) let sent = try await Self.sentBodies(provider) #expect(sent.count == 2) diff --git a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges.SuccessCases+Pagination.swift b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges.SuccessCases+Pagination.swift index 864534d1..22ebad1f 100644 --- a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges.SuccessCases+Pagination.swift +++ b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges.SuccessCases+Pagination.swift @@ -103,7 +103,10 @@ extension CloudKitServiceTests.FetchZoneChanges.SuccessCases { (zoneCount: 3, syncToken: "token-2"), ]) - let (zones, token) = try await service.fetchAllZoneChanges(database: .private) + let (zones, token) = try await CloudKitServiceTests.FetchZoneChanges.fetchAllZoneChanges( + service, + database: .private + ) #expect(zones.count == 3) #expect(token == "token-2") @@ -121,7 +124,10 @@ extension CloudKitServiceTests.FetchZoneChanges.SuccessCases { (zoneCount: 2, syncToken: "token-3"), ]) - let (zones, token) = try await service.fetchAllZoneChanges(database: .private) + let (zones, token) = try await CloudKitServiceTests.FetchZoneChanges.fetchAllZoneChanges( + service, + database: .private + ) #expect(zones.count == 7) #expect(token == "token-3") @@ -137,7 +143,8 @@ extension CloudKitServiceTests.FetchZoneChanges.SuccessCases { syncToken: "stuck-token" ) - let (zones, token) = try await service.fetchAllZoneChanges( + let (zones, token) = try await CloudKitServiceTests.FetchZoneChanges.fetchAllZoneChanges( + service, syncToken: "stuck-token", database: .private ) diff --git a/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+ErrorHandling.swift b/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+ErrorHandling.swift index ce942ab2..ce410fe5 100644 --- a/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+ErrorHandling.swift +++ b/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+ErrorHandling.swift @@ -91,7 +91,7 @@ extension CloudKitServiceTests.ModifyZones { do { _ = try entry.get() Issue.record("expected .zoneOperationFailed") - } catch let error as CloudKitError { + } catch { guard case .zoneOperationFailed(let failure) = error else { Issue.record("expected .zoneOperationFailed, got \(error)") return diff --git a/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience+ZoneID.swift b/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience+ZoneID.swift index 8b2db1b0..12d34277 100644 --- a/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience+ZoneID.swift +++ b/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience+ZoneID.swift @@ -34,99 +34,111 @@ internal import Testing extension CloudKitServiceTests.RecordWriteConvenience { /// Pins zoneID forwarding on the single-record write conveniences (#454). - @Suite("Record Write Convenience ZoneID") + @Suite("Record Write Convenience ZoneID", .disabled(if: Platform.isWindowsSwift62)) internal struct ZoneIDForwarding { private typealias Helper = CloudKitServiceTests.Rereference @Test("createRecord forwards zoneID into the modifyRecords request body") internal func createForwardsZoneID() async throws { - guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let (service, provider) = try Helper.makeServiceWithProvider(responsesByOperation: [ - "modifyRecords": try Helper.recordsResponse([ - Helper.noteRecord(recordName: "note-1", changeTag: "tag-1") + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let (service, provider) = try Helper.makeServiceWithProvider(responsesByOperation: [ + "modifyRecords": try Helper.recordsResponse([ + Helper.noteRecord(recordName: "note-1", changeTag: "tag-1") + ]) ]) - ]) - _ = try await service.createRecord( - recordType: "Note", - recordName: "note-1", - fields: ["title": .string("Hello")], - zoneID: ZoneID(zoneName: "Articles", ownerName: "_abc123"), - database: Helper.publicDatabase - ) + _ = try await service.createRecord( + recordType: "Note", + recordName: "note-1", + fields: ["title": .string("Hello")], + zoneID: ZoneID(zoneName: "Articles", ownerName: "_abc123"), + database: Helper.publicDatabase + ) - let bodies = await provider.bodies(for: "modifyRecords").compactMap { $0 } - let data = try #require(bodies.first) - let body = try #require( - try JSONSerialization.jsonObject(with: data) as? [String: Any] - ) - let zoneID = try #require(body["zoneID"] as? [String: Any]) - #expect(zoneID["zoneName"] as? String == "Articles") - #expect(zoneID["ownerRecordName"] as? String == "_abc123") + let bodies = await provider.bodies(for: "modifyRecords").compactMap { $0 } + let data = try #require(bodies.first) + let body = try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["zoneName"] as? String == "Articles") + #expect(zoneID["ownerRecordName"] as? String == "_abc123") + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif } @Test("updateRecord forwards zoneID into the modifyRecords request body") internal func updateForwardsZoneID() async throws { - guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let (service, provider) = try Helper.makeServiceWithProvider(responsesByOperation: [ - "modifyRecords": try Helper.recordsResponse([ - Helper.noteRecord(recordName: "note-1", changeTag: "tag-2") + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let (service, provider) = try Helper.makeServiceWithProvider(responsesByOperation: [ + "modifyRecords": try Helper.recordsResponse([ + Helper.noteRecord(recordName: "note-1", changeTag: "tag-2") + ]) ]) - ]) - _ = try await service.updateRecord( - recordType: "Note", - recordName: "note-1", - fields: ["title": .string("Renamed")], - recordChangeTag: "tag-1", - zoneID: ZoneID(zoneName: "Articles", ownerName: "_abc123"), - database: Helper.publicDatabase - ) + _ = try await service.updateRecord( + recordType: "Note", + recordName: "note-1", + fields: ["title": .string("Renamed")], + recordChangeTag: "tag-1", + zoneID: ZoneID(zoneName: "Articles", ownerName: "_abc123"), + database: Helper.publicDatabase + ) - let bodies = await provider.bodies(for: "modifyRecords").compactMap { $0 } - let data = try #require(bodies.first) - let body = try #require( - try JSONSerialization.jsonObject(with: data) as? [String: Any] - ) - let zoneID = try #require(body["zoneID"] as? [String: Any]) - #expect(zoneID["zoneName"] as? String == "Articles") - #expect(zoneID["ownerRecordName"] as? String == "_abc123") + let bodies = await provider.bodies(for: "modifyRecords").compactMap { $0 } + let data = try #require(bodies.first) + let body = try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["zoneName"] as? String == "Articles") + #expect(zoneID["ownerRecordName"] as? String == "_abc123") + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif } @Test("deleteRecord forwards zoneID into the modifyRecords request body") internal func deleteForwardsZoneID() async throws { - guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let (service, provider) = try Helper.makeServiceWithProvider(responsesByOperation: [ - "modifyRecords": try Helper.recordsResponse([ - Helper.noteRecord(recordName: "note-1", changeTag: "tag-2") + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let (service, provider) = try Helper.makeServiceWithProvider(responsesByOperation: [ + "modifyRecords": try Helper.recordsResponse([ + Helper.noteRecord(recordName: "note-1", changeTag: "tag-2") + ]) ]) - ]) - try await service.deleteRecord( - recordType: "Note", - recordName: "note-1", - recordChangeTag: "tag-1", - zoneID: ZoneID(zoneName: "Articles", ownerName: "_abc123"), - database: Helper.publicDatabase - ) + try await service.deleteRecord( + recordType: "Note", + recordName: "note-1", + recordChangeTag: "tag-1", + zoneID: ZoneID(zoneName: "Articles", ownerName: "_abc123"), + database: Helper.publicDatabase + ) - let bodies = await provider.bodies(for: "modifyRecords").compactMap { $0 } - let data = try #require(bodies.first) - let body = try #require( - try JSONSerialization.jsonObject(with: data) as? [String: Any] - ) - let zoneID = try #require(body["zoneID"] as? [String: Any]) - #expect(zoneID["zoneName"] as? String == "Articles") - #expect(zoneID["ownerRecordName"] as? String == "_abc123") + let bodies = await provider.bodies(for: "modifyRecords").compactMap { $0 } + let data = try #require(bodies.first) + let body = try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["zoneName"] as? String == "Articles") + #expect(zoneID["ownerRecordName"] as? String == "_abc123") + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif } } } diff --git a/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift index 42616492..da13cf41 100644 --- a/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift +++ b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift @@ -111,7 +111,7 @@ extension CloudKitServiceTests.ServerErrorCodes { database: .public(.prefers(.serverToServer)) ) Issue.record("expected queryRecords to throw") - } catch let error as CloudKitError { + } catch { #expect( error.serverErrorCode == nil, "an unmodelled code must not be mistaken for a modelled one" diff --git a/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift index 6a1f82c7..381a70ce 100644 --- a/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift +++ b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift @@ -59,7 +59,7 @@ extension CloudKitServiceTests.ServerErrorCodes { database: .public(.prefers(.serverToServer)) ) Issue.record("expected queryRecords to throw for \(expectation.code)") - } catch let error as CloudKitError { + } catch { #expect( CloudKitServiceTests.ServerErrorCodes.caseLabel(of: error) == expectation.caseLabel ) diff --git a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+FailureCases.swift b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+FailureCases.swift index 690f194e..1280d819 100644 --- a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+FailureCases.swift +++ b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+FailureCases.swift @@ -117,7 +117,7 @@ extension CloudKitServiceTests.Subscriptions { database: Self.database ) Issue.record("expected createSubscription to throw") - } catch let error as CloudKitError { + } catch { guard case .subscriptionOperationFailed(let failure) = error else { Issue.record("expected .subscriptionOperationFailed, got \(error)") return diff --git a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+LikelyDuplicateCases.swift b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+LikelyDuplicateCases.swift index a59601f7..18b46012 100644 --- a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+LikelyDuplicateCases.swift +++ b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+LikelyDuplicateCases.swift @@ -74,7 +74,7 @@ extension CloudKitServiceTests.Subscriptions { database: Self.database ) Issue.record("expected createSubscription to throw") - } catch let error as CloudKitError { + } catch { guard case .subscriptionLikelyDuplicate(let failure) = error else { Issue.record("expected .subscriptionLikelyDuplicate, got \(error)") return diff --git a/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+FailureCases.swift b/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+FailureCases.swift index 74f76705..10e3acf9 100644 --- a/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+FailureCases.swift +++ b/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+FailureCases.swift @@ -62,7 +62,7 @@ extension CloudKitServiceTests.Tokens { database: Self.database ) Issue.record("expected createAPNsToken to throw") - } catch let error as CloudKitError { + } catch { guard case .badRequest(let reason) = error else { Issue.record("expected .badRequest, got \(error)") return @@ -88,7 +88,7 @@ extension CloudKitServiceTests.Tokens { database: Self.database ) Issue.record("expected createAPNsToken to throw") - } catch let error as CloudKitError { + } catch { guard case .authenticationFailed = error else { Issue.record("expected .authenticationFailed, got \(error)") return @@ -116,7 +116,7 @@ extension CloudKitServiceTests.Tokens { database: Self.database ) Issue.record("expected registerAPNsToken to throw") - } catch let error as CloudKitError { + } catch { guard case .badRequest = error else { Issue.record("expected .badRequest, got \(error)") return diff --git a/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+ErrorHandling.swift b/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+ErrorHandling.swift index e76a10e5..26881607 100644 --- a/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+ErrorHandling.swift +++ b/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+ErrorHandling.swift @@ -52,7 +52,7 @@ extension CloudKitServiceTests.Upload { database: .public(.prefers(.serverToServer)) ) Issue.record("Expected authentication error") - } catch let error as CloudKitError { + } catch { if case .authenticationFailed(let reason) = error { #expect(error.httpStatusCode == 401, "Should return 401 Unauthorized") #expect(error.serverErrorCode == "AUTHENTICATION_FAILED") @@ -60,8 +60,6 @@ extension CloudKitServiceTests.Upload { } else { Issue.record("Expected authenticationFailed error, got \(error)") } - } catch { - Issue.record("Expected CloudKitError, got \(type(of: error))") } } @@ -82,14 +80,12 @@ extension CloudKitServiceTests.Upload { database: .public(.prefers(.serverToServer)) ) Issue.record("Expected bad request error") - } catch let error as CloudKitError { + } catch { if case .badRequest = error { // expected } else { Issue.record("Expected .badRequest error, got \(error)") } - } catch { - Issue.record("Expected CloudKitError, got \(type(of: error))") } } } diff --git a/Tests/MistKitTests/CloudKitService/Zones/CloudKitServiceTests.ZoneOwnerWireKey+WireFormat.swift b/Tests/MistKitTests/CloudKitService/Zones/CloudKitServiceTests.ZoneOwnerWireKey+WireFormat.swift index b175d457..391faca4 100644 --- a/Tests/MistKitTests/CloudKitService/Zones/CloudKitServiceTests.ZoneOwnerWireKey+WireFormat.swift +++ b/Tests/MistKitTests/CloudKitService/Zones/CloudKitServiceTests.ZoneOwnerWireKey+WireFormat.swift @@ -37,7 +37,7 @@ extension CloudKitServiceTests.ZoneOwnerWireKey { /// /// Apple's Zone ID Dictionary and live responses use `ownerRecordName`, not /// the mistaken `ownerName` key MistKit previously emitted. - @Suite("ZoneID Wire Format") + @Suite("ZoneID Wire Format", .disabled(if: Platform.isWindowsSwift62)) internal struct WireFormat { private static let database: Database = .private @@ -70,46 +70,54 @@ extension CloudKitServiceTests.ZoneOwnerWireKey { @Test("queryRecords() encodes ownerRecordName, never ownerName") internal func queryEncodesOwnerRecordName() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let provider = ResponseProvider.successfulQuery() - let service = try Self.makeService(provider) + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let provider = ResponseProvider.successfulQuery() + let service = try Self.makeService(provider) - _ = try await service.queryRecords( - MistKit.Query(recordType: "TestRecord"), - zoneID: ZoneID(zoneName: "SharedZone", ownerName: "_owner-record-name"), - database: Self.database - ) + _ = try await service.queryRecords( + MistKit.Query(recordType: "TestRecord"), + zoneID: ZoneID(zoneName: "SharedZone", ownerName: "_owner-record-name"), + database: Self.database + ) - let body = try await Self.sentBody(for: "queryRecords", from: provider) - let zoneID = try #require(body["zoneID"] as? [String: Any]) - #expect(zoneID["ownerRecordName"] as? String == "_owner-record-name") - #expect(zoneID["ownerName"] == nil) + let body = try await Self.sentBody(for: "queryRecords", from: provider) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["ownerRecordName"] as? String == "_owner-record-name") + #expect(zoneID["ownerName"] == nil) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif } @Test("modifyZones() encodes ownerRecordName inside each operation's zoneID") internal func modifyZonesEncodesOwnerRecordName() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let provider = try ResponseProvider.successfulModifyZones(zoneCount: 1) - let service = try Self.makeService(provider) + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let provider = try ResponseProvider.successfulModifyZones(zoneCount: 1) + let service = try Self.makeService(provider) - _ = try await service.modifyZones( - [.create(ZoneID(zoneName: "Shared", ownerName: "other-user"))], - database: Self.database - ) + _ = try await service.modifyZones( + [.create(ZoneID(zoneName: "Shared", ownerName: "other-user"))], + database: Self.database + ) - let body = try await Self.sentBody(for: "modifyZones", from: provider) - let operations = try #require(body["operations"] as? [[String: Any]]) - let operation = try #require(operations.first) - let zone = try #require(operation["zone"] as? [String: Any]) - let zoneID = try #require(zone["zoneID"] as? [String: Any]) - #expect(zoneID["ownerRecordName"] as? String == "other-user") - #expect(zoneID["ownerName"] == nil) + let body = try await Self.sentBody(for: "modifyZones", from: provider) + let operations = try #require(body["operations"] as? [[String: Any]]) + let operation = try #require(operations.first) + let zone = try #require(operation["zone"] as? [String: Any]) + let zoneID = try #require(zone["zoneID"] as? [String: Any]) + #expect(zoneID["ownerRecordName"] as? String == "other-user") + #expect(zoneID["ownerName"] == nil) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif } } } diff --git a/Tests/MistKitTests/Helpers/Platform.swift b/Tests/MistKitTests/Helpers/Platform.swift index d64b75b0..d9487b07 100644 --- a/Tests/MistKitTests/Helpers/Platform.swift +++ b/Tests/MistKitTests/Helpers/Platform.swift @@ -21,4 +21,15 @@ internal enum Platform { return false #endif }() + + /// True only on Windows × Swift 6.2, which silently aborts emitting + /// MistKitTests past a tip-over size (no `error:` / stack dump). + /// Prefer compile-time body `#if !(os(Windows) && compiler(>=6.2) && compiler(<6.3))` + /// (with `Issue.record` in `#else`) so tip-over IR is omitted from emit; this + /// flag pairs with `.disabled(if:)` so Windows 6.2 does not fail at runtime. + #if os(Windows) && compiler(>=6.2) && compiler(<6.3) + internal static let isWindowsSwift62 = true + #else + internal static let isWindowsSwift62 = false + #endif } diff --git a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRotation.swift b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRotation.swift new file mode 100644 index 00000000..458e7e37 --- /dev/null +++ b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRotation.swift @@ -0,0 +1,47 @@ +// +// MockTokenManagerWithRotation.swift +// MistKit +// + +internal import Foundation +internal import HTTPTypes +internal import OpenAPIRuntime + +@testable import MistKit + +/// Mock TokenManager that records rotated web auth tokens from responses. +internal final class MockTokenManagerWithRotation: TokenManager { + private actor State { + private var receivedTokens: [String] = [] + + func append(_ token: String) { + receivedTokens.append(token) + } + + func tokens() -> [String] { + receivedTokens + } + } + + private let state = State() + + internal var hasCredentials: Bool { + get async { true } + } + + internal var receivedRotatedTokens: [String] { + get async { await state.tokens() } + } + + internal func validateCredentials() async throws(TokenManagerError) -> Bool { + true + } + + internal func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { + try APITokenAuthenticator(token: TestConstants.apiToken) + } + + internal func didReceiveRotatedWebAuthToken(_ token: String) async throws(TokenManagerError) { + await state.append(token) + } +} diff --git a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRotationFailure.swift b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRotationFailure.swift new file mode 100644 index 00000000..89a81b68 --- /dev/null +++ b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRotationFailure.swift @@ -0,0 +1,27 @@ +// +// MockTokenManagerWithRotationFailure.swift +// MistKit +// + +internal import Foundation + +@testable import MistKit + +/// Mock TokenManager that fails when adopting a rotated web auth token. +internal final class MockTokenManagerWithRotationFailure: TokenManager { + internal var hasCredentials: Bool { + get async { true } + } + + internal func validateCredentials() async throws(TokenManagerError) -> Bool { + true + } + + internal func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { + try APITokenAuthenticator(token: TestConstants.apiToken) + } + + internal func didReceiveRotatedWebAuthToken(_ token: String) async throws(TokenManagerError) { + throw TokenManagerError.invalidCredentials(.webAuthTokenTooShort) + } +} diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+EdgeCases.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+EdgeCases.swift index 3ca45c80..d3a7fbc1 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+EdgeCases.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+EdgeCases.swift @@ -14,12 +14,10 @@ extension FieldValueConversionTests { return } let intZero = FieldValue.int64(0) - let intComponents = Components.Schemas.FieldValueRequest(from: intZero) - // #expect(#expect(intComponents.type == .int64) + _ = Components.Schemas.FieldValueRequest(from: intZero) let doubleZero = FieldValue.double(0.0) - let doubleComponents = Components.Schemas.FieldValueRequest(from: doubleZero) - // #expect(#expect(doubleComponents.type == .double) + _ = Components.Schemas.FieldValueRequest(from: doubleZero) } @Test("Convert negative values") @@ -29,12 +27,10 @@ extension FieldValueConversionTests { return } let negativeInt = FieldValue.int64(-100) - let intComponents = Components.Schemas.FieldValueRequest(from: negativeInt) - // #expect(#expect(intComponents.type == .int64) + _ = Components.Schemas.FieldValueRequest(from: negativeInt) let negativeDouble = FieldValue.double(-3.14) - let doubleComponents = Components.Schemas.FieldValueRequest(from: negativeDouble) - // #expect(#expect(doubleComponents.type == .double) + _ = Components.Schemas.FieldValueRequest(from: negativeDouble) } @Test("Convert large numbers") @@ -44,12 +40,10 @@ extension FieldValueConversionTests { return } let largeInt = FieldValue.int64(Int.max) - let intComponents = Components.Schemas.FieldValueRequest(from: largeInt) - // #expect(#expect(intComponents.type == .int64) + _ = Components.Schemas.FieldValueRequest(from: largeInt) let largeDouble = FieldValue.double(Double.greatestFiniteMagnitude) - let doubleComponents = Components.Schemas.FieldValueRequest(from: largeDouble) - // #expect(#expect(doubleComponents.type == .double) + _ = Components.Schemas.FieldValueRequest(from: largeDouble) } @Test("Convert empty string") @@ -59,7 +53,7 @@ extension FieldValueConversionTests { return } let emptyString = FieldValue.string("") - let components = Components.Schemas.FieldValueRequest(from: emptyString) + _ = Components.Schemas.FieldValueRequest(from: emptyString) } @Test("Convert string with special characters") @@ -69,7 +63,7 @@ extension FieldValueConversionTests { return } let specialString = FieldValue.string("Hello\nWorld\t🌍") - let components = Components.Schemas.FieldValueRequest(from: specialString) + _ = Components.Schemas.FieldValueRequest(from: specialString) } } } diff --git a/Tests/MistKitTests/Models/Notifications/CourierTests.swift b/Tests/MistKitTests/Models/Notifications/CourierTests.swift index 0fba4350..17c4a6f9 100644 --- a/Tests/MistKitTests/Models/Notifications/CourierTests.swift +++ b/Tests/MistKitTests/Models/Notifications/CourierTests.swift @@ -35,6 +35,36 @@ @Suite("Courier") internal struct CourierTests { + private actor PollCounter { + private(set) var count = 0 + func increment() -> Int { + count += 1 + return count + } + } + + private actor FirstNotificationGate { + private var notification: CourierNotification? + private var continuation: CheckedContinuation? + + func store(_ notification: CourierNotification) { + self.notification = notification + continuation?.resume() + continuation = nil + } + + func waitForFirst() async { + if notification != nil { + return + } + await withCheckedContinuation { continuation = $0 } + } + + func firstNotification() -> CourierNotification? { + notification + } + } + private static func courierURL() throws -> URL { try #require(URL(string: "https://webcourier.icloud.com/poll")) } @@ -74,17 +104,11 @@ let notification = try #require(result) #expect(notification.reason == .recordCreated) } + @Test("notifications yields a decoded notification from the stream") internal func notificationsYieldsDecodedNotification() async throws { let url = try Self.courierURL() let body = #"{"ck":{"qry":{"fo":1,"sid":"s","rid":"r"}}}"# - actor PollCounter { - private(set) var count = 0 - func increment() -> Int { - count += 1 - return count - } - } let polls = PollCounter() let transport: Courier.Transport = { _, _ in let pollCount = await polls.increment() @@ -97,28 +121,6 @@ return (statusCode: 200, data: Data()) } - actor FirstNotificationGate { - private var notification: CourierNotification? - private var continuation: CheckedContinuation? - - func store(_ notification: CourierNotification) { - self.notification = notification - continuation?.resume() - continuation = nil - } - - func waitForFirst() async { - if notification != nil { - return - } - await withCheckedContinuation { continuation = $0 } - } - - func firstNotification() -> CourierNotification? { - notification - } - } - let stream = Courier.notifications(courierURL: url, perPollTimeout: 1, transport: transport) let gate = FirstNotificationGate() let consumeTask = Task { diff --git a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversionEdgeCases.swift b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversionEdgeCases.swift index 387200d3..aca51a5e 100644 --- a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversionEdgeCases.swift +++ b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversionEdgeCases.swift @@ -35,7 +35,7 @@ internal import Testing extension ZoneMetadataTests { /// Edge-case decoding for ``ZoneInfo`` conversion. - @Suite("ZoneInfo Conversion Edge Cases") + @Suite("ZoneInfo Conversion Edge Cases", .disabled(if: Platform.isWindowsSwift62)) internal struct ZoneInfoConversionEdgeCases { private static func decodeZone(_ json: String) throws -> Components.Schemas.Zone { try JSONDecoder().decode(Components.Schemas.Zone.self, from: Data(json.utf8)) @@ -43,33 +43,41 @@ extension ZoneMetadataTests { @Test("atomic decodes false without collapsing into nil") internal func atomicFalseIsPreserved() throws { - let zone = try Self.decodeZone( - """ - { "zoneID": { "zoneName": "Articles" }, "atomic": false } - """ - ) + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let zone = try Self.decodeZone( + """ + { "zoneID": { "zoneName": "Articles" }, "atomic": false } + """ + ) - let info = try ZoneInfo(from: zone) + let info = try ZoneInfo(from: zone) - #expect(info.atomic == false) + #expect(info.atomic == false) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif } @Test("ZoneInfo still throws when the zone payload has no zoneName") internal func missingZoneNameThrows() throws { - let zone = try Self.decodeZone( - """ - { "zoneID": { "ownerRecordName": "_defaultOwner" }, "atomic": true } - """ - ) + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let zone = try Self.decodeZone( + """ + { "zoneID": { "ownerRecordName": "_defaultOwner" }, "atomic": true } + """ + ) - ConversionFailureReporter.$assertionHandler.withValue( - { _, _, _ in }, - operation: { - #expect(throws: ConversionError.self) { - _ = try ZoneInfo(from: zone) + ConversionFailureReporter.$assertionHandler.withValue( + { _, _, _ in }, + operation: { + #expect(throws: ConversionError.self) { + _ = try ZoneInfo(from: zone) + } } - } - ) + ) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif } } } From 3708e09deefd66033699fe8ce022fdf1f2fd5e4a Mon Sep 17 00:00:00 2001 From: leogdion Date: Thu, 3 Sep 2026 13:42:30 -0400 Subject: [PATCH 14/25] Record why FieldValue.bytes is a base64 String (#467, #468) --- .claude/memory/MEMORY.md | 1 + ...oject_fieldvalue_bytes_is_base64_string.md | 65 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 .claude/memory/project_fieldvalue_bytes_is_base64_string.md diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index a5b011af..5a6d8bae 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -51,3 +51,4 @@ Project-scoped agent memory for MistKit. This directory **replaces** any native - [Dogfood pins are branch pins, not tags](project_dogfood_pins_are_branch_pins_not_tags.md) — tagging a monorepo package does NOT mean Examples switch to `from:`; they keep `path:` and CI pins both deps to branch HEADs - [MistKitConfiguration integration branch](project_mkc_integration_branch.md) — subrepo tracks `mistkit-beta.5`; it is beta.5-line scaffolding and must be DELETED in the `→ main` release PR - [Draft-gated CI needs ready_for_review](reference_draft_gated_ci_needs_ready_for_review.md) — an `if: draft == false` job stays `skipped` forever unless `types:` lists `ready_for_review`; re-running replays the stale payload +- [FieldValue.bytes is a base64 String](project_fieldvalue_bytes_is_base64_string.md) — wire-format mirror, no recorded rationale; #467 specs the move to Data; base64 has no false-positive signal so never infer it diff --git a/.claude/memory/project_fieldvalue_bytes_is_base64_string.md b/.claude/memory/project_fieldvalue_bytes_is_base64_string.md new file mode 100644 index 00000000..beb0a991 --- /dev/null +++ b/.claude/memory/project_fieldvalue_bytes_is_base64_string.md @@ -0,0 +1,65 @@ +--- +name: project_fieldvalue_bytes_is_base64_string +description: FieldValue.bytes holds a base64 String (not Data) to mirror the wire format; changing it to Data is specced in #467 +metadata: + type: project +--- + +`FieldValue.bytes` is `case bytes(String)` — a **base64-encoded string**, not `Data` +(`Sources/MistKit/Models/FieldValues/FieldValue.swift`). + +**There is no recorded rationale for this.** The case arrives fully formed in `d11c6c5` +("v1.0.0 beta.1", #298), which squashes the entire pre-beta history, and nothing in the +issue tracker, `ReleaseNotes.md`, or this memory store deliberates it. Don't go looking +again — this file *is* the answer to "why String?". + +The de facto reasons visible in the code: + +1. It mirrors the wire 1:1 — `openapi.yaml` defines `BytesValue` as `type: string` + ("Base64-encoded string"), and the generated `BytesValue` is `typealias … = Swift.String`. +2. It keeps the conversion layer total: `.string` and `.bytes` share a `String` payload, so + several switch arms collapse into one (`FieldValue+Codable.swift`, + `ScalarPayload.text`/`.inferred` in `FieldValue+Components+Scalar.swift`). With `Data`, + every decode site gains a fallible `Data(base64Encoded:)` step. + +Cost: `bytesValue` returns `String?` and there is **no** `Data` bridge anywhere in +`Sources/MistKit/` — callers hand-roll the decode. + +**Changing it to `Data` is specced in issue #467** (decisions settled there: reuse +`ConversionError.typeValueMismatch` rather than a new case; untagged base64 keeps +resolving to `.string`; `dataValue` stays a strict `.bytes`-only match). + +## Base64 has no false-positive signal + +Load-bearing for #467, and worth knowing generally: base64 carries no header, checksum, +or self-identifying structure. Validity is only a character-set-and-length check, so **any** +`[A-Za-z0-9+/]` string whose length is a multiple of 4 decodes successfully: + +| Input | `Data(base64Encoded:)` | +|---|---| +| `"test"`, `"user"`, `"name"`, `"true"`, `"data"`, `"Chen"` | 3 bytes of garbage | +| `"hello"` (len 5), `"Mei"` (len 3) | `nil` | + +`"Chen"` is from Apple's own example record (`.claude/docs/webservices.md`, +`"lastName" : {"value" : "Chen"}`) — a four-letter surname is indistinguishable from base64. + +**Therefore: never infer `.bytes` by attempting a base64 decode**, neither in the scalar +decode chain nor as a `dataValue` fallback for `.string`. A non-`nil` result proves only +that a string *could* be base64, never that it *is*; inference would hand back +plausible-looking garbage instead of a visible failure. + +## Untagged BYTES responses — frequency unmeasured + +When a response omits `type`, first-match-wins inference claims a base64 string as +`.string` (documented in CLAUDE.md as lossy). How often CloudKit actually omits `type` on +reads is **not established**: our `openapi.yaml` calls it optional, but that wording is +*ours*, hand-written from Apple's docs — and Apple's authoritative `Record Field Dictionary` +is referenced at `.claude/docs/webservices.md` without its definition being included in the +offline copy. Note "optional in the schema" ≠ "sometimes absent in practice". No captured +live responses exist in the repo; every `"type"` in the tests is a hand-written fixture. + +Settle it the way #430 settled `metaSyncToken` — against a live container +(`swift run mistdemo create` with a bytes field, then a `.debug` query) — before treating +untagged binary as either real or theoretical. + +Related: [[project_release_process]] From 83877bc012fd6e15ceb53535acc53f582e07a11e Mon Sep 17 00:00:00 2001 From: leogdion Date: Thu, 3 Sep 2026 14:34:08 -0400 Subject: [PATCH 15/25] Claude docs consolidation (#469) --- .claude/docs/README.md | 319 ++-------- .claude/docs/SUMMARY.md | 425 ------------- .claude/docs/cloudkit-schema-plan.md | 561 ------------------ ...t-SyndiKit-0.6.1-documentation-syndikit.md | 488 --------------- .../phases/phase-1-core-infrastructure.md | 2 +- .../mistdemo/swift-configuration-reference.md | 2 +- .../docs/protocol-extraction-continuation.md | 559 ----------------- ...onfiguration.md => swift-configuration.md} | 0 ...-documentation-logging.md => swift-log.md} | 0 ...enerator.md => swift-openapi-generator.md} | 0 ...apiruntime.md => swift-openapi-runtime.md} | 0 .claude/memory/MEMORY.md | 2 + .../feedback_subrepo_directive_scope.md | 28 + .../reference_git_trees_add_bases_on_main.md | 24 + AGENTS.md | 34 +- .../.claude}/data-sources-api-research.md | 0 .../BushelCloud/.claude}/firmware-wiki.md | 0 .../BushelCloud/.claude}/mobileasset-wiki.md | 0 Examples/BushelCloud/CLAUDE.md | 12 + .../cloudkit-public-database-architecture.md | 0 Examples/CelestraCloud/AGENTS.md | 1 + 21 files changed, 129 insertions(+), 2328 deletions(-) delete mode 100644 .claude/docs/SUMMARY.md delete mode 100644 .claude/docs/cloudkit-schema-plan.md delete mode 100644 .claude/docs/https_-swiftpackageindex.com-brightdigit-SyndiKit-0.6.1-documentation-syndikit.md delete mode 100644 .claude/docs/protocol-extraction-continuation.md rename .claude/docs/{https_-swiftpackageindex.com-apple-swift-configuration-1.0.0-documentation-configuration.md => swift-configuration.md} (100%) rename .claude/docs/{https_-swiftpackageindex.com-apple-swift-log-main-documentation-logging.md => swift-log.md} (100%) rename .claude/docs/{https_-swiftpackageindex.com-apple-swift-openapi-generator-1.10.3-documentation-swift-openapi-generator.md => swift-openapi-generator.md} (100%) rename .claude/docs/{https_-swiftpackageindex.com-apple-swift-openapi-runtime-1.9.0-documentation-openapiruntime.md => swift-openapi-runtime.md} (100%) create mode 100644 .claude/memory/feedback_subrepo_directive_scope.md create mode 100644 .claude/memory/reference_git_trees_add_bases_on_main.md rename {.claude/docs => Examples/BushelCloud/.claude}/data-sources-api-research.md (100%) rename {.claude/docs => Examples/BushelCloud/.claude}/firmware-wiki.md (100%) rename {.claude/docs => Examples/BushelCloud/.claude}/mobileasset-wiki.md (100%) rename {.claude/docs => Examples/CelestraCloud/.claude}/cloudkit-public-database-architecture.md (100%) diff --git a/.claude/docs/README.md b/.claude/docs/README.md index 76419618..0a1bd37f 100644 --- a/.claude/docs/README.md +++ b/.claude/docs/README.md @@ -1,290 +1,71 @@ -# CloudKit Documentation Reference +# MistKit Reference Documentation -This directory contains Apple's official documentation for CloudKit Web Services, CloudKit JS, and Swift Testing, downloaded for offline reference during MistKit development. +Offline copies of external documentation, kept here so agents can consult them +without network access. Each file carries its source URL and download date in a +header comment. -## File Overview +This file is the **only** router for this directory: if you add a doc, add a row +below, and if a doc is not listed here, it is unreachable in practice. -### webservices.md (289 KB) -**Source**: https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ +## Dependency documentation -**Primary Use**: REST API implementation reference for MistKit's core functionality +Docs for packages MistKit actually depends on (see `Package.swift`). -**Key Topics**: -- **Authentication**: API tokens, server-to-server keys, web auth tokens -- **Request Composition**: URL structure, headers, request/response formats -- **Endpoints**: - - Records: `/records/query`, `/records/modify`, `/records/lookup`, `/records/changes` - - Zones: `/zones/list`, `/zones/lookup`, `/zones/modify`, `/zones/changes` - - Subscriptions: `/subscriptions/*` - - Users: `/users/current`, `/users/discover`, `/users/lookup/contacts` - - Assets: `/assets/upload` - - Tokens: `/tokens/create`, `/tokens/register` -- **Data Types**: Record dictionaries, field types, references, assets, locations -- **Error Handling**: Error response formats and codes +| Doc | Size | Consult when | +|-----|------|--------------| +| [swift-openapi-generator.md](swift-openapi-generator.md) | 235 KB | Configuring `openapi-generator-config.yaml`, type overrides, naming strategies, filtering the spec, troubleshooting generated code | +| [swift-openapi-runtime.md](swift-openapi-runtime.md) | 125 KB | Working with `Client`/`Types` in `Sources/MistKitOpenAPI/`, middleware, transports, `@_spi(Generated)` APIs, content types and streaming | +| [swift-configuration.md](swift-configuration.md) | 304 KB | MistDemo configuration — providers, precedence, key resolution. See also `mistdemo/swift-configuration-reference.md` for the MistKit-specific guide | +| [swift-log.md](swift-log.md) | 97 KB | Logger setup, metadata, log levels, handler behavior. See also AGENTS.md § Logging for MistKit's conventions | -**When to Consult**: -- Implementing any CloudKit REST API endpoint -- Understanding request/response formats -- Implementing authentication mechanisms -- Working with CloudKit-specific data types -- Debugging API responses and errors +## CloudKit API references ---- +| Doc | Size | Consult when | +|-----|------|--------------| +| [webservices.md](webservices.md) | 282 KB | **Authoritative REST reference.** Implementing any endpoint, authentication, request/response formats, data types, error codes | +| [cloudkitjs.md](cloudkitjs.md) | 183 KB | Understanding CloudKit concepts and operation flows; designing Swift types that mirror CloudKit structures | +| [QUICK_REFERENCE.md](QUICK_REFERENCE.md) | 9 KB | Fast lookup — endpoint shapes, field types, query filters, error codes, type mapping, known endpoint discrepancies | -### cloudkitjs.md (188 KB) -**Source**: https://developer.apple.com/documentation/cloudkitjs +Archived Apple docs are not always complete. Verify endpoint details against +Apple's live/archived reference when something looks wrong — see +`.claude/memory/reference_cloudkit_archived_endpoints.md`. -**Primary Use**: Understanding CloudKit concepts and data structures (adapted to Swift) +## Schema -**Key Topics**: -- **Configuration**: Container setup, authentication flows -- **Core Classes**: - - `CloudKit.Container`: Container access, authentication - - `CloudKit.Database`: Database operations (public/private/shared) - - `CloudKit.Record*`: Record operations and responses - - `CloudKit.RecordZone*`: Zone management - - `CloudKit.Subscription*`: Subscription handling - - `CloudKit.Notification*`: Push notification types -- **Operations**: - - Query operations with filters and sorting - - Batch operations for records - - Zone management - - Subscription management - - User discovery - - Sharing records -- **Response Objects**: All response types and their properties -- **Error Handling**: `CKError` structure and error types +| Doc | Size | Consult when | +|-----|------|--------------| +| [cloudkit-schema-reference.md](cloudkit-schema-reference.md) | 9 KB | Reading/modifying `.ckdb` files — grammar, field options, permissions, MistKit-specific notes | +| [sosumi-cloudkit-schema-source.md](sosumi-cloudkit-schema-source.md) | 8 KB | Authoritative schema-language grammar, identifier rules, system fields | +| [schema-design-workflow.md](schema-design-workflow.md) | 15 KB | End-to-end schema design and validation workflow | -**When to Consult**: -- Designing Swift types that mirror CloudKit structures -- Understanding CloudKit operation flows -- Implementing query builders -- Working with subscriptions and notifications -- Understanding CloudKit error handling patterns -- Comparing JS API patterns to REST API +## Tooling ---- +| Doc | Size | Consult when | +|-----|------|--------------| +| [cktool.md](cktool.md) / [cktool-full.md](cktool-full.md) | 6 / 4 KB | Native CLI for schema export/import, token management, seeding test data | +| [cktooljs.md](cktooljs.md) / [cktooljs-full.md](cktooljs-full.md) | 6 / 10 KB | JS library for programmatic schema deployment and CI automation | -### testing-enablinganddisabling.md (126 KB) -**Source**: https://developer.apple.com/documentation/testing/enablinganddisabling +## Testing -**Primary Use**: Writing modern Swift tests for MistKit +| Doc | Size | Consult when | +|-----|------|--------------| +| [testing-enablinganddisabling.md](testing-enablinganddisabling.md) | 123 KB | Swift Testing — `@Test`/`@Suite`, traits, parameterization, async, XCTest migration | +| [test-organization-guide.md](test-organization-guide.md) | 41 KB | How this repo organizes test files and parent types | -**Key Topics**: -- **Test Definition**: `@Test` macro for test functions -- **Test Organization**: `@Suite` for grouping tests -- **Conditional Testing**: `.enabled(if:)`, `.disabled()` traits -- **Parameterization**: Testing with collections of inputs -- **Async Testing**: `async`/`await` test support -- **Expectations**: `#expect()`, `#require()` macros -- **Migration**: Converting from XCTest to Swift Testing -- **Tags**: Categorizing tests with tags -- **Known Issues**: `.bug()` trait for tracking known failures -- **Parallelization**: Serial vs parallel execution +## MistDemo -**When to Consult**: -- Writing new test functions -- Setting up test suites -- Implementing parameterized tests -- Testing async/await code -- Migrating from XCTest -- Organizing test execution +`mistdemo/` holds the MistDemo design docs — start at +[mistdemo/README.md](mistdemo/README.md). `mistdemo/phases/` tracks +implementation phases. ---- +## Research -### swift-openapi-generator.md (235 KB) -**Source**: https://swiftpackageindex.com/apple/swift-openapi-generator/1.10.3/documentation/swift-openapi-generator +`research/` holds dated investigations into specific failures, kept for the +reasoning rather than as current reference. Each is a point-in-time record. -**Primary Use**: Code generation configuration and troubleshooting +## Related -**Key Topics**: -- **Generator Configuration**: YAML config options, naming strategies (defensive vs idiomatic), access modifiers -- **Type Overrides**: Replacing generated types with custom implementations (e.g., Foundation.UUID) -- **Document Filtering**: Generating subsets by operations, paths, or tags -- **Middleware System**: ClientMiddleware for auth, logging, retry logic -- **Transport Protocols**: ClientTransport abstraction, URLSession integration -- **Content Types**: JSON, multipart, URL-encoded, plain text, binary, streaming -- **Event Streams**: JSON Lines, JSON Sequence, Server-sent Events helpers -- **API Stability**: Understanding breaking vs non-breaking changes -- **Naming Strategies**: Defensive (safe) vs idiomatic (Swift-style) identifier mapping -- **Code Generation Modes**: Build plugin vs manual CLI invocation +Example-specific domain docs live with their examples, not here: -**When to Consult**: -- Configuring `openapi-generator-config.yaml` settings -- Setting up custom type overrides for CloudKit types -- Implementing authentication or logging middleware -- Understanding generated code structure and evolution -- Troubleshooting "Decl has a package access level" errors -- Filtering large OpenAPI specs for specific operations -- Working with streaming responses or multipart uploads - ---- - -### cktool.md / cktool-full.md -**Source**: https://developer.apple.com/icloud/ck-tool/ - -**Primary Use**: CloudKit command-line tool for schema and data management - -**Files**: -- `cktool.md` - Curated summary with command examples -- `cktool-full.md` - Complete Apple documentation - -**Key Topics**: -- **Authentication**: Management tokens, user tokens, Keychain storage -- **Schema Management**: - - Reset development schema to production - - Export schema to `.ckdb` files - - Import schema from files to development -- **Data Commands**: - - Query records with filters - - Create records with JSON field definitions -- **Automation**: CI/CD integration, test data seeding -- **Token Management**: Saving and managing CloudKit tokens - -**When to Consult**: -- Setting up development environments with CloudKit schemas -- Exporting/importing schemas for version control -- Automating schema deployment in CI/CD pipelines -- Seeding test data for MistKit integration tests -- Resetting development databases -- Understanding CloudKit Management API workflows - ---- - -### cktooljs.md / cktooljs-full.md -**Source**: https://developer.apple.com/documentation/cktooljs/ -**Version**: CKTool JS 1.2.15+ - -**Primary Use**: JavaScript library for CloudKit management operations - -**Files**: -- `cktooljs.md` - Curated summary with key information -- `cktooljs-full.md` - Complete Apple documentation (41 pages) - -**Key Topics**: -- **Core Modules**: - - `@apple/cktool.database` - CloudKit types and operations - - `@apple/cktool.target.nodejs` - Node.js configuration - - `@apple/cktool.target.browser` - Browser configuration -- **Capabilities**: - - Deploy schemas to Sandbox databases - - Seed databases with test data - - Restore Sandbox to production settings - - Create automated integration test scripts -- **API Components**: - - PromisesApi and CancellablePromise - - Configuration for server communication - - Container and ContainersResponse structures - - Error handling framework - -**When to Consult**: -- Building JavaScript-based CloudKit automation tools -- Creating CI/CD pipelines with Node.js -- Understanding CloudKit Management API from JS perspective -- Automating schema deployment programmatically -- Building developer tooling for CloudKit workflows -- Comparing management API patterns across platforms - ---- - -## Quick Reference: When to Use Each Doc - -### Implementing Core API Functionality -→ **webservices.md**: Authoritative source for all REST endpoints - -### Designing Type Systems -→ **cloudkitjs.md**: Model CloudKit's data structures in Swift - -### Configuring Code Generation -→ **swift-openapi-generator.md**: OpenAPI generator setup and troubleshooting - -### Writing Tests -→ **testing-enablinganddisabling.md**: Modern Swift Testing patterns - -### Schema Management & Automation -→ **cktool.md**: Native command-line tool for schema and data operations -→ **cktooljs.md**: JavaScript library for programmatic management - ---- - -## Integration with MistKit Development - -### Architecture Decisions -When designing MistKit's API surface: -1. Start with **webservices.md** for REST API capabilities -2. Reference **cloudkitjs.md** for ergonomic API design patterns -3. Adapt CloudKit JS patterns to Swift idioms (async/await, Result builders, etc.) - -### Implementation Workflow -1. **Plan**: Review endpoint in `webservices.md` -2. **Design**: Check `cloudkitjs.md` for conceptual patterns -3. **Implement**: Write Swift code with async/await -4. **Test**: Use patterns from `testing-enablinganddisabling.md` - -### Data Type Mapping -- CloudKit types → Swift types -- Reference **webservices.md** for wire format -- Reference **cloudkitjs.md** for semantic meaning - ---- - -## Common Patterns - -### Authentication Flow -1. **webservices.md** → Request signing, token formats -2. **cloudkitjs.md** → Authentication state management -3. **cktool.md** → Management token setup and storage - -### Record Operations -1. **webservices.md** → `/records/modify` endpoint structure -2. **cloudkitjs.md** → `Database.saveRecords()` operation flow -3. **cktool.md** → Creating test records via CLI - -### Query Operations -1. **webservices.md** → `/records/query` request format -2. **cloudkitjs.md** → Query filters, sort descriptors, pagination -3. **cktool.md** → Query filters via command-line - -### Error Handling -1. **webservices.md** → HTTP status codes, error response format -2. **cloudkitjs.md** → `CKError` codes and retry logic -3. **cktooljs.md** → Error handling in management operations - -### Development Workflows -1. **cktool.md** → Export/import schemas for version control -2. **cktooljs.md** → Programmatic schema deployment and automation -3. **webservices.md** → Understanding underlying API operations - ---- - -## Documentation Ecosystem Map - -``` -CloudKit Development & Operations -├── Runtime APIs (Application Level) -│ ├── webservices.md ────────── REST API reference -│ ├── cloudkitjs.md ─────────── JS SDK for web apps -│ └── MistKit ───────────────── Swift implementation -│ -├── Management APIs (Development Level) -│ ├── cktool.md ─────────────── Native CLI tool (Xcode) -│ └── cktooljs.md ───────────── JS library for automation -│ -├── Code Generation -│ └── swift-openapi-generator.md ─ Generate Swift from OpenAPI -│ -└── Testing - └── testing-enablinganddisabling.md ─ Swift Testing framework -``` - -## Notes - -- These docs are from Apple's official documentation and community sources -- Most content downloaded via llm.codes and filtered for relevance -- Last updated: November 4, 2025 -- Corresponds to: - - CloudKit Web Services (archived) - - CloudKit JS 1.0+ - - Swift Testing (Swift 6.0+) - - cktool (Xcode 13+) - - CKTool JS (latest) +- `Examples/BushelCloud/.claude/` — firmware/MobileAsset wikis, data-source research +- `Examples/CelestraCloud/.claude/` — public-database architecture, schema setup diff --git a/.claude/docs/SUMMARY.md b/.claude/docs/SUMMARY.md deleted file mode 100644 index aa1e0abc..00000000 --- a/.claude/docs/SUMMARY.md +++ /dev/null @@ -1,425 +0,0 @@ -# Documentation Summary - -## Overview - -This directory contains three comprehensive Apple documentation files for MistKit development: - -1. **webservices.md** (289 KB) - CloudKit Web Services REST API Reference -2. **cloudkitjs.md** (188 KB) - CloudKit JS Framework Documentation -3. **testing-enablinganddisabling.md** (126 KB) - Swift Testing Framework Guide - ---- - -## webservices.md - CloudKit Web Services REST API - -**Source**: Apple's CloudKit Web Services Reference (Archived Documentation) - -### What It Covers - -#### Authentication & Security -- **API Token Authentication**: For web/client apps requiring user authentication - - Creating API tokens in CloudKit Dashboard - - Generating web authentication tokens - - Token lifecycle and refresh patterns - - Request signing with HMAC-SHA256 - -- **Server-to-Server Keys**: For backend/admin operations - - Certificate generation and key management - - Request authentication flow - - Signature computation - -#### Request Composition -- Base URL structure: `https://api.apple-cloudkit.com/database/1/{container}/{env}/{db}/{operation}` -- HTTP methods and headers -- Request body formats -- Response structures -- Error handling - -#### Core Endpoints - -**Records API** -- `POST /records/query` - Query records with filters and sorting -- `POST /records/modify` - Create, update, replace, delete records (batch) -- `POST /records/lookup` - Fetch specific records by ID -- `POST /records/changes` - Fetch incremental changes - -**Zones API** -- `POST /zones/list` - List all zones -- `POST /zones/lookup` - Fetch specific zones -- `POST /zones/modify` - Create, update, delete zones -- `POST /zones/changes` - Fetch zone changes - -**Subscriptions API** -- `POST /subscriptions/list` - List all subscriptions -- `POST /subscriptions/lookup` - Fetch specific subscriptions -- `POST /subscriptions/modify` - Create, update, delete subscriptions - -**Users API** -- `GET /users/current` - Get current authenticated user -- `POST /users/discover` - Discover users by email/phone -- `POST /users/lookup/contacts` - Lookup users from contacts -- `POST /users/lookup/email` - Lookup by email address -- `POST /users/lookup/phone` - Lookup by phone number - -**Assets API** -- `POST /assets/upload` - Upload binary assets -- Asset download URLs in record responses - -**Tokens API** -- `POST /tokens/create` - Create web auth token -- `POST /tokens/register` - Register for push notifications - -#### Data Types & Field Types - -**Basic Types** -- `STRING`, `INT64`, `DOUBLE`, `BYTES`, `DATE` - -**Complex Types** -- `LOCATION` - Latitude/longitude coordinates -- `REFERENCE` - References to other records (with delete actions) -- `ASSET` - File attachments with metadata - -**List Types** -- `STRING_LIST`, `INT64_LIST`, `DOUBLE_LIST`, `DATE_LIST` -- `LOCATION_LIST`, `REFERENCE_LIST` - -#### Error Handling -- Error response format -- Common error codes: - - `AUTHENTICATION_REQUIRED` - - `INVALID_ARGUMENTS` - - `NOT_FOUND` - - `CONFLICT` (for record changes) - - `ATOMIC_ERROR` (for batch operations) - - `ZONE_NOT_FOUND` - - `THROTTLED` - - `INTERNAL_ERROR` - -### Key Implementation Patterns - -1. **Batch Operations**: All modify operations support atomic batches -2. **Change Tracking**: Server sync tokens for incremental updates -3. **Conflict Resolution**: Record change tags (ETags) for optimistic locking -4. **Pagination**: Continuation markers for large result sets - ---- - -## cloudkitjs.md - CloudKit JS Framework - -**Source**: Apple's CloudKit JS Documentation (Current) - -### What It Covers - -#### Configuration & Setup -- Embedding CloudKit JS in web pages -- Container configuration with API tokens -- Environment selection (development/production) -- Authentication flows - -#### Core Classes - -**CloudKit Namespace** -- Configuration methods -- Global constants and enumerations -- Container access - -**CloudKit.Container** -- `publicCloudDatabase` - Access public database -- `privateCloudDatabase` - Access private database (requires auth) -- `sharedCloudDatabase` - Access shared database -- User authentication methods -- User discovery operations -- Share acceptance - -**CloudKit.Database** -- **Record Operations**: - - `saveRecords()` - Save/update records - - `fetchRecords()` - Fetch by record IDs - - `deleteRecords()` - Delete records - - `performQuery()` - Query with filters - - `newRecordsBatch()` - Batch builder - -- **Zone Operations**: - - `fetchAllRecordZones()` - List all zones - - `fetchRecordZones()` - Fetch specific zones - - `saveRecordZones()` - Create/update zones - - `deleteRecordZones()` - Delete zones - -- **Subscription Operations**: - - `fetchAllSubscriptions()` - List subscriptions - - `fetchSubscriptions()` - Fetch specific subscriptions - - `saveSubscriptions()` - Create/update subscriptions - - `deleteSubscriptions()` - Delete subscriptions - -- **Change Tracking**: - - `fetchDatabaseChanges()` - Database-level changes - - `fetchRecordZoneChanges()` - Zone-level changes - -#### Query System - -**Filter Comparators** -- Equality: `EQUALS`, `NOT_EQUALS` -- Comparison: `LESS_THAN`, `GREATER_THAN`, etc. -- String: `BEGINS_WITH`, `CONTAINS_ALL_TOKENS` -- List: `IN`, `LIST_CONTAINS` - -**Sort Descriptors** -- Field name -- Ascending/descending order - -**Pagination** -- `resultsLimit` for page size -- `continuationMarker` for next page - -#### Response Objects - -**CloudKit.Response** (base class) -- `isSuccess` - Operation status -- `hasErrors` - Error indication - -**CloudKit.RecordsResponse** -- `records` - Array of fetched records -- `errors` - Array of errors (by record) - -**CloudKit.QueryResponse** -- `records` - Query results -- `moreComing` - Has more pages -- `continuationMarker` - Token for next page - -**CloudKit.RecordsBatchBuilder** -- Fluent API for batch operations -- `create()`, `update()`, `replace()`, `delete()` -- `commit()` to execute - -**CloudKit.DatabaseChangesResponse** -- Changed zones since sync token -- New sync token - -**CloudKit.RecordZoneChangesResponse** -- Changed records in zones -- Deleted record IDs -- New sync tokens per zone - -#### Notification Types - -**CloudKit.Notification** (base) -- Notification ID and type -- Subscription ID - -**CloudKit.QueryNotification** -- Record change details -- Query subscription results - -**CloudKit.RecordZoneNotification** -- Zone change notifications - -#### Error Handling - -**CloudKit.CKError** -- `ckErrorCode` - CloudKit error code -- `isServerError` - Server vs client error -- `serverErrorCode` - Detailed server error -- Retry suggestions - -### Key Concepts - -1. **Databases**: Public (unauthenticated), Private (user), Shared (shared with user) -2. **Zones**: Logical groupings in private/shared databases (default zone always exists) -3. **Subscriptions**: Push notifications for record/zone changes -4. **Change Tracking**: Sync tokens for efficient synchronization -5. **Sharing**: Records can be shared between users - ---- - -## testing-enablinganddisabling.md - Swift Testing Framework - -**Source**: Apple's Swift Testing Documentation (Swift 6.0+) - -### What It Covers - -#### Test Definition - -**@Test Macro** -```swift -@Test("Display name") -func testFunction() async throws { } -``` -- No naming conventions required -- Can be defined anywhere (not just in classes) -- Supports async/await and throwing -- Can have custom display names - -**@Suite Macro** -```swift -@Suite("Feature Tests") -struct FeatureTests { - @Test func testA() { } - @Test func testB() { } -} -``` -- Groups related tests -- Uses Swift's type system -- Can nest suites -- Shares setup/teardown - -#### Test Traits - -**Conditional Execution** -- `.enabled(if: condition)` - Run only if true -- `.disabled()` - Never run -- `.disabled(if: condition)` - Skip if true -- `.disabled("Reason")` - Skip with explanation - -**Time Limits** -- `.timeLimit(.seconds(10))` - Fail if exceeds duration -- `.timeLimit(.minutes(1))` - -**Tags** -- `.tags(.critical)` - Categorize tests -- `.tags(.slow, .integration)` - Multiple tags -- Run specific tags from command line - -**Bug Tracking** -- `.bug("URL")` - Link to bug report -- `.bug(id: "12345")` - Bug number -- Associates tests with known issues - -**Parallelization** -- `.serialized` - Force serial execution -- Default is parallel in-process - -#### Test Parameterization - -**Single Collection** -```swift -@Test(arguments: [1, 2, 3, 4, 5]) -func validate(value: Int) { } -``` - -**Multiple Collections** -```swift -@Test(arguments: ["a", "b"], [1, 2]) -func combine(letter: String, number: Int) { } -``` - -**Zipped Collections** -```swift -@Test(arguments: zip(["a", "b"], [1, 2])) -func paired(letter: String, number: Int) { } -``` - -**Custom Arguments** -```swift -struct TestCase: CustomTestStringConvertible { - let input: String - let expected: Int -} - -@Test(arguments: [ - TestCase(input: "hello", expected: 5), - TestCase(input: "world", expected: 5) -]) -func validate(testCase: TestCase) { } -``` - -#### Expectations - -**#expect() - Continue on failure** -```swift -#expect(value == expected) -#expect(array.count > 0) -#expect(string.contains("test")) -``` - -**#require() - Stop on failure** -```swift -let value = try #require(optionalValue) // Stops if nil -#expect(value.isValid) -``` - -**Error Checking** -```swift -#expect(throws: MyError.invalid) { - try someOperation() -} -``` - -**Async Expectations** -```swift -await #expect { - try await asyncOperation() - return true -} -``` - -#### Migration from XCTest - -| XCTest | Swift Testing | -|--------|---------------| -| `class XCTestCase` | `@Suite struct` or `@Test func` | -| `func testFoo()` | `@Test func foo()` | -| `setUp()` | `init()` | -| `tearDown()` | `deinit` | -| `setUpWithError()` | `init() throws` | -| `tearDownWithError()` | N/A (use defer) | -| `XCTAssertEqual` | `#expect(a == b)` | -| `XCTAssertTrue` | `#expect(value)` | -| `XCTAssertNil` | `#expect(value == nil)` | -| `XCTUnwrap` | `try #require(value)` | -| `XCTAssertThrowsError` | `#expect(throws:)` | -| `addTeardownBlock` | `defer { }` | -| `continueAfterFailure` | `#expect()` (always continues) | - -#### Running Tests - -**Swift Package Manager** -```bash -swift test # Run all -swift test --filter TestName # Run specific -swift test --parallel # Parallel execution -``` - -**Xcode** -- Test navigator shows all `@Test` functions -- Run button next to each test -- Test report shows parameterized cases - ---- - -## How to Use These Docs Together - -### Implementing a Feature - -1. **Design Phase** - - Read `cloudkitjs.md` to understand operation semantics - - Check `webservices.md` for exact REST endpoint details - - Plan Swift API surface - -2. **Implementation Phase** - - Use `webservices.md` for request/response formats - - Map CloudKit types to Swift types - - Implement async/await patterns - - Add error handling - -3. **Testing Phase** - - Use `testing-enablinganddisabling.md` for test patterns - - Write parameterized tests for edge cases - - Test async operations - - Handle error paths - -### Example: Implementing Record Query - -1. **cloudkitjs.md** → Understand `Database.performQuery()` operation -2. **webservices.md** → Get exact POST `/records/query` format -3. Implement Swift async function -4. **testing-enablinganddisabling.md** → Write parameterized tests - ---- - -## Quick Navigation - -- **Need endpoint details?** → `webservices.md` -- **Need CloudKit concepts?** → `cloudkitjs.md` -- **Need test patterns?** → `testing-enablinganddisabling.md` -- **Need quick reference?** → `QUICK_REFERENCE.md` -- **Need integration guide?** → `README.md` diff --git a/.claude/docs/cloudkit-schema-plan.md b/.claude/docs/cloudkit-schema-plan.md deleted file mode 100644 index 067a98b2..00000000 --- a/.claude/docs/cloudkit-schema-plan.md +++ /dev/null @@ -1,561 +0,0 @@ -# CloudKit Schema Plan for Bushel & Demo App - -## Overview - -Three CloudKit record types for tracking macOS restore images, Xcode releases, and Swift versions with their compatibility relationships. Optimized for Bushel's virtualization use case and demonstrating MistKit's capabilities. - -## Record Types - -### 1. RestoreImage - -**Purpose**: macOS IPSW files for Apple Virtualization framework (VirtualMac restore images) - -**Fields**: -- `version` (String, indexed) - macOS version: "14.2.1", "15.0 Beta 3" -- `buildNumber` (String, indexed) - Build identifier: "23C71", "24A5264n" -- `releaseDate` (Date, indexed) - Official release date -- `downloadURL` (String) - Direct IPSW download link -- `fileSize` (Int64) - File size in bytes -- `sha256Hash` (String) - SHA-256 checksum for integrity verification -- `sha1Hash` (String) - SHA-1 hash (from MESU/ipsw.me for compatibility) -- `isSigned` (Boolean, indexed) - Whether Apple still signs this restore image -- `isPrerelease` (Boolean, indexed) - Beta/RC release indicator -- `source` (String) - Data source: "ipsw.me", "mrmacintosh.com", "mesu.apple.com" -- `notes` (String) - Additional metadata or release notes - -**Indexes**: -- `version` - For version lookups -- `buildNumber` - Unique identifier queries -- `releaseDate` - Chronological sorting -- `isSigned` - Filter to signed-only images -- `isPrerelease` - Filter beta vs final releases -- Compound: `(isSigned, releaseDate)` - "Latest signed releases" - -### 2. XcodeVersion - -**Purpose**: Xcode releases with macOS requirements and bundled Swift versions - -**Fields**: -- `version` (String, indexed) - Xcode version: "15.1", "15.2 Beta 3" -- `buildNumber` (String) - Build identifier: "15C65" -- `releaseDate` (Date, indexed) - Release date -- `downloadURL` (String) - Optional developer.apple.com download link -- `fileSize` (Int64) - Download size in bytes -- `isPrerelease` (Boolean, indexed) - Beta/RC indicator -- `minimumMacOS` (Reference) - Link to minimum RestoreImage record required -- `includedSwiftVersion` (Reference) - Link to bundled Swift compiler -- `sdkVersions` (String) - JSON of SDKs: `{"macOS": "14.2", "iOS": "17.2", "watchOS": "10.2"}` -- `notes` (String) - Release notes or additional info - -**Indexes**: -- `version` - Version lookups -- `releaseDate` - Chronological sorting -- `isPrerelease` - Filter production vs beta - -### 3. SwiftVersion - -**Purpose**: Swift compiler releases bundled with Xcode - -**Fields**: -- `version` (String, indexed) - Swift version: "5.9", "5.10", "6.0" -- `releaseDate` (Date, indexed) - Release date -- `downloadURL` (String) - Optional swift.org toolchain download -- `isPrerelease` (Boolean) - Beta/snapshot indicator -- `notes` (String) - Release notes - -**Indexes**: -- `version` - Version lookups -- `releaseDate` - Chronological sorting - -## Relationship Model - -**Simplified unidirectional references:** - -``` -RestoreImage "14.2.1" - - No outbound references - -XcodeVersion "15.1" - ├─ minimumMacOS → RestoreImage "13.5" - └─ includedSwiftVersion → SwiftVersion "5.9.2" - -SwiftVersion "5.9.2" - - No outbound references -``` - -**Example Query (Bushel use case):** -```swift -// 1. Get RestoreImage by version -let restoreImage = queryRestoreImage(version: "14.2.1") - -// 2. Find compatible Xcode versions -let xcodeVersions = queryXcode(where: minimumMacOS.version <= "14.2.1") - -// 3. Display restore image with compatible dev tools -``` - -## Data Sources - -### RestoreImage Records - -#### Primary Source - ipsw.me API via IPSWDownloads Swift Package - -- **Device**: `VirtualMac2,1` (Apple Virtual Machine restore images) -- **Coverage**: 46 final releases from macOS 12.4 (May 2022) onwards -- **Provides**: version, buildid, sha256sum, sha1sum, md5sum, filesize, url, releasedate, signed status -- **Format**: Clean JSON API -- **Package**: https://github.com/brightdigit/IPSWDownloads - -**Example API Response:** -```json -{ - "identifier": "VirtualMac2,1", - "version": "26.1", - "buildid": "25B78", - "sha1sum": "479d6bb78f069062ca016d496fd50804b673e815", - "md5sum": "e270ede6a1eba02253ac42bcd76dab4b", - "sha256sum": "e0217b3cd0f2edb9ab3294480bef5af2a0be43e86d84a15fab6bca31d3802ee8", - "filesize": 18718884780, - "url": "https://updates.cdn-apple.com/2025FallFCS/fullrestores/089-04148/791B6F00-A30B-4EB0-B2E3-257167F7715B/UniversalMac_26.1_25B78_Restore.ipsw", - "releasedate": "2025-11-03T21:34:29Z", - "uploaddate": "2025-10-30T05:50:08Z", - "signed": true -} -``` - -#### Secondary Source - Mr. Macintosh Database - -- **URL**: https://mrmacintosh.com/apple-silicon-m1-full-macos-restore-ipsw-firmware-files-database/ -- **Coverage**: Beta/RC releases including Big Sur 11.x through current -- **Provides**: version, build, releasedate, download url, signing status, beta/RC classification -- **Total additional entries**: ~100+ beta/RC versions -- **Format**: HTML scraping required - -**Data Available:** -- Version number (e.g., "26.1 Beta 4") -- Build identifier (e.g., "25B5072a") -- Release date (formatted as MM/DD or MM/DD/YY) -- Download URL (Apple CDN links) -- Signing status ("YES" or "N/A") -- Release classification (Final, RC, Beta with numbering) - -#### Freshness Detection - Apple MESU XML - -- **URL**: https://mesu.apple.com/assets/macos/com_apple_macOSIPSW/com_apple_macOSIPSW.xml -- **Coverage**: Single entry - currently signed latest release only -- **Provides**: BuildVersion, ProductVersion, FirmwareURL, FirmwareSHA1 -- **Purpose**: Detect new releases immediately, trigger sync if version not in database -- **Update Frequency**: Real-time by Apple - -**Example XML Structure:** -```xml - - - 25B78 - 26.1 - https://updates.cdn-apple.com/.../UniversalMac_26.1_25B78_Restore.ipsw - 479d6bb78f069062ca016d496fd50804b673e815 - - -``` - -**Total Coverage**: ~140+ restore images from Big Sur 11.0 (2020) to current - -### XcodeVersion Records - -#### Primary Source - xcodereleases.com - -- **URL**: https://xcodereleases.com -- **Coverage**: Community-maintained comprehensive database -- **Provides**: version, build, release date, minimum macOS, bundled Swift version, SDK versions -- **Includes**: Both release and beta versions -- **Format**: Likely requires scraping or API if available - -#### Secondary Source - Apple Developer Release Notes - -- **Purpose**: Official documentation for validation -- **Use**: Manual curation for critical metadata - -### SwiftVersion Records - -#### Primary Source - swiftversion.net - -- **URL**: https://swiftversion.net -- **Coverage**: Community-maintained Swift version database -- **Provides**: version, release date, Xcode bundling information -- **Includes**: Comprehensive historical coverage -- **Format**: Likely requires scraping or API if available - -#### Secondary Source - swift.org Official Releases - -- **URL**: https://swift.org -- **Purpose**: Primary validation source -- **Use**: GitHub releases for version tracking - -## Database Configuration - -- **Schema Level**: Container (schema applies to both public and private databases) -- **Database**: Public Database (readable by all, writable with authentication) -- **Write Target**: Demo app writes to public database via `database: .public` parameter -- **Zone**: Default Zone (sufficient for this use case) -- **Write Access**: API token authentication for sync tool (MistKit) -- **Read Access**: Public (Bushel queries directly using native CloudKit framework) -- **Permissions**: `GRANT READ TO "_world"` makes records publicly readable -- **Container**: User-configurable (e.g., `iCloud.com.yourcompany.Bushel`) - -## Data Import Strategy - -### Sync Command Workflow - -#### 1. Fetch from ipsw.me (via IPSWDownloads package) - -```swift -// Query VirtualMac2,1 device for all firmwares -let device = try await ipswDownloads.device("VirtualMac2,1") -let firmwares = device.firmwares - -// Map to RestoreImage records with complete metadata -let restoreImages = firmwares.map { firmware in - RestoreImageRecord( - version: firmware.version, - buildNumber: firmware.buildid, - releaseDate: firmware.releasedate, - downloadURL: firmware.url, - fileSize: firmware.filesize, - sha256Hash: firmware.sha256sum, - sha1Hash: firmware.sha1sum, - isSigned: firmware.signed, - isPrerelease: false, // ipsw.me only has finals - source: "ipsw.me" - ) -} -``` - -#### 2. Fetch from Mr. Macintosh - -```swift -// Scrape database for beta/RC versions -let mrMacHTML = try await fetchHTML("https://mrmacintosh.com/...") -let betaReleases = parseMrMacTable(mrMacHTML) - -// Filter duplicates (match on version + build) -let uniqueBetas = betaReleases.filter { beta in - !restoreImages.contains(where: { $0.buildNumber == beta.buildNumber }) -} - -// Add beta-specific RestoreImage records -restoreImages.append(contentsOf: uniqueBetas.map { beta in - RestoreImageRecord( - version: beta.version, - buildNumber: beta.build, - releaseDate: beta.releaseDate, - downloadURL: beta.url, - isSigned: beta.signed, - isPrerelease: true, // Beta/RC - source: "mrmacintosh.com" - ) -}) -``` - -#### 3. Check MESU XML - -```swift -// Parse for latest signed release -let mesuXML = try await fetchMESU() -let latestRelease = parseMESUXML(mesuXML) - -// If version not in database, add from MESU -if !restoreImages.contains(where: { $0.buildNumber == latestRelease.buildNumber }) { - restoreImages.append(RestoreImageRecord( - version: latestRelease.productVersion, - buildNumber: latestRelease.buildVersion, - downloadURL: latestRelease.firmwareURL, - sha1Hash: latestRelease.firmwareSHA1, - isSigned: true, - isPrerelease: false, - source: "mesu.apple.com" - )) -} - -// Update signing status for existing records -// (MESU only lists currently signed version, so others are unsigned) -``` - -#### 4. Fetch Xcode data (xcodereleases.com) - -```swift -// Parse versions and requirements -let xcodeReleases = try await fetchXcodeReleases() - -// Create XcodeVersion records with References to RestoreImage -let xcodeRecords = xcodeReleases.map { release in - XcodeVersionRecord( - version: release.version, - buildNumber: release.build, - releaseDate: release.date, - isPrerelease: release.isBeta, - minimumMacOS: referenceToRestoreImage(version: release.minMacOS), - includedSwiftVersion: referenceToSwiftVersion(version: release.swiftVersion), - sdkVersions: release.sdks.toJSON() - ) -} -``` - -#### 5. Fetch Swift data (swiftversion.net) - -```swift -// Parse versions and metadata -let swiftReleases = try await fetchSwiftVersions() - -// Create SwiftVersion records -let swiftRecords = swiftReleases.map { release in - SwiftVersionRecord( - version: release.version, - releaseDate: release.date, - isPrerelease: release.isBeta - ) -} - -// Link from XcodeVersion records (already done in step 4) -``` - -#### 6. Upsert to CloudKit - -```swift -// Use record IDs based on version+build for idempotency -for image in restoreImages { - let recordID = CKRecord.ID(recordName: "RestoreImage-\(image.buildNumber)") - - // Update existing records if data changed - // Create new records for new versions - try await cloudKit.save(image, withID: recordID) -} - -// Same for XcodeVersion and SwiftVersion records -``` - -### Export Command - -Simple JSON dump for inspection/debugging: - -```json -{ - "restoreImages": [ - { - "version": "14.2.1", - "buildNumber": "23C71", - "releaseDate": "2024-01-22T00:00:00Z", - "downloadURL": "https://updates.cdn-apple.com/.../UniversalMac_14.2.1_23C71_Restore.ipsw", - "fileSize": 17892345678, - "sha256Hash": "abc123...", - "isSigned": true, - "isPrerelease": false, - "source": "ipsw.me" - } - ], - "xcodeVersions": [...], - "swiftVersions": [...] -} -``` - -## Demo CLI Application - -### Two Commands - -#### 1. `sync` - Import/update all data from sources to CloudKit - -```bash -# Full sync - fetch all data from all sources -./demo sync - -# Incremental sync - only check for new versions -./demo sync --incremental - -# Dry run - preview changes without writing to CloudKit -./demo sync --dry-run - -# Sync specific record types only -./demo sync --restore-images-only -./demo sync --xcode-only -./demo sync --swift-only -``` - -**Implementation:** -- Uses MistKit for all CloudKit operations -- Implements async/await throughout -- Handles rate limiting and batch operations -- Provides progress output -- Logs all changes made - -#### 2. `export` - Export CloudKit data to JSON - -```bash -# Export all records to stdout -./demo export - -# Write to file -./demo export --output data.json - -# Export specific record types -./demo export --restore-images-only -./demo export --xcode-only - -# Pretty-print JSON -./demo export --pretty - -# Filter exports -./demo export --signed-only -./demo export --no-betas -``` - -**Implementation:** -- Queries CloudKit for all records -- Serializes to JSON -- Supports filtering and formatting options - -## Bushel Integration Pattern - -Bushel will use **native CloudKit framework** (not MistKit) to query the public database: - -### Example Queries - -#### 1. Get all signed restore images, sorted by date - -```swift -let query = CKQuery( - recordType: "RestoreImage", - predicate: NSPredicate(format: "isSigned == true") -) -query.sortDescriptors = [NSSortDescriptor(key: "releaseDate", ascending: false)] - -let results = try await publicDatabase.records(matching: query) -``` - -#### 2. Filter to final releases only (no betas) - -```swift -let query = CKQuery( - recordType: "RestoreImage", - predicate: NSPredicate(format: "isSigned == true AND isPrerelease == false") -) -``` - -#### 3. Find compatible Xcode versions for a restore image - -```swift -// For a given restore image version, find Xcode versions that can run on it -let query = CKQuery( - recordType: "XcodeVersion", - predicate: NSPredicate(format: "minimumMacOS.version <= %@", "14.2.1") -) -``` - -#### 4. Get Swift version for an Xcode release - -```swift -// Fetch Xcode record -let xcodeRecord = try await publicDatabase.record(for: xcodeRecordID) - -// Fetch referenced Swift version -let swiftReference = xcodeRecord["includedSwiftVersion"] as! CKRecord.Reference -let swiftRecord = try await publicDatabase.record(for: swiftReference.recordID) -``` - -### Display Patterns - -Bushel can display: -- Restore image metadata: version, build, size, signing status, release date -- Compatible Xcode versions for each restore image -- Swift versions bundled with Xcode -- Filtering options: final vs beta, signed vs unsigned -- Search by version number or build - -## Implementation Plan - -### Phase 1: Schema Documentation ✓ - -- [x] Create `cloudkit-schema-plan.md` with complete schema definition -- [x] Document all fields, indexes, relationships -- [x] Include query patterns and examples - -### Phase 2: Swift Model Types - -- [ ] Define Codable structs matching CloudKit schema - - `RestoreImageRecord` - - `XcodeVersionRecord` - - `SwiftVersionRecord` -- [ ] Create CloudKit field mapping helpers -- [ ] Implement Reference type handling for relationships - -### Phase 3: Data Fetchers - -- [ ] Integrate IPSWDownloads package for ipsw.me -- [ ] Implement Mr. Macintosh HTML scraper -- [ ] Implement MESU XML parser -- [ ] Implement xcodereleases.com parser (research API/scraping approach) -- [ ] Implement swiftversion.net parser (research API/scraping approach) - -### Phase 4: Demo CLI with MistKit - -- [ ] Setup Swift Package with MistKit dependency -- [ ] Setup CloudKit container and configure authentication -- [ ] Implement `sync` command with data pipeline -- [ ] Implement `export` command for inspection -- [ ] Add Swift ArgumentParser for CLI interface -- [ ] Add logging and error handling - -### Phase 5: Blog Post Integration - -- [ ] Demonstrate MistKit usage patterns in blog post -- [ ] Show CloudKit querying with async/await -- [ ] Highlight practical real-world use case -- [ ] Document lessons learned -- [ ] Include code examples from demo app - -## Reference Documentation - -### MobileAsset Framework - -Key insights from TheAppleWiki MobileAsset documentation: - -- MESU (mesu.apple.com) serves **static XML plists** containing asset metadata -- MESU is **not a MobileAsset** - it's a special firmware manifest system -- The macOS IPSW XML is one of three special plists: - - `macos/com_apple_macOSIPSW/com_apple_macOSIPSW.xml` - - `bridgeos/com_apple_bridgeOSIPSW/com_apple_bridgeOSIPSW.xml` - - `visionos/com_apple_visionOSIPSW/com_apple_visionOSIPSW.xml` -- These contain URLs for **.ipsw files for the latest version** only -- MESU is intentionally limited to current signed releases - -### Firmware Wiki - -Key insights from TheAppleWiki Firmware documentation: - -- Main firmware manifest for iOS/iPod/Apple TV/HomePod mini: https://s.mzstatic.com/version -- Separate manifests for macOS, bridgeOS, visionOS (listed above) -- MESU serves only the **latest signed version**, updated in real-time by Apple -- Historical versions and beta releases require community databases like ipsw.me - -### ipsw.me API - -- **Devices endpoint**: https://api.ipsw.me/v4/devices -- **Device firmware endpoint**: https://api.ipsw.me/v4/device/{identifier} -- **VirtualMac identifier**: `VirtualMac2,1` for Apple Virtualization framework -- Comprehensive coverage: 46 final releases from macOS 12.4 onwards -- Complete metadata: SHA-256, SHA-1, MD5, file sizes, release dates, signing status - -## Next Steps - -1. Save MobileAsset and Firmware wiki documentation for future reference -2. Update Task 5 subtasks in Task Master with refined implementation plan -3. Begin Swift model type definitions -4. Research xcodereleases.com and swiftversion.net data access methods -5. Setup CloudKit container configuration -6. Begin demo app scaffolding with MistKit integration - -## Notes - -- This schema is designed for **public database** read access by Bushel -- Demo app uses **MistKit** to populate and maintain CloudKit data -- Bushel uses **native CloudKit framework** to query the data -- Blog post will showcase both approaches as a complete ecosystem diff --git a/.claude/docs/https_-swiftpackageindex.com-brightdigit-SyndiKit-0.6.1-documentation-syndikit.md b/.claude/docs/https_-swiftpackageindex.com-brightdigit-SyndiKit-0.6.1-documentation-syndikit.md deleted file mode 100644 index 1a0a1c1d..00000000 --- a/.claude/docs/https_-swiftpackageindex.com-brightdigit-SyndiKit-0.6.1-documentation-syndikit.md +++ /dev/null @@ -1,488 +0,0 @@ - - -# https://swiftpackageindex.com/brightdigit/SyndiKit/0.6.1/documentation/syndikit - -Framework - -# SyndiKit - -Swift Package for Decoding RSS Feeds. - -## Overview - -Built on top of XMLCoder, **SyndiKit** provides models and utilities for decoding RSS feeds of various formats and extensions. - -### Features - -- Import of RSS 2.0, Atom, and JSONFeed formats - -- Extensions for various formats such as: - -- iTunes-compatabile podcasts - -- YouTube channels - -- WordPress export data -- User-friendly errors - -- Abstractions for format-agnostic parsing - -### Requirements - -**Apple Platforms** - -- Xcode 13.3 or later - -- Swift 5.5.2 or later - -- iOS 15.4 / watchOS 8.5 / tvOS 15.4 / macOS 12.3 or later deployment targets - -**Linux** - -- Ubuntu 18.04 or later - -### Installation - -Swift Package Manager is Apple’s decentralized dependency manager to integrate libraries to your Swift projects. It is now fully integrated with Xcode 11. - -To integrate **SyndiKit** into your project using SPM, specify it in your Package.swift file: - -let package = Package( -... -dependencies: [\ -.package(url: "https://github.com/brightdigit/SyndiKit", from: "0.3.0")\ -], -targets: [\ -.target(\ -name: "YourTarget",\ -dependencies: ["SyndiKit", ...]),\ -...\ -] -) - -If this is for an Xcode project simply import the Github repository at: - -### Decoding Your First Feed - -You can get started decoding your feed by creating your first `SynDecoder`. Once you’ve created you decoder you can decode using `decode(_:)`: - -let decoder = SynDecoder() -let empowerAppsData = Data(contentsOf: "empowerapps-show.xml")! -let empowerAppsRSSFeed = try decoder.decode(empowerAppsData) - -### Working with Abstractions - -Rather than working directly with the various formats, **SyndiKit** abstracts many of the common properties of the various formats. This enables developers to be agnostic regarding the specific format. - -let decoder = SynDecoder() - -// decoding a RSS 2.0 feed -let empowerAppsData = Data(contentsOf: "empowerapps-show.xml")! -let empowerAppsRSSFeed = try decoder.decode(empowerAppsData) -print(empowerAppsRSSFeed.title) // Prints "Empower Apps" - -// decoding a Atom feed from YouTube -let kiloLocoData = Data(contentsOf: "kilo.youtube.xml")! -let kiloLocoAtomFeed = try decoder.decode(kiloLocoData) -print(kiloLocoAtomFeed.title) // Prints "Kilo Loco" - -For a mapping of properties: - -| Feedable | RSS 2.0 `channel` | Atom `AtomFeed` | JSONFeed `JSONFeed` | -| --- | --- | --- | --- | -| `title` | `title` | `title` | `title` | -| `siteURL` | `link` | `siteURL` | `title` | -| `summary` | `description` | `summary` | `homePageUrl` | -| `updated` | `lastBuildDate` | `pubDate` or `published` | `nil` | -| `authors` | `author` | `authors` | `author` | -| `copyright` | `copyright` | `nil` | `nil` | -| `image` | `url` | `links`.`first` | `nil` | -| `children` | `items` | `entries` | `items` | - -### Specifying Formats - -If you wish to access properties of specific formats, you can attempt to cast the objects to see if they match: - -let empowerAppsRSSFeed = try decoder.decode(empowerAppsData) -if let rssFeed = empowerAppsRSSFeed as? RSSFeed { -print(rssFeed.channel.title) // Prints "Empower Apps" -} - -let kiloLocoAtomFeed = try decoder.decode(kiloLocoData) -if let atomFeed = kiloLocoAtomFeed as? AtomFeed { -print(atomFeed.title) // Prints "Kilo Loco" -} - -### Accessing Extensions - -In addition to supporting RSS, Atom, and JSONFeed, **SyndiKit** also supports various RSS extensions for specific media including: YouTube, iTunes, and WordPress. - -You can access these properties via their specific feed formats or via the `media` property on `Entryable`. - -let empowerAppsRSSFeed = try decoder.decode(empowerAppsData) -switch empowerAppsRSSFeed.children.last?.media { -case .podcast(let podcast): -print(podcast.title) // print "WWDC 2018 - What Does It Mean For Businesses?" -default: -print("Not a Podcast! 🤷‍♂️") -} - -let kiloLocoAtomFeed = try decoder.decode(kiloLocoData) -switch kiloLocoAtomFeed.children.last?.media { -case .video(.youtube(let youtube): -print(youtube.videoID) // print "SBJFl-3wqx8" -print(youtube.channelID) // print "UCv75sKQFFIenWHrprnrR9aA" -default: -print("Not a Youtube Video! 🤷‍♂️") -} - -| `MediaContent` | Actual Property | -| --- | --- | -| `title` | `itunesTitle` | -| `episode` | `itunesEpisode` | -| `author` | `itunesAuthor` | -| `subtitle` | `itunesSubtitle` | -| `summary` | `itunesSummary` | -| `explicit` | `itunesExplicit` | -| `duration` | `itunesDuration` | -| `image` | `itunesImage` | -| `channelID` | `youtubeChannelID` | -| `videoID` | `youtubeVideoID` | - -## Topics - -### Decoding an RSS Feed - -`class SynDecoder` - -An object that decodes instances of Feedable from JSON or XML objects. - -### Basic Feeds - -The basic types used by **SyndiKit** for traversing the feed in abstract manner without needing the specific properties from the various feed formats. - -`protocol Feedable` - -Basic abstract Feed - -`protocol Entryable` - -Basic Feed type with abstract properties. - -`struct Author` - -a person, corporation, or similar entity. - -`protocol EntryCategory` - -Abstract category type. - -`enum EntryID` - -An identifier for an entry based on the RSS guid. - -### Abstract Media Types - -Abstract media types which can be pulled for the various `Entryable` objects. - -`protocol PodcastEpisode` - -A protocol representing a podcast episode. - -`enum MediaContent` - -A struct representing an Atom category. Represents different types of media content. - -`enum Video` - -A struct representing an Atom category. An enumeration representing different types of videos. - -### XML Primitive Types - -In many cases, types are encoded in non-matching types but are intended to strong-typed for various formats. These primitives are setup to make XML decoding easier while retaining their intended strong-type. - -`struct CData` - -#CDATA XML element. - -`struct XMLStringInt` - -XML Element which contains a `String` parsable into a `Integer`. - -`struct ListString` - -A struct representing a list of values that can be encoded/decoded as a comma-separated string. Useful for handling feed formats where multiple values are stored in a single string field. - -### Syndication Updates - -Properties from the RDF Site Summary Syndication Module concerning how often it is updated a feed is updated. - -`struct SyndicationUpdate` - -Properties concerning how often it is updated a feed is updated. - -`enum SyndicationUpdatePeriod` - -Describes the period over which the channel format is updated. - -`typealias SyndicationUpdateFrequency` - -Used to describe the frequency of updates in relation to the update period. A positive integer indicates how many times in that period the channel is updated. - -### Atom Feed Format - -Specific properties related to the Atom format. - -`struct AtomFeed` - -A struct representing an Atom category. An XML-based Web content and metadata syndication format. - -`struct AtomEntry` - -A struct representing an entry in an Atom feed. - -`struct AtomCategory` - -A struct representing an Atom category. A struct representing an Atom category. - -`struct AtomMedia` - -A struct representing an Atom category. Media structure which enables content publishers and bloggers to syndicate multimedia content such as TV and video clips, movies, images and audio. - -`struct AtomMediaGroup` - -A group of media elements in an Atom feed. - -`struct Link` - -A struct representing a link with a URL and optional relationship type. Used in various feed formats to represent hyperlinks with metadata. - -### JSON Feed Format - -Specific properties related to the JSON Feed format. - -`struct JSONFeed` - -A struct representing an Atom category. A struct representing a JSON feed. - -`struct JSONItem` - -A struct representing an Atom category. A struct representing an item in JSON format. - -### OPML Feed Formate - -`struct OPML` - -A struct representing an OPML (Outline Processor Markup Language) document. OPML is an XML format for outlines that can be used to exchange subscription lists between feed readers. It consists of a version, head section with metadata, and body section with outline elements. - -`enum OutlineType` - -### RSS Feed Format - -Specific properties related to the RSS Feed format. - -`struct RSSFeed` - -A struct representing an Atom category. RSS is a Web content syndication format. - -`struct RSSChannel` - -A struct representing an Atom category. A struct representing information about the channel (metadata) and its contents. - -`struct RSSImage` - -Represents a GIF, JPEG, or PNG image. - -`struct RSSItem` - -A struct representing an RSS item/entry. RSS items contain the individual pieces of content within an RSS feed, including title, link, description, publication date, and various media attachments. - -`struct RSSItemCategory` - -A struct representing an Atom category. A struct representing a category for an RSS item. - -`struct Enclosure` - -A struct representing an enclosure for a resource. - -### Podcast Extensions - -Specific properties related to . - -`struct PodcastPerson` - -A struct representing a person associated with a podcast. - -`struct PodcastSeason` - -A struct representing a season of a podcast. - -`struct PodcastChapters` - -A struct representing chapters of a podcast. - -`struct PodcastLocation` - -A struct representing the location of a podcast. - -`struct PodcastSoundbite` - -A struct representing a soundbite from a podcast. - -`struct PodcastTranscript` - -A struct representing a podcast transcript. - -`struct PodcastFunding` - -A struct representing funding information for a podcast. - -`struct PodcastLocked` - -A struct representing a locked podcast. - -### WordPress Extensions - -Specific extension properties provided by WordPress. - -`enum WordPressElements` - -A namespace for WordPress related elements. - -`struct WordPressPost` - -A struct representing a WordPress post. - -`typealias WPTag` - -A typealias for `WordPressElements.Tag` - -`typealias WPCategory` - -A typealias for `WordPressElements.Category` - -`typealias WPPostMeta` - -A typealias for `WordPressElements.PostMeta`. - -`enum WordPressError` - -An error type representing a missing field in a WordPress post. - -### YouTube Extensions - -Specific type abstracting the id properties a YouTube RSS Feed. - -`protocol YouTubeID` - -A struct representing an Atom category. A protocol abstracting the ID properties of a YouTube RSS Feed. - -### iTunes Extensions - -Specific extension properties provided by iTunes regarding mostly podcasts and their episodes. - -`typealias iTunesImage` - -A type alias for iTunes image links. - -`struct iTunesOwner` - -A struct representing an Atom category. A struct representing the owner of an iTunes account. - -`typealias iTunesEpisode` - -A struct representing an Atom category. A type alias for an iTunes episode. - -`struct iTunesDuration` - -A struct representing the duration of an iTunes track. - -### Site Directories - -Types related to the format used by the . - -`protocol SiteDirectory` - -A protocol for site directories. - -`struct SiteCollectionDirectory` - -A directory of site collections. - -`protocol SiteDirectoryBuilder` - -A protocol for building site directories. - -`struct CategoryDescriptor` - -A struct representing an Atom category. A descriptor for a category. - -`struct CategoryLanguage` - -A struct representing an Atom category. A struct representing a category in a specific language. - -`struct Site` - -A struct representing a website. - -`struct SiteCategory` - -A struct representing an Atom category. A struct representing a site category. - -`struct SiteCollectionDirectoryBuilder` - -A builder for creating a site collection directory. - -`struct SiteLanguage` - -A struct representing an Atom category. A struct representing a site language. - -`struct SiteLanguageCategory` - -A struct representing an Atom category. A struct representing a category of site languages. - -`struct SiteLanguageContent` - -A struct representing an Atom category. A struct representing the content of a site in a specific language. - -`typealias SiteCategoryType` - -A type alias representing a site category. - -`typealias SiteCollection` - -A collection of site language content. - -`typealias SiteLanguageType` - -A type representing the language of a website. - -`typealias SiteStub` - -A type alias for `SiteLanguageCategory.Site`. - -- SyndiKit -- Overview -- Features -- Requirements -- Installation -- Decoding Your First Feed -- Working with Abstractions -- Specifying Formats -- Accessing Extensions -- License -- Topics - -| -| - ---- - diff --git a/.claude/docs/mistdemo/phases/phase-1-core-infrastructure.md b/.claude/docs/mistdemo/phases/phase-1-core-infrastructure.md index 0534b062..f40c091d 100644 --- a/.claude/docs/mistdemo/phases/phase-1-core-infrastructure.md +++ b/.claude/docs/mistdemo/phases/phase-1-core-infrastructure.md @@ -185,7 +185,7 @@ targets: [ **Note**: MistDemo uses Swift Configuration (v1.0.0+) for configuration management, replacing ArgumentParser during Phase 1 (issue #212). The current architecture uses manual argument parsing with hierarchical provider resolution (CLI → Environment → Defaults). See `MistDemoConfig.swift` for the implementation pattern. **Reference Documentation:** -- [Swift Configuration Guide](.claude/docs/https_-swiftpackageindex.com-apple-swift-configuration-1.0.0-documentation-configuration.md) +- [Swift Configuration Guide](.claude/docs/swift-configuration.md) - [MistDemo Swift Configuration Reference](./swift-configuration-reference.md) ## File Structure diff --git a/.claude/docs/mistdemo/swift-configuration-reference.md b/.claude/docs/mistdemo/swift-configuration-reference.md index 1935c140..16a37c0f 100644 --- a/.claude/docs/mistdemo/swift-configuration-reference.md +++ b/.claude/docs/mistdemo/swift-configuration-reference.md @@ -343,7 +343,7 @@ let providers: [ConfigurationProvider] = [ - **Official Package**: [apple/swift-configuration](https://github.com/apple/swift-configuration) - **Package Index**: [Swift Configuration 1.0.0 Documentation](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration) -- **Local Reference**: [.claude/docs/https_-swiftpackageindex.com-apple-swift-configuration-1.0.0-documentation-configuration.md](../.claude/docs/https_-swiftpackageindex.com-apple-swift-configuration-1.0.0-documentation-configuration.md) +- **Local Reference**: [.claude/docs/swift-configuration.md](../.claude/docs/swift-configuration.md) - **MistDemo Implementation**: `Examples/MistDemo/Sources/MistDemo/Configuration/MistDemoConfig.swift` - **ConfigKeyKit Strategy**: [configkeykit-strategy.md](./configkeykit-strategy.md) diff --git a/.claude/docs/protocol-extraction-continuation.md b/.claude/docs/protocol-extraction-continuation.md deleted file mode 100644 index c3a8905a..00000000 --- a/.claude/docs/protocol-extraction-continuation.md +++ /dev/null @@ -1,559 +0,0 @@ -# MistKit Protocol Extraction - Continuation Guide - -## Current State (As of this session) - -### ✅ Completed Work - -#### Phase 1: Critical Build Fixes -- ✅ Added missing `processLookupRecordsResponse` method -- ✅ Complete boolean support in FieldValue system -- ✅ All 157 tests passing -- ✅ Build successful - -#### Phase 2: API Consolidation -- ✅ Deprecated `CloudKitService+RecordModification.swift` methods -- ✅ Enhanced `CloudKitService+WriteOperations.swift` with optional `recordName` -- ✅ Clear migration path established - -#### Phase 3: Simple Cleanup -- ✅ Deleted `Examples/MistDemo` directory -- ✅ Linting verified (only pre-existing style warnings) - -### 🔄 Remaining Work (Phase 3 continuation) - -The major work item remaining is **extracting Bushel's protocol-oriented patterns into MistKit core**. This is a 6-8 hour task that will significantly improve the developer experience. - ---- - -## Quick Verification Commands - -Before starting, verify the current state: - -```bash -cd /Users/leo/Documents/Projects/MistKit - -# Should build cleanly -swift build - -# Should show 157/157 tests passing -swift test - -# Should show current branch -git branch --show-current -# Expected: blog-post-examples-code-celestra - -# Verify Bushel example exists -ls Examples/Bushel/Sources/Bushel/Protocols/ -# Should show: CloudKitRecord.swift, RecordManaging.swift, etc. - -# Verify MistDemo was deleted -ls Examples/ -# Should show only: Bushel, Celestra (no MistDemo) -``` - ---- - -## Remaining Tasks Breakdown - -### Task 1: Extract CloudKitRecord Protocol (2-3 hours) - -**Source:** `Examples/Bushel/Sources/Bushel/Protocols/CloudKitRecord.swift` - -**Destination:** `Sources/MistKit/Protocols/CloudKitRecord.swift` - -**What to extract:** -```swift -public protocol CloudKitRecord: Codable, Sendable { - static var cloudKitRecordType: String { get } - var recordName: String { get } - func toCloudKitFields() -> [String: FieldValue] - static func from(recordInfo: RecordInfo) -> Self? - static func formatForDisplay(_ recordInfo: RecordInfo) -> String -} -``` - -**Steps:** -1. Create `Sources/MistKit/Protocols/` directory -2. Copy `CloudKitRecord.swift` from Bushel to new location -3. Update imports (should only need `Foundation`) -4. Make protocol `public` (it's currently internal in Bushel) -5. Update file header with MistKit copyright - -**Testing:** -- Build should succeed -- Create a simple test conforming a test struct to `CloudKitRecord` -- Verify protocol requirements are clear - ---- - -### Task 2: Extract RecordManaging Protocol (1-2 hours) - -**Source:** `Examples/Bushel/Sources/Bushel/Protocols/RecordManaging.swift` - -**Destination:** `Sources/MistKit/Protocols/RecordManaging.swift` - -**What to extract:** -```swift -public protocol RecordManaging { - func queryRecords(recordType: String) async throws -> [RecordInfo] - func executeBatchOperations(_ operations: [RecordOperation], recordType: String) async throws -} -``` - -**Key Decision:** The protocol in Bushel throws untyped errors, but MistKit uses `throws(CloudKitError)`. - -**Recommendation:** Use untyped `throws` for protocol flexibility, implementations can be more specific. - -**Steps:** -1. Copy `RecordManaging.swift` to `Sources/MistKit/Protocols/` -2. Make protocol `public` -3. Update to use MistKit's `RecordInfo` and `RecordOperation` types -4. Update file header - ---- - -### Task 3: Add FieldValue Convenience Extensions (2 hours) - -**Source:** `Examples/Bushel/Sources/Bushel/Extensions/FieldValue+Extensions.swift` - -**Destination:** `Sources/MistKit/Extensions/FieldValue+Convenience.swift` - -**What to add:** -```swift -extension FieldValue { - public var stringValue: String? { - if case .string(let value) = self { return value } - return nil - } - - public var intValue: Int? { - if case .int64(let value) = self { return value } - return nil - } - - public var boolValue: Bool? { - if case .boolean(let value) = self { return value } - return nil - } - - public var dateValue: Date? { - if case .date(let value) = self { return value } - return nil - } - - public var referenceValue: Reference? { - if case .reference(let value) = self { return value } - return nil - } - - // Add similar for: doubleValue, bytesValue, locationValue, assetValue, listValue -} -``` - -**Note:** Check if Bushel has these - they're **essential** for the `CloudKitRecord` protocol to work ergonomically. - -**Testing:** -```swift -let fields: [String: FieldValue] = ["name": .string("Test")] -XCTAssertEqual(fields["name"]?.stringValue, "Test") -XCTAssertNil(fields["name"]?.intValue) -``` - ---- - -### Task 4: Add RecordManaging Generic Extensions (3-4 hours) - -**Source:** `Examples/Bushel/Sources/Bushel/Protocols/RecordManaging+Generic.swift` - -**Destination:** `Sources/MistKit/Extensions/RecordManaging+Generic.swift` - -**What to extract:** -```swift -public extension RecordManaging { - func sync(_ records: [T]) async throws { - // Convert records to RecordOperation array - // Call executeBatchOperations - } - - func query(_ type: T.Type) async throws -> [T] { - // Query by cloudKitRecordType - // Convert RecordInfo results using T.from() - } - - func list(_ type: T.Type) async throws -> [RecordInfo] { - // Query and return raw RecordInfo - } -} -``` - -**Critical Implementation Details:** - -1. **Batch Size Handling** (CloudKit limit: 200 operations) -```swift -func sync(_ records: [T]) async throws { - let operations = records.map { record in - RecordOperation.create( - recordType: T.cloudKitRecordType, - recordName: record.recordName, - fields: record.toCloudKitFields() - ) - } - - // Split into chunks of 200 - for chunk in operations.chunked(size: 200) { - try await executeBatchOperations(chunk, recordType: T.cloudKitRecordType) - } -} -``` - -2. **Error Handling** - See Bushel's implementation for handling partial failures - -**Testing:** -- Create test struct conforming to `CloudKitRecord` -- Test sync with < 200 records -- Test sync with > 200 records (batching) -- Test query operations -- Verify type safety - ---- - -### Task 5: Add CloudKitService Conformance (1 hour) - -**Destination:** `Sources/MistKit/Service/CloudKitService+RecordManaging.swift` - -**Implementation:** -```swift -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -extension CloudKitService: RecordManaging { - public func queryRecords(recordType: String) async throws -> [RecordInfo] { - // Use existing queryRecords implementation - try await self.queryRecords( - recordType: recordType, - desiredKeys: nil, - filters: [], - sortDescriptors: [] - ) - } - - public func executeBatchOperations(_ operations: [RecordOperation], recordType: String) async throws { - _ = try await self.modifyRecords(operations) - } -} -``` - -**Testing:** -- Verify CloudKitService now conforms to RecordManaging -- Test that generic extensions work on CloudKitService instances - ---- - -### Task 6: Update Bushel to Import from MistKit (1 hour) - -**Files to update:** -- `Examples/Bushel/Sources/Bushel/Protocols/CloudKitRecord.swift` - DELETE -- `Examples/Bushel/Sources/Bushel/Protocols/RecordManaging.swift` - DELETE -- `Examples/Bushel/Sources/Bushel/Protocols/RecordManaging+Generic.swift` - DELETE -- All Bushel source files that reference these protocols - -**Changes:** -```swift -// OLD: -import protocol Bushel.CloudKitRecord - -// NEW: -import MistKit -// CloudKitRecord is now part of MistKit -``` - -**Verification:** -```bash -cd Examples/Bushel -swift build -# Should build successfully using MistKit's protocols -``` - ---- - -### Task 7: Add Tests for Protocols (2-3 hours) - -**Create:** `Tests/MistKitTests/Protocols/CloudKitRecordTests.swift` - -**Test Coverage:** -```swift -@Test("CloudKitRecord protocol conformance") -func testCloudKitRecordConformance() async throws { - struct TestRecord: CloudKitRecord { - static var cloudKitRecordType: String { "TestRecord" } - var recordName: String - var name: String - var count: Int - - func toCloudKitFields() -> [String: FieldValue] { - ["name": .string(name), "count": .int64(count)] - } - - static func from(recordInfo: RecordInfo) -> TestRecord? { - guard let name = recordInfo.fields["name"]?.stringValue, - let count = recordInfo.fields["count"]?.intValue else { - return nil - } - return TestRecord(recordName: recordInfo.recordName, name: name, count: count) - } - - static func formatForDisplay(_ recordInfo: RecordInfo) -> String { - recordInfo.recordName - } - } - - let record = TestRecord(recordName: "test-1", name: "Test", count: 42) - #expect(record.toCloudKitFields()["name"]?.stringValue == "Test") - #expect(record.toCloudKitFields()["count"]?.intValue == 42) -} - -@Test("RecordManaging generic operations") -func testRecordManagingSync() async throws { - // Test with mock CloudKitService - // Verify sync operations work - // Verify batching works (test with > 200 records) -} -``` - ---- - -### Task 8: Advanced Features (Optional - 2-3 hours) - -**Only if time permits:** - -**Source:** `Examples/Bushel/Sources/Bushel/Protocols/CloudKitRecordCollection.swift` - -This uses Swift 6.0 variadic generics for type-safe multi-record-type operations: - -```swift -protocol CloudKitRecordCollection { - associatedtype RecordTypeSetType: RecordTypeIterating - static var recordTypes: RecordTypeSetType { get } -} -``` - -**Enables:** -```swift -try await service.syncAllRecords(swiftVersions, restoreImages, xcodeVersions) -``` - -**Decision:** This is advanced and can be deferred. The core protocols provide 90% of the value. - ---- - -## Important Context & Decisions - -### Why Extract to Core? - -1. **Reduces Boilerplate:** From ~50 lines to ~20 lines per model -2. **Type Safety:** Compile-time guarantees, eliminates stringly-typed APIs -3. **Production Tested:** Bushel uses this in production syncing 1000+ records -4. **DX Improvement:** This was the #1 request from early users - -### Key Design Principles - -1. **Additive Only:** No breaking changes to existing APIs -2. **Protocol-Oriented:** Enables testing via mocking -3. **Swift 6 Ready:** All types are `Sendable` -4. **Documentation First:** Every public API needs examples - -### Potential Issues to Watch - -1. **Boolean Confusion:** CloudKit uses int64 (0/1) on wire, Swift uses Bool - - Document this clearly in `CloudKitRecord` protocol docs - - FieldValue convenience extensions handle the conversion - -2. **Batch Limits:** CloudKit has 200 operations per request limit - - The generic `sync()` must chunk operations - - See Bushel's implementation for reference - -3. **Error Handling:** Bushel's `RecordInfo.isError` pattern is fragile - - Consider improving error handling in MistKit's implementation - - Maybe add typed errors for batch operations - ---- - -## File Reference Map - -### Source Files in Bushel (to extract from) - -``` -Examples/Bushel/Sources/Bushel/ -├── Protocols/ -│ ├── CloudKitRecord.swift → Extract to MistKit/Protocols/ -│ ├── RecordManaging.swift → Extract to MistKit/Protocols/ -│ ├── RecordManaging+Generic.swift → Extract to MistKit/Extensions/ -│ ├── CloudKitRecordCollection.swift → Optional (advanced) -│ └── RecordTypeSet.swift → Optional (advanced) -├── Extensions/ -│ └── FieldValue+Extensions.swift → Extract to MistKit/Extensions/ -└── Services/ - └── BushelCloudKitService.swift → Reference for conformance example -``` - -### Target Structure in MistKit - -``` -Sources/MistKit/ -├── Protocols/ -│ ├── CloudKitRecord.swift ← NEW -│ ├── RecordManaging.swift ← NEW -│ └── CloudKitRecordCollection.swift ← NEW (optional) -├── Extensions/ -│ ├── FieldValue+Convenience.swift ← NEW -│ ├── RecordManaging+Generic.swift ← NEW -│ └── RecordManaging+RecordCollection.swift ← NEW (optional) -└── Service/ - └── CloudKitService+RecordManaging.swift ← NEW (conformance) - -Tests/MistKitTests/ -└── Protocols/ - ├── CloudKitRecordTests.swift ← NEW - └── RecordManagingTests.swift ← NEW -``` - ---- - -## Example Domain Models to Test With - -Use these as test cases (from Bushel): - -### Simple Model: -```swift -struct SwiftVersionRecord: CloudKitRecord { - static var cloudKitRecordType: String { "SwiftVersion" } - var recordName: String - var version: String - var releaseDate: Date - - // Implement protocol requirements... -} -``` - -### Complex Model with References: -```swift -struct XcodeVersionRecord: CloudKitRecord { - static var cloudKitRecordType: String { "XcodeVersion" } - var recordName: String - var version: String - var buildNumber: String - var releaseDate: Date - var swiftVersion: Reference // Reference to SwiftVersionRecord - var macOSVersion: Reference // Reference to another record - - // Implement protocol requirements... -} -``` - ---- - -## Testing Checklist - -Before considering this work complete: - -- [ ] All protocols compile and are public -- [ ] CloudKitService conforms to RecordManaging -- [ ] Generic extensions work with test models -- [ ] FieldValue convenience extensions work -- [ ] Batch operations handle 200+ record limit -- [ ] Bushel example builds using MistKit protocols -- [ ] New tests added with >90% coverage -- [ ] Documentation updated with examples -- [ ] `swift build` succeeds -- [ ] `swift test` shows all tests passing -- [ ] No new lint violations introduced -- [ ] CHANGELOG.md updated - ---- - -## Useful Commands - -```bash -# Build just MistKit -swift build --target MistKit - -# Build Bushel example -cd Examples/Bushel && swift build - -# Build Celestra example -cd Examples/Celestra && swift build - -# Run specific test suite -swift test --filter CloudKitRecordTests - -# Check protocol conformance -swift build -Xswiftc -debug-constraints 2>&1 | grep "CloudKitRecord" - -# Find protocol usage -rg "CloudKitRecord" Examples/Bushel/Sources/ - -# Generate documentation -swift package generate-documentation -``` - ---- - -## Questions to Consider - -When implementing, think about: - -1. **Should `formatForDisplay` be required or have a default implementation?** - - Recommendation: Provide default that returns `recordName` - -2. **Should RecordManaging support transactions/atomic operations?** - - Recommendation: Add optional `atomic` parameter to `executeBatchOperations` - -3. **How to handle partial failures in batch operations?** - - Recommendation: Return `[Result]` instead of throwing - -4. **Should we provide convenience initializers for common record types?** - - Recommendation: Yes, add `CloudKitRecord.create(fields:)` helper - ---- - -## Estimated Timeline - -| Task | Time | Priority | -|------|------|----------| -| Extract CloudKitRecord | 2-3h | HIGH | -| Extract RecordManaging | 1-2h | HIGH | -| FieldValue convenience extensions | 2h | HIGH | -| RecordManaging generic extensions | 3-4h | HIGH | -| CloudKitService conformance | 1h | HIGH | -| Update Bushel to import from MistKit | 1h | HIGH | -| Add comprehensive tests | 2-3h | HIGH | -| Advanced features (variadic generics) | 2-3h | LOW | -| Documentation & examples | 1-2h | MEDIUM | - -**Total: 13-20 hours** (8-14 hours for core features only) - ---- - -## Success Criteria - -The protocol extraction is complete when: - -1. ✅ A new model conforming to `CloudKitRecord` requires <25 lines of code -2. ✅ Bushel example builds using MistKit's protocols (no local duplicates) -3. ✅ Generic `sync()` and `query()` operations work with any `CloudKitRecord` -4. ✅ All tests pass with >90% coverage on new code -5. ✅ Documentation includes before/after examples showing DX improvement -6. ✅ No breaking changes to existing MistKit APIs - ---- - -## Contact Points - -If stuck, reference these key files: - -- **Error Handling Pattern:** `Sources/MistKit/CloudKitError.swift` -- **Existing Protocol Example:** `Sources/MistKit/TokenManager.swift` -- **Testing Patterns:** `Tests/MistKitTests/Core/FieldValue/FieldValueTests.swift` -- **Bushel Production Usage:** `Examples/Bushel/Sources/Bushel/Commands/SyncCommand.swift` - ---- - -Good luck! This is high-value work that will significantly improve the MistKit developer experience. 🚀 diff --git a/.claude/docs/https_-swiftpackageindex.com-apple-swift-configuration-1.0.0-documentation-configuration.md b/.claude/docs/swift-configuration.md similarity index 100% rename from .claude/docs/https_-swiftpackageindex.com-apple-swift-configuration-1.0.0-documentation-configuration.md rename to .claude/docs/swift-configuration.md diff --git a/.claude/docs/https_-swiftpackageindex.com-apple-swift-log-main-documentation-logging.md b/.claude/docs/swift-log.md similarity index 100% rename from .claude/docs/https_-swiftpackageindex.com-apple-swift-log-main-documentation-logging.md rename to .claude/docs/swift-log.md diff --git a/.claude/docs/https_-swiftpackageindex.com-apple-swift-openapi-generator-1.10.3-documentation-swift-openapi-generator.md b/.claude/docs/swift-openapi-generator.md similarity index 100% rename from .claude/docs/https_-swiftpackageindex.com-apple-swift-openapi-generator-1.10.3-documentation-swift-openapi-generator.md rename to .claude/docs/swift-openapi-generator.md diff --git a/.claude/docs/https_-swiftpackageindex.com-apple-swift-openapi-runtime-1.9.0-documentation-openapiruntime.md b/.claude/docs/swift-openapi-runtime.md similarity index 100% rename from .claude/docs/https_-swiftpackageindex.com-apple-swift-openapi-runtime-1.9.0-documentation-openapiruntime.md rename to .claude/docs/swift-openapi-runtime.md diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 5a6d8bae..bb7a3bfb 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -36,6 +36,7 @@ Project-scoped agent memory for MistKit. This directory **replaces** any native - [macOS APNs entitlement key](project_macos_aps_entitlement_key.md) — macOS needs `com.apple.developer.aps-environment`; iOS uses `aps-environment`. codesign silently strips the wrong-platform key. - [RecordResult pattern throughout API](feedback_record_result_pattern_throughout.md) — Surface per-item modify failures with the RecordResult success-or-failure pattern everywhere (subscriptions, zones…), not just records - [Subrepo-local fixes belong in the subrepo](feedback_subrepo_fixes_belong_in_subrepo.md) — Changes isolated to an Example subrepo (e.g. CelestraCloud copyright headers) go in that subrepo's own repo, not a parent MistKit branch +- [Subrepo directive is about local hygiene only](feedback_subrepo_directive_scope.md) — cross-cutting changes originating in the parent DO belong on a parent branch; don't over-apply the subrepo rule - [beta.4 worktree layout](project_beta4_worktree_layout.md) — Remaining v1.0.0-beta.4 issues are developed in parallel worktrees under MistKit.git/wt-, PR'd to the v1.0.0-beta.4 base - [#419 already fixed in beta.3](project_419_fixed_in_beta3.md) — MistDemoApp view inits shipped in 5a58120; verified building on macOS Swift 6.3.2, do not re-implement - [Never git stash in this multi-worktree repo](feedback_never_git_stash_multiworktree.md) — The stash stack is shared across worktrees; a pop in one can bury a sibling branch's WIP. Commit instead. @@ -43,6 +44,7 @@ Project-scoped agent memory for MistKit. This directory **replaces** any native - [MISTKIT_BRANCH pin resolves tags too](project_mistkit_branch_pin_resolves_tags.md) — `git ls-remote` matches tags; a tag value silently pins the old release and greens example CI without testing the branch - [MistKit release process](project_release_process.md) — Branch `vX` vs tag `X`; pins invert at release; retain release branches; notes are a flat bullet list for new entries - [Use git trees, not git worktree](feedback_use_git_trees_not_git_worktree.md) — Manage worktrees with `git trees add/rm/list/clean`; `add` pushes to origin unless `--no-push` +- [git trees add bases new worktrees on main](reference_git_trees_add_bases_on_main.md) — not on the invoking worktree's branch; reset onto the intended base, then first push needs --force-with-lease - [Examples workflow tracks subrepo tools-version](feedback_examples_workflow_tracks_tools_version.md) — MistDemo/Bushel/Celestra/MKC are tools-version 6.4; examples.yml + MistDemo.yml use 6.4 nightly / Xcode 27 - [ConfigKey bases must be dash-case](reference_configkey_cli_flag_dash_case.md) — snake_case silently breaks CLI flags and secret redaction; ENV works either way, so it hides the bug - [ConfigKeyKit ConfigValueReading](reference_configkeykit_configvaluereading.md) — ConfigKeyKit#1 shipped in-core in 1.0.0-beta.2; there is no ConfigKeyKitConfiguration package diff --git a/.claude/memory/feedback_subrepo_directive_scope.md b/.claude/memory/feedback_subrepo_directive_scope.md new file mode 100644 index 00000000..15f1b04b --- /dev/null +++ b/.claude/memory/feedback_subrepo_directive_scope.md @@ -0,0 +1,28 @@ +--- +name: feedback_subrepo_directive_scope +description: "The subrepo-fixes directive governs subrepo-LOCAL hygiene only; cross-cutting changes that originate in the parent do belong on a parent branch" +metadata: + node_type: memory + type: feedback +--- + +`feedback_subrepo_fixes_belong_in_subrepo` is about **subrepo-local hygiene** — +a change concerning only content already inside the subrepo (the cited case is +CelestraCloud copyright headers). It does **not** block cross-cutting work that +originates in the parent repo and lands in a subrepo. + +**Why:** Asked to move MistKit's `.claude/docs/` domain files into +`Examples/BushelCloud/` and `Examples/CelestraCloud/`, I read the directive as +forbidding it and put a false choice to Leo ("do it in the subrepo, or don't move +them"). He pushed back. The directive's own "How to apply" line carves this out: +*"only touch subrepo files when the change is part of that cross-cutting +concern"* — and the adjacent agent-notes line says repo-wide CI bumps DO extend +into the Examples subrepos in the same pass. + +**How to apply:** Ask where the change *originates*. Originates inside the +subrepo and concerns only it → route to the standalone repo. Originates in the +parent, or decides what the parent owns → parent branch is correct; the only +real constraint is the mechanical one of getting it upstream later. Quote the +directive before invoking it. Related: +[[feedback_subrepo_fixes_belong_in_subrepo]], +[[project_examples_dir_is_for_mistkit_dev]]. diff --git a/.claude/memory/reference_git_trees_add_bases_on_main.md b/.claude/memory/reference_git_trees_add_bases_on_main.md new file mode 100644 index 00000000..c7062e83 --- /dev/null +++ b/.claude/memory/reference_git_trees_add_bases_on_main.md @@ -0,0 +1,24 @@ +--- +name: reference_git_trees_add_bases_on_main +description: "git trees add branches from main regardless of which worktree you run it in; reset onto the intended base immediately after" +metadata: + node_type: memory + type: reference +--- + +`git trees add ` creates the new branch from **`main`**, not from the +branch of the worktree you invoke it in. It also pushes that `main`-based commit +to `origin/` right away (unless `--no-push`). + +**Why:** Running `git trees add claude-docs-consolidation` from the +`v1.0.0-beta.5` worktree produced a tree at `main` (687b532) — 15 commits behind, +with 23 files of `.claude/` drift. The intended base was 3708e09. Nothing warns +about this; the output just says "Preparing worktree (new branch …)" and the +drift is silent until you diff. + +**How to apply:** After `git trees add` from a non-`main` base, immediately +`git reset --hard origin/` and confirm with +`git log --oneline HEAD..origin/ | wc -l` (want 0). Because the +stale commit is already on the remote, the first real push needs +`--force-with-lease`. Related: [[feedback_use_git_trees_not_git_worktree]], +[[project_beta4_worktree_layout]]. diff --git a/AGENTS.md b/AGENTS.md index 81baa336..3e98fb9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -467,36 +467,22 @@ Key endpoints documented in the OpenAPI spec: ## Reference Documentation -Apple's official CloudKit documentation is available in `.claude/docs/` for offline reference during development: +Offline copies of external documentation live in `.claude/docs/`. **See +[`.claude/docs/README.md`](.claude/docs/README.md) — it is the single router for +that directory**, listing every doc with its size and when to consult it. -### When to Consult Each Document +Highlights: `webservices.md` is the authoritative CloudKit REST reference; +`swift-openapi-generator.md` and `swift-openapi-runtime.md` cover the generated +client in `Sources/MistKitOpenAPI/`; `QUICK_REFERENCE.md` is the fast lookup for +endpoint shapes, field types, and error codes. -**webservices.md** (289 KB) - CloudKit Web Services REST API -- **Primary use**: Implementing REST API endpoints -- **Contains**: Authentication, request formats, all endpoints, data types, error codes -- **Consult when**: Writing API client code, handling authentication, debugging responses - -**cloudkitjs.md** (188 KB) - CloudKit JS Framework -- **Primary use**: Understanding CloudKit concepts and operation flows -- **Contains**: Container/database patterns, operations, response objects, error handling -- **Consult when**: Designing Swift types, implementing queries, working with subscriptions - -**testing-enablinganddisabling.md** (126 KB) - Swift Testing Framework -- **Primary use**: Writing modern Swift tests -- **Contains**: `@Test` macros, async testing, parameterization, migration from XCTest -- **Consult when**: Writing or organizing tests, testing async code - -**swift-openapi-generator.md** (235 KB) - Swift OpenAPI Generator Documentation -- **Primary use**: Understanding code generation configuration and features -- **Contains**: Generator configuration, type overrides, middleware system, transport protocols, API stability -- **Consult when**: Configuring openapi-generator-config.yaml, implementing middleware, troubleshooting generated code - -See `.claude/docs/README.md` for detailed topic breakdowns and integration guidance. +Do not duplicate the per-document breakdown here — it drifts. Add new docs to +the router instead. ### MistDemo Documentation - **Swift Configuration Reference** (`.claude/docs/mistdemo/swift-configuration-reference.md`) - Guide for using Swift Configuration in MistDemo -- **Official Swift Configuration Docs** (`.claude/docs/https_-swiftpackageindex.com-apple-swift-configuration-1.0.0-documentation-configuration.md`) - Full API reference +- **Official Swift Configuration Docs** (`.claude/docs/swift-configuration.md`) - Full API reference ### CloudKit Schema Language diff --git a/.claude/docs/data-sources-api-research.md b/Examples/BushelCloud/.claude/data-sources-api-research.md similarity index 100% rename from .claude/docs/data-sources-api-research.md rename to Examples/BushelCloud/.claude/data-sources-api-research.md diff --git a/.claude/docs/firmware-wiki.md b/Examples/BushelCloud/.claude/firmware-wiki.md similarity index 100% rename from .claude/docs/firmware-wiki.md rename to Examples/BushelCloud/.claude/firmware-wiki.md diff --git a/.claude/docs/mobileasset-wiki.md b/Examples/BushelCloud/.claude/mobileasset-wiki.md similarity index 100% rename from .claude/docs/mobileasset-wiki.md rename to Examples/BushelCloud/.claude/mobileasset-wiki.md diff --git a/Examples/BushelCloud/CLAUDE.md b/Examples/BushelCloud/CLAUDE.md index 967000f5..497b347a 100644 --- a/Examples/BushelCloud/CLAUDE.md +++ b/Examples/BushelCloud/CLAUDE.md @@ -707,3 +707,15 @@ For detailed guides on advanced topics, see: - Critical issues solved and lessons learned - Common pitfalls to avoid - Lessons for building future CloudKit demos + +- **[.claude/data-sources-api-research.md](.claude/data-sources-api-research.md)** - External data source research + - xcodereleases.com API structure + - swiftversion.net scraping approach + - MistKit API patterns used by the sync tool + +- **[.claude/firmware-wiki.md](.claude/firmware-wiki.md)** - IPSW/OTA firmware reference (TheAppleWiki) + - Firmware file formats and contents + - Background for IPSWFetcher and MESUFetcher + +- **[.claude/mobileasset-wiki.md](.claude/mobileasset-wiki.md)** - MobileAsset framework reference (TheAppleWiki) + - Asset catalog structure behind MESU endpoints diff --git a/.claude/docs/cloudkit-public-database-architecture.md b/Examples/CelestraCloud/.claude/cloudkit-public-database-architecture.md similarity index 100% rename from .claude/docs/cloudkit-public-database-architecture.md rename to Examples/CelestraCloud/.claude/cloudkit-public-database-architecture.md diff --git a/Examples/CelestraCloud/AGENTS.md b/Examples/CelestraCloud/AGENTS.md index 334d32c3..d5876cc1 100644 --- a/Examples/CelestraCloud/AGENTS.md +++ b/Examples/CelestraCloud/AGENTS.md @@ -390,6 +390,7 @@ Code must be concurrency-safe with proper actor isolation. - `.claude/IMPLEMENTATION_NOTES.md` - Design decisions, patterns, and technical context - `.claude/AI_SCHEMA_WORKFLOW.md` - CloudKit schema design guide for AI agents - `.claude/CLOUDKIT_SCHEMA_SETUP.md` - Schema deployment instructions +- `.claude/cloudkit-public-database-architecture.md` - Public database architecture and schema reference for the RSS reader ## Pull Request Testing From 716f57218c4e9cb8d3f527c08b4d4f81bd4aca8a Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 3 Sep 2026 14:59:07 -0400 Subject: [PATCH 16/25] Add VALIDATE to Reference.Action (#464). Co-authored-by: Cursor --- ReleaseNotes.md | 3 +++ .../FieldValues/FieldValue+Components.swift | 2 ++ .../MistKit/Models/FieldValues/Reference.swift | 8 ++++++-- .../Components.Schemas.FieldValueRequest.swift | 2 ++ .../Components.Schemas.ListValuePayload.swift | 2 ++ Sources/MistKitOpenAPI/Types.swift | 9 ++++++--- ...ieldValueConversionTests+ComplexTypes.swift | 18 ++++++++++++++++++ openapi.yaml | 10 ++++++++-- 8 files changed, 47 insertions(+), 7 deletions(-) diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 3dae4c76..17ccbd78 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,5 +1,8 @@ ## 1.0.0-beta.4 +### Field Values +* Add VALIDATE to `Reference.Action` for CloudKit Web Services reference dictionaries (#464) + ### Change Tracking * Add changes/database and changes/zone endpoints; deprecate zones/changes (#401, #47, #46) by @leogdion in https://github.com/brightdigit/MistKit/pull/429 * modifyZones per-zone failures (#431) + zones/changes metaSyncToken (#430) by @leogdion in https://github.com/brightdigit/MistKit/pull/443 diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift index ad318b3a..b8e9c707 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift @@ -102,6 +102,8 @@ extension FieldValue { action = .deleteSelf case .NONE: action = Reference.Action.none + case .VALIDATE: + action = .validate case nil: action = nil } diff --git a/Sources/MistKit/Models/FieldValues/Reference.swift b/Sources/MistKit/Models/FieldValues/Reference.swift index 648ee924..7732296c 100644 --- a/Sources/MistKit/Models/FieldValues/Reference.swift +++ b/Sources/MistKit/Models/FieldValues/Reference.swift @@ -29,15 +29,19 @@ /// Reference dictionary as defined in CloudKit Web Services public struct Reference: Codable, Equatable, Sendable { - /// Reference action types supported by CloudKit + /// Reference action types supported by CloudKit Web Services. + /// Native `CKRecord.ReferenceAction` only has `none` and `deleteSelf`; + /// `validate` is a Web Services case that verifies the target record exists + /// before creating the reference (create fails if missing). public enum Action: String, Codable, Sendable { case deleteSelf = "DELETE_SELF" case none = "NONE" + case validate = "VALIDATE" } /// The record name being referenced public let recordName: String - /// The action to take (DELETE_SELF, NONE, or nil) + /// The action to take (`NONE`, `DELETE_SELF`, `VALIDATE`, or nil) public let action: Action? /// Initialize a reference value diff --git a/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift b/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift index 342d7edd..16565c3f 100644 --- a/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift +++ b/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift @@ -101,6 +101,8 @@ extension Components.Schemas.FieldValueRequest { action = .DELETE_SELF case .some(.none): action = .NONE + case .some(.validate): + action = .VALIDATE case nil: action = nil } diff --git a/Sources/MistKit/OpenAPI/Components/Components.Schemas.ListValuePayload.swift b/Sources/MistKit/OpenAPI/Components/Components.Schemas.ListValuePayload.swift index 1fca7871..9cc7c761 100644 --- a/Sources/MistKit/OpenAPI/Components/Components.Schemas.ListValuePayload.swift +++ b/Sources/MistKit/OpenAPI/Components/Components.Schemas.ListValuePayload.swift @@ -99,6 +99,8 @@ extension Components.Schemas.ListValuePayload { action = .DELETE_SELF case .some(.none): action = .NONE + case .some(.validate): + action = .VALIDATE case nil: action = nil } diff --git a/Sources/MistKitOpenAPI/Types.swift b/Sources/MistKitOpenAPI/Types.swift index c86073a9..de76f390 100644 --- a/Sources/MistKitOpenAPI/Types.swift +++ b/Sources/MistKitOpenAPI/Types.swift @@ -1653,14 +1653,17 @@ public enum Components { /// /// - Remark: Generated from `#/components/schemas/ReferenceValue/recordName`. public var recordName: Swift.String - /// Action to perform on the referenced record + /// Action to perform on the referenced record. NONE performs no action; DELETE_SELF deletes this record when the referenced record is deleted; VALIDATE verifies the target record exists before creating the reference (create fails if missing). VALIDATE is a CloudKit Web Services value; native CKRecord.ReferenceAction has only none and deleteSelf. + /// /// /// - Remark: Generated from `#/components/schemas/ReferenceValue/action`. @frozen public enum actionPayload: String, Codable, Hashable, Sendable, CaseIterable { case NONE = "NONE" case DELETE_SELF = "DELETE_SELF" + case VALIDATE = "VALIDATE" } - /// Action to perform on the referenced record + /// Action to perform on the referenced record. NONE performs no action; DELETE_SELF deletes this record when the referenced record is deleted; VALIDATE verifies the target record exists before creating the reference (create fails if missing). VALIDATE is a CloudKit Web Services value; native CKRecord.ReferenceAction has only none and deleteSelf. + /// /// /// - Remark: Generated from `#/components/schemas/ReferenceValue/action`. public var action: Components.Schemas.ReferenceValue.actionPayload? @@ -1668,7 +1671,7 @@ public enum Components { /// /// - Parameters: /// - recordName: The record name being referenced - /// - action: Action to perform on the referenced record + /// - action: Action to perform on the referenced record. NONE performs no action; DELETE_SELF deletes this record when the referenced record is deleted; VALIDATE verifies the target record exists before creating the reference (create fails if missing). VALIDATE is a CloudKit Web Services value; native CKRecord.ReferenceAction has only none and deleteSelf. public init( recordName: Swift.String, action: Components.Schemas.ReferenceValue.actionPayload? = nil diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ComplexTypes.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ComplexTypes.swift index 4832779e..b8fec619 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ComplexTypes.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ComplexTypes.swift @@ -120,6 +120,24 @@ extension FieldValueConversionTests { } } + @Test("Convert reference FieldValue with VALIDATE action to Components.FieldValue") + internal func convertReferenceWithValidateAction() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let reference = Reference(recordName: "test-record-validate", action: .validate) + let fieldValue = FieldValue.reference(reference) + let components = Components.Schemas.FieldValueRequest(from: fieldValue) + + if case .ReferenceValue(let value) = components.value { + #expect(value.recordName == "test-record-validate") + #expect(value.action == .VALIDATE) + } else { + Issue.record("Expected referenceValue") + } + } + @Test("Convert asset FieldValue with all fields to Components.FieldValue") internal func convertAssetWithAllFields() { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { diff --git a/openapi.yaml b/openapi.yaml index 98180514..9fc621d5 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1644,8 +1644,14 @@ components: description: The record name being referenced action: type: string - enum: [NONE, DELETE_SELF] - description: Action to perform on the referenced record + enum: [NONE, DELETE_SELF, VALIDATE] + description: > + Action to perform on the referenced record. NONE performs no action; + DELETE_SELF deletes this record when the referenced record is deleted; + VALIDATE verifies the target record exists before creating the + reference (create fails if missing). VALIDATE is a CloudKit Web + Services value; native CKRecord.ReferenceAction has only none and + deleteSelf. AssetValue: type: object From cbeecedb9184f20c46d949f534610f265cdefcca Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 3 Sep 2026 15:05:56 -0400 Subject: [PATCH 17/25] Represent FieldValue.bytes as Data (#467). Co-authored-by: Cursor --- .claude/memory/MEMORY.md | 2 +- ...oject_fieldvalue_bytes_is_base64_string.md | 61 +++++++------------ AGENTS.md | 4 +- .../Extensions/FieldValue+FieldType.swift | 6 +- .../Utilities/FieldValueFormatter.swift | 4 +- .../FieldValue+FieldTypeTests+BytesType.swift | 13 +++- ReleaseNotes.md | 4 ++ Sources/MistKit/Models/ConversionError.swift | 5 +- .../FieldValues/FieldValue+Codable.swift | 4 +- .../FieldValue+Components+List.swift | 13 ++-- .../FieldValue+Components+Scalar.swift | 15 +++-- .../FieldValues/FieldValue+Components.swift | 21 +++++++ .../FieldValues/FieldValue+Convenience.swift | 21 ++++++- .../Models/FieldValues/FieldValue.swift | 2 +- ...Components.Schemas.FieldValueRequest.swift | 2 +- .../Components.Schemas.ListValuePayload.swift | 2 +- ...FieldValueConversionTests+BasicTypes.swift | 5 +- .../FieldValueConversionTests+Lists.swift | 57 +++++++++++++++++ ...ldValueConversionTests+ResponseTypes.swift | 44 ++++++++++++- .../Models/FieldValues/FieldValueTests.swift | 15 ++--- .../FieldValueConvenienceTests.swift | 22 +++++-- docs/internals/field-type-polymorphism.md | 2 +- 22 files changed, 243 insertions(+), 81 deletions(-) diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index bb7a3bfb..0527ad01 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -53,4 +53,4 @@ Project-scoped agent memory for MistKit. This directory **replaces** any native - [Dogfood pins are branch pins, not tags](project_dogfood_pins_are_branch_pins_not_tags.md) — tagging a monorepo package does NOT mean Examples switch to `from:`; they keep `path:` and CI pins both deps to branch HEADs - [MistKitConfiguration integration branch](project_mkc_integration_branch.md) — subrepo tracks `mistkit-beta.5`; it is beta.5-line scaffolding and must be DELETED in the `→ main` release PR - [Draft-gated CI needs ready_for_review](reference_draft_gated_ci_needs_ready_for_review.md) — an `if: draft == false` job stays `skipped` forever unless `types:` lists `ready_for_review`; re-running replays the stale payload -- [FieldValue.bytes is a base64 String](project_fieldvalue_bytes_is_base64_string.md) — wire-format mirror, no recorded rationale; #467 specs the move to Data; base64 has no false-positive signal so never infer it +- [FieldValue.bytes is domain Data](project_fieldvalue_bytes_is_base64_string.md) — wire/generated BytesValue stays base64 String; do not infer .bytes from untagged strings diff --git a/.claude/memory/project_fieldvalue_bytes_is_base64_string.md b/.claude/memory/project_fieldvalue_bytes_is_base64_string.md index beb0a991..b21eea65 100644 --- a/.claude/memory/project_fieldvalue_bytes_is_base64_string.md +++ b/.claude/memory/project_fieldvalue_bytes_is_base64_string.md @@ -1,39 +1,30 @@ --- name: project_fieldvalue_bytes_is_base64_string -description: FieldValue.bytes holds a base64 String (not Data) to mirror the wire format; changing it to Data is specced in #467 +description: FieldValue.bytes is Data in the domain; the wire and generated BytesValue stay a base64 String. Do not infer .bytes from untagged strings. metadata: type: project --- -`FieldValue.bytes` is `case bytes(String)` — a **base64-encoded string**, not `Data` -(`Sources/MistKit/Models/FieldValues/FieldValue.swift`). - -**There is no recorded rationale for this.** The case arrives fully formed in `d11c6c5` -("v1.0.0 beta.1", #298), which squashes the entire pre-beta history, and nothing in the -issue tracker, `ReleaseNotes.md`, or this memory store deliberates it. Don't go looking -again — this file *is* the answer to "why String?". - -The de facto reasons visible in the code: - -1. It mirrors the wire 1:1 — `openapi.yaml` defines `BytesValue` as `type: string` - ("Base64-encoded string"), and the generated `BytesValue` is `typealias … = Swift.String`. -2. It keeps the conversion layer total: `.string` and `.bytes` share a `String` payload, so - several switch arms collapse into one (`FieldValue+Codable.swift`, - `ScalarPayload.text`/`.inferred` in `FieldValue+Components+Scalar.swift`). With `Data`, - every decode site gains a fallible `Data(base64Encoded:)` step. - -Cost: `bytesValue` returns `String?` and there is **no** `Data` bridge anywhere in -`Sources/MistKit/` — callers hand-roll the decode. - -**Changing it to `Data` is specced in issue #467** (decisions settled there: reuse -`ConversionError.typeValueMismatch` rather than a new case; untagged base64 keeps -resolving to `.string`; `dataValue` stays a strict `.bytes`-only match). +`FieldValue.bytes` is `case bytes(Data)` (`Sources/MistKit/Models/FieldValues/FieldValue.swift`). +Base64 encode/decode happens at the conversion boundary. This is a **domain-layer** +change only (issue #467): `Sources/MistKitOpenAPI/` and `openapi.yaml` are untouched — +the generated `BytesValue` remains `typealias … = Swift.String` because the wire format +is still base64 text. + +- Encode: `value.base64EncodedString()` in `FieldValueRequest`, `ListValuePayload`, and + `FieldValue+Codable`. +- Decode: `Data(base64Encoded:)` at the `FieldValue` boundary. `ScalarPayload.bytes` + still holds the raw wire `String` so `requireString` stays total. +- Malformed tagged `BYTES` throws `ConversionError.typeValueMismatch` (no new public + error case) via `reportAndThrow()`, passing the **unwrapped** string as `value`. +- `bytesValue: String?` returns `base64EncodedString()`. `dataValue: Data?` matches + `.bytes` only — never fall back to decoding a `.string` payload. ## Base64 has no false-positive signal -Load-bearing for #467, and worth knowing generally: base64 carries no header, checksum, -or self-identifying structure. Validity is only a character-set-and-length check, so **any** -`[A-Za-z0-9+/]` string whose length is a multiple of 4 decodes successfully: +Base64 carries no header, checksum, or self-identifying structure. Validity is only a +character-set-and-length check, so **any** `[A-Za-z0-9+/]` string whose length is a +multiple of 4 decodes successfully: | Input | `Data(base64Encoded:)` | |---|---| @@ -44,22 +35,14 @@ or self-identifying structure. Validity is only a character-set-and-length check `"lastName" : {"value" : "Chen"}`) — a four-letter surname is indistinguishable from base64. **Therefore: never infer `.bytes` by attempting a base64 decode**, neither in the scalar -decode chain nor as a `dataValue` fallback for `.string`. A non-`nil` result proves only -that a string *could* be base64, never that it *is*; inference would hand back -plausible-looking garbage instead of a visible failure. +decode chain nor as a `dataValue` fallback for `.string`. Untagged base64 continues to +resolve as `.string`. ## Untagged BYTES responses — frequency unmeasured When a response omits `type`, first-match-wins inference claims a base64 string as `.string` (documented in CLAUDE.md as lossy). How often CloudKit actually omits `type` on -reads is **not established**: our `openapi.yaml` calls it optional, but that wording is -*ours*, hand-written from Apple's docs — and Apple's authoritative `Record Field Dictionary` -is referenced at `.claude/docs/webservices.md` without its definition being included in the -offline copy. Note "optional in the schema" ≠ "sometimes absent in practice". No captured -live responses exist in the repo; every `"type"` in the tests is a hand-written fixture. - -Settle it the way #430 settled `metaSyncToken` — against a live container -(`swift run mistdemo create` with a bytes field, then a `.debug` query) — before treating -untagged binary as either real or theoretical. +reads is **not established**. If an untagged `BYTES` response occurs, `dataValue` is `nil` +and the base64 text remains on `stringValue`. Related: [[project_release_process]] diff --git a/AGENTS.md b/AGENTS.md index 3e98fb9d..c0958f60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,12 +135,12 @@ MistKit uses separate types for requests and responses at the OpenAPI schema lev **Request type tagging (issue #375):** Most request values omit `type` and let CloudKit infer it from the value structure. Three scalar types are ambiguous on the wire and **must** carry an explicit `type`, otherwise CloudKit infers the wrong type and rejects the write with `BAD_REQUEST`: - `TIMESTAMP` (`.date`) — a millisecond number, otherwise read as `INT64`/`DOUBLE` -- `BYTES` (`.bytes`) — a base64 string, otherwise read as `STRING` +- `BYTES` (`.bytes`) — domain `Data`, encoded as a base64 string on the wire, otherwise read as `STRING` - `DOUBLE` (`.double`) — a whole-valued double serializes without a fraction, otherwise read as `INT64` Object/array-shaped values (`REFERENCE`, `ASSET`, `LOCATION`, `LIST`) and `STRING`/`INT64` are unambiguous and stay untagged. Tagging happens in the exhaustive `init(from:)` switch (`Components.Schemas.FieldValueRequest.swift`). `type` is *not* required globally because CloudKit documents it as optional. -**Response type recovery (issue #375):** The generated `value` `oneOf` is *undiscriminated* — the decoder tries cases first-match-wins (`String → Int64 → Double → Bytes → Date`), so a whole-millisecond `TIMESTAMP` decodes as `Int64Value` and a base64 `BYTES` string decodes as `StringValue`. The response conversion therefore honors an explicit `type` *over* the decoded case (`makeTypedScalar` in `FieldValue+Components+Scalar.swift`). For the genuinely-ambiguous scalars whose correct interpretation differs from inference it produces the typed value directly: `TIMESTAMP`/`DOUBLE` from any numeric case, `BYTES` from any string case. `INT64`/`STRING` validate the category then defer to inference (which already yields them, and for `INT64` avoids truncating a fractional number). When `type` is absent it falls back to first-match-wins inference (`makeInferredScalar`), which is lossy for the ambiguous scalars (BYTES→`.string`, whole-number TIMESTAMP→`.int64`). +**Response type recovery (issue #375):** The generated `value` `oneOf` is *undiscriminated* — the decoder tries cases first-match-wins (`String → Int64 → Double → Bytes → Date`), so a whole-millisecond `TIMESTAMP` decodes as `Int64Value` and a base64 `BYTES` string decodes as `StringValue`. The response conversion therefore honors an explicit `type` *over* the decoded case (`makeTypedScalar` in `FieldValue+Components+Scalar.swift`). For the genuinely-ambiguous scalars whose correct interpretation differs from inference it produces the typed value directly: `TIMESTAMP`/`DOUBLE` from any numeric case, `BYTES` from any string case (decoded with `Data(base64Encoded:)`; malformed tagged base64 throws `ConversionError.typeValueMismatch` with the unwrapped string). `INT64`/`STRING` validate the category then defer to inference (which already yields them, and for `INT64` avoids truncating a fractional number). When `type` is absent it falls back to first-match-wins inference (`makeInferredScalar`), which is lossy for the ambiguous scalars (BYTES→`.string`, whole-number TIMESTAMP→`.int64`). Do **not** infer `.bytes` from untagged base64 — ordinary strings such as `"Chen"` decode as valid base64. Domain `.bytes` is `Data`; `bytesValue` re-encodes to base64 and `dataValue` matches `.bytes` only (no `.string` fallback). The generated `BytesValue` in `Sources/MistKitOpenAPI/` stays `String`. When a scalar `type` *contradicts* the value's category — a numeric type (`TIMESTAMP`/`DOUBLE`/`INT64`) over a non-number, or a string type (`STRING`/`BYTES`) over a non-string — the response is internally inconsistent and the conversion **throws** `ConversionError.typeValueMismatch` (via `requireNumeric`/`requireString`) rather than coercing to the value's shape. This matches the codebase's existing fail-loud `unmappableFieldValue` philosophy. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Extensions/FieldValue+FieldType.swift b/Examples/MistDemo/Sources/MistDemoKit/Extensions/FieldValue+FieldType.swift index ccb5188e..2f1725f7 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Extensions/FieldValue+FieldType.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Extensions/FieldValue+FieldType.swift @@ -71,10 +71,12 @@ extension FieldValue { return .date(dateValue) case .bytes: - guard let stringValue = value as? String else { + guard let stringValue = value as? String, + let data = Data(base64Encoded: stringValue) + else { return nil } - return .bytes(stringValue) + return .bytes(data) case .asset: return convertAsset(value: value) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Utilities/FieldValueFormatter.swift b/Examples/MistDemo/Sources/MistDemoKit/Utilities/FieldValueFormatter.swift index 8d9d1645..dc817b79 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Utilities/FieldValueFormatter.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Utilities/FieldValueFormatter.swift @@ -45,7 +45,7 @@ internal enum FieldValueFormatter { case .double(let double): return "\(double)" case .bytes(let bytes): - return bytes + return bytes.base64EncodedString() case .date(let date): return formatDate(date) case .location(let location): @@ -73,7 +73,7 @@ internal enum FieldValueFormatter { case .double(let double): return "\(double)" case .bytes(let bytes): - return "bytes(\(bytes.count) chars, base64: \(bytes))" + return "bytes(\(bytes.count) bytes, base64: \(bytes.base64EncodedString()))" case .date(let date): return "date(\(formatDate(date)))" case .location(let location): diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+BytesType.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+BytesType.swift index 79333ed4..4612d532 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+BytesType.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+BytesType.swift @@ -36,18 +36,25 @@ internal import Testing extension FieldValueFieldTypeTests { @Suite("Bytes Type") internal struct BytesType { - @Test("Initialize FieldValue.bytes from String value and bytes type") + @Test("Initialize FieldValue.bytes from valid base64 String value and bytes type") internal func initializeBytesFromStringValue() { - let fieldValue = FieldValue(value: "base64data" as String, fieldType: .bytes) + let fieldValue = FieldValue(value: "aGVsbG8=" as String, fieldType: .bytes) #expect(fieldValue != nil) if case .bytes(let value) = fieldValue { - #expect(value == "base64data") + #expect(value == Data("hello".utf8)) } else { Issue.record("Expected .bytes case") } } + @Test("Bytes type with malformed base64 returns nil") + internal func bytesTypeWithMalformedBase64ReturnsNil() { + let fieldValue = FieldValue(value: "not!valid!" as String, fieldType: .bytes) + + #expect(fieldValue == nil) + } + @Test("Bytes type with non-String value returns nil") internal func bytesTypeWithNonStringValueReturnsNil() { let fieldValue = FieldValue(value: 42 as Int, fieldType: .bytes) diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 17ccbd78..4548b074 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,3 +1,7 @@ +## Unreleased + +* Represent `FieldValue.bytes` as `Data` instead of a base64 `String` (#467) + ## 1.0.0-beta.4 ### Field Values diff --git a/Sources/MistKit/Models/ConversionError.swift b/Sources/MistKit/Models/ConversionError.swift index b0e2cd1b..303c8b32 100644 --- a/Sources/MistKit/Models/ConversionError.swift +++ b/Sources/MistKit/Models/ConversionError.swift @@ -43,8 +43,9 @@ public enum ConversionError: LocalizedError, Sendable, Equatable { /// A field value's structure matched no known `FieldValue` case. case unmappableFieldValue(fieldName: String, value: String, type: String?) /// A response declared a scalar `type` that the field's value cannot satisfy - /// (e.g. a `TIMESTAMP` tag over a string value). Such a response is internally - /// inconsistent and cannot be faithfully represented. + /// (e.g. a `TIMESTAMP` tag over a string value, or a `BYTES` tag over a string + /// that is not valid base64). Such a response is internally inconsistent and + /// cannot be faithfully represented. case typeValueMismatch(fieldName: String, declaredType: String, value: String) /// A list element matched no known `FieldValue` case. case unmappableListItem(fieldName: String, item: String) diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Codable.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Codable.swift index d2af06a4..be35b038 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Codable.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Codable.swift @@ -113,8 +113,10 @@ extension FieldValue { /// `false` when `self` is a complex case that this method did not handle. private func encodeScalar(to container: inout any SingleValueEncodingContainer) throws -> Bool { switch self { - case .string(let val), .bytes(let val): + case .string(let val): try container.encode(val) + case .bytes(let val): + try container.encode(val.base64EncodedString()) case .int64(let val): try container.encode(val) case .double(let val): diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Components+List.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Components+List.swift index 8f54fc93..7d08dced 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Components+List.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Components+List.swift @@ -49,7 +49,7 @@ extension FieldValue { listItem: Components.Schemas.ListValuePayload, fieldName: String ) throws(ConversionError) { - if let simpleValue = Self.makeSimpleListItem(from: listItem) { + if let simpleValue = try Self.makeSimpleListItem(from: listItem, fieldName: fieldName) { self = simpleValue } else if let complexValue = try Self.makeComplexListItem(from: listItem, fieldName: fieldName) { @@ -85,7 +85,9 @@ extension FieldValue { case .DoubleValue(let doubleValue): self = .double(doubleValue) case .BytesValue(let bytesValue): - self = .bytes(bytesValue) + self = .bytes( + try Self.dataFromBase64(bytesValue, fieldName: fieldName, declaredType: "BYTES") + ) default: let failure = ConversionError.unmappableNestedListItem( fieldName: fieldName, @@ -96,8 +98,9 @@ extension FieldValue { } private static func makeSimpleListItem( - from listItem: Components.Schemas.ListValuePayload - ) -> FieldValue? { + from listItem: Components.Schemas.ListValuePayload, + fieldName: String + ) throws(ConversionError) -> FieldValue? { if case .StringValue(let strVal) = listItem { return .string(strVal) } @@ -108,7 +111,7 @@ extension FieldValue { return .double(dblVal) } if case .BytesValue(let bytesVal) = listItem { - return .bytes(bytesVal) + return .bytes(try dataFromBase64(bytesVal, fieldName: fieldName, declaredType: "BYTES")) } if case .DateValue(let dateVal) = listItem { return .date(Date(timeIntervalSince1970: dateVal / 1_000)) diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift index f8d2b024..5699195f 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift @@ -49,13 +49,17 @@ extension FieldValue { /// The `FieldValue` first-match-wins inference produces for this payload. Lossy for the /// ambiguous scalars: a base64 BYTES reads back as `.string`, and a whole-number - /// TIMESTAMP reads back as `.int64`. + /// TIMESTAMP reads back as `.int64`. `BytesValue` stays a wire `String`; decode to + /// `Data` here, falling back to `.string` if the payload is not valid base64. fileprivate var inferred: FieldValue { switch self { case .string(let strVal): return .string(strVal) case .bytes(let bytesVal): - return .bytes(bytesVal) + if let data = Data(base64Encoded: bytesVal) { + return .bytes(data) + } + return .string(bytesVal) case .int64(let intVal): return .int64(Int(intVal)) case .double(let dblVal): @@ -84,7 +88,9 @@ extension FieldValue { /// `BytesValue` both arrive as JSON strings. fileprivate var text: String? { switch self { - case .string(let strVal), .bytes(let strVal): + case .string(let strVal): + return strVal + case .bytes(let strVal): return strVal case .int64, .double, .date: return nil @@ -160,7 +166,8 @@ extension FieldValue { _ = try requireNumeric(value, fieldName: fieldName, declaredType: declared) return nil case .text(.bytes): - return .bytes(try requireString(value, fieldName: fieldName, declaredType: declared)) + let string = try requireString(value, fieldName: fieldName, declaredType: declared) + return .bytes(try dataFromBase64(string, fieldName: fieldName, declaredType: declared)) case .text(.string): // Validate the category, then defer to inference, which already produces `.string`. _ = try requireString(value, fieldName: fieldName, declaredType: declared) diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift index b8e9c707..091c0e35 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift @@ -186,4 +186,25 @@ extension FieldValue { ) try failure.reportAndThrow() } + + /// Decode a wire base64 string into `Data`, throwing + /// ``ConversionError/typeValueMismatch`` when the payload is not valid base64. + /// + /// `value` is the unwrapped string from ``requireString``, not the `oneOf` + /// wrapper, so callers can recover the raw payload from the error. + internal static func dataFromBase64( + _ string: String, + fieldName: String, + declaredType: String + ) throws(ConversionError) -> Data { + guard let data = Data(base64Encoded: string) else { + let failure = ConversionError.typeValueMismatch( + fieldName: fieldName, + declaredType: declaredType, + value: string + ) + try failure.reportAndThrow() + } + return data + } } diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Convenience.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Convenience.swift index 50b54c0e..fe09a28f 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Convenience.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Convenience.swift @@ -85,10 +85,27 @@ extension FieldValue { return nil } - /// Extract base64-encoded bytes if this is a .bytes case + /// Extract base64-encoded bytes if this is a `.bytes` case. /// - /// - Returns: The base64 string, or nil if this is not a .bytes case + /// - Returns: The payload as a base64 string, or nil if this is not a `.bytes` case public var bytesValue: String? { + if case .bytes(let value) = self { + return value.base64EncodedString() + } + return nil + } + + /// Extract the binary payload if this is a `.bytes` case. + /// + /// Matches `.bytes` only. An untagged CloudKit `BYTES` response is claimed by + /// first-match-wins inference as `.string`, so `dataValue` returns `nil` for + /// it; the base64 text remains available via ``stringValue``. This accessor + /// does not attempt `Data(base64Encoded:)` on a `.string` payload: base64 has + /// no false-positive signal, so ordinary strings such as `"Chen"` or `"test"` + /// would decode as plausible-looking garbage. + /// + /// - Returns: The `Data` payload, or nil if this is not a `.bytes` case + public var dataValue: Data? { if case .bytes(let value) = self { return value } diff --git a/Sources/MistKit/Models/FieldValues/FieldValue.swift b/Sources/MistKit/Models/FieldValues/FieldValue.swift index 8fadacf6..1e11ad30 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue.swift @@ -34,7 +34,7 @@ public enum FieldValue: Codable, Equatable, Sendable { case string(String) case int64(Int) case double(Double) - case bytes(String) // Base64-encoded string + case bytes(Data) // Binary data; base64-encoded on the wire case date(Date) // Date/time value case location(Location) case reference(Reference) diff --git a/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift b/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift index 16565c3f..d80ca1c2 100644 --- a/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift +++ b/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift @@ -56,7 +56,7 @@ extension Components.Schemas.FieldValueRequest { self.init(value: .DoubleValue(value), _type: .DOUBLE) case .bytes(let value): // A base64 string is otherwise indistinguishable from a STRING. - self.init(value: .BytesValue(value), _type: .BYTES) + self.init(value: .BytesValue(value.base64EncodedString()), _type: .BYTES) case .date(let value): // Tag TIMESTAMP (else inferred as INT64/DOUBLE) and round to whole milliseconds: // CloudKit rejects a fractional TIMESTAMP value (e.g. 1747999812347.89) with diff --git a/Sources/MistKit/OpenAPI/Components/Components.Schemas.ListValuePayload.swift b/Sources/MistKit/OpenAPI/Components/Components.Schemas.ListValuePayload.swift index 9cc7c761..95f0c315 100644 --- a/Sources/MistKit/OpenAPI/Components/Components.Schemas.ListValuePayload.swift +++ b/Sources/MistKit/OpenAPI/Components/Components.Schemas.ListValuePayload.swift @@ -51,7 +51,7 @@ extension Components.Schemas.ListValuePayload { return .DoubleValue(value) } if case .bytes(let value) = fieldValue { - return .BytesValue(value) + return .BytesValue(value.base64EncodedString()) } if case .date(let value) = fieldValue { return .DateValue(value.timeIntervalSince1970 * 1_000) diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift index a303ca41..cf4b1ee0 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift @@ -91,11 +91,12 @@ extension FieldValueConversionTests { Issue.record("FieldValue is not available on this operating system.") return } - let fieldValue = FieldValue.bytes("base64encodedstring") + let payload = Data("hello".utf8) + let fieldValue = FieldValue.bytes(payload) let components = Components.Schemas.FieldValueRequest(from: fieldValue) if case .BytesValue(let value) = components.value { - #expect(value == "base64encodedstring") + #expect(value == payload.base64EncodedString()) } else { Issue.record("Expected bytesValue") } diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift index a3c7d567..2a8dedd4 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift @@ -98,5 +98,62 @@ extension FieldValueConversionTests { Issue.record("Expected ListValue") } } + + @Test("BYTES list element that is not valid base64 throws typeValueMismatch") + internal func malformedBytesListElementThrows() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + ConversionFailureReporter.$assertionHandler.withValue( + { _, _, _ in }, + operation: { + #expect( + throws: ConversionError.typeValueMismatch( + fieldName: "field", + declaredType: "BYTES", + value: "not!valid!" + ) + ) { + _ = try FieldValue(listItem: .BytesValue("not!valid!"), fieldName: "field") + } + } + ) + } + + @Test("Nested BYTES list element that is not valid base64 throws typeValueMismatch") + internal func malformedBytesNestedListElementThrows() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + ConversionFailureReporter.$assertionHandler.withValue( + { _, _, _ in }, + operation: { + #expect( + throws: ConversionError.typeValueMismatch( + fieldName: "field", + declaredType: "BYTES", + value: "not!valid!" + ) + ) { + _ = try FieldValue( + nestedListValue: [.BytesValue("not!valid!")], + fieldName: "field" + ) + } + } + ) + } + + @Test("BYTES list element with valid base64 reads as .bytes Data") + internal func validBytesListElement() throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let value = try FieldValue(listItem: .BytesValue("aGVsbG8="), fieldName: "field") + #expect(value == .bytes(Data("hello".utf8))) + } } } diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ResponseTypes.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ResponseTypes.swift index dd3dbfc7..7129185f 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ResponseTypes.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ResponseTypes.swift @@ -60,7 +60,7 @@ extension FieldValueConversionTests { return } let value = try Self.decode(#"{"value": "aGVsbG8=", "type": "BYTES"}"#) - #expect(value == .bytes("aGVsbG8=")) + #expect(value == .bytes(Data("hello".utf8))) } @Test("Whole-valued DOUBLE with type reads back as .double, not .int64") @@ -169,8 +169,50 @@ extension FieldValueConversionTests { return } #expect(try Self.decode(#"{"value": "plain"}"#) == .string("plain")) + #expect(try Self.decode(#"{"value": "Chen"}"#) == .string("Chen")) #expect(try Self.decode(#"{"value": 42}"#) == .int64(42)) #expect(try Self.decode(#"{"value": 3.5}"#) == .double(3.5)) } + + @Test("Tagged BYTES that is not valid base64 throws typeValueMismatch with the raw string") + internal func taggedMalformedBytesThrows() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + expectTypeValueMismatch( + #"{"value": "not!valid!", "type": "BYTES"}"#, + value: "not!valid!" + ) + } + + @Test("Untagged malformed base64 infers as .string and does not throw") + internal func inferredUntaggedMalformedBytesIsString() throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + #expect(try Self.decode(#"{"value": "not!valid!"}"#) == .string("not!valid!")) + #expect(try Self.decode(#"{"value": "aGVsbG8="}"#) == .string("aGVsbG8=")) + } + + /// Expects decoding `json` to throw `typeValueMismatch` whose `value` is the + /// unwrapped string payload, with the DEBUG assertion trap suppressed. + private func expectTypeValueMismatch(_ json: String, value: String) { + ConversionFailureReporter.$assertionHandler.withValue( + { _, _, _ in }, + operation: { + #expect( + throws: ConversionError.typeValueMismatch( + fieldName: "field", + declaredType: "BYTES", + value: value + ) + ) { + _ = try Self.decode(json) + } + } + ) + } } } diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueTests.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueTests.swift index 83d289ba..603f5f3f 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueTests.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueTests.swift @@ -135,18 +135,19 @@ internal struct FieldValueTests { #expect(milliseconds == date.timeIntervalSince1970 * 1_000) } - /// Tests that `.bytes` encodes as its raw string payload. + /// Tests that `.bytes` encodes as a base64 string payload. /// - /// `.bytes` shares the scalar arm with `.string`, so it emits the same wire - /// value; it decodes back as `.string` since the decoder has no bytes branch. - @Test("FieldValue bytes encodes as its string payload") + /// Encoding emits `Data.base64EncodedString()`. Decoding still has no bytes + /// branch, so the payload reads back as `.string`. + @Test("FieldValue bytes encodes as its base64 string payload") internal func fieldValueBytesEncodesAsString() throws { - let payload = "YWJjMTIz" + let payload = Data("abc123".utf8) + let encoded = payload.base64EncodedString() let bytesData = try JSONEncoder().encode(FieldValue.bytes(payload)) - let stringData = try JSONEncoder().encode(FieldValue.string(payload)) + let stringData = try JSONEncoder().encode(FieldValue.string(encoded)) #expect(bytesData == stringData) let decoded = try JSONDecoder().decode(FieldValue.self, from: bytesData) - #expect(decoded == .string(payload)) + #expect(decoded == .string(encoded)) } } diff --git a/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests.swift b/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests.swift index 2f7945d1..9f412ad1 100644 --- a/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests.swift +++ b/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests.swift @@ -116,11 +116,11 @@ internal struct FieldValueConvenienceTests { #expect(FieldValue.int64(1_704_067_200).dateValue == nil) } - @Test("bytesValue extracts String from .bytes case") + @Test("bytesValue extracts base64 String from .bytes case") internal func bytesValueExtraction() { - let base64 = "SGVsbG8gV29ybGQ=" - let value = FieldValue.bytes(base64) - #expect(value.bytesValue == base64) + let data = Data("Hello World".utf8) + let value = FieldValue.bytes(data) + #expect(value.bytesValue == data.base64EncodedString()) } @Test("bytesValue returns nil for non-bytes cases") @@ -128,6 +128,20 @@ internal struct FieldValueConvenienceTests { #expect(FieldValue.string("test").bytesValue == nil) } + @Test("dataValue extracts Data from .bytes case") + internal func dataValueExtraction() { + let data = Data("Hello World".utf8) + let value = FieldValue.bytes(data) + #expect(value.dataValue == data) + } + + @Test("dataValue returns nil for non-bytes cases including .string") + internal func dataValueReturnsNilForWrongType() { + #expect(FieldValue.string("test").dataValue == nil) + #expect(FieldValue.string("SGVsbG8gV29ybGQ=").dataValue == nil) + #expect(FieldValue.string("Chen").dataValue == nil) + } + @Test("locationValue extracts Location from .location case") internal func locationValueExtraction() { let location = Location( diff --git a/docs/internals/field-type-polymorphism.md b/docs/internals/field-type-polymorphism.md index a9aacd67..3ea16043 100644 --- a/docs/internals/field-type-polymorphism.md +++ b/docs/internals/field-type-polymorphism.md @@ -11,7 +11,7 @@ public enum FieldValue: Codable, Equatable, Sendable { case string(String) case int64(Int) case double(Double) - case bytes(String) // Base64-encoded binary data + case bytes(Data) // Binary data; base64-encoded on the wire case date(Date) // Stored as milliseconds since epoch case location(Location) case reference(Reference) From e678b41b71fd52c401a2e50fa9ab744e7d6a6c4f Mon Sep 17 00:00:00 2001 From: leogdion Date: Fri, 4 Sep 2026 11:54:11 -0400 Subject: [PATCH 18/25] Verify asset downloads against fileChecksum (#473, #466) * Verify asset downloads against fileChecksum (#466). Co-authored-by: Cursor * Fix watchOS asset download tests and dogfood CDN verify in MistDemo. Replace MockURLProtocol with an internal fetch closure so download tests do not hit the network on watchOS, and add DownloadAssetPhase to exercise Asset.download() against live CDN bytes. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- AGENTS.md | 2 + .../Phases/DownloadAssetPhase.swift | 78 +++++++++ .../Tests/PrivateDatabaseTest.swift | 1 + .../Tests/PublicDatabaseTest.swift | 1 + ReleaseNotes.md | 1 + .../CloudKitError+ErrorDescription.swift | 6 + .../CloudKitError+ServerErrorCode.swift | 3 +- .../CloudKitService/CloudKitError.swift | 8 + .../Models/FieldValues/Asset+Checksum.swift | 67 ++++++++ .../Models/FieldValues/Asset+Download.swift | 107 ++++++++++++ .../FieldValues/AssetChecksumTests.swift | 67 ++++++++ .../FieldValues/AssetDownloadTests.swift | 160 ++++++++++++++++++ 12 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DownloadAssetPhase.swift create mode 100644 Sources/MistKit/Models/FieldValues/Asset+Checksum.swift create mode 100644 Sources/MistKit/Models/FieldValues/Asset+Download.swift create mode 100644 Tests/MistKitTests/Models/FieldValues/AssetChecksumTests.swift create mode 100644 Tests/MistKitTests/Models/FieldValues/AssetDownloadTests.swift diff --git a/AGENTS.md b/AGENTS.md index c0958f60..ad5e2cfc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -358,6 +358,8 @@ Asset uploads use `URLSession.shared` directly rather than the injected `ClientT - `requestAssetUploadURL()` - Step 1: Get CDN upload URL → `Sources/MistKit/CloudKitService/CloudKitService+AssetOperations.swift` - `uploadAssetData()` - Step 2: Upload binary data to CDN → `Sources/MistKit/CloudKitService/CloudKitService+AssetUpload.swift` +**Asset download checksum (issue #466):** `Asset.matches(data:)` SHA-256-hashes plaintext bytes (`swift-crypto`) and compares to `fileChecksum` as base64 of the digest first (same convention as `HashFunction.cloudKitBodyHash`), then as hex (case-insensitive). Missing `fileChecksum` is `false`. `wrappingKey` / CDN encryption is out of scope. `Asset.download(using:)` (`#if !os(WASI)`, macOS 12 / iOS 15+) GETs `downloadURL` via `URLSession` (default `.shared`, same CDN-vs-API pool split as uploads) and **never** returns unverified bytes: missing/invalid URL → `CloudKitError.missingAssetDownloadURL`, non-2xx → `httpError`, missing checksum → `missingAssetChecksum`, digest mismatch → `assetChecksumMismatch`. Tests: `AssetChecksumTests`, `AssetDownloadTests`. + **Future Consideration:** A `ClientTransport` extension could provide a generic upload method, but would need to: - Handle connection pooling separately for different hosts diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DownloadAssetPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DownloadAssetPhase.swift new file mode 100644 index 00000000..92ba74a3 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DownloadAssetPhase.swift @@ -0,0 +1,78 @@ +// +// DownloadAssetPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKit + +/// Looks up a created record's `image` asset, downloads the CDN bytes, and +/// verifies them against ``Asset/fileChecksum`` via ``Asset/download(using:)``. +internal struct DownloadAssetPhase: IntegrationPhase { + internal typealias Input = CreatedRecordNames + internal typealias Output = NoState + + internal static let title = "Download and verify asset" + internal static let emoji = "📥" + internal static let apiName = "downloadAsset" + + internal func run( + input: CreatedRecordNames, context: PhaseContext + ) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + guard let recordName = input.names.first else { + throw IntegrationTestError.missingPhaseState("createdRecordNames") + } + + let results = try await context.service.lookupRecords( + recordNames: [recordName], + database: context.database + ) + guard case .success(let record)? = results.first else { + throw IntegrationTestError.verificationFailed( + "Lookup of '\(recordName)' did not return a record for asset download" + ) + } + guard case .asset(let asset) = record.fields["image"] else { + throw IntegrationTestError.verificationFailed( + "Record '\(recordName)' has no 'image' asset to download" + ) + } + + if context.verbose { + print(" Record: \(recordName)") + print(" fileChecksum: \(asset.fileChecksum ?? "nil")") + print(" downloadURL: \(asset.downloadURL ?? "nil")") + } + + let data = try await asset.download() + print("✅ Downloaded and verified \(data.count) bytes against fileChecksum") + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift index 18602e7c..9e82ea4e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift @@ -48,6 +48,7 @@ internal struct PrivateDatabaseTest: PhasedIntegrationTest { ZonePayloadMetadataPhase(), UploadAssetPhase(), CreateRecordsPhase(), + DownloadAssetPhase(), RereferenceAssetPhase(), QueryRecordsPhase(), LookupRecordsPhase(), diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift index 850c9d38..72e26b79 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift @@ -57,6 +57,7 @@ internal struct PublicDatabaseTest: PhasedIntegrationTest { LookupZonePhase(), UploadAssetPhase(), CreateRecordsPhase(), + DownloadAssetPhase(), RereferenceAssetPhase(), QueryRecordsPhase(), LookupRecordsPhase(), diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 4548b074..377eb82b 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,6 +1,7 @@ ## Unreleased * Represent `FieldValue.bytes` as `Data` instead of a base64 `String` (#467) +* Verify downloaded asset bytes against `fileChecksum`, and add `Asset.download(using:)` that refuses unverified data (#466) ## 1.0.0-beta.4 diff --git a/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift b/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift index a09cde34..5fa8854f 100644 --- a/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift +++ b/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift @@ -82,6 +82,12 @@ extension CloudKitError { let location = path.map { "from '\($0)'" } ?? "from inline material" return "Failed to load CloudKit private key \(location): \(underlying.localizedDescription)" + case .missingAssetDownloadURL: + return "Asset downloadURL is missing or is not a valid URL" + case .missingAssetChecksum: + return "Asset fileChecksum is missing; refusing to return unverified bytes" + case .assetChecksumMismatch: + return "Downloaded asset bytes did not match fileChecksum" case .accessDenied, .atomicFailure, .authenticationFailed, .authenticationRequired, .badRequest, .conflict, .exists, .internalServerError, .notFound, .quotaExceeded, .throttled, .tryAgainLater, .validatingReferenceError, .zoneNotFound, diff --git a/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift b/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift index 7e76abb1..e245e80b 100644 --- a/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift +++ b/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift @@ -86,7 +86,8 @@ extension CloudKitError { .incompleteResponse, .conversionFailed, .recordOperationFailed, .zoneOperationFailed, .subscriptionOperationFailed, .subscriptionLikelyDuplicate, .underlyingError, .decodingError, .networkError, .unsupportedOperationType, .paginationLimitExceeded, - .zonePaginationLimitExceeded, .missingCredentials, .invalidPrivateKey: + .zonePaginationLimitExceeded, .missingCredentials, .invalidPrivateKey, + .missingAssetDownloadURL, .missingAssetChecksum, .assetChecksumMismatch: return nil } } diff --git a/Sources/MistKit/CloudKitService/CloudKitError.swift b/Sources/MistKit/CloudKitService/CloudKitError.swift index 37a22929..17992a57 100644 --- a/Sources/MistKit/CloudKitService/CloudKitError.swift +++ b/Sources/MistKit/CloudKitService/CloudKitError.swift @@ -135,6 +135,14 @@ public enum CloudKitError: LocalizedError, Sendable { reason: String ) case invalidPrivateKey(path: String?, underlying: any Error) + /// `Asset.download(using:)` had no usable `downloadURL`. + case missingAssetDownloadURL + /// `Asset.download(using:)` requires `fileChecksum` and never returns bytes + /// that have not been verified against it. + case missingAssetChecksum + /// Downloaded asset bytes did not match `Asset.fileChecksum` (SHA-256 of the + /// plaintext, compared as base64 then as hex). + case assetChecksumMismatch /// HTTP status code if this error originated from an HTTP response, otherwise nil. /// diff --git a/Sources/MistKit/Models/FieldValues/Asset+Checksum.swift b/Sources/MistKit/Models/FieldValues/Asset+Checksum.swift new file mode 100644 index 00000000..951dfe4e --- /dev/null +++ b/Sources/MistKit/Models/FieldValues/Asset+Checksum.swift @@ -0,0 +1,67 @@ +// +// Asset+Checksum.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Crypto +public import Foundation + +extension Asset { + private static let hexDigits: [Character] = Array("0123456789abcdef") + + private static func hexEncoded(_ data: Data) -> String { + data.reduce(into: "") { result, byte in + result.append(Self.hexDigits[Int(byte >> 4)]) + result.append(Self.hexDigits[Int(byte & 0x0F)]) + } + } + + /// Returns whether `data` is the plaintext file this asset describes. + /// + /// Hashes the raw plaintext bytes with SHA-256 and compares the digest to + /// ``fileChecksum``. The checksum is first interpreted as the base64 encoding + /// of the digest (the same convention as CloudKit request body hashes). If + /// that comparison fails, the checksum is compared to the hex encoding of + /// the digest, case-insensitively. + /// + /// Returns `false` when ``fileChecksum`` is missing. CDN `wrappingKey` + /// encryption is out of scope: this method never unwraps or decrypts. + /// + /// - Parameter data: Plaintext file bytes. Encrypted CDN payloads are not + /// decoded here. + /// - Returns: Whether `data`'s SHA-256 digest matches ``fileChecksum``. + public func matches(data: Data) -> Bool { + guard let fileChecksum else { + return false + } + let digest = Data(SHA256.hash(data: data)) + if fileChecksum == digest.base64EncodedString() { + return true + } + return fileChecksum.lowercased() == Self.hexEncoded(digest) + } +} diff --git a/Sources/MistKit/Models/FieldValues/Asset+Download.swift b/Sources/MistKit/Models/FieldValues/Asset+Download.swift new file mode 100644 index 00000000..0b959cb7 --- /dev/null +++ b/Sources/MistKit/Models/FieldValues/Asset+Download.swift @@ -0,0 +1,107 @@ +// +// Asset+Download.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +#if canImport(FoundationNetworking) + public import FoundationNetworking +#endif + +#if !os(WASI) + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + extension Asset { + private static func requireSuccess(_ response: URLResponse) throws { + let statusCode = (response as? HTTPURLResponse)?.statusCode + guard let statusCode else { + throw CloudKitError.invalidResponse + } + guard (200...299).contains(statusCode) else { + throw CloudKitError.httpError(statusCode: statusCode) + } + } + + /// Downloads this asset's bytes and verifies them against ``fileChecksum``. + /// + /// Never returns unverified data. A missing ``fileChecksum`` is an error, + /// not a skip. CDN `wrappingKey` encryption is out of scope. + /// + /// - Parameter session: Session used for the GET. Defaults to `.shared`, + /// matching CDN asset uploads (a connection pool separate from the + /// CloudKit API transport). + /// - Returns: The response body after a successful checksum comparison. + /// - Throws: ``CloudKitError/missingAssetDownloadURL`` when ``downloadURL`` + /// is missing or not a valid URL; ``CloudKitError/httpError(statusCode:)`` + /// on a non-success HTTP status; ``CloudKitError/missingAssetChecksum`` + /// when ``fileChecksum`` is absent; ``CloudKitError/assetChecksumMismatch`` + /// when the body does not match. + public func download(using session: URLSession = .shared) async throws -> Data { + try await download { url in + try await session.data(from: url) + } + } + + /// Testable download path that takes a fetch closure instead of a session. + /// + /// - Parameter fetching: Performs the GET for the resolved download URL. + /// - Returns: The response body after a successful checksum comparison. + /// - Throws: ``CloudKitError/missingAssetDownloadURL`` when ``downloadURL`` + /// is missing or not a valid URL; ``CloudKitError/httpError(statusCode:)`` + /// on a non-success HTTP status; ``CloudKitError/missingAssetChecksum`` + /// when ``fileChecksum`` is absent; ``CloudKitError/assetChecksumMismatch`` + /// when the body does not match; or any error thrown by `fetching`. + internal func download( + fetching: (URL) async throws -> (Data, URLResponse) + ) async throws -> Data { + let url = try resolvedDownloadURL() + let (data, response) = try await fetching(url) + try Self.requireSuccess(response) + try requireMatchingChecksum(data) + return data + } + + private func resolvedDownloadURL() throws -> URL { + guard let downloadURL, !downloadURL.isEmpty else { + throw CloudKitError.missingAssetDownloadURL + } + guard let url = URL(string: downloadURL), url.host != nil else { + throw CloudKitError.missingAssetDownloadURL + } + return url + } + + private func requireMatchingChecksum(_ data: Data) throws { + guard fileChecksum != nil else { + throw CloudKitError.missingAssetChecksum + } + guard matches(data: data) else { + throw CloudKitError.assetChecksumMismatch + } + } + } +#endif diff --git a/Tests/MistKitTests/Models/FieldValues/AssetChecksumTests.swift b/Tests/MistKitTests/Models/FieldValues/AssetChecksumTests.swift new file mode 100644 index 00000000..774a5657 --- /dev/null +++ b/Tests/MistKitTests/Models/FieldValues/AssetChecksumTests.swift @@ -0,0 +1,67 @@ +// +// AssetChecksumTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import Testing + +@testable import MistKit + +@Suite("Asset Checksum") +internal struct AssetChecksumTests { + /// SHA-256 of the UTF-8 bytes of `"hello"`. + private static let plaintext = Data("hello".utf8) + private static let sha256Base64 = "LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=" + private static let sha256Hex = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + + @Test("matches is true for the base64 SHA-256 of the plaintext") + internal func matchesBase64Checksum() { + let asset = Asset(fileChecksum: Self.sha256Base64) + #expect(asset.matches(data: Self.plaintext)) + } + + @Test("matches is true for the hex SHA-256 of the plaintext, case-insensitively") + internal func matchesHexChecksumCaseInsensitively() { + let lower = Asset(fileChecksum: Self.sha256Hex) + let upper = Asset(fileChecksum: Self.sha256Hex.uppercased()) + #expect(lower.matches(data: Self.plaintext)) + #expect(upper.matches(data: Self.plaintext)) + } + + @Test("matches is false when fileChecksum is missing") + internal func missingChecksumDoesNotMatch() { + let asset = Asset() + #expect(!asset.matches(data: Self.plaintext)) + } + + @Test("matches is false when the digest does not match fileChecksum") + internal func mismatchedChecksumDoesNotMatch() { + let asset = Asset(fileChecksum: Self.sha256Base64) + #expect(!asset.matches(data: Data("world".utf8))) + } +} diff --git a/Tests/MistKitTests/Models/FieldValues/AssetDownloadTests.swift b/Tests/MistKitTests/Models/FieldValues/AssetDownloadTests.swift new file mode 100644 index 00000000..7dbc0e75 --- /dev/null +++ b/Tests/MistKitTests/Models/FieldValues/AssetDownloadTests.swift @@ -0,0 +1,160 @@ +// +// AssetDownloadTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if !os(WASI) + internal import Foundation + internal import Testing + + @testable import MistKit + + #if canImport(FoundationNetworking) + internal import FoundationNetworking + #endif + + @Suite("Asset Download") + internal struct AssetDownloadTests { + private static let plaintext = Data("hello".utf8) + private static let sha256Base64 = "LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=" + private static let downloadURLString = "https://cvws.icloud-content.com/asset.bin" + + private static func httpResponse(statusCode: Int, url: URL) throws -> HTTPURLResponse { + guard + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + ) + else { + throw URLError(.badServerResponse) + } + return response + } + + @Test("download returns bytes when HTTP succeeds and the checksum matches") + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + internal func downloadReturnsVerifiedBytes() async throws { + let asset = Asset( + fileChecksum: Self.sha256Base64, + downloadURL: Self.downloadURLString + ) + let data = try await asset.download { url in + (Self.plaintext, try Self.httpResponse(statusCode: 200, url: url)) + } + #expect(data == Self.plaintext) + } + + @Test("download throws httpError on a non-success status") + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + internal func downloadThrowsOnHTTPFailure() async throws { + let asset = Asset( + fileChecksum: Self.sha256Base64, + downloadURL: Self.downloadURLString + ) + let error = await #expect(throws: CloudKitError.self) { + _ = try await asset.download { url in + (Data(), try Self.httpResponse(statusCode: 404, url: url)) + } + } + guard case .httpError(let statusCode) = error else { + Issue.record("Expected httpError, got \(error)") + return + } + #expect(statusCode == 404) + } + + @Test("download throws assetChecksumMismatch and does not return the body") + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + internal func downloadThrowsOnChecksumMismatch() async throws { + let asset = Asset( + fileChecksum: Self.sha256Base64, + downloadURL: Self.downloadURLString + ) + let error = await #expect(throws: CloudKitError.self) { + _ = try await asset.download { url in + (Data("world".utf8), try Self.httpResponse(statusCode: 200, url: url)) + } + } + guard case .assetChecksumMismatch = error else { + Issue.record("Expected assetChecksumMismatch, got \(error)") + return + } + } + + @Test("download throws missingAssetChecksum instead of returning unverified bytes") + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + internal func downloadThrowsWhenChecksumMissing() async throws { + let asset = Asset(downloadURL: Self.downloadURLString) + let error = await #expect(throws: CloudKitError.self) { + _ = try await asset.download { url in + (Self.plaintext, try Self.httpResponse(statusCode: 200, url: url)) + } + } + guard case .missingAssetChecksum = error else { + Issue.record("Expected missingAssetChecksum, got \(error)") + return + } + } + + @Test("download throws missingAssetDownloadURL when the URL is absent") + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + internal func downloadThrowsWhenURLMissing() async throws { + let asset = Asset(fileChecksum: Self.sha256Base64) + let error = await #expect(throws: CloudKitError.self) { + _ = try await asset.download { _ in + Issue.record("fetch should not run when downloadURL is missing") + throw URLError(.badURL) + } + } + guard case .missingAssetDownloadURL = error else { + Issue.record("Expected missingAssetDownloadURL, got \(error)") + return + } + } + + @Test("download throws missingAssetDownloadURL when the URL is invalid") + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + internal func downloadThrowsWhenURLInvalid() async throws { + let asset = Asset( + fileChecksum: Self.sha256Base64, + downloadURL: "not a url" + ) + let error = await #expect(throws: CloudKitError.self) { + _ = try await asset.download { _ in + Issue.record("fetch should not run when downloadURL is invalid") + throw URLError(.badURL) + } + } + guard case .missingAssetDownloadURL = error else { + Issue.record("Expected missingAssetDownloadURL, got \(error)") + return + } + } + } +#endif From 40896167ffdc5625c978b93917a2dd8c5b1580da Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 4 Sep 2026 12:48:59 -0400 Subject: [PATCH 19/25] Fix MistDemo Integration static build for Swift 6.4. MistDemo requires tools-version 6.4, so pin the musl build to nightly-6.4.x-noble and the matching 6.4.x-branch Static Linux SDK. Co-authored-by: Cursor --- .github/workflows/MistDemo-Integration.yml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/MistDemo-Integration.yml b/.github/workflows/MistDemo-Integration.yml index 23c507db..92ef7cb6 100644 --- a/.github/workflows/MistDemo-Integration.yml +++ b/.github/workflows/MistDemo-Integration.yml @@ -19,9 +19,12 @@ # after rotating, then re-run via workflow_dispatch. # # Two-job design: -# 1) `build` runs in `swift:6.3-noble`, installs the Swift Static -# Linux SDK (musl), and produces a self-contained statically -# linked mistdemo binary. +# 1) `build` runs in `swiftlang/swift:nightly-6.4.x-noble` (MistDemo +# declares swift-tools-version: 6.4; no 6.4 RELEASE image yet), +# installs the matching 6.4.x-branch Static Linux SDK (musl), and +# produces a self-contained statically linked mistdemo binary. +# Bump container + SDK URL/checksum together; switch to a 6.4 +# RELEASE pin when Apple ships one. # 2) `integration` runs on plain `ubuntu-24.04` (no Swift toolchain, # no LD_LIBRARY_PATH needed) and executes the live CloudKit # phases against the static binary. @@ -46,9 +49,9 @@ jobs: build: name: Build static mistdemo runs-on: ubuntu-24.04 - # Pin to an exact Swift patch — the Static Linux SDK URL + checksum - # below are tied to this same version. Bump together. - container: swift:6.3.2-noble + # Pin nightly container + 6.4.x-branch Static Linux SDK together. + # Snapshot URL/checksum match SyndiKit-style 6.4.x-branch SDK pins. + container: swiftlang/swift:nightly-6.4.x-noble if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} timeout-minutes: 45 defaults: @@ -61,8 +64,8 @@ jobs: run: | set -euo pipefail swift sdk install \ - https://download.swift.org/swift-6.3.2-release/static-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_static-linux-0.1.0.artifactbundle.tar.gz \ - --checksum 3fd798bef6f4408f1ea5a6f94ce4d4052830c4326ab85ebc04f983f01b3da407 + https://download.swift.org/swift-6.4.x-branch/static-sdk/swift-6.4.x-DEVELOPMENT-SNAPSHOT-2026-09-01-a/swift-6.4.x-DEVELOPMENT-SNAPSHOT-2026-09-01-a_static-linux-0.1.0.artifactbundle.tar.gz \ + --checksum 68a0c0345f715c4272eda1ef79d53e86c0b19a5600bc678ef48a641b22538f85 swift sdk list - name: swift build --swift-sdk x86_64-swift-linux-musl -c release From 4a45b88721759acfcfe70cd22024070bbafd2162 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 4 Sep 2026 13:49:28 -0400 Subject: [PATCH 20/25] Resolve mistdemo bin path via SwiftPM in Integration CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Verify static linkage" step hardcoded .build/x86_64-swift-linux-musl/release/mistdemo, which the nightly 6.4 toolchain does not populate — the build succeeded but the step failed with "No such file or directory", blocking the integration job. Ask SwiftPM for the directory with --show-bin-path instead, print diagnostics (ls of the bin dir plus a find over .build) when the binary is still missing, and stage the verified binary at a fixed path so upload-artifact no longer duplicates the layout assumption. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/MistDemo-Integration.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/MistDemo-Integration.yml b/.github/workflows/MistDemo-Integration.yml index 92ef7cb6..a153196f 100644 --- a/.github/workflows/MistDemo-Integration.yml +++ b/.github/workflows/MistDemo-Integration.yml @@ -76,7 +76,19 @@ jobs: working-directory: Examples/MistDemo run: | set -euo pipefail - BIN=.build/x86_64-swift-linux-musl/release/mistdemo + # Ask SwiftPM where it put the binary rather than hardcoding the + # layout - the cross-compiled output directory is toolchain + # dependent and has moved between releases. + BIN_DIR=$(swift build -c release --swift-sdk x86_64-swift-linux-musl --show-bin-path) + BIN="$BIN_DIR/mistdemo" + if [ ! -f "$BIN" ]; then + echo "::error::mistdemo not found at $BIN" + echo "Contents of $BIN_DIR:" + ls -la "$BIN_DIR" || true + echo "Executables under .build:" + find .build -type f -name mistdemo || true + exit 1 + fi ls -lh "$BIN" # A statically linked binary either reports "not a dynamic # executable" (musl) or "statically linked" (glibc). Anything @@ -87,11 +99,15 @@ jobs: echo "::error::Binary has dynamic dependencies; static link failed" exit 1 fi + # Stage at a fixed path so the upload step does not have to know + # the toolchain-specific build layout. + mkdir -p "$RUNNER_TEMP/artifact" + cp "$BIN" "$RUNNER_TEMP/artifact/mistdemo" - uses: actions/upload-artifact@v4 with: name: mistdemo - path: Examples/MistDemo/.build/x86_64-swift-linux-musl/release/mistdemo + path: ${{ runner.temp }}/artifact/mistdemo retention-days: 1 # Single executable; default zip compression is fine. From 04101008a75491067dae3bdf4a188e2a9b1f99f2 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 4 Sep 2026 13:50:52 -0400 Subject: [PATCH 21/25] Fill out the Unreleased release notes section The Unreleased section covered only two of the branch's changes. Add the missing public API additions: web auth token rotation (#462, #463), the zone payload metadata zoneType/deleted plus the ownerRecordName decoding fix (#444), the zoneID: parameter on createRecord/updateRecord/ deleteRecord/uploadAssets (#454), the new asset-download CloudKitError cases (#466), the MistKitConfiguration extraction (#455), the MistDemo zone-aware work (#438, #453, #454), and the release runbook plus code review follow-ups (#460, #461). Also move the Reference.Action VALIDATE entry (#464) out of the 1.0.0-beta.4 section: that tag is already published and does not contain the commit, so the change ships in this release, not the previous one. Per the release runbook, new entries are a flat bullet list with no category subsections. Co-Authored-By: Claude Opus 5 (1M context) --- ReleaseNotes.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 377eb82b..562ac15d 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -2,12 +2,18 @@ * Represent `FieldValue.bytes` as `Data` instead of a base64 `String` (#467) * Verify downloaded asset bytes against `fileChecksum`, and add `Asset.download(using:)` that refuses unverified data (#466) +* Add `VALIDATE` to `Reference.Action` for CloudKit Web Services reference dictionaries (#464) +* Consume rotated web auth tokens from the `X-Apple-CloudKit-Web-Auth-Token` response header via a new `TokenManager.didReceiveRotatedWebAuthToken(_:)` requirement, defaulted to a no-op so existing conformances keep compiling (#462, #463) +* Model the zone payload fields confirmed live in #444: `ZoneInfo.zoneType` (a closed `ZoneType` enum that throws `ConversionError.unrecognizedZoneType` on unknown wire values) and `ZoneInfo.deleted`, so change feeds surface tombstones (#444) +* Fix `ownerRecordName` never decoding on zone payloads — `ZoneID` read the wire key as `ownerName`, so shared-zone owners were always `nil` (#444) +* Add an optional `zoneID:` parameter to `createRecord`, `updateRecord`, `deleteRecord`, and `uploadAssets`, so writes and asset uploads can target custom and shared zones (#454) +* Add `CloudKitError.missingAssetDownloadURL`, `.missingAssetChecksum`, and `.assetChecksumMismatch` for the asset download path (#466) +* Extract the shared CloudKit credential configuration glue into a separate `MistKitConfiguration` package and converge the examples on typed configuration keys (#455) +* MistDemo: expose `zoneName`/`zoneOwner` on the web query panel and add zone-aware writes plus a live shared-zone round-trip phase (#438, #453, #454) +* Add a repeatable release runbook, and address code review fixes, coverage, and lint tooling (#460, #461) ## 1.0.0-beta.4 -### Field Values -* Add VALIDATE to `Reference.Action` for CloudKit Web Services reference dictionaries (#464) - ### Change Tracking * Add changes/database and changes/zone endpoints; deprecate zones/changes (#401, #47, #46) by @leogdion in https://github.com/brightdigit/MistKit/pull/429 * modifyZones per-zone failures (#431) + zones/changes metaSyncToken (#430) by @leogdion in https://github.com/brightdigit/MistKit/pull/443 From 1edd73ad099df6e87a535dff883323a4377699af Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 4 Sep 2026 13:54:00 -0400 Subject: [PATCH 22/25] Add the v1.0.0-beta.5 roadmap section to README Scripts/release.sh verify-tag asserts a '### v' roadmap section exists in the tagged tree; there was none for beta.5, so the release check would have failed. Also check off #146 (custom CloudKit zone support for queries) under beta.4. The issue closed 2026-08-28 and shipped in that release, but it was still listed unchecked under Backlog / Post-beta. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ca1e6c8e..99741419 100644 --- a/README.md +++ b/README.md @@ -553,12 +553,22 @@ MistKit is released under the MIT License. See [LICENSE](LICENSE) for details. - [x] [Fetching Database Changes (changes/database)](https://github.com/brightdigit/MistKit/issues/46) ✅ - [x] [Fetching Record Zone Changes (changes/zone)](https://github.com/brightdigit/MistKit/issues/47) ✅ - [x] [Clarify change-tracking endpoint coverage](https://github.com/brightdigit/MistKit/issues/401) ✅ *(deprecates `zones/changes` in favor of `changes/database`)* +- [x] [Add custom CloudKit zone support for queries](https://github.com/brightdigit/MistKit/issues/146) ✅ + +### v1.0.0-beta.5 + +- [x] [Zone payloads: `deleted`, `zoneType`, and `ownerRecordName` decoding](https://github.com/brightdigit/MistKit/issues/444) ✅ +- [x] [Zone-aware writes (CRUD + assets)](https://github.com/brightdigit/MistKit/issues/454) ✅ +- [x] [Consume web auth token rotation (`X-Apple-CloudKit-Web-Auth-Token`)](https://github.com/brightdigit/MistKit/issues/462) ✅ +- [x] [Verify downloaded asset bytes against `Asset.fileChecksum`](https://github.com/brightdigit/MistKit/issues/466) ✅ +- [x] [Change `FieldValue.bytes` from `String` to `Data`](https://github.com/brightdigit/MistKit/issues/467) ✅ +- [x] [Add `VALIDATE` to `Reference.Action`](https://github.com/brightdigit/MistKit/issues/464) ✅ +- [x] [MistDemo web UI: `zoneName`/`zoneOwner` inputs on the query panel](https://github.com/brightdigit/MistKit/issues/438) ✅ ### Backlog / Post-beta - [ ] [Discovering All User Identities (GET users/discover)](https://github.com/brightdigit/MistKit/issues/28) - [ ] [Fetching Contacts (users/lookup/contacts)](https://github.com/brightdigit/MistKit/issues/33) -- [ ] [Feature: Add custom CloudKit zone support for queries](https://github.com/brightdigit/MistKit/issues/146) ### v1.0.0 From 0eb79dd227180ea0adcdba887917d5dc849d3529 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 4 Sep 2026 14:45:29 -0400 Subject: [PATCH 23/25] fixup! Verify asset downloads against fileChecksum (#473, #466) Remove the checksum verification #473 added: fileChecksum cannot be recomputed client-side, so the check rejected every genuine asset. The value is not a SHA-256 of the plaintext. Verified against a live container, it decodes to 21 bytes -- a 0x01 version prefix plus a 20-byte digest -- so it could never equal a 32-byte SHA-256, and Asset.download() therefore threw on every real CloudKit asset. The MistDemo Integration job caught this; the unit tests did not, because they built fixtures with the same formula they asserted against. It is minted server-side: MistKit reads it verbatim from the CDN upload receipt, the receipt embeds a fragment of it, and Apple's archived reference labels the field only [SIGNATURE] with no algorithm. It is deterministic and content-addressed -- identical bytes yield identical checksums, it doubles as the content address in downloadURL, and it is what rereferenceAssets echoes back -- so it is an identity/caching token, not an integrity check. ~1,500 candidate constructions over three byte-exact samples produced zero matches; see .claude/docs/research/asset-filechecksum.md. Delete matches(data:), assetChecksumMismatch, missingAssetChecksum and AssetChecksumTests rather than deprecating them: the API cannot do what its name says, and beta is the time to remove it. download(using:) now returns bytes unverified; MistDemo checks the declared size instead, which is client-checkable and catches truncation. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/docs/research/asset-filechecksum.md | 168 ++++++++++++++++++ AGENTS.md | 4 +- .../Phases/DownloadAssetPhase.swift | 17 +- .../CloudKitError+ErrorDescription.swift | 4 - .../CloudKitError+ServerErrorCode.swift | 2 +- .../CloudKitService/CloudKitError.swift | 6 - .../Models/FieldValues/Asset+Checksum.swift | 67 ------- .../Models/FieldValues/Asset+Download.swift | 32 ++-- .../MistKit/Models/FieldValues/Asset.swift | 9 +- .../FieldValues/AssetChecksumTests.swift | 67 ------- .../FieldValues/AssetDownloadTests.swift | 55 ++---- 11 files changed, 221 insertions(+), 210 deletions(-) create mode 100644 .claude/docs/research/asset-filechecksum.md delete mode 100644 Sources/MistKit/Models/FieldValues/Asset+Checksum.swift delete mode 100644 Tests/MistKitTests/Models/FieldValues/AssetChecksumTests.swift diff --git a/.claude/docs/research/asset-filechecksum.md b/.claude/docs/research/asset-filechecksum.md new file mode 100644 index 00000000..be69b053 --- /dev/null +++ b/.claude/docs/research/asset-filechecksum.md @@ -0,0 +1,168 @@ +# Reverse-engineering CloudKit's asset `fileChecksum` + +**Date:** 2026-09-04 +**Container:** `iCloud.com.brightdigit.MistDemo` / `development` / public database (web-auth) +**Question:** How is the `fileChecksum` on a CloudKit `ASSET`/`ASSETID` field computed, and can a +client verify downloaded asset bytes against it? + +**Bottom line:** `fileChecksum` is **deterministic and content-addressed**, but it is **not +client-derivable**. It is minted server-side by the CDN, returned in the `assets/upload` receipt, and +is an opaque token that Apple's own documentation labels only `[SIGNATURE]`. ~1,500 candidate +constructions over three byte-exact samples all failed. `Asset.matches(data:)` and +`Asset.download(using:)` as currently written (SHA-256 of plaintext, base64 or hex) **cannot ever +succeed** against a real CloudKit asset. + +--- + +## 1. Samples + +All PNGs produced by `PNGData.generate(withSizeInKB:)` +(`Examples/MistDemo/Sources/MistDemoKit/Integration/PNGData.swift`). The generator was reimplemented +in Python and **verified byte-exact**: generated lengths and SHA-1s match both the live upload sizes +and the bytes downloaded back from the CDN. + +| `--asset-size` | bytes | sha1(plaintext) | sha256(plaintext)[:20] | observed `fileChecksum` | prefix | 20-byte body | +|---|---|---|---|---|---|---| +| 7 | 7320 | `f77d1cf229bcd696f01e655b4a7814320aa31509` | `9d3c…` | `AfQRAtjnqPAbe1u9gaUx81u01Uis` | `01` | `f41102d8e7a8f01b7b5bbd81a531f35bb4d548ac` | +| 50 | 51682 | `e5d44d02e25223f26a70dd7c6cb6e57603c628d3` | `8e6d2a5a81e0d603915bd5eb020c2905854f2178` | `AUStEc+gPyq1KTFbGO3RbXVpusut` | `01` | `44ad11cfa03f2ab529315b18edd16d7569bacbad` | +| 100 | 102933 | `59de06c35010dfd1cdc1d3798a4e88fc1c65f506` | `1d49ff05dba92c11b17579a5641b792ed7b49920` | `AZqG2xi2/dCW6cKE4VPySz6q2pRk` | `01` | `9a86db18b6fdd096e9c284e153f24b3eaada9464` | +| (shared-zone phase) | 51200 | — (not regenerated) | — | `AWbGPi+3CS5arEas6JNFb9tbw1zY` | `01` | `66c63e2fb7092e5aac46ace893456fdb5bc35cd8` | + +Every checksum decodes to exactly **21 bytes = a constant `0x01` version prefix + a 20-byte body**. +Body one-bit counts are 81, 83, 81 out of 160 — statistically uniform, consistent with a +cryptographic digest rather than a structured identifier. + +The same string appears URL-safe-encoded as the CDN path component: +`https://cvws.icloud-content.com/B/AfQRAtjnqPAbe1u9gaUx81u01Uis/${f}?…` — i.e. it doubles as the +content address. + +## 2. Established facts + +### 2.1 It is deterministic (content-addressed) + +Uploading the **same bytes** in two independent `mistdemo test-public` runs, into two different +records, produced **identical** checksums: + +| bytes | run 1 | run 2 | same? | +|---|---|---|---| +| 51200 | `AWbGPi+3CS5arEas6JNFb9tbw1zY` | `AWbGPi+3CS5arEas6JNFb9tbw1zY` | ✅ | +| 102933 | `AZqG2xi2/dCW6cKE4VPySz6q2pRk` | `AZqG2xi2/dCW6cKE4VPySz6q2pRk` | ✅ | + +The 51682 sample also reproduced the value recorded in an earlier, separate session +(`AUStEc+gPyq1KTFbGO3RbXVpusut`). **No per-upload nonce or per-record key is involved** — the value +is a pure function of the content (plus, possibly, fixed container-scoped context). + +### 2.2 The CDN stores and serves unmodified plaintext + +Downloading the 7320-byte asset back from its `downloadURL` returned HTTP 200, exactly 7320 bytes, +**byte-identical** to the locally generated PNG. So the checksum is *not* a digest over an encrypted +or otherwise transformed server-side representation — the plaintext is what is stored. (No +`wrappingKey` was present in any upload response.) + +### 2.3 It is minted server-side, not by the client + +MistKit does not compute it. `CloudKitService+AssetUpload.swift:106` reads it straight out of the +CDN's `singleFileUpload` response: + +```swift +return Asset( + fileChecksum: uploadResponse.singleFile.fileChecksum, + … +) +``` + +The client's only role is to pass the value through into the subsequent `records/modify`. Apple's +archived [Uploading Assets](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/UploadAssets.html) +documents the response purely as opaque pass-through data, giving `fileChecksum` the placeholder +`[SIGNATURE]` and specifying **no algorithm**. No primary Apple source in `.claude/docs/` documents +one either (`QUICK_REFERENCE.md:109` names the field but not its computation). + +### 2.4 The upload receipt embeds a fragment of the checksum + +The `receipt` is a binary (protobuf-like) blob. For the 51682 sample it contains the subsequence +`52 04 69bacbad` — a length-4 field whose value `69bacbad` is exactly the **last 4 bytes of the +checksum body** (`…7569bacbad`). This confirms the checksum body is a server-side artifact carried +inside the signed receipt, and reinforces that it is minted by Apple's infrastructure rather than +derived by either endpoint. + +## 3. Hypotheses tested — all negative + +Every construction below was evaluated against **all three byte-exact samples**; a hypothesis was +only ever going to be accepted on a ≥2-sample reproduction. **Total: ~1,500 probes, zero matches.** + +| Family | Variants tried | Result | +|---|---|---| +| Plain digests | sha1, sha224, sha256, sha384, sha512, sha512_224/256, md5, sha3_224/256/384/512, blake2b, blake2s, ripemd160, sm3, md5-sha1, shake_128/256 — **every 20-byte sliding window** of each digest, not just the first | ❌ | +| blake2 with `digest_size=20` | blake2b-160, blake2s-160 | ❌ | +| Length framing | size as le64/be64/le32/be32/decimal-ASCII, prepended / appended / both, over sha1, sha256, sha512, md5, blake2b, sha3_256 | ❌ | +| Version-byte framing | `0x00`/`0x01`/`0x02`/`0x0100`/`0x0001` pre- and post-pended, over sha1/sha256 | ❌ | +| HMAC | 16 candidate keys (empty, zero-blocks, container id `iCloud.com.brightdigit.MistDemo`, `CloudKit`, `cloudkit`, `Apple`, `com.apple.cloudkit`, `com.apple.Dataclass.CloudKit`, `_defaultZone`, `development`, `public`, `singleFileUpload`, record type) × sha1/sha256/sha512/md5/blake2b, **and** with key/message swapped | ❌ | +| Data-derived keys | `hmac(k=sha256(data), m=len)`, `hmac(k=len, m=data)` | ❌ | +| Chunk trees | leaf ∈ {sha1, sha256, md5, blake2b}, root ∈ {sha1, sha256}, chunk sizes 512 B → 32 MiB (all powers of two) plus 51200/65535/102400; flat concatenation, truncated-leaf concatenation, index-framed leaves, and **binary Merkle trees** | ❌ | +| Representations | hashing the lowercase-hex, uppercase-hex, standard-base64, and url-safe-base64 encodings of the data | ❌ | +| Nested / iterated | `a(b(data))` and `a(b(data) ‖ data)` across sha1/sha256/md5/sha512/blake2b | ❌ | +| hash160 style | `ripemd160(sha256(d))`, `ripemd160(sha1(d))`, `sha1(sha256(d))`, `ripemd160(blake2b(d))`, etc. | ❌ | +| Protobuf-style wrappers | `H(tag ‖ inner_digest ‖ size)` with tags `01`, `00`, `0a14`, `1220`, size in both endiannesses | ❌ | +| PNG-internal | sha1/sha256 of the IDAT payload only, and of the inflated raw pixel data | ❌ | +| Encrypted-representation | ruled out empirically — CDN returns byte-identical plaintext (§2.2) | ❌ | + +Also ruled out by construction: the 21-byte length excludes plain SHA-256 (32 B); the reproducibility +result (§2.1) excludes any per-upload nonce or random salt. + +## 4. Verdict + +**`fileChecksum` is not client-derivable from the asset bytes.** + +The strongest evidence: + +1. **Apple never documents an algorithm.** The archived Web Services Reference — the only primary + source — treats the value as opaque pass-through and calls it `[SIGNATURE]`. A signature, not a + digest, is the natural reading of a value the client is told only to echo back. +2. **The client never computes it.** It arrives from the CDN in the upload receipt; MistKit's sole + involvement is forwarding it (§2.3). +3. **The receipt embeds part of it** (§2.4), placing its provenance inside Apple's server-side signed + blob. +4. **~1,500 constructions over three byte-exact samples produced no match** (§3), including the + entire space of common digests at every truncation offset. +5. The `0x01` prefix plus a 20-byte body most plausibly denotes a **versioned server-side signature + or truncated keyed digest** whose key lives on Apple's infrastructure — unobtainable by a client + by definition. + +It remains conceivable that the body is some unkeyed digest under a framing not tried here, but the +combination of (1)–(4) makes a **server-held key** the far likelier explanation, and no amount of +client-side search can close that gap. + +### Consequence for MistKit + +`Asset.matches(data:)` (`Sources/MistKit/Models/FieldValues/Asset+Checksum.swift`) compares +`fileChecksum` against base64/hex of **SHA-256 of the plaintext**. Per the table above, that can +never match a real CloudKit asset. Because `Asset.download(using:)` treats a mismatch as fatal +(`CloudKitError.assetChecksumMismatch`) and refuses to return unverified bytes, **downloading any +genuine CloudKit asset currently always throws** — which is exactly the failing download-verify phase +in `mistdemo test-public`. + +Options, in rough order of preference: + +1. **Drop checksum verification as a precondition for returning bytes.** Return the downloaded data; + the transport is already TLS-authenticated against Apple's CDN. Optionally expose `fileChecksum` + as an opaque identity/caching token, which is what it demonstrably is. +2. **Verify size instead.** `size` from the asset dictionary is meaningful, client-checkable, and + catches truncated downloads — the realistic failure mode. +3. **Keep `matches(data:)` but re-document it** as a local-integrity helper against a + caller-supplied digest, not as CloudKit checksum validation. + +Whichever is chosen, the current documentation comments asserting `fileChecksum` is "SHA-256 of the +plaintext" are factually wrong and should be corrected. + +## 5. Reproducing + +```bash +cd Examples/MistDemo +swiftly run +6.4.x-snapshot-2026-06-15 swift run mistdemo test-public \ + --record-count 1 --asset-size 50 --verbose +``` + +Grep the output for `"fileChecksum"` alongside the adjacent `"size"`. The Python reimplementation of +`PNGData.generate` used to reconstruct exact upload bytes (verified against CDN downloads) is +reproduced by porting `PNGData.swift`: solid-color RGB PNG, filter byte 0 per scanline, zlib +*stored* (uncompressed) DEFLATE blocks, square side = `round(sqrt(sizeKB * 1024 / 3))`. diff --git a/AGENTS.md b/AGENTS.md index ad5e2cfc..1b0ccd94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -358,7 +358,9 @@ Asset uploads use `URLSession.shared` directly rather than the injected `ClientT - `requestAssetUploadURL()` - Step 1: Get CDN upload URL → `Sources/MistKit/CloudKitService/CloudKitService+AssetOperations.swift` - `uploadAssetData()` - Step 2: Upload binary data to CDN → `Sources/MistKit/CloudKitService/CloudKitService+AssetUpload.swift` -**Asset download checksum (issue #466):** `Asset.matches(data:)` SHA-256-hashes plaintext bytes (`swift-crypto`) and compares to `fileChecksum` as base64 of the digest first (same convention as `HashFunction.cloudKitBodyHash`), then as hex (case-insensitive). Missing `fileChecksum` is `false`. `wrappingKey` / CDN encryption is out of scope. `Asset.download(using:)` (`#if !os(WASI)`, macOS 12 / iOS 15+) GETs `downloadURL` via `URLSession` (default `.shared`, same CDN-vs-API pool split as uploads) and **never** returns unverified bytes: missing/invalid URL → `CloudKitError.missingAssetDownloadURL`, non-2xx → `httpError`, missing checksum → `missingAssetChecksum`, digest mismatch → `assetChecksumMismatch`. Tests: `AssetChecksumTests`, `AssetDownloadTests`. +**Asset download — `fileChecksum` is NOT verifiable client-side (issues #466, #473):** `Asset.download(using:)` (`#if !os(WASI)`, macOS 12 / iOS 15+) GETs `downloadURL` via `URLSession` (default `.shared`, same CDN-vs-API pool split as uploads) and returns the bytes unverified: missing/invalid URL → `CloudKitError.missingAssetDownloadURL`, non-2xx → `httpError`. Check `Asset.size` if you need a guard against a truncated download. + +#466 originally asked for the bytes to be verified against `fileChecksum`, and #473 implemented that as SHA-256-of-plaintext compared as base64 then hex. **That is not what `fileChecksum` is, and the goal is not achievable.** Verified against a live container (`iCloud.com.brightdigit.MistDemo`/`development`): the value decodes to 21 bytes — a `0x01` version prefix plus a 20-byte digest — so it can never equal a 32-byte SHA-256, and `Asset.download` therefore threw on *every* genuine CloudKit asset (the MistDemo Integration job caught this; unit tests did not, because they built fixtures with the same formula they asserted against). It is minted server-side — MistKit reads it verbatim out of the CDN upload receipt (`CloudKitService+AssetUpload.swift`), the receipt embeds a fragment of it, and Apple's archived reference labels the field only `[SIGNATURE]` with no algorithm. It is deterministic and content-addressed (identical bytes → identical checksum; it doubles as the content address in `downloadURL` and is what `rereferenceAssets` echoes back), so treat it as an identity/caching token. ~1,500 candidate constructions over three byte-exact samples produced zero matches — full write-up in `.claude/docs/research/asset-filechecksum.md`. `Asset.matches(data:)`, `CloudKitError.assetChecksumMismatch`, `missingAssetChecksum` and `AssetChecksumTests` were **removed** rather than deprecated; do not reintroduce plaintext-digest verification. `referenceChecksum`/`wrappingKey` never appear in live responses (encrypted assets are unexercised) and are equally unusable for this. Tests: `AssetDownloadTests`. **Future Consideration:** A `ClientTransport` extension could provide a generic upload method, but would need to: diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DownloadAssetPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DownloadAssetPhase.swift index 92ba74a3..8c468e84 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DownloadAssetPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DownloadAssetPhase.swift @@ -30,13 +30,17 @@ internal import Foundation internal import MistKit -/// Looks up a created record's `image` asset, downloads the CDN bytes, and -/// verifies them against ``Asset/fileChecksum`` via ``Asset/download(using:)``. +/// Looks up a created record's `image` asset and downloads the CDN bytes, +/// checking the byte count against the asset's declared `size`. +/// +/// Checksum verification is deliberately not requested: CloudKit's +/// `fileChecksum` is an opaque server-minted value, not a digest of the +/// plaintext, so it cannot be recomputed client-side. internal struct DownloadAssetPhase: IntegrationPhase { internal typealias Input = CreatedRecordNames internal typealias Output = NoState - internal static let title = "Download and verify asset" + internal static let title = "Download asset" internal static let emoji = "📥" internal static let apiName = "downloadAsset" @@ -71,7 +75,12 @@ internal struct DownloadAssetPhase: IntegrationPhase { } let data = try await asset.download() - print("✅ Downloaded and verified \(data.count) bytes against fileChecksum") + if let declaredSize = asset.size, Int64(data.count) != declaredSize { + throw IntegrationTestError.verificationFailed( + "Downloaded \(data.count) bytes but the asset declares \(declaredSize)" + ) + } + print("✅ Downloaded \(data.count) bytes") return NoState() } diff --git a/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift b/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift index 5fa8854f..4afe3ec2 100644 --- a/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift +++ b/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift @@ -84,10 +84,6 @@ extension CloudKitError { "Failed to load CloudKit private key \(location): \(underlying.localizedDescription)" case .missingAssetDownloadURL: return "Asset downloadURL is missing or is not a valid URL" - case .missingAssetChecksum: - return "Asset fileChecksum is missing; refusing to return unverified bytes" - case .assetChecksumMismatch: - return "Downloaded asset bytes did not match fileChecksum" case .accessDenied, .atomicFailure, .authenticationFailed, .authenticationRequired, .badRequest, .conflict, .exists, .internalServerError, .notFound, .quotaExceeded, .throttled, .tryAgainLater, .validatingReferenceError, .zoneNotFound, diff --git a/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift b/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift index e245e80b..95febdff 100644 --- a/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift +++ b/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift @@ -87,7 +87,7 @@ extension CloudKitError { .subscriptionOperationFailed, .subscriptionLikelyDuplicate, .underlyingError, .decodingError, .networkError, .unsupportedOperationType, .paginationLimitExceeded, .zonePaginationLimitExceeded, .missingCredentials, .invalidPrivateKey, - .missingAssetDownloadURL, .missingAssetChecksum, .assetChecksumMismatch: + .missingAssetDownloadURL: return nil } } diff --git a/Sources/MistKit/CloudKitService/CloudKitError.swift b/Sources/MistKit/CloudKitService/CloudKitError.swift index 17992a57..f97e20c6 100644 --- a/Sources/MistKit/CloudKitService/CloudKitError.swift +++ b/Sources/MistKit/CloudKitService/CloudKitError.swift @@ -137,12 +137,6 @@ public enum CloudKitError: LocalizedError, Sendable { case invalidPrivateKey(path: String?, underlying: any Error) /// `Asset.download(using:)` had no usable `downloadURL`. case missingAssetDownloadURL - /// `Asset.download(using:)` requires `fileChecksum` and never returns bytes - /// that have not been verified against it. - case missingAssetChecksum - /// Downloaded asset bytes did not match `Asset.fileChecksum` (SHA-256 of the - /// plaintext, compared as base64 then as hex). - case assetChecksumMismatch /// HTTP status code if this error originated from an HTTP response, otherwise nil. /// diff --git a/Sources/MistKit/Models/FieldValues/Asset+Checksum.swift b/Sources/MistKit/Models/FieldValues/Asset+Checksum.swift deleted file mode 100644 index 951dfe4e..00000000 --- a/Sources/MistKit/Models/FieldValues/Asset+Checksum.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// Asset+Checksum.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Crypto -public import Foundation - -extension Asset { - private static let hexDigits: [Character] = Array("0123456789abcdef") - - private static func hexEncoded(_ data: Data) -> String { - data.reduce(into: "") { result, byte in - result.append(Self.hexDigits[Int(byte >> 4)]) - result.append(Self.hexDigits[Int(byte & 0x0F)]) - } - } - - /// Returns whether `data` is the plaintext file this asset describes. - /// - /// Hashes the raw plaintext bytes with SHA-256 and compares the digest to - /// ``fileChecksum``. The checksum is first interpreted as the base64 encoding - /// of the digest (the same convention as CloudKit request body hashes). If - /// that comparison fails, the checksum is compared to the hex encoding of - /// the digest, case-insensitively. - /// - /// Returns `false` when ``fileChecksum`` is missing. CDN `wrappingKey` - /// encryption is out of scope: this method never unwraps or decrypts. - /// - /// - Parameter data: Plaintext file bytes. Encrypted CDN payloads are not - /// decoded here. - /// - Returns: Whether `data`'s SHA-256 digest matches ``fileChecksum``. - public func matches(data: Data) -> Bool { - guard let fileChecksum else { - return false - } - let digest = Data(SHA256.hash(data: data)) - if fileChecksum == digest.base64EncodedString() { - return true - } - return fileChecksum.lowercased() == Self.hexEncoded(digest) - } -} diff --git a/Sources/MistKit/Models/FieldValues/Asset+Download.swift b/Sources/MistKit/Models/FieldValues/Asset+Download.swift index 0b959cb7..c8b232a2 100644 --- a/Sources/MistKit/Models/FieldValues/Asset+Download.swift +++ b/Sources/MistKit/Models/FieldValues/Asset+Download.swift @@ -46,20 +46,22 @@ public import Foundation } } - /// Downloads this asset's bytes and verifies them against ``fileChecksum``. + /// Downloads this asset's bytes. /// - /// Never returns unverified data. A missing ``fileChecksum`` is an error, - /// not a skip. CDN `wrappingKey` encryption is out of scope. + /// The bytes are **not** verified against ``fileChecksum``. That value is + /// an opaque, server-minted identifier read out of the CDN upload receipt + /// — Apple documents it only as a signature and specifies no algorithm, so + /// it cannot be recomputed from the plaintext. Check ``size`` if you need a + /// client-side guard against a truncated download. CDN `wrappingKey` + /// encryption is out of scope. /// /// - Parameter session: Session used for the GET. Defaults to `.shared`, /// matching CDN asset uploads (a connection pool separate from the /// CloudKit API transport). - /// - Returns: The response body after a successful checksum comparison. + /// - Returns: The response body. /// - Throws: ``CloudKitError/missingAssetDownloadURL`` when ``downloadURL`` /// is missing or not a valid URL; ``CloudKitError/httpError(statusCode:)`` - /// on a non-success HTTP status; ``CloudKitError/missingAssetChecksum`` - /// when ``fileChecksum`` is absent; ``CloudKitError/assetChecksumMismatch`` - /// when the body does not match. + /// on a non-success HTTP status. public func download(using session: URLSession = .shared) async throws -> Data { try await download { url in try await session.data(from: url) @@ -69,19 +71,16 @@ public import Foundation /// Testable download path that takes a fetch closure instead of a session. /// /// - Parameter fetching: Performs the GET for the resolved download URL. - /// - Returns: The response body after a successful checksum comparison. + /// - Returns: The response body. /// - Throws: ``CloudKitError/missingAssetDownloadURL`` when ``downloadURL`` /// is missing or not a valid URL; ``CloudKitError/httpError(statusCode:)`` - /// on a non-success HTTP status; ``CloudKitError/missingAssetChecksum`` - /// when ``fileChecksum`` is absent; ``CloudKitError/assetChecksumMismatch`` - /// when the body does not match; or any error thrown by `fetching`. + /// on a non-success HTTP status; or any error thrown by `fetching`. internal func download( fetching: (URL) async throws -> (Data, URLResponse) ) async throws -> Data { let url = try resolvedDownloadURL() let (data, response) = try await fetching(url) try Self.requireSuccess(response) - try requireMatchingChecksum(data) return data } @@ -94,14 +93,5 @@ public import Foundation } return url } - - private func requireMatchingChecksum(_ data: Data) throws { - guard fileChecksum != nil else { - throw CloudKitError.missingAssetChecksum - } - guard matches(data: data) else { - throw CloudKitError.assetChecksumMismatch - } - } } #endif diff --git a/Sources/MistKit/Models/FieldValues/Asset.swift b/Sources/MistKit/Models/FieldValues/Asset.swift index e88f822b..2dbb536c 100644 --- a/Sources/MistKit/Models/FieldValues/Asset.swift +++ b/Sources/MistKit/Models/FieldValues/Asset.swift @@ -29,7 +29,14 @@ /// Asset dictionary as defined in CloudKit Web Services public struct Asset: Codable, Equatable, Sendable { - /// The file checksum + /// Opaque, server-minted checksum identifying the stored file. + /// + /// Apple documents this only as a signature and specifies no algorithm. It is + /// produced by the CDN upload receipt, not computed by the client, and is not + /// a digest of the plaintext bytes — observed values are a `0x01` version + /// byte plus a 20-byte digest, and it doubles as the content address in + /// ``downloadURL``. Treat it as an identity/caching token; it cannot be + /// recomputed to validate downloaded bytes. Use ``size`` for that. public let fileChecksum: String? /// The file size in bytes public let size: Int64? diff --git a/Tests/MistKitTests/Models/FieldValues/AssetChecksumTests.swift b/Tests/MistKitTests/Models/FieldValues/AssetChecksumTests.swift deleted file mode 100644 index 774a5657..00000000 --- a/Tests/MistKitTests/Models/FieldValues/AssetChecksumTests.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// AssetChecksumTests.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Foundation -internal import Testing - -@testable import MistKit - -@Suite("Asset Checksum") -internal struct AssetChecksumTests { - /// SHA-256 of the UTF-8 bytes of `"hello"`. - private static let plaintext = Data("hello".utf8) - private static let sha256Base64 = "LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=" - private static let sha256Hex = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" - - @Test("matches is true for the base64 SHA-256 of the plaintext") - internal func matchesBase64Checksum() { - let asset = Asset(fileChecksum: Self.sha256Base64) - #expect(asset.matches(data: Self.plaintext)) - } - - @Test("matches is true for the hex SHA-256 of the plaintext, case-insensitively") - internal func matchesHexChecksumCaseInsensitively() { - let lower = Asset(fileChecksum: Self.sha256Hex) - let upper = Asset(fileChecksum: Self.sha256Hex.uppercased()) - #expect(lower.matches(data: Self.plaintext)) - #expect(upper.matches(data: Self.plaintext)) - } - - @Test("matches is false when fileChecksum is missing") - internal func missingChecksumDoesNotMatch() { - let asset = Asset() - #expect(!asset.matches(data: Self.plaintext)) - } - - @Test("matches is false when the digest does not match fileChecksum") - internal func mismatchedChecksumDoesNotMatch() { - let asset = Asset(fileChecksum: Self.sha256Base64) - #expect(!asset.matches(data: Data("world".utf8))) - } -} diff --git a/Tests/MistKitTests/Models/FieldValues/AssetDownloadTests.swift b/Tests/MistKitTests/Models/FieldValues/AssetDownloadTests.swift index 7dbc0e75..1e518a2a 100644 --- a/Tests/MistKitTests/Models/FieldValues/AssetDownloadTests.swift +++ b/Tests/MistKitTests/Models/FieldValues/AssetDownloadTests.swift @@ -40,7 +40,7 @@ @Suite("Asset Download") internal struct AssetDownloadTests { private static let plaintext = Data("hello".utf8) - private static let sha256Base64 = "LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=" + private static let opaqueChecksum = "AUStEc+gPyq1KTFbGO3RbXVpusut" private static let downloadURLString = "https://cvws.icloud-content.com/asset.bin" private static func httpResponse(statusCode: Int, url: URL) throws -> HTTPURLResponse { @@ -57,24 +57,11 @@ return response } - @Test("download returns bytes when HTTP succeeds and the checksum matches") - @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) - internal func downloadReturnsVerifiedBytes() async throws { - let asset = Asset( - fileChecksum: Self.sha256Base64, - downloadURL: Self.downloadURLString - ) - let data = try await asset.download { url in - (Self.plaintext, try Self.httpResponse(statusCode: 200, url: url)) - } - #expect(data == Self.plaintext) - } - @Test("download throws httpError on a non-success status") @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) internal func downloadThrowsOnHTTPFailure() async throws { let asset = Asset( - fileChecksum: Self.sha256Base64, + fileChecksum: Self.opaqueChecksum, downloadURL: Self.downloadURLString ) let error = await #expect(throws: CloudKitError.self) { @@ -89,43 +76,35 @@ #expect(statusCode == 404) } - @Test("download throws assetChecksumMismatch and does not return the body") + @Test("download returns bytes without checking the opaque fileChecksum") @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) - internal func downloadThrowsOnChecksumMismatch() async throws { + internal func downloadReturnsBytesWithoutChecksumCheck() async throws { + // A real server-minted fileChecksum: a version byte plus a 20-byte + // digest, not derivable from the plaintext. Downloads must not gate on it. let asset = Asset( - fileChecksum: Self.sha256Base64, + fileChecksum: "AUStEc+gPyq1KTFbGO3RbXVpusut", downloadURL: Self.downloadURLString ) - let error = await #expect(throws: CloudKitError.self) { - _ = try await asset.download { url in - (Data("world".utf8), try Self.httpResponse(statusCode: 200, url: url)) - } - } - guard case .assetChecksumMismatch = error else { - Issue.record("Expected assetChecksumMismatch, got \(error)") - return + let data = try await asset.download { url in + (Self.plaintext, try Self.httpResponse(statusCode: 200, url: url)) } + #expect(data == Self.plaintext) } - @Test("download throws missingAssetChecksum instead of returning unverified bytes") + @Test("download returns bytes when the asset has no checksum") @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) - internal func downloadThrowsWhenChecksumMissing() async throws { + internal func downloadReturnsBytesWhenChecksumMissing() async throws { let asset = Asset(downloadURL: Self.downloadURLString) - let error = await #expect(throws: CloudKitError.self) { - _ = try await asset.download { url in - (Self.plaintext, try Self.httpResponse(statusCode: 200, url: url)) - } - } - guard case .missingAssetChecksum = error else { - Issue.record("Expected missingAssetChecksum, got \(error)") - return + let data = try await asset.download { url in + (Self.plaintext, try Self.httpResponse(statusCode: 200, url: url)) } + #expect(data == Self.plaintext) } @Test("download throws missingAssetDownloadURL when the URL is absent") @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) internal func downloadThrowsWhenURLMissing() async throws { - let asset = Asset(fileChecksum: Self.sha256Base64) + let asset = Asset(fileChecksum: Self.opaqueChecksum) let error = await #expect(throws: CloudKitError.self) { _ = try await asset.download { _ in Issue.record("fetch should not run when downloadURL is missing") @@ -142,7 +121,7 @@ @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) internal func downloadThrowsWhenURLInvalid() async throws { let asset = Asset( - fileChecksum: Self.sha256Base64, + fileChecksum: Self.opaqueChecksum, downloadURL: "not a url" ) let error = await #expect(throws: CloudKitError.self) { From 7f04e05b7f6a5f4091d870c3d4e718ad097519ac Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 7 Sep 2026 12:33:38 +0100 Subject: [PATCH 24/25] Document MistDemo live oracle, RetryPolicy ban, and TIMESTAMP field types. Replace nonexistent DATE/DATE_LIST names in QUICK_REFERENCE, record standing agent rules, and align users/caller OpenAPI prose with MistDemo verification. Co-authored-by: Cursor --- .claude/agent-notes.md | 2 ++ .claude/docs/QUICK_REFERENCE.md | 8 ++++--- .claude/memory/MEMORY.md | 2 ++ ...ct_mistdemo_is_live_verification_oracle.md | 10 +++++++++ ...oject_retry_policy_deliberately_removed.md | 7 +++++++ Sources/MistKitOpenAPI/Client.swift | 7 ++++--- Sources/MistKitOpenAPI/Types.swift | 21 +++++++++++-------- openapi.yaml | 7 ++++--- 8 files changed, 46 insertions(+), 18 deletions(-) create mode 100644 .claude/memory/project_mistdemo_is_live_verification_oracle.md create mode 100644 .claude/memory/project_retry_policy_deliberately_removed.md diff --git a/.claude/agent-notes.md b/.claude/agent-notes.md index 43e09a16..0d00f9e7 100644 --- a/.claude/agent-notes.md +++ b/.claude/agent-notes.md @@ -33,3 +33,5 @@ Standing always/never directives and corrections from the human. Agents must rea - Lint findings for agents: use `LINT_REPORT=1 ./Scripts/lint.sh` (human summary on stderr + JSON between `### MISTKIT_LINT_REPORT_* ###` markers on stdout) or `LINT_REPORT=json ./Scripts/lint.sh` (JSON only on stdout). Report mode is read-only and walks swift-format, SwiftLint, `swift build`, and Periphery; all four lint tools run with `--strict` so any warning or error fails the pipeline; success requires exit 0 and `summary.totalFindings == 0`. See `.claude/skills/fix-lint/SKILL.md`. - Prefer force unwraps (`!`) over verbose `guard let … else { preconditionFailure }` for compile-time-known-safe values (constant URL literals, test fixtures); suppress lint with `swift-format-ignore: NeverForceUnwrap` plus a `swiftlint:disable force_unwrapping` / `swiftlint:enable force_unwrapping` block when a doc comment sits between the disable and the unwrap (`:next` only when the unwrap is the immediate next line). - NEVER use `Task.sleep(for:)` / `Duration` clocks in MistKitTests — package deployment target is iOS 14 (and peers); use `Task.sleep(nanoseconds:)` instead (CI iOS simulator build failed on CourierTests for this). +- NEVER reintroduce `RetryPolicy` / client retry-with-jitter in MistKit — it was deliberately removed (#148 lineage); rate-limit honoring is fine, retries are not. +- ALWAYS treat MistDemo (web UI + `test-public` / `test-private` integration phases) as the live-verification oracle for CloudKit wire behavior: endpoints and facts those flows exercise count as live-confirmed — do NOT re-flag them as unproven / needs-a-live-container. diff --git a/.claude/docs/QUICK_REFERENCE.md b/.claude/docs/QUICK_REFERENCE.md index 248d46f6..ec0aac8a 100644 --- a/.claude/docs/QUICK_REFERENCE.md +++ b/.claude/docs/QUICK_REFERENCE.md @@ -103,17 +103,19 @@ Body: Included in signature | `INT64` | Integer | `{"value": 42}` | | `DOUBLE` | Floating point | `{"value": 3.14}` | | `BYTES` | Binary data | `{"value": "base64..."}` | -| `DATE` | Timestamp | `{"value": 1234567890000}` | +| `TIMESTAMP` | Timestamp (ms since epoch) | `{"value": 1234567890000}` | | `LOCATION` | Coordinates | `{"value": {"latitude": 37.7, "longitude": -122.4}}` | | `REFERENCE` | Record ref | `{"value": {"recordName": "id", "action": "NONE"}}` | | `ASSET` | File reference | `{"value": {"fileChecksum": "...", "size": 1024, "downloadURL": "..."}}` | | `STRING_LIST` | Array of strings | `{"value": ["a", "b"]}` | | `INT64_LIST` | Array of ints | `{"value": [1, 2, 3]}` | | `DOUBLE_LIST` | Array of doubles | `{"value": [1.1, 2.2]}` | -| `DATE_LIST` | Array of dates | `{"value": [123, 456]}` | +| `TIMESTAMP_LIST` | Array of timestamps | `{"value": [123, 456]}` | | `LOCATION_LIST` | Array of locations | `{"value": [{"latitude": ...}, ...]}` | | `REFERENCE_LIST` | Array of refs | `{"value": [{"recordName": "id1"}, ...]}` | +Ambiguous scalars on writes (`TIMESTAMP`, `BYTES`, `DOUBLE`) must carry an explicit `type` tag — otherwise CloudKit infers the wrong type and rejects with `BAD_REQUEST` (issue #375). + --- ## Query Filters (cloudkitjs.md adapted) @@ -229,7 +231,7 @@ struct FeatureXTests { | `INT64` | `Int` | | `DOUBLE` | `Double` | | `BYTES` | `Data` | -| `DATE` | `Date` (milliseconds since epoch) | +| `TIMESTAMP` | `Date` (milliseconds since epoch) | | `LOCATION` | `CLLocationCoordinate2D` or custom struct | | `REFERENCE` | Custom `CKReference` struct | | `ASSET` | Custom `CKAsset` struct with URL | diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 0527ad01..11839bda 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -54,3 +54,5 @@ Project-scoped agent memory for MistKit. This directory **replaces** any native - [MistKitConfiguration integration branch](project_mkc_integration_branch.md) — subrepo tracks `mistkit-beta.5`; it is beta.5-line scaffolding and must be DELETED in the `→ main` release PR - [Draft-gated CI needs ready_for_review](reference_draft_gated_ci_needs_ready_for_review.md) — an `if: draft == false` job stays `skipped` forever unless `types:` lists `ready_for_review`; re-running replays the stale payload - [FieldValue.bytes is domain Data](project_fieldvalue_bytes_is_base64_string.md) — wire/generated BytesValue stays base64 String; do not infer .bytes from untagged strings +- [RetryPolicy deliberately removed](project_retry_policy_deliberately_removed.md) — do not reintroduce client retry-with-jitter (#148 lineage); rate-limit honoring is fine +- [MistDemo is the live-verification oracle](project_mistdemo_is_live_verification_oracle.md) — web UI + test-public/test-private count as live-confirmed; do not re-flag covered CloudKit wire facts as unproven diff --git a/.claude/memory/project_mistdemo_is_live_verification_oracle.md b/.claude/memory/project_mistdemo_is_live_verification_oracle.md new file mode 100644 index 00000000..a249841f --- /dev/null +++ b/.claude/memory/project_mistdemo_is_live_verification_oracle.md @@ -0,0 +1,10 @@ +# MistDemo is the live-verification oracle + +CloudKit wire facts exercised through MistDemo count as live-confirmed: + +- MistDemo **web interface** (browser against a real container) +- `mistdemo test-public` / `mistdemo test-private` integration phases + +Do **not** re-flag covered behaviors as "unproven", "needs a live container", or "one-shot archaeology only" when MistDemo already runs them. That includes (non-exhaustive): `users/caller`, `records/resolve` / `records/accept`, `createShare`, `assets/rereference` (and ASSETID tagging on that path), `ownerRecordName` / zone payload metadata, shared-zone round-trips. + +Prefer MistDemo phase coverage over archaeology notes that still hedge those facts when they conflict. diff --git a/.claude/memory/project_retry_policy_deliberately_removed.md b/.claude/memory/project_retry_policy_deliberately_removed.md new file mode 100644 index 00000000..14d342e3 --- /dev/null +++ b/.claude/memory/project_retry_policy_deliberately_removed.md @@ -0,0 +1,7 @@ +# RetryPolicy deliberately removed + +MistKit previously had a client-side `RetryPolicy` (retry-with-jitter). It was **deliberately removed** along the #148 lineage — do not reintroduce it. + +Rate-limit honoring (backing off when CloudKit signals limits) is fine. Automatic client retries / jittered retry policies are not. + +Zero `RetryPolicy` references remain in `Sources/MistKit` by design. A future agent could easily re-add retries as "hardening"; treat that as a regression unless the human explicitly reverses this decision. diff --git a/Sources/MistKitOpenAPI/Client.swift b/Sources/MistKitOpenAPI/Client.swift index 9037e6b8..f3fe41f3 100644 --- a/Sources/MistKitOpenAPI/Client.swift +++ b/Sources/MistKitOpenAPI/Client.swift @@ -3647,9 +3647,10 @@ public struct Client: APIProtocol { /// Get the Caller (Current User) /// /// Fetch the authenticated caller's user information. This replaces the deprecated - /// `users/current` endpoint. Requires public database with a web-auth token - /// (user-context auth); server-to-server credentials and the private database - /// will be rejected with `BAD_REQUEST: endpoint not applicable in the database type`. + /// `users/current` endpoint. MistKit routes `fetchCaller` against the public + /// database with web-auth; MistDemo's FetchCallerPhase and share phases exercise + /// it successfully against a live container. Server-to-server credentials are + /// rejected. MistKit does not exercise a private-database path for this endpoint. /// /// /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/caller`. diff --git a/Sources/MistKitOpenAPI/Types.swift b/Sources/MistKitOpenAPI/Types.swift index de76f390..34bf7dee 100644 --- a/Sources/MistKitOpenAPI/Types.swift +++ b/Sources/MistKitOpenAPI/Types.swift @@ -145,9 +145,10 @@ public protocol APIProtocol: Sendable { /// Get the Caller (Current User) /// /// Fetch the authenticated caller's user information. This replaces the deprecated - /// `users/current` endpoint. Requires public database with a web-auth token - /// (user-context auth); server-to-server credentials and the private database - /// will be rejected with `BAD_REQUEST: endpoint not applicable in the database type`. + /// `users/current` endpoint. MistKit routes `fetchCaller` against the public + /// database with web-auth; MistDemo's FetchCallerPhase and share phases exercise + /// it successfully against a live container. Server-to-server credentials are + /// rejected. MistKit does not exercise a private-database path for this endpoint. /// /// /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/caller`. @@ -532,9 +533,10 @@ extension APIProtocol { /// Get the Caller (Current User) /// /// Fetch the authenticated caller's user information. This replaces the deprecated - /// `users/current` endpoint. Requires public database with a web-auth token - /// (user-context auth); server-to-server credentials and the private database - /// will be rejected with `BAD_REQUEST: endpoint not applicable in the database type`. + /// `users/current` endpoint. MistKit routes `fetchCaller` against the public + /// database with web-auth; MistDemo's FetchCallerPhase and share phases exercise + /// it successfully against a live container. Server-to-server credentials are + /// rejected. MistKit does not exercise a private-database path for this endpoint. /// /// /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/caller`. @@ -11086,9 +11088,10 @@ public enum Operations { /// Get the Caller (Current User) /// /// Fetch the authenticated caller's user information. This replaces the deprecated - /// `users/current` endpoint. Requires public database with a web-auth token - /// (user-context auth); server-to-server credentials and the private database - /// will be rejected with `BAD_REQUEST: endpoint not applicable in the database type`. + /// `users/current` endpoint. MistKit routes `fetchCaller` against the public + /// database with web-auth; MistDemo's FetchCallerPhase and share phases exercise + /// it successfully against a live container. Server-to-server credentials are + /// rejected. MistKit does not exercise a private-database path for this endpoint. /// /// /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/caller`. diff --git a/openapi.yaml b/openapi.yaml index 9fc621d5..ac0a9ba1 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -858,9 +858,10 @@ paths: summary: Get the Caller (Current User) description: | Fetch the authenticated caller's user information. This replaces the deprecated - `users/current` endpoint. Requires public database with a web-auth token - (user-context auth); server-to-server credentials and the private database - will be rejected with `BAD_REQUEST: endpoint not applicable in the database type`. + `users/current` endpoint. MistKit routes `fetchCaller` against the public + database with web-auth; MistDemo's FetchCallerPhase and share phases exercise + it successfully against a live container. Server-to-server credentials are + rejected. MistKit does not exercise a private-database path for this endpoint. operationId: getCaller tags: - Users From bf5a96ac4de092c7e6e24f23e7cb8aed4a0332c1 Mon Sep 17 00:00:00 2001 From: leogdion Date: Wed, 9 Sep 2026 04:55:36 +0100 Subject: [PATCH 25/25] Model zone payload fields confirmed live in issue #444. (#478) --- .claude/agent-notes.md | 5 + .claude/docs/QUICK_REFERENCE.md | 11 +- .claude/memory/MEMORY.md | 1 + .claude/memory/project_docs_live_in_docc.md | 18 + ...ct_mistdemo_is_live_verification_oracle.md | 4 +- .../reference_cloudkit_archived_endpoints.md | 16 +- AGENTS.md | 27 +- .../MistDemoKit/Commands/ResolveCommand.swift | 2 +- .../Keys/MistDemoKeysTests.swift | 3 +- README.md | 142 +++- ReleaseNotes.md | 13 +- .../CloudKitService+DatabaseChanges.swift | 3 +- .../CloudKitService+Operations.swift | 2 +- .../CloudKitService+RecordZoneChanges.swift | 2 +- .../CloudKitService+ShareOperations.swift | 2 +- ...oudKitService+SubscriptionOperations.swift | 4 +- .../AbstractionLayerArchitecture.md | 22 +- .../AuthenticationAndDatabases.md | 117 ++- .../CloudKitAsYourBackend.md | 790 ++++++++++++++++++ .../CloudKitLimitsAndPerformance.md | 15 +- .../Documentation.docc/ConfiguringMistKit.md | 21 +- .../Documentation.docc/DeployingMistKit.md | 340 ++++++++ .../Documentation.docc/Documentation.md | 21 +- .../FieldTypePolymorphism.md | 240 ++++++ .../GeneratedCodeAnalysis.md | 126 +-- .../GeneratedCodeWorkflow.md | 41 +- .../Documentation.docc/HandlingErrors.md | 84 +- .../OpenAPICodeGeneration.md | 53 +- .../Documentation.docc/RequestSigning.md | 355 ++++++++ .../talk-cloudkit-console-records.png | Bin 0 -> 362787 bytes .../Resources/talk-github-secrets.png | Bin 0 -> 117620 bytes .../Resources/talk-icloud-sign-in.png | Bin 0 -> 189606 bytes .../Resources/talk-mistdemo-macos.png | Bin 0 -> 200642 bytes .../Resources/talk-mistdemo-web.jpg | Bin 0 -> 127740 bytes .../Resources/talk-server-to-server-key.png | Bin 0 -> 191616 bytes .../WhatCloudKitGotWrong.md | 153 ++++ .../Documentation.docc/WhatTheAIGotWrong.md | 230 +++++ .../Documentation.docc/WorkingWithRecords.md | 25 +- Sources/MistKit/Models/BatchSyncResult.swift | 3 +- .../Models/CloudKitServerErrorCode.swift | 4 +- Sources/MistKit/Models/Queries/Query.swift | 9 +- Sources/MistKit/Models/RecordInfo.swift | 4 +- .../Models/RecordOperation+EncodedSize.swift | 3 +- Sources/MistKit/Models/RecordOperation.swift | 6 +- .../Models/RecordOperationFailure.swift | 2 +- .../MistKit/Models/Sharing/CreatedShare.swift | 5 +- .../Models/Sharing/ShareTargetReference.swift | 2 +- .../Models/SubscriptionOperationFailure.swift | 4 +- .../Models/Tokens/APNsEnvironment.swift | 2 +- .../Models/Tokens/APNsTokenResult.swift | 2 +- .../MistKit/Models/ZoneOperationFailure.swift | 6 +- .../Models/Zones/DatabaseChangesResult.swift | 5 +- .../Components.Schemas.ListValuePayload.swift | 68 +- Sources/MistKitOpenAPI/Client.swift | 2 +- Sources/MistKitOpenAPI/Types.swift | 30 +- .../FieldValueConversionTests+Lists.swift | 56 ++ docs/README.md | 4 - docs/cloudkit-guide/.social/README.md | 199 ----- docs/cloudkit-guide/README.md | 460 ---------- docs/cloudkit-guide/articles/README.md | 11 - ...uthenticating-cloudkit-backend-services.md | 367 -------- .../articles/deploying-mistkit-server-side.md | 445 ---------- .../rebuilding-mistkit-claude-code-part-1.md | 535 ------------ .../rebuilding-mistkit-claude-code-part-2.md | 195 ----- docs/internals/authentication-middleware.md | 361 -------- docs/internals/error-code-parsing.md | 293 ------- docs/internals/field-type-polymorphism.md | 199 ----- docs/talk-feedback.md | 63 -- docs/why-mistkit.md | 60 -- openapi.yaml | 18 +- 70 files changed, 2839 insertions(+), 3472 deletions(-) create mode 100644 .claude/memory/project_docs_live_in_docc.md create mode 100644 Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md create mode 100644 Sources/MistKit/Documentation.docc/DeployingMistKit.md create mode 100644 Sources/MistKit/Documentation.docc/FieldTypePolymorphism.md create mode 100644 Sources/MistKit/Documentation.docc/RequestSigning.md create mode 100644 Sources/MistKit/Documentation.docc/Resources/talk-cloudkit-console-records.png create mode 100644 Sources/MistKit/Documentation.docc/Resources/talk-github-secrets.png create mode 100644 Sources/MistKit/Documentation.docc/Resources/talk-icloud-sign-in.png create mode 100644 Sources/MistKit/Documentation.docc/Resources/talk-mistdemo-macos.png create mode 100644 Sources/MistKit/Documentation.docc/Resources/talk-mistdemo-web.jpg create mode 100644 Sources/MistKit/Documentation.docc/Resources/talk-server-to-server-key.png create mode 100644 Sources/MistKit/Documentation.docc/WhatCloudKitGotWrong.md create mode 100644 Sources/MistKit/Documentation.docc/WhatTheAIGotWrong.md delete mode 100644 docs/README.md delete mode 100644 docs/cloudkit-guide/.social/README.md delete mode 100644 docs/cloudkit-guide/README.md delete mode 100644 docs/cloudkit-guide/articles/README.md delete mode 100644 docs/cloudkit-guide/articles/authenticating-cloudkit-backend-services.md delete mode 100644 docs/cloudkit-guide/articles/deploying-mistkit-server-side.md delete mode 100644 docs/cloudkit-guide/articles/rebuilding-mistkit-claude-code-part-1.md delete mode 100644 docs/cloudkit-guide/articles/rebuilding-mistkit-claude-code-part-2.md delete mode 100644 docs/internals/authentication-middleware.md delete mode 100644 docs/internals/error-code-parsing.md delete mode 100644 docs/internals/field-type-polymorphism.md delete mode 100644 docs/talk-feedback.md delete mode 100644 docs/why-mistkit.md diff --git a/.claude/agent-notes.md b/.claude/agent-notes.md index 0d00f9e7..ed9785b4 100644 --- a/.claude/agent-notes.md +++ b/.claude/agent-notes.md @@ -35,3 +35,8 @@ Standing always/never directives and corrections from the human. Agents must rea - NEVER use `Task.sleep(for:)` / `Duration` clocks in MistKitTests — package deployment target is iOS 14 (and peers); use `Task.sleep(nanoseconds:)` instead (CI iOS simulator build failed on CourierTests for this). - NEVER reintroduce `RetryPolicy` / client retry-with-jitter in MistKit — it was deliberately removed (#148 lineage); rate-limit honoring is fine, retries are not. - ALWAYS treat MistDemo (web UI + `test-public` / `test-private` integration phases) as the live-verification oracle for CloudKit wire behavior: endpoints and facts those flows exercise count as live-confirmed — do NOT re-flag them as unproven / needs-a-live-container. +- Before writing any synthesis/analysis document, READ the existing `.claude/memory/` files and the `WhatCloudKitGotWrong` / `WhatTheAIGotWrong` DocC articles first — they are the prior art and the new work must cross-reference (corroborate/sharpen/contradict) them rather than restate or duplicate them. +- MistDemo is the live-verification oracle for CloudKit wire behavior: anything exercised via the MistDemo web interface or `test-public` / `test-private` integration phases counts as live-confirmed — do NOT re-flag those as "unproven" / "needs a live container" (including users/caller, shares resolve/accept, assets/rereference/ASSETID, ownerRecordName, zone payloads). Prefer MistDemo over lagging `openapi.yaml` hedges; the `WhatCloudKitGotWrong` / `WhatTheAIGotWrong` DocC articles' "Still open"/Limitations were refreshed against MistDemo on the talk branch. +- The 2026-09-08 removal of the top-level `docs/` tree (folded into README + the DocC catalog) needed NO archive tag — Leo's call; the squash-merge preservation rule means *ask*, not *always tag*. +- Long-form guides, the talk write-up, and retrospectives live in `Sources/MistKit/Documentation.docc/` (published on Swift Package Index) — do NOT recreate a top-level `docs/` directory for prose; every DocC code sample must be copied from current source, not from older docs. +- `.claude/memory/_raw/` (uncurated archaeology reports + `recovered/`) was removed 2026-09-09 at Leo's request — do NOT recreate an uncurated holding pen under `.claude/memory/`; findings go straight into named memory files or GitHub issues. diff --git a/.claude/docs/QUICK_REFERENCE.md b/.claude/docs/QUICK_REFERENCE.md index ec0aac8a..a58866b0 100644 --- a/.claude/docs/QUICK_REFERENCE.md +++ b/.claude/docs/QUICK_REFERENCE.md @@ -9,13 +9,12 @@ https://api.apple-cloudkit.com/database/{version}/{container}/{environment}/{dat ### Authentication -**API Token (User-based)** +**API Token / Web Auth (user-based)** — query parameters, **not** headers: ``` -Headers: - X-Apple-CloudKit-Request-KeyID: [api-token] - X-Apple-CloudKit-Request-ISO8601Date: [timestamp] - X-Apple-CloudKit-Request-SignatureV1: [signature] +?ckAPIToken=[api-token]&ckWebAuthToken=[web-auth-token] ``` +`ckWebAuthToken` must be percent-encoded (`+`→`%2B`, `/`→`%2F`, `=`→`%3D`). +Required for private/shared databases and for every `/users/*` route. **Server-to-Server Key** ``` @@ -36,7 +35,7 @@ Body: Included in signature | Record Changes | `/records/changes` | POST | | List Zones | `/zones/list` | POST | | Modify Zones | `/zones/modify` | POST | -| Current User | `/users/current` | GET | +| Current User | `/users/caller` | GET | *(`users/current` is deprecated)* | Upload Asset | `/assets/upload` | POST | ### Request Format (POST endpoints) diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 11839bda..ca330e38 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -56,3 +56,4 @@ Project-scoped agent memory for MistKit. This directory **replaces** any native - [FieldValue.bytes is domain Data](project_fieldvalue_bytes_is_base64_string.md) — wire/generated BytesValue stays base64 String; do not infer .bytes from untagged strings - [RetryPolicy deliberately removed](project_retry_policy_deliberately_removed.md) — do not reintroduce client retry-with-jitter (#148 lineage); rate-limit honoring is fine - [MistDemo is the live-verification oracle](project_mistdemo_is_live_verification_oracle.md) — web UI + test-public/test-private count as live-confirmed; do not re-flag covered CloudKit wire facts as unproven +- [Docs live in the DocC catalog](project_docs_live_in_docc.md) — top-level `docs/` removed 2026-09-08; prose guides, the talk article, and retrospectives are DocC articles; verify with the symbol-graph + `docc convert` recipe diff --git a/.claude/memory/project_docs_live_in_docc.md b/.claude/memory/project_docs_live_in_docc.md new file mode 100644 index 00000000..a2bb5cc8 --- /dev/null +++ b/.claude/memory/project_docs_live_in_docc.md @@ -0,0 +1,18 @@ +--- +name: docs-live-in-docc +description: "Long-form guides, the iOSDevUK talk write-up, and the retrospectives live in the DocC catalog; the top-level docs/ tree was removed on 2026-09-08" +metadata: + node_type: memory + type: project +--- + +The top-level `docs/` directory (talk transcripts, links page, internals write-ups, retrospectives, blog drafts) was removed on 2026-09-08 on the `447-iosdevuk-talk-docc` branch. Its content was folded into: + +- `Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md` — the talk, links, and Q&A (deck screenshots in `Resources/talk-*`). +- `RequestSigning.md`, `FieldTypePolymorphism.md`, `DeployingMistKit.md`, `WhatCloudKitGotWrong.md`, `WhatTheAIGotWrong.md` — new DocC articles. +- `AuthenticationAndDatabases.md` (console setup, sign-in flows, key rotation) and `HandlingErrors.md` ("Under the hood" section) — merged. +- `README.md` — "Why Server-Side CloudKit?", "Guides", and the talk link list. + +The last commit that still contains `docs/` is `c7b0f3d`. No archive tag was made (Leo's decision). + +**How to apply:** put new prose documentation in the DocC catalog (published on Swift Package Index via `.spi.yml`), not in a `docs/` directory. Copy code samples from current source. Verify with the symbol-graph + `docc convert` recipe: `swift build --target MistKit -Xswiftc -emit-symbol-graph -Xswiftc -emit-symbol-graph-dir -Xswiftc .build/symbol-graphs`, copy `MistKit*.symbols.json` to a scratch dir, then `docc convert Sources/MistKit/Documentation.docc --fallback-display-name MistKit --fallback-bundle-identifier com.brightdigit.MistKit --additional-symbol-graph-dir --output-path ` and grep for `warning:`. The transport-accepting `CloudKitService` initializers are `internal`; docs must not claim consumers can inject a `ClientTransport`. diff --git a/.claude/memory/project_mistdemo_is_live_verification_oracle.md b/.claude/memory/project_mistdemo_is_live_verification_oracle.md index a249841f..f382fa6a 100644 --- a/.claude/memory/project_mistdemo_is_live_verification_oracle.md +++ b/.claude/memory/project_mistdemo_is_live_verification_oracle.md @@ -7,4 +7,6 @@ CloudKit wire facts exercised through MistDemo count as live-confirmed: Do **not** re-flag covered behaviors as "unproven", "needs a live container", or "one-shot archaeology only" when MistDemo already runs them. That includes (non-exhaustive): `users/caller`, `records/resolve` / `records/accept`, `createShare`, `assets/rereference` (and ASSETID tagging on that path), `ownerRecordName` / zone payload metadata, shared-zone round-trips. -Prefer MistDemo phase coverage over archaeology notes that still hedge those facts when they conflict. +Prose that still hedges those facts (some `openapi.yaml` descriptions) is stale relative to MistDemo coverage — prefer MistDemo phases when they conflict. + +On the `447-iosdevuk-talk` branch, `the `WhatCloudKitGotWrong` DocC article` ("Still unresolved" + §1 rotation gap) and `the `WhatTheAIGotWrong` DocC article` (Limitations §10) were refreshed against this MistDemo coverage. diff --git a/.claude/memory/reference_cloudkit_archived_endpoints.md b/.claude/memory/reference_cloudkit_archived_endpoints.md index f88aee88..80705f19 100644 --- a/.claude/memory/reference_cloudkit_archived_endpoints.md +++ b/.claude/memory/reference_cloudkit_archived_endpoints.md @@ -7,7 +7,21 @@ metadata: originSessionId: 85eb85c8-acee-4c14-9f93-0db663866771 --- -The local `.claude/docs/webservices.md` does NOT cover every CloudKit Web Services endpoint. Notably it omits `assets/rereference`. Do not conclude an endpoint "doesn't exist" from grepping local docs alone. +`.claude/docs/webservices.md` is a **partial scrape, not the reference** — and it is far more +incomplete than its 289 KB suggests. It contains exactly **two** endpoint headings: +`users/current` and `records/modify`. Verified counts (2026-09-01) — every one of these returns +**zero** hits: + +`zones/changes` · `changes/database` · `changes/zone` · `records/changes` · `records/resolve` · +`records/accept` · `assets/rereference` · `tokens/create` · `metaSyncToken` · `syncToken` · +`moreComing` · `continuationMarker` · `"Zone Dictionary"` + +Most of the remaining bulk is `CloudKitQuickStart` / `iCloudDesignGuide` filler, plus at least +four literal `# The page you're looking for can't be found.` blocks where the scrape 404'd. + +**The trap:** grepping this file for almost any endpoint returns zero, and zero reads as proof +of absence. Never conclude an endpoint "doesn't exist" — or that a key is unused — from +grepping local docs alone. Go to Apple's archived reference, and prefer a live call over both. Authoritative source: Apple's archived CloudKit Web Services Reference, e.g. `assets/rereference` at https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/RereferenceAssets.html diff --git a/AGENTS.md b/AGENTS.md index 1b0ccd94..fac28b4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,6 +140,8 @@ MistKit uses separate types for requests and responses at the OpenAPI schema lev Object/array-shaped values (`REFERENCE`, `ASSET`, `LOCATION`, `LIST`) and `STRING`/`INT64` are unambiguous and stay untagged. Tagging happens in the exhaustive `init(from:)` switch (`Components.Schemas.FieldValueRequest.swift`). `type` is *not* required globally because CloudKit documents it as optional. +**Timestamps must be whole milliseconds.** CloudKit rejects a fractional `TIMESTAMP` (e.g. `1747999812347.89`) with `BAD_REQUEST "Invalid value, expected type TIMESTAMP"`, and Swift's `Date` carries sub-millisecond precision — so `.date` values are `.rounded()` on the way out, not just tagged. **The same constraint applies to `LocationValue.timestamp`** (`Components.Schemas.FieldValueRequest.swift:89`), which is a second, nested millisecond field with no `type` tag of its own to disambiguate it; that instance was found only via a live CelestraCloud integration failure (PR #377, commit `1eb639a`). Sub-millisecond precision is therefore destroyed on every write: a `Date` in is a different `Date` out. + **Response type recovery (issue #375):** The generated `value` `oneOf` is *undiscriminated* — the decoder tries cases first-match-wins (`String → Int64 → Double → Bytes → Date`), so a whole-millisecond `TIMESTAMP` decodes as `Int64Value` and a base64 `BYTES` string decodes as `StringValue`. The response conversion therefore honors an explicit `type` *over* the decoded case (`makeTypedScalar` in `FieldValue+Components+Scalar.swift`). For the genuinely-ambiguous scalars whose correct interpretation differs from inference it produces the typed value directly: `TIMESTAMP`/`DOUBLE` from any numeric case, `BYTES` from any string case (decoded with `Data(base64Encoded:)`; malformed tagged base64 throws `ConversionError.typeValueMismatch` with the unwrapped string). `INT64`/`STRING` validate the category then defer to inference (which already yields them, and for `INT64` avoids truncating a fractional number). When `type` is absent it falls back to first-match-wins inference (`makeInferredScalar`), which is lossy for the ambiguous scalars (BYTES→`.string`, whole-number TIMESTAMP→`.int64`). Do **not** infer `.bytes` from untagged base64 — ordinary strings such as `"Chen"` decode as valid base64. Domain `.bytes` is `Data`; `bytesValue` re-encodes to base64 and `dataValue` matches `.bytes` only (no `.string` fallback). The generated `BytesValue` in `Sources/MistKitOpenAPI/` stays `String`. When a scalar `type` *contradicts* the value's category — a numeric type (`TIMESTAMP`/`DOUBLE`/`INT64`) over a non-number, or a string type (`STRING`/`BYTES`) over a non-string — the response is internally inconsistent and the conversion **throws** `ConversionError.typeValueMismatch` (via `requireNumeric`/`requireString`) rather than coercing to the value's shape. This matches the codebase's existing fail-loud `unmappableFieldValue` philosophy. @@ -376,7 +378,7 @@ A `ClientTransport` extension could provide a generic upload method, but would n - `Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder+StringFilters.swift` — string-specific: `beginsWith`, `notBeginsWith`, `containsAllTokens` - `Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder+ListMemberFilters.swift` — list-specific: `listContains`, etc. -**IN/NOT_IN serialization:** Uses `ListValuePayload` (`Components.Schemas.ListValuePayload`) to wrap array values. The fix in v1.0.0-alpha.5 ensures the correct `value` key structure is used when serializing list comparators. +**IN/NOT_IN serialization:** Uses `ListValuePayload` (`Components.Schemas.ListValuePayload`) to wrap array values, and tags the list's element type explicitly via `cloudKitListType(for:)` (`.STRING_LIST`, `.INT64_LIST`, …). The v1.0.0-alpha.5 fix (issue #192 / PR #205) **added that `type` tag** — without it CloudKit cannot determine the element type and rejects every `.in()` query with `HTTP 400 BadRequestException: Unexpected input`, at any array size. `ListValuePayload` was already in use and was never the problem; the `value` key structure is unchanged. The element type is derived from the *first* element, so a heterogeneous list is tagged by element zero, and an empty list is sent untagged. ### CloudKit Web Services Integration - Base URL: `https://api.apple-cloudkit.com` @@ -461,13 +463,24 @@ The `openapi.yaml` file serves as the source of truth for: - Authentication requirements - Error response formats -Key endpoints documented in the OpenAPI spec: -- Records: `/records/query`, `/records/modify`, `/records/lookup`, `/records/changes` -- Zones: `/zones/list`, `/zones/lookup`, `/zones/modify`, `/zones/changes` +Key endpoints documented in the OpenAPI spec. All are under +`/database/{version}/{container}/{environment}/{database}/` **except the token endpoints** — +see the `/device/` note below. +- Records: `/records/query`, `/records/modify`, `/records/lookup`, `/records/changes`, + `/records/resolve`, `/records/accept` +- Zones: `/zones/list`, `/zones/lookup`, `/zones/modify`, `/zones/changes` *(deprecated by + Apple in favor of `/changes/database`)* +- Changes: `/changes/database`, `/changes/zone` - Subscriptions: `/subscriptions/list`, `/subscriptions/lookup`, `/subscriptions/modify` -- Users: `/users/caller`, `/users/discover` (POST + GET), `/users/lookup/email`, `/users/lookup/id` -- Assets: `/assets/upload` -- Tokens: `/tokens/create`, `/tokens/register` +- Users: `/users/caller`, `/users/discover` (POST; GET is `@available(*, unavailable)` — Apple + returns HTTP 500, see #28), `/users/lookup/email`, `/users/lookup/id` + *(`/users/lookup/contacts` and `/users/current` are deprecated and unwrapped)* +- Assets: `/assets/upload`, `/assets/rereference` +- Tokens: **`/device/{version}/{container}/{environment}/tokens/create`** and + `…/tokens/register` — container-scoped, with **no `{database}` segment**. Apple's archived + reference documents these under `/database/…`, but the live service routes only `OPTIONS` + there and returns `405 Method Not Allowed` on POST. The working path is the one CloudKit JS + uses (`setApiModuleName("device")`). See issue #382. ## Reference Documentation diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ResolveCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ResolveCommand.swift index c31e4159..416b857f 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/ResolveCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ResolveCommand.swift @@ -32,7 +32,7 @@ internal import MistKit /// Command for `records/resolve`. Resolves share short GUIDs — the handle /// carried by CloudKit share URLs — into the root record, the governing -/// `cloudKit.share` record, and the caller's participation in each share. +/// `cloudkit.share` record, and the caller's participation in each share. public struct ResolveCommand: MistDemoCommand, OutputFormatting { /// The configuration type. public typealias Config = ResolveConfig diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/Keys/MistDemoKeysTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Keys/MistDemoKeysTests.swift index e77287af..264bae36 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/Keys/MistDemoKeysTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Keys/MistDemoKeysTests.swift @@ -38,7 +38,8 @@ internal import Testing /// deployment docs rely on, and the command-line flags the README documents. @Suite("MistDemoKeys") internal struct MistDemoKeysTests { - /// The eight variables `MistDemo-Integration.yml` and `docs/cloudkit-guide/` set. + /// The eight variables `MistDemo-Integration.yml` sets (see the + /// "Deploying MistKit" article in the DocC catalog for the contract). /// /// `CLOUDKIT_CONTAINER_ID` is the one that changed: the old `container.identifier` /// base resolved to `CLOUDKIT_CONTAINER_IDENTIFIER`, so the value CI supplied was diff --git a/README.md b/README.md index 99741419..cbe98b92 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A Swift Package for Server-Side and Command-Line Access to [CloudKit Web Service ## Table of Contents - [Overview](#overview) - [Key Features](#key-features) +- [Why Server-Side CloudKit?](#why-server-side-cloudkit) - [Getting Started](#getting-started) - [Installation](#installation) - [Requirements](#requirements) @@ -28,6 +29,8 @@ A Swift Package for Server-Side and Command-Line Access to [CloudKit Web Service - [Advanced Usage](#advanced-usage) - [Examples](#examples) - [Documentation](#documentation) + - [Guides](#guides) + - [CloudKit as Your Backend (talk)](#cloudkit-as-your-backend-talk) - [License](#license) - [Acknowledgments](#acknowledgments) - [Roadmap](#roadmap) @@ -41,13 +44,24 @@ Built with Swift concurrency (async/await) and designed for modern Swift applica ## Key Features -- **🌍 Cross-Platform Support**: Works on macOS, iOS, tvOS, watchOS, visionOS, and Linux +- **🌍 Cross-Platform Support**: Works on macOS, iOS, tvOS, watchOS, visionOS, Linux, and Windows - **⚡ Modern Swift**: Built with Swift 6 concurrency features and structured error handling - **🔐 Multiple Authentication Methods**: API token, web authentication, and server-to-server authentication - **🛡️ Type-Safe**: Comprehensive type safety with Swift's type system - **📋 OpenAPI-Based**: Generated from CloudKit Web Services [OpenAPI specification](https://www.openapis.org/) using [swift-openapi-generator](https://github.com/apple/swift-openapi-generator) - **🔒 Secure**: Built-in security best practices and credential management +## Why Server-Side CloudKit? + +Apple's CloudKit framework only runs on Apple platforms. MistKit wraps the CloudKit Web Services REST API so server-side Swift, Linux services, and command-line tools can take part in the same containers as your apps. Four patterns cover most uses: + +- **Public database as a managed catalog** — a scheduled job writes data every user wants and the app just queries it. [BushelCloud](https://github.com/brightdigit/BushelCloud) ([`Examples/BushelCloud`](Examples/BushelCloud/)) syncs macOS restore images and Xcode/Swift versions for [Bushel](https://getbushel.app); [CelestraCloud](https://github.com/brightdigit/CelestraCloud) ([`Examples/CelestraCloud`](Examples/CelestraCloud/)) syncs RSS feeds for [Celestra](https://celestr.app). Software-version catalogs, asset packs, feature flags, and MDM configuration fit the same shape. +- **Private database on behalf of a user** — the user signs in once, the server keeps their web auth token, and reads or writes their private database while they are away. [HeartWitch](https://github.com/brightdigit/HeartWitch) links an Apple Watch to a Vapor backend this way; wearable data pipelines, two-way sync with external services, and server-side processing of uploads are the same idea. +- **Web app ↔ Apple device bridge** — a browser portal for a CloudKit-backed app, or a webhook handler that writes straight into a user's records. +- **Data aggregation** — anonymized telemetry read through `records/changes`, or crowdsourced data cleaned up by a background job. + +The talk that walks through all of this is [CloudKit as Your Backend](#cloudkit-as-your-backend-talk) below. + ## Getting Started ### Installation @@ -56,7 +70,7 @@ Add MistKit to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/brightdigit/MistKit.git", from: "1.0.0-beta.2") + .package(url: "https://github.com/brightdigit/MistKit.git", from: "1.0.0-beta.5") ] ``` @@ -429,12 +443,124 @@ try await adaptiveManager.upgradeToWebAuthentication(webAuthToken: webToken) Check out the `Examples/` directory for complete working examples: - **[MistDemo](Examples/MistDemo/)**: Web-based CloudKit authentication demo with automatic token capture -- **[BushelCloud](Examples/BushelCloud/)**: Server-to-Server auth demo syncing macOS restore images, Xcode, and Swift versions — backend for the [Bushel app](https://getbushel.app) -- **[CelestraCloud](Examples/CelestraCloud/)**: RSS reader demonstrating CloudKit query filtering, sorting, and web etiquette patterns — backend for the [Celestra app](https://celestr.app), built with [SyndiKit](https://github.com/brightdigit/SyndiKit) +- **[BushelCloud](Examples/BushelCloud/)** ([standalone repo](https://github.com/brightdigit/BushelCloud)): Server-to-Server auth demo syncing macOS restore images, Xcode, and Swift versions from a scheduled GitHub Actions job — backend for the [Bushel app](https://getbushel.app) +- **[CelestraCloud](Examples/CelestraCloud/)** ([standalone repo](https://github.com/brightdigit/CelestraCloud)): RSS reader demonstrating CloudKit query filtering, sorting, and web etiquette patterns — backend for the [Celestra app](https://celestr.app), built with [SyndiKit](https://github.com/brightdigit/SyndiKit) ## Documentation -- **[API Documentation](https://swiftpackageindex.com/brightdigit/MistKit/~/documentation)**: Complete API reference +- **[API Documentation](https://swiftpackageindex.com/brightdigit/MistKit/~/documentation/mistkit)**: Complete API reference, hosted on Swift Package Index + +### Guides + +The DocC catalog (`Sources/MistKit/Documentation.docc/`) carries the long-form guides. Links below point at the published pages; pages added on this branch appear once Swift Package Index rebuilds the default branch. + +- **[CloudKit as Your Backend](https://swiftpackageindex.com/brightdigit/MistKit/~/documentation/mistkit/cloudkitasyourbackend)**: the conference talk in article form — see [below](#cloudkit-as-your-backend-talk) +- **[Authentication and Databases](https://swiftpackageindex.com/brightdigit/MistKit/~/documentation/mistkit/authenticationanddatabases)**: which credentials reach which database, obtaining tokens and keys from the CloudKit Console +- **[Request Signing](https://swiftpackageindex.com/brightdigit/MistKit/~/documentation/mistkit/requestsigning)**: token managers, authenticators, the middleware, and the ECDSA payload +- **[Working with Records](https://swiftpackageindex.com/brightdigit/MistKit/~/documentation/mistkit/workingwithrecords)**: query, create, update, delete, batch, and sync +- **[Field Type Polymorphism](https://swiftpackageindex.com/brightdigit/MistKit/~/documentation/mistkit/fieldtypepolymorphism)**: how nine CloudKit field types map onto one Swift enum, and the wire-format traps +- **[Handling Errors](https://swiftpackageindex.com/brightdigit/MistKit/~/documentation/mistkit/handlingerrors)**: typed errors at every layer and how CloudKit's JSON becomes a `CloudKitError` +- **[Deploying MistKit](https://swiftpackageindex.com/brightdigit/MistKit/~/documentation/mistkit/deployingmistkit)**: static Linux builds, credentials in CI, scheduling, idempotency, observability +- **[Configuring MistKit](https://swiftpackageindex.com/brightdigit/MistKit/~/documentation/mistkit/configuringmistkit)** and **[CloudKit Limits and Performance](https://swiftpackageindex.com/brightdigit/MistKit/~/documentation/mistkit/cloudkitlimitsandperformance)** +- **[Abstraction Layer Architecture](https://swiftpackageindex.com/brightdigit/MistKit/~/documentation/mistkit/abstractionlayerarchitecture)**, **[OpenAPI Code Generation](https://swiftpackageindex.com/brightdigit/MistKit/~/documentation/mistkit/openapicodegeneration)**, **[Generated Code Workflow](https://swiftpackageindex.com/brightdigit/MistKit/~/documentation/mistkit/generatedcodeworkflow)**, **[Generated Code Analysis](https://swiftpackageindex.com/brightdigit/MistKit/~/documentation/mistkit/generatedcodeanalysis)** +- **[What CloudKit Got Wrong](https://swiftpackageindex.com/brightdigit/MistKit/~/documentation/mistkit/whatcloudkitgotwrong)**: where Apple's documentation and Apple's server disagree +- **[What the AI Got Wrong](https://swiftpackageindex.com/brightdigit/MistKit/~/documentation/mistkit/whattheaigotwrong)**: an evidence-backed catalogue of AI-assisted development failure modes from this project + +Articles on brightdigit.com: [Rebuilding MistKit with Claude Code, part 1](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-1/) and [part 2](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-2/). + +### CloudKit as Your Backend (talk) + +*From iOS to Server-Side Swift* — given in 2026 at Swift Craft, Swift Rockies, and iOSDevUK by Leo Dion ([@leogdion@c.im](https://c.im/@leogdion)). The full article, following the slide order with screenshots and code from this repository, is in the DocC catalog: **[CloudKit as Your Backend](https://swiftpackageindex.com/brightdigit/MistKit/~/documentation/mistkit/cloudkitasyourbackend)** (source: [`CloudKitAsYourBackend.md`](Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md)). + +> CloudKit has excellent documentation for iOS and macOS client development. But backend services — podcast aggregation, RSS readers, data processing — face APIs that Apple barely documents. I rebuilt a comprehensive CloudKit library using AI-generated OpenAPI specifications. The result: type-safe Swift code supporting three authentication methods (server-to-server, web authentication token, and API token), typed error handling, and production deployments. + +Links from the talk: + +#### Leo Dion / BrightDigit + +- [MistKit on GitHub](https://github.com/brightdigit/MistKit) and [MistKit issues](https://github.com/brightdigit/MistKit/issues) — "What's next?" +- [MistDemo](Examples/MistDemo/) — CLI, macOS app and web demo used for integration testing +- [Bushel](https://getbushel.app) — Virtualization for App Developers +- [AtLeast](https://atleast.app) — Passive Timer for Apple Watch +- [Heartwitch](https://heartwitch.app) — Apple Watch heart-rate streaming; [App Store](https://apps.apple.com/us/app/heartwitch/id1480031203) +- [BrightDigit](https://brightdigit.com) +- [linktr.ee/leogdion](https://linktr.ee/leogdion) +- [iOSDevUK](https://www.iosdevuk.com) + +#### Use case examples + +- [BushelCloud](https://github.com/brightdigit/BushelCloud) — server-to-server sync of macOS restore images, Xcode and Swift versions for Bushel; the GitHub Actions deployment shown in the talk (also at [`Examples/BushelCloud`](Examples/BushelCloud/)) + - [`cloudkit-sync-dev.yml`](Examples/BushelCloud/.github/workflows/cloudkit-sync-dev.yml) — scheduled workflow + - [`cloudkit-sync` composite action](Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml) +- [CelestraCloud](https://github.com/brightdigit/CelestraCloud) — RSS feed sync into a public database for Celestra, with query filtering and sorting (also at [`Examples/CelestraCloud`](Examples/CelestraCloud/)) +- [HeartWitch](https://github.com/brightdigit/HeartWitch) — the private-database, Apple Watch to Vapor bridge +- [Rebuilding MistKit with Claude Code, part 1](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-1/) and [part 2](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-2/) + +#### What is CloudKit + +- [WWDC 2014 "Introducing CloudKit" (session 208)](https://nonstrict.eu/wwdcindex/wwdc2014/208/) — Apple no longer hosts the video; this mirror has the video, slides and transcript. Transcript only: [ASCIIwwdc](https://asciiwwdc.com/2014/sessions/208) +- [CloudKit framework documentation](https://developer.apple.com/documentation/cloudkit) +- [Enabling CloudKit in your app](https://developer.apple.com/documentation/cloudkit/enabling-cloudkit-in-your-app) — Xcode capability setup +- [CloudKit Console](https://icloud.developer.apple.com/dashboard/) +- [Integrating a text-based schema into your workflow](https://developer.apple.com/documentation/cloudkit/integrating-a-text-based-schema-into-your-workflow) +- [cktool](https://developer.apple.com/icloud/ck-tool/) and [Automating CloudKit Development](https://developer.apple.com/icloud/cloudkit/automating/) + +#### CloudKit Web Services + +- [CloudKit JS](https://developer.apple.com/documentation/cloudkitjs) +- [CloudKit Web Services Reference](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/index.html) — archived + - [Composing Web Service Requests](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/SettingUpWebServices.html) + - [Accessing CloudKit Using an API Token](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/SettingUpWebServices.html#//apple_ref/doc/uid/TP40015240-CH24-SW2) + - [Accessing CloudKit Using a Server-to-Server Key](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/SettingUpWebServices.html#//apple_ref/doc/uid/TP40015240-CH24-SW6) + - [Document Revision History](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/RevisionHistory.html) — last updated 2016 + - [Uploading Assets](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/UploadAssets.html) + - [Types and Dictionaries](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/Types.html) — field types + - [Error Codes](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ErrorCodes.html) + - [Discovering User Identities (POST users/discover)](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/DiscoveringUserIdentities%28usersdiscover%29.html) + - [Discovering All User Identities (GET users/discover)](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/DiscoveringAllUserIdentities.html) — the endpoint that returns HTTP 500 +- Base URL: `https://api.apple-cloudkit.com/database/{version}/{container}/{environment}/{database}/{operation}` +- [Apple Developer Support](https://developer.apple.com/support/) — contact URL in the OpenAPI document + +#### Authentication + +- [CKFetchWebAuthTokenOperation](https://developer.apple.com/documentation/cloudkit/ckfetchwebauthtokenoperation) +- [Sign in with Apple](https://developer.apple.com/documentation/signinwithapple) — mentioned as not existing when Heartwitch was built +- [swift-crypto](https://github.com/apple/swift-crypto) — `P256.Signing.PrivateKey` used in `RequestSignature` +- MistKit source shown in the talk: [`AuthenticationMiddleware.swift`](Sources/MistKit/Authentication/AuthenticationMiddleware.swift), [`APITokenAuthenticator.swift`](Sources/MistKit/Authentication/APITokenAuthenticator.swift), [`WebAuthTokenAuthenticator.swift`](Sources/MistKit/Authentication/WebAuthTokenAuthenticator.swift), [`ServerToServerAuthenticator.swift`](Sources/MistKit/Authentication/ServerToServerAuthenticator.swift), [`RequestSignature.swift`](Sources/MistKit/Authentication/RequestSignature.swift) + +#### Swift OpenAPI Generator + +- [swift-openapi-generator](https://github.com/apple/swift-openapi-generator) +- [WWDC23 "Meet Swift OpenAPI Generator"](https://developer.apple.com/videos/play/wwdc2023/10171/) +- [Documentation](https://swiftpackageindex.com/apple/swift-openapi-generator/documentation/swift-openapi-generator) and [tutorial: Working with Swift OpenAPI Generator](https://swiftpackageindex.com/apple/swift-openapi-generator/tutorials/swift-openapi-generator) +- [ClientMiddleware](https://swiftpackageindex.com/apple/swift-openapi-runtime/documentation/openapiruntime/clientmiddleware) — the ["Implement a custom client middleware"](https://swiftpackageindex.com/apple/swift-openapi-runtime/documentation/openapiruntime/clientmiddleware#Implement-a-custom-client-middleware) bearer-token example is a section on that page +- [Example projects](https://github.com/apple/swift-openapi-generator/tree/main/Examples) — including auth, logging and retrying middleware examples +- Package ecosystem: [swift-openapi-runtime](https://github.com/apple/swift-openapi-runtime), [swift-openapi-urlsession](https://github.com/apple/swift-openapi-urlsession), [swift-openapi-async-http-client](https://github.com/swift-server/swift-openapi-async-http-client), [swift-okhttp](https://github.com/frameo-net/swift-okhttp), [swift-openapi-vapor](https://github.com/vapor/swift-openapi-vapor), [swift-openapi-hummingbird](https://github.com/hummingbird-project/swift-openapi-hummingbird), [swift-openapi-lambda](https://github.com/awslabs/swift-openapi-lambda) +- [OpenAPI Specification](https://spec.openapis.org/oas/latest.html) +- [MistKit `openapi.yaml`](openapi.yaml) + +#### Field types and error handling + +- [`FieldValue.swift`](Sources/MistKit/Models/FieldValues/FieldValue.swift) +- [MistKit issue #28: discoverAllUserIdentities returns HTTP 500](https://github.com/brightdigit/MistKit/issues/28) + - Broken call, CloudKit Web Services: [Discovering All User Identities (GET users/discover)](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/DiscoveringAllUserIdentities.html) + - Broken call, CloudKit JS: [`CloudKit.Container.discoverAllUserIdentities`](https://developer.apple.com/documentation/cloudkitjs/cloudkit.container/discoveralluseridentities) +- Apple Feedback FB22754466 — public copy on [Open Radar](https://openradar.appspot.com/FB22754466); filed via [Feedback Assistant](https://feedbackassistant.apple.com/) + +#### Deployment + +- [GitHub Actions: scheduled workflows (`schedule` / cron)](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule) +- [GitHub Actions: using secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions) +- [GitHub Actions: creating a composite action](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action) +- [actions/checkout](https://github.com/actions/checkout) +- [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) +- [Swift Docker images](https://hub.docker.com/_/swift) — the sync action builds with `swiftlang/swift:nightly-6.4.x-noble` + +#### Other tools mentioned + +- [Hummingbird](https://hummingbird.codes) — server behind the MistDemo web interface +- [Vapor](https://vapor.codes) — Heartwitch backend +- [OBS Studio](https://obsproject.com) — Heartwitch streaming overlay ### Apple References @@ -560,10 +686,12 @@ MistKit is released under the MIT License. See [LICENSE](LICENSE) for details. - [x] [Zone payloads: `deleted`, `zoneType`, and `ownerRecordName` decoding](https://github.com/brightdigit/MistKit/issues/444) ✅ - [x] [Zone-aware writes (CRUD + assets)](https://github.com/brightdigit/MistKit/issues/454) ✅ - [x] [Consume web auth token rotation (`X-Apple-CloudKit-Web-Auth-Token`)](https://github.com/brightdigit/MistKit/issues/462) ✅ -- [x] [Verify downloaded asset bytes against `Asset.fileChecksum`](https://github.com/brightdigit/MistKit/issues/466) ✅ +- [x] [Add `Asset.download(using:)`](https://github.com/brightdigit/MistKit/issues/466) ✅ — checksum verification turned out to be impossible: `fileChecksum` is an opaque server-minted token ([#473](https://github.com/brightdigit/MistKit/pull/473)) - [x] [Change `FieldValue.bytes` from `String` to `Data`](https://github.com/brightdigit/MistKit/issues/467) ✅ - [x] [Add `VALIDATE` to `Reference.Action`](https://github.com/brightdigit/MistKit/issues/464) ✅ - [x] [MistDemo web UI: `zoneName`/`zoneOwner` inputs on the query panel](https://github.com/brightdigit/MistKit/issues/438) ✅ +- [x] [Create `MistKitConfiguration` package for shared CloudKit config glue](https://github.com/brightdigit/MistKit/issues/407) ✅ +- [x] [Complete iOSDevUK talk material — talk article and guides in DocC, `docs/` removed](https://github.com/brightdigit/MistKit/issues/447) ✅ ### Backlog / Post-beta @@ -589,7 +717,7 @@ MistKit is released under the MIT License. See [LICENSE](LICENSE) for details. - **Issues**: [GitHub Issues](https://github.com/brightdigit/MistKit/issues) - **Discussions**: [GitHub Discussions](https://github.com/brightdigit/MistKit/discussions) -- **Documentation**: [API Reference](https://brightdigit.github.io/MistKit) +- **Documentation**: [API Reference](https://swiftpackageindex.com/brightdigit/MistKit/~/documentation/mistkit) --- diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 562ac15d..916c2454 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,16 +1,19 @@ -## Unreleased +## 1.0.0-beta.5 * Represent `FieldValue.bytes` as `Data` instead of a base64 `String` (#467) -* Verify downloaded asset bytes against `fileChecksum`, and add `Asset.download(using:)` that refuses unverified data (#466) +* Add `Asset.download(using:)`, which fetches an asset's `downloadURL` from the CDN and returns the bytes, plus `CloudKitError.missingAssetDownloadURL`. `fileChecksum` turned out to be a server-minted identity token rather than a digest of the file, so no client-side checksum verification is offered — check `Asset.size` to guard against truncation (#466, #473) * Add `VALIDATE` to `Reference.Action` for CloudKit Web Services reference dictionaries (#464) * Consume rotated web auth tokens from the `X-Apple-CloudKit-Web-Auth-Token` response header via a new `TokenManager.didReceiveRotatedWebAuthToken(_:)` requirement, defaulted to a no-op so existing conformances keep compiling (#462, #463) * Model the zone payload fields confirmed live in #444: `ZoneInfo.zoneType` (a closed `ZoneType` enum that throws `ConversionError.unrecognizedZoneType` on unknown wire values) and `ZoneInfo.deleted`, so change feeds surface tombstones (#444) * Fix `ownerRecordName` never decoding on zone payloads — `ZoneID` read the wire key as `ownerName`, so shared-zone owners were always `nil` (#444) * Add an optional `zoneID:` parameter to `createRecord`, `updateRecord`, `deleteRecord`, and `uploadAssets`, so writes and asset uploads can target custom and shared zones (#454) -* Add `CloudKitError.missingAssetDownloadURL`, `.missingAssetChecksum`, and `.assetChecksumMismatch` for the asset download path (#466) -* Extract the shared CloudKit credential configuration glue into a separate `MistKitConfiguration` package and converge the examples on typed configuration keys (#455) +* Round `[Date]` list elements and nested `Location.timestamp` values to whole milliseconds on the way out, matching the scalar path, and make the list conversion an exhaustive `default`-free switch so a new `FieldValue` case fails the build instead of writing an empty list +* Extract the shared CloudKit credential configuration glue into a separate `MistKitConfiguration` package and converge the examples on typed configuration keys (#407, #455) * MistDemo: expose `zoneName`/`zoneOwner` on the web query panel and add zone-aware writes plus a live shared-zone round-trip phase (#438, #453, #454) -* Add a repeatable release runbook, and address code review fixes, coverage, and lint tooling (#460, #461) +* Documentation: add the "CloudKit as Your Backend" talk as a DocC article with its screenshots, plus new guides for request signing, field type polymorphism, deploying MistKit, and the two retrospectives (What CloudKit Got Wrong, What the AI Got Wrong); re-verify every existing catalog article against current source; link the guides and talk resources from the README; remove the top-level `docs/` directory (#447) +* Add a repeatable release runbook, address code review fixes, coverage, and lint tooling, and fix the MistDemo Integration static build for Swift 6.4 (#460, #461, #475) + +**Full Changelog**: https://github.com/brightdigit/MistKit/compare/1.0.0-beta.4...1.0.0-beta.5 ## 1.0.0-beta.4 diff --git a/Sources/MistKit/CloudKitService/CloudKitService+DatabaseChanges.swift b/Sources/MistKit/CloudKitService/CloudKitService+DatabaseChanges.swift index 61686732..4e096297 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+DatabaseChanges.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+DatabaseChanges.swift @@ -66,7 +66,8 @@ extension CloudKitService { /// // Store result.syncToken for the next fetch. /// ``` /// - /// - Note: Per-zone failures are surfaced as ``ZoneChangeResult/failure(_:)`` + /// - Note: Per-zone failures are surfaced as ``OperationResult/failure(_:)`` + /// on a ``ZoneChangeResult`` /// entries in ``DatabaseChangesResult/zones`` rather than thrown, so one /// bad zone never discards the zones that succeeded. public func fetchDatabaseChanges( diff --git a/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift b/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift index 884fac2b..0fb85da2 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift @@ -45,7 +45,7 @@ extension CloudKitService { /// The unified ``Query`` value carries the `recordType` plus any /// ``QueryFilter`` predicates and ``QuerySort`` descriptors. The same /// value can be embedded in a subscription via - /// ``SubscriptionInfo/Kind/query(_:)``. + /// ``SubscriptionInfo/Kind/query(_:firesOn:firesOnce:)``. /// /// - Parameters: /// - query: The query to execute. diff --git a/Sources/MistKit/CloudKitService/CloudKitService+RecordZoneChanges.swift b/Sources/MistKit/CloudKitService/CloudKitService+RecordZoneChanges.swift index 5fe0e462..2b2bfd90 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+RecordZoneChanges.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+RecordZoneChanges.swift @@ -75,7 +75,7 @@ extension CloudKitService { /// ``` /// /// - Note: Per-zone failures are surfaced as - /// ``ZoneRecordChangesResult/failure(_:)`` entries rather than thrown. + /// ``OperationResult/failure(_:)`` entries of ``ZoneRecordChangesResult`` rather than thrown. public func fetchRecordZoneChanges( zones: [ZoneChangesRequest], reverse: Bool? = nil, diff --git a/Sources/MistKit/CloudKitService/CloudKitService+ShareOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+ShareOperations.swift index 248081a1..c0732d17 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+ShareOperations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+ShareOperations.swift @@ -34,7 +34,7 @@ extension CloudKitService { /// (`records/resolve`). /// /// Given the short GUIDs carried by share URLs, returns the root record, - /// the governing `cloudKit.share` record, the owner's identity, and the + /// the governing `cloudkit.share` record, the owner's identity, and the /// caller's participation in each share. Set /// ``ShortGUIDDictionary/shouldFetchRootRecord`` to have CloudKit include the root /// record itself, optionally narrowed by diff --git a/Sources/MistKit/CloudKitService/CloudKitService+SubscriptionOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+SubscriptionOperations.swift index c32123f6..5b1819ec 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+SubscriptionOperations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+SubscriptionOperations.swift @@ -34,8 +34,8 @@ extension CloudKitService { /// /// Subscriptions are the change triggers that produce push notifications; /// pair them with a registered APNs token - /// (``createAPNsToken(environment:database:)`` / - /// ``registerAPNsToken(_:database:)``) to receive pushes. + /// (``createAPNsToken(environment:clientId:database:)`` / + /// ``registerAPNsToken(_:environment:clientId:database:)``) to receive pushes. /// /// - Parameter database: The CloudKit database scope to query. /// - Returns: Every subscription registered in the database. diff --git a/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md b/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md index 0d756cb5..1b5e54f5 100644 --- a/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md +++ b/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md @@ -58,14 +58,16 @@ public struct CloudKitService: Sendable { } ``` -The four public initialisers live in `Sources/MistKit/CloudKitService/CloudKitService+Initialization.swift`: +The initialisers live in `Sources/MistKit/CloudKitService/CloudKitService+Initialization.swift`: -| Initializer | Use case | -| --- | --- | -| `init(containerIdentifier:credentials:environment:transport:)` | Standard. Per-call database, per-call token manager resolution. | -| `init(containerIdentifier:tokenManager:environment:transport:)` | Bespoke. One manager for every dispatched call regardless of database. | -| `init(containerIdentifier:credentials:environment:)` | URLSession convenience (non-WASI). | -| `init(containerIdentifier:tokenManager:environment:)` | URLSession convenience (non-WASI). | +| Initializer | Visibility | Use case | +| --- | --- | --- | +| ``CloudKitService/init(containerIdentifier:credentials:environment:)`` | public (non-WASI) | Standard. Per-call database, per-call token manager resolution, `URLSessionTransport`. | +| ``CloudKitService/init(containerIdentifier:tokenManager:environment:)`` | public (non-WASI) | Bespoke. One manager for every dispatched call regardless of database. | +| `init(containerIdentifier:credentials:environment:transport:)` | internal | Same, with an injected `ClientTransport` — used by MistKit's tests. | +| `init(containerIdentifier:tokenManager:environment:transport:)` | internal | Same, with an injected `ClientTransport`. | + +A public transport-accepting initializer is on the roadmap; see . Operations are split across focused extension files (`CloudKitService+Operations.swift`, `+WriteOperations.swift`, `+ZoneOperations.swift`, `+UserOperations.swift`, `+AssetOperations.swift`, etc.). Each extension method takes a `database:` where applicable, resolves a `TokenManager`, builds a fresh generated `Client` with that manager wired into `AuthenticationMiddleware`, and dispatches the request. @@ -237,9 +239,9 @@ The closure shape (`(Data, URL) async throws -> (statusCode: Int?, data: Data)`) ## Logging -`MistKitLogger` is the central swift-log wrapper with three subsystems (`api`, `auth`, `network`). Helpers (`logError`, `logWarning`, `logInfo`, `logDebug`) call through `SecureLogging.safeLogMessage` by default to mask tokens, key IDs, and other secrets. Set `MISTKIT_DISABLE_LOG_REDACTION=1` to suppress redaction while debugging. +MistKit logs through [swift-log](https://github.com/apple/swift-log) with four labeled subsystems — `com.brightdigit.MistKit.api`, `.auth`, `.network`, and `.middleware` — created via an internal `Logger(subsystem:)` initializer. Consumers bootstrap `LoggingSystem` once and set the level per label. -`LoggingMiddleware` runs after `AuthenticationMiddleware` and emits structured request/response logs in `DEBUG` builds — the auth values it sees are already in their wire form, but the secure helpers redact them again before they reach the log line. +`LoggingMiddleware` runs after `AuthenticationMiddleware` and emits request/response traces on the `middleware` subsystem at `.debug`. Bodies (up to 1 MiB) and query parameters are only collected when that subsystem's level is `.debug` or lower, so the default `.info` level costs nothing. There is no built-in redaction: the values the middleware sees are already in wire form (query tokens, signed headers), so leave the `middleware` subsystem above `.debug` in production. See . ## What the wrapper does *not* do @@ -253,6 +255,8 @@ A few intentional non-features that show up in many wrapper libraries but not th ## See Also - +- +- - - - diff --git a/Sources/MistKit/Documentation.docc/AuthenticationAndDatabases.md b/Sources/MistKit/Documentation.docc/AuthenticationAndDatabases.md index 13ab1018..e48f4564 100644 --- a/Sources/MistKit/Documentation.docc/AuthenticationAndDatabases.md +++ b/Sources/MistKit/Documentation.docc/AuthenticationAndDatabases.md @@ -80,16 +80,7 @@ let service = CloudKitService( The service does **not** carry a database. The database is chosen per call, and the appropriate token manager is resolved from ``Credentials`` each time. Misconfiguration (no credential set covers a given call's database/user-context combination) surfaces at the call site as ``CloudKitError/missingCredentials(database:availability:reason:)``, not at construction. -For a custom transport (mock, instrumented, WASI), use the generic initializer: - -```swift -let service = CloudKitService( - containerIdentifier: container, - credentials: credentials, - environment: .production, - transport: customTransport -) -``` +The public initializers wire up `URLSessionTransport`; a transport-accepting initializer exists but is internal today (see ). ## Pick a database per call @@ -170,20 +161,114 @@ For server-to-server, ``ServerToServerAuthenticator`` consumes the request body ## When to use a custom TokenManager -The standard path — ``Credentials`` plus per-call ``Database`` — covers almost every use. Reach for ``CloudKitService/init(containerIdentifier:tokenManager:environment:transport:)`` only when: +The standard path — ``Credentials`` plus per-call ``Database`` — covers almost every use. Reach for ``CloudKitService/init(containerIdentifier:tokenManager:environment:)`` only when: - You need to **dynamically refresh** credentials between requests (e.g. rotate web-auth tokens from a remote secret store). - You're **testing** and want every dispatched operation to use a stub manager that returns canned authenticators. - You're building a **specialized auth flow** that doesn't fit the developer-key / user-token / API-token taxonomy. -A custom manager is used for *every* dispatched operation regardless of database — you opt out of the per-call resolution entirely. See ``TokenManager`` and the concrete managers under "Advanced — custom token managers and storage" on the module landing page. +A custom manager is used for *every* dispatched operation regardless of database — you opt out of the per-call resolution entirely. See for the ``TokenManager`` protocol and the concrete managers. + +## Obtaining credentials from the CloudKit Console + +Everything below starts in the [CloudKit Console](https://icloud.developer.apple.com/dashboard/): pick your container, then open **Tokens & Keys**. + +### API token + +Under **API Tokens**, press `+`, name the token, and pick a **Sign-in Callback**. Optionally tick **User Info** to have the user's name returned alongside the web auth token. Save, and copy the token string into `CLOUDKIT_API_TOKEN`. + +The sign-in callback decides how a web auth token comes back to you later: + +- **URL Redirect** — Apple's sign-in page redirects the browser to a URL you supply with the token appended as the `ckSession` query parameter. Pick this when your backend handles the callback directly. +- **Post Message** — Apple's sign-in window posts a JavaScript `message` event to your page with the token in `e.data.ckWebAuthToken`. This is what CloudKit JS uses by default. + +An API token alone cannot reach the private or shared database. Its main job is to identify the container for the flows below. + +### Web auth token via browser redirect + +1. Your service makes a request with only `ckAPIToken` set. +2. CloudKit replies `401` with `serverErrorCode` `AUTHENTICATION_REQUIRED` and a `redirectURL` pointing at Apple's sign-in page. +3. Your service redirects the browser there; the user signs in with their Apple ID. +4. Apple redirects back to your registered callback with `ckSession=…` (the web auth token). +5. Your service stores that token next to the API token and uses both for every subsequent request — MistKit sends it as the `ckWebAuthToken` query item. + +MistKit surfaces step 2 as ``CloudKitError/authenticationRequired(reason:)`` on single-record conveniences, and as a per-item ``OperationFailure`` (whose ``OperationFailure/redirectURL`` carries the sign-in URL) on batch results: + +```swift +do { + _ = try await service.queryRecords(Query(recordType: "Note"), database: .private) +} catch CloudKitError.authenticationRequired(let reason) { + // Send the user through the sign-in flow, then retry with the new token. + logger.info("Sign-in required: \(reason ?? "")") +} +``` + +The token is valid for 30 minutes by default, or two weeks if the user ticks *Keep me signed in*, and CloudKit returns a rotated token in the `X-Apple-CloudKit-Web-Auth-Token` header of every response. MistKit adopts the rotated token automatically through ``TokenManager/didReceiveRotatedWebAuthToken(_:)`` — see . + +### Web auth token from an iOS app + +If your backend acts on behalf of a user who is already signed in to your iOS app, skip the browser. `CKFetchWebAuthTokenOperation` exchanges the device's iCloud session for a web auth token your server can use: + +```swift +extension CKDatabase { + func fetchWebAuthToken(apiToken: String) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + let operation = CKFetchWebAuthTokenOperation(apiToken: apiToken) + operation.qualityOfService = .userInitiated + operation.fetchWebAuthTokenResultBlock = { @Sendable result in + continuation.resume(with: result) + } + add(operation) + } + } +} +``` + +Run it against the **private** database — on the public database it fails or returns an unattributed token. Post the result to your backend over your own authenticated API; from there the MistKit side is identical to the browser flow. + +### Server-to-server key + +Under **Server-to-Server Keys**, press `+`. The console shows the exact commands; the key pair is yours, and only the public half is uploaded: + +```bash +# Step 1: generate a P-256 private key +openssl ecparam -name prime256v1 -genkey -noout -out eckey.pem + +# Step 2: output the public key and paste it into the console +openssl ec -in eckey.pem -pubout +``` + +Name the key, paste the public key, save, and copy the **Key ID** into `CLOUDKIT_KEY_ID`. Keep `eckey.pem` on the server — never commit it — and hand it to MistKit as ``PrivateKeyMaterial/file(path:)`` or, when a secret store injects the PEM contents, ``PrivateKeyMaterial/raw(_:)``. + +Every request is then signed with the key: MistKit builds the payload `::`, signs it with ECDSA P-256, and sends the `X-Apple-CloudKit-Request-KeyID`, `X-Apple-CloudKit-Request-ISO8601Date`, and `X-Apple-CloudKit-Request-SignatureV1` headers. There is no `Authorization` header. walks through the implementation. + +### Rotating a server-to-server key + +Keys do not expire on their own, but the console allows several active keys per container, which makes a zero-downtime rotation possible: + +1. Generate a new key pair and add its public key as a new entry. +2. Roll the new Key ID and PEM into your secret store. +3. Restart the service so it picks up the new credentials. +4. Once the new key is confirmed in use, delete the old key from the console. + +### Environment variables + +The conventional variable names used by MistDemo, BushelCloud, and CelestraCloud: + +| Variable | Method | +| --- | --- | +| `CLOUDKIT_CONTAINER_ID`, `CLOUDKIT_ENVIRONMENT` | All | +| `CLOUDKIT_API_TOKEN` | API token / web auth | +| `CLOUDKIT_WEB_AUTH_TOKEN` | Web auth | +| `CLOUDKIT_KEY_ID`, `CLOUDKIT_PRIVATE_KEY` or `CLOUDKIT_PRIVATE_KEY_PATH` | Server-to-server | -## Reference material +Where those values come from per platform — CI secrets, Kubernetes, `systemd`, managed hosts — is covered in . -The longer prose guides live in the repo (outside this DocC bundle): +## Further reading -- `docs/cloudkit-guide/articles/authenticating-cloudkit-backend-services.md` — full backend setup walkthrough including obtaining tokens, the browser-redirect web-auth flow, `CKFetchWebAuthTokenOperation` for iOS handoff, CloudKit Dashboard configuration, and CI/CD secret rotation. -- `docs/internals/authentication-middleware.md` — Mermaid diagrams of the middleware chain and per-scheme signing paths. +- — the middleware, the authenticators, and the signing payload in detail. +- — the talk that motivates the three methods, with console screenshots. +- — why the auth matrix has holes and what each signing mistake looks like on the wire. ## Topics diff --git a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md new file mode 100644 index 00000000..75819c77 --- /dev/null +++ b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md @@ -0,0 +1,790 @@ +# CloudKit as Your Backend + +From iOS to server-side Swift — the talk behind MistKit, consolidated into one article. + +## Overview + +CloudKit is great for iOS apps. How about backend services? This article is the written form of a conference talk by Leo Dion ([@leogdion@c.im](https://c.im/@leogdion)) that walks from "what is CloudKit" to a scheduled GitHub Actions job writing to a CloudKit public database from a stock Ubuntu runner, and explains the three problems that shaped MistKit along the way: authentication, field-type polymorphism, and error handling. + +The talk was given in 2026 at Swift Craft, Swift Rockies (Calgary), and iOSDevUK (Aberystwyth). The abstract: + +> CloudKit has excellent documentation for iOS and macOS client development. But backend services — podcast aggregation, RSS readers, data processing — face APIs that Apple barely documents. I rebuilt a comprehensive CloudKit library using AI-generated OpenAPI specifications. The result: type-safe Swift code supporting three authentication methods (server-to-server, web authentication token, and API token), typed error handling, and production deployments. + +The sections below follow the slide order. Every code sample is taken from the current MistKit source or its example projects, so the article stays accurate as the library evolves. A complete list of the links shown during the talk is at the end, in . + +## What is CloudKit + +Travel back to WWDC 2014 — the year Swift was introduced — and you also find the introduction of CloudKit. The idea was simple: give iOS developers a backend for storage, logic, database, search, and notifications without running a server. + +Setting it up is a checkbox in Xcode: enable the **iCloud** capability, tick **CloudKit**, and add a **container**. The container is the storage for your record types and records. + +### Records and field types + +Records are the main way data is stored. Think of a record as a table row with typed fields. The CloudKit Console lets you create record types and fields interactively; each field has one of nine types: + +| CloudKit type | Native framework type | MistKit ``FieldValue`` case | +| --- | --- | --- | +| `STRING` | `String` | ``FieldValue/string(_:)`` | +| `INT64` | `Int` / `NSNumber` | ``FieldValue/int64(_:)`` | +| `DOUBLE` | `Double` / `NSNumber` | ``FieldValue/double(_:)`` | +| `BYTES` | `Data` | ``FieldValue/bytes(_:)`` | +| `TIMESTAMP` | `Date` | ``FieldValue/date(_:)`` | +| `LOCATION` | `CLLocation` | ``FieldValue/location(_:)`` | +| `REFERENCE` | `CKRecord.Reference` | ``FieldValue/reference(_:)`` | +| `ASSET` | `CKAsset` | ``FieldValue/asset(_:)`` | +| `LIST` | `Array` | ``FieldValue/list(_:)`` | + +There is no boolean type — CloudKit stores booleans as `INT64` `0`/`1`, which is why MistKit offers ``FieldValue/init(booleanValue:)`` and ``FieldValue/boolValue``. + +If you would rather script the schema than click through the console, CloudKit has a text-based schema language and the `cktool` command-line tool ([Integrating a text-based schema into your workflow](https://developer.apple.com/documentation/cloudkit/integrating-a-text-based-schema-into-your-workflow)): + +```text +RECORD TYPE Note ( + "title" STRING QUERYABLE SORTABLE SEARCHABLE, + "index" INT64 QUERYABLE SORTABLE, + "image" ASSET +); +``` + +Once a record type exists you can add records through the console — this is the `Note` type MistKit's demo apps use: + +![CloudKit Console showing Note records in the MistDemo container](talk-cloudkit-console-records) + +### Containers, environments, databases, zones + +The hierarchy, top to bottom: + +``` +Container iCloud.com.example.MyApp (typically one per app) + └─ Environment development | production + └─ Database public | private | shared + └─ Zone _defaultZone | custom zones + └─ Record +``` + +- The **private database** belongs to the signed-in user. Every user gets their own, and it lives in *their* iCloud account: delete the app and the data survives; delete the iCloud account and it is gone. +- The **public database** is shared by every user of the app. Reads do not require a signed-in user; writes do. +- The **shared database** holds records other users have shared with the current user. +- **Environments** let you develop against `development` and promote the schema to `production` from the console when it is ready. + +A typical macOS app built on the native CloudKit framework shows the same records the console does: + +![The MistDemo macOS app querying Note records](talk-mistdemo-macos) + +Everything above is the native, on-device CloudKit story. The rest of the talk is about what happens when the code that needs those records does not run on an Apple device. + +## What is CloudKit Web Services + +Part of CloudKit's original design was access from the web. There are two ways in: + +- **CloudKit JS** — a JavaScript library that gives a browser roughly the same API as the CloudKit framework. If you are running in a browser, use it; MistKit is not for that. +- **CloudKit Web Services** — the REST API underneath. Every operation is a `POST` (or occasionally `GET`) against: + +```text +https://api.apple-cloudkit.com/database/{version}/{container}/{environment}/{database}/{operation} +``` + +The REST API is documented in Apple's *CloudKit Web Services Reference*, which is now in the developer library archive. Its **document revision history ends in 2016**. The API still works — it is what CloudKit JS talks to — but nothing about it has been written down by Apple in a decade, and in several places the archived reference and the live server disagree. catalogs those. + +## Why server-side CloudKit + +Each database has its own reason to be reached from a server. + +### Private database: Heartwitch + +[Heartwitch](https://heartwitch.app) is an Apple Watch app that streams a workout's heart rate to a browser overlay so streamers can show it on a live stream. Built before Sign in with Apple existed, it looked like this: + +``` +Apple Watch ──POST heart rate──▶ Vapor server ──WebSocket──▶ Browser ──▶ OBS overlay + │ + └── PostgreSQL (username + password accounts) +``` + +The website had a username-and-password login. Typing a password on a watch face is a terrible experience, and the watch is already signed in to iCloud — so the watch app wrote its identity to the user's CloudKit private database instead. Linking the two identities happened on the web: + +1. The watch app runs and adds an *Apple Watch* record to the user's private database. +2. The user signs in to the website. +3. The website offers "sign in to CloudKit" via CloudKit JS, which yields a web auth token. +4. The server uses that token to read the user's private database, finds the watch record, and stores the watch ID next to the Postgres account. +5. The watch posts heart rate with its watch ID; the Vapor server now knows which user it belongs to and where to stream it. + +Step 4 is the important design decision: the server **copies** what it needs out of CloudKit. The private database is owned by the user, not by you, so treat it as an *input* rather than a system of record. + +Source: [brightdigit/HeartWitch](https://github.com/brightdigit/HeartWitch). + +### Public database: Bushel + +[Bushel](https://getbushel.app) is a macOS virtual-machine app for developers. To install a particular macOS version into a VM you need its *restore image*, and Bushel needs to know which images exist, where to download them, whether Apple still signs them, and which Xcode and Swift versions they support. + +That data is universal — every Bushel user wants the same list — so it belongs in a **public database**. A scheduled job pulls the data from several upstream sources and writes it into CloudKit; the app just queries the container: + +``` +GitHub Actions (cron) ──▶ bushel-cloud CLI (MistKit) ──▶ CloudKit public database ◀── Bushel app + │ + └── IPSW, AppleDB, MESU, VirtualBuddy TSS, Xcode releases, swift.org +``` + +The same pattern powers [Celestra](https://celestr.app), an RSS reader whose feeds are fetched and synced to a public database on a schedule. + +Sources: [brightdigit/BushelCloud](https://github.com/brightdigit/BushelCloud) (also in this repository under `Examples/BushelCloud/`) and [brightdigit/CelestraCloud](https://github.com/brightdigit/CelestraCloud) (`Examples/CelestraCloud/`). + +### More use cases + +The two stories above generalize into four families: + +| Family | Examples | +| --- | --- | +| **Public database as a managed catalog** | Restore-image catalog (Bushel), RSS aggregation (Celestra), software-version catalogs, asset packs for a creative app, feature flags and remote config, MDM configuration catalogs | +| **Private database on behalf of a user** | Linking a wearable to an external service (Heartwitch), sensor-data pipelines to a fitness or research platform, two-way sync with Todoist/Notion/Calendar, server-side processing of a user's uploads (OCR, transcoding, tagging) | +| **Web app ↔ Apple device bridge** | A browser portal for a CloudKit-backed app, a webhook handler (Stripe, GitHub, forms) that writes straight into a user's records | +| **Data aggregation** | Anonymized telemetry read via `records/changes`, crowdsourced data cleaned and written back by a background steward | + +Apple's CloudKit framework only runs on Apple platforms. MistKit wraps CloudKit Web Services so server-side Swift, Linux services, and command-line tools can take part in the same containers as your apps. + +## Building MistKit + +The first MistKit, started in 2020 for Heartwitch, was written by hand, which meant: + +- writing each API call from the archived documentation, +- verifying whether that documentation was even correct, +- re-learning CloudKit's architecture piece by piece, +- implementing ECDSA signing for server-to-server authentication, +- and hand-writing two network stacks — `URLSession` for clients and `AsyncHTTPClient` for servers — before `async`/`await`. + +Only what Heartwitch needed got implemented. Two things made a full rebuild feasible. + +### Swift OpenAPI Generator + +[swift-openapi-generator](https://github.com/apple/swift-openapi-generator), announced at WWDC 2023, reads an OpenAPI document and generates a type-safe Swift client. The document describes metadata, servers, paths (with their methods and responses), and reusable component schemas: + +```yaml +openapi: 3.1.0 +info: + title: Todo API + version: 1.0.0 +servers: + - url: https://api.example.com/v1 +paths: + /todos: + get: + operationId: listTodos + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Todo" +components: + schemas: + Todo: + type: object + required: [id, title, isCompleted] + properties: + id: { type: string } + title: { type: string } + isCompleted: { type: boolean } +``` + +The generator ships transports for `URLSession` and `AsyncHTTPClient`, plus server transports for Vapor, Hummingbird, and Lambda, so one spec covers every place MistKit needs to run. How MistKit drives the generator is in ; what the output looks like is in . + +### Turning the documentation into a spec + +The remaining problem was producing an OpenAPI document for an API that only exists as 2016-era prose. That is where AI-assisted development came in: each documented endpoint was fed to an LLM and translated into `openapi.yaml`, then abstractions were built on top. It was not automatic — hallucinated APIs, context-window limits, and unrequested scaffolding (retries, caches, "secure memory") were constant, and the assistant regularly declared success before running the build. The catalog of what went wrong, with evidence, is ; the narrative version is in the two *Rebuilding MistKit with Claude Code* articles ([part 1](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-1/), [part 2](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-2/)). + +### Three layers + +The result is layered so callers never see the generated code: + +``` +Your code service.queryRecords(recordType: "Note", database: .private) + │ + ▼ +MistKit abstraction CloudKitService · FieldValue · QueryFilter · CloudKitError + │ + ▼ +Generated client Operations.queryRecords.Input / Output (swift-openapi-generator) + │ + ▼ +CloudKit Web Services +``` + + describes each box. + +### Proving it works + +Unit tests were not enough — the assistant would report success on code that failed the moment it touched a real container. So MistDemo (`Examples/MistDemo/`) exists: a command-line tool with live integration phases (`mistdemo test-public`, `mistdemo test-private`), a macOS app, and a web interface served by Hummingbird that exercises every endpoint through either MistKit or CloudKit JS, side by side: + +![The MistKit web demo, switching between MistKit and CloudKit JS backends](talk-mistdemo-web) + +MistDemo is the live-verification oracle for everything in this article that describes CloudKit's wire behavior. + +## Authentication + +On a device, authentication is invisible: the user is signed in to iCloud and the framework does the rest. On a server you have to prove who you are with credentials you manage. Apple documents three methods; it is more honest to call it **two and a half**, because the first one is a prerequisite for the second rather than a peer. + +All of them start in the CloudKit Console under **Tokens & Keys** for your container. + +### API token + +Create an API token with the `+` button, give it a name, choose a **sign-in callback** (see below), optionally restrict it to certain domains, and decide whether to allow user discoverability at sign-in. Every web-services request then carries it as a query item: + +```text +?ckAPIToken=[API token] +``` + +CloudKit JS is configured the same way: + +```javascript +CloudKit.configure({ + containers: [{ + containerIdentifier: '[insert your container ID here]', + apiTokenAuth: { apiToken: '[insert your API token]' }, + environment: 'development' + }] +}); +``` + +On its own an API token grants only what the public database's `_world` role allows — typically reads. Its real job is to identify the container for the next method. + +### Web auth token + +A web auth token is what you need to act as a specific iCloud user, and the only way into the private and shared databases. Requests carry both tokens: + +```text +?ckAPIToken=[API token]&ckWebAuthToken=[Web Auth Token] +``` + +The user gets one by signing in with their Apple ID: + +![Apple's sign-in window for the MistDemo container](talk-icloud-sign-in) + +How the token comes back depends on the sign-in callback chosen when the API token was created: + +- **Post message** — Apple's sign-in window posts a `message` event to your page: + + ```javascript + window.addEventListener('message', function(e) { + console.log(e.data.ckWebAuthToken); + }) + ``` + +- **URL redirect** — Apple redirects the browser to your callback URL with the token as a query parameter. Note the parameter is named `ckSession` on the redirect even though the request-side query item is `ckWebAuthToken`: + + ```text + https://[callback-url]/?ckSession=[Web Auth Token] + ``` + +Two facts about the token that Apple documents only in the archived reference: + +- **Lifetime** — 30 minutes by default, two weeks if the user ticks *Keep me signed in*. +- **Rotation** — every response carries a fresh token in the `X-Apple-CloudKit-Web-Auth-Token` header, and the documented rule is that the previous token is invalid once the response arrives. MistKit's `AuthenticationMiddleware` reads that header after every request and hands it to ``TokenManager/didReceiveRotatedWebAuthToken(_:)``; ``WebAuthTokenManager`` and ``AdaptiveTokenManager`` adopt it. (In practice the live server has been observed to keep accepting old tokens, but the library follows the written rule.) + +#### From inside an iOS app + +You do not need a browser at all if the user is already signed in to your iOS app. `CKFetchWebAuthTokenOperation` exchanges the device's iCloud session for a web auth token that your server can use. MistDemo wraps it in an `async` function: + +```swift +extension CKDatabase { + internal func fetchWebAuthToken(apiToken: String) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + let operation = CKFetchWebAuthTokenOperation(apiToken: apiToken) + operation.qualityOfService = .userInitiated + operation.fetchWebAuthTokenResultBlock = { @Sendable result in + continuation.resume(with: result) + } + add(operation) + } + } +} +``` + +Run it against the **private** database; on the public database it fails or returns an unattributed token. Post the result to your backend over your own authenticated API and store it there. + +### Server to server + +For a job with no user — a cron job, a daemon, a CLI — use a server-to-server key. Under **Tokens & Keys → Server-to-Server Keys**, the console shows the exact commands: + +![The "New Server-to-Server Key" page in the CloudKit Console](talk-server-to-server-key) + +```bash +# Step 1: generate a P-256 private key +openssl ecparam -name prime256v1 -genkey -noout -out eckey.pem + +# Step 2: output the public key (pipe to pbcopy to paste into the console) +openssl ec -in eckey.pem -pubout +``` + +Paste the public key into step 3, save, and copy the **Key ID**. The private key never leaves your server. + +Each request is then signed. The payload is three strings joined by colons — the ISO 8601 date, the base64 SHA-256 of the body (or the empty string for no body), and the URL subpath — signed with ECDSA P-256 and sent in three headers: + +| Header | Value | +| --- | --- | +| `X-Apple-CloudKit-Request-KeyID` | your key ID | +| `X-Apple-CloudKit-Request-ISO8601Date` | the same date that was signed | +| `X-Apple-CloudKit-Request-SignatureV1` | base64-encoded ECDSA signature | + +There is no `Authorization` header. Every mistake in any of those pieces produces the same generic `401`, which is why the signing code in MistKit stores the exact date *string* it signed rather than re-formatting a `Date` on the way out. + +### Which method for which database + +| | Public | Private | Shared | +| --- | :-: | :-: | :-: | +| API token only | whatever `_world` grants | — | — | +| Web auth token | ✓ user-attributed | ✓ | ✓ | +| Server-to-server | ✓ developer-attributed | — | — | + +The public database accepts two methods and they are **not interchangeable**: the same record written via web auth and via server-to-server ends up with two different creators, and the `/users/*` routes accept web auth only. MistKit therefore makes every public call say which it wants — ``Database/public(_:)`` carries a ``PublicAuthPreference`` — rather than defaulting silently. covers the model; the deeper "why" is in . + +### OpenAPI middleware + +swift-openapi-generator does not know how to sign CloudKit requests, but it has middleware: `ServerMiddleware` runs before a request is received and `ClientMiddleware` runs before one is sent. Apple's own example shows a bearer-token middleware. MistKit's version is one small type whose only job is to ask a ``TokenManager`` for the current ``Authenticator`` and let it modify the request: + +```swift +internal struct AuthenticationMiddleware: ClientMiddleware { + internal let tokenManager: any TokenManager + + internal func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String, + next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + guard let authenticator = try await tokenManager.currentAuthenticator() else { + throw TokenManagerError.invalidCredentials(.noCredentialsAvailable) + } + + var modifiedRequest = request + var modifiedBody = body + try await authenticator.authenticate(request: &modifiedRequest, body: &modifiedBody) + let (response, responseBody) = try await next(modifiedRequest, modifiedBody, baseURL) + if let rotated = response.headerFields[.cloudKitWebAuthToken] { + do { + try await tokenManager.didReceiveRotatedWebAuthToken(rotated) + } catch { + let message = "Failed to consume rotated web auth token: \(error.localizedDescription)" + Logger(subsystem: .auth).warning("\(message)") + RotatedWebAuthTokenFailureReporter.assertionHandler(message) + } + } + return (response, responseBody) + } +} +``` + +``` +App ──▶ OpenAPI Client ──▶ AuthenticationMiddleware ──▶ TokenManager.currentAuthenticator() + │ + ├── authenticator.authenticate(&request, &body) + ▼ + next(request, body, baseURL) ──▶ CloudKit + │ + └── read X-Apple-CloudKit-Web-Auth-Token → tokenManager +``` + +Each scheme is one `authenticate(request:body:)` implementation. The API token appends a query item: + +```swift +public func authenticate( + request: inout HTTPRequest, + body: inout HTTPBody? +) async throws { + request.appendQueryItems([URLQueryItem(name: "ckAPIToken", value: token)]) +} +``` + +The web auth token percent-encodes `+`, `/`, and `=` (CloudKit rejects the raw form) and appends both items: + +```swift +public func authenticate( + request: inout HTTPRequest, + body: inout HTTPBody? +) async throws { + let encoded = Self.encoder.encode(webAuthToken) + request.appendQueryItems([ + URLQueryItem(name: "ckAPIToken", value: apiToken), + URLQueryItem(name: "ckWebAuthToken", value: encoded), + ]) +} +``` + +Server-to-server buffers the body (so it can be both hashed and replayed), builds a signature, and appends the headers: + +```swift +public func authenticate( + request: inout HTTPRequest, + body: inout HTTPBody? +) async throws { + let bodyData = try await Data(buffering: &body, upTo: bodyBufferLimit) + + let signature = try RequestSignature( + keyID: keyID, + privateKey: privateKey, + requestBody: bodyData, + webServiceSubpath: request.path + ) + + request.headerFields.append(contentsOf: signature.headers) +} +``` + +`RequestSignature` does the actual signing: + +```swift +internal init( + keyID: String, + privateKey: P256.Signing.PrivateKey, + bodyHash: String, + webServiceSubpath: String, + iso8601DateString: String +) throws { + let payload = "\(iso8601DateString):\(bodyHash):\(webServiceSubpath)" + let signature = try privateKey.signature(for: Data(payload.utf8)) + + self.init( + keyID: keyID, + iso8601DateString: iso8601DateString, + signatureDerRepresentation: signature.derRepresentation + ) +} + +internal var headers: HTTPFields { + var fields = HTTPFields() + fields[.cloudKitRequestKeyID] = keyID + fields[.cloudKitRequestISO8601Date] = iso8601DateString + fields[.cloudKitRequestSignatureV1] = signatureBase64 + return fields +} +``` + +where the body hash is `SHA256.cloudKitBodyHash(of:)` — `base64(SHA256(body))`, or `""` when there is no body. The full design, including why `body` is `inout`, is in . + +## Field type polymorphism + +If you have handled JSON from a JavaScript-flavored API you know the problem: a field's value can be any of nine types, and the JSON does not always say which. MistKit models the domain side as one enum: + +```swift +public enum FieldValue: Codable, Equatable, Sendable { + case string(String) + case int64(Int) + case double(Double) + case bytes(Data) // Binary data; base64-encoded on the wire + case date(Date) // Date/time value + case location(Location) + case reference(Reference) + case asset(Asset) + case list([FieldValue]) +} +``` + +``Location``, ``Reference``, and ``Asset`` are MistKit's own structs so the package does not depend on Core Location or CloudKit on the server. A reference is a record name plus an action; an asset is what CloudKit returns for a file, including the download URL: + +```swift +public struct Reference: Codable, Equatable, Sendable { + public enum Action: String, Codable, Sendable { + case deleteSelf = "DELETE_SELF" + case none = "NONE" + case validate = "VALIDATE" + } + + public let recordName: String + public let action: Action? +} + +public struct Asset: Codable, Equatable, Sendable { + public let fileChecksum: String? + public let size: Int64? + public let referenceChecksum: String? + public let wrappingKey: String? + public let receipt: String? + public let downloadURL: String? +} +``` + +In `openapi.yaml` the value is a `oneOf` over the nine wire shapes with an optional `type` tag: + +```yaml +FieldValueRequest: + properties: + value: + oneOf: [StringValue, Int64Value, DoubleValue, BytesValue, + DateValue, LocationValue, ReferenceValue, AssetValue, ListValue] + type: + enum: [STRING, INT64, DOUBLE, BYTES, TIMESTAMP, REFERENCE, ASSET, ASSETID, + LOCATION, STRING_LIST, INT64_LIST, ...] +``` + +The thirty-second version hides a lot: the `oneOf` has no discriminator, so a whole-millisecond timestamp decodes as an integer and a base64 blob decodes as a string, and three of the scalar types must be tagged explicitly on writes or CloudKit rejects them. has the whole story. + +## Error handling + +CloudKit documents its HTTP status codes and a JSON error body that is the same for all of them: + +```json +{ + "uuid": "a1b2c3d4-...", + "serverErrorCode": "AUTHENTICATION_FAILED", + "reason": "The request requires authentication." +} +``` + +So the spec declares one `ErrorResponse` schema with an enum of codes and strings for everything else: + +```yaml +ErrorResponse: + properties: + uuid: { type: string } + serverErrorCode: + type: string + enum: [ACCESS_DENIED, ATOMIC_ERROR, AUTHENTICATION_FAILED, ...] + reason: { type: string } + redirectURL: { type: string } +``` + +and the generator produces a matching enum and struct. MistKit maps each of the fourteen documented codes onto its own ``CloudKitError`` case so callers pattern-match instead of comparing strings — see . + +### The endpoint that returns HTTP 500 + +One documented call does not work at all: `GET users/discover` ("discover all user identities") returns `500 INTERNAL_ERROR` from both the REST API and Apple's own CloudKit JS, reproducibly, after authentication succeeds. It appears to have been retired server-side — possibly for privacy reasons — without the documentation changing. MistKit generates the operation from `openapi.yaml` but does not surface it in ``CloudKitService``; the `POST users/discover` form, which takes lookup infos, works and is what ``CloudKitService/discoverUserIdentities(lookupInfos:)`` calls. The details are tracked in [MistKit issue #28](https://github.com/brightdigit/MistKit/issues/28) and Apple Feedback FB22754466. + +## Deployment + +Bushel's sync job runs entirely in GitHub Actions. The key ID and private key (or a base64-encoded copy of it) live in repository secrets: + +![Repository secrets: CLOUDKIT_KEY_ID, CLOUDKIT_PRIVATE_KEY, CLOUDKIT_API_TOKEN, CLOUDKIT_WEB_AUTH_TOKEN](talk-github-secrets) + +The scheduled workflow (`Examples/BushelCloud/.github/workflows/cloudkit-sync-dev.yml`) fires three times a day at off-the-hour minutes and hands the secrets to a composite action: + +```yaml +name: Scheduled CloudKit Sync (Development) + +on: + schedule: + - cron: '17 2 * * *' # 02:17 UTC + - cron: '43 10 * * *' # 10:43 UTC + - cron: '29 18 * * *' # 18:29 UTC + workflow_dispatch: + +concurrency: + group: cloudkit-sync-dev + cancel-in-progress: true + +jobs: + sync-dev: + name: Sync to CloudKit (Development) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: CloudKit Sync + uses: ./.github/actions/cloudkit-sync + with: + environment: development + container-id: iCloud.com.brightdigit.Bushel + cloudkit-key-id: ${{ secrets.CLOUDKIT_KEY_ID }} + cloudkit-private-key: ${{ secrets.CLOUDKIT_PRIVATE_KEY }} + virtualbuddy-api-key: ${{ secrets.VIRTUALBUDDY_API_KEY }} + enable-export: 'false' +``` + +The action (`Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml`) downloads a pre-built binary when one is cached, builds it in Docker otherwise, validates the PEM before touching CloudKit, and runs the CLI with the credentials in environment variables: + +```yaml +name: 'CloudKit Sync Action' + +inputs: + environment: + description: 'CloudKit environment (development or production)' + required: true + container-id: + description: 'CloudKit container ID' + required: true + cloudkit-key-id: + description: 'CloudKit S2S key ID' + required: true + cloudkit-private-key: + description: 'CloudKit S2S private key (PEM content)' + required: true + +runs: + using: "composite" + steps: + - name: Download pre-built binary (if available) + id: download-binary + uses: dawidd6/action-download-artifact@v3 + continue-on-error: true + with: + workflow: bushel-cloud-build.yml + workflow_conclusion: success + name: bushel-cloud-binary + path: ./binary + branch: ${{ github.ref_name }} + + - name: Build binary (fallback if artifact unavailable) + if: steps.download-binary.outcome != 'success' + shell: bash + run: | + docker run --rm -v "$PWD:/workspace" -w /workspace swiftlang/swift:nightly-6.4.x-noble \ + swift build -c release --static-swift-stdlib + mkdir -p ./binary + cp .build/release/bushel-cloud ./binary/ + + - name: Make binary executable + shell: bash + run: chmod +x ./binary/bushel-cloud + + - name: Run CloudKit sync with change tracking + shell: bash + env: + CLOUDKIT_KEY_ID: ${{ inputs.cloudkit-key-id }} + CLOUDKIT_PRIVATE_KEY: ${{ inputs.cloudkit-private-key }} + CLOUDKIT_ENVIRONMENT: ${{ inputs.environment }} + CLOUDKIT_CONTAINER_ID: ${{ inputs.container-id }} + BUSHEL_SYNC_JSON_OUTPUT_FILE: sync-result.json + run: | + ./binary/bushel-cloud sync \ + --verbose \ + --container-identifier "$CLOUDKIT_CONTAINER_ID" +``` + +The CLI writes a JSON report that the action turns into the workflow's summary page. Static builds, credential injection on other platforms, tiered scheduling, idempotency, and observability are covered in . + +## What's next + +Every endpoint in the CloudKit Web Services reference is implemented — records, zones, changes, subscriptions, users, sharing, assets, and APNs tokens — and exercised live by MistDemo. What the project needs now is people using it: try it against your own container and file what you find on the [issue tracker](https://github.com/brightdigit/MistKit/issues). + +And one more thing: Leo's apps, both backed by patterns from this talk — [Bushel](https://getbushel.app), virtualization for app developers, and [AtLeast](https://atleast.app), a passive timer for Apple Watch. + +## Links + +### Leo Dion / BrightDigit + +- [MistKit on GitHub](https://github.com/brightdigit/MistKit) +- [MistKit issues](https://github.com/brightdigit/MistKit/issues) — "What's next?" +- [MistDemo](https://github.com/brightdigit/MistKit/tree/main/Examples/MistDemo) — CLI, macOS app and web demo used for integration testing +- [Bushel](https://getbushel.app) — Virtualization for App Developers +- [AtLeast](https://atleast.app) — Passive Timer for Apple Watch +- [Heartwitch](https://heartwitch.app) — Apple Watch heart-rate streaming; [App Store](https://apps.apple.com/us/app/heartwitch/id1480031203) +- [BrightDigit](https://brightdigit.com) +- [linktr.ee/leogdion](https://linktr.ee/leogdion) +- [iOSDevUK](https://www.iosdevuk.com) + +### Use case examples + +- [BushelCloud](https://github.com/brightdigit/BushelCloud) — server-to-server sync of macOS restore images, Xcode and Swift versions for Bushel; the GitHub Actions deployment shown in the talk (also at [`Examples/BushelCloud`](https://github.com/brightdigit/MistKit/tree/main/Examples/BushelCloud)) + - [`cloudkit-sync-dev.yml`](https://github.com/brightdigit/MistKit/blob/main/Examples/BushelCloud/.github/workflows/cloudkit-sync-dev.yml) — scheduled workflow + - [`cloudkit-sync` composite action](https://github.com/brightdigit/MistKit/blob/main/Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml) +- [CelestraCloud](https://github.com/brightdigit/CelestraCloud) — RSS feed sync into a public database for Celestra, with query filtering and sorting (also at [`Examples/CelestraCloud`](https://github.com/brightdigit/MistKit/tree/main/Examples/CelestraCloud)) +- [HeartWitch](https://github.com/brightdigit/HeartWitch) — the private-database, Apple Watch to Vapor bridge +- [Rebuilding MistKit with Claude Code, part 1](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-1/) and [part 2](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-2/) + +### What is CloudKit + +- [WWDC 2014 "Introducing CloudKit" (session 208)](https://nonstrict.eu/wwdcindex/wwdc2014/208/) — Apple no longer hosts the video; this mirror has the video, slides and transcript. Transcript only: [ASCIIwwdc](https://asciiwwdc.com/2014/sessions/208) +- [CloudKit framework documentation](https://developer.apple.com/documentation/cloudkit) +- [Enabling CloudKit in your app](https://developer.apple.com/documentation/cloudkit/enabling-cloudkit-in-your-app) — Xcode capability setup +- [CloudKit Console](https://icloud.developer.apple.com/dashboard/) +- [Integrating a text-based schema into your workflow](https://developer.apple.com/documentation/cloudkit/integrating-a-text-based-schema-into-your-workflow) +- [cktool](https://developer.apple.com/icloud/ck-tool/) and [Automating CloudKit Development](https://developer.apple.com/icloud/cloudkit/automating/) + +### CloudKit Web Services + +- [CloudKit JS](https://developer.apple.com/documentation/cloudkitjs) +- [CloudKit Web Services Reference](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/index.html) — archived + - [Composing Web Service Requests](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/SettingUpWebServices.html) + - [Accessing CloudKit Using an API Token](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/SettingUpWebServices.html#//apple_ref/doc/uid/TP40015240-CH24-SW2) + - [Accessing CloudKit Using a Server-to-Server Key](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/SettingUpWebServices.html#//apple_ref/doc/uid/TP40015240-CH24-SW6) + - [Document Revision History](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/RevisionHistory.html) — last updated 2016 + - [Uploading Assets](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/UploadAssets.html) + - [Types and Dictionaries](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/Types.html) — field types + - [Error Codes](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ErrorCodes.html) + - [Discovering User Identities (POST users/discover)](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/DiscoveringUserIdentities%28usersdiscover%29.html) + - [Discovering All User Identities (GET users/discover)](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/DiscoveringAllUserIdentities.html) — the endpoint that returns HTTP 500 +- Base URL: `https://api.apple-cloudkit.com/database/{version}/{container}/{environment}/{database}/{operation}` +- [Apple Developer Support](https://developer.apple.com/support/) — contact URL in the OpenAPI document + +### Authentication + +- [CKFetchWebAuthTokenOperation](https://developer.apple.com/documentation/cloudkit/ckfetchwebauthtokenoperation) +- [Sign in with Apple](https://developer.apple.com/documentation/signinwithapple) — mentioned as not existing when Heartwitch was built +- [swift-crypto](https://github.com/apple/swift-crypto) — `P256.Signing.PrivateKey` used in `RequestSignature` +- MistKit source shown in the talk: + - [`AuthenticationMiddleware.swift`](https://github.com/brightdigit/MistKit/blob/main/Sources/MistKit/Authentication/AuthenticationMiddleware.swift) + - [`APITokenAuthenticator.swift`](https://github.com/brightdigit/MistKit/blob/main/Sources/MistKit/Authentication/APITokenAuthenticator.swift) + - [`WebAuthTokenAuthenticator.swift`](https://github.com/brightdigit/MistKit/blob/main/Sources/MistKit/Authentication/WebAuthTokenAuthenticator.swift) + - [`ServerToServerAuthenticator.swift`](https://github.com/brightdigit/MistKit/blob/main/Sources/MistKit/Authentication/ServerToServerAuthenticator.swift) + - [`RequestSignature.swift`](https://github.com/brightdigit/MistKit/blob/main/Sources/MistKit/Authentication/RequestSignature.swift) + +### Swift OpenAPI Generator + +- [swift-openapi-generator](https://github.com/apple/swift-openapi-generator) +- [WWDC23 "Meet Swift OpenAPI Generator"](https://developer.apple.com/videos/play/wwdc2023/10171/) +- [Documentation](https://swiftpackageindex.com/apple/swift-openapi-generator/documentation/swift-openapi-generator) and [tutorial: Working with Swift OpenAPI Generator](https://swiftpackageindex.com/apple/swift-openapi-generator/tutorials/swift-openapi-generator) +- [ClientMiddleware](https://swiftpackageindex.com/apple/swift-openapi-runtime/documentation/openapiruntime/clientmiddleware) — the ["Implement a custom client middleware"](https://swiftpackageindex.com/apple/swift-openapi-runtime/documentation/openapiruntime/clientmiddleware#Implement-a-custom-client-middleware) bearer-token example is a section on that page +- [Example projects](https://github.com/apple/swift-openapi-generator/tree/main/Examples) — including auth, logging and retrying middleware examples +- Package ecosystem: + - [swift-openapi-runtime](https://github.com/apple/swift-openapi-runtime) + - [swift-openapi-urlsession](https://github.com/apple/swift-openapi-urlsession) + - [swift-openapi-async-http-client](https://github.com/swift-server/swift-openapi-async-http-client) + - [swift-okhttp](https://github.com/frameo-net/swift-okhttp) + - [swift-openapi-vapor](https://github.com/vapor/swift-openapi-vapor) + - [swift-openapi-hummingbird](https://github.com/hummingbird-project/swift-openapi-hummingbird) + - [swift-openapi-lambda](https://github.com/awslabs/swift-openapi-lambda) +- [OpenAPI Specification](https://spec.openapis.org/oas/latest.html) +- [MistKit `openapi.yaml`](https://github.com/brightdigit/MistKit/blob/main/openapi.yaml) + +### Field types and error handling + +- [`FieldValue.swift`](https://github.com/brightdigit/MistKit/blob/main/Sources/MistKit/Models/FieldValues/FieldValue.swift) +- [MistKit issue #28: discoverAllUserIdentities returns HTTP 500](https://github.com/brightdigit/MistKit/issues/28) + - Broken call, CloudKit Web Services: [Discovering All User Identities (GET users/discover)](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/DiscoveringAllUserIdentities.html) + - Broken call, CloudKit JS: [`CloudKit.Container.discoverAllUserIdentities`](https://developer.apple.com/documentation/cloudkitjs/cloudkit.container/discoveralluseridentities) +- Apple Feedback FB22754466 — public copy on [Open Radar](https://openradar.appspot.com/FB22754466); filed via [Feedback Assistant](https://feedbackassistant.apple.com/) + +### Deployment + +- [GitHub Actions: scheduled workflows (`schedule` / cron)](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule) +- [GitHub Actions: using secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions) +- [GitHub Actions: creating a composite action](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action) +- [actions/checkout](https://github.com/actions/checkout) +- [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) +- [Swift Docker images](https://hub.docker.com/_/swift) — the sync action builds with `swiftlang/swift:nightly-6.4.x-noble` + +### Other tools mentioned + +- [Hummingbird](https://hummingbird.codes) — server behind the MistDemo web interface +- [Vapor](https://vapor.codes) — Heartwitch backend +- [OBS Studio](https://obsproject.com) — Heartwitch streaming overlay + +## Questions + +Questions asked during rehearsals and at the talks, with the answers as they stand today. + +**How much does CloudKit cost?** There is no separate CloudKit fee beyond the Apple Developer Program. Quotas for storage, transfer, and requests scale with active users; exceeding them returns `QUOTA_EXCEEDED` or throttling. Apple does not publish a clear overage price list. + +**What kind of database is it?** NoSQL, document-oriented, with a schema. Records have typed fields; relationships are references, not joins. The schema is defined up front (console or `cktool`), not created as you go. + +**Do I need a signed-in user for the public database?** To write, yes. To read, no — the `_world` role decides. + +**Why server-to-server instead of web auth for the public database?** Server-to-server is the only method that needs no human. It gives you a writable identity without anyone signing in, its key does not expire or rotate, and records it writes belong to your service rather than to whichever user happened to be signed in. A nightly job running on a web auth token would be dead by the second night. The mirror image is why private and shared reject server-to-server — there is no such thing as "your server's private database". + +**How long does a web auth token last?** 30 minutes, or two weeks with *Keep me signed in*, and the documented rule is that it rotates on every response. See . + +**How do I get a web auth token from inside my iOS app?** `CKFetchWebAuthTokenOperation`, against the private database; see above. + +**What happens to a user's private data if they delete the app or their iCloud account?** Delete the app: the data survives in iCloud. Sign out of iCloud: the data is intact but unreachable from that device. Delete the iCloud account: the data is gone and you never had a copy — which is why Heartwitch copies what it needs into Postgres. + +**Can I use this from a browser extension?** Use CloudKit JS unless you specifically need Swift. + +**Does this run on Linux? Windows? WASM?** Linux and Windows, yes — that is the point. Server-to-server signing needs swift-crypto, which is unavailable on Windows and WASI, so those targets use API-token + web-auth credentials. WASI also lacks a first-class HTTP transport; for browsers use CloudKit JS. + +**How does this compare to Vapor plus the CloudKit framework?** The CloudKit framework only runs on Apple platforms. MistKit runs anywhere Swift runs. + +**What is the production story for key storage?** GitHub Actions secrets for the two example jobs; a secrets manager or environment-variable injection in general. Never commit a `.pem`. covers the options per platform. diff --git a/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md b/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md index aa96e372..f8f43edf 100644 --- a/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md +++ b/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md @@ -37,7 +37,7 @@ Raise `maxPages` when you know the result set is genuinely large. Narrow filters ## Batching writes -CloudKit's `/records/modify` endpoint accepts a batch of operations in a single round-trip. The practical server-side cap is around 200 operations per request. ``CloudKitService/modifyRecords(_:atomic:database:)`` does not chunk for you — split larger batches yourself: +CloudKit's `/records/modify` endpoint accepts a batch of operations in a single round-trip. The practical server-side cap is around 200 operations per request. ``CloudKitService/modifyRecords(_:atomic:zoneID:desiredKeys:numbersAsStrings:database:)`` does not chunk for you — split larger batches yourself: ```swift let chunked = stride(from: 0, to: operations.count, by: 200).map { @@ -58,9 +58,9 @@ for chunk in chunked { ## Asset upload transport -Asset uploads are a two-step workflow: ``CloudKitService/requestAssetUploadURL(recordType:fieldName:recordName:database:)`` returns a one-time URL on `cvws.icloud-content.com`, then ``CloudKitService/uploadAssetData(_:to:using:)`` PUTs the bytes there. MistKit's high-level ``CloudKitService/uploadAssets(data:recordType:fieldName:recordName:using:database:)`` chains both steps. +Asset uploads are a two-step workflow: ``CloudKitService/requestAssetUploadURL(recordType:fieldName:recordName:zoneID:database:)`` returns a one-time URL on `cvws.icloud-content.com`, then ``CloudKitService/uploadAssetData(_:to:using:)`` PUTs the bytes there. MistKit's high-level ``CloudKitService/uploadAssets(data:recordType:fieldName:recordName:zoneID:using:database:)`` chains both steps. -The CDN upload deliberately does **not** flow through the configured ``ClientTransport``. It uses `URLSession.shared` directly: +The CDN upload deliberately does **not** flow through the service's `ClientTransport`. It uses `URLSession.shared` directly: > Warning: CloudKit's API host (`api.apple-cloudkit.com`) and asset CDN (`cvws.icloud-content.com`) are different origins. Reusing the same HTTP/2 connection across both produces 421 Misdirected Request errors. Asset uploads keep a separate connection pool to avoid this. @@ -114,17 +114,18 @@ For custom transports, prefer one transport per `CloudKitService` and reuse the - ``CloudKitError/paginationLimitExceeded(maxPages:records:)`` - ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:zoneID:database:)`` -- ``CloudKitService/modifyRecords(_:atomic:database:)`` +- ``CloudKitService/modifyRecords(_:atomic:zoneID:desiredKeys:numbersAsStrings:database:)`` ### Asset uploads -- ``CloudKitService/uploadAssets(data:recordType:fieldName:recordName:using:database:)`` -- ``CloudKitService/requestAssetUploadURL(recordType:fieldName:recordName:database:)`` +- ``CloudKitService/uploadAssets(data:recordType:fieldName:recordName:zoneID:using:database:)`` +- ``CloudKitService/requestAssetUploadURL(recordType:fieldName:recordName:zoneID:database:)`` - ``CloudKitService/uploadAssetData(_:to:using:)`` - ``AssetUploader`` -### See Also +## See Also - +- - - diff --git a/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md b/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md index ae437f85..62457367 100644 --- a/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md +++ b/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md @@ -79,22 +79,15 @@ The configuration question for your app is: which credentials does the deploymen ## Custom transport -The default public initializer uses `URLSessionTransport` from `swift-openapi-urlsession`. For testing, request inspection, or platforms without URLSession, supply your own ``ClientTransport`` via the generic initializer: +The public initializers use `URLSessionTransport` from `swift-openapi-urlsession` and are available on every platform except WASI (`#if !os(WASI)`). ``CloudKitService`` stores its `ClientTransport` internally, but the initializers that accept a transport are not part of the public surface today — MistKit's own tests use them to substitute a mock transport that asserts on outgoing requests and returns canned responses. -```swift -let service = CloudKitService( - containerIdentifier: container, - credentials: credentials, - environment: .development, - transport: customTransport -) -``` +Consequences for consumers: -Common reasons to override the transport: +- **Apple platforms and Linux** — construct the service with the public initializers; URLSession is wired up for you. +- **Instrumentation** — configure the `middleware` logging subsystem (below) rather than wrapping the transport. +- **WASI** — has no public ``CloudKitService`` initializer yet, and the web-services API needs ECDSA signing and an HTTP transport that WASI lacks. For CloudKit access from a browser, use [CloudKit JS](https://developer.apple.com/documentation/cloudkitjs). -- **Tests** — substitute a mock transport that asserts on outgoing requests and returns canned responses. -- **Instrumentation** — wrap `URLSessionTransport` to record request/response pairs for debugging. -- **WASI** — `URLSession` is unavailable, so the WASI build path requires a transport you provide (the public URLSession initializer is gated behind `#if !os(WASI)`). +A public transport-accepting initializer (for AsyncHTTPClient on the server, for example) is tracked on the project roadmap in the README. > Warning: Asset uploads do **not** flow through the configured `transport`. They use `URLSession.shared` directly to avoid HTTP/2 connection reuse between CloudKit's API host and the CDN, which surfaces as 421 Misdirected Request errors. See for the full rationale. @@ -168,7 +161,7 @@ func env(_ key: String) throws -> String { - ``Environment`` - ``Database`` -### See Also +## See Also - - diff --git a/Sources/MistKit/Documentation.docc/DeployingMistKit.md b/Sources/MistKit/Documentation.docc/DeployingMistKit.md new file mode 100644 index 00000000..574ac5d9 --- /dev/null +++ b/Sources/MistKit/Documentation.docc/DeployingMistKit.md @@ -0,0 +1,340 @@ +# Deploying MistKit + +From a local CLI to a scheduled CloudKit job in CI — building a static Linux binary, getting credentials into the process, and structuring GitHub Actions workflows for scheduled sync. + +## Overview + +The hard part of using MistKit on a backend is not writing the code. It is deciding where the code runs, how the credentials get there, and what happens when nobody is watching. This article picks up where leaves off and covers the operational side, using two production deployments as worked examples: [BushelCloud](https://github.com/brightdigit/BushelCloud) and [CelestraCloud](https://github.com/brightdigit/CelestraCloud). Both live in this repository under `Examples/`, and both ship today as scheduled GitHub Actions jobs writing to a CloudKit public database from stock Ubuntu runners. + +"Deploying" a MistKit-based service means one of three things: + +1. **A long-running web service** that handles user requests and talks to CloudKit on their behalf (web auth token) or as itself (server-to-server). +2. **A scheduled job** — a CLI or daemon that wakes on a cron, pulls data from somewhere, and writes it to CloudKit (server-to-server). +3. **A one-shot CLI** a human runs occasionally — data import, schema bootstrapping, audits. + +The first is a normal web-app deployment where MistKit is just another HTTP client. The second is where backend CloudKit shines and where the operational patterns are least obvious, so it gets most of the space below. The third is the local-dev story plus credential hygiene. + +| Concern | Long-running service | Scheduled job | +| --- | --- | --- | +| **Auth** | Web auth token (per user), API token (public reads), or server-to-server | Server-to-server (or API token for read-only public sync) | +| **Runtime** | Vapor/Hummingbird host, kept warm | Container or `runs-on:` runner, exits on completion | +| **Credentials** | Long-lived secrets in the process environment | Injected per run from CI secrets | +| **Idempotency** | Per request | Per run — "what if this fires twice?" | +| **Observability** | Existing APM / logs | Job summary, artifacts, optional notification | +| **Failure mode** | Returns 5xx to the caller | Silent unless you wire up alerts | + +## Picking an auth method for your deployment + +From a deployment perspective the question is: **what credentials does my running process need, and where do they come from?** + +### Server-to-server (autonomous services, scheduled jobs) + +Ship with `CLOUDKIT_KEY_ID` and either `CLOUDKIT_PRIVATE_KEY` (inline PEM) or `CLOUDKIT_PRIVATE_KEY_PATH` (a file). The PEM is the sensitive piece — the private half of an ECDSA P-256 key pair — and it is limited to the **public database**. Use it for scheduled jobs, daemons, and CLIs that write on their own behalf, or a long-running service that operates as itself. + +### API token (public-database readers) + +Ship with `CLOUDKIT_API_TOKEN` only. No signing, no key file, no clock-synchronised timestamps. The trade-off is that an API token alone grants only what the public schema's `_world` role allows — typically reads. Good fits: a read replica mirroring a public dataset into a search index, a status page, a thin proxy exposing a curated subset of public records. + +### Web auth token (acting on behalf of a user) + +Ship with `CLOUDKIT_API_TOKEN` **and** a per-user `CLOUDKIT_WEB_AUTH_TOKEN`. The second token arrives at request time through one of the sign-in flows in ; it is not something you set once as a static variable. There is no good reason to run a *scheduled job* on a web auth token — it expires in 30 minutes (two weeks with "Keep me signed in") and rotates on every response, so a nightly job would be dead by the second night. It belongs to long-running services that hold tokens in a session store. + +## Building a deployable binary + +### Static Linux builds + +MistKit targets cross-platform Swift, so the deployment artifact for Linux is a single statically-linked binary that needs no Swift runtime on the host: + +```bash +swift build -c release --static-swift-stdlib +``` + +Both examples build inside the official Swift container image so the binary is portable across any modern Ubuntu runner. BushelCloud's build workflow runs the job in `container: swiftlang/swift:nightly-6.4.x-noble`; its sync action's fallback path does the same with `docker run` inside a `runs-on: ubuntu-latest` step. CelestraCloud sets the container at the job level. Either works; the job-level form is slightly cleaner when every step needs the toolchain. (Both currently pin a Swift 6.4 nightly because their manifests declare `swift-tools-version: 6.4`; move to a release image when one ships.) + +The same binary drops into a distroless or `ubuntu:noble` image for Kubernetes, Fly.io, or a plain `systemd` unit on a VPS. + +### Binary caching in CI + +A release build from scratch takes a couple of minutes on a stock runner. For a job that fires three times a day that is wasted time — and time during which a transient toolchain or network hiccup can fail a scheduled production run. Both repos build once and reuse: + +- **CelestraCloud** caches the binary with `actions/cache@v4`, keyed on the hash of `Sources/**/*.swift` and `Package.swift`, and passes it to each downstream tier job through `actions/upload-artifact@v4` / `download-artifact@v4`. +- **BushelCloud** publishes the binary from a separate `bushel-cloud-build.yml` workflow and has the sync action download that artifact, falling back to an inline build when the artifact has expired: + +```yaml +- name: Download pre-built binary (if available) + id: download-binary + uses: dawidd6/action-download-artifact@v3 + continue-on-error: true # Don't fail if artifact is missing + with: + workflow: bushel-cloud-build.yml + workflow_conclusion: success + name: bushel-cloud-binary + path: ./binary + branch: ${{ github.ref_name }} + +- name: Build binary (fallback if artifact unavailable) + if: steps.download-binary.outcome != 'success' + shell: bash + run: | + docker run --rm -v "$PWD:/workspace" -w /workspace swiftlang/swift:nightly-6.4.x-noble \ + swift build -c release --static-swift-stdlib + mkdir -p ./binary + cp .build/release/bushel-cloud ./binary/ +``` + +GitHub Actions artifact retention defaults to 90 days; the fallback prevents a stale-artifact failure on day 91. + +For a long-running service the equivalent is shipping a built image: one workflow builds and publishes the container, the runtime pulls and runs it. + +## Providing credentials at runtime + +These patterns apply whether MistKit runs as a GitHub Actions job, a container on Kubernetes, a `systemd` daemon, or a developer's local CLI. Only *how* values reach the process environment changes. + +### The environment-variable contract + +MistKit itself has no configuration dependency (see ); the example CLIs read these variables, and they are the conventional names to use: + +| Variable | Method | Purpose | +| --- | --- | --- | +| `CLOUDKIT_CONTAINER_ID` | All | Container identifier, e.g. `iCloud.com.example.MyApp` | +| `CLOUDKIT_ENVIRONMENT` | All | `development` or `production` | +| `CLOUDKIT_API_TOKEN` | API token / web auth | Token from the CloudKit Console | +| `CLOUDKIT_WEB_AUTH_TOKEN` | Web auth | Per-user token from the sign-in flow | +| `CLOUDKIT_KEY_ID` | Server-to-server | Key ID from the CloudKit Console | +| `CLOUDKIT_PRIVATE_KEY` | Server-to-server | Inline PEM contents | +| `CLOUDKIT_PRIVATE_KEY_PATH` | Server-to-server | Filesystem path to the PEM file | + +A minimal bootstrap reads them once at startup and constructs the service: + +```swift +let env = ProcessInfo.processInfo.environment + +guard let containerID = env["CLOUDKIT_CONTAINER_ID"] else { + throw ConfigurationError.missingRequired("CLOUDKIT_CONTAINER_ID") +} +guard let keyID = env["CLOUDKIT_KEY_ID"] else { + throw ConfigurationError.missingRequired("CLOUDKIT_KEY_ID") +} + +let privateKey: PrivateKeyMaterial +if let inline = env["CLOUDKIT_PRIVATE_KEY"] { + privateKey = .raw(inline) +} else if let path = env["CLOUDKIT_PRIVATE_KEY_PATH"] { + privateKey = .file(path: path) +} else { + throw ConfigurationError.missingRequired("CLOUDKIT_PRIVATE_KEY or CLOUDKIT_PRIVATE_KEY_PATH") +} + +let credentials = try Credentials( + serverToServer: ServerToServerCredentials(keyID: keyID, privateKey: privateKey) +) + +let service = CloudKitService( + containerIdentifier: containerID, + credentials: credentials, + environment: env["CLOUDKIT_ENVIRONMENT"] == "production" ? .production : .development +) +``` + +``PrivateKeyMaterial/raw(_:)`` normalizes literal `\n` escapes, which matters when a CI secret store escapes the newlines in the PEM on the way through. The [MistKitConfiguration](https://github.com/brightdigit/MistKitConfiguration) package does this parsing for you, with CLI-argument and `.env` layering on top. + +### Inline values vs. file paths + +- **Inline** (`CLOUDKIT_PRIVATE_KEY`) is simplest when the credential comes from a CI secret store or a `.env` file: pass the string through and the key never touches disk. BushelCloud's composite action does this: + + ```yaml + env: + CLOUDKIT_KEY_ID: ${{ inputs.cloudkit-key-id }} + CLOUDKIT_PRIVATE_KEY: ${{ inputs.cloudkit-private-key }} + CLOUDKIT_ENVIRONMENT: ${{ inputs.environment }} + CLOUDKIT_CONTAINER_ID: ${{ inputs.container-id }} + ``` + +- **File path** (`CLOUDKIT_PRIVATE_KEY_PATH`) is what you want when the platform mounts the credential as a file — Kubernetes secrets, `systemd`'s `LoadCredential=`, Docker secrets, a secrets-manager CSI driver — because you inherit its encryption-at-rest and rotation. CelestraCloud writes the PEM to a temp file first: + + ```yaml + env: + CLOUDKIT_PRIVATE_KEY_PATH: /tmp/cloudkit_key.pem + + steps: + - name: Create CloudKit private key file + run: | + cat <<'EOF' > $CLOUDKIT_PRIVATE_KEY_PATH + ${{ secrets.CLOUDKIT_PRIVATE_KEY }} + EOF + chmod 600 $CLOUDKIT_PRIVATE_KEY_PATH + + # ... sync step ... + + - name: Cleanup private key + if: always() + run: rm -f $CLOUDKIT_PRIVATE_KEY_PATH + ``` + + The `chmod 600` and the `if: always()` cleanup matter little on an ephemeral runner and a great deal on a long-lived host. + +### Validate the key before you hit CloudKit + +A truncated PEM does not fail at parse time; it fails when you sign the first request, as a generic `401 AUTHENTICATION_FAILED` with no hint why. BushelCloud's action validates the PEM shape before the sync step: + +```bash +if ! grep -q "BEGIN.*PRIVATE KEY" <<< "$CLOUDKIT_PRIVATE_KEY"; then + echo "Error: PEM header not found" + echo "Common issues: missing BEGIN/END markers, extra whitespace, copy/paste truncation" + exit 1 +fi + +if ! grep -q "END.*PRIVATE KEY" <<< "$CLOUDKIT_PRIVATE_KEY"; then + echo "Error: PEM footer not found" + exit 1 +fi + +PEM_CONTENT=$(sed -n '/BEGIN/,/END/p' <<< "$CLOUDKIT_PRIVATE_KEY" | grep -v "BEGIN\|END") +if ! base64 -d >/dev/null 2>&1 <<< "$PEM_CONTENT"; then + echo "Error: PEM content is not valid base64" + exit 1 +fi +``` + +The here-string form (`<<< "$VAR"`) is deliberate: it keeps the secret out of the process argument list, which on Linux is visible to other users via `/proc/*/cmdline`. Do not pipe secrets through `echo "$PEM" | grep`. + +For a long-running service the same check belongs in the startup health check — fail at boot, not on the first request. MistKit's own equivalent is ``CloudKitError/invalidPrivateKey(path:underlying:)``, thrown when a `.file(path:)` PEM cannot be read or parsed. + +### Wiring it up in different runtimes + +- **Local development** — a `.env` file in the project root (add it to `.gitignore`), loaded by MistKitConfiguration or `source`d into the shell. +- **GitHub Actions / GitLab CI** — the project's secret store, exposed through `env:` blocks or `${{ secrets.NAME }}`. +- **Docker / Compose** — `environment:`, `env_file:`, or `--env-file`. +- **Kubernetes** — `Secret` resources projected as env vars (`envFrom: secretRef:`) or files (`volumeMounts` + `secret:`); the file form pairs with `CLOUDKIT_PRIVATE_KEY_PATH`. +- **systemd on a VPS** — `EnvironmentFile=` for plain variables; `LoadCredential=` for keys that should stay encrypted at rest. +- **Managed platforms** (Fly.io, Railway, Render, Lambda) — each has a secrets tab; the values land in `ProcessInfo.processInfo.environment` the same way. + +## Scheduling strategies + +`on: schedule:` is the easy part. The design decisions are *what* to schedule and *how often*. + +### Single cron: BushelCloud + +BushelCloud syncs macOS, Xcode, and Swift version data three times a day. There is one logical job, so the schedule is three lines: + +```yaml +on: + schedule: + - cron: '17 2 * * *' # 02:17 UTC + - cron: '43 10 * * *' # 10:43 UTC + - cron: '29 18 * * *' # 18:29 UTC + + workflow_dispatch: # Manual trigger for testing +``` + +The offsets give roughly eight-hour spacing, aligned with the twelve-hour cache of one upstream source (the VirtualBuddy TSS API). `workflow_dispatch` stays on for ad-hoc reruns. + +The **production** sync (`cloudkit-sync-prod.yml`) is `workflow_dispatch` only: the live production container is updated when a human clicks the button, after the development environment has had a clean run. Commit to that policy early. + +### Tiered scheduling: CelestraCloud + +Not all RSS feeds are equal. Popular feeds want frequent refresh; feeds that have not published in months can be checked weekly. `update-feeds.yml` encodes this with two cron lines, a `determine-tier` job that inspects the current hour, and downstream jobs gated on its outputs: + +```yaml +on: + schedule: + - cron: '0 2 * * *' # Daily: standard feeds + - cron: '0 3 * * 0' # Weekly Sunday: stale feeds +``` + +`determine-tier` reads `date -u +%H`, emits a `tier` output (`high`, `standard`, `stale`, or `pr-test`), and each tier job carries `if: needs.determine-tier.outputs.runs_standard == 'true'` (and so on). One workflow, several schedules, no duplicated YAML. + +Within a tier the CLI call is parameterized by the tier's filters: + +```yaml +strategy: + matrix: + include: + - name: "Pass 1: Very popular feeds" + args: "--update-min-popularity 100 --update-max-failures 2 --update-delay 2.0 --update-limit 100" + - name: "Pass 2: Popular feeds" + args: "--update-min-popularity 10 --update-max-failures 5 --update-delay 2.5 --update-limit 100" +``` + +Those `--update-*` flags map straight onto ``QueryFilter`` values in the CLI. The same pattern works for any job that needs "the top N by some metric" without scanning the whole table. + +### Avoiding the thundering herd + +BushelCloud schedules at `:17`, `:43`, and `:29`. GitHub documents that scheduled workflows can be delayed during periods of high load, particularly at the top of the hour when half the world's crons fire. A non-zero minute typically lands closer to the intended time. + +## Concurrency, idempotency, and retries + +BushelCloud uses a `concurrency` group with `cancel-in-progress: true` so a new sync cancels an older one still in flight: + +```yaml +concurrency: + group: cloudkit-sync-dev + cancel-in-progress: true +``` + +This is safe **only because the job is idempotent**. BushelCloud uses deterministic record names derived from build numbers and `.forceReplace` operations, so a rerun updates existing records rather than duplicating them. CelestraCloud queries by GUID before uploading and skips articles that already exist. Neither cares whether the previous run finished. + +If your job is not idempotent — it appends to a log, or increments a counter — keep the default `cancel-in-progress: false` and add an application-level lock (a CloudKit record acting as a leader-election token, for instance). + +MistKit deliberately does **not** retry transient errors for you. For `THROTTLED` (429) and `TRY_AGAIN_LATER` (503) the pattern is a small wrapper at the operation site with exponential backoff: + +```swift +func withBackoff(_ operation: () async throws -> T) async throws -> T { + var delay: UInt64 = 1_000_000_000 // 1 s in nanoseconds + for attempt in 1...5 { + do { + return try await operation() + } catch CloudKitError.throttled, CloudKitError.tryAgainLater { + if attempt == 5 { throw CloudKitError.tryAgainLater(reason: "gave up after 5 attempts") } + try await Task.sleep(nanoseconds: delay) + delay *= 2 + } + } + fatalError("unreachable") +} +``` + + lists which ``CloudKitError`` cases are worth retrying; everything else is a configuration or programming error and should surface. + +## Observability: reporting from a cron job + +The hardest part of a quiet scheduled job is knowing whether it ran and what it did. Both repos use two-step reporting: the CLI writes a structured JSON report, and a CI step turns it into the workflow's summary page via `$GITHUB_STEP_SUMMARY`. + +CelestraCloud passes `--update-json-output-path ./feed-update-standard.json` to the CLI and a `summary` job `jq`s the results into Markdown: + +```bash +total_feeds=$(jq -r '.summary.totalFeeds // 0' "$json_file") +success_count=$(jq -r '.summary.successCount // 0' "$json_file") +echo "- **Total Feeds Processed:** $total_feeds" >> $GITHUB_STEP_SUMMARY +echo "- **Successful:** $success_count" >> $GITHUB_STEP_SUMMARY +``` + +BushelCloud does the same through the `BUSHEL_SYNC_JSON_OUTPUT_FILE` environment variable, with a per-record-type table of created / updated / failed counts. Both retain the JSON as a workflow artifact (`actions/upload-artifact@v4`, 7–30 days) so a separate process — a daily digest, a dashboard scrape, a manual audit — can read historical results without re-running the job. + +For a long-running service the equivalent is the request logging you already have (MistKit emits through swift-log — see ) plus a health-check endpoint that exercises a representative MistKit call so auth or schema drift shows up before users notice. + +## Development vs. production environments + +CloudKit containers expose two parallel environments, and ``Environment`` on ``CloudKitService`` (or `CLOUDKIT_ENVIRONMENT` in the example CLIs) selects one. The pattern that works: + +1. **Two workflows or deployments**, one per environment. BushelCloud has `cloudkit-sync-dev.yml` (scheduled) and `cloudkit-sync-prod.yml` (`workflow_dispatch` only). +2. **Two sets of secrets**, suffixed `_DEV` and `_PROD`, referenced explicitly. No shared default that one environment can accidentally cross-contaminate. Server-to-server keys are created per environment in the CloudKit Console, so the production key is a different key. +3. **Schema changes go through development first**, deployed with `cktool` and verified by the next scheduled dev sync. Once dev has been clean for a day, promote the schema to production and trigger the prod deployment. + +This is ordinary dev/prod hygiene with one CloudKit-specific quirk: the schema lives on Apple's infrastructure and must be promoted explicitly, from the console or with `xcrun cktool`. + +## Topics + +### Types + +- ``Credentials`` +- ``ServerToServerCredentials`` +- ``PrivateKeyMaterial`` +- ``Environment`` + +## See Also + +- +- +- +- diff --git a/Sources/MistKit/Documentation.docc/Documentation.md b/Sources/MistKit/Documentation.docc/Documentation.md index b9f96399..51625a98 100644 --- a/Sources/MistKit/Documentation.docc/Documentation.md +++ b/Sources/MistKit/Documentation.docc/Documentation.md @@ -8,7 +8,9 @@ A Swift package for server-side and command-line access to CloudKit Web Services MistKit wraps Apple's [CloudKit Web Services REST API](https://developer.apple.com/documentation/cloudkitwebservices) with a modern Swift surface so server-side code, CLIs, and platforms without the native CloudKit framework (Linux, WASI, Windows) can read and write the same containers as your Apple apps. -The library is built on `swift-openapi-generator` against Apple's published OpenAPI specification, with a hand-written abstraction layer on top that exposes typed records, async iteration, structured errors, and three authentication schemes. +The library is built on `swift-openapi-generator` against an OpenAPI document written for CloudKit Web Services, with a hand-written abstraction layer on top that exposes typed records, async iteration, structured errors, and three authentication schemes. + +New to server-side CloudKit? Start with , the written form of the conference talk that explains why the library exists and how its pieces fit together. ## Quick start @@ -65,7 +67,7 @@ The wrapper layer is described in . The code-g ## Platform support -MistKit runs on macOS, iOS, tvOS, watchOS, visionOS, Linux, WASI, and Windows. Server-to-server signing depends on Crypto / swift-crypto, so it is unavailable on Windows and WASI — those targets must use API-token + web-auth credentials. URL-loading conveniences and asset upload use `URLSession`; on WASI builds you supply a `ClientTransport` explicitly via the generic initializer. +MistKit runs on macOS, iOS, tvOS, watchOS, visionOS, Linux, WASI, and Windows. Server-to-server signing depends on Crypto / swift-crypto, so it is unavailable on Windows and WASI — those targets must use API-token + web-auth credentials. URL-loading conveniences and asset upload use `URLSession`; the public initializers are compiled only for non-WASI platforms, and a public transport-accepting initializer for WASI is not available yet (see ). > Tip: On native Apple platforms (macOS, iOS, tvOS, watchOS, visionOS) prefer the native [CloudKit framework](https://developer.apple.com/documentation/cloudkit). It integrates with the system account, handles push notifications and long-lived operations, and avoids the per-request signing overhead of the web-services API. MistKit is intended for environments where the native framework isn't available — server-side Swift, CLIs, Linux, and Windows. @@ -160,6 +162,21 @@ MistKit runs on macOS, iOS, tvOS, watchOS, visionOS, Linux, WASI, and Windows. S - - +### Guides + +- +- + +### Under the hood + +- +- + +### Background + +- +- + ## See Also - [CloudKit Web Services documentation](https://developer.apple.com/documentation/cloudkitwebservices) diff --git a/Sources/MistKit/Documentation.docc/FieldTypePolymorphism.md b/Sources/MistKit/Documentation.docc/FieldTypePolymorphism.md new file mode 100644 index 00000000..3d608e09 --- /dev/null +++ b/Sources/MistKit/Documentation.docc/FieldTypePolymorphism.md @@ -0,0 +1,240 @@ +# Field Type Polymorphism + +How MistKit maps CloudKit's nine dynamically-typed field values onto one Swift enum, and why the wire format makes that harder than it looks. + +## Overview + +A CloudKit field value is a JSON object with a `value` and an optional `type`: + +```json +{ "value": 1747999812347, "type": "TIMESTAMP" } +``` + +`value` can be a string, a number, or one of four object shapes, and `type` is one of nine tags — but `type` is optional, three of the scalar types are structurally identical to another scalar, and the tags CloudKit accepts on a write are not the tags it returns on a read. MistKit models this with three layers: + +| Layer | Type | Where | +| --- | --- | --- | +| Domain | ``FieldValue`` — the only type callers see | `Models/FieldValues/FieldValue.swift` | +| Request | `Components.Schemas.FieldValueRequest` — generated | `Sources/MistKitOpenAPI/Types.swift` | +| Response | `Components.Schemas.FieldValueResponse` — generated | `Sources/MistKitOpenAPI/Types.swift` | + +The generated types are described in ; how to read and write fields day to day is in . This article explains the conversion between the layers and the rules it enforces. + +## The domain enum + +```swift +public enum FieldValue: Codable, Equatable, Sendable { + case string(String) + case int64(Int) + case double(Double) + case bytes(Data) // Binary data; base64-encoded on the wire + case date(Date) // Date/time value + case location(Location) + case reference(Reference) + case asset(Asset) + case list([FieldValue]) +} +``` + +``Location``, ``Reference``, and ``Asset`` are MistKit's own value types, so the package has no dependency on Core Location or the CloudKit framework. There is no boolean case: CloudKit stores booleans as `INT64` `0`/`1`, and ``FieldValue/init(booleanValue:)`` / ``FieldValue/boolValue`` bridge that convention. + +## Request and response are different schemas + +`openapi.yaml` deliberately declares two schemas rather than one: + +```yaml +FieldValueRequest: + properties: + value: + oneOf: [StringValue, Int64Value, DoubleValue, BytesValue, + DateValue, LocationValue, ReferenceValue, AssetValue, ListValue] + type: + enum: [STRING, INT64, DOUBLE, BYTES, TIMESTAMP, REFERENCE, ASSET, ASSETID, + LOCATION, STRING_LIST, INT64_LIST, DOUBLE_LIST, BYTES_LIST, + TIMESTAMP_LIST, REFERENCE_LIST, LOCATION_LIST, ASSET_LIST] # 17 values + +FieldValueResponse: + properties: + value: + oneOf: [StringValue, Int64Value, DoubleValue, BytesValue, + DateValue, LocationValue, ReferenceValue, AssetValue, ListValue] + type: + enum: [STRING, INT64, DOUBLE, BYTES, REFERENCE, ASSET, ASSETID, + LOCATION, TIMESTAMP, LIST] # 10 values +``` + +| | Request | Response | +| --- | --- | --- | +| `type` values | 17 — scalars, complex, and eight `*_LIST` element tags | 10 — scalars, complex, and a single `LIST` | +| `type` required? | Optional on the wire, **mandatory** for `TIMESTAMP`, `BYTES`, `DOUBLE` | Optional — usually present, and the only way to recover ambiguous scalars | + +Two generated types mean the compiler refuses to put a response value into a request slot. The conversions live in `OpenAPI/Components/Components.Schemas.FieldValueRequest.swift` (domain → request) and `Models/FieldValues/FieldValue+Components.swift` plus `FieldValue+Components+Scalar.swift` (response → domain). + +## Writes: three scalars must be tagged + +CloudKit infers a field's type from the JSON shape of `value`, so most values are sent without `type`. Three scalar types cannot be inferred, because their JSON is indistinguishable from another type: + +| Domain case | Wire form | Inferred as, if untagged | +| --- | --- | --- | +| `.date` (`TIMESTAMP`) | millisecond number | `INT64` / `DOUBLE` | +| `.bytes` (`BYTES`) | base64 string | `STRING` | +| `.double` (`DOUBLE`) | whole-valued number (`3.0` serializes as `3`) | `INT64` | + +Untagged, CloudKit infers the wrong type and rejects the write with `BAD_REQUEST "Invalid value, expected type TIMESTAMP"`. The request conversion is a single `default`-free switch that tags exactly these three: + +```swift +internal init(from fieldValue: FieldValue) { + switch fieldValue { + case .string(let value): + self.init(value: .StringValue(value)) + case .int64(let value): + self.init(value: .Int64Value(Int64(value))) + case .double(let value): + // Whole-valued doubles serialize without a fraction and would be read as INT64. + self.init(value: .DoubleValue(value), _type: .DOUBLE) + case .bytes(let value): + // A base64 string is otherwise indistinguishable from a STRING. + self.init(value: .BytesValue(value.base64EncodedString()), _type: .BYTES) + case .date(let value): + // Tag TIMESTAMP (else inferred as INT64/DOUBLE) and round to whole milliseconds: + // CloudKit rejects a fractional TIMESTAMP value (e.g. 1747999812347.89) with + // BAD_REQUEST "expected type TIMESTAMP", and Date carries sub-millisecond precision. + self.init( + value: .DateValue((value.timeIntervalSince1970 * 1_000).rounded()), + _type: .TIMESTAMP + ) + case .location(let location): + self.init(location: location) + case .reference(let reference): + self.init(reference: reference) + case .asset(let asset): + self.init(asset: asset) + case .list(let list): + self.init(list: list) + } +} +``` + +Object- and array-shaped values (`REFERENCE`, `ASSET`, `LOCATION`, `LIST`) and `STRING`/`INT64` are unambiguous and stay untagged. Because the switch has no `default`, a tenth `FieldValue` case is a compile error here rather than a silent fallback. + +### Timestamps are whole milliseconds + +CloudKit rejects a fractional `TIMESTAMP`, and Swift's `Date` carries sub-millisecond precision, so `.date` values are rounded on the way out. The same rule applies to the nested `Location.timestamp`, which has no `type` tag of its own to disambiguate it. Sub-millisecond precision is therefore destroyed on every write: a `Date` in is a different `Date` out. The list path (`ListValuePayload`) applies the same rounding to `[Date]` elements. + +### IN / NOT_IN filters use the list tags + +The eight `*_LIST` request tags exist for one caller: ``QueryFilter`` `IN`/`NOT_IN` comparators. Without an element-type tag CloudKit rejects every `.in()` query with `HTTP 400 BadRequestException: Unexpected input`, at any array size. The tag is derived from the first element, so a heterogeneous list is tagged by element zero and an empty list is sent untagged. + +## Reads: recovering the type from an undiscriminated oneOf + +The response `value` is an undiscriminated `oneOf`. swift-openapi-generator decodes such a union by trying each case in declaration order and keeping the first that succeeds: + +``` +String → Int64 → Double → Bytes → Date → Location → Reference → Asset → List +``` + +So a whole-millisecond `TIMESTAMP` decodes as `Int64Value`, a fractional one as `DoubleValue`, and a base64 `BYTES` string as `StringValue`. The decoded case alone cannot tell you what CloudKit meant; the sibling `type` tag can. The response conversion therefore runs three stages in order: + +``` +FieldValueResponse (value, type) + │ + ├─ 1. makeTypedComplex — complex/list tag? check the value's shape matches + ├─ 2. makeSimpleFieldValue + │ ├─ makeTypedScalar — explicit scalar tag wins over the decoded case + │ └─ makeInferredScalar — no tag: first-match inference (lossy) + ├─ 3. makeComplexFieldValue — untagged object/array shapes + └─ otherwise: ConversionError.unmappableFieldValue +``` + +### Explicit scalar tags win + +```swift +internal static func makeSimpleFieldValue( + from value: Components.Schemas.FieldValueResponse.valuePayload, + type fieldType: Components.Schemas.FieldValueResponse._typePayload?, + fieldName: String +) throws(ConversionError) -> FieldValue? { + if let typed = try makeTypedScalar(from: value, type: fieldType, fieldName: fieldName) { + return typed + } + return makeInferredScalar(from: value) +} +``` + +`makeTypedScalar` classifies the tag by the value category it demands and produces the typed value directly for the three ambiguous scalars: `TIMESTAMP` and `DOUBLE` from any numeric case, `BYTES` from any string case (decoded with `Data(base64Encoded:)`). `INT64` and `STRING` validate the category and then defer to inference, which already yields the right case and — for `INT64` — avoids truncating a fractional number. + +### Contradictions fail loudly + +A tag that contradicts the value's category — a numeric type over a string, a string type over a number — describes an internally inconsistent response. The conversion **throws** ``ConversionError/typeValueMismatch(fieldName:declaredType:value:)`` rather than coercing to whichever shape arrived. The same rule covers the complex tags: a declared `REFERENCE`, `ASSET`, `ASSETID`, `LOCATION`, or `LIST` whose decoded value is not the matching `oneOf` case throws instead of being silently reinterpreted. `ASSETID` shares `AssetValue` with `ASSET`; `LIST` is validated at the container level only — element types stay lenient. + +Untagged responses are unaffected: they resolve purely from the value's self-describing structure, so a well-formed response never starts failing because a tag was absent. Every ``ConversionError`` surfaces to callers as ``CloudKitError/conversionFailed(_:)``. + +### Inference is lossy by design + +When `type` is absent the conversion falls back to first-match inference, which cannot recover the ambiguous scalars: a base64 `BYTES` reads back as `.string`, a whole-number `TIMESTAMP` as `.int64`. MistKit does **not** guess `.bytes` from an untagged string that happens to be valid base64 — ordinary strings such as `"Chen"` decode as valid base64, so that guess would corrupt real data. ``FieldValue/dataValue`` matches `.bytes` only; there is no `.string` fallback. + +## Lists lose their element type + +The request enum has eight `*_LIST` tags; the response enum collapses every list to a single `LIST`, and `ListValuePayload` carries no per-element tag. A `[Date]` written as `TIMESTAMP_LIST` therefore reads back as `[.int64]`. The element type is information you must *send* and can never *read back*; MistKit re-infers elements structurally, with the same lossiness as untagged scalars. + +## Assets: one schema for both directions + +Unlike `FieldValue`, the `AssetValue` schema is not split. One all-optional struct is referenced from both request and response, and the domain ``Asset`` mirrors it: + +```swift +public struct Asset: Codable, Equatable, Sendable { + public let fileChecksum: String? + public let size: Int64? + public let referenceChecksum: String? + public let wrappingKey: String? + public let receipt: String? + public let downloadURL: String? +} +``` + +The payload is semantically asymmetric even though the type is not: + +| Field | Write (request) | Read (response) | +| --- | --- | --- | +| `receipt`, `wrappingKey`, `referenceChecksum` | Produced by the CDN upload step | Not returned | +| `downloadURL` | Ignored if sent | Where to fetch the bytes | +| `fileChecksum`, `size` | Optional metadata | Returned by CloudKit | + +The service layer contains the asymmetry instead of the type system: ``CloudKitService/uploadAssets(data:recordType:fieldName:recordName:zoneID:using:database:)`` returns an ``AssetUploadReceipt`` after the two-step upload, from which the write-side `Asset` is built, and reads construct an `Asset` from only what CloudKit returned. Splitting the schema would either break the nine-case symmetry of ``FieldValue`` or force a read/write distinction into the public API that nothing else needs. + +`fileChecksum` is an opaque, server-minted identity token — a version byte plus a 20-byte digest — not a SHA-256 of the plaintext. It cannot be recomputed client-side to verify a download; use ``Asset/size`` as a guard against truncation. + +## Summary + +``` +┌─────────────────────────────────────────────┐ +│ Public API: FieldValue (9-case enum) │ +└──────────────────┬──────────────────────────┘ + │ + ┌───────────┴───────────┐ + ▼ ▼ +┌──────────────────┐ ┌────────────────────┐ +│ FieldValueRequest│ │ FieldValueResponse │ +│ tag TIMESTAMP / │ │ honor `type` over │ +│ BYTES / DOUBLE; │ │ decoded case; throw│ +│ round to ms │ │ on contradiction │ +└──────────────────┘ └────────────────────┘ + │ │ + └───────────┬───────────┘ + ▼ + CloudKit Web Services (JSON) +``` + +## Topics + +### Domain types + +- ``FieldValue`` +- ``Location`` +- ``Reference`` +- ``Asset`` + +### Errors + +- ``ConversionError`` +- ``CloudKitError/conversionFailed(_:)`` diff --git a/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md b/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md index 62a00cec..5883290e 100644 --- a/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md +++ b/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md @@ -8,11 +8,11 @@ Running `./Scripts/generate-openapi.sh` emits two files: ``` Sources/MistKitOpenAPI/ -├── Client.swift (~3,600 lines) -└── Types.swift (~8,600 lines) +├── Client.swift (~5,000 lines) +└── Types.swift (~14,200 lines) ``` -Both are committed to the repository and module-`internal`. `CloudKitService` and the rest of the wrapper layer treat them as a typed JSON-over-HTTP transport — they don't show up in MistKit's public surface. For setup of the pipeline that produces these files, see . +Both are committed to the repository in their own target, `MistKitOpenAPI`, generated with `accessModifier: public` so the raw client is available as an escape hatch (`import MistKitOpenAPI`). The `MistKit` target does `internal import MistKitOpenAPI`, so `CloudKitService` and the rest of the wrapper treat the generated code as a typed JSON-over-HTTP transport and none of it leaks into MistKit's own public surface. For setup of the pipeline that produces these files, see . ## File headers @@ -37,7 +37,7 @@ Both files begin with: A `Sendable` protocol with one method per operation: ```swift -internal protocol APIProtocol: Sendable { +public protocol APIProtocol: Sendable { func queryRecords(_ input: Operations.queryRecords.Input) async throws -> Operations.queryRecords.Output func modifyRecords(_ input: Operations.modifyRecords.Input) async throws -> Operations.modifyRecords.Output func lookupRecords(_ input: Operations.lookupRecords.Input) async throws -> Operations.lookupRecords.Output @@ -74,10 +74,10 @@ Properties: The concrete implementation of `APIProtocol`: ```swift -internal struct Client: APIProtocol { +public struct Client: APIProtocol { private let client: UniversalClient - internal init( + public init( serverURL: URL, configuration: Configuration = .init(), transport: any ClientTransport, @@ -100,7 +100,7 @@ The constructor takes a transport (URLSession or custom) and an ordered middlewa Each operation pairs a serializer (typed `Input` → `HTTPRequest`) with a deserializer (`HTTPResponse` → typed `Output`). For `queryRecords`: ```swift -internal func queryRecords(_ input: Operations.queryRecords.Input) async throws -> Operations.queryRecords.Output { +public func queryRecords(_ input: Operations.queryRecords.Input) async throws -> Operations.queryRecords.Output { try await client.send( input: input, forOperation: Operations.queryRecords.id, @@ -158,7 +158,7 @@ Each branch of the response switch produces a distinct `Output` case. `.undocume ```swift extension APIProtocol { - internal func queryRecords( + public func queryRecords( path: Operations.queryRecords.Input.Path, headers: Operations.queryRecords.Input.Headers = .init(), body: Operations.queryRecords.Input.Body @@ -173,9 +173,9 @@ MistKit's wrapper layer doesn't lean on these — it builds full `Input` values ### Servers ```swift -internal enum Servers { - internal enum Server1 { - internal static func url() throws -> Foundation.URL { +public enum Servers { + public enum Server1 { + public static func url() throws -> Foundation.URL { try Foundation.URL( validatingOpenAPIServerURL: "https://api.apple-cloudkit.com", variables: [] @@ -196,11 +196,11 @@ Two top-level namespaces: `Components` (reusable schemas) and `Operations` (per- Every `#/components/schemas/...` entry becomes a `struct` with `Codable, Hashable, Sendable`: ```swift -internal struct ZoneID: Codable, Hashable, Sendable { - internal var zoneName: Swift.String? - internal var ownerName: Swift.String? - internal init(zoneName: Swift.String? = nil, ownerName: Swift.String? = nil) { … } - internal enum CodingKeys: String, CodingKey { case zoneName, ownerName } +public struct ZoneID: Codable, Hashable, Sendable { + public var zoneName: Swift.String? + public var ownerName: Swift.String? + public init(zoneName: Swift.String? = nil, ownerName: Swift.String? = nil) { … } + public enum CodingKeys: String, CodingKey { case zoneName, ownerName } } ``` @@ -215,7 +215,7 @@ Generated features per struct: String enums become Swift enums with the OpenAPI string as the raw value. CloudKit's filter comparators: ```swift -internal enum comparatorPayload: String, Codable, Hashable, Sendable, CaseIterable { +public enum comparatorPayload: String, Codable, Hashable, Sendable, CaseIterable { case EQUALS, NOT_EQUALS, LESS_THAN, LESS_THAN_OR_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUALS, NEAR, CONTAINS_ALL_TOKENS, IN, NOT_IN, CONTAINS_ANY_TOKENS, LIST_CONTAINS, NOT_LIST_CONTAINS, @@ -228,34 +228,36 @@ This eliminates string typos at call sites. ### Field values: request vs response -CloudKit's API is asymmetric — request bodies omit the `type` field, response bodies sometimes include it — so the OpenAPI schema models two separate types and the generator emits both: +CloudKit's API is asymmetric — a request must tag only the scalars whose JSON shape is ambiguous, while a response usually carries `type` and accepts a different set of tags — so the OpenAPI schema models two separate types and the generator emits both: -- `Components.Schemas.FieldValueRequest` — no `type` field. -- `Components.Schemas.FieldValueResponse` — optional `type` field. +- `Components.Schemas.FieldValueRequest` — optional `type` with a 17-value enum (scalars, complex types, and eight `*_LIST` element tags). MistKit tags `TIMESTAMP`, `BYTES`, and `DOUBLE` on every write; untagged, CloudKit infers the wrong type and rejects them. +- `Components.Schemas.FieldValueResponse` — optional `type` with a 10-value enum (a single `LIST`). - `Components.Schemas.RecordRequest` — fields keyed to `FieldValueRequest`. - `Components.Schemas.RecordResponse` — fields keyed to `FieldValueResponse`. The compiler refuses to put a response value in a request. MistKit's wrapper hides the split behind a single domain ``FieldValue`` enum and converts at the boundary: - Outgoing: `Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift` converts ``FieldValue`` → `FieldValueRequest`. -- Incoming: `Sources/MistKit/Models/FieldValues/FieldValue+Components.swift` converts `FieldValueResponse` → ``FieldValue``. +- Incoming: `Sources/MistKit/Models/FieldValues/FieldValue+Components.swift` (and `+Scalar.swift`) converts `FieldValueResponse` → ``FieldValue``. + +The tagging rules, the undiscriminated `oneOf` decoding order, and the list asymmetry are explained in . ### Error responses CloudKit's HTTP error responses share one body schema regardless of status code. The OpenAPI spec models this as a single unified `Failure` response: ```swift -internal struct Failure: Codable, Hashable, Sendable { - internal var uuid: Swift.String? - internal enum serverErrorCodePayload: String, Codable, Hashable, Sendable { +public struct Failure: Codable, Hashable, Sendable { + public var uuid: Swift.String? + public enum serverErrorCodePayload: String, Codable, Hashable, Sendable { case ACCESS_DENIED, ATOMIC_ERROR, AUTHENTICATION_FAILED, AUTHENTICATION_REQUIRED, BAD_REQUEST, CONFLICT, EXISTS, INTERNAL_ERROR, NOT_FOUND, QUOTA_EXCEEDED, THROTTLED, TRY_AGAIN_LATER, VALIDATING_REFERENCE_ERROR, ZONE_NOT_FOUND } - internal var serverErrorCode: serverErrorCodePayload? - internal var reason: Swift.String? - internal var redirectURL: Swift.String? + public var serverErrorCode: serverErrorCodePayload? + public var reason: Swift.String? + public var redirectURL: Swift.String? } ``` @@ -264,16 +266,16 @@ The wrapper's `CloudKitResponseType` protocol (`Sources/MistKit/OpenAPI/CloudKit ### Parameters ```swift -internal enum Parameters { - internal typealias version = Swift.String - internal typealias container = Swift.String +public enum Parameters { + public typealias version = Swift.String + public typealias container = Swift.String - internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { + public enum environment: String, Codable, Hashable, Sendable, CaseIterable { case development case production } - internal enum database: String, Codable, Hashable, Sendable, CaseIterable { + public enum database: String, Codable, Hashable, Sendable, CaseIterable { case _public = "public" // `public` is a keyword — generator prefixes with `_` case _private = "private" case shared @@ -288,37 +290,37 @@ MistKit's public ``Database`` enum is *not* the same type. The wrapper's ``Datab Each operation has an `Input` / `Output` tree: ```swift -internal enum Operations { - internal enum queryRecords { - internal static let id: Swift.String = "queryRecords" - - internal struct Input: Sendable, Hashable { - internal struct Path: Sendable, Hashable { - internal var version: Components.Parameters.version - internal var container: Components.Parameters.container - internal var environment: Components.Parameters.environment - internal var database: Components.Parameters.database +public enum Operations { + public enum queryRecords { + public static let id: Swift.String = "queryRecords" + + public struct Input: Sendable, Hashable { + public struct Path: Sendable, Hashable { + public var version: Components.Parameters.version + public var container: Components.Parameters.container + public var environment: Components.Parameters.environment + public var database: Components.Parameters.database } - internal struct Headers: Sendable, Hashable { - internal var accept: [OpenAPIRuntime.AcceptHeaderContentType< + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType< Operations.queryRecords.AcceptableContentType >] } - internal enum Body: Sendable, Hashable { + public enum Body: Sendable, Hashable { case json(Components.Schemas.QueryRequest) } - internal var path: Path - internal var headers: Headers - internal var body: Body + public var path: Path + public var headers: Headers + public var body: Body } - internal enum Output: Sendable, Hashable { - internal struct Ok: Sendable, Hashable { - internal enum Body: Sendable, Hashable { + public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + public enum Body: Sendable, Hashable { case json(Components.Schemas.QueryResponse) - internal var json: Components.Schemas.QueryResponse { get throws { … } } + public var json: Components.Schemas.QueryResponse { get throws { … } } } - internal var body: Body + public var body: Body } case ok(Ok) case badRequest(Components.Responses.BadRequest) @@ -391,15 +393,26 @@ internal struct AuthenticationMiddleware: ClientMiddleware { guard let authenticator = try await tokenManager.currentAuthenticator() else { throw TokenManagerError.invalidCredentials(.noCredentialsAvailable) } + var modifiedRequest = request var modifiedBody = body try await authenticator.authenticate(request: &modifiedRequest, body: &modifiedBody) - return try await next(modifiedRequest, modifiedBody, baseURL) + let (response, responseBody) = try await next(modifiedRequest, modifiedBody, baseURL) + if let rotated = response.headerFields[.cloudKitWebAuthToken] { + do { + try await tokenManager.didReceiveRotatedWebAuthToken(rotated) + } catch { + let message = "Failed to consume rotated web auth token: \(error.localizedDescription)" + Logger(subsystem: .auth).warning("\(message)") + RotatedWebAuthTokenFailureReporter.assertionHandler(message) + } + } + return (response, responseBody) } } ``` -The middleware is intentionally tiny — adding a new authentication scheme means writing a new ``Authenticator``, not touching this file. Details in . +The middleware is intentionally tiny — adding a new authentication scheme means writing a new ``Authenticator``, not touching this file. The one thing it does on the way back is hand the rotated `X-Apple-CloudKit-Web-Auth-Token` to the token manager. Details in . ### 3. FieldValue lives outside the generated layer @@ -421,10 +434,11 @@ import struct Foundation.Date #endif ``` -The wrapper layer follows the same convention. WASI excludes `URLSessionTransport` entirely — non-WASI builds get URLSession-backed convenience initializers on ``CloudKitService``; WASI callers pass a `ClientTransport` explicitly to the generic initializer. +The wrapper layer follows the same convention. WASI excludes `URLSessionTransport` entirely — the URLSession-backed public initializers on ``CloudKitService`` are compiled only for non-WASI platforms, and the transport-accepting initializer is internal today, so WASI has no public entry point yet (see ). ## See Also +- - - - diff --git a/Sources/MistKit/Documentation.docc/GeneratedCodeWorkflow.md b/Sources/MistKit/Documentation.docc/GeneratedCodeWorkflow.md index 75d5079e..83330e87 100644 --- a/Sources/MistKit/Documentation.docc/GeneratedCodeWorkflow.md +++ b/Sources/MistKit/Documentation.docc/GeneratedCodeWorkflow.md @@ -183,30 +183,31 @@ unaffected. ## CI verification -A typical CI job to verify generated code is up to date: +`.github/workflows/check-generated-openapi.yml` runs on every push to `main` and every pull request. It regenerates inside a `swift:latest` container — using the generator fallback built from `Scripts/OpenAPITools`, so no mise is needed on the runner — and fails if the committed output differs: ```yaml -- name: Setup tools - run: | - curl https://mise.run | sh - eval "$(~/.local/bin/mise activate bash)" - mise install - -- name: Regenerate - run: ./Scripts/generate-openapi.sh - -- name: Fail if generated code drifts from spec - run: | - if ! git diff --exit-code Sources/MistKitOpenAPI/; then - echo "::error::Generated code is out of date. Run ./Scripts/generate-openapi.sh and commit." - exit 1 - fi - -- name: Build + test - run: swift build && swift test +jobs: + regenerate-and-diff: + name: Regenerate Sources/MistKitOpenAPI and diff + runs-on: ubuntu-latest + container: + image: swift:latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Regenerate OpenAPI code + run: ./Scripts/generate-openapi.sh + + - name: Fail if the committed output does not match + run: | + if ! git diff --exit-code Sources/MistKitOpenAPI/; then + echo "::error::Sources/MistKitOpenAPI/ does not match the output of ./Scripts/generate-openapi.sh." + exit 1 + fi ``` -This catches the "edited `openapi.yaml`, forgot to commit the regenerated files" mistake before it lands. +This catches the "edited `openapi.yaml`, forgot to commit the regenerated files" mistake — and any hand edit to generated code — before it lands. ## Troubleshooting diff --git a/Sources/MistKit/Documentation.docc/HandlingErrors.md b/Sources/MistKit/Documentation.docc/HandlingErrors.md index d0396665..0c9cafac 100644 --- a/Sources/MistKit/Documentation.docc/HandlingErrors.md +++ b/Sources/MistKit/Documentation.docc/HandlingErrors.md @@ -188,7 +188,7 @@ MistKit infers the duplicate from that reason string and surfaces it through two - ``OperationFailure/isLikelyDuplicate`` — opt-in `Bool` on a ``SubscriptionOperationFailure``, an exact-match on the marker reason. - ``CloudKitError/subscriptionLikelyDuplicate(_:)`` — thrown from the single-subscription ``CloudKitService/createSubscription(_:database:)`` convenience when the hint matches. -Both are intentionally hedged (`isLikely…`) because the wire-level code is just `INTERNAL_ERROR` — the duplicate interpretation is MistKit's inference from the reason string, not a confirmed server signal. Batch ``CloudKitService/modifySubscriptions(_:database:)`` is unchanged: per-subscription failures still flow through ``SubscriptionResult/failure(_:)`` with the raw `serverErrorCode` and reason intact, so callers that want the unhedged result can inspect them directly. +Both are intentionally hedged (`isLikely…`) because the wire-level code is just `INTERNAL_ERROR` — the duplicate interpretation is MistKit's inference from the reason string, not a confirmed server signal. Batch ``CloudKitService/modifySubscriptions(_:database:)`` is unchanged: per-subscription failures still flow through ``OperationResult/failure(_:)`` with the raw `serverErrorCode` and reason intact, so callers that want the unhedged result can inspect them directly. ```swift do { @@ -203,6 +203,81 @@ do { > Note: Empirical probing (2026-05-25, public DB) confirmed the uniqueness key is `(recordType, firesOn)` with **exact-set** match on `firesOn` (a superset or disjoint fire-event set does *not* collide), and that re-creating with the *same* `subscriptionID` succeeds idempotently rather than erroring. CloudKit is silent on which subscription collided; reconcile client-side by `(recordType, firesOn)`, not by ID. +## Under the hood: from HTTP response to CloudKitError + +CloudKit returns every failure as JSON with the same shape, whatever the status code: + +```json +{ + "uuid": "a1b2c3d4-...", + "serverErrorCode": "AUTHENTICATION_FAILED", + "reason": "The request requires authentication." +} +``` + +`openapi.yaml` models that as one `Failure` response, and swift-openapi-generator turns each operation's status codes into an `Output` enum with a case per status plus `.undocumented`. Turning that into a ``CloudKitError`` is a short, fully typed pipeline: + +``` +Operations.queryRecords.Output (generated: .ok / .badRequest / … / .undocumented) + │ + ▼ +CloudKitResponseType.toCloudKitError() one exhaustive switch per operation + │ (.ok → nil; each failure case → statusCode) + ▼ +CloudKitError(failure, statusCode:) reads serverErrorCode + reason from the JSON body + │ + ▼ +CloudKitError(serverErrorCode:statusCode:reason:) + ├── nil code → .httpErrorWithDetails(statusCode:reason:) + ├── known code → dedicated case, e.g. .badRequest(reason:) + └── unknown code → .unknownServerError(code:statusCode:reason:) +``` + +Each generated `Output` conforms to an internal `CloudKitResponseType` protocol with a single requirement, `toCloudKitError()`, implemented as an exhaustive switch — so a status code added to `openapi.yaml` becomes a build error rather than a silently dropped case: + +```swift +extension Operations.queryRecords.Output: CloudKitResponseType { + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + // … one line per documented status … + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} +``` + +The status-keyed initializer reads the body and delegates the code-to-case mapping to a single dictionary-backed lookup in ``CloudKitServerErrorCode``, so the wire strings and their documented HTTP statuses live in one place: + +```swift +internal init(serverErrorCode code: String?, statusCode: Int, reason: String?) { + guard let code else { + self = .httpErrorWithDetails(statusCode: statusCode, reason: reason) + return + } + self = Self.make( + from: CloudKitServerErrorCode(rawValue: code), + statusCode: statusCode, + reason: reason + ) +} +``` + +An undocumented status logs the full response at `.debug` (it may echo request data such as email addresses) and a sanitized line at `.warning`, then becomes ``CloudKitError/httpError(statusCode:)``. Every ``CloudKitService`` operation checks for an error first and only then unwraps the success payload, so a caller never sees a generated response type. + +End to end: + +``` +HTTP 400 {"serverErrorCode": "BAD_REQUEST", "reason": "Invalid filter"} + → Operations.queryRecords.Output.badRequest(…) + → toCloudKitError() → CloudKitError(failure, statusCode: 400) + → CloudKitError(serverErrorCode: "BAD_REQUEST", statusCode: 400, reason: "Invalid filter") + → CloudKitError.badRequest(reason: "Invalid filter") +``` + ## Retry and recovery Recoverable failures fall into three buckets: @@ -264,5 +339,12 @@ func isTransient(_ error: CloudKitError) -> Bool { ### Per-operation failures +- ``OperationResult`` - ``SubscriptionOperationFailure`` - ``SubscriptionResult`` +- ``CloudKitServerErrorCode`` + +## See Also + +- +- diff --git a/Sources/MistKit/Documentation.docc/OpenAPICodeGeneration.md b/Sources/MistKit/Documentation.docc/OpenAPICodeGeneration.md index 08e2b7bf..ce1a2858 100644 --- a/Sources/MistKit/Documentation.docc/OpenAPICodeGeneration.md +++ b/Sources/MistKit/Documentation.docc/OpenAPICodeGeneration.md @@ -27,8 +27,8 @@ openapi.yaml ▼ swift-openapi-generator (provisioned by mise) │ - ├── Sources/MistKitOpenAPI/Client.swift (~3,600 lines, committed) - └── Sources/MistKitOpenAPI/Types.swift (~8,600 lines, committed) + ├── Sources/MistKitOpenAPI/Client.swift (~5,000 lines, committed) + └── Sources/MistKitOpenAPI/Types.swift (~14,200 lines, committed) │ ▼ Hand-written wrapper (Sources/MistKit/, committed) @@ -59,7 +59,7 @@ mise exec -- swiftlint --fix mise exec -- swift-openapi-generator --version ``` -`./Scripts/generate-openapi.sh` puts mise's `$PATH` shims in front of the user's shell, then calls `swift-openapi-generator generate` directly. There is no Mintfile; references in older documentation to `mint`/`Mintfile` are out of date. +`./Scripts/generate-openapi.sh` puts mise's `$PATH` shims in front of the user's shell and calls `swift-openapi-generator generate`. When the generator is not on `$PATH` (CI containers, remote sessions) it falls back to `swift run --package-path Scripts/OpenAPITools swift-openapi-generator`, a tiny package that pins the same generator version, so regeneration works without mise. There is no Mintfile; references in older documentation to `mint`/`Mintfile` are out of date. ## Generation script @@ -73,16 +73,23 @@ SCRIPT_DIR=$(dirname "$(readlink -f "$0")") PACKAGE_DIR="${SCRIPT_DIR}/.." # Put mise-managed tools on PATH -if command -v mise >/dev/null 2>&1; then +if command -v mise >/dev/null 2>&1 && [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then eval "$(mise -C "$PACKAGE_DIR" env -s bash)" fi -pushd $PACKAGE_DIR +pushd "$PACKAGE_DIR" || exit -swift-openapi-generator generate \ - --output-directory Sources/MistKit/Generated \ - --config openapi-generator-config.yaml \ - openapi.yaml +if command -v swift-openapi-generator >/dev/null 2>&1; then + GENERATOR=(swift-openapi-generator) +else + echo "ℹ️ swift-openapi-generator not on PATH; building it from Scripts/OpenAPITools." + GENERATOR=(swift run --package-path Scripts/OpenAPITools swift-openapi-generator) +fi + +"${GENERATOR[@]}" generate \ + --output-directory Sources/MistKitOpenAPI \ + --config openapi-generator-config.yaml \ + openapi.yaml popd @@ -103,7 +110,7 @@ Run it whenever `openapi.yaml` or `openapi-generator-config.yaml` changes: generate: - types - client -accessModifier: internal +accessModifier: public additionalFileComments: - periphery:ignore:all - swift-format-ignore-file @@ -119,32 +126,20 @@ There is intentionally no `typeOverrides` block. The asymmetry between request a ## Package.swift integration -Generated code is referenced as ordinary source files in the `MistKit` target. The generator is **not** used as a SwiftPM build plugin. Library consumers don't need mise or `swift-openapi-generator`; they just compile the committed sources. +Generated code lives in its own target and product, `MistKitOpenAPI`, which the `MistKit` target depends on with `internal import MistKitOpenAPI`. Consumers who need the raw client can `import MistKitOpenAPI` directly. The generator is **not** used as a SwiftPM build plugin. Library consumers don't need mise or `swift-openapi-generator`; they just compile the committed sources. The runtime dependencies pulled in by the generated client: ```swift .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.8.0"), -.package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.1.0"), +.package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.2.0"), ``` -Plus the standard MistKit dependencies: `swift-http-types`, `swift-crypto`, `swift-log`, `swift-async-algorithms`. +Plus MistKit's other dependencies: `swift-crypto` (server-to-server signing) and `swift-log`. `HTTPTypes` arrives transitively through `swift-openapi-runtime`. ## Swift language settings -MistKit declares `swift-tools-version: 6.1` and enables the Swift 6.2 upcoming features that are stable for production use: - -```swift -let swiftSettings: [SwiftSetting] = [ - .enableUpcomingFeature("ExistentialAny"), // SE-0335 - .enableUpcomingFeature("InternalImportsByDefault"), // SE-0409 - .enableUpcomingFeature("MemberImportVisibility"), // SE-0444 (Swift 6.1+) - .enableUpcomingFeature("FullTypedThrows"), // SE-0413 - // … plus experimental features stable enough for production use -] -``` - -`InternalImportsByDefault` is the reason every import in MistKit has an explicit access modifier (`internal import Foundation`, `public import OpenAPIRuntime`, …). Generated code is compiled with the same settings. +MistKit declares `swift-tools-version: 6.1`. The `MistKit` target compiles with the Swift 6 language mode plus the upcoming features that `Package.swift` enables in its shared `swiftSettings` — including `InternalImportsByDefault`, which is why every import in the hand-written sources carries an explicit access modifier (`internal import Foundation`, `public import HTTPTypes`, …). The generated `MistKitOpenAPI` target is compiled without those settings so the generator's output never has to be post-processed; its single bare `import HTTPTypes` in `Client.swift` is the documented exception to the import convention. ## Request/response asymmetry @@ -176,8 +171,8 @@ The compiler refuses to slot a response value into a request, and vice versa. Co ``` Sources/MistKitOpenAPI/ -├── Client.swift (~3,600 lines, committed) -└── Types.swift (~8,600 lines, committed) +├── Client.swift (~5,000 lines, committed) +└── Types.swift (~14,200 lines, committed) ``` Both files lead with: @@ -213,6 +208,8 @@ Never edit anything under `Sources/MistKitOpenAPI/` by hand — change `openapi. ## See Also +- + - - - diff --git a/Sources/MistKit/Documentation.docc/RequestSigning.md b/Sources/MistKit/Documentation.docc/RequestSigning.md new file mode 100644 index 00000000..a55865c7 --- /dev/null +++ b/Sources/MistKit/Documentation.docc/RequestSigning.md @@ -0,0 +1,355 @@ +# Request Signing + +How every outgoing request acquires its credentials — the token manager, the authenticator, the middleware between them, and the ECDSA signature for server-to-server calls. + +## Overview + +MistKit signs requests with an HTTP middleware rather than with the generator's built-in security schemes. The middleware knows nothing about CloudKit's three authentication methods; it asks a ``TokenManager`` for the current ``Authenticator`` and lets that value mutate the request. Adding a scheme means adding an authenticator, not touching the middleware. + +Which scheme is legal for which database, and how a call site picks one, is covered in . This article is about the mechanics underneath. + +## TokenManager + +A ``TokenManager`` owns the credential lifecycle — loading, validating, rotating, persisting — and vends an ``Authenticator`` to whoever needs to apply those credentials to a request: + +```swift +public protocol TokenManager: Sendable { + var hasCredentials: Bool { get async } + func validateCredentials() async throws(TokenManagerError) -> Bool + func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? + func didReceiveRotatedWebAuthToken(_ token: String) async throws(TokenManagerError) +} +``` + +Four managers ship in the box: ``APITokenManager``, ``WebAuthTokenManager``, ``ServerToServerAuthManager``, and the runtime-upgradable ``AdaptiveTokenManager``. When you construct ``CloudKitService`` with a ``Credentials`` value you never name them — the per-call resolution in `Credentials+TokenManager.swift` picks one from the target ``Database`` and ``PublicAuthPreference``. + +`didReceiveRotatedWebAuthToken(_:)` has a no-op default so managers that never hold a web auth token compile unchanged. + +## Authenticator + +Each concrete ``Authenticator`` owns both the credential payload and the rule for attaching it to a request: + +```swift +public protocol Authenticator: Sendable { + static var storageKey: String { get } + var defaultStorageIdentifier: String { get } + init(decoding data: Data) throws + func authenticate(request: inout HTTPRequest, body: inout HTTPBody?) async throws + func encoded() throws -> Data +} +``` + +The `init(decoding:)` / `encoded()` pair is the on-disk format used by ``TokenStorage``. `Authenticator` deliberately does not inherit `Codable` or `Equatable`: either would impose a `Self` requirement and prevent its use as `any Authenticator`. + +### Why `body` is `inout` + +`HTTPBody` is a single-pass async sequence. ``ServerToServerAuthenticator`` has to read every byte to compute the SHA-256 over the body, which consumes the iterator. It buffers the bytes and reassigns `body = HTTPBody(bytes)` so downstream middleware and the transport see a fresh, replayable copy of the same data. The `inout` parameter exists for that reassignment; the other two authenticators never touch `body`. + +## The middleware + +`AuthenticationMiddleware` conforms to OpenAPIRuntime's `ClientMiddleware` and intercepts every outgoing request: + +```swift +internal struct AuthenticationMiddleware: ClientMiddleware { + internal let tokenManager: any TokenManager + + internal func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String, + next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + guard let authenticator = try await tokenManager.currentAuthenticator() else { + throw TokenManagerError.invalidCredentials(.noCredentialsAvailable) + } + + var modifiedRequest = request + var modifiedBody = body + try await authenticator.authenticate(request: &modifiedRequest, body: &modifiedBody) + let (response, responseBody) = try await next(modifiedRequest, modifiedBody, baseURL) + if let rotated = response.headerFields[.cloudKitWebAuthToken] { + do { + try await tokenManager.didReceiveRotatedWebAuthToken(rotated) + } catch { + let message = "Failed to consume rotated web auth token: \(error.localizedDescription)" + Logger(subsystem: .auth).warning("\(message)") + RotatedWebAuthTokenFailureReporter.assertionHandler(message) + } + } + return (response, responseBody) + } +} +``` + +The complete round trip, for any scheme: + +``` +App queryRecords(...) / createRecord(...) + │ + ▼ +OpenAPI Client ───▶ AuthenticationMiddleware.intercept(request, body, next) + │ + ├─▶ tokenManager.currentAuthenticator() + │ ├─ nil → throws TokenManagerError.invalidCredentials(.noCredentialsAvailable) + │ └─ some → authenticator + │ + ├─▶ authenticator.authenticate(&request, &body) ← scheme-specific (below) + │ + ├─▶ next(request, body, baseURL) ──▶ transport ──▶ api.apple-cloudkit.com + │ │ + │◀──────────────────────── (response, body) ◀────────────┘ + │ + └─▶ X-Apple-CloudKit-Web-Auth-Token present? + └─ yes → tokenManager.didReceiveRotatedWebAuthToken(token) + ▲ + └── decoded result +``` + +The per-scheme branching — one query item, two query items, or three signed headers — lives entirely inside each `authenticate(request:body:)`. + +## API token + +``APITokenAuthenticator`` appends one query item and returns. No body access, no async work: + +```swift +public func authenticate( + request: inout HTTPRequest, + body: inout HTTPBody? +) async throws { + request.appendQueryItems([URLQueryItem(name: "ckAPIToken", value: token)]) +} +``` + +```text +GET /database/1/iCloud.com.example/development/public/records/query?ckAPIToken=abc123... +``` + +The token is a 64-character hex string identifying the container; the initializer validates that shape. On its own it grants whatever the public schema's `_world` role allows. + +## Web auth token + +``WebAuthTokenAuthenticator`` URL-encodes the user token through `CharacterMapEncoder` and appends both items: + +``` +AuthenticationMiddleware ──▶ WebAuthTokenAuthenticator.authenticate(&request, &body) + │ + ├─▶ CharacterMapEncoder.encode(webAuthToken) + │ + → %2B + │ / → %2F + │ = → %3D + │ + └─▶ append ?ckAPIToken=<…>&ckWebAuthToken= +``` + +```swift +public func authenticate( + request: inout HTTPRequest, + body: inout HTTPBody? +) async throws { + let encoded = Self.encoder.encode(webAuthToken) + request.appendQueryItems([ + URLQueryItem(name: "ckAPIToken", value: apiToken), + URLQueryItem(name: "ckWebAuthToken", value: encoded), + ]) +} +``` + +CloudKit rejects a token containing raw `+`, `/`, or `=`, and those three characters are exactly what a base64-flavored token contains. The three-entry map is the whole encoder. + +This scheme grants access to the private and shared databases for the authenticated user, and is the only one accepted on the `/users/*` routes. + +### Rotation + +Every CloudKit response carries an `X-Apple-CloudKit-Web-Auth-Token` header with a fresh token, and Apple documents the previous token as invalid once the response is received. The middleware forwards the header to the manager; ``WebAuthTokenManager`` validates and stores it, and ``AdaptiveTokenManager`` additionally persists it to its ``TokenStorage``: + +```swift +public func didReceiveRotatedWebAuthToken(_ token: String) async throws(TokenManagerError) { + guard webAuthToken != nil else { + return + } + + let authenticator = try WebAuthTokenAuthenticator( + apiToken: apiToken, + webAuthToken: token + ) + self.webAuthToken = token + + if let storage = storage { + do { + try await storage.store(authenticator, identifier: apiToken) + } catch { + Logger(subsystem: .auth).warning( + "Failed to store credentials after token rotation: \(error.localizedDescription)" + ) + } + } +} +``` + +A custom ``TokenManager`` that holds a web auth token should override the method too, or it will keep sending the token from its first request. + +## Server to server + +``ServerToServerAuthenticator`` is used for backend services without a user. It buffers the body, signs, and appends headers: + +``` +AuthenticationMiddleware ──▶ ServerToServerAuthenticator.authenticate(&request, &body) + │ + ├─▶ Data(buffering: &body, upTo: bodyBufferLimit) + │ (consumes the stream, reassigns a replayable HTTPBody) + │ + ├─▶ RequestSignature(keyID, privateKey, requestBody, webServiceSubpath) + │ └─ signed header bundle + │ + └─▶ request.headerFields.append(contentsOf: signature.headers) +``` + +```swift +public func authenticate( + request: inout HTTPRequest, + body: inout HTTPBody? +) async throws { + let bodyData = try await Data(buffering: &body, upTo: bodyBufferLimit) + + let signature = try RequestSignature( + keyID: keyID, + privateKey: privateKey, + requestBody: bodyData, + webServiceSubpath: request.path + ) + + request.headerFields.append(contentsOf: signature.headers) +} +``` + +`bodyBufferLimit` defaults to ``ServerToServerAuthenticator/defaultBodyBufferLimit`` (1 MiB). A body larger than that fails the request rather than being signed over a truncated copy that would not match what the transport sends. + +### RequestSignature + +`RequestSignature` is an internal value type holding a signed header bundle: + +```swift +internal struct RequestSignature: Sendable { + internal let keyID: String + internal let iso8601DateString: String // exact string that was signed + internal let signatureDerRepresentation: Data // DER bytes + internal var signatureBase64: String { ... } // wire form, derived on demand + internal var headers: HTTPFields { ... } // typed headers, ready to append +} +``` + +It is a transport-format value, not a domain value, and two storage choices follow from that: + +- **The date is stored as a `String`, not a `Date`.** The ISO 8601 string is part of the signed payload. Re-formatting a `Date` on every header access risks a wire string that differs from what was signed (formatter options, fractional seconds), and every such mismatch is an indistinguishable `401`. Storing the string locks the wire form to the signed form. +- **The signature is stored as DER `Data`, not a base64 `String`.** The bytes are the natural form; base64 is computed on demand, and the struct stays free of the `@available` constraints that come with `P256.Signing.ECDSASignature`. + +### Signing process + +The convenience initializer `init(keyID:privateKey:requestBody:webServiceSubpath:date:)` does: + +1. **Format the ISO 8601 date.** `Date.ISO8601FormatStyle` on macOS 12 / iOS 15 / tvOS 15 / watchOS 8 and later; a shared `ISO8601DateFormatter` (documented thread-safe for `string(from:)`) on older systems. +2. **Hash the body.** `SHA256.cloudKitBodyHash(of:)` returns `base64(SHA256(body))`, or the **empty string** when the body is `nil` — not the hash of empty data. Both are defensible; only one is what CloudKit accepts. +3. **Build the payload:** `"::"`. +4. **Sign with P-256.** `privateKey.signature(for: Data(payload.utf8))` → DER bytes. +5. **Store** the key ID, the exact date string, and the DER bytes. + +The core initializer takes the pre-formatted strings directly, which is what deterministic tests use: + +```swift +internal init( + keyID: String, + privateKey: P256.Signing.PrivateKey, + bodyHash: String, + webServiceSubpath: String, + iso8601DateString: String +) throws { + let payload = "\(iso8601DateString):\(bodyHash):\(webServiceSubpath)" + let signature = try privateKey.signature(for: Data(payload.utf8)) + + self.init( + keyID: keyID, + iso8601DateString: iso8601DateString, + signatureDerRepresentation: signature.derRepresentation + ) +} +``` + +### Wire format + +```http +X-Apple-CloudKit-Request-KeyID: +X-Apple-CloudKit-Request-ISO8601Date: 2026-05-15T14:30:00Z +X-Apple-CloudKit-Request-SignatureV1: +``` + +There is no `Authorization` header. The `HTTPField.Name` constants for these three headers — and for the `X-Apple-CloudKit-Web-Auth-Token` response header — live in `Sources/MistKit/Authentication/HTTPField.Name+CloudKit.swift`. + +## AdaptiveTokenManager + +``AdaptiveTokenManager`` is an `actor` that starts with an API token and can be upgraded to web authentication at runtime — the shape a web app needs, where anonymous public reads precede a sign-in: + +```swift +public actor AdaptiveTokenManager: TokenManager { + internal let apiToken: String + internal var webAuthToken: String? + internal let storage: (any TokenStorage)? + + public func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { + if let webToken = webAuthToken { + return try WebAuthTokenAuthenticator(apiToken: apiToken, webAuthToken: webToken) + } + return try APITokenAuthenticator(token: apiToken) + } +} +``` + +``AdaptiveTokenManager/upgradeToWebAuthentication(webAuthToken:)`` validates the token by constructing a ``WebAuthTokenAuthenticator``, stores it, and persists to ``TokenStorage`` when one was supplied — a storage failure is logged, not thrown, so the upgrade itself never fails on persistence. A typical flow: + +1. App starts with the API token only and can read the public database. +2. The user completes CloudKit's web sign-in and the app receives a web auth token. +3. The app calls `upgradeToWebAuthentication(webAuthToken:)`. +4. Every subsequent request carries the user's token; the private database is reachable. + +The actor makes the state change safe under concurrent requests, and the optional storage lets the credentials survive relaunch. + +## Choosing a manager + +``` +Credentials / config + │ + ├── API token only ──────────────────▶ APITokenManager ──────────▶ APITokenAuthenticator ──▶ public (reads) + ├── API + web auth token ────────────▶ WebAuthTokenManager ──────▶ WebAuthTokenAuthenticator ──▶ private / shared / public (user) + ├── key ID + P-256 key ──────────────▶ ServerToServerAuthManager ▶ ServerToServerAuthenticator ──▶ public (service) + └── API token now, upgrade later ───▶ AdaptiveTokenManager ─┬──▶ APITokenAuthenticator (before upgrade) + └──▶ WebAuthTokenAuthenticator (after upgrade) +``` + +With ``Credentials`` this selection is automatic per call. Pass a manager to ``CloudKitService/init(containerIdentifier:tokenManager:environment:)`` only when you need the same manager for every call regardless of database — stub managers in tests, or a bespoke flow that refreshes credentials from a remote store. + +## Topics + +### Protocols + +- ``TokenManager`` +- ``Authenticator`` +- ``TokenStorage`` + +### Authenticators + +- ``APITokenAuthenticator`` +- ``WebAuthTokenAuthenticator`` +- ``ServerToServerAuthenticator`` + +### Token managers + +- ``APITokenManager`` +- ``WebAuthTokenManager`` +- ``ServerToServerAuthManager`` +- ``AdaptiveTokenManager`` + +### Errors + +- ``TokenManagerError`` +- ``TokenStorageError`` +- ``InvalidCredentialReason`` diff --git a/Sources/MistKit/Documentation.docc/Resources/talk-cloudkit-console-records.png b/Sources/MistKit/Documentation.docc/Resources/talk-cloudkit-console-records.png new file mode 100644 index 0000000000000000000000000000000000000000..af0b8bef5ab2cda887ad9934e0590db28e8093e3 GIT binary patch literal 362787 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 vfBLxWxAqKdw4rA8I^T)Gk7CkmOHr-mb*LqU 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

1wVLjxxWWxM(L1Uw~aU!c|u|$+~MA# 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