Skip to content
Merged
27 changes: 24 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions Examples/MistDemo/App/MistDemoApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// NativeCloudKitService.swift
// CloudKitStore.swift
// MistDemo
//
// Created by Leo Dion.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// NativeCloudKitError.swift
// CloudKitStoreError.swift
// MistDemo
//
// Created by Leo Dion.
Expand Down Expand Up @@ -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."
}
}
}
Expand Down
25 changes: 14 additions & 11 deletions Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift
Comment thread
leogdion marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -48,16 +50,25 @@
/// 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
@State internal var webAuthTokenError: String?
@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") {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down
Loading
Loading