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/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..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/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/NativeCloudKitService.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift similarity index 61% rename from Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitService.swift rename to Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift index 58209591..d183db28 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,34 @@ #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 { /// 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: 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 - 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 { container.database(with: databaseScope) } /// Creates a new service for the given CloudKit container. /// - Parameter containerIdentifier: The CloudKit container identifier. @@ -79,21 +86,37 @@ 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 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`). - /// 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, @@ -129,19 +152,21 @@ // 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. + /// 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 { @@ -150,41 +175,32 @@ 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 } - /// 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) + _ = try await database.deleteRecord( + withID: CKRecord.ID(recordName: note.id) + ) } - // 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 { + /// Capture a web-auth token via `CKFetchWebAuthTokenOperation` for the + /// given CloudKit API token. Issues the same `158__…` value that + /// MistKit / `mistdemo auth-token` consume. + nonisolated 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 - ) - } + operation.fetchWebAuthTokenResultBlock = { result in + continuation.resume(with: result) } - // CKFetchWebAuthTokenOperation is a CKDatabaseOperation; running - // it against the private 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/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.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift index a3f9e568..eb052668 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift @@ -37,7 +37,9 @@ import UIKit #endif - /// View for managing the iCloud account and web auth token. + /// 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 { /// Where the current `apiToken` value came from on this launch. internal enum TokenSource { @@ -48,7 +50,7 @@ /// Env var name the MistDemo CLI also reads. internal static let envVarName = "CLOUDKIT_API_TOKEN" - @EnvironmentObject internal var service: NativeCloudKitService + @Environment(CloudKitStore.self) internal var service @AppStorage("MistDemoApp.cloudKitApiToken") internal var apiToken: String = "" @State internal var webAuthToken: String? @State internal var fetchingWebAuthToken = false @@ -56,8 +58,17 @@ @State internal var tokenSource: TokenSource = .manual internal var body: some View { + @Bindable var bindable = service Form { - containerSection + Section("Container") { + LabeledContent("Container", value: service.containerIdentifier) + Picker("Database", selection: $bindable.databaseScope) { + ForEach(CKDatabase.Scope.selectable, id: \.self) { scope in + Text(scope.label).tag(scope) + } + } + LabeledContent("iCloud Status", value: statusLabel) + } webAuthTokenSection if let error = service.lastError { Section("Last Service Error") { @@ -98,14 +109,6 @@ } } - 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 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..fc88696d 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,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") @@ -98,7 +103,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 +121,7 @@ NoteEditView(mode: .create) { _ in Task { await runQuery() } } - .environmentObject(service) + .environment(service) } } @@ -132,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 diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift index 58a1bb9f..d3cb9afb 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 @@ -78,7 +78,7 @@ note = updated onChange() } - .environmentObject(service) + .environment(service) } .confirmationDialog( "Delete \(note.title ?? note.id)?", 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/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/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 { 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 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/Examples/MistDemo/project.yml b/Examples/MistDemo/project.yml index 535e7e8c..3e9f6b46 100644 --- a/Examples/MistDemo/project.yml +++ b/Examples/MistDemo/project.yml @@ -73,12 +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: 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..