Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ A Swift Package for Server-Side and Command-Line Access to [CloudKit Web Service

## Overview

MistKit provides a modern Swift interface to [CloudKit Web Services](https://developer.apple.com/documentation/cloudkitwebservices) REST API, enabling cross-platform CloudKit access for server-side Swift applications, command-line tools, and platforms where the [CloudKit framework](https://developer.apple.com/documentation/cloudkit) isn't available.
MistKit provides a modern Swift interface to [CloudKit Web Services](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/index.html) REST API, enabling cross-platform CloudKit access for server-side Swift applications, command-line tools, and platforms where the [CloudKit framework](https://developer.apple.com/documentation/cloudkit) isn't available.

Built with Swift concurrency (async/await) and designed for modern Swift applications, MistKit supports all three CloudKit authentication methods and provides type-safe access to CloudKit operations.

Expand Down Expand Up @@ -178,7 +178,7 @@ Private/shared always use web-auth.
#### API Token Authentication

1. **Get API Token**:
- Log into [Apple Developer Console](https://developer.apple.com)
- Log into the [CloudKit Console](https://icloud.developer.apple.com/dashboard/)
- Navigate to CloudKit Database
- Generate an API Token

Expand Down Expand Up @@ -555,6 +555,8 @@ Links from the talk:
- [actions/checkout](https://github.com/actions/checkout)
- [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact)
- [Swift Docker images](https://hub.docker.com/_/swift) — the sync action builds with `swiftlang/swift:nightly-6.4.x-noble`
- [swift-configuration](https://github.com/apple/swift-configuration) — how the example CLIs read configuration from environment variables or arguments
- [MistKitConfiguration](https://github.com/brightdigit/MistKitConfiguration) — the shared credential-configuration package built on it (also at [`Packages/MistKitConfiguration`](Packages/MistKitConfiguration/))

#### Other tools mentioned

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ Every type that crosses a task boundary is `Sendable`. The wrapper enforces this
- ``Authenticator`` declares a `Sendable` constraint on the protocol itself.
- ``TokenManager`` likewise.

Token-manager *implementations* that need mutable state (``AdaptiveTokenManager``, anything that caches a refreshed token) are `actor`s — the only `Sendable` shape that owns mutable state safely under Swift 6 strict concurrency. The middleware never reaches into those actors directly; it only calls `currentAuthenticator()`, which is `async`.
Token-manager *implementations* that need mutable state (``AdaptiveTokenManager``, anything that caches a refreshed token) are `actor`s — the only `Sendable` shape that owns mutable state safely under [Swift 6 strict concurrency](https://www.swift.org/migration/documentation/swift-6-concurrency-migration-guide/). The middleware never reaches into those actors directly; it only calls `currentAuthenticator()`, which is `async`.

## Typed throws

Expand Down Expand Up @@ -231,9 +231,9 @@ Sync endpoints follow the same shape: ``RecordChangesResult`` and ``ZoneChangesR

## Asset upload: separate URLSession by design

Asset upload is a two-step dance: ask CloudKit for a CDN URL, then PUT the bytes to the CDN. The two steps target **different hosts** (`api.apple-cloudkit.com` and `cvws.icloud-content.com`).
Asset upload is a two-step dance: ask CloudKit for a CDN URL, then PUT the bytes to the CDN. The two steps target **[different hosts](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/UploadAssets.html)** (`api.apple-cloudkit.com` and `cvws.icloud-content.com`).

URLSession (and any HTTP/2 client) will happily reuse a connection between hosts when it can, and CloudKit's CDN responds with `421 Misdirected Request` if the wrong host is reached over a reused HTTP/2 connection. To avoid that, asset upload uses `URLSession.shared.upload(_:to:)` directly via a dedicated ``AssetUploader`` closure — **not** the injected `ClientTransport`. The two connection pools stay separate.
URLSession (and any HTTP/2 client) will happily reuse a connection between hosts when it can, and CloudKit's CDN responds with `421 Misdirected Request` if the wrong host is reached over a reused HTTP/2 connection. To avoid that, asset upload uses [`URLSession.shared.upload(_:to:)`](https://developer.apple.com/documentation/foundation/urlsession) directly via a dedicated ``AssetUploader`` closure — **not** the injected `ClientTransport`. The two connection pools stay separate.

The closure shape (`(Data, URL) async throws -> (statusCode: Int?, data: Data)`) is a dependency-injection seam: tests pass in a stub uploader without touching the network. Custom uploaders in production code must preserve the connection-pool separation, or the same 421 errors will return.

Expand All @@ -260,5 +260,5 @@ A few intentional non-features that show up in many wrapper libraries but not th
- <doc:OpenAPICodeGeneration>
- <doc:GeneratedCodeAnalysis>
- <doc:GeneratedCodeWorkflow>
- [Swift Concurrency Documentation](https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html)
- [Swift Concurrency Documentation](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/)
- [swift-openapi-runtime](https://github.com/apple/swift-openapi-runtime)
18 changes: 9 additions & 9 deletions Sources/MistKit/Documentation.docc/AuthenticationAndDatabases.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Configure ``CloudKitService`` once with the credentials it needs, then pick a ``

## Overview

CloudKit Web Services accepts three authentication schemes, and only some scheme/database combinations are legal:
[CloudKit Web Services](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/SettingUpWebServices.html) accepts three authentication schemes, and only some scheme/database combinations are legal:

| Database | API token | Web auth | Server-to-server |
| --- | :-: | :-: | :-: |
Expand Down Expand Up @@ -38,7 +38,7 @@ let credentials = try Credentials(

### Server-to-server (developer-attributed)

Provide a CloudKit key ID and an ECDSA P-256 private key. ``PrivateKeyMaterial`` accepts either raw key bytes, PEM data, or a path to a PEM file.
Provide a CloudKit key ID and an [ECDSA P-256 private key](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/SettingUpWebServices.html#//apple_ref/doc/uid/TP40015240-CH24-SW6). ``PrivateKeyMaterial`` accepts either raw key bytes, PEM data, or a path to a PEM file.

```swift
let credentials = try Credentials(
Expand Down Expand Up @@ -136,7 +136,7 @@ There is no default on the `database:` parameter. Every call picks explicitly.

## User-identity routes

A handful of routes (`/users/caller`, `/users/discover`, `/users/lookup/email`, `/users/lookup/id`) only work against the public database with web-auth credentials — CloudKit rejects server-to-server signing on these endpoints. MistKit's user-identity methods (``CloudKitService/fetchCaller()``, ``CloudKitService/lookupUsersByEmail(_:)``, ``CloudKitService/lookupUsersByRecordName(_:)``) pass `.public(.requires(.webAuth))` internally — they will throw ``CloudKitError/missingCredentials(database:availability:reason:)`` if your ``Credentials`` lack ``APICredentials/webAuthToken``.
A handful of routes (`/users/caller`, [`/users/discover`](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/DiscoveringUserIdentities%28usersdiscover%29.html), `/users/lookup/email`, `/users/lookup/id`) only work against the public database with web-auth credentials — CloudKit rejects server-to-server signing on these endpoints. MistKit's user-identity methods (``CloudKitService/fetchCaller()``, ``CloudKitService/lookupUsersByEmail(_:)``, ``CloudKitService/lookupUsersByRecordName(_:)``) pass `.public(.requires(.webAuth))` internally — they will throw ``CloudKitError/missingCredentials(database:availability:reason:)`` if your ``Credentials`` lack ``APICredentials/webAuthToken``.

## Where the signing happens

Expand Down Expand Up @@ -180,14 +180,14 @@ Under **API Tokens**, press `+`, name the token, and pick a **Sign-in Callback**
The sign-in callback decides how a web auth token comes back to you later:

- **URL Redirect** — Apple's sign-in page redirects the browser to a URL you supply with the token appended as the `ckSession` query parameter. Pick this when your backend handles the callback directly.
- **Post Message** — Apple's sign-in window posts a JavaScript `message` event to your page with the token in `e.data.ckWebAuthToken`. This is what CloudKit JS uses by default.
- **Post Message** — Apple's sign-in window posts a JavaScript `message` event to your page with the token in `e.data.ckWebAuthToken`. This is what [CloudKit JS](https://developer.apple.com/documentation/cloudkitjs) uses by default.

An API token alone cannot reach the private or shared database. Its main job is to identify the container for the flows below.

### Web auth token via browser redirect

1. Your service makes a request with only `ckAPIToken` set.
2. CloudKit replies `401` with `serverErrorCode` `AUTHENTICATION_REQUIRED` and a `redirectURL` pointing at Apple's sign-in page.
2. CloudKit replies `401` with `serverErrorCode` [`AUTHENTICATION_REQUIRED`](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ErrorCodes.html) and a `redirectURL` pointing at Apple's sign-in page.
3. Your service redirects the browser there; the user signs in with their Apple ID.
4. Apple redirects back to your registered callback with `ckSession=…` (the web auth token).
5. Your service stores that token next to the API token and uses both for every subsequent request — MistKit sends it as the `ckWebAuthToken` query item.
Expand All @@ -207,7 +207,7 @@ The token is valid for 30 minutes by default, or two weeks if the user ticks *Ke

### Web auth token from an iOS app

If your backend acts on behalf of a user who is already signed in to your iOS app, skip the browser. `CKFetchWebAuthTokenOperation` exchanges the device's iCloud session for a web auth token your server can use:
If your backend acts on behalf of a user who is already signed in to your iOS app, skip the browser. [`CKFetchWebAuthTokenOperation`](https://developer.apple.com/documentation/cloudkit/ckfetchwebauthtokenoperation) exchanges the device's iCloud session for a web auth token your server can use:

```swift
extension CKDatabase {
Expand All @@ -228,7 +228,7 @@ Run it against the **private** database — on the public database it fails or r

### Server-to-server key

Under **Server-to-Server Keys**, press `+`. The console shows the exact commands; the key pair is yours, and only the public half is uploaded:
Under **[Server-to-Server Keys](https://icloud.developer.apple.com/dashboard/)**, press `+`. The console shows the exact commands; the key pair is yours, and only the public half is uploaded:

```bash
# Step 1: generate a P-256 private key
Expand All @@ -240,7 +240,7 @@ openssl ec -in eckey.pem -pubout

Name the key, paste the public key, save, and copy the **Key ID** into `CLOUDKIT_KEY_ID`. Keep `eckey.pem` on the server — never commit it — and hand it to MistKit as ``PrivateKeyMaterial/file(path:)`` or, when a secret store injects the PEM contents, ``PrivateKeyMaterial/raw(_:)``.

Every request is then signed with the key: MistKit builds the payload `<iso8601Date>:<base64 SHA-256 of body>:<subpath>`, signs it with ECDSA P-256, and sends the `X-Apple-CloudKit-Request-KeyID`, `X-Apple-CloudKit-Request-ISO8601Date`, and `X-Apple-CloudKit-Request-SignatureV1` headers. There is no `Authorization` header. <doc:RequestSigning> walks through the implementation.
Every request is then signed with the key: MistKit builds the payload `<iso8601Date>:<base64 SHA-256 of body>:<subpath>`, signs it with [ECDSA P-256](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/SettingUpWebServices.html#//apple_ref/doc/uid/TP40015240-CH24-SW6), and sends the `X-Apple-CloudKit-Request-KeyID`, `X-Apple-CloudKit-Request-ISO8601Date`, and `X-Apple-CloudKit-Request-SignatureV1` headers. There is no `Authorization` header. <doc:RequestSigning> walks through the implementation.

### Rotating a server-to-server key

Expand All @@ -253,7 +253,7 @@ Keys do not expire on their own, but the console allows several active keys per

### Environment variables

The conventional variable names used by MistDemo, BushelCloud, and CelestraCloud:
The conventional variable names used by MistDemo, [BushelCloud](https://github.com/brightdigit/BushelCloud), and [CelestraCloud](https://github.com/brightdigit/CelestraCloud):

| Variable | Method |
| --- | --- |
Expand Down
Loading
Loading