From 241c318ecae45dfd8155800b26a1085feb78b1f5 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 31 Aug 2026 09:37:19 -0400 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] [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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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) {