From 526501041928f4376708a56363df2e91f205e12b Mon Sep 17 00:00:00 2001 From: leogdion Date: Wed, 13 May 2026 09:40:12 -0400 Subject: [PATCH 1/8] Resolve #328: MistDemoApp CloudKit refresh (CKRecord-first, @Observable, public/private picker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename `NativeCloudKitService`/`Error` to `CloudKitStore`/`Error` — the app target no longer depends on MistKit, so the "Native" disambiguator is dead weight; "Store" reads as the SwiftUI source-of-truth idiom. - `Note` wraps `CKRecord` instead of copying fields out of it. Update is now "mutate the held record, save" — no extra fetch round-trip to refresh the change tag. - `@Observable` + `@MainActor` on `CloudKitStore`; views use `@Environment(CloudKitStore.self)` and `@Bindable` for the picker. App entry switches to `@State` + `.environment(_:)`. - Public/private database picker in `AccountView`; `QueryView` and `ZoneListView` re-fetch on `.onChange(of: store.databaseScope)` and show the active scope in their navigation title. - Drop web-auth-token UI (`AccountView+Actions.swift`, related state) and the `CLOUDKIT_API_TOKEN` scheme env var — the native app authenticates via the signed-in iCloud user. Co-Authored-By: Claude Opus 4.7 (1M context) --- Examples/MistDemo/App/MistDemoApp.swift | 6 +- .../Sources/MistDemoApp/Models/Note.swift | 46 +++--- ...udKitService.swift => CloudKitStore.swift} | 105 ++++++------- ...itError.swift => CloudKitStoreError.swift} | 9 +- .../Views/AccountView+Actions.swift | 78 ---------- .../MistDemoApp/Views/AccountView.swift | 141 ++---------------- .../MistDemoApp/Views/NoteEditView.swift | 2 +- .../Sources/MistDemoApp/Views/QueryView.swift | 12 +- .../MistDemoApp/Views/RecordDetailView.swift | 10 +- .../Sources/MistDemoApp/Views/RootView.swift | 2 +- .../MistDemoApp/Views/ZoneListView.swift | 8 +- Examples/MistDemo/project.yml | 10 -- 12 files changed, 113 insertions(+), 316 deletions(-) rename Examples/MistDemo/Sources/MistDemoApp/Services/{NativeCloudKitService.swift => CloudKitStore.swift} (62%) rename Examples/MistDemo/Sources/MistDemoApp/Services/{NativeCloudKitError.swift => CloudKitStoreError.swift} (84%) delete mode 100644 Examples/MistDemo/Sources/MistDemoApp/Views/AccountView+Actions.swift diff --git a/Examples/MistDemo/App/MistDemoApp.swift b/Examples/MistDemo/App/MistDemoApp.swift index c2a4e808..b4e97089 100644 --- a/Examples/MistDemo/App/MistDemoApp.swift +++ b/Examples/MistDemo/App/MistDemoApp.swift @@ -32,14 +32,14 @@ import SwiftUI @main internal struct MistDemoAppMain: App { - @StateObject private var service = NativeCloudKitService( - containerIdentifier: NativeCloudKitService.demoContainerIdentifier + @State private var service = CloudKitStore( + containerIdentifier: CloudKitStore.demoContainerIdentifier ) internal var body: some Scene { WindowGroup("MistDemo (Native CloudKit)") { RootView() - .environmentObject(service) + .environment(service) } #if os(macOS) .defaultSize(width: 880, height: 600) diff --git a/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift b/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift index 1d83c752..28d43d8e 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift @@ -39,9 +39,9 @@ /// "image" ASSET /// ); /// - /// Created / modified timestamps come from CloudKit's system metadata - /// (`CKRecord.creationDate` / `.modificationDate`), so there's no need - /// for custom `createdAt` / `modified` schema fields. + /// Wraps a `CKRecord` rather than copying fields out of it — the record is + /// the source of truth, so an update can mutate it in place and `save` it + /// without re-fetching to refresh the change tag. internal struct Note: Identifiable, Hashable { /// Known field name constants for `Note` records. internal enum Fields { @@ -53,33 +53,33 @@ /// CloudKit record type identifier. internal static let recordType = "Note" - internal let id: String - internal let title: String? - internal let index: Int64? - internal let imageAssetURL: URL? + internal let record: CKRecord - /// CloudKit-managed metadata - internal let modificationDate: Date? - internal let creationDate: Date? - internal let recordChangeTag: String? + internal var id: CKRecord.ID { record.recordID } + internal var recordName: String { record.recordID.recordName } + internal var title: String? { record[Fields.title] as? String } + internal var index: Int64? { (record[Fields.index] as? NSNumber)?.int64Value } + internal var imageAssetURL: URL? { (record[Fields.image] as? CKAsset)?.fileURL } + internal var creationDate: Date? { record.creationDate } + internal var modificationDate: Date? { record.modificationDate } + internal var recordChangeTag: String? { record.recordChangeTag } internal init?(_ record: CKRecord) { guard record.recordType == Self.recordType else { return nil } - self.id = record.recordID.recordName - self.title = record[Fields.title] as? String - self.index = (record[Fields.index] as? NSNumber)?.int64Value - self.imageAssetURL = (record[Fields.image] as? CKAsset)?.fileURL - self.modificationDate = record.modificationDate - self.creationDate = record.creationDate - self.recordChangeTag = record.recordChangeTag + self.record = record } - // Identity-based equality: two Notes with the same recordID are equal - // regardless of field state. Lets SwiftUI selection bindings track a - // record across edits without losing focus when fields change. - internal static func == (lhs: Note, rhs: Note) -> Bool { lhs.id == rhs.id } - internal func hash(into hasher: inout Hasher) { hasher.combine(id) } + // Identity-based equality so SwiftUI selection / NavigationLink paths + // remain stable across edits. RecordDetailView replaces its `@State` Note + // with a fresh wrapper after save, which is what drives the re-render — + // not Equatable comparison. + internal static func == (lhs: Note, rhs: Note) -> Bool { + lhs.record.recordID == rhs.record.recordID + } + internal func hash(into hasher: inout Hasher) { + hasher.combine(record.recordID) + } } #endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitService.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift similarity index 62% rename from Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitService.swift rename to Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift index 58209591..7c705c76 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitService.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift @@ -1,5 +1,5 @@ // -// NativeCloudKitService.swift +// CloudKitStore.swift // MistDemo // // Created by Leo Dion. @@ -29,27 +29,49 @@ #if canImport(CloudKit) && !os(tvOS) && !os(watchOS) import CloudKit - public import Combine import Foundation - - /// Thin wrapper around Apple's CloudKit framework that mirrors the read-side - /// operations the MistKit-driven MistDemo CLI exposes. The two demos hit the - /// same CloudKit container, so a presentation can flip between them and show - /// identical data accessed through different stacks. + public import Observation + + /// Observable source of truth for the MistDemo app's CloudKit state. + /// + /// Wraps `CKContainer`/`CKDatabase` directly. MistKit's REST surface is + /// reserved for server/Linux/WASI/Windows contexts where the CloudKit + /// framework isn't available. + @Observable @MainActor - public final class NativeCloudKitService: ObservableObject { + public final class CloudKitStore { + /// Public or private CloudKit database, selectable at runtime. + public enum DatabaseScope: String, CaseIterable, Identifiable, Sendable { + case `public` + case `private` + + public var id: String { rawValue } + + public var label: String { + switch self { + case .public: return "Public" + case .private: return "Private" + } + } + } + /// The shared demo container identifier — must match `MistDemoConfig.containerIdentifier`. public static let demoContainerIdentifier = "iCloud.com.brightdigit.MistDemo" - @Published internal var accountStatus: CKAccountStatus = .couldNotDetermine - @Published internal var lastError: String? + internal var accountStatus: CKAccountStatus = .couldNotDetermine + internal var lastError: String? + internal var databaseScope: DatabaseScope = .private internal let containerIdentifier: String - private let container: CKContainer + @ObservationIgnored private let container: CKContainer - /// Convenience: which database we want to demo against. The MistDemo CLI - /// defaults to `.private`, so mirror that here. - internal var database: CKDatabase { container.privateCloudDatabase } + /// The CloudKit database for the current `databaseScope`. + internal var database: CKDatabase { + switch databaseScope { + case .public: return container.publicCloudDatabase + case .private: return container.privateCloudDatabase + } + } /// Creates a new service for the given CloudKit container. /// - Parameter containerIdentifier: The CloudKit container identifier. @@ -81,14 +103,14 @@ } } - /// List all record zones in the private database (parity with `mistdemo lookup-zones`). + /// List all record zones in the selected database (parity with `mistdemo lookup-zones`). internal func loadZones() async throws -> [ZoneRow] { let zones = try await database.allRecordZones() return zones.map(ZoneRow.init).sorted { $0.zoneName < $1.zoneName } } - /// Query `Note` records from the demo container's private database, sorted - /// by `index` (parity with `mistdemo query --record-type Note --sort index`). + /// Query `Note` records from the selected database, sorted by `index` + /// (parity with `mistdemo query --record-type Note --sort index`). /// Note's schema is defined in `schema.ckdb`. internal func queryNotes(limit: Int = 50) async throws -> [Note] { let predicate = NSPredicate(value: true) @@ -129,63 +151,34 @@ // MARK: - Write operations (parity with `mistdemo create / update / delete`) - /// Create a new Note in the private database. + /// Create a new Note in the selected database. internal func createNote(title: String, index: Int64, imageURL: URL?) async throws -> Note { let record = CKRecord(recordType: Note.recordType) Self.apply(title: title, index: index, imageURL: imageURL, to: record) let saved = try await database.save(record) guard let note = Note(saved) else { - throw NativeCloudKitError.unexpectedSaveResult + throw CloudKitStoreError.unexpectedSaveResult } return note } - /// Update an existing Note. Fetches the current record (so the change tag - /// is fresh), mutates the fields, and saves. + /// Mutate the existing record in place and save. The record carries its + /// own change tag from the original query, so no extra fetch round-trip + /// is needed before save. internal func updateNote( _ existing: Note, title: String, index: Int64, imageURL: URL? ) async throws -> Note { - let recordID = CKRecord.ID(recordName: existing.id) - let record = try await database.record(for: recordID) - Self.apply(title: title, index: index, imageURL: imageURL, to: record) - let saved = try await database.save(record) + Self.apply(title: title, index: index, imageURL: imageURL, to: existing.record) + let saved = try await database.save(existing.record) guard let note = Note(saved) else { - throw NativeCloudKitError.unexpectedSaveResult + throw CloudKitStoreError.unexpectedSaveResult } return note } - /// Delete a Note by record name. + /// Delete a Note by record ID. internal func deleteNote(_ note: Note) async throws { - let recordID = CKRecord.ID(recordName: note.id) - _ = try await database.deleteRecord(withID: recordID) - } - - // MARK: - Web auth token (parity with `mistdemo auth-token`) - - /// Fetch a CloudKit web auth token (the `158__...` value that MistKit / - /// the MistDemo CLI consume). Demonstrates that a native app and a - /// REST-based MistKit consumer can share the same auth surface. - /// - /// `apiToken` is the public CloudKit API token from CloudKit Dashboard, - /// not the user's iCloud password. It must match the configured container. - internal func fetchWebAuthToken(apiToken: String) async throws -> String { - try await withCheckedThrowingContinuation { continuation in - let operation = CKFetchWebAuthTokenOperation(apiToken: apiToken) - operation.qualityOfService = .userInitiated - operation.fetchWebAuthTokenCompletionBlock = { token, error in - if let token { - continuation.resume(returning: token) - } else { - continuation.resume( - throwing: error ?? NativeCloudKitError.webAuthTokenUnavailable - ) - } - } - // CKFetchWebAuthTokenOperation is a CKDatabaseOperation; running - // it against the private database picks up the demo container. - database.add(operation) - } + _ = try await database.deleteRecord(withID: note.record.recordID) } } #endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitError.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift similarity index 84% rename from Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitError.swift rename to Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift index 2925516d..8e334fd6 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitError.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift @@ -1,5 +1,5 @@ // -// NativeCloudKitError.swift +// CloudKitStoreError.swift // MistDemo // // Created by Leo Dion. @@ -30,17 +30,14 @@ #if canImport(CloudKit) && !os(tvOS) && !os(watchOS) import Foundation - /// Errors specific to native CloudKit operations. - internal enum NativeCloudKitError: Error, LocalizedError { + /// Errors specific to `CloudKitStore` operations. + internal enum CloudKitStoreError: Error, LocalizedError { case unexpectedSaveResult - case webAuthTokenUnavailable internal var errorDescription: String? { switch self { case .unexpectedSaveResult: return "CloudKit returned a record that couldn't be parsed as a Note." - case .webAuthTokenUnavailable: - return "CloudKit returned no web auth token and no error." } } } diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView+Actions.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView+Actions.swift deleted file mode 100644 index fef8a934..00000000 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView+Actions.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// AccountView+Actions.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. -// - -#if canImport(SwiftUI) && canImport(CloudKit) && !os(tvOS) && !os(watchOS) - import CloudKit - import SwiftUI - - #if canImport(AppKit) - import AppKit - #elseif canImport(UIKit) - import UIKit - #endif - - extension AccountView { - internal func seedTokenIfNeeded() { - guard apiToken.isEmpty else { - return - } - if let envValue = ProcessInfo.processInfo.environment[Self.envVarName], - !envValue.isEmpty, - !envValue.hasPrefix("${") - { - apiToken = envValue - tokenSource = .environment - } - } - - internal func fetchToken() async { - fetchingWebAuthToken = true - webAuthTokenError = nil - webAuthToken = nil - defer { fetchingWebAuthToken = false } - do { - let token = try await service.fetchWebAuthToken( - apiToken: apiToken.trimmingCharacters(in: .whitespacesAndNewlines) - ) - webAuthToken = token - } catch { - webAuthTokenError = error.localizedDescription - } - } - - internal func copy(_ value: String) { - #if canImport(AppKit) - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(value, forType: .string) - #elseif canImport(UIKit) - UIPasteboard.general.string = value - #endif - } - } -#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift index a3f9e568..24d77399 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift @@ -31,34 +31,24 @@ import CloudKit import SwiftUI - #if canImport(AppKit) - import AppKit - #elseif canImport(UIKit) - import UIKit - #endif - - /// View for managing the iCloud account and web auth token. + /// View showing the iCloud account status and the public/private database + /// selector. The native app authenticates via the signed-in iCloud user, so + /// there's no token plumbing to surface. internal struct AccountView: View { - /// Where the current `apiToken` value came from on this launch. - internal enum TokenSource { - case manual - case environment - } - - /// Env var name the MistDemo CLI also reads. - internal static let envVarName = "CLOUDKIT_API_TOKEN" - - @EnvironmentObject internal var service: NativeCloudKitService - @AppStorage("MistDemoApp.cloudKitApiToken") internal var apiToken: String = "" - @State internal var webAuthToken: String? - @State internal var fetchingWebAuthToken = false - @State internal var webAuthTokenError: String? - @State internal var tokenSource: TokenSource = .manual + @Environment(CloudKitStore.self) private var service internal var body: some View { + @Bindable var bindable = service Form { - containerSection - webAuthTokenSection + Section("Container") { + LabeledContent("Container", value: service.containerIdentifier) + Picker("Database", selection: $bindable.databaseScope) { + ForEach(CloudKitStore.DatabaseScope.allCases) { scope in + Text(scope.label).tag(scope) + } + } + LabeledContent("iCloud Status", value: statusLabel) + } if let error = service.lastError { Section("Last Service Error") { Text(error).font(.callout).foregroundStyle(.red) @@ -74,17 +64,6 @@ } } } - .onAppear { seedTokenIfNeeded() } - } - - private var sourceCaption: String? { - switch tokenSource { - case .manual: - return nil - case .environment: - return - "Loaded from $\(Self.envVarName) (xcodegen baked it into the scheme from .env)." - } } private var statusLabel: String { @@ -97,97 +76,5 @@ @unknown default: return "Unknown" } } - - private var containerSection: some View { - Section("Container") { - LabeledContent("Container", value: service.containerIdentifier) - LabeledContent("Database", value: "Private") - LabeledContent("iCloud Status", value: statusLabel) - } - } - - private var webAuthTokenSection: some View { - Section { - tokenTextField - tokenActions - tokenDisplay - } header: { - Text("Web Auth Token") - } footer: { - Text( - "Issues the same `158__…` token that MistKit / " - + "`mistdemo auth-token` consume. " - + "Uses CKFetchWebAuthTokenOperation." - ) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - private var tokenTextField: some View { - Group { - TextField( - "CloudKit API Token", - text: $apiToken, - prompt: Text("Paste from CloudKit Dashboard") - ) - .textFieldStyle(.roundedBorder) - .font(.body.monospaced()) - .onChange(of: apiToken) { _, _ in tokenSource = .manual } - #if os(iOS) - .autocorrectionDisabled(true) - .textInputAutocapitalization(.never) - #endif - if let caption = sourceCaption { - Text(caption).font(.caption).foregroundStyle(.secondary) - } - } - } - - private var tokenActions: some View { - HStack { - Button { - Task { await fetchToken() } - } label: { - if fetchingWebAuthToken { - HStack(spacing: 6) { - ProgressView().controlSize(.small) - Text("Fetching…") - } - } else { - Text("Fetch Web Auth Token") - } - } - .buttonStyle(.borderedProminent) - .disabled(apiToken.isEmpty || fetchingWebAuthToken) - if webAuthToken != nil { - Button("Clear", role: .destructive) { - webAuthToken = nil - webAuthTokenError = nil - } - } - } - } - - @ViewBuilder - private var tokenDisplay: some View { - if let webAuthToken { - LabeledContent("Web Auth Token") { - VStack(alignment: .trailing, spacing: 6) { - Text(webAuthToken) - .font(.callout.monospaced()) - .lineLimit(3) - .truncationMode(.middle) - .textSelection(.enabled) - Button("Copy") { copy(webAuthToken) } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - } - if let webAuthTokenError { - Text(webAuthTokenError).font(.callout).foregroundStyle(.red) - } - } } #endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/NoteEditView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/NoteEditView.swift index f9d607be..f285f4ac 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/NoteEditView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/NoteEditView.swift @@ -42,7 +42,7 @@ internal let mode: Mode internal let onSaved: (Note) -> Void - @EnvironmentObject private var service: NativeCloudKitService + @Environment(CloudKitStore.self) private var service @Environment(\.dismiss) private var dismiss @State private var title: String = "" diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift index c86ea446..7ac66674 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift @@ -32,7 +32,7 @@ /// View for querying Note records from CloudKit. internal struct QueryView: View { - @EnvironmentObject private var service: NativeCloudKitService + @Environment(CloudKitStore.self) private var service @State private var limit: Int = 50 @State private var notes: [Note] = [] @State private var loading = false @@ -69,7 +69,7 @@ List(notes, selection: $selectedNote) { note in NavigationLink(value: note) { VStack(alignment: .leading, spacing: 2) { - Text(note.title ?? note.id).font(.body) + Text(note.title ?? note.recordName).font(.body) HStack(spacing: 12) { if let index = note.index { Label("\(index)", systemImage: "number") @@ -98,7 +98,11 @@ .navigationDestination(for: Note.self) { note in RecordDetailView(note: note, onChange: { Task { await runQuery() } }) } - .navigationTitle("Notes") + .navigationTitle("Notes — \(service.databaseScope.label)") + .onChange(of: service.databaseScope) { _, _ in + notes = [] + Task { await runQuery() } + } .toolbar { ToolbarItem { Button { @@ -112,7 +116,7 @@ NoteEditView(mode: .create) { _ in Task { await runQuery() } } - .environmentObject(service) + .environment(service) } } diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift index 58a1bb9f..3be9c145 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift @@ -35,7 +35,7 @@ @State internal var note: Note internal let onChange: () -> Void - @EnvironmentObject private var service: NativeCloudKitService + @Environment(CloudKitStore.self) private var service @Environment(\.dismiss) private var dismiss @State private var showEditSheet = false @@ -55,7 +55,7 @@ } } .formStyle(.grouped) - .navigationTitle(note.title ?? note.id) + .navigationTitle(note.title ?? note.recordName) .toolbar { ToolbarItem { Button { @@ -78,10 +78,10 @@ note = updated onChange() } - .environmentObject(service) + .environment(service) } .confirmationDialog( - "Delete \(note.title ?? note.id)?", + "Delete \(note.title ?? note.recordName)?", isPresented: $showDeleteConfirmation, titleVisibility: .visible ) { @@ -96,7 +96,7 @@ private var identitySection: some View { Section("Identity") { - LabeledContent("Record Name", value: note.id) + LabeledContent("Record Name", value: note.recordName) LabeledContent("Record Type", value: Note.recordType) if let recordChangeTag = note.recordChangeTag { LabeledContent("Change Tag", value: recordChangeTag) diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/RootView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/RootView.swift index 9178baea..e44fc969 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/RootView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/RootView.swift @@ -32,7 +32,7 @@ /// Root view hosting the navigation split between sidebar and detail. public struct RootView: View { - @EnvironmentObject private var service: NativeCloudKitService + @Environment(CloudKitStore.self) private var service @State private var selection: SidebarItem? = .account /// The view body. diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/ZoneListView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/ZoneListView.swift index 498a32de..ceca163a 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/ZoneListView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/ZoneListView.swift @@ -32,7 +32,7 @@ /// View listing all CloudKit record zones. internal struct ZoneListView: View { - @EnvironmentObject private var service: NativeCloudKitService + @Environment(CloudKitStore.self) private var service @State private var zones: [ZoneRow] = [] @State private var loading = false @State private var loadError: String? @@ -67,13 +67,17 @@ } } } - .navigationTitle("Zones") + .navigationTitle("Zones — \(service.databaseScope.label)") .toolbar { ToolbarItem { Button("Refresh") { Task { await refresh() } } } } .task { await refresh() } + .onChange(of: service.databaseScope) { _, _ in + zones = [] + Task { await refresh() } + } } private func refresh() async { diff --git a/Examples/MistDemo/project.yml b/Examples/MistDemo/project.yml index 535e7e8c..cac19679 100644 --- a/Examples/MistDemo/project.yml +++ b/Examples/MistDemo/project.yml @@ -73,14 +73,6 @@ schemes: MistDemoApp-macOS: all run: config: Debug - # Baked from $CLOUDKIT_API_TOKEN at xcodegen-generate time. The .env - # file at Examples/MistDemo/.env (gitignored) is sourced by the - # `make generate` target. The whole *.xcodeproj is gitignored - # repo-wide, so the substituted value never lands in git. Empty - # string when the env var isn't set — AccountView falls back to the - # in-app TextField. - environmentVariables: - CLOUDKIT_API_TOKEN: ${CLOUDKIT_API_TOKEN} test: config: Debug archive: @@ -92,8 +84,6 @@ schemes: MistDemoApp-iOS: all run: config: Debug - environmentVariables: - CLOUDKIT_API_TOKEN: ${CLOUDKIT_API_TOKEN} test: config: Debug archive: From 980adf8e50fa66ba2a805af324a00598d1a60c17 Mon Sep 17 00:00:00 2001 From: codefactor-io Date: Wed, 13 May 2026 13:42:28 +0000 Subject: [PATCH 2/8] [CodeFactor] Apply fixes --- .../MistDemo/Sources/MistDemoKit/Resources/index.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html index 061d46ce..2bf13fb0 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html @@ -41,10 +41,10 @@ padding: 24px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); } - h1 { font-size: 24px; margin: 0 0 4px 0; } - h2 { font-size: 18px; margin: 0 0 12px 0; } - h3 { font-size: 15px; margin: 0 0 8px 0; } - p { color: var(--muted); margin: 0 0 16px 0; line-height: 1.5; } + h1 { font-size: 24px; margin: 0 0 4px; } + h2 { font-size: 18px; margin: 0 0 12px; } + h3 { font-size: 15px; margin: 0 0 8px; } + p { color: var(--muted); margin: 0 0 16px; line-height: 1.5; } label { display: block; font-size: 13px; font-weight: 600; margin: 12px 0 4px; } input { width: 100%; @@ -111,7 +111,7 @@ border-radius: 6px; font-size: 12px; overflow-x: auto; - margin: 8px 0 0 0; + margin: 8px 0 0; max-height: 240px; } .mode-toggle { From 7286db5a0de707885a5facb65eb241f14d3b8b19 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Wed, 13 May 2026 15:45:02 -0400 Subject: [PATCH 3/8] Gate WebBackendFactory on canImport(Hummingbird) to fix wasm build WebBackendFactory.live calls CloudKitService's URLSession-defaulted convenience init, which is gated behind #if !os(WASI). The rest of the Server/ folder is already wrapped in #if canImport(Hummingbird); this file was missed. Wrapping it the same way unblocks the wasm, wasm 6.2, and wasm-embedded CI jobs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Server/WebBackendFactory.swift | 85 ++++++++++--------- 1 file changed, 44 insertions(+), 41 deletions(-) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift index 9a385751..05aa7374 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift @@ -27,49 +27,52 @@ // OTHER DEALINGS IN THE SOFTWARE. // -internal import Foundation -internal import MistKit +#if canImport(Hummingbird) + internal import Foundation + internal import MistKit -/// Factory that returns a `WebBackend` configured with the captured -/// web-auth token. Injected into `WebServer` so tests can supply a -/// mock without going through MistKit. -/// -/// When server-to-server credentials are present, the produced service -/// holds both auth flavors and `CloudKitService` picks the right one -/// per operation based on the request's `database`. -internal struct WebBackendFactory: Sendable { - internal let make: @Sendable (_ webAuthToken: String) throws -> any WebBackend + /// Factory that returns a `WebBackend` configured with the captured + /// web-auth token. Injected into `WebServer` so tests can supply a + /// mock without going through MistKit. + /// + /// When server-to-server credentials are present, the produced service + /// holds both auth flavors and `CloudKitService` picks the right one + /// per operation based on the request's `database`. + internal struct WebBackendFactory: Sendable { + internal let make: @Sendable (_ webAuthToken: String) throws -> any WebBackend - internal init( - make: @escaping @Sendable (_ webAuthToken: String) throws -> any WebBackend - ) { - self.make = make - } + internal init( + make: + @escaping @Sendable (_ webAuthToken: String) throws -> any WebBackend + ) { + self.make = make + } - /// Production factory: builds a `CloudKitService` for the captured - /// web-auth token paired with the command's API token. If - /// `serverToServer` is non-nil, the same service can also satisfy - /// public-database routes via S2S signing. - internal static func live( - apiToken: String, - containerIdentifier: String, - environment: MistKit.Environment, - serverToServer: ServerToServerCredentials? = nil - ) -> WebBackendFactory { - WebBackendFactory { webAuthToken in - let apiAuth = APICredentials( - apiToken: apiToken, - webAuthToken: webAuthToken - ) - let credentials = try Credentials( - serverToServer: serverToServer, - apiAuth: apiAuth - ) - return CloudKitService( - containerIdentifier: containerIdentifier, - credentials: credentials, - environment: environment - ) + /// Production factory: builds a `CloudKitService` for the captured + /// web-auth token paired with the command's API token. If + /// `serverToServer` is non-nil, the same service can also satisfy + /// public-database routes via S2S signing. + internal static func live( + apiToken: String, + containerIdentifier: String, + environment: MistKit.Environment, + serverToServer: ServerToServerCredentials? = nil + ) -> WebBackendFactory { + WebBackendFactory { webAuthToken in + let apiAuth = APICredentials( + apiToken: apiToken, + webAuthToken: webAuthToken + ) + let credentials = try Credentials( + serverToServer: serverToServer, + apiAuth: apiAuth + ) + return CloudKitService( + containerIdentifier: containerIdentifier, + credentials: credentials, + environment: environment + ) + } } } -} +#endif From 2dc542e754d27481222a86ccbee320951e1fe6c7 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Wed, 13 May 2026 19:51:31 -0400 Subject: [PATCH 4/8] Address PR #339 review: roll back Note CKRecord wrapper, restore web-auth-token UI Two review comments from #pullrequestreview-4286058024: 1. Note: revert from CKRecord wrapper back to value-struct (id/title/index/ imageAssetURL + system metadata). updateNote now fetches by ID before apply+save instead of mutating the original record in place; deleteNote reconstructs CKRecord.ID from the recordName. Views switch from note.recordName to note.id. 2. AccountView: restore the API-token TextField, "Fetch Web Auth Token" button, copyable token display, and CLOUDKIT_API_TOKEN env-var seed, ported from the pre-#328 NativeCloudKitService design onto the new @Observable CloudKitStore + @Environment binding. Database picker stays. Adds CloudKitStore.fetchWebAuthToken via CKFetchWebAuthTokenOperation and a webAuthTokenUnavailable error case. Recreates AccountView+Actions.swift (deleted in #328). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Sources/MistDemoApp/Models/Note.swift | 46 +++---- .../MistDemoApp/Services/CloudKitStore.swift | 39 +++++- .../Services/CloudKitStoreError.swift | 3 + .../Views/AccountView+Actions.swift | 78 +++++++++++ .../MistDemoApp/Views/AccountView.swift | 124 +++++++++++++++++- .../Sources/MistDemoApp/Views/QueryView.swift | 2 +- .../MistDemoApp/Views/RecordDetailView.swift | 6 +- 7 files changed, 261 insertions(+), 37 deletions(-) create mode 100644 Examples/MistDemo/Sources/MistDemoApp/Views/AccountView+Actions.swift diff --git a/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift b/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift index 28d43d8e..1d83c752 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift @@ -39,9 +39,9 @@ /// "image" ASSET /// ); /// - /// Wraps a `CKRecord` rather than copying fields out of it — the record is - /// the source of truth, so an update can mutate it in place and `save` it - /// without re-fetching to refresh the change tag. + /// Created / modified timestamps come from CloudKit's system metadata + /// (`CKRecord.creationDate` / `.modificationDate`), so there's no need + /// for custom `createdAt` / `modified` schema fields. internal struct Note: Identifiable, Hashable { /// Known field name constants for `Note` records. internal enum Fields { @@ -53,33 +53,33 @@ /// CloudKit record type identifier. internal static let recordType = "Note" - internal let record: CKRecord + internal let id: String + internal let title: String? + internal let index: Int64? + internal let imageAssetURL: URL? - internal var id: CKRecord.ID { record.recordID } - internal var recordName: String { record.recordID.recordName } - internal var title: String? { record[Fields.title] as? String } - internal var index: Int64? { (record[Fields.index] as? NSNumber)?.int64Value } - internal var imageAssetURL: URL? { (record[Fields.image] as? CKAsset)?.fileURL } - internal var creationDate: Date? { record.creationDate } - internal var modificationDate: Date? { record.modificationDate } - internal var recordChangeTag: String? { record.recordChangeTag } + /// CloudKit-managed metadata + internal let modificationDate: Date? + internal let creationDate: Date? + internal let recordChangeTag: String? internal init?(_ record: CKRecord) { guard record.recordType == Self.recordType else { return nil } - self.record = record + self.id = record.recordID.recordName + self.title = record[Fields.title] as? String + self.index = (record[Fields.index] as? NSNumber)?.int64Value + self.imageAssetURL = (record[Fields.image] as? CKAsset)?.fileURL + self.modificationDate = record.modificationDate + self.creationDate = record.creationDate + self.recordChangeTag = record.recordChangeTag } - // Identity-based equality so SwiftUI selection / NavigationLink paths - // remain stable across edits. RecordDetailView replaces its `@State` Note - // with a fresh wrapper after save, which is what drives the re-render — - // not Equatable comparison. - internal static func == (lhs: Note, rhs: Note) -> Bool { - lhs.record.recordID == rhs.record.recordID - } - internal func hash(into hasher: inout Hasher) { - hasher.combine(record.recordID) - } + // Identity-based equality: two Notes with the same recordID are equal + // regardless of field state. Lets SwiftUI selection bindings track a + // record across edits without losing focus when fields change. + internal static func == (lhs: Note, rhs: Note) -> Bool { lhs.id == rhs.id } + internal func hash(into hasher: inout Hasher) { hasher.combine(id) } } #endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift index 7c705c76..0701dc79 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift @@ -162,14 +162,17 @@ return note } - /// Mutate the existing record in place and save. The record carries its - /// own change tag from the original query, so no extra fetch round-trip - /// is needed before save. + /// Update an existing Note: fetch the underlying record by ID, apply the + /// new field values, and save. The fetch picks up the current change tag + /// so the save is rejected (rather than blindly clobbering) if the record + /// has been modified since the caller read it. internal func updateNote( _ existing: Note, title: String, index: Int64, imageURL: URL? ) async throws -> Note { - Self.apply(title: title, index: index, imageURL: imageURL, to: existing.record) - let saved = try await database.save(existing.record) + let recordID = CKRecord.ID(recordName: existing.id) + let record = try await database.record(for: recordID) + Self.apply(title: title, index: index, imageURL: imageURL, to: record) + let saved = try await database.save(record) guard let note = Note(saved) else { throw CloudKitStoreError.unexpectedSaveResult } @@ -178,7 +181,31 @@ /// Delete a Note by record ID. internal func deleteNote(_ note: Note) async throws { - _ = try await database.deleteRecord(withID: note.record.recordID) + _ = try await database.deleteRecord( + withID: CKRecord.ID(recordName: note.id) + ) + } + + /// Capture a web-auth token via `CKFetchWebAuthTokenOperation` for the + /// given CloudKit API token. Issues the same `158__…` value that + /// MistKit / `mistdemo auth-token` consume. + internal func fetchWebAuthToken(apiToken: String) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + let operation = CKFetchWebAuthTokenOperation(apiToken: apiToken) + operation.qualityOfService = .userInitiated + operation.fetchWebAuthTokenCompletionBlock = { token, error in + if let token { + continuation.resume(returning: token) + } else { + continuation.resume( + throwing: error ?? CloudKitStoreError.webAuthTokenUnavailable + ) + } + } + // CKFetchWebAuthTokenOperation is a CKDatabaseOperation; running it + // against the selected database picks up the demo container. + database.add(operation) + } } } #endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift index 8e334fd6..413c0be8 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift @@ -33,11 +33,14 @@ /// Errors specific to `CloudKitStore` operations. internal enum CloudKitStoreError: Error, LocalizedError { case unexpectedSaveResult + case webAuthTokenUnavailable internal var errorDescription: String? { switch self { case .unexpectedSaveResult: return "CloudKit returned a record that couldn't be parsed as a Note." + case .webAuthTokenUnavailable: + return "CloudKit returned no web auth token and no error." } } } diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView+Actions.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView+Actions.swift new file mode 100644 index 00000000..fef8a934 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView+Actions.swift @@ -0,0 +1,78 @@ +// +// AccountView+Actions.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. +// + +#if canImport(SwiftUI) && canImport(CloudKit) && !os(tvOS) && !os(watchOS) + import CloudKit + import SwiftUI + + #if canImport(AppKit) + import AppKit + #elseif canImport(UIKit) + import UIKit + #endif + + extension AccountView { + internal func seedTokenIfNeeded() { + guard apiToken.isEmpty else { + return + } + if let envValue = ProcessInfo.processInfo.environment[Self.envVarName], + !envValue.isEmpty, + !envValue.hasPrefix("${") + { + apiToken = envValue + tokenSource = .environment + } + } + + internal func fetchToken() async { + fetchingWebAuthToken = true + webAuthTokenError = nil + webAuthToken = nil + defer { fetchingWebAuthToken = false } + do { + let token = try await service.fetchWebAuthToken( + apiToken: apiToken.trimmingCharacters(in: .whitespacesAndNewlines) + ) + webAuthToken = token + } catch { + webAuthTokenError = error.localizedDescription + } + } + + internal func copy(_ value: String) { + #if canImport(AppKit) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + #elseif canImport(UIKit) + UIPasteboard.general.string = value + #endif + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift index 24d77399..4ddd01ec 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift @@ -31,11 +31,31 @@ import CloudKit import SwiftUI - /// View showing the iCloud account status and the public/private database - /// selector. The native app authenticates via the signed-in iCloud user, so - /// there's no token plumbing to surface. + #if canImport(AppKit) + import AppKit + #elseif canImport(UIKit) + import UIKit + #endif + + /// View showing the iCloud account status, the public/private database + /// selector, and a web-auth-token capture flow that mirrors + /// `mistdemo auth-token`. internal struct AccountView: View { - @Environment(CloudKitStore.self) private var service + /// Where the current `apiToken` value came from on this launch. + internal enum TokenSource { + case manual + case environment + } + + /// Env var name the MistDemo CLI also reads. + internal static let envVarName = "CLOUDKIT_API_TOKEN" + + @Environment(CloudKitStore.self) internal var service + @AppStorage("MistDemoApp.cloudKitApiToken") internal var apiToken: String = "" + @State internal var webAuthToken: String? + @State internal var fetchingWebAuthToken = false + @State internal var webAuthTokenError: String? + @State internal var tokenSource: TokenSource = .manual internal var body: some View { @Bindable var bindable = service @@ -49,6 +69,7 @@ } LabeledContent("iCloud Status", value: statusLabel) } + webAuthTokenSection if let error = service.lastError { Section("Last Service Error") { Text(error).font(.callout).foregroundStyle(.red) @@ -64,6 +85,17 @@ } } } + .onAppear { seedTokenIfNeeded() } + } + + private var sourceCaption: String? { + switch tokenSource { + case .manual: + return nil + case .environment: + return + "Loaded from $\(Self.envVarName) (xcodegen baked it into the scheme from .env)." + } } private var statusLabel: String { @@ -76,5 +108,89 @@ @unknown default: return "Unknown" } } + + private var webAuthTokenSection: some View { + Section { + tokenTextField + tokenActions + tokenDisplay + } header: { + Text("Web Auth Token") + } footer: { + Text( + "Issues the same `158__…` token that MistKit / " + + "`mistdemo auth-token` consume. " + + "Uses CKFetchWebAuthTokenOperation." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private var tokenTextField: some View { + Group { + TextField( + "CloudKit API Token", + text: $apiToken, + prompt: Text("Paste from CloudKit Dashboard") + ) + .textFieldStyle(.roundedBorder) + .font(.body.monospaced()) + .onChange(of: apiToken) { _, _ in tokenSource = .manual } + #if os(iOS) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + #endif + if let caption = sourceCaption { + Text(caption).font(.caption).foregroundStyle(.secondary) + } + } + } + + private var tokenActions: some View { + HStack { + Button { + Task { await fetchToken() } + } label: { + if fetchingWebAuthToken { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Fetching…") + } + } else { + Text("Fetch Web Auth Token") + } + } + .buttonStyle(.borderedProminent) + .disabled(apiToken.isEmpty || fetchingWebAuthToken) + if webAuthToken != nil { + Button("Clear", role: .destructive) { + webAuthToken = nil + webAuthTokenError = nil + } + } + } + } + + @ViewBuilder + private var tokenDisplay: some View { + if let webAuthToken { + LabeledContent("Web Auth Token") { + VStack(alignment: .trailing, spacing: 6) { + Text(webAuthToken) + .font(.callout.monospaced()) + .lineLimit(3) + .truncationMode(.middle) + .textSelection(.enabled) + Button("Copy") { copy(webAuthToken) } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + if let webAuthTokenError { + Text(webAuthTokenError).font(.callout).foregroundStyle(.red) + } + } } #endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift index 7ac66674..f8fd74d3 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift @@ -69,7 +69,7 @@ List(notes, selection: $selectedNote) { note in NavigationLink(value: note) { VStack(alignment: .leading, spacing: 2) { - Text(note.title ?? note.recordName).font(.body) + Text(note.title ?? note.id).font(.body) HStack(spacing: 12) { if let index = note.index { Label("\(index)", systemImage: "number") diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift index 3be9c145..d3cb9afb 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift @@ -55,7 +55,7 @@ } } .formStyle(.grouped) - .navigationTitle(note.title ?? note.recordName) + .navigationTitle(note.title ?? note.id) .toolbar { ToolbarItem { Button { @@ -81,7 +81,7 @@ .environment(service) } .confirmationDialog( - "Delete \(note.title ?? note.recordName)?", + "Delete \(note.title ?? note.id)?", isPresented: $showDeleteConfirmation, titleVisibility: .visible ) { @@ -96,7 +96,7 @@ private var identitySection: some View { Section("Identity") { - LabeledContent("Record Name", value: note.recordName) + LabeledContent("Record Name", value: note.id) LabeledContent("Record Type", value: Note.recordType) if let recordChangeTag = note.recordChangeTag { LabeledContent("Change Tag", value: recordChangeTag) From 36a1299111541140a70d76ee48a50c2aefce561e Mon Sep 17 00:00:00 2001 From: leogdion Date: Thu, 14 May 2026 08:37:05 -0400 Subject: [PATCH 5/8] Resolve #338: per-call PublicAuthPreference encoded in Database (#340) --- CLAUDE.md | 27 ++- .../CloudKit/MistKitClientFactory.swift | 2 +- .../MistDemoKit/Commands/CreateCommand.swift | 5 +- .../MistDemoKit/Commands/DeleteCommand.swift | 3 +- .../Commands/DemoErrorsRunner+Output.swift | 2 +- .../Commands/DemoErrorsRunner.swift | 12 +- .../Commands/DemoInFilterCommand.swift | 15 +- .../Commands/FetchChangesCommand.swift | 6 +- .../MistDemoKit/Commands/LookupCommand.swift | 3 +- .../MistDemoKit/Commands/ModifyCommand.swift | 4 +- .../MistDemoKit/Commands/QueryCommand.swift | 6 +- .../MistDemoKit/Commands/UpdateCommand.swift | 3 +- .../Commands/UploadAssetCommand.swift | 12 +- .../Configuration/MistDemoConfig.swift | 23 ++- .../Integration/PhasedIntegrationTest.swift | 4 +- .../Integration/Phases/CleanupPhase.swift | 5 +- .../Phases/CreateRecordsPhase.swift | 3 +- .../Phases/IncrementalSyncPhase.swift | 5 +- .../Integration/Phases/InitialSyncPhase.swift | 4 +- .../Phases/LookupRecordsPhase.swift | 5 +- .../Phases/ModifyRecordsPhase.swift | 5 +- .../Integration/Phases/UploadAssetPhase.swift | 3 +- .../Tests/PublicDatabaseTest.swift | 10 +- .../MistDemoKit/Server/WebRequests.swift | 2 +- .../AuthenticationHelper+SetupHelpers.swift | 4 +- ...KitClientFactoryTests+BadCredentials.swift | 2 +- ...lientFactoryTests+CustomTokenManager.swift | 2 +- ...KitClientFactoryTests+PublicDatabase.swift | 6 +- ...tionCredentialsTests+ToConfiguration.swift | 12 +- .../TestPrivateConfigTests.swift | 4 +- .../MistDemoConfig+Testing.swift | 2 +- .../Server/WebServerTests+Database.swift | 2 +- ...ionHelperTests+APIOnlyAuthentication.swift | 2 +- ...erTests+AuthenticationMethodPriority.swift | 2 +- ...erTests+ServerToServerAuthentication.swift | 4 +- ...icationHelperTests+WebAuthentication.swift | 4 +- .../Credentials+TokenManager.swift | 147 ++++++++++------ .../Authentication/PublicAuthPreference.swift | 79 +++++++++ Sources/MistKit/Database.swift | 33 +++- ...onfiguration+ConvenienceInitializers.swift | 3 +- .../CloudKitService+AssetOperations.swift | 4 +- .../CloudKitService+Classification.swift | 15 +- .../CloudKitService+ClientDispatch.swift | 22 +-- .../CloudKitService+LookupOperations.swift | 4 +- .../CloudKitService+Operations.swift | 4 +- .../CloudKitService+QueryPagination.swift | 2 +- .../CloudKitService+RecordManaging.swift | 17 +- .../CloudKitService+SyncOperations.swift | 4 +- .../CloudKitService+UserOperations.swift | 20 +-- .../CloudKitService+WriteOperations.swift | 8 +- .../ResponseProcessing/CloudKitError.swift | 18 +- .../CredentialAvailability.swift | 46 +++++ ...ialsTokenManagerTests+PrivateKeyLoad.swift | 2 +- ...ialsTokenManagerTests+PublicDatabase.swift | 162 ++++++++++++++++-- ...entialsTokenManagerTests+UserContext.swift | 82 ++------- Tests/MistKitTests/Core/DatabaseTests.swift | 13 +- .../PublicTypes/CloudKitErrorTests.swift | 65 +++++++ ...ServiceTests.FetchChanges+Concurrent.swift | 2 +- ...viceTests.FetchChanges+ErrorHandling.swift | 11 +- ...rviceTests.FetchChanges+SuccessCases.swift | 41 +++-- ...ServiceTests.FetchChanges+Validation.swift | 26 ++- ...eTests.FetchZoneChanges+SuccessCases.swift | 10 +- ...iceTests.FetchZoneChanges+Validation.swift | 2 +- ...erviceTests.LookupZones+SuccessCases.swift | 6 +- ...CloudKitServiceTests.Query+EdgeCases.swift | 12 +- ...rviceTests.Query+ExistingRecordNames.swift | 78 +++++++++ ...loudKitServiceTests.Query+Validation.swift | 23 ++- ...viceTests.QueryPagination+ErrorCases.swift | 3 +- ...ceTests.QueryPagination+SuccessCases.swift | 9 +- ...KitServiceTests.Upload+ErrorHandling.swift | 6 +- ...KitServiceTests.Upload+NetworkErrors.swift | 9 +- ...dKitServiceTests.Upload+SuccessCases.swift | 15 +- ...oudKitServiceTests.Upload+Validation.swift | 12 +- 73 files changed, 923 insertions(+), 307 deletions(-) create mode 100644 Sources/MistKit/Authentication/PublicAuthPreference.swift create mode 100644 Sources/MistKit/Service/ResponseProcessing/CredentialAvailability.swift create mode 100644 Tests/MistKitTests/PublicTypes/CloudKitErrorTests.swift create mode 100644 Tests/MistKitTests/Service/CloudKitServiceQuery/CloudKitServiceTests.Query+ExistingRecordNames.swift diff --git a/CLAUDE.md b/CLAUDE.md index beb1a392..9896cb18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -290,13 +290,34 @@ A `ClientTransport` extension could provide a generic upload method, but would n ### CloudKit Web Services Integration - Base URL: `https://api.apple-cloudkit.com` - Authentication: - - **Public database**: `CLOUDKIT_KEY_ID` + `CLOUDKIT_PRIVATE_KEY` or `CLOUDKIT_PRIVATE_KEY_PATH` → server-to-server signing - - **Private database**: `CLOUDKIT_API_TOKEN` + `CLOUDKIT_WEB_AUTH_TOKEN` → web authentication + - **Public database**: caller picks per-call via `PublicAuthPreference` carried on `Database.public(_:)`. Either `.requires(.serverToServer)` (key-pair signing — needs `CLOUDKIT_KEY_ID` + `CLOUDKIT_PRIVATE_KEY` or `CLOUDKIT_PRIVATE_KEY_PATH`) or `.requires(.webAuth)` (user-attributed — needs `CLOUDKIT_API_TOKEN` + `CLOUDKIT_WEB_AUTH_TOKEN`). Use `.prefers(_:)` to fall back to whichever cred is configured. + - **Private / Shared database**: always `CLOUDKIT_API_TOKEN` + `CLOUDKIT_WEB_AUTH_TOKEN` → web-auth (CloudKit rejects S2S on these scopes). - All operations should reference the OpenAPI spec in `openapi.yaml` - URL Pattern: `/database/{version}/{container}/{environment}/{database}/{operation}` -- Supported databases: `public`, `private`, `shared` +- Supported databases: `Database.public(PublicAuthPreference)`, `Database.private`, `Database.shared` - Environments: `development`, `production` +### Per-call attribution for `.public` + +`Database` carries the signing choice when targeting public: + +```swift +public enum Database { + case `public`(PublicAuthPreference) + case `private` + case shared +} +``` + +`PublicAuthPreference` is constructed via two factories — never via the (internal) memberwise init: + +- `.prefers(.serverToServer)` — try S2S, fall back to web-auth/API-token if S2S isn't configured. +- `.prefers(.webAuth)` — try web-auth, fall back to S2S if web-auth isn't configured. +- `.requires(.serverToServer)` — must use S2S; throw `missingCredentials(.preferenceRequired)` otherwise. +- `.requires(.webAuth)` — must use web-auth; throw `missingCredentials(.preferenceRequired)` otherwise. + +There is **no default** on the operation `database:` parameter — every call must pick explicitly. The `requiresUserContext` flag on the dispatcher is gone; user-context routes (`users/*`) pass `.public(.requires(.webAuth))` directly. See `Sources/MistKit/Authentication/PublicAuthPreference.swift` and `Sources/MistKit/Authentication/Credentials/Credentials+TokenManager.swift`. + ### Testing Strategy - Use Swift Testing framework (`@Test` macro) for all tests - Unit tests for all public APIs diff --git a/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift b/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift index 31de7cac..b3b6d955 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift @@ -66,7 +66,7 @@ public struct MistKitClientFactory: Sendable { ) #else if config.badCredentials { - guard config.database != .public else { + if case .public = config.database { throw ConfigurationError.badCredentialsOnPublicDB } return try create(from: config, tokenManager: makeBadCredentialsTokenManager()) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift index 87ff9012..cd31f8fd 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift @@ -84,8 +84,9 @@ public struct CreateCommand: MistDemoCommand, OutputFormatting { let recordInfo = try await client.createRecord( recordType: config.recordType, recordName: recordName, - fields: cloudKitFields - // Zone: config.zone - to be added when CloudKitService supports it + fields: cloudKitFields, + // Zone: config.zone - to be added when CloudKitService supports it + database: config.base.database ) // Format and output result diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift index 373512e3..94bf05f3 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift @@ -90,7 +90,8 @@ public struct DeleteCommand: MistDemoCommand, OutputFormatting { try await client.deleteRecord( recordType: config.recordType, recordName: config.recordName, - recordChangeTag: effectiveChangeTag + recordChangeTag: effectiveChangeTag, + database: config.base.database ) let result = DeleteResult( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift index 69301727..17fb2b2b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift @@ -36,7 +36,7 @@ extension DemoErrorsRunner { print("🛑 CloudKit Error Demo — typed CloudKitError handling") print(String(repeating: "=", count: 80)) print("Container: \(config.containerIdentifier)") - print("Database: \(config.database.rawValue)") + print("Database: \(config.database.pathSegment)") print(String(repeating: "=", count: 80)) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift index f096d047..ce259489 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift @@ -145,7 +145,8 @@ internal struct DemoErrorsRunner { let created = try await service.createRecord( recordType: Self.conflictRecordType, recordName: recordName, - fields: ["title": .string("original")] + fields: ["title": .string("original")], + database: config.database ) createdRecordName = created.recordName staleTag = created.recordChangeTag @@ -160,7 +161,8 @@ internal struct DemoErrorsRunner { recordType: Self.conflictRecordType, recordName: recordName, fields: ["title": .string("first-update")], - recordChangeTag: staleTag + recordChangeTag: staleTag, + database: config.database ) } catch { print("❌ Setup update failed: \(error)") @@ -173,7 +175,8 @@ internal struct DemoErrorsRunner { recordType: Self.conflictRecordType, recordName: recordName, fields: ["title": .string("second-update-stale")], - recordChangeTag: staleTag + recordChangeTag: staleTag, + database: config.database ) print("⚠️ Expected 409 but update was accepted.") } catch { @@ -198,7 +201,8 @@ internal struct DemoErrorsRunner { do { try await service.deleteRecord( recordType: Self.conflictRecordType, - recordName: createdRecordName + recordName: createdRecordName, + database: config.database ) print(" ✅ Deleted.") } catch { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift index 09c3b99c..7c3a32e6 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift @@ -106,7 +106,8 @@ public struct DemoInFilterCommand: MistDemoCommand { fields: [ "title": .string("demo-in-filter-\(tag)-idx\(idx)"), "index": .int64(idx), - ] + ], + database: config.database ) createdNames.append(record.recordName) print(" Created \(record.recordName) (index=\(idx))") @@ -122,7 +123,9 @@ public struct DemoInFilterCommand: MistDemoCommand { ) async throws { print("\nVerifying records are queryable...") let allRecords = try await client.queryRecords( - recordType: recordType, limit: 200 + recordType: recordType, + limit: 200, + database: config.database ) let visible = allRecords.filter { createdNames.contains($0.recordName) @@ -136,7 +139,8 @@ public struct DemoInFilterCommand: MistDemoCommand { let results = try await client.queryRecords( recordType: recordType, filters: [.in("index", [.int64(10), .int64(30)])], - limit: 200 + limit: 200, + database: config.database ) let matching = results.filter { @@ -163,7 +167,10 @@ public struct DemoInFilterCommand: MistDemoCommand { recordType: recordType, recordName: name ) - _ = try await client.modifyRecords([operation]) + _ = try await client.modifyRecords( + [operation], + database: config.database + ) print(" Deleted \(name)") } print("Done.") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift index e97c39d4..5f50cdeb 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift @@ -108,7 +108,8 @@ public struct FetchChangesCommand: MistDemoCommand, OutputFormatting { print("\n📦 Fetching all changes (automatic pagination)...") let (records, newToken) = try await service.fetchAllRecordChanges( zoneID: zoneID, - syncToken: config.syncToken + syncToken: config.syncToken, + database: config.base.database ) print("\n✅ Fetched \(records.count) record(s)") displayRecords(records, limit: 5) @@ -125,7 +126,8 @@ public struct FetchChangesCommand: MistDemoCommand, OutputFormatting { let result = try await service.fetchRecordChanges( zoneID: zoneID, syncToken: config.syncToken, - resultsLimit: config.limit ?? 10 + resultsLimit: config.limit ?? 10, + database: config.base.database ) print("\n✅ Fetched \(result.records.count) record(s)") displayRecords(result.records, limit: 5) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupCommand.swift index bd674267..6871ebf5 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupCommand.swift @@ -78,7 +78,8 @@ public struct LookupCommand: MistDemoCommand, OutputFormatting { let records = try await client.lookupRecords( recordNames: config.recordNames, - desiredKeys: config.fields + desiredKeys: config.fields, + database: config.base.database ) // Report missing names to stderr so a JSON/CSV/etc. stdout stream stays parseable diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift index b25ab501..8d11c206 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift @@ -80,7 +80,9 @@ public struct ModifyCommand: MistDemoCommand, OutputFormatting { } let results = try await client.modifyRecords( - operations, atomic: config.atomic + operations, + atomic: config.atomic, + database: config.base.database ) let rows = results.map { record in diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift index 9bd520d5..4d0a9ea2 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift @@ -81,14 +81,16 @@ public struct QueryCommand: MistDemoCommand, OutputFormatting { recordType: config.recordType, filters: filters, sortBy: nil, - limit: config.limit + limit: config.limit, + database: config.base.database ) } else { recordInfos = try await client.queryRecords( recordType: config.recordType, filters: nil, sortBy: nil, - limit: config.limit + limit: config.limit, + database: config.base.database ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift index 6fd3b5de..eabce926 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift @@ -106,7 +106,8 @@ public struct UpdateCommand: MistDemoCommand, OutputFormatting { recordType: config.recordType, recordName: config.recordName, fields: cloudKitFields, - recordChangeTag: effectiveChangeTag + recordChangeTag: effectiveChangeTag, + database: config.base.database ) try await outputResult(recordInfo, format: config.output) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/UploadAssetCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/UploadAssetCommand.swift index 7d98c7ce..84c0b683 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/UploadAssetCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/UploadAssetCommand.swift @@ -137,7 +137,8 @@ public struct UploadAssetCommand: MistDemoCommand, OutputFormatting { data: data, recordType: config.recordType, fieldName: config.fieldName, - recordName: config.recordName + recordName: config.recordName, + database: config.base.database ) print("\n✅ Asset uploaded!") print(" Record Name: \(result.recordName)") @@ -186,7 +187,8 @@ public struct UploadAssetCommand: MistDemoCommand, OutputFormatting { return try await service.createRecord( recordType: config.recordType, recordName: newRecordName, - fields: fields + fields: fields, + database: config.base.database ) } } @@ -197,7 +199,8 @@ public struct UploadAssetCommand: MistDemoCommand, OutputFormatting { service: CloudKitService ) async throws -> RecordInfo { let existingRecords = try await service.lookupRecords( - recordNames: [recordName] + recordNames: [recordName], + database: config.base.database ) guard let existingRecord = existingRecords.first else { throw UploadAssetError.operationFailed( @@ -208,7 +211,8 @@ public struct UploadAssetCommand: MistDemoCommand, OutputFormatting { recordType: config.recordType, recordName: recordName, fields: fields, - recordChangeTag: existingRecord.recordChangeTag + recordChangeTag: existingRecord.recordChangeTag, + database: config.base.database ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift index 5df8a721..64fe379a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift @@ -103,7 +103,7 @@ public struct MistDemoConfig: Sendable, ConfigurationParseable { let databaseString = config.string(forKey: "database", default: "public") ?? "public" - guard let database = MistKit.Database(rawValue: databaseString) else { + guard let database = MistDemoConfig.parseDatabase(databaseString) else { throw ConfigurationError.invalidDatabase(databaseString) } self.database = database @@ -167,6 +167,27 @@ public struct MistDemoConfig: Sendable, ConfigurationParseable { self.badCredentials = badCredentials } + /// Map a `"public" | "private" | "shared"` string to a `MistKit.Database`. + /// + /// `"public"` resolves to `.public(.prefers(.serverToServer))` to match + /// `toPrimaryCredentials()`'s "S2S-preferred, web-auth augments" policy. + /// Returns `nil` for unrecognized strings so callers can raise a + /// configuration error. + internal static func parseDatabase( + _ raw: String + ) -> MistKit.Database? { + switch raw { + case "public": + return .public(.prefers(.serverToServer)) + case "private": + return .private + case "shared": + return .shared + default: + return nil + } + } + /// Returns a copy with the given database override. internal func with( database: MistKit.Database diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift index 6a2b0ca5..3e483c75 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift @@ -97,7 +97,7 @@ extension PhasedIntegrationTest { print("\u{1F9EA} Integration Test Suite: \(name)") print(String(repeating: "=", count: 80)) print("Container: \(context.containerIdentifier)") - let dbLabel = database == .public ? "public" : "private" + let dbLabel = database.pathSegment == "public" ? "public" : "private" print("Database: \(dbLabel)") print("Record Count: \(context.recordCount)") print("Asset Size: \(context.assetSizeKB) KB") @@ -118,7 +118,7 @@ extension PhasedIntegrationTest { ) let cid = context.containerIdentifier print(" 2. Select your container: \(cid)") - let dbName = database == .public ? "Public" : "Private" + let dbName = database.pathSegment == "public" ? "Public" : "Private" print( " 3. Navigate to \(dbName) Database \u{2192} Records" ) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CleanupPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CleanupPhase.swift index 8dfcff3b..6016b9cc 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CleanupPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CleanupPhase.swift @@ -59,7 +59,10 @@ internal struct CleanupPhase: IntegrationPhase, CleanupPhaseMarker { } do { - _ = try await context.service.modifyRecords(deleteOps) + _ = try await context.service.modifyRecords( + deleteOps, + database: context.database + ) deletedCount = input.names.count if context.verbose { for name in input.names { print(" ✅ Deleted: \(name)") } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift index 6992e801..ef527616 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift @@ -59,7 +59,8 @@ internal struct CreateRecordsPhase: IntegrationPhase { "title": .string("Test Record \(recordIndex)"), "index": .int64(recordIndex), "image": .asset(input.asset), - ] + ], + database: context.database ) createdRecordNames.append(record.recordName) if context.verbose { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/IncrementalSyncPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/IncrementalSyncPhase.swift index 52b8cc37..4fc3ae3b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/IncrementalSyncPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/IncrementalSyncPhase.swift @@ -56,7 +56,10 @@ internal struct IncrementalSyncPhase: IntegrationPhase { } do { - let incrementalResult = try await context.service.fetchRecordChanges(syncToken: token) + let incrementalResult = try await context.service.fetchRecordChanges( + syncToken: token, + database: context.database + ) print("✅ Fetched \(incrementalResult.records.count) changed records") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/InitialSyncPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/InitialSyncPhase.swift index ae592a64..3a8ef274 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/InitialSyncPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/InitialSyncPhase.swift @@ -44,7 +44,9 @@ internal struct InitialSyncPhase: IntegrationPhase { print("\n\(Self.emoji) \(Self.title)") do { - let initialResult = try await context.service.fetchRecordChanges() + let initialResult = try await context.service.fetchRecordChanges( + database: context.database + ) print("✅ Fetched \(initialResult.records.count) records") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift index 46424f8c..6f91ac79 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift @@ -48,7 +48,10 @@ internal struct LookupRecordsPhase: IntegrationPhase { print(" Looking up \(lookupNames.count) of \(input.names.count) record(s) by name") } - let records = try await context.service.lookupRecords(recordNames: lookupNames) + let records = try await context.service.lookupRecords( + recordNames: lookupNames, + database: context.database + ) print("✅ Looked up \(records.count) record(s)") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift index 82825ba9..a2b19d1e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift @@ -56,7 +56,10 @@ internal struct ModifyRecordsPhase: IntegrationPhase { ) } - _ = try await context.service.modifyRecords(operations) + _ = try await context.service.modifyRecords( + operations, + database: context.database + ) if context.verbose { for recordName in recordsToUpdate { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/UploadAssetPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/UploadAssetPhase.swift index 3fc0e04b..999c9045 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/UploadAssetPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/UploadAssetPhase.swift @@ -53,7 +53,8 @@ internal struct UploadAssetPhase: IntegrationPhase { let receipt = try await context.service.uploadAssets( data: testData, recordType: IntegrationTestData.recordType, - fieldName: "image" + fieldName: "image", + database: context.database ) print("✅ Uploaded asset: \(testData.count) bytes") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift index 5b23f7db..e8cdceca 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift @@ -43,13 +43,13 @@ internal struct PublicDatabaseTest: PhasedIntegrationTest { /// call from the service's `Credentials`. The runner sets this based on /// whether web-auth credentials are configured. internal init( - database: MistKit.Database = .public, + database: MistKit.Database = .public(.prefers(.serverToServer)), includeUserContextPhases: Bool = false ) { - precondition( - database == .public, - "PublicDatabaseTest only supports the public database" - ) + if case .public = database { + } else { + preconditionFailure("PublicDatabaseTest only supports the public database") + } self.database = database var phases: [any IntegrationPhase] = [ diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift index 2aea5ba1..27b03945 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift @@ -185,7 +185,7 @@ internal enum WebRequests { else { return defaultDatabase } - guard let database = MistKit.Database(rawValue: raw) else { + guard let database = MistDemoConfig.parseDatabase(raw) else { throw DecodingError.dataCorruptedError( forKey: key, in: container, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationHelper+SetupHelpers.swift b/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationHelper+SetupHelpers.swift index 51690729..2470e8a9 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationHelper+SetupHelpers.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationHelper+SetupHelpers.swift @@ -38,7 +38,7 @@ extension AuthenticationHelper { privateKeyFile: String?, databaseOverride: MistKit.Database? ) async throws -> AuthenticationResult { - let database = MistKit.Database.public + let database: MistKit.Database = .public(.prefers(.serverToServer)) if databaseOverride == .private { throw AuthenticationError.serverToServerRequiresPublicDatabase @@ -85,7 +85,7 @@ extension AuthenticationHelper { apiToken: String, databaseOverride: MistKit.Database? ) async throws -> AuthenticationResult { - let database = MistKit.Database.public + let database: MistKit.Database = .public(.prefers(.serverToServer)) if databaseOverride == .private { throw AuthenticationError.privateRequiresWebAuth diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+BadCredentials.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+BadCredentials.swift index a053bed0..1544f0cc 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+BadCredentials.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+BadCredentials.swift @@ -66,7 +66,7 @@ extension MistKitClientFactoryTests { internal func badCredentialsOnPublicDatabaseThrows() async throws { let config = try await MistKitClientFactoryTests.makeConfig( apiToken: "real-config-token", - database: .public, + database: .public(.prefers(.serverToServer)), keyID: "real-key-id", privateKey: MistKitClientFactoryTests.validPrivateKey, badCredentials: true diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+CustomTokenManager.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+CustomTokenManager.swift index b86f734f..1768ba67 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+CustomTokenManager.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+CustomTokenManager.swift @@ -52,7 +52,7 @@ extension MistKitClientFactoryTests { @Test("Create client with custom token manager for public database") internal func createWithCustomTokenManagerPublicDB() async throws { let config = try await MistKitClientFactoryTests.makeConfig( - apiToken: "api-token", database: .public + apiToken: "api-token", database: .public(.prefers(.serverToServer)) ) let tokenManager = APITokenManager(apiToken: "custom-token") diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+PublicDatabase.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+PublicDatabase.swift index e66865d8..867e38da 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+PublicDatabase.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+PublicDatabase.swift @@ -39,7 +39,7 @@ extension MistKitClientFactoryTests { @Test("Create client for public database") internal func createForPublicDatabaseTest() async throws { let config = try await MistKitClientFactoryTests.makeConfig( - apiToken: "api-token", database: .public + apiToken: "api-token", database: .public(.prefers(.serverToServer)) ) let tokenManager = APITokenManager(apiToken: "api-token") @@ -53,7 +53,9 @@ extension MistKitClientFactoryTests { @Test("Public database creation requires API token") internal func publicDatabaseRequiresAPIToken() async throws { - let config = try await MistKitClientFactoryTests.makeConfig(apiToken: "", database: .public) + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "", database: .public(.prefers(.serverToServer)) + ) #expect(throws: ConfigurationError.self) { try MistKitClientFactory.create(for: config) diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift index 1ec9d3af..b54071ad 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift @@ -45,7 +45,7 @@ extension AuthenticationCredentialsTests { @Test("public with raw private key produces serverToServer with .raw material") internal func publicWithRawKey() async throws { let config = try await MistKitClientFactoryTests.makeConfig( - database: .public, + database: .public(.prefers(.serverToServer)), keyID: "test-key-id", privateKey: MistKitClientFactoryTests.validPrivateKey ) @@ -69,7 +69,7 @@ extension AuthenticationCredentialsTests { containerIdentifier: "iCloud.com.test.App", apiToken: "test-api-token", environment: .development, - database: .public, + database: .public(.prefers(.serverToServer)), webAuthToken: nil, keyID: "test-key-id", privateKey: nil, @@ -100,7 +100,7 @@ extension AuthenticationCredentialsTests { @Test("public missing keyID throws missingRequired(\"key.id\")") internal func publicMissingKeyIDThrows() async throws { let config = try await MistKitClientFactoryTests.makeConfig( - database: .public, + database: .public(.prefers(.serverToServer)), keyID: "", privateKey: MistKitClientFactoryTests.validPrivateKey ) @@ -120,7 +120,7 @@ extension AuthenticationCredentialsTests { @Test("public missing private key material throws missingRequired(\"private.key\")") internal func publicMissingPrivateKeyThrows() async throws { let config = try await MistKitClientFactoryTests.makeConfig( - database: .public, + database: .public(.prefers(.serverToServer)), keyID: "test-key-id" ) @@ -177,7 +177,7 @@ extension AuthenticationCredentialsTests { internal func publicEmbedsAPIAuthWhenAvailable() async throws { let config = try await MistKitClientFactoryTests.makeConfig( apiToken: "api", - database: .public, + database: .public(.prefers(.serverToServer)), webAuthToken: "web", keyID: "k", privateKey: MistKitClientFactoryTests.validPrivateKey @@ -194,7 +194,7 @@ extension AuthenticationCredentialsTests { internal func publicOmitsAPIAuthWhenWebAuthMissing() async throws { let config = try await MistKitClientFactoryTests.makeConfig( apiToken: "", - database: .public, + database: .public(.prefers(.serverToServer)), webAuthToken: nil, keyID: "k", privateKey: MistKitClientFactoryTests.validPrivateKey diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift index bf3047a4..16155ba3 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift @@ -71,12 +71,12 @@ internal struct TestPrivateConfigTests { // Even though we configure the base for the public DB, TestPrivateConfig // must override to `.private`. The init also requires web-auth credentials. let baseConfig = try await MistDemoConfig( - database: .public, + database: .public(.prefers(.serverToServer)), webAuthToken: "wat-xyz" ) let config = TestPrivateConfig(base: baseConfig.with(database: .private)) - #expect(config.base.database == .private) + #expect(config.base.database == MistKit.Database.private) } @Test("Memberwise init preserves base configuration values") diff --git a/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift b/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift index 90d0a737..75c90c3f 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift @@ -92,7 +92,7 @@ extension MistDemoConfig { key("container.identifier"): .init(stringLiteral: containerIdentifier), key("api.token"): .init(stringLiteral: apiToken), key("environment"): .init(stringLiteral: envString), - key("database"): .init(stringLiteral: database.rawValue), + key("database"): .init(stringLiteral: database.pathSegment), key("host"): .init(stringLiteral: host), key("port"): .init(integerLiteral: port), key("auth.timeout"): .init(integerLiteral: Int(authTimeout)), diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Database.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Database.swift index 64c36aba..d1a7106f 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Database.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Database.swift @@ -110,7 +110,7 @@ default: captured = nil } - #expect(captured == .public) + #expect(captured == .public(.prefers(.serverToServer))) } @Test("CRUD requests with an unknown `database` value return 400") diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+APIOnlyAuthentication.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+APIOnlyAuthentication.swift index dbe421db..efc186be 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+APIOnlyAuthentication.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+APIOnlyAuthentication.swift @@ -48,7 +48,7 @@ extension AuthenticationHelperTests { databaseOverride: nil ) - #expect(result.database == .public) + #expect(result.database == .public(.prefers(.serverToServer))) #expect(result.authMethod.contains("API-only")) } catch AuthenticationError.invalidAPIToken { // Expected with test token diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+AuthenticationMethodPriority.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+AuthenticationMethodPriority.swift index 5fdf8d32..c2f5bc2d 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+AuthenticationMethodPriority.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+AuthenticationMethodPriority.swift @@ -51,7 +51,7 @@ extension AuthenticationHelperTests { databaseOverride: nil ) - #expect(result.database == .public) + #expect(result.database == .public(.prefers(.serverToServer))) #expect(result.authMethod.contains("Server-to-server")) } catch AuthenticationError.invalidServerToServerCredentials { // Expected with test credentials diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+ServerToServerAuthentication.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+ServerToServerAuthentication.swift index 929d4345..89771f4f 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+ServerToServerAuthentication.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+ServerToServerAuthentication.swift @@ -70,7 +70,7 @@ extension AuthenticationHelperTests { ) // If we get here, validation succeeded (unlikely with test key) - #expect(result.database == .public) + #expect(result.database == .public(.prefers(.serverToServer))) #expect(result.authMethod.contains("Server-to-server")) } catch AuthenticationError.invalidServerToServerCredentials { // Expected - test key won't validate @@ -93,7 +93,7 @@ extension AuthenticationHelperTests { databaseOverride: nil ) - #expect(result.database == .public) + #expect(result.database == .public(.prefers(.serverToServer))) #expect(result.authMethod.contains("Server-to-server")) } catch AuthenticationError.invalidServerToServerCredentials { // Expected with test key diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+WebAuthentication.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+WebAuthentication.swift index ebe7960f..1379cb67 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+WebAuthentication.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+WebAuthentication.swift @@ -69,10 +69,10 @@ extension AuthenticationHelperTests { keyID: nil, privateKey: nil, privateKeyFile: nil, - databaseOverride: .public + databaseOverride: .public(.prefers(.serverToServer)) ) - #expect(result.database == .public) + #expect(result.database == .public(.prefers(.serverToServer))) #expect(result.authMethod.contains("Web authentication")) #expect(result.authMethod.contains("public")) } catch AuthenticationError.invalidWebAuthCredentials { diff --git a/Sources/MistKit/Authentication/Credentials/Credentials+TokenManager.swift b/Sources/MistKit/Authentication/Credentials/Credentials+TokenManager.swift index 242c0797..ee0fa22b 100644 --- a/Sources/MistKit/Authentication/Credentials/Credentials+TokenManager.swift +++ b/Sources/MistKit/Authentication/Credentials/Credentials+TokenManager.swift @@ -31,20 +31,19 @@ extension Credentials { /// Resolve the appropriate token manager for an outgoing request. /// - /// Picks among the populated `serverToServer` and `apiAuth` credentials - /// based on the target `database` and whether the route requires - /// user-context authentication: + /// The signing choice is encoded in `database`: + /// - `.public(let auth)` consults `auth` and the populated credential sets + /// per the table below. + /// - `.private` / `.shared` always use web-auth — CloudKit rejects + /// server-to-server signing on those scopes — and require + /// `apiAuth.webAuthToken`. /// - /// - `requiresUserContext == true`: web-auth is mandatory regardless of - /// database. CloudKit's user-identity routes (`fetchCaller`, - /// `lookupUsersByEmail`, `lookupUsersByRecordName`, - /// `discoverAllUserIdentities`) live on `.public` but still need - /// web-auth to identify the caller. - /// - `.public` + no user context: prefers server-to-server signing, falls - /// back to web-auth, then bare API-token. - /// - `.private` / `.shared`: requires `apiAuth.webAuthToken`. CloudKit - /// rejects server-to-server signing for these databases, so any - /// `serverToServer` material is ignored on this path. + /// Resolution for `.public(let auth)`: + /// - `auth.required` + mode's creds present → use `auth.mode`. + /// - `auth.required` + mode's creds absent → throw `.preferenceRequired`. + /// - `auth.prefers` + mode's creds present → use `auth.mode`. + /// - `auth.prefers` + mode's creds absent → fall back to the other mode. + /// - `auth.prefers` + neither mode configured → throw `.notConfigured`. /// /// - Throws: `CloudKitError.missingCredentials` when no populated credential /// set can satisfy the requested combination, @@ -52,63 +51,78 @@ extension Credentials { /// read, or any error from `ServerToServerAuthManager.init` when the PEM /// is malformed. internal func makeTokenManager( - for database: Database, - requiresUserContext: Bool = false + for database: Database ) throws -> any TokenManager { - if requiresUserContext { - return try makeUserContextTokenManager(database: database) - } switch database { - case .public: - return try makePublicTokenManager() + case .public(let auth): + return try makePublicTokenManager(auth: auth) case .private, .shared: return try makePrivateOrSharedTokenManager(database) } } - private func makeUserContextTokenManager( - database: Database + private func makePublicTokenManager( + auth: PublicAuthPreference ) throws -> any TokenManager { - guard let api = apiAuth, let webAuthToken = api.webAuthToken else { + switch auth.mode { + case .serverToServer: + return try makePublicWithS2SPreference(auth: auth) + case .webAuth: + return try makePublicWithWebAuthPreference(auth: auth) + } + } + + private func makePublicWithS2SPreference( + auth: PublicAuthPreference + ) throws -> any TokenManager { + if let s2s = serverToServer { + return try makeServerToServerManager(s2s) + } + if auth.required { throw CloudKitError.missingCredentials( - database: database, - reason: "user-context routes require apiAuth with a webAuthToken" + database: .public(auth), + availability: .preferenceRequired, + reason: "PublicAuthPreference.requires(.serverToServer) " + + "but no serverToServer credentials are configured" ) } - return WebAuthTokenManager( - apiToken: api.apiToken, - webAuthToken: webAuthToken + if let api = apiAuth { + return makeAPITokenManager(api) + } + throw CloudKitError.missingCredentials( + database: .public(auth), + availability: .notConfigured, + reason: "expected serverToServer or apiAuth credentials" ) } - private func makePublicTokenManager() throws -> any TokenManager { - if let s2s = serverToServer { - let pem: String - do { - pem = try s2s.privateKey.loadPEM() - } catch { - throw CloudKitError.invalidPrivateKey( - path: s2s.privateKey.filePath, - underlying: error - ) - } - return try ServerToServerAuthManager( - keyID: s2s.keyID, - pemString: pem + private func makePublicWithWebAuthPreference( + auth: PublicAuthPreference + ) throws -> any TokenManager { + if let api = apiAuth, let webAuthToken = api.webAuthToken { + return WebAuthTokenManager( + apiToken: api.apiToken, + webAuthToken: webAuthToken ) } + if auth.required { + throw CloudKitError.missingCredentials( + database: .public(auth), + availability: .preferenceRequired, + reason: "PublicAuthPreference.requires(.webAuth) " + + "but no apiAuth.webAuthToken is configured" + ) + } + if let s2s = serverToServer { + return try makeServerToServerManager(s2s) + } if let api = apiAuth { - if let webAuthToken = api.webAuthToken { - return WebAuthTokenManager( - apiToken: api.apiToken, - webAuthToken: webAuthToken - ) - } - return APITokenManager(apiToken: api.apiToken) + return makeAPITokenManager(api) } throw CloudKitError.missingCredentials( - database: .public, - reason: "expected serverToServer or apiAuth credentials" + database: .public(auth), + availability: .notConfigured, + reason: "expected apiAuth.webAuthToken or serverToServer credentials" ) } @@ -118,6 +132,7 @@ extension Credentials { guard let api = apiAuth, let webAuthToken = api.webAuthToken else { throw CloudKitError.missingCredentials( database: database, + availability: .notConfigured, reason: "private and shared databases require apiAuth with a webAuthToken" ) @@ -127,4 +142,34 @@ extension Credentials { webAuthToken: webAuthToken ) } + + private func makeServerToServerManager( + _ s2s: ServerToServerCredentials + ) throws -> any TokenManager { + let pem: String + do { + pem = try s2s.privateKey.loadPEM() + } catch { + throw CloudKitError.invalidPrivateKey( + path: s2s.privateKey.filePath, + underlying: error + ) + } + return try ServerToServerAuthManager( + keyID: s2s.keyID, + pemString: pem + ) + } + + private func makeAPITokenManager( + _ api: APICredentials + ) -> any TokenManager { + if let webAuthToken = api.webAuthToken { + return WebAuthTokenManager( + apiToken: api.apiToken, + webAuthToken: webAuthToken + ) + } + return APITokenManager(apiToken: api.apiToken) + } } diff --git a/Sources/MistKit/Authentication/PublicAuthPreference.swift b/Sources/MistKit/Authentication/PublicAuthPreference.swift new file mode 100644 index 00000000..74845464 --- /dev/null +++ b/Sources/MistKit/Authentication/PublicAuthPreference.swift @@ -0,0 +1,79 @@ +// +// PublicAuthPreference.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. +// + +/// Per-call attribution choice for `Database.public` requests. +/// +/// CloudKit's public database accepts two signing methods: +/// server-to-server (key-pair signed, attributed to the developer key) and +/// web-auth (user session token, attributed to the iCloud user). The same +/// server legitimately writes some records as "the app" and others as +/// "this user", so the choice is genuinely per-call. +/// +/// Construct via the static factories — `internal init` keeps the four +/// valid `(mode, required)` combinations the only reachable ones. +/// +/// ```swift +/// // Server-attributed write, fall back to web-auth if S2S isn't configured. +/// service.createRecord(..., database: .public(.prefers(.serverToServer))) +/// +/// // User-attributed write, throw if web-auth credentials aren't configured. +/// service.createRecord(..., database: .public(.requires(.webAuth))) +/// ``` +public struct PublicAuthPreference: Sendable, Hashable { + /// Which signing material to use for a `.public` request. + public enum Mode: Sendable, Hashable { + /// Sign with the server-to-server key pair. Records are attributed to + /// the developer key, not an end user. + case serverToServer + + /// Sign with the user's web-auth token. Records are attributed to the + /// iCloud user that issued the token. + case webAuth + } + + /// The signing material the caller wants. + public let mode: Mode + + /// Whether to throw if `mode`'s credentials aren't configured. + /// + /// - `true` → throw `CloudKitError.missingCredentials(availability: .preferenceRequired)`. + /// - `false` → fall back to the other configured credential set when possible. + public let required: Bool + + /// Prefer the given mode; fall back to the other if it isn't configured. + public static func prefers(_ mode: Mode) -> Self { + .init(mode: mode, required: false) + } + + /// Require the given mode; throw `missingCredentials(.preferenceRequired)` + /// if its credentials aren't configured. + public static func requires(_ mode: Mode) -> Self { + .init(mode: mode, required: true) + } +} diff --git a/Sources/MistKit/Database.swift b/Sources/MistKit/Database.swift index edfb9037..b357a819 100644 --- a/Sources/MistKit/Database.swift +++ b/Sources/MistKit/Database.swift @@ -27,11 +27,36 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +/// CloudKit database scope plus, for `.public`, the per-call attribution +/// choice between server-to-server signing and web-auth signing. +/// +/// The auth payload is part of `.public` rather than a separate parameter +/// because it only matters there — CloudKit rejects server-to-server signing +/// on `.private` and `.shared`, so those cases carry no payload. Encoding +/// the choice in the type means call sites either pick one explicitly +/// (`Database.public(.requires(.webAuth))`) or use a scope where the choice +/// doesn't exist (`Database.private`). +public enum Database: Sendable, Hashable { + /// Public database. Caller must pick a signing method via + /// `PublicAuthPreference`. + case `public`(PublicAuthPreference) -/// CloudKit database types -public enum Database: String, Sendable { - case `public` + /// Private database. Web-auth is the only valid signing method. case `private` + + /// Shared database. Web-auth is the only valid signing method. case shared + + /// The path segment used to build CloudKit Web Services URLs + /// (`/database/{version}/{container}/{environment}/{database}/…`). + public var pathSegment: String { + switch self { + case .public: + return "public" + case .private: + return "private" + case .shared: + return "shared" + } + } } diff --git a/Sources/MistKit/MistKitConfiguration+ConvenienceInitializers.swift b/Sources/MistKit/MistKitConfiguration+ConvenienceInitializers.swift index 1887d1c0..96d22a87 100644 --- a/Sources/MistKit/MistKitConfiguration+ConvenienceInitializers.swift +++ b/Sources/MistKit/MistKitConfiguration+ConvenienceInitializers.swift @@ -100,7 +100,8 @@ extension MistKitConfiguration { MistKitConfiguration( container: container, environment: environment, - database: .public, // Server-to-server only supports public database + database: .public(.requires(.serverToServer)), + // Server-to-server only supports public database apiToken: "", // Not used with server-to-server auth webAuthToken: nil, keyID: keyID, diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+AssetOperations.swift b/Sources/MistKit/Service/Extensions/CloudKitService+AssetOperations.swift index 647c31ef..ed2ada45 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+AssetOperations.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+AssetOperations.swift @@ -75,7 +75,7 @@ extension CloudKitService { fieldName: String, recordName: String? = nil, using uploader: AssetUploader? = nil, - database: Database = .public + database: Database ) async throws(CloudKitError) -> AssetUploadReceipt { let maxSize: Int = 15 * 1_024 * 1_024 guard data.count <= maxSize else { @@ -138,7 +138,7 @@ extension CloudKitService { fieldName: String, recordName: String? = nil, zoneID: ZoneID? = nil, - database: Database = .public + database: Database ) async throws(CloudKitError) -> AssetUploadToken { do { let tokenRequest = diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+Classification.swift b/Sources/MistKit/Service/Extensions/CloudKitService+Classification.swift index 9663b26b..03e621d9 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+Classification.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+Classification.swift @@ -64,11 +64,13 @@ extension CloudKitService { /// - Throws: `CloudKitError` if the underlying query fails. public func fetchExistingRecordNames( recordType: String, - limit: Int? = nil + limit: Int? = nil, + database: Database ) async throws(CloudKitError) -> Set { let result: QueryResult = try await queryRecords( recordType: recordType, - limit: limit ?? Self.maxRecordsPerRequest + limit: limit ?? Self.maxRecordsPerRequest, + database: database ) return Set(result.records.map(\.recordName)) } @@ -108,9 +110,14 @@ extension CloudKitService { public func modifyRecords( _ operations: [RecordOperation], classification: OperationClassification, - atomic: Bool = false + atomic: Bool = false, + database: Database ) async throws(CloudKitError) -> BatchSyncResult { - let records = try await modifyRecords(operations, atomic: atomic) + let records = try await modifyRecords( + operations, + atomic: atomic, + database: database + ) return BatchSyncResult(records: records, classification: classification) } } diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+ClientDispatch.swift b/Sources/MistKit/Service/Extensions/CloudKitService+ClientDispatch.swift index b8ffdf7a..88b041e2 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+ClientDispatch.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+ClientDispatch.swift @@ -35,29 +35,29 @@ extension CloudKitService { /// Resolve the token manager for an outgoing request and build a fresh /// OpenAPI `Client` whose middleware chain authenticates against it. /// - /// Called once per dispatched operation. When the service was built with a - /// caller-supplied `tokenManager:`, that fixed manager is used regardless of - /// `database` / `requiresUserContext`. Otherwise `Credentials` picks an - /// appropriate manager via its `makeTokenManager(for:requiresUserContext:)` - /// extension. + /// Called once per dispatched operation. The signing choice for `.public` + /// requests is carried by the `Database` value itself + /// (`.public(PublicAuthPreference)`); `.private` / `.shared` always use + /// web-auth. + /// + /// When the service was built with a caller-supplied `tokenManager:`, that + /// fixed manager is used regardless of `database`. Otherwise `Credentials` + /// resolves the manager via `makeTokenManager(for:)`. /// /// - Throws: `CloudKitError.missingCredentials` when `Credentials` cannot /// satisfy the requested combination. internal func client( - for database: Database, - requiresUserContext: Bool = false + for database: Database ) throws -> Client { let tokenManager: any TokenManager if let fixedTokenManager { tokenManager = fixedTokenManager } else if let credentials { - tokenManager = try credentials.makeTokenManager( - for: database, - requiresUserContext: requiresUserContext - ) + tokenManager = try credentials.makeTokenManager(for: database) } else { throw CloudKitError.missingCredentials( database: database, + availability: .notConfigured, reason: "service has neither credentials nor a fixed token manager" ) } diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+LookupOperations.swift b/Sources/MistKit/Service/Extensions/CloudKitService+LookupOperations.swift index 99b0e91a..6ec8adc6 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+LookupOperations.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+LookupOperations.swift @@ -39,7 +39,7 @@ extension CloudKitService { internal func modifyRecords( operations: [Components.Schemas.RecordOperation], atomic: Bool = true, - database: Database = .public + database: Database ) async throws(CloudKitError) -> [RecordInfo] { do { let client = try self.client(for: database) @@ -71,7 +71,7 @@ extension CloudKitService { public func lookupRecords( recordNames: [String], desiredKeys: [String]? = nil, - database: Database = .public + database: Database ) async throws(CloudKitError) -> [RecordInfo] { do { let client = try self.client(for: database) diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+Operations.swift b/Sources/MistKit/Service/Extensions/CloudKitService+Operations.swift index 21292185..32eebec6 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+Operations.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+Operations.swift @@ -96,7 +96,7 @@ extension CloudKitService { sortBy: [QuerySort]? = nil, limit: Int? = nil, desiredKeys: [String]? = nil, - database: Database = .public + database: Database ) async throws(CloudKitError) -> [RecordInfo] { let result: QueryResult = try await queryRecords( recordType: recordType, @@ -149,7 +149,7 @@ extension CloudKitService { limit: Int? = nil, desiredKeys: [String]? = nil, continuationMarker: String? = nil, - database: Database = .public + database: Database ) async throws(CloudKitError) -> QueryResult { let effectiveLimit = limit ?? defaultQueryLimit diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+QueryPagination.swift b/Sources/MistKit/Service/Extensions/CloudKitService+QueryPagination.swift index 0ce61153..7c423aea 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+QueryPagination.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+QueryPagination.swift @@ -62,7 +62,7 @@ extension CloudKitService { pageSize: Int? = nil, desiredKeys: [String]? = nil, maxPages: Int = 1_000, - database: Database = .public + database: Database ) async throws(CloudKitError) -> [RecordInfo] { var allRecords: [RecordInfo] = [] var currentMarker: String? diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+RecordManaging.swift b/Sources/MistKit/Service/Extensions/CloudKitService+RecordManaging.swift index c6558538..ecf1e0c1 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+RecordManaging.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+RecordManaging.swift @@ -36,6 +36,12 @@ import Foundation @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) extension CloudKitService: RecordManaging { /// Query records of a specific type from CloudKit (deprecated single-page form) + /// + /// `RecordManaging` is a database-agnostic abstraction predating per-call + /// `PublicAuthPreference`; this conformance targets the public database + /// with `.requires(.serverToServer)` to preserve the previous "S2S when + /// configured" behavior. Callers who need different attribution should + /// call `CloudKitService` directly with an explicit `Database` value. @available( *, deprecated, message: "Silently truncates at one page. Use queryAllRecords or queryRecords -> QueryResult." @@ -47,7 +53,8 @@ extension CloudKitService: RecordManaging { sortBy: nil, limit: 200, desiredKeys: nil, - continuationMarker: nil + continuationMarker: nil, + database: .public(.prefers(.serverToServer)) ) return result.records } @@ -57,7 +64,10 @@ extension CloudKitService: RecordManaging { _ operations: [RecordOperation], recordType: String ) async throws { - _ = try await self.modifyRecords(operations) + _ = try await self.modifyRecords( + operations, + database: .public(.prefers(.serverToServer)) + ) } /// Query all records of a specific type, automatically paginating @@ -66,7 +76,8 @@ extension CloudKitService: RecordManaging { recordType: recordType, filters: nil, sortBy: nil, - pageSize: nil + pageSize: nil, + database: .public(.prefers(.serverToServer)) ) } } diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+SyncOperations.swift b/Sources/MistKit/Service/Extensions/CloudKitService+SyncOperations.swift index d7af0c32..a6a5b0eb 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+SyncOperations.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+SyncOperations.swift @@ -81,7 +81,7 @@ extension CloudKitService { zoneID: ZoneID? = nil, syncToken: String? = nil, resultsLimit: Int? = nil, - database: Database = .public + database: Database ) async throws(CloudKitError) -> RecordChangesResult { if let limit = resultsLimit { guard limit > 0 && limit <= 200 else { @@ -166,7 +166,7 @@ extension CloudKitService { syncToken: String? = nil, resultsLimit: Int? = nil, maxPages: Int = 1_000, - database: Database = .public + database: Database ) async throws(CloudKitError) -> (records: [RecordInfo], syncToken: String?) { var allRecords: [RecordInfo] = [] var currentToken = syncToken diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+UserOperations.swift b/Sources/MistKit/Service/Extensions/CloudKitService+UserOperations.swift index 7156981b..d119473e 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+UserOperations.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+UserOperations.swift @@ -50,13 +50,13 @@ extension CloudKitService { /// `Credentials` must include an `apiAuth` with a `webAuthToken`. public func fetchCaller() async throws(CloudKitError) -> UserInfo { do { - let client = try self.client(for: .public, requiresUserContext: true) + let client = try self.client(for: .public(.requires(.webAuth))) let response = try await client.getCaller( .init( path: Operations.getCaller.Input.Path( containerIdentifier: containerIdentifier, environment: environment, - database: .public + database: .public(.requires(.webAuth)) ) ) ) @@ -91,13 +91,13 @@ extension CloudKitService { ) public func discoverAllUserIdentities() async throws(CloudKitError) -> [UserIdentity] { do { - let client = try self.client(for: .public, requiresUserContext: true) + let client = try self.client(for: .public(.requires(.webAuth))) let response = try await client.discoverAllUserIdentities( .init( path: Operations.discoverAllUserIdentities.Input.Path( containerIdentifier: containerIdentifier, environment: environment, - database: .public + database: .public(.requires(.webAuth)) ) ) ) @@ -121,13 +121,13 @@ extension CloudKitService { _ emails: [String] ) async throws(CloudKitError) -> [UserIdentity] { do { - let client = try self.client(for: .public, requiresUserContext: true) + let client = try self.client(for: .public(.requires(.webAuth))) let response = try await client.lookupUsersByEmail( .init( path: Operations.lookupUsersByEmail.Input.Path( containerIdentifier: containerIdentifier, environment: environment, - database: .public + database: .public(.requires(.webAuth)) ), body: .json( .init(users: emails.map { .init(emailAddress: $0) }) @@ -151,13 +151,13 @@ extension CloudKitService { _ recordNames: [String] ) async throws(CloudKitError) -> [UserIdentity] { do { - let client = try self.client(for: .public, requiresUserContext: true) + let client = try self.client(for: .public(.requires(.webAuth))) let response = try await client.lookupUsersByRecordName( .init( path: Operations.lookupUsersByRecordName.Input.Path( containerIdentifier: containerIdentifier, environment: environment, - database: .public + database: .public(.requires(.webAuth)) ), body: .json( .init(users: recordNames.map { .init(userRecordName: $0) }) @@ -181,13 +181,13 @@ extension CloudKitService { lookupInfos: [UserIdentityLookupInfo] ) async throws(CloudKitError) -> [UserIdentity] { do { - let client = try self.client(for: .public, requiresUserContext: true) + let client = try self.client(for: .public(.requires(.webAuth))) let response = try await client.discoverUserIdentities( .init( path: Operations.discoverUserIdentities.Input.Path( containerIdentifier: containerIdentifier, environment: environment, - database: .public + database: .public(.requires(.webAuth)) ), body: .json( .init( diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+WriteOperations.swift b/Sources/MistKit/Service/Extensions/CloudKitService+WriteOperations.swift index 2cf7874c..801d3783 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+WriteOperations.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+WriteOperations.swift @@ -49,7 +49,7 @@ extension CloudKitService { public func modifyRecords( _ operations: [RecordOperation], atomic: Bool = false, - database: Database = .public + database: Database ) async throws(CloudKitError) -> [RecordInfo] { do { let apiOperations = try operations.map { @@ -97,7 +97,7 @@ extension CloudKitService { recordType: String, recordName: String? = nil, fields: [String: FieldValue], - database: Database = .public + database: Database ) async throws(CloudKitError) -> RecordInfo { let operation = RecordOperation.create( recordType: recordType, @@ -125,7 +125,7 @@ extension CloudKitService { recordName: String, fields: [String: FieldValue], recordChangeTag: String? = nil, - database: Database = .public + database: Database ) async throws(CloudKitError) -> RecordInfo { let operation = RecordOperation.update( recordType: recordType, @@ -151,7 +151,7 @@ extension CloudKitService { recordType: String, recordName: String, recordChangeTag: String? = nil, - database: Database = .public + database: Database ) async throws(CloudKitError) { let operation = RecordOperation.delete( recordType: recordType, diff --git a/Sources/MistKit/Service/ResponseProcessing/CloudKitError.swift b/Sources/MistKit/Service/ResponseProcessing/CloudKitError.swift index ef57a1e4..4d5d416e 100644 --- a/Sources/MistKit/Service/ResponseProcessing/CloudKitError.swift +++ b/Sources/MistKit/Service/ResponseProcessing/CloudKitError.swift @@ -45,7 +45,11 @@ public enum CloudKitError: LocalizedError, Sendable { case networkError(URLError) case unsupportedOperationType(String) case paginationLimitExceeded(maxPages: Int, records: [RecordInfo]) - case missingCredentials(database: Database, reason: String) + case missingCredentials( + database: Database, + availability: CredentialAvailability = .notConfigured, + reason: String + ) case invalidPrivateKey(path: String?, underlying: any Error) /// HTTP status code if this error originated from an HTTP response, otherwise nil. @@ -127,9 +131,17 @@ public enum CloudKitError: LocalizedError, Sendable { return "CloudKit query exceeded pagination limit of \(maxPages) pages " + "(collected \(records.count) records)" - case .missingCredentials(let database, let reason): + case .missingCredentials(let database, let availability, let reason): + let availabilityLabel: String + switch availability { + case .notConfigured: + availabilityLabel = "not configured" + case .preferenceRequired: + availabilityLabel = "required by preference but not configured" + } return - "Missing credentials for database '\(database.rawValue)': \(reason)" + "Missing credentials for database '\(database.pathSegment)' " + + "(\(availabilityLabel)): \(reason)" case .invalidPrivateKey(let path, let underlying): let location = path.map { "from '\($0)'" } ?? "from inline material" return diff --git a/Sources/MistKit/Service/ResponseProcessing/CredentialAvailability.swift b/Sources/MistKit/Service/ResponseProcessing/CredentialAvailability.swift new file mode 100644 index 00000000..a5d8eb2d --- /dev/null +++ b/Sources/MistKit/Service/ResponseProcessing/CredentialAvailability.swift @@ -0,0 +1,46 @@ +// +// CredentialAvailability.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. +// + +/// Why a credential set was missing when the dispatcher tried to satisfy +/// a request. +/// +/// Attached to `CloudKitError.missingCredentials(_:availability:reason:)` so +/// callers can distinguish a misconfiguration ("no credentials at all") from +/// a deliberate `PublicAuthPreference.requires(...)` that couldn't be +/// satisfied ("we have web-auth but the caller required server-to-server"). +public enum CredentialAvailability: Sendable, Hashable { + /// No credential of the type the route needs is configured on + /// `Credentials`. + case notConfigured + + /// A credential type was required by `PublicAuthPreference.requires(_:)` + /// but is not configured. The dispatcher refuses to silently substitute + /// the other credential set. + case preferenceRequired +} diff --git a/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PrivateKeyLoad.swift b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PrivateKeyLoad.swift index 0d8db709..2560f94f 100644 --- a/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PrivateKeyLoad.swift +++ b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PrivateKeyLoad.swift @@ -48,7 +48,7 @@ extension CredentialsTokenManagerTests { ) ) do { - _ = try credentials.makeTokenManager(for: .public) + _ = try credentials.makeTokenManager(for: .public(.requires(.serverToServer))) Issue.record("expected makeTokenManager to throw .invalidPrivateKey") } catch let error as CloudKitError { guard case .invalidPrivateKey(let path, _) = error else { diff --git a/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PublicDatabase.swift b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PublicDatabase.swift index b0b72c24..7e2354b4 100644 --- a/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PublicDatabase.swift +++ b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PublicDatabase.swift @@ -35,8 +35,10 @@ import Testing extension CredentialsTokenManagerTests { @Suite("Public Database") internal struct PublicDatabase { - @Test(".public + serverToServer → ServerToServerAuthManager") - internal func publicPicksServerToServer() async throws { + // MARK: - prefers(.serverToServer) + + @Test(".public(.prefers(.serverToServer)) + S2S only → S2S") + internal func prefersS2SOnlyS2SPicksS2S() async throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { Issue.record("ServerToServerAuthManager is not available on this operating system.") return @@ -44,36 +46,104 @@ extension CredentialsTokenManagerTests { let credentials = try Credentials( serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials() ) - let manager = try credentials.makeTokenManager(for: .public) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.serverToServer)) + ) + #expect(manager is ServerToServerAuthManager) + } + + @Test(".public(.prefers(.serverToServer)) + both creds → S2S") + internal func prefersS2SBothCredsPicksS2S() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials(), + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() + ) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.serverToServer)) + ) #expect(manager is ServerToServerAuthManager) } - @Test(".public + apiAuth.webAuthToken → WebAuthTokenManager") - internal func publicPicksWebAuthOverAPIToken() async throws { + @Test(".public(.prefers(.serverToServer)) + web-auth only → falls back to web-auth") + internal func prefersS2SOnlyWebAuthFallsBackToWebAuth() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() + ) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.serverToServer)) + ) + #expect(manager is WebAuthTokenManager) + } + + @Test(".public(.prefers(.serverToServer)) + API token only → APITokenManager") + internal func prefersS2SAPITokenOnlyFallsBackToAPIToken() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsTokenOnly() + ) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.serverToServer)) + ) + #expect(manager is APITokenManager) + } + + // MARK: - prefers(.webAuth) + + @Test(".public(.prefers(.webAuth)) + both creds → web-auth") + internal func prefersWebAuthBothCredsPicksWebAuth() async throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials(), apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() ) - let manager = try credentials.makeTokenManager(for: .public) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.webAuth)) + ) #expect(manager is WebAuthTokenManager) } - @Test(".public + apiAuth (token only) → APITokenManager") - internal func publicPicksAPITokenWhenNoWebAuth() async throws { + @Test(".public(.prefers(.webAuth)) + S2S only → falls back to S2S") + internal func prefersWebAuthOnlyS2SFallsBackToS2S() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials() + ) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.webAuth)) + ) + #expect(manager is ServerToServerAuthManager) + } + + @Test(".public(.prefers(.webAuth)) + API token only → APITokenManager") + internal func prefersWebAuthAPITokenOnlyFallsBackToAPIToken() async throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } let credentials = try Credentials( apiAuth: CredentialsTokenManagerTests.makeAPICredentialsTokenOnly() ) - let manager = try credentials.makeTokenManager(for: .public) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.webAuth)) + ) #expect(manager is APITokenManager) } - @Test(".public + serverToServer prefers S2S over apiAuth") - internal func publicPrefersServerToServerOverAPIAuth() async throws { + // MARK: - requires(.serverToServer) + + @Test(".public(.requires(.serverToServer)) + both creds → S2S") + internal func requiresS2SBothCredsPicksS2S() async throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } @@ -81,8 +151,76 @@ extension CredentialsTokenManagerTests { serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials(), apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() ) - let manager = try credentials.makeTokenManager(for: .public) + let manager = try credentials.makeTokenManager( + for: .public(.requires(.serverToServer)) + ) #expect(manager is ServerToServerAuthManager) } + + @Test(".public(.requires(.serverToServer)) without S2S → throws preferenceRequired") + internal func requiresS2SWithoutS2SThrowsPreferenceRequired() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() + ) + #expect { + _ = try credentials.makeTokenManager( + for: .public(.requires(.serverToServer)) + ) + } throws: { error in + guard + let cloudKitError = error as? CloudKitError, + case .missingCredentials(_, let availability, _) = cloudKitError + else { return false } + return availability == .preferenceRequired + } + } + + // MARK: - requires(.webAuth) + + @Test(".public(.requires(.webAuth)) + both creds → web-auth") + internal func requiresWebAuthBothCredsPicksWebAuth() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials(), + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() + ) + let manager = try credentials.makeTokenManager( + for: .public(.requires(.webAuth)) + ) + #expect(manager is WebAuthTokenManager) + } + + @Test(".public(.requires(.webAuth)) without web-auth → throws preferenceRequired") + internal func requiresWebAuthWithoutWebAuthThrowsPreferenceRequired() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials() + ) + #expect { + _ = try credentials.makeTokenManager( + for: .public(.requires(.webAuth)) + ) + } throws: { error in + guard + let cloudKitError = error as? CloudKitError, + case .missingCredentials(_, let availability, _) = cloudKitError + else { return false } + return availability == .preferenceRequired + } + } + + // Note: The "no creds at all" path in the dispatcher's resolution table + // (".prefers + neither mode configured → throws notConfigured") is not + // tested here because `Credentials.init` asserts that at least one of + // `serverToServer` or `apiAuth` is populated. Reaching `notConfigured` + // would require constructing an empty `Credentials`, which the type + // doesn't permit. } } diff --git a/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+UserContext.swift b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+UserContext.swift index 3beecfe5..4774b0bf 100644 --- a/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+UserContext.swift +++ b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+UserContext.swift @@ -33,10 +33,15 @@ import Testing @testable import MistKit extension CredentialsTokenManagerTests { + /// Coverage for the "user-context" routes (`users/caller`, + /// `users/lookup/*`, `users/discover`). With the per-call + /// `PublicAuthPreference` rewrite these no longer take a separate + /// `requiresUserContext` flag — they pass `.public(.requires(.webAuth))` + /// directly to the dispatcher. @Suite("User-Context Branch") internal struct UserContext { - @Test("requiresUserContext on .public → WebAuthTokenManager") - internal func userContextOnPublicPicksWebAuth() async throws { + @Test(".public(.requires(.webAuth)) + both creds → web-auth (S2S ignored)") + internal func requiresWebAuthOnPublicIgnoresS2S() async throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } @@ -45,14 +50,13 @@ extension CredentialsTokenManagerTests { apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() ) let manager = try credentials.makeTokenManager( - for: .public, requiresUserContext: true + for: .public(.requires(.webAuth)) ) - // S2S is present, but user-context routes ignore it — must pick web-auth. #expect(manager is WebAuthTokenManager) } - @Test("requiresUserContext without web-auth → throws missingCredentials") - internal func userContextWithoutWebAuthThrows() async throws { + @Test(".public(.requires(.webAuth)) + S2S only → throws preferenceRequired") + internal func requiresWebAuthWithoutWebAuthThrows() async throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } @@ -61,13 +65,13 @@ extension CredentialsTokenManagerTests { ) #expect(throws: CloudKitError.self) { _ = try credentials.makeTokenManager( - for: .public, requiresUserContext: true + for: .public(.requires(.webAuth)) ) } } - @Test("requiresUserContext with apiAuth (token only) → throws missingCredentials") - internal func userContextWithAPITokenOnlyThrows() async throws { + @Test(".public(.requires(.webAuth)) + API token only → throws preferenceRequired") + internal func requiresWebAuthWithAPITokenOnlyThrows() async throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } @@ -76,65 +80,7 @@ extension CredentialsTokenManagerTests { ) #expect(throws: CloudKitError.self) { _ = try credentials.makeTokenManager( - for: .public, requiresUserContext: true - ) - } - } - - @Test("requiresUserContext on .private + web-auth → WebAuthTokenManager") - internal func userContextOnPrivatePicksWebAuth() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - let credentials = try Credentials( - apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() - ) - let manager = try credentials.makeTokenManager( - for: .private, requiresUserContext: true - ) - #expect(manager is WebAuthTokenManager) - } - - @Test("requiresUserContext on .shared + web-auth → WebAuthTokenManager") - internal func userContextOnSharedPicksWebAuth() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - let credentials = try Credentials( - apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() - ) - let manager = try credentials.makeTokenManager( - for: .shared, requiresUserContext: true - ) - #expect(manager is WebAuthTokenManager) - } - - @Test("requiresUserContext on .private + S2S only → throws missingCredentials") - internal func userContextOnPrivateRejectsServerToServerOnly() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - let credentials = try Credentials( - serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials() - ) - #expect(throws: CloudKitError.self) { - _ = try credentials.makeTokenManager( - for: .private, requiresUserContext: true - ) - } - } - - @Test("requiresUserContext on .shared + S2S only → throws missingCredentials") - internal func userContextOnSharedRejectsServerToServerOnly() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - let credentials = try Credentials( - serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials() - ) - #expect(throws: CloudKitError.self) { - _ = try credentials.makeTokenManager( - for: .shared, requiresUserContext: true + for: .public(.requires(.webAuth)) ) } } diff --git a/Tests/MistKitTests/Core/DatabaseTests.swift b/Tests/MistKitTests/Core/DatabaseTests.swift index be56e064..679290f5 100644 --- a/Tests/MistKitTests/Core/DatabaseTests.swift +++ b/Tests/MistKitTests/Core/DatabaseTests.swift @@ -6,11 +6,12 @@ import Testing /// Test suite for Database enum functionality and behavior validation @Suite("Database") internal struct DatabaseTests { - /// Tests Database enum raw values - @Test("Database enum raw values") - internal func databaseRawValues() { - #expect(Database.public.rawValue == "public") - #expect(Database.private.rawValue == "private") - #expect(Database.shared.rawValue == "shared") + /// Tests that each Database scope produces the expected URL path segment. + @Test("Database pathSegment values") + internal func databasePathSegments() { + #expect(Database.public(.prefers(.serverToServer)).pathSegment == "public") + #expect(Database.public(.requires(.webAuth)).pathSegment == "public") + #expect(Database.private.pathSegment == "private") + #expect(Database.shared.pathSegment == "shared") } } diff --git a/Tests/MistKitTests/PublicTypes/CloudKitErrorTests.swift b/Tests/MistKitTests/PublicTypes/CloudKitErrorTests.swift new file mode 100644 index 00000000..b3ebc9c6 --- /dev/null +++ b/Tests/MistKitTests/PublicTypes/CloudKitErrorTests.swift @@ -0,0 +1,65 @@ +// +// CloudKitErrorTests.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. +// + +import Foundation +import Testing + +@testable import MistKit + +@Suite("CloudKitError") +internal struct CloudKitErrorTests { + @Test(".missingCredentials with .notConfigured describes as not configured") + internal func missingCredentialsNotConfiguredDescribesAsNotConfigured() throws { + let error = CloudKitError.missingCredentials( + database: .public(.prefers(.webAuth)), + availability: .notConfigured, + reason: "no API token provided" + ) + + let description = try #require(error.errorDescription) + #expect(description.contains("public")) + #expect(description.contains("not configured")) + #expect(!description.contains("required by preference")) + #expect(description.contains("no API token provided")) + } + + @Test(".missingCredentials with .preferenceRequired describes as preference required") + internal func missingCredentialsPreferenceRequiredDescribesAsPreferenceRequired() throws { + let error = CloudKitError.missingCredentials( + database: .public(.requires(.webAuth)), + availability: .preferenceRequired, + reason: "web-auth preference required" + ) + + let description = try #require(error.errorDescription) + #expect(description.contains("public")) + #expect(description.contains("required by preference but not configured")) + #expect(description.contains("web-auth preference required")) + } +} diff --git a/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+Concurrent.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+Concurrent.swift index c50c1dca..82cb0ba9 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+Concurrent.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+Concurrent.swift @@ -53,7 +53,7 @@ extension CloudKitServiceTests.FetchChanges { ) { group in for _ in 0.. Date: Thu, 14 May 2026 09:10:52 -0400 Subject: [PATCH 6/8] Address PR #339 review: use CKDatabase.Scope, fix web-auth-token routing + scheme env - Replace CloudKitStore.DatabaseScope with CKDatabase.Scope; new CKDatabaseScope+Demo.swift extension provides the demo-scoped selectable list ([.public, .private]) and label. - Route CKFetchWebAuthTokenOperation through container.privateCloudDatabase unconditionally; the operation is documented to require the private database and was previously running against the user-selected scope. - Migrate fetchWebAuthTokenCompletionBlock -> fetchWebAuthTokenResultBlock (the completion-block API is deprecated in macOS 12+); drop the now- unreachable webAuthTokenUnavailable error case. - Bake CLOUDKIT_API_TOKEN into the macOS + iOS scheme run actions so xcodegen substitutes the .env value AccountView already reads from ProcessInfo at launch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Services/CKDatabaseScope+Demo.swift | 47 +++++++++++++++++++ .../MistDemoApp/Services/CloudKitStore.swift | 41 ++++------------ .../Services/CloudKitStoreError.swift | 3 -- .../MistDemoApp/Views/AccountView.swift | 2 +- Examples/MistDemo/project.yml | 4 ++ 5 files changed, 60 insertions(+), 37 deletions(-) create mode 100644 Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabaseScope+Demo.swift diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabaseScope+Demo.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabaseScope+Demo.swift new file mode 100644 index 00000000..37ff7b20 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabaseScope+Demo.swift @@ -0,0 +1,47 @@ +// +// CKDatabaseScope+Demo.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. +// + +#if canImport(CloudKit) && !os(tvOS) && !os(watchOS) + import CloudKit + + extension CKDatabase.Scope { + /// Scopes exposed in the MistDemoApp picker. `.shared` is intentionally + /// excluded because the demo's `schema.ckdb` has no shared zones. + internal static let selectable: [CKDatabase.Scope] = [.public, .private] + + internal var label: String { + switch self { + case .public: return "Public" + case .private: return "Private" + case .shared: return "Shared" + @unknown default: return "Unknown" + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift index 0701dc79..78e5d642 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift @@ -40,38 +40,18 @@ @Observable @MainActor public final class CloudKitStore { - /// Public or private CloudKit database, selectable at runtime. - public enum DatabaseScope: String, CaseIterable, Identifiable, Sendable { - case `public` - case `private` - - public var id: String { rawValue } - - public var label: String { - switch self { - case .public: return "Public" - case .private: return "Private" - } - } - } - /// The shared demo container identifier — must match `MistDemoConfig.containerIdentifier`. public static let demoContainerIdentifier = "iCloud.com.brightdigit.MistDemo" internal var accountStatus: CKAccountStatus = .couldNotDetermine internal var lastError: String? - internal var databaseScope: DatabaseScope = .private + internal var databaseScope: CKDatabase.Scope = .private internal let containerIdentifier: String @ObservationIgnored private let container: CKContainer /// The CloudKit database for the current `databaseScope`. - internal var database: CKDatabase { - switch databaseScope { - case .public: return container.publicCloudDatabase - case .private: return container.privateCloudDatabase - } - } + internal var database: CKDatabase { container.database(with: databaseScope) } /// Creates a new service for the given CloudKit container. /// - Parameter containerIdentifier: The CloudKit container identifier. @@ -193,18 +173,13 @@ try await withCheckedThrowingContinuation { continuation in let operation = CKFetchWebAuthTokenOperation(apiToken: apiToken) operation.qualityOfService = .userInitiated - operation.fetchWebAuthTokenCompletionBlock = { token, error in - if let token { - continuation.resume(returning: token) - } else { - continuation.resume( - throwing: error ?? CloudKitStoreError.webAuthTokenUnavailable - ) - } + operation.fetchWebAuthTokenResultBlock = { result in + continuation.resume(with: result) } - // CKFetchWebAuthTokenOperation is a CKDatabaseOperation; running it - // against the selected database picks up the demo container. - database.add(operation) + // CKFetchWebAuthTokenOperation must run against the private database + // regardless of the user's scope selection — running it on the public + // database fails or returns an unattributed token. + container.privateCloudDatabase.add(operation) } } } diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift index 413c0be8..8e334fd6 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift @@ -33,14 +33,11 @@ /// Errors specific to `CloudKitStore` operations. internal enum CloudKitStoreError: Error, LocalizedError { case unexpectedSaveResult - case webAuthTokenUnavailable internal var errorDescription: String? { switch self { case .unexpectedSaveResult: return "CloudKit returned a record that couldn't be parsed as a Note." - case .webAuthTokenUnavailable: - return "CloudKit returned no web auth token and no error." } } } diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift index 4ddd01ec..eb052668 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift @@ -63,7 +63,7 @@ Section("Container") { LabeledContent("Container", value: service.containerIdentifier) Picker("Database", selection: $bindable.databaseScope) { - ForEach(CloudKitStore.DatabaseScope.allCases) { scope in + ForEach(CKDatabase.Scope.selectable, id: \.self) { scope in Text(scope.label).tag(scope) } } diff --git a/Examples/MistDemo/project.yml b/Examples/MistDemo/project.yml index cac19679..3e9f6b46 100644 --- a/Examples/MistDemo/project.yml +++ b/Examples/MistDemo/project.yml @@ -73,6 +73,8 @@ schemes: MistDemoApp-macOS: all run: config: Debug + environmentVariables: + CLOUDKIT_API_TOKEN: ${CLOUDKIT_API_TOKEN} test: config: Debug archive: @@ -84,6 +86,8 @@ schemes: MistDemoApp-iOS: all run: config: Debug + environmentVariables: + CLOUDKIT_API_TOKEN: ${CLOUDKIT_API_TOKEN} test: config: Debug archive: From bdf23131673fa9c894e3869de957469fd1cfe325 Mon Sep 17 00:00:00 2001 From: leogdion Date: Thu, 14 May 2026 09:43:19 -0400 Subject: [PATCH 7/8] Mark CloudKitStore.fetchWebAuthToken nonisolated to fix CK callback crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The continuation body inherited @MainActor isolation from CloudKitStore, which tripped a dispatch_assert_queue assertion on com.apple.cloudkit.callback when CKFetchWebAuthTokenOperation's result block fired — crashing with EXC_BREAKPOINT in _dispatch_assert_queue_fail on macOS 26.5. Marking the bridge nonisolated lets the operation enqueue + callback dispatch run off the main actor. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift index 78e5d642..27a87f1a 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift @@ -169,7 +169,7 @@ /// Capture a web-auth token via `CKFetchWebAuthTokenOperation` for the /// given CloudKit API token. Issues the same `158__…` value that /// MistKit / `mistdemo auth-token` consume. - internal func fetchWebAuthToken(apiToken: String) async throws -> String { + nonisolated internal func fetchWebAuthToken(apiToken: String) async throws -> String { try await withCheckedThrowingContinuation { continuation in let operation = CKFetchWebAuthTokenOperation(apiToken: apiToken) operation.qualityOfService = .userInitiated From 4f74f7879b98724839dea27bb3fd4b2411cff52f Mon Sep 17 00:00:00 2001 From: leogdion Date: Thu, 14 May 2026 09:50:46 -0400 Subject: [PATCH 8/8] Add owner "You" badge and newest-first sort to native MistDemo Mirrors the web demo: track the signed-in user's record name via CKContainer.userRecordID, capture each note's creator from CKRecord.creatorUserRecordID, and tag matching rows in QueryView. Also sorts Notes by creationDate desc with modificationDate desc as the tiebreaker, matching the web demo's default ordering. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Sources/MistDemoApp/Models/Note.swift | 2 ++ .../MistDemoApp/Services/CloudKitStore.swift | 29 ++++++++++++++++--- .../Sources/MistDemoApp/Views/QueryView.swift | 27 ++++++++++++++++- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift b/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift index 1d83c752..6b3e396f 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift @@ -62,6 +62,7 @@ internal let modificationDate: Date? internal let creationDate: Date? internal let recordChangeTag: String? + internal let creatorUserRecordName: String? internal init?(_ record: CKRecord) { guard record.recordType == Self.recordType else { @@ -74,6 +75,7 @@ self.modificationDate = record.modificationDate self.creationDate = record.creationDate self.recordChangeTag = record.recordChangeTag + self.creatorUserRecordName = record.creatorUserRecordID?.recordName } // Identity-based equality: two Notes with the same recordID are equal diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift index 27a87f1a..d183db28 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift @@ -47,6 +47,11 @@ internal var lastError: String? internal var databaseScope: CKDatabase.Scope = .private + /// The signed-in iCloud user's record name. Mirrors `currentUserRecordName` + /// in the web demo and is used to flag the "You" badge on notes the + /// current user created. + internal var currentUserRecordName: String? + internal let containerIdentifier: String @ObservationIgnored private let container: CKContainer @@ -81,6 +86,17 @@ self.accountStatus = .couldNotDetermine self.lastError = error.localizedDescription } + if accountStatus == .available { + do { + let recordID = try await container.userRecordID() + self.currentUserRecordName = recordID.recordName + } catch { + self.currentUserRecordName = nil + self.lastError = error.localizedDescription + } + } else { + self.currentUserRecordName = nil + } } /// List all record zones in the selected database (parity with `mistdemo lookup-zones`). @@ -89,13 +105,18 @@ return zones.map(ZoneRow.init).sorted { $0.zoneName < $1.zoneName } } - /// Query `Note` records from the selected database, sorted by `index` - /// (parity with `mistdemo query --record-type Note --sort index`). - /// Note's schema is defined in `schema.ckdb`. + /// Query `Note` records from the selected database, newest first — + /// primary sort on creation date desc, modification date desc as the + /// tiebreaker. Matches the web demo's default sort. + /// Note's schema is defined in `schema.ckdb` (`___createTime` and + /// `___modTime` are both `SORTABLE`). internal func queryNotes(limit: Int = 50) async throws -> [Note] { let predicate = NSPredicate(value: true) let query = CKQuery(recordType: Note.recordType, predicate: predicate) - query.sortDescriptors = [NSSortDescriptor(key: Note.Fields.index, ascending: true)] + query.sortDescriptors = [ + NSSortDescriptor(key: "creationDate", ascending: false), + NSSortDescriptor(key: "modificationDate", ascending: false), + ] let (matchResults, _) = try await database.records( matching: query, diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift index f8fd74d3..fc88696d 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift @@ -69,7 +69,12 @@ List(notes, selection: $selectedNote) { note in NavigationLink(value: note) { VStack(alignment: .leading, spacing: 2) { - Text(note.title ?? note.id).font(.body) + HStack(spacing: 8) { + Text(note.title ?? note.id).font(.body) + if isOwnedByCurrentUser(note) { + ownerBadge(creator: note.creatorUserRecordName) + } + } HStack(spacing: 12) { if let index = note.index { Label("\(index)", systemImage: "number") @@ -136,6 +141,26 @@ } } + /// Mirrors the web demo's "You" badge — flag notes the signed-in user + /// created. CloudKit may stamp the creator as `__defaultOwner__` for + /// records the caller just created, so accept that sentinel as well. + private func isOwnedByCurrentUser(_ note: Note) -> Bool { + guard let creator = note.creatorUserRecordName else { return false } + if creator == "__defaultOwner__" { return true } + return creator == service.currentUserRecordName + } + + private func ownerBadge(creator: String?) -> some View { + Text("You") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.green.opacity(0.2), in: Capsule()) + .foregroundStyle(.green) + .accessibilityLabel("Created by you") + .help(creator.map { "Created by \($0)" } ?? "Created by you") + } + private func runQuery() async { loading = true loadError = nil