From 7d5ee7bcfeeb68c2c71f2dd295261ab55740b36f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 16:02:01 +0000 Subject: [PATCH 01/12] Revise the CloudKit as Your Backend article against the iOSDevUK recording Fold in what was said on stage that the rehearsal-based draft lacked: the BYTES vs ASSET distinction, the one-line definition of MistKit, the Claude/Cursor and other-language points about openapi.yaml, the reason for the abstraction layer over the generated client, the subpath used in server-to-server signing, the swift-configuration credential loading in the BushelCloud deployment, the Bushel payoff, the closing plugs, and one live audience question. Mirror the two new links into the README. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CZstUEGAAPuxzTjiAPii4S --- README.md | 2 + .../CloudKitAsYourBackend.md | 38 ++++++++++++------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 4b04de6b..08a310fc 100644 --- a/README.md +++ b/README.md @@ -484,6 +484,7 @@ Links from the talk: - [AtLeast](https://atleast.app) — Passive Timer for Apple Watch - [Heartwitch](https://heartwitch.app) — Apple Watch heart-rate streaming; [App Store](https://apps.apple.com/us/app/heartwitch/id1480031203) - [BrightDigit](https://brightdigit.com) +- [Empower Apps podcast](https://www.empowerapps.show) - [linktr.ee/leogdion](https://linktr.ee/leogdion) - [iOSDevUK](https://www.iosdevuk.com) @@ -555,6 +556,7 @@ 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 BushelCloud CLI reads credentials from environment variables or arguments #### Other tools mentioned diff --git a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md index dd32510f..105319c8 100644 --- a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md +++ b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md @@ -10,7 +10,7 @@ The talk was given in 2026 at Swift Craft and iOSDevUK (Aberystwyth). The abstra > CloudKit has excellent documentation for iOS and macOS client development. But backend services — podcast aggregation, RSS readers, data processing — face APIs that Apple barely documents. I rebuilt a comprehensive CloudKit library using AI-generated OpenAPI specifications. The result: type-safe Swift code supporting three authentication methods (server-to-server, web authentication token, and API token), typed error handling, and production deployments. -The sections below follow the slide order. Every code sample is taken from the current MistKit source or its example projects, so the article stays accurate as the library evolves. A complete list of the links shown during the talk is at the end, in . +The sections below follow the slide order and were revised against the recording of the iOSDevUK session, so the emphasis matches what was said on stage. Every code sample is taken from the current MistKit source or its example projects, so the article stays accurate as the library evolves. A complete list of the links shown during the talk is at the end, in . ## Table of Contents @@ -56,7 +56,9 @@ Records are the main way data is stored. Think of a record as a table row with t There is no boolean type — CloudKit stores booleans as `INT64` `0`/`1`, which is why MistKit offers ``FieldValue/init(booleanValue:)`` and ``FieldValue/boolValue``. -If you would rather script the schema than click through the console, CloudKit has a text-based schema language and the `cktool` command-line tool ([Integrating a text-based schema into your workflow](https://developer.apple.com/documentation/cloudkit/integrating-a-text-based-schema-into-your-workflow)): +`BYTES` and `ASSET` both hold binary data; the difference is intent. `BYTES` is a small blob stored inline in the record. `ASSET` is for files: the bytes are uploaded separately and the record holds a reference that comes back with a download URL. + +If you would rather script the schema than click through the console, CloudKit has a text-based schema language, much like SQL DDL, and the `cktool` command-line tool ([Integrating a text-based schema into your workflow](https://developer.apple.com/documentation/cloudkit/integrating-a-text-based-schema-into-your-workflow)). With a management token from the console, the whole schema workflow can be scripted: ```text RECORD TYPE Note ( @@ -159,7 +161,7 @@ The two stories above generalize into four families: | **Web app ↔ Apple device bridge** | A browser portal for a CloudKit-backed app, a webhook handler (Stripe, GitHub, forms) that writes straight into a user's records | | **Data aggregation** | Anonymized telemetry read via `records/changes`, crowdsourced data cleaned and written back by a background steward | -Apple's CloudKit framework only runs on Apple platforms. MistKit wraps CloudKit Web Services so server-side Swift, Linux services, and command-line tools can take part in the same containers as your apps. +Apple's CloudKit framework only runs on Apple platforms. The one-line description of MistKit from the talk: it is the CloudKit framework for the places the CloudKit framework does not go — Linux, Windows, and anything else that can only reach CloudKit through Web Services — so server-side Swift, Linux services, and command-line tools can take part in the same containers as your apps. ## Building MistKit @@ -211,11 +213,13 @@ The generator ships transports for `URLSession` and `AsyncHTTPClient`, plus serv ### Turning the documentation into a spec -The remaining problem was producing an OpenAPI document for an API that only exists as 2016-era prose. That is where AI-assisted development came in: each documented endpoint was fed to an LLM and translated into `openapi.yaml`, then abstractions were built on top. It was not automatic — hallucinated APIs, context-window limits, and unrequested scaffolding (retries, caches, "secure memory") were constant, and the assistant regularly declared success before running the build. The catalog of what went wrong, with evidence, is ; the narrative version is in the two *Rebuilding MistKit with Claude Code* articles ([part 1](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-1/), [part 2](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-2/)). +The remaining problem was producing an OpenAPI document for an API that only exists as 2016-era prose. That is where AI-assisted development came in: each documented endpoint was fed to an LLM — a little Claude, a little Cursor — and translated into `openapi.yaml`, then abstractions were built on top. Converting one format of documentation into another is a job these tools are genuinely good at. It was not automatic — hallucinated APIs, context-window limits, and unrequested scaffolding (retries, caches, "secure memory") were constant, and the assistant regularly declared success before running the build. The catalog of what went wrong, with evidence, is ; the narrative version is in the two *Rebuilding MistKit with Claude Code* articles ([part 1](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-1/), [part 2](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-2/)). + +A side effect worth pointing out: the spec is not Swift-specific. [`openapi.yaml`](https://github.com/brightdigit/MistKit/blob/main/openapi.yaml) documents every call and every schema of CloudKit Web Services, so someone who wants the same client in Java, Python, PHP, Go, Dart, or Smalltalk can feed the same file to their own generator. ### Three layers -The result is layered so callers never see the generated code: +The generated client was the next thing that needed work. As Honza Dvorsky, one of the generator's maintainers, will tell you, the generated code works — but it is not the API you would want to hand to the users of a Swift library. So MistKit is layered so callers never see the generated code: ``` Your code service.queryRecords(recordType: "Note", database: .private) @@ -238,11 +242,11 @@ Unit tests were not enough — the assistant would report success on code that f ![The MistKit web demo, switching between MistKit and CloudKit JS backends](talk-mistdemo-web) -MistDemo is the live-verification oracle for everything in this article that describes CloudKit's wire behavior. +MistDemo is the live-verification oracle for everything in this article that describes CloudKit's wire behavior. The lesson generalizes beyond MistKit: if an assistant wrote it, run it yourself against the real thing before believing the summary. ## Authentication -On a device, authentication is invisible: the user is signed in to iCloud and the framework does the rest. On a server you have to prove who you are with credentials you manage. Apple documents three methods; it is more honest to call it **two and a half**, because the first one is a prerequisite for the second rather than a peer. +On a device, authentication is invisible: the user is signed in to iCloud and the framework does the rest. Outside the Apple ecosystem you either put a sign-in button on a web page or manage credentials yourself, and on a server you have to prove who you are with credentials you manage. Apple documents three methods; it is more honest to call it **two and a half**, because the first one is a prerequisite for the second rather than a peer. All of them start in the CloudKit Console under **Tokens & Keys** for your container. @@ -338,7 +342,7 @@ openssl ec -in eckey.pem -pubout Paste the public key into step 3, save, and copy the **Key ID**. The private key never leaves your server. -Each request is then signed. The payload is three strings joined by colons — the ISO 8601 date, the base64 SHA-256 of the body (or the empty string for no body), and the URL subpath — signed with ECDSA P-256 and sent in three headers: +Each request is then signed. The payload is three strings joined by colons — the ISO 8601 date, the base64 SHA-256 of the body (or the empty string for no body), and the URL subpath — signed with ECDSA P-256 and sent in three headers. The subpath is everything between the host and the query string, for example `/database/1/iCloud.com.example.App/development/public/records/modify`: | Header | Value | | --- | --- | @@ -500,7 +504,7 @@ public enum FieldValue: Codable, Equatable, Sendable { } ``` -``Location``, ``Reference``, and ``Asset`` are MistKit's own structs so the package does not depend on Core Location or CloudKit on the server. A reference is a record name plus an action; an asset is what CloudKit returns for a file, including the download URL: +``Location``, ``Reference``, and ``Asset`` are MistKit's own structs so the package does not depend on Core Location or CloudKit on the server — Core Location in particular changes often enough that a server library is better off without it. A reference is a record name plus an action; an asset is what CloudKit returns for a file, including the download URL: ```swift public struct Reference: Codable, Equatable, Sendable { @@ -541,7 +545,7 @@ The thirty-second version hides a lot: the `oneOf` has no discriminator, so a wh ## Error handling -CloudKit documents its HTTP status codes and a JSON error body that is the same for all of them: +This is the best-documented corner of the API: a table of every `serverErrorCode` alongside the HTTP status it pairs with, as easy for an assistant to work from as for a person. The JSON error body is the same shape for all of them: ```json { @@ -568,11 +572,11 @@ and the generator produces a matching enum and struct. MistKit maps each of the ### The endpoint that returns HTTP 500 -One documented call does not work at all: `GET users/discover` ("discover all user identities") returns `500 INTERNAL_ERROR` from both the REST API and Apple's own CloudKit JS, reproducibly, after authentication succeeds. It appears to have been retired server-side — possibly for privacy reasons — without the documentation changing. MistKit generates the operation from `openapi.yaml` but does not surface it in ``CloudKitService``; the `POST users/discover` form, which takes lookup infos, works and is what ``CloudKitService/discoverUserIdentities(lookupInfos:)`` calls. The details are tracked in [MistKit issue #28](https://github.com/brightdigit/MistKit/issues/28) and Apple Feedback FB22754466. +One documented call does not work at all: `GET users/discover` ("discover all user identities") returns `500 INTERNAL_ERROR` from both the REST API and Apple's own CloudKit JS, reproducibly, after authentication succeeds. It appears to have been retired server-side — possibly for privacy reasons — without the documentation changing; the framework's equivalent, `CKDiscoverAllUserIdentitiesOperation`, is deprecated as well, so the archived web-services page is the only place the feature still looks alive. MistKit generates the operation from `openapi.yaml` but does not surface it in ``CloudKitService``; the `POST users/discover` form, which takes lookup infos, works and is what ``CloudKitService/discoverUserIdentities(lookupInfos:)`` calls. The details are tracked in [MistKit issue #28](https://github.com/brightdigit/MistKit/issues/28) and Apple Feedback FB22754466. ## Deployment -Bushel's sync job runs entirely in GitHub Actions. The key ID and private key (or a base64-encoded copy of it) live in repository secrets: +Bushel's sync job runs entirely in GitHub Actions. The key ID and private key (pasted as-is, or as a base64-encoded copy if that is easier to handle) live in repository secrets. The API token and web auth token in the screenshot are there for the integration tests, not for the sync job: ![Repository secrets: CLOUDKIT_KEY_ID, CLOUDKIT_PRIVATE_KEY, CLOUDKIT_API_TOKEN, CLOUDKIT_WEB_AUTH_TOKEN](talk-github-secrets) @@ -674,13 +678,15 @@ runs: --container-identifier "$CLOUDKIT_CONTAINER_ID" ``` -The CLI writes a JSON report that the action turns into the workflow's summary page. Static builds, credential injection on other platforms, tiered scheduling, idempotency, and observability are covered in . +The CLI reads its credentials through [swift-configuration](https://github.com/apple/swift-configuration), which accepts the same keys as environment variables or command-line arguments, so the action passes secrets through the environment while a developer can pass flags locally. The CLI writes a JSON report that the action turns into the workflow's summary page. + +The payoff is visible in Bushel: each scheduled run checks whether Apple has posted a new restore image, a bug-fix release, or a new beta, and the app shows every version with a signed or unsigned flag — an unsigned image cannot be installed, so that flag is the one users care about. Static builds, credential injection on other platforms, tiered scheduling, idempotency, and observability are covered in . ## What's next Every endpoint in the CloudKit Web Services reference is implemented — records, zones, changes, subscriptions, users, sharing, assets, and APNs tokens — and exercised live by MistDemo. What the project needs now is people using it: try it against your own container and file what you find on the [issue tracker](https://github.com/brightdigit/MistKit/issues). -And one more thing: Leo's apps, both backed by patterns from this talk — [Bushel](https://getbushel.app), virtualization for app developers, and [AtLeast](https://atleast.app), a passive timer for Apple Watch. +And one more thing: Leo's apps, both backed by patterns from this talk — [Bushel](https://getbushel.app), virtualization for app developers, on the Mac App Store with larger updates planned around macOS 27, and [AtLeast](https://atleast.app), a passive timer for breathing and meditation on Apple Watch, in TestFlight for watchOS 26. Leo runs [BrightDigit](https://brightdigit.com), hosts the [Empower Apps](https://www.empowerapps.show) podcast, and is available for Swift work of any kind. ## Links @@ -693,6 +699,7 @@ And one more thing: Leo's apps, both backed by patterns from this talk — [Bush - [AtLeast](https://atleast.app) — Passive Timer for Apple Watch - [Heartwitch](https://heartwitch.app) — Apple Watch heart-rate streaming; [App Store](https://apps.apple.com/us/app/heartwitch/id1480031203) - [BrightDigit](https://brightdigit.com) +- [Empower Apps podcast](https://www.empowerapps.show) - [linktr.ee/leogdion](https://linktr.ee/leogdion) - [iOSDevUK](https://www.iosdevuk.com) @@ -776,6 +783,7 @@ And one more thing: Leo's apps, both backed by patterns from this talk — [Bush - [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 BushelCloud CLI reads credentials from environment variables or arguments ### Other tools mentioned @@ -787,6 +795,8 @@ And one more thing: Leo's apps, both backed by patterns from this talk — [Bush Questions asked during rehearsals and at the talks, with the answers as they stand today. +**Can sign-in go through a third-party provider instead of iCloud?** (asked at iOSDevUK) For your own site's accounts, yes — Sign in with Apple, Google, and the rest all have libraries. But a CloudKit web auth token only ever comes from an iCloud sign-in, through CloudKit JS or `CKFetchWebAuthTokenOperation`. A third-party identity can sit alongside it, the way Heartwitch's Postgres accounts do; it cannot replace it. + **How much does CloudKit cost?** There is no separate CloudKit fee beyond the Apple Developer Program. Quotas for storage, transfer, and requests scale with active users; exceeding them returns `QUOTA_EXCEEDED` or throttling. Apple does not publish a clear overage price list. **What kind of database is it?** NoSQL, document-oriented, with a schema. Records have typed fields; relationships are references, not joins. The schema is defined up front (console or `cktool`), not created as you go. From d4fe9165dda895c363b1e8e9dc7e7ff9b19820bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 16:07:37 +0000 Subject: [PATCH 02/12] Drop the closing plug, place the auth-library question in the middleware section Remove the BrightDigit, podcast, and availability sentence and the podcast link. Replace the reconstructed Q&A entry with a middleware-section paragraph on how Imperial, JWTKit, and Hummingbird Auth relate to the CloudKit credential, and fold the GraphQL moment into the polymorphism section as the motivating example. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CZstUEGAAPuxzTjiAPii4S --- README.md | 1 - .../Documentation.docc/CloudKitAsYourBackend.md | 11 +++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 08a310fc..03c428ff 100644 --- a/README.md +++ b/README.md @@ -484,7 +484,6 @@ Links from the talk: - [AtLeast](https://atleast.app) — Passive Timer for Apple Watch - [Heartwitch](https://heartwitch.app) — Apple Watch heart-rate streaming; [App Store](https://apps.apple.com/us/app/heartwitch/id1480031203) - [BrightDigit](https://brightdigit.com) -- [Empower Apps podcast](https://www.empowerapps.show) - [linktr.ee/leogdion](https://linktr.ee/leogdion) - [iOSDevUK](https://www.iosdevuk.com) diff --git a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md index 105319c8..dd96323d 100644 --- a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md +++ b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md @@ -364,7 +364,9 @@ The public database accepts two methods and they are **not interchangeable**: th ### OpenAPI middleware -swift-openapi-generator does not know how to sign CloudKit requests, but it has middleware: `ServerMiddleware` runs before a request is received and `ClientMiddleware` runs before one is sent. Apple's own example shows a bearer-token middleware. MistKit's version is one small type whose only job is to ask a ``TokenManager`` for the current ``Authenticator`` and let it modify the request: +swift-openapi-generator does not know how to sign CloudKit requests, but it has middleware: `ServerMiddleware` runs before a request is received and `ClientMiddleware` runs before one is sent. Apple's own example shows a bearer-token middleware, and the generator's [example projects](https://github.com/apple/swift-openapi-generator/tree/main/Examples) include authentication, logging, and retrying middlewares to copy from. + +This is the same slot the server-side Swift authentication libraries plug into, and a question at iOSDevUK asked how they fit. They sit on the *server* side of the picture: [Imperial](https://github.com/vapor-community/Imperial) federates sign-in through Apple, Google, GitHub, and other OAuth providers for Vapor, [JWTKit](https://github.com/vapor/jwt-kit) verifies the identity tokens Sign in with Apple hands back, and [Hummingbird Auth](https://github.com/hummingbird-project/hummingbird-auth) covers sessions and bearer tokens for Hummingbird. Any of them can authenticate the *users* of your service. What none of them can do is mint a CloudKit web auth token — that only comes from an iCloud sign-in through CloudKit JS or `CKFetchWebAuthTokenOperation` — so a third-party identity sits alongside the CloudKit credential, the way Heartwitch's Postgres accounts do, rather than replacing it. MistKit's middleware is the client-side counterpart: it holds the CloudKit credential and signs the outgoing request. MistKit's version is one small type whose only job is to ask a ``TokenManager`` for the current ``Authenticator`` and let it modify the request: ```swift internal struct AuthenticationMiddleware: ClientMiddleware { @@ -488,7 +490,7 @@ where the body hash is `SHA256.cloudKitBodyHash(of:)` — `base64(SHA256(body))` ## Field type polymorphism -If you have handled JSON from a JavaScript-flavored API you know the problem: a field's value can be any of nine types, and the JSON does not always say which. MistKit models the domain side as one enum: +If you have handled JSON from a JavaScript-flavored API you know the problem. On stage it took the audience a moment to supply the name of the best-known example — not REST, not gRPC, the one Facebook uses: GraphQL — where the shape of a response is decided by the query and the client has to decode values whose types it does not know in advance. Swift's `Codable` wants those types at compile time, so a Swift client needs an explicit way to model "one of several". CloudKit has the same need in miniature: a field's value can be any of nine types, and the JSON does not always say which. A number, for instance, could be an `INT64` or a `DOUBLE`, because JavaScript does not distinguish them. MistKit models the domain side as one enum: ```swift public enum FieldValue: Codable, Equatable, Sendable { @@ -686,7 +688,7 @@ The payoff is visible in Bushel: each scheduled run checks whether Apple has pos Every endpoint in the CloudKit Web Services reference is implemented — records, zones, changes, subscriptions, users, sharing, assets, and APNs tokens — and exercised live by MistDemo. What the project needs now is people using it: try it against your own container and file what you find on the [issue tracker](https://github.com/brightdigit/MistKit/issues). -And one more thing: Leo's apps, both backed by patterns from this talk — [Bushel](https://getbushel.app), virtualization for app developers, on the Mac App Store with larger updates planned around macOS 27, and [AtLeast](https://atleast.app), a passive timer for breathing and meditation on Apple Watch, in TestFlight for watchOS 26. Leo runs [BrightDigit](https://brightdigit.com), hosts the [Empower Apps](https://www.empowerapps.show) podcast, and is available for Swift work of any kind. +And one more thing: Leo's apps, both backed by patterns from this talk — [Bushel](https://getbushel.app), virtualization for app developers, on the Mac App Store with larger updates planned around macOS 27, and [AtLeast](https://atleast.app), a passive timer for breathing and meditation on Apple Watch, in TestFlight for watchOS 26. ## Links @@ -699,7 +701,6 @@ And one more thing: Leo's apps, both backed by patterns from this talk — [Bush - [AtLeast](https://atleast.app) — Passive Timer for Apple Watch - [Heartwitch](https://heartwitch.app) — Apple Watch heart-rate streaming; [App Store](https://apps.apple.com/us/app/heartwitch/id1480031203) - [BrightDigit](https://brightdigit.com) -- [Empower Apps podcast](https://www.empowerapps.show) - [linktr.ee/leogdion](https://linktr.ee/leogdion) - [iOSDevUK](https://www.iosdevuk.com) @@ -795,8 +796,6 @@ And one more thing: Leo's apps, both backed by patterns from this talk — [Bush Questions asked during rehearsals and at the talks, with the answers as they stand today. -**Can sign-in go through a third-party provider instead of iCloud?** (asked at iOSDevUK) For your own site's accounts, yes — Sign in with Apple, Google, and the rest all have libraries. But a CloudKit web auth token only ever comes from an iCloud sign-in, through CloudKit JS or `CKFetchWebAuthTokenOperation`. A third-party identity can sit alongside it, the way Heartwitch's Postgres accounts do; it cannot replace it. - **How much does CloudKit cost?** There is no separate CloudKit fee beyond the Apple Developer Program. Quotas for storage, transfer, and requests scale with active users; exceeding them returns `QUOTA_EXCEEDED` or throttling. Apple does not publish a clear overage price list. **What kind of database is it?** NoSQL, document-oriented, with a schema. Records have typed fields; relationships are references, not joins. The schema is defined up front (console or `cktool`), not created as you go. From c5f246989b833a32cfbf1b8123103de14f14e708 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 16:08:58 +0000 Subject: [PATCH 03/12] State the audience question on third-party sign-in libraries as asked Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CZstUEGAAPuxzTjiAPii4S --- Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md index dd96323d..ac0b4926 100644 --- a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md +++ b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md @@ -366,7 +366,7 @@ The public database accepts two methods and they are **not interchangeable**: th swift-openapi-generator does not know how to sign CloudKit requests, but it has middleware: `ServerMiddleware` runs before a request is received and `ClientMiddleware` runs before one is sent. Apple's own example shows a bearer-token middleware, and the generator's [example projects](https://github.com/apple/swift-openapi-generator/tree/main/Examples) include authentication, logging, and retrying middlewares to copy from. -This is the same slot the server-side Swift authentication libraries plug into, and a question at iOSDevUK asked how they fit. They sit on the *server* side of the picture: [Imperial](https://github.com/vapor-community/Imperial) federates sign-in through Apple, Google, GitHub, and other OAuth providers for Vapor, [JWTKit](https://github.com/vapor/jwt-kit) verifies the identity tokens Sign in with Apple hands back, and [Hummingbird Auth](https://github.com/hummingbird-project/hummingbird-auth) covers sessions and bearer tokens for Hummingbird. Any of them can authenticate the *users* of your service. What none of them can do is mint a CloudKit web auth token — that only comes from an iCloud sign-in through CloudKit JS or `CKFetchWebAuthTokenOperation` — so a third-party identity sits alongside the CloudKit credential, the way Heartwitch's Postgres accounts do, rather than replacing it. MistKit's middleware is the client-side counterpart: it holds the CloudKit credential and signs the outgoing request. MistKit's version is one small type whose only job is to ask a ``TokenManager`` for the current ``Authenticator`` and let it modify the request: +This is the same slot the server-side Swift authentication libraries plug into. A question at iOSDevUK asked whether there is a Swift library for signing in with a third-party service, and what other authentication implementations exist. There are several, and they sit on the *server* side of the picture: [Imperial](https://github.com/vapor-community/Imperial) federates sign-in through Apple, Google, GitHub, and other OAuth providers for Vapor, [JWTKit](https://github.com/vapor/jwt-kit) verifies the identity tokens Sign in with Apple hands back, and [Hummingbird Auth](https://github.com/hummingbird-project/hummingbird-auth) covers sessions and bearer tokens for Hummingbird. Any of them can authenticate the *users* of your service. What none of them can do is mint a CloudKit web auth token — that only comes from an iCloud sign-in through CloudKit JS or `CKFetchWebAuthTokenOperation` — so a third-party identity sits alongside the CloudKit credential, the way Heartwitch's Postgres accounts do, rather than replacing it. MistKit's middleware is the client-side counterpart: it holds the CloudKit credential and signs the outgoing request. MistKit's version is one small type whose only job is to ask a ``TokenManager`` for the current ``Authenticator`` and let it modify the request: ```swift internal struct AuthenticationMiddleware: ClientMiddleware { From 72bf3f70f5fb7f489574c990781f9f247dcdefc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 16:19:39 +0000 Subject: [PATCH 04/12] Address review: name GraphQL plainly, drop the Core Location aside, link MistKitConfiguration Also split the middleware paragraph before the client-side counterpart sentence, per the review note on readability. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CZstUEGAAPuxzTjiAPii4S --- README.md | 1 + .../Documentation.docc/CloudKitAsYourBackend.md | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 03c428ff..383b61af 100644 --- a/README.md +++ b/README.md @@ -556,6 +556,7 @@ Links from the talk: - [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 BushelCloud CLI reads credentials 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 diff --git a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md index ac0b4926..f4cc3e6e 100644 --- a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md +++ b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md @@ -366,7 +366,9 @@ The public database accepts two methods and they are **not interchangeable**: th swift-openapi-generator does not know how to sign CloudKit requests, but it has middleware: `ServerMiddleware` runs before a request is received and `ClientMiddleware` runs before one is sent. Apple's own example shows a bearer-token middleware, and the generator's [example projects](https://github.com/apple/swift-openapi-generator/tree/main/Examples) include authentication, logging, and retrying middlewares to copy from. -This is the same slot the server-side Swift authentication libraries plug into. A question at iOSDevUK asked whether there is a Swift library for signing in with a third-party service, and what other authentication implementations exist. There are several, and they sit on the *server* side of the picture: [Imperial](https://github.com/vapor-community/Imperial) federates sign-in through Apple, Google, GitHub, and other OAuth providers for Vapor, [JWTKit](https://github.com/vapor/jwt-kit) verifies the identity tokens Sign in with Apple hands back, and [Hummingbird Auth](https://github.com/hummingbird-project/hummingbird-auth) covers sessions and bearer tokens for Hummingbird. Any of them can authenticate the *users* of your service. What none of them can do is mint a CloudKit web auth token — that only comes from an iCloud sign-in through CloudKit JS or `CKFetchWebAuthTokenOperation` — so a third-party identity sits alongside the CloudKit credential, the way Heartwitch's Postgres accounts do, rather than replacing it. MistKit's middleware is the client-side counterpart: it holds the CloudKit credential and signs the outgoing request. MistKit's version is one small type whose only job is to ask a ``TokenManager`` for the current ``Authenticator`` and let it modify the request: +This is the same slot the server-side Swift authentication libraries plug into. A question at iOSDevUK asked whether there is a Swift library for signing in with a third-party service, and what other authentication implementations exist. There are several, and they sit on the *server* side of the picture: [Imperial](https://github.com/vapor-community/Imperial) federates sign-in through Apple, Google, GitHub, and other OAuth providers for Vapor, [JWTKit](https://github.com/vapor/jwt-kit) verifies the identity tokens Sign in with Apple hands back, and [Hummingbird Auth](https://github.com/hummingbird-project/hummingbird-auth) covers sessions and bearer tokens for Hummingbird. Any of them can authenticate the *users* of your service. What none of them can do is mint a CloudKit web auth token — that only comes from an iCloud sign-in through CloudKit JS or `CKFetchWebAuthTokenOperation` — so a third-party identity sits alongside the CloudKit credential, the way Heartwitch's Postgres accounts do, rather than replacing it. + +MistKit's middleware is the client-side counterpart: it holds the CloudKit credential and signs the outgoing request. MistKit's version is one small type whose only job is to ask a ``TokenManager`` for the current ``Authenticator`` and let it modify the request: ```swift internal struct AuthenticationMiddleware: ClientMiddleware { @@ -490,7 +492,7 @@ where the body hash is `SHA256.cloudKitBodyHash(of:)` — `base64(SHA256(body))` ## Field type polymorphism -If you have handled JSON from a JavaScript-flavored API you know the problem. On stage it took the audience a moment to supply the name of the best-known example — not REST, not gRPC, the one Facebook uses: GraphQL — where the shape of a response is decided by the query and the client has to decode values whose types it does not know in advance. Swift's `Codable` wants those types at compile time, so a Swift client needs an explicit way to model "one of several". CloudKit has the same need in miniature: a field's value can be any of nine types, and the JSON does not always say which. A number, for instance, could be an `INT64` or a `DOUBLE`, because JavaScript does not distinguish them. MistKit models the domain side as one enum: +If you have handled JSON from a JavaScript-flavored API you know the problem. The best-known example is GraphQL, where the shape of a response is decided by the query and the client has to decode values whose types it does not know in advance. Swift's `Codable` wants those types at compile time, so a Swift client needs an explicit way to model "one of several". CloudKit has the same need in miniature: a field's value can be any of nine types, and the JSON does not always say which. A number, for instance, could be an `INT64` or a `DOUBLE`, because JavaScript does not distinguish them. MistKit models the domain side as one enum: ```swift public enum FieldValue: Codable, Equatable, Sendable { @@ -506,7 +508,7 @@ public enum FieldValue: Codable, Equatable, Sendable { } ``` -``Location``, ``Reference``, and ``Asset`` are MistKit's own structs so the package does not depend on Core Location or CloudKit on the server — Core Location in particular changes often enough that a server library is better off without it. A reference is a record name plus an action; an asset is what CloudKit returns for a file, including the download URL: +``Location``, ``Reference``, and ``Asset`` are MistKit's own structs so the package does not depend on Core Location or CloudKit on the server. A reference is a record name plus an action; an asset is what CloudKit returns for a file, including the download URL: ```swift public struct Reference: Codable, Equatable, Sendable { @@ -680,7 +682,7 @@ runs: --container-identifier "$CLOUDKIT_CONTAINER_ID" ``` -The CLI reads its credentials through [swift-configuration](https://github.com/apple/swift-configuration), which accepts the same keys as environment variables or command-line arguments, so the action passes secrets through the environment while a developer can pass flags locally. The CLI writes a JSON report that the action turns into the workflow's summary page. +The CLI reads its credentials through [swift-configuration](https://github.com/apple/swift-configuration), which accepts the same keys as environment variables or command-line arguments, so the action passes secrets through the environment while a developer can pass flags locally. [MistKitConfiguration](https://github.com/brightdigit/MistKitConfiguration) packages that layering, plus PEM and key-ID validation, for reuse across the examples. The CLI writes a JSON report that the action turns into the workflow's summary page. The payoff is visible in Bushel: each scheduled run checks whether Apple has posted a new restore image, a bug-fix release, or a new beta, and the app shows every version with a signed or unsigned flag — an unsigned image cannot be installed, so that flag is the one users care about. Static builds, credential injection on other platforms, tiered scheduling, idempotency, and observability are covered in . @@ -785,6 +787,7 @@ And one more thing: Leo's apps, both backed by patterns from this talk — [Bush - [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 BushelCloud CLI reads credentials from environment variables or arguments +- [MistKitConfiguration](https://github.com/brightdigit/MistKitConfiguration) — the shared credential-configuration package built on it (also at [`Packages/MistKitConfiguration`](https://github.com/brightdigit/MistKit/tree/main/Packages/MistKitConfiguration)) ### Other tools mentioned From e27c7dd5203c080a1804df4b5b601ad8fd4a20ab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 16:21:36 +0000 Subject: [PATCH 05/12] Address CodeRabbit: PEM format, secrets off the command line, middleware timing, GraphQL precision PrivateKeyMaterial only accepts raw PEM or a file path, and the BushelCloud sync action validates the BEGIN/END markers, so drop the base64 option the talk mentioned. Steer secrets to environment variables rather than flags, state ServerMiddleware's position relative to the handler, and describe GraphQL's union/interface polymorphism precisely. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CZstUEGAAPuxzTjiAPii4S --- .../MistKit/Documentation.docc/CloudKitAsYourBackend.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md index f4cc3e6e..23dda8f6 100644 --- a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md +++ b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md @@ -364,7 +364,7 @@ The public database accepts two methods and they are **not interchangeable**: th ### OpenAPI middleware -swift-openapi-generator does not know how to sign CloudKit requests, but it has middleware: `ServerMiddleware` runs before a request is received and `ClientMiddleware` runs before one is sent. Apple's own example shows a bearer-token middleware, and the generator's [example projects](https://github.com/apple/swift-openapi-generator/tree/main/Examples) include authentication, logging, and retrying middlewares to copy from. +swift-openapi-generator does not know how to sign CloudKit requests, but it has middleware: `ServerMiddleware` runs after the transport receives a request and before it reaches your handler; `ClientMiddleware` runs before a request is sent. Apple's own example shows a bearer-token middleware, and the generator's [example projects](https://github.com/apple/swift-openapi-generator/tree/main/Examples) include authentication, logging, and retrying middlewares to copy from. This is the same slot the server-side Swift authentication libraries plug into. A question at iOSDevUK asked whether there is a Swift library for signing in with a third-party service, and what other authentication implementations exist. There are several, and they sit on the *server* side of the picture: [Imperial](https://github.com/vapor-community/Imperial) federates sign-in through Apple, Google, GitHub, and other OAuth providers for Vapor, [JWTKit](https://github.com/vapor/jwt-kit) verifies the identity tokens Sign in with Apple hands back, and [Hummingbird Auth](https://github.com/hummingbird-project/hummingbird-auth) covers sessions and bearer tokens for Hummingbird. Any of them can authenticate the *users* of your service. What none of them can do is mint a CloudKit web auth token — that only comes from an iCloud sign-in through CloudKit JS or `CKFetchWebAuthTokenOperation` — so a third-party identity sits alongside the CloudKit credential, the way Heartwitch's Postgres accounts do, rather than replacing it. @@ -492,7 +492,7 @@ where the body hash is `SHA256.cloudKitBodyHash(of:)` — `base64(SHA256(body))` ## Field type polymorphism -If you have handled JSON from a JavaScript-flavored API you know the problem. The best-known example is GraphQL, where the shape of a response is decided by the query and the client has to decode values whose types it does not know in advance. Swift's `Codable` wants those types at compile time, so a Swift client needs an explicit way to model "one of several". CloudKit has the same need in miniature: a field's value can be any of nine types, and the JSON does not always say which. A number, for instance, could be an `INT64` or a `DOUBLE`, because JavaScript does not distinguish them. MistKit models the domain side as one enum: +If you have handled JSON from a JavaScript-flavored API you know the problem. The best-known example is GraphQL: a field declared as a union or interface can come back as any of several concrete types, and the client has to read `__typename` before it knows which one to decode. Swift's `Codable` wants those types at compile time, so a Swift client needs an explicit way to model "one of several". CloudKit has the same need in miniature: a field's value can be any of nine types, and the JSON does not always say which. A number, for instance, could be an `INT64` or a `DOUBLE`, because JavaScript does not distinguish them. MistKit models the domain side as one enum: ```swift public enum FieldValue: Codable, Equatable, Sendable { @@ -580,7 +580,7 @@ One documented call does not work at all: `GET users/discover` ("discover all us ## Deployment -Bushel's sync job runs entirely in GitHub Actions. The key ID and private key (pasted as-is, or as a base64-encoded copy if that is easier to handle) live in repository secrets. The API token and web auth token in the screenshot are there for the integration tests, not for the sync job: +Bushel's sync job runs entirely in GitHub Actions. The key ID and private key live in repository secrets. The private-key secret must hold the complete PEM, `BEGIN`/`END` markers included — the action validates that before touching CloudKit, and ``PrivateKeyMaterial/raw(_:)`` tolerates literal `\n` escapes if the secret store mangles the newlines. The API token and web auth token in the screenshot are there for the integration tests, not for the sync job: ![Repository secrets: CLOUDKIT_KEY_ID, CLOUDKIT_PRIVATE_KEY, CLOUDKIT_API_TOKEN, CLOUDKIT_WEB_AUTH_TOKEN](talk-github-secrets) @@ -682,7 +682,7 @@ runs: --container-identifier "$CLOUDKIT_CONTAINER_ID" ``` -The CLI reads its credentials through [swift-configuration](https://github.com/apple/swift-configuration), which accepts the same keys as environment variables or command-line arguments, so the action passes secrets through the environment while a developer can pass flags locally. [MistKitConfiguration](https://github.com/brightdigit/MistKitConfiguration) packages that layering, plus PEM and key-ID validation, for reuse across the examples. The CLI writes a JSON report that the action turns into the workflow's summary page. +The CLI reads its configuration through [swift-configuration](https://github.com/apple/swift-configuration), which accepts the same keys as environment variables or command-line flags. Use flags for the non-secret settings — container, environment — and keep the API token, web auth token, and private key in environment variables or a secret store: the action injects them through the environment, and a secret passed as a flag lands in shell history and the process table. [MistKitConfiguration](https://github.com/brightdigit/MistKitConfiguration) packages that layering, plus PEM and key-ID validation, for reuse across the examples. The CLI writes a JSON report that the action turns into the workflow's summary page. The payoff is visible in Bushel: each scheduled run checks whether Apple has posted a new restore image, a bug-fix release, or a new beta, and the app shows every version with a signed or unsigned flag — an unsigned image cannot be installed, so that flag is the one users care about. Static builds, credential injection on other platforms, tiered scheduling, idempotency, and observability are covered in . From 0e2227bc8eeca68592fb76ebd16e1343c116685e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 16:23:02 +0000 Subject: [PATCH 06/12] Address review: source the subpath definition, soften the Honza attribution, keep app plugs evergreen Apple defines the signed subpath as the URL without the host part and MistKit signs HTTPRequest.path verbatim, so describe it that way instead of asserting query-string handling the reference never specifies. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CZstUEGAAPuxzTjiAPii4S --- Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md index 23dda8f6..49746f29 100644 --- a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md +++ b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md @@ -219,7 +219,7 @@ A side effect worth pointing out: the spec is not Swift-specific. [`openapi.yaml ### Three layers -The generated client was the next thing that needed work. As Honza Dvorsky, one of the generator's maintainers, will tell you, the generated code works — but it is not the API you would want to hand to the users of a Swift library. So MistKit is layered so callers never see the generated code: +The generated client was the next thing that needed work. After talking it over with Honza Dvorsky, one of the generator's maintainers, the conclusion was the same one anyone who has read the generated code reaches: it works, but it is not the API you would want to hand to the users of a Swift library. So MistKit is layered so callers never see the generated code: ``` Your code service.queryRecords(recordType: "Note", database: .private) @@ -342,7 +342,7 @@ openssl ec -in eckey.pem -pubout Paste the public key into step 3, save, and copy the **Key ID**. The private key never leaves your server. -Each request is then signed. The payload is three strings joined by colons — the ISO 8601 date, the base64 SHA-256 of the body (or the empty string for no body), and the URL subpath — signed with ECDSA P-256 and sent in three headers. The subpath is everything between the host and the query string, for example `/database/1/iCloud.com.example.App/development/public/records/modify`: +Each request is then signed. The payload is three strings joined by colons — the ISO 8601 date, the base64 SHA-256 of the body (or the empty string for no body), and the URL subpath — signed with ECDSA P-256 and sent in three headers. The subpath is the request URL with the scheme and host removed — everything from `/database/` onward, for example `/database/1/iCloud.com.example.App/development/public/records/modify` — which is exactly what MistKit signs as the request's path: | Header | Value | | --- | --- | @@ -690,7 +690,7 @@ The payoff is visible in Bushel: each scheduled run checks whether Apple has pos Every endpoint in the CloudKit Web Services reference is implemented — records, zones, changes, subscriptions, users, sharing, assets, and APNs tokens — and exercised live by MistDemo. What the project needs now is people using it: try it against your own container and file what you find on the [issue tracker](https://github.com/brightdigit/MistKit/issues). -And one more thing: Leo's apps, both backed by patterns from this talk — [Bushel](https://getbushel.app), virtualization for app developers, on the Mac App Store with larger updates planned around macOS 27, and [AtLeast](https://atleast.app), a passive timer for breathing and meditation on Apple Watch, in TestFlight for watchOS 26. +And one more thing: Leo's apps, both backed by patterns from this talk — [Bushel](https://getbushel.app), virtualization for app developers on the Mac, and [AtLeast](https://atleast.app), a passive timer for breathing and meditation on Apple Watch. ## Links From cf42a3c48c7c1499da94c89dca039943adca87f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 16:28:13 +0000 Subject: [PATCH 07/12] Note that AuthenticationMiddleware also consumes rotated tokens from the response Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CZstUEGAAPuxzTjiAPii4S --- Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md index 49746f29..fae02219 100644 --- a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md +++ b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md @@ -368,7 +368,7 @@ swift-openapi-generator does not know how to sign CloudKit requests, but it has This is the same slot the server-side Swift authentication libraries plug into. A question at iOSDevUK asked whether there is a Swift library for signing in with a third-party service, and what other authentication implementations exist. There are several, and they sit on the *server* side of the picture: [Imperial](https://github.com/vapor-community/Imperial) federates sign-in through Apple, Google, GitHub, and other OAuth providers for Vapor, [JWTKit](https://github.com/vapor/jwt-kit) verifies the identity tokens Sign in with Apple hands back, and [Hummingbird Auth](https://github.com/hummingbird-project/hummingbird-auth) covers sessions and bearer tokens for Hummingbird. Any of them can authenticate the *users* of your service. What none of them can do is mint a CloudKit web auth token — that only comes from an iCloud sign-in through CloudKit JS or `CKFetchWebAuthTokenOperation` — so a third-party identity sits alongside the CloudKit credential, the way Heartwitch's Postgres accounts do, rather than replacing it. -MistKit's middleware is the client-side counterpart: it holds the CloudKit credential and signs the outgoing request. MistKit's version is one small type whose only job is to ask a ``TokenManager`` for the current ``Authenticator`` and let it modify the request: +MistKit's middleware is the client-side counterpart: it holds the CloudKit credential and signs the outgoing request. MistKit's version is one small type whose main job is to ask a ``TokenManager`` for the current ``Authenticator`` and let it modify the request; on the way back it also hands any rotated web auth token in the response to the token manager: ```swift internal struct AuthenticationMiddleware: ClientMiddleware { From 92c196977e2d718bb01198344707bf5f7209b7b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 16:38:00 +0000 Subject: [PATCH 08/12] Address review: MistKit definition wording, Honza and MistDemo links, auth-library phrasing, Open Radar, Bushel attribution Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CZstUEGAAPuxzTjiAPii4S --- .../Documentation.docc/CloudKitAsYourBackend.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md index fae02219..0f166508 100644 --- a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md +++ b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md @@ -161,7 +161,7 @@ The two stories above generalize into four families: | **Web app ↔ Apple device bridge** | A browser portal for a CloudKit-backed app, a webhook handler (Stripe, GitHub, forms) that writes straight into a user's records | | **Data aggregation** | Anonymized telemetry read via `records/changes`, crowdsourced data cleaned and written back by a background steward | -Apple's CloudKit framework only runs on Apple platforms. The one-line description of MistKit from the talk: it is the CloudKit framework for the places the CloudKit framework does not go — Linux, Windows, and anything else that can only reach CloudKit through Web Services — so server-side Swift, Linux services, and command-line tools can take part in the same containers as your apps. +Apple's CloudKit framework only runs on Apple platforms. MistKit is the CloudKit framework for the places the CloudKit framework does not go — Linux, Windows, and anything else by wrapping the CloudKit Web Services — server-side Swift, Linux services, and command-line tools can take part in the same containers as your apps. ## Building MistKit @@ -219,7 +219,7 @@ A side effect worth pointing out: the spec is not Swift-specific. [`openapi.yaml ### Three layers -The generated client was the next thing that needed work. After talking it over with Honza Dvorsky, one of the generator's maintainers, the conclusion was the same one anyone who has read the generated code reaches: it works, but it is not the API you would want to hand to the users of a Swift library. So MistKit is layered so callers never see the generated code: +The generated client was the next thing that needed work. After talking it over with [Honza Dvorsky](https://github.com/czechboy0), one of the generator's maintainers, the conclusion was the same one anyone who has read the generated code reaches: it works, but it is not the API you would want to hand to the users of a Swift library. So MistKit is layered so callers never see the generated code: ``` Your code service.queryRecords(recordType: "Note", database: .private) @@ -242,7 +242,7 @@ Unit tests were not enough — the assistant would report success on code that f ![The MistKit web demo, switching between MistKit and CloudKit JS backends](talk-mistdemo-web) -MistDemo is the live-verification oracle for everything in this article that describes CloudKit's wire behavior. The lesson generalizes beyond MistKit: if an assistant wrote it, run it yourself against the real thing before believing the summary. +[MistDemo](https://github.com/brightdigit/MistKit/tree/main/Examples/MistDemo) is the live-verification oracle for everything in this article that describes CloudKit's wire behavior. The lesson generalizes beyond MistKit: if an assistant wrote it, run it yourself against the real thing before believing the summary. ## Authentication @@ -366,7 +366,7 @@ The public database accepts two methods and they are **not interchangeable**: th swift-openapi-generator does not know how to sign CloudKit requests, but it has middleware: `ServerMiddleware` runs after the transport receives a request and before it reaches your handler; `ClientMiddleware` runs before a request is sent. Apple's own example shows a bearer-token middleware, and the generator's [example projects](https://github.com/apple/swift-openapi-generator/tree/main/Examples) include authentication, logging, and retrying middlewares to copy from. -This is the same slot the server-side Swift authentication libraries plug into. A question at iOSDevUK asked whether there is a Swift library for signing in with a third-party service, and what other authentication implementations exist. There are several, and they sit on the *server* side of the picture: [Imperial](https://github.com/vapor-community/Imperial) federates sign-in through Apple, Google, GitHub, and other OAuth providers for Vapor, [JWTKit](https://github.com/vapor/jwt-kit) verifies the identity tokens Sign in with Apple hands back, and [Hummingbird Auth](https://github.com/hummingbird-project/hummingbird-auth) covers sessions and bearer tokens for Hummingbird. Any of them can authenticate the *users* of your service. What none of them can do is mint a CloudKit web auth token — that only comes from an iCloud sign-in through CloudKit JS or `CKFetchWebAuthTokenOperation` — so a third-party identity sits alongside the CloudKit credential, the way Heartwitch's Postgres accounts do, rather than replacing it. +This is the same slot the server-side Swift authentication libraries plug into. There are several Swift libraries for signing in with third-party services, such as [Imperial](https://github.com/vapor-community/Imperial), which federates sign-in through Apple, Google, GitHub, and other OAuth providers for Vapor, and [JWTKit](https://github.com/vapor/jwt-kit), which verifies the identity tokens Sign in with Apple hands back; [Hummingbird Auth](https://github.com/hummingbird-project/hummingbird-auth) covers sessions and bearer tokens for Hummingbird. They sit on the *server* side of the picture, and any of them can authenticate the *users* of your service. What none of them can do is mint a CloudKit web auth token — that only comes from an iCloud sign-in through CloudKit JS or `CKFetchWebAuthTokenOperation` — so a third-party identity sits alongside the CloudKit credential, the way Heartwitch's Postgres accounts do, rather than replacing it. MistKit's middleware is the client-side counterpart: it holds the CloudKit credential and signs the outgoing request. MistKit's version is one small type whose main job is to ask a ``TokenManager`` for the current ``Authenticator`` and let it modify the request; on the way back it also hands any rotated web auth token in the response to the token manager: @@ -576,7 +576,7 @@ and the generator produces a matching enum and struct. MistKit maps each of the ### The endpoint that returns HTTP 500 -One documented call does not work at all: `GET users/discover` ("discover all user identities") returns `500 INTERNAL_ERROR` from both the REST API and Apple's own CloudKit JS, reproducibly, after authentication succeeds. It appears to have been retired server-side — possibly for privacy reasons — without the documentation changing; the framework's equivalent, `CKDiscoverAllUserIdentitiesOperation`, is deprecated as well, so the archived web-services page is the only place the feature still looks alive. MistKit generates the operation from `openapi.yaml` but does not surface it in ``CloudKitService``; the `POST users/discover` form, which takes lookup infos, works and is what ``CloudKitService/discoverUserIdentities(lookupInfos:)`` calls. The details are tracked in [MistKit issue #28](https://github.com/brightdigit/MistKit/issues/28) and Apple Feedback FB22754466. +One documented call does not work at all: `GET users/discover` ("discover all user identities") returns `500 INTERNAL_ERROR` from both the REST API and Apple's own CloudKit JS, reproducibly, after authentication succeeds. It appears to have been retired server-side — possibly for privacy reasons — without the documentation changing; the framework's equivalent, `CKDiscoverAllUserIdentitiesOperation`, is deprecated as well, so the archived web-services page is the only place the feature still looks alive. MistKit generates the operation from `openapi.yaml` but does not surface it in ``CloudKitService``; the `POST users/discover` form, which takes lookup infos, works and is what ``CloudKitService/discoverUserIdentities(lookupInfos:)`` calls. The details are tracked in [MistKit issue #28](https://github.com/brightdigit/MistKit/issues/28) and Apple Feedback FB22754466 ([Open Radar](https://openradar.appspot.com/FB22754466)). ## Deployment @@ -690,7 +690,7 @@ The payoff is visible in Bushel: each scheduled run checks whether Apple has pos Every endpoint in the CloudKit Web Services reference is implemented — records, zones, changes, subscriptions, users, sharing, assets, and APNs tokens — and exercised live by MistDemo. What the project needs now is people using it: try it against your own container and file what you find on the [issue tracker](https://github.com/brightdigit/MistKit/issues). -And one more thing: Leo's apps, both backed by patterns from this talk — [Bushel](https://getbushel.app), virtualization for app developers on the Mac, and [AtLeast](https://atleast.app), a passive timer for breathing and meditation on Apple Watch. +And one more thing: Leo's apps — [Bushel](https://getbushel.app), virtualization for app developers on the Mac, backed by the patterns in this talk, and [AtLeast](https://atleast.app), a passive timer for breathing and meditation on Apple Watch. ## Links From 7ec3207229292ffdcbd21e9d089b04c71501de90 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 16:40:55 +0000 Subject: [PATCH 09/12] Split the MistKit definition into two sentences and drop a repeated phrase Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CZstUEGAAPuxzTjiAPii4S --- Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md index 0f166508..944d3068 100644 --- a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md +++ b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md @@ -161,7 +161,7 @@ The two stories above generalize into four families: | **Web app ↔ Apple device bridge** | A browser portal for a CloudKit-backed app, a webhook handler (Stripe, GitHub, forms) that writes straight into a user's records | | **Data aggregation** | Anonymized telemetry read via `records/changes`, crowdsourced data cleaned and written back by a background steward | -Apple's CloudKit framework only runs on Apple platforms. MistKit is the CloudKit framework for the places the CloudKit framework does not go — Linux, Windows, and anything else by wrapping the CloudKit Web Services — server-side Swift, Linux services, and command-line tools can take part in the same containers as your apps. +Apple's CloudKit framework only runs on Apple platforms. MistKit is the CloudKit framework for the places the CloudKit framework does not go — Linux, Windows, and anything else — by wrapping the CloudKit Web Services. Server-side Swift, Linux services, and command-line tools can take part in the same containers as your apps. ## Building MistKit @@ -246,7 +246,7 @@ Unit tests were not enough — the assistant would report success on code that f ## Authentication -On a device, authentication is invisible: the user is signed in to iCloud and the framework does the rest. Outside the Apple ecosystem you either put a sign-in button on a web page or manage credentials yourself, and on a server you have to prove who you are with credentials you manage. Apple documents three methods; it is more honest to call it **two and a half**, because the first one is a prerequisite for the second rather than a peer. +On a device, authentication is invisible: the user is signed in to iCloud and the framework does the rest. Outside the Apple ecosystem you either put a sign-in button on a web page or manage credentials yourself, and on a server you have to prove who you are with those credentials. Apple documents three methods; it is more honest to call it **two and a half**, because the first one is a prerequisite for the second rather than a peer. All of them start in the CloudKit Console under **Tokens & Keys** for your container. From d878783019e3a95ad991971aa047a102a2b6bf56 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 16:47:30 +0000 Subject: [PATCH 10/12] Link people, projects, and Apple documentation throughout the talk article Wrap the first mention per section of framework symbols, archived CloudKit Web Services pages, the CloudKit Console, the OpenAPI transports, Vapor, Hummingbird, swift-crypto, GitHub Actions docs, and the example directories. Describe the swift-configuration link as covering all the example CLIs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CZstUEGAAPuxzTjiAPii4S --- README.md | 2 +- .../CloudKitAsYourBackend.md | 82 +++++++++---------- 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 383b61af..1c380147 100644 --- a/README.md +++ b/README.md @@ -555,7 +555,7 @@ 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 BushelCloud CLI reads credentials from environment variables or arguments +- [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 diff --git a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md index 944d3068..c958d8d8 100644 --- a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md +++ b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md @@ -4,9 +4,9 @@ From iOS to server-side Swift — the talk behind MistKit, consolidated into one ## Overview -CloudKit is great for iOS apps. How about backend services? This article is the written form of a conference talk by Leo Dion ([@leogdion@c.im](https://c.im/@leogdion)) that walks from "what is CloudKit" to a scheduled GitHub Actions job writing to a CloudKit public database from a stock Ubuntu runner, and explains the three problems that shaped MistKit along the way: authentication, field-type polymorphism, and error handling. +CloudKit is great for iOS apps. How about backend services? This article is the written form of a conference talk by Leo Dion ([@leogdion@c.im](https://c.im/@leogdion)) that walks from "what is CloudKit" to a scheduled [GitHub Actions](https://docs.github.com/en/actions) job writing to a CloudKit public database from a stock Ubuntu runner, and explains the three problems that shaped [MistKit](https://github.com/brightdigit/MistKit) along the way: authentication, field-type polymorphism, and error handling. -The talk was given in 2026 at Swift Craft and iOSDevUK (Aberystwyth). The abstract: +The talk was given in 2026 at Swift Craft and [iOSDevUK](https://www.iosdevuk.com) (Aberystwyth). The abstract: > CloudKit has excellent documentation for iOS and macOS client development. But backend services — podcast aggregation, RSS readers, data processing — face APIs that Apple barely documents. I rebuilt a comprehensive CloudKit library using AI-generated OpenAPI specifications. The result: type-safe Swift code supporting three authentication methods (server-to-server, web authentication token, and API token), typed error handling, and production deployments. @@ -34,13 +34,13 @@ The sections below follow the slide order and were revised against the recording ## What is CloudKit -Travel back to WWDC 2014 — the year Swift was introduced — and you also find the introduction of CloudKit. The idea was simple: give iOS developers a backend for storage, logic, database, search, and notifications without running a server. +Travel back to [WWDC 2014](https://nonstrict.eu/wwdcindex/wwdc2014/208/) — the year Swift was introduced — and you also find the introduction of [CloudKit](https://developer.apple.com/documentation/cloudkit). The idea was simple: give iOS developers a backend for storage, logic, database, search, and notifications without running a server. -Setting it up is a checkbox in Xcode: enable the **iCloud** capability, tick **CloudKit**, and add a **container**. The container is the storage for your record types and records. +Setting it up is a checkbox in Xcode: [enable the **iCloud** capability](https://developer.apple.com/documentation/cloudkit/enabling-cloudkit-in-your-app), tick **CloudKit**, and add a **container**. The container is the storage for your record types and records. ### Records and field types -Records are the main way data is stored. Think of a record as a table row with typed fields. The CloudKit Console lets you create record types and fields interactively; each field has one of nine types: +Records are the main way data is stored. Think of a record as a table row with typed fields. The [CloudKit Console](https://icloud.developer.apple.com/dashboard/) lets you create record types and fields interactively; each field has one of nine types: | CloudKit type | Native framework type | MistKit ``FieldValue`` case | | --- | --- | --- | @@ -49,9 +49,9 @@ Records are the main way data is stored. Think of a record as a table row with t | `DOUBLE` | `Double` / `NSNumber` | ``FieldValue/double(_:)`` | | `BYTES` | `Data` | ``FieldValue/bytes(_:)`` | | `TIMESTAMP` | `Date` | ``FieldValue/date(_:)`` | -| `LOCATION` | `CLLocation` | ``FieldValue/location(_:)`` | -| `REFERENCE` | `CKRecord.Reference` | ``FieldValue/reference(_:)`` | -| `ASSET` | `CKAsset` | ``FieldValue/asset(_:)`` | +| `LOCATION` | [`CLLocation`](https://developer.apple.com/documentation/corelocation/cllocation) | ``FieldValue/location(_:)`` | +| `REFERENCE` | [`CKRecord.Reference`](https://developer.apple.com/documentation/cloudkit/ckrecord/reference) | ``FieldValue/reference(_:)`` | +| `ASSET` | [`CKAsset`](https://developer.apple.com/documentation/cloudkit/ckasset) | ``FieldValue/asset(_:)`` | | `LIST` | `Array` | ``FieldValue/list(_:)`` | There is no boolean type — CloudKit stores booleans as `INT64` `0`/`1`, which is why MistKit offers ``FieldValue/init(booleanValue:)`` and ``FieldValue/boolValue``. @@ -99,14 +99,14 @@ Everything above is the native, on-device CloudKit story. The rest of the talk i Part of CloudKit's original design was access from the web. There are two ways in: -- **CloudKit JS** — a JavaScript library that gives a browser roughly the same API as the CloudKit framework. If you are running in a browser, use it; MistKit is not for that. +- **[CloudKit JS](https://developer.apple.com/documentation/cloudkitjs)** — a JavaScript library that gives a browser roughly the same API as the CloudKit framework. If you are running in a browser, use it; MistKit is not for that. - **CloudKit Web Services** — the REST API underneath. Every operation is a `POST` (or occasionally `GET`) against: ```text https://api.apple-cloudkit.com/database/{version}/{container}/{environment}/{database}/{operation} ``` -The REST API is documented in Apple's *CloudKit Web Services Reference*, which is now in the developer library archive. Its **document revision history ends in 2016**. The API still works — it is what CloudKit JS talks to — but nothing about it has been written down by Apple in a decade, and in several places the archived reference and the live server disagree. catalogs those. +The REST API is documented in Apple's [*CloudKit Web Services Reference*](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/index.html), which is now in the developer library archive. Its **[document revision history](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/RevisionHistory.html) ends in 2016**. The API still works — it is what CloudKit JS talks to — but nothing about it has been written down by Apple in a decade, and in several places the archived reference and the live server disagree. catalogs those. ## Why server-side CloudKit @@ -114,7 +114,7 @@ Each database has its own reason to be reached from a server. ### Private database: Heartwitch -[Heartwitch](https://heartwitch.app) is an Apple Watch app that streams a workout's heart rate to a browser overlay so streamers can show it on a live stream. Built before Sign in with Apple existed, it looked like this: +[Heartwitch](https://heartwitch.app) is an Apple Watch app that streams a workout's heart rate to a browser overlay so streamers can show it on a live stream. Built before [Sign in with Apple](https://developer.apple.com/documentation/signinwithapple) existed, it looked like this: ``` Apple Watch ──POST heart rate──▶ Vapor server ──WebSocket──▶ Browser ──▶ OBS overlay @@ -127,8 +127,8 @@ The website had a username-and-password login. Typing a password on a watch face 1. The watch app runs and adds an *Apple Watch* record to the user's private database. 2. The user signs in to the website. 3. The website offers "sign in to CloudKit" via CloudKit JS, which yields a web auth token. -4. The server uses that token to read the user's private database, finds the watch record, and stores the watch ID next to the Postgres account. -5. The watch posts heart rate with its watch ID; the Vapor server now knows which user it belongs to and where to stream it. +4. The server uses that token to read the user's private database, finds the watch record, and stores the watch ID next to the [Postgres](https://www.postgresql.org) account. +5. The watch posts heart rate with its watch ID; the [Vapor](https://vapor.codes) server now knows which user it belongs to and where to stream it. Step 4 is the important design decision: the server **copies** what it needs out of CloudKit. The private database is owned by the user, not by you, so treat it as an *input* rather than a system of record. @@ -171,13 +171,13 @@ The first MistKit, started in 2020 for Heartwitch, was written by hand, which me - verifying whether that documentation was even correct, - re-learning CloudKit's architecture piece by piece, - implementing ECDSA signing for server-to-server authentication, -- and hand-writing two network stacks — `URLSession` for clients and `AsyncHTTPClient` for servers — before `async`/`await`. +- and hand-writing two network stacks — [`URLSession`](https://developer.apple.com/documentation/foundation/urlsession) for clients and [`AsyncHTTPClient`](https://github.com/swift-server/async-http-client) for servers — before `async`/`await`. Only what Heartwitch needed got implemented. Two things made a full rebuild feasible. ### Swift OpenAPI Generator -[swift-openapi-generator](https://github.com/apple/swift-openapi-generator), announced at WWDC 2023, reads an OpenAPI document and generates a type-safe Swift client. The document describes metadata, servers, paths (with their methods and responses), and reusable component schemas: +[swift-openapi-generator](https://github.com/apple/swift-openapi-generator), announced at [WWDC 2023](https://developer.apple.com/videos/play/wwdc2023/10171/), reads an OpenAPI document and generates a type-safe Swift client. The document describes metadata, servers, paths (with their methods and responses), and reusable component schemas: ```yaml openapi: 3.1.0 @@ -209,11 +209,11 @@ components: isCompleted: { type: boolean } ``` -The generator ships transports for `URLSession` and `AsyncHTTPClient`, plus server transports for Vapor, Hummingbird, and Lambda, so one spec covers every place MistKit needs to run. How MistKit drives the generator is in ; what the output looks like is in . +The generator ships transports for `URLSession` and `AsyncHTTPClient`, plus server transports for [Vapor](https://github.com/vapor/swift-openapi-vapor), [Hummingbird](https://github.com/hummingbird-project/swift-openapi-hummingbird), and [Lambda](https://github.com/awslabs/swift-openapi-lambda), so one spec covers every place MistKit needs to run. How MistKit drives the generator is in ; what the output looks like is in . ### Turning the documentation into a spec -The remaining problem was producing an OpenAPI document for an API that only exists as 2016-era prose. That is where AI-assisted development came in: each documented endpoint was fed to an LLM — a little Claude, a little Cursor — and translated into `openapi.yaml`, then abstractions were built on top. Converting one format of documentation into another is a job these tools are genuinely good at. It was not automatic — hallucinated APIs, context-window limits, and unrequested scaffolding (retries, caches, "secure memory") were constant, and the assistant regularly declared success before running the build. The catalog of what went wrong, with evidence, is ; the narrative version is in the two *Rebuilding MistKit with Claude Code* articles ([part 1](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-1/), [part 2](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-2/)). +The remaining problem was producing an OpenAPI document for an API that only exists as 2016-era prose. That is where AI-assisted development came in: each documented endpoint was fed to an LLM — a little [Claude](https://claude.com/claude-code), a little [Cursor](https://cursor.com) — and translated into `openapi.yaml`, then abstractions were built on top. Converting one format of documentation into another is a job these tools are genuinely good at. It was not automatic — hallucinated APIs, context-window limits, and unrequested scaffolding (retries, caches, "secure memory") were constant, and the assistant regularly declared success before running the build. The catalog of what went wrong, with evidence, is ; the narrative version is in the two *Rebuilding MistKit with Claude Code* articles ([part 1](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-1/), [part 2](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-2/)). A side effect worth pointing out: the spec is not Swift-specific. [`openapi.yaml`](https://github.com/brightdigit/MistKit/blob/main/openapi.yaml) documents every call and every schema of CloudKit Web Services, so someone who wants the same client in Java, Python, PHP, Go, Dart, or Smalltalk can feed the same file to their own generator. @@ -238,7 +238,7 @@ CloudKit Web Services ### Proving it works -Unit tests were not enough — the assistant would report success on code that failed the moment it touched a real container. So MistDemo (`Examples/MistDemo/`) exists: a command-line tool with live integration phases (`mistdemo test-public`, `mistdemo test-private`), a macOS app, and a web interface served by Hummingbird that exercises every endpoint through either MistKit or CloudKit JS, side by side: +Unit tests were not enough — the assistant would report success on code that failed the moment it touched a real container. So MistDemo ([`Examples/MistDemo/`](https://github.com/brightdigit/MistKit/tree/main/Examples/MistDemo)) exists: a command-line tool with live integration phases (`mistdemo test-public`, `mistdemo test-private`), a macOS app, and a web interface served by [Hummingbird](https://hummingbird.codes) that exercises every endpoint through either MistKit or CloudKit JS, side by side: ![The MistKit web demo, switching between MistKit and CloudKit JS backends](talk-mistdemo-web) @@ -248,11 +248,11 @@ Unit tests were not enough — the assistant would report success on code that f On a device, authentication is invisible: the user is signed in to iCloud and the framework does the rest. Outside the Apple ecosystem you either put a sign-in button on a web page or manage credentials yourself, and on a server you have to prove who you are with those credentials. Apple documents three methods; it is more honest to call it **two and a half**, because the first one is a prerequisite for the second rather than a peer. -All of them start in the CloudKit Console under **Tokens & Keys** for your container. +All of them start in the [CloudKit Console](https://icloud.developer.apple.com/dashboard/) under **Tokens & Keys** for your container. ### API token -Create an API token with the `+` button, give it a name, choose a **sign-in callback** (see below), optionally restrict it to certain domains, and decide whether to allow user discoverability at sign-in. Every web-services request then carries it as a query item: +[Create an API token](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/SettingUpWebServices.html#//apple_ref/doc/uid/TP40015240-CH24-SW2) with the `+` button, give it a name, choose a **sign-in callback** (see below), optionally restrict it to certain domains, and decide whether to allow user discoverability at sign-in. Every web-services request then carries it as a query item: ```text ?ckAPIToken=[API token] @@ -274,7 +274,7 @@ On its own an API token grants only what the public database's `_world` role all ### Web auth token -A web auth token is what you need to act as a specific iCloud user, and the only way into the private and shared databases. Requests carry both tokens: +A [web auth token](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/SettingUpWebServices.html) is what you need to act as a specific iCloud user, and the only way into the private and shared databases. Requests carry both tokens: ```text ?ckAPIToken=[API token]&ckWebAuthToken=[Web Auth Token] @@ -300,14 +300,14 @@ How the token comes back depends on the sign-in callback chosen when the API tok https://[callback-url]/?ckSession=[Web Auth Token] ``` -Two facts about the token that Apple documents only in the archived reference: +Two facts about the token that Apple documents only in the [archived reference](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/index.html): - **Lifetime** — 30 minutes by default, two weeks if the user ticks *Keep me signed in*. - **Rotation** — every response carries a fresh token in the `X-Apple-CloudKit-Web-Auth-Token` header, and the documented rule is that the previous token is invalid once the response arrives. MistKit's `AuthenticationMiddleware` reads that header after every request and hands it to ``TokenManager/didReceiveRotatedWebAuthToken(_:)``; ``WebAuthTokenManager`` and ``AdaptiveTokenManager`` adopt it. (In practice the live server has been observed to keep accepting old tokens, but the library follows the written rule.) #### From inside an iOS app -You do not need a browser at all if the user is already signed in to your iOS app. `CKFetchWebAuthTokenOperation` exchanges the device's iCloud session for a web auth token that your server can use. MistDemo wraps it in an `async` function: +You do not need a browser at all if the user is already signed in to your iOS app. [`CKFetchWebAuthTokenOperation`](https://developer.apple.com/documentation/cloudkit/ckfetchwebauthtokenoperation) exchanges the device's iCloud session for a web auth token that your server can use. MistDemo wraps it in an `async` function: ```swift extension CKDatabase { @@ -328,7 +328,7 @@ Run it against the **private** database; on the public database it fails or retu ### Server to server -For a job with no user — a cron job, a daemon, a CLI — use a server-to-server key. Under **Tokens & Keys → Server-to-Server Keys**, the console shows the exact commands: +For a job with no user — a cron job, a daemon, a CLI — use a [server-to-server key](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/SettingUpWebServices.html#//apple_ref/doc/uid/TP40015240-CH24-SW6). Under **Tokens & Keys → Server-to-Server Keys**, the console shows the exact commands: ![The "New Server-to-Server Key" page in the CloudKit Console](talk-server-to-server-key) @@ -360,13 +360,13 @@ There is no `Authorization` header. Every mistake in any of those pieces produce | Web auth token | ✓ user-attributed | ✓ | ✓ | | Server-to-server | ✓ developer-attributed | — | — | -The public database accepts two methods and they are **not interchangeable**: the same record written via web auth and via server-to-server ends up with two different creators, and the `/users/*` routes accept web auth only. MistKit therefore makes every public call say which it wants — ``Database/public(_:)`` carries a ``PublicAuthPreference`` — rather than defaulting silently. covers the model; the deeper "why" is in . +The public database accepts two methods and they are **not interchangeable**: the same record written via web auth and via server-to-server ends up with two different creators, and the [`/users/*` routes](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/GetCurrentUser.html) accept web auth only. MistKit therefore makes every public call say which it wants — ``Database/public(_:)`` carries a ``PublicAuthPreference`` — rather than defaulting silently. covers the model; the deeper "why" is in . ### OpenAPI middleware -swift-openapi-generator does not know how to sign CloudKit requests, but it has middleware: `ServerMiddleware` runs after the transport receives a request and before it reaches your handler; `ClientMiddleware` runs before a request is sent. Apple's own example shows a bearer-token middleware, and the generator's [example projects](https://github.com/apple/swift-openapi-generator/tree/main/Examples) include authentication, logging, and retrying middlewares to copy from. +[swift-openapi-generator](https://github.com/apple/swift-openapi-generator) does not know how to sign CloudKit requests, but it has middleware: `ServerMiddleware` runs after the transport receives a request and before it reaches your handler; [`ClientMiddleware`](https://swiftpackageindex.com/apple/swift-openapi-runtime/documentation/openapiruntime/clientmiddleware) runs before a request is sent. Apple's own example shows a bearer-token middleware, and the generator's [example projects](https://github.com/apple/swift-openapi-generator/tree/main/Examples) include authentication, logging, and retrying middlewares to copy from. -This is the same slot the server-side Swift authentication libraries plug into. There are several Swift libraries for signing in with third-party services, such as [Imperial](https://github.com/vapor-community/Imperial), which federates sign-in through Apple, Google, GitHub, and other OAuth providers for Vapor, and [JWTKit](https://github.com/vapor/jwt-kit), which verifies the identity tokens Sign in with Apple hands back; [Hummingbird Auth](https://github.com/hummingbird-project/hummingbird-auth) covers sessions and bearer tokens for Hummingbird. They sit on the *server* side of the picture, and any of them can authenticate the *users* of your service. What none of them can do is mint a CloudKit web auth token — that only comes from an iCloud sign-in through CloudKit JS or `CKFetchWebAuthTokenOperation` — so a third-party identity sits alongside the CloudKit credential, the way Heartwitch's Postgres accounts do, rather than replacing it. +This is the same slot the server-side Swift authentication libraries plug into. There are several Swift libraries for signing in with third-party services, such as [Imperial](https://github.com/vapor-community/Imperial), which federates sign-in through Apple, Google, GitHub, and other OAuth providers for [Vapor](https://vapor.codes), and [JWTKit](https://github.com/vapor/jwt-kit), which verifies the identity tokens [Sign in with Apple](https://developer.apple.com/documentation/signinwithapple) hands back; [Hummingbird Auth](https://github.com/hummingbird-project/hummingbird-auth) covers sessions and bearer tokens for [Hummingbird](https://hummingbird.codes). They sit on the *server* side of the picture, and any of them can authenticate the *users* of your service. What none of them can do is mint a CloudKit web auth token — that only comes from an iCloud sign-in through CloudKit JS or `CKFetchWebAuthTokenOperation` — so a third-party identity sits alongside the CloudKit credential, the way Heartwitch's Postgres accounts do, rather than replacing it. MistKit's middleware is the client-side counterpart: it holds the CloudKit credential and signs the outgoing request. MistKit's version is one small type whose main job is to ask a ``TokenManager`` for the current ``Authenticator`` and let it modify the request; on the way back it also hands any rotated web auth token in the response to the token manager: @@ -492,7 +492,7 @@ where the body hash is `SHA256.cloudKitBodyHash(of:)` — `base64(SHA256(body))` ## Field type polymorphism -If you have handled JSON from a JavaScript-flavored API you know the problem. The best-known example is GraphQL: a field declared as a union or interface can come back as any of several concrete types, and the client has to read `__typename` before it knows which one to decode. Swift's `Codable` wants those types at compile time, so a Swift client needs an explicit way to model "one of several". CloudKit has the same need in miniature: a field's value can be any of nine types, and the JSON does not always say which. A number, for instance, could be an `INT64` or a `DOUBLE`, because JavaScript does not distinguish them. MistKit models the domain side as one enum: +If you have handled JSON from a JavaScript-flavored API you know the problem. The best-known example is [GraphQL](https://graphql.org): a field declared as a union or interface can come back as any of several concrete types, and the client has to read `__typename` before it knows which one to decode. Swift's [`Codable`](https://developer.apple.com/documentation/swift/codable) wants those types at compile time, so a Swift client needs an explicit way to model "one of several". CloudKit has the same need in miniature: a field's value can be any of nine types, and the JSON does not always say which. A number, for instance, could be an `INT64` or a `DOUBLE`, because JavaScript does not distinguish them. MistKit models the domain side as one enum: ```swift public enum FieldValue: Codable, Equatable, Sendable { @@ -508,7 +508,7 @@ public enum FieldValue: Codable, Equatable, Sendable { } ``` -``Location``, ``Reference``, and ``Asset`` are MistKit's own structs so the package does not depend on Core Location or CloudKit on the server. A reference is a record name plus an action; an asset is what CloudKit returns for a file, including the download URL: +``Location``, ``Reference``, and ``Asset`` are MistKit's own structs so the package does not depend on [Core Location](https://developer.apple.com/documentation/corelocation) or CloudKit on the server. A reference is a record name plus an action; an asset is what CloudKit returns for a file, including the download URL: ```swift public struct Reference: Codable, Equatable, Sendable { @@ -549,7 +549,7 @@ The thirty-second version hides a lot: the `oneOf` has no discriminator, so a wh ## Error handling -This is the best-documented corner of the API: a table of every `serverErrorCode` alongside the HTTP status it pairs with, as easy for an assistant to work from as for a person. The JSON error body is the same shape for all of them: +This is the best-documented corner of the API: a [table of every `serverErrorCode`](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ErrorCodes.html) alongside the HTTP status it pairs with, as easy for an assistant to work from as for a person. The JSON error body is the same shape for all of them: ```json { @@ -576,15 +576,15 @@ and the generator produces a matching enum and struct. MistKit maps each of the ### The endpoint that returns HTTP 500 -One documented call does not work at all: `GET users/discover` ("discover all user identities") returns `500 INTERNAL_ERROR` from both the REST API and Apple's own CloudKit JS, reproducibly, after authentication succeeds. It appears to have been retired server-side — possibly for privacy reasons — without the documentation changing; the framework's equivalent, `CKDiscoverAllUserIdentitiesOperation`, is deprecated as well, so the archived web-services page is the only place the feature still looks alive. MistKit generates the operation from `openapi.yaml` but does not surface it in ``CloudKitService``; the `POST users/discover` form, which takes lookup infos, works and is what ``CloudKitService/discoverUserIdentities(lookupInfos:)`` calls. The details are tracked in [MistKit issue #28](https://github.com/brightdigit/MistKit/issues/28) and Apple Feedback FB22754466 ([Open Radar](https://openradar.appspot.com/FB22754466)). +One documented call does not work at all: [`GET users/discover`](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/DiscoveringAllUserIdentities.html) ("discover all user identities") returns `500 INTERNAL_ERROR` from both the REST API and Apple's own CloudKit JS, reproducibly, after authentication succeeds. It appears to have been retired server-side — possibly for privacy reasons — without the documentation changing; the framework's equivalent, [`CKDiscoverAllUserIdentitiesOperation`](https://developer.apple.com/documentation/cloudkit/ckdiscoveralluseridentitiesoperation), is deprecated as well, so the archived web-services page is the only place the feature still looks alive. MistKit generates the operation from `openapi.yaml` but does not surface it in ``CloudKitService``; the [`POST users/discover`](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/DiscoveringUserIdentities%28usersdiscover%29.html) form, which takes lookup infos, works and is what ``CloudKitService/discoverUserIdentities(lookupInfos:)`` calls. The details are tracked in [MistKit issue #28](https://github.com/brightdigit/MistKit/issues/28) and [Apple Feedback](https://feedbackassistant.apple.com/) FB22754466 ([Open Radar](https://openradar.appspot.com/FB22754466)). ## Deployment -Bushel's sync job runs entirely in GitHub Actions. The key ID and private key live in repository secrets. The private-key secret must hold the complete PEM, `BEGIN`/`END` markers included — the action validates that before touching CloudKit, and ``PrivateKeyMaterial/raw(_:)`` tolerates literal `\n` escapes if the secret store mangles the newlines. The API token and web auth token in the screenshot are there for the integration tests, not for the sync job: +Bushel's sync job runs entirely in [GitHub Actions](https://docs.github.com/en/actions). The key ID and private key live in [repository secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions). The private-key secret must hold the complete PEM, `BEGIN`/`END` markers included — the action validates that before touching CloudKit, and ``PrivateKeyMaterial/raw(_:)`` tolerates literal `\n` escapes if the secret store mangles the newlines. The API token and web auth token in the screenshot are there for the integration tests, not for the sync job: ![Repository secrets: CLOUDKIT_KEY_ID, CLOUDKIT_PRIVATE_KEY, CLOUDKIT_API_TOKEN, CLOUDKIT_WEB_AUTH_TOKEN](talk-github-secrets) -The scheduled workflow (`Examples/BushelCloud/.github/workflows/cloudkit-sync-dev.yml`) fires three times a day at off-the-hour minutes and hands the secrets to a composite action: +The scheduled workflow ([`Examples/BushelCloud/.github/workflows/cloudkit-sync-dev.yml`](https://github.com/brightdigit/MistKit/blob/main/Examples/BushelCloud/.github/workflows/cloudkit-sync-dev.yml)) fires three times a day at off-the-hour minutes and hands the secrets to a composite action: ```yaml name: Scheduled CloudKit Sync (Development) @@ -622,7 +622,7 @@ jobs: enable-export: 'false' ``` -The action (`Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml`) downloads a pre-built binary when one is cached, builds it in Docker otherwise, validates the PEM before touching CloudKit, and runs the CLI with the credentials in environment variables: +The action ([`Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml`](https://github.com/brightdigit/MistKit/blob/main/Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml)) downloads a pre-built binary when one is cached, builds it in Docker otherwise, validates the PEM before touching CloudKit, and runs the CLI with the credentials in environment variables: ```yaml name: 'CloudKit Sync Action' @@ -684,7 +684,7 @@ runs: The CLI reads its configuration through [swift-configuration](https://github.com/apple/swift-configuration), which accepts the same keys as environment variables or command-line flags. Use flags for the non-secret settings — container, environment — and keep the API token, web auth token, and private key in environment variables or a secret store: the action injects them through the environment, and a secret passed as a flag lands in shell history and the process table. [MistKitConfiguration](https://github.com/brightdigit/MistKitConfiguration) packages that layering, plus PEM and key-ID validation, for reuse across the examples. The CLI writes a JSON report that the action turns into the workflow's summary page. -The payoff is visible in Bushel: each scheduled run checks whether Apple has posted a new restore image, a bug-fix release, or a new beta, and the app shows every version with a signed or unsigned flag — an unsigned image cannot be installed, so that flag is the one users care about. Static builds, credential injection on other platforms, tiered scheduling, idempotency, and observability are covered in . +The payoff is visible in [Bushel](https://getbushel.app): each scheduled run checks whether Apple has posted a new restore image, a bug-fix release, or a new beta, and the app shows every version with a signed or unsigned flag — an unsigned image cannot be installed, so that flag is the one users care about. Static builds, credential injection on other platforms, tiered scheduling, idempotency, and observability are covered in . ## What's next @@ -786,7 +786,7 @@ And one more thing: Leo's apps — [Bushel](https://getbushel.app), virtualizati - [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 BushelCloud CLI reads credentials from environment variables or arguments +- [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`](https://github.com/brightdigit/MistKit/tree/main/Packages/MistKitConfiguration)) ### Other tools mentioned @@ -799,9 +799,9 @@ And one more thing: Leo's apps — [Bushel](https://getbushel.app), virtualizati Questions asked during rehearsals and at the talks, with the answers as they stand today. -**How much does CloudKit cost?** There is no separate CloudKit fee beyond the Apple Developer Program. Quotas for storage, transfer, and requests scale with active users; exceeding them returns `QUOTA_EXCEEDED` or throttling. Apple does not publish a clear overage price list. +**How much does CloudKit cost?** There is no separate CloudKit fee beyond the [Apple Developer Program](https://developer.apple.com/programs/). Quotas for storage, transfer, and requests scale with active users; exceeding them returns `QUOTA_EXCEEDED` or throttling. Apple does not publish a clear overage price list. -**What kind of database is it?** NoSQL, document-oriented, with a schema. Records have typed fields; relationships are references, not joins. The schema is defined up front (console or `cktool`), not created as you go. +**What kind of database is it?** NoSQL, document-oriented, with a schema. Records have typed fields; relationships are references, not joins. The schema is defined up front (console or [`cktool`](https://developer.apple.com/icloud/ck-tool/)), not created as you go. **Do I need a signed-in user for the public database?** To write, yes. To read, no — the `_world` role decides. @@ -813,10 +813,10 @@ Questions asked during rehearsals and at the talks, with the answers as they sta **What happens to a user's private data if they delete the app or their iCloud account?** Delete the app: the data survives in iCloud. Sign out of iCloud: the data is intact but unreachable from that device. Delete the iCloud account: the data is gone and you never had a copy — which is why Heartwitch copies what it needs into Postgres. -**Can I use this from a browser extension?** Use CloudKit JS unless you specifically need Swift. +**Can I use this from a browser extension?** Use [CloudKit JS](https://developer.apple.com/documentation/cloudkitjs) unless you specifically need Swift. -**Does this run on Linux? Windows? WASM?** Linux and Windows, yes — that is the point. Server-to-server signing needs swift-crypto, which is unavailable on Windows and WASI, so those targets use API-token + web-auth credentials. WASI also lacks a first-class HTTP transport; for browsers use CloudKit JS. +**Does this run on Linux? Windows? WASM?** Linux and Windows, yes — that is the point. Server-to-server signing needs [swift-crypto](https://github.com/apple/swift-crypto), which is unavailable on Windows and [WASI](https://wasi.dev), so those targets use API-token + web-auth credentials. WASI also lacks a first-class HTTP transport; for browsers use CloudKit JS. **How does this compare to Vapor plus the CloudKit framework?** The CloudKit framework only runs on Apple platforms. MistKit runs anywhere Swift runs. -**What is the production story for key storage?** GitHub Actions secrets for the two example jobs; a secrets manager or environment-variable injection in general. Never commit a `.pem`. covers the options per platform. +**What is the production story for key storage?** [GitHub Actions secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions) for the two example jobs; a secrets manager or environment-variable injection in general. Never commit a `.pem`. covers the options per platform. From 27cb3cb8bb158fae389e442d4788e55f3dd116fe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 16:51:10 +0000 Subject: [PATCH 11/12] Link people, projects, and Apple documentation across the DocC catalog; fix broken links Replace the four links to developer.apple.com/documentation/cloudkitwebservices, which returns 404, with the archived CloudKit Web Services Reference; point the "Apple Developer Console" link at the CloudKit Console; update the retired Swift-book concurrency URL; rename "CloudKit Dashboard" to "CloudKit Console"; and drop the reference to a CHANGELOG.md that does not exist. Then wrap the first mention per section of framework symbols, archived reference pages, tools, and third-party projects in links. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CZstUEGAAPuxzTjiAPii4S --- .../AbstractionLayerArchitecture.md | 8 ++--- .../AuthenticationAndDatabases.md | 20 +++++------ .../CloudKitLimitsAndPerformance.md | 10 +++--- .../Documentation.docc/ConfiguringMistKit.md | 10 +++--- .../Documentation.docc/DeployingMistKit.md | 34 +++++++++---------- .../Documentation.docc/Documentation.md | 10 +++--- .../FieldTypePolymorphism.md | 10 +++--- .../GeneratedCodeAnalysis.md | 8 ++--- .../GeneratedCodeWorkflow.md | 8 ++--- .../Documentation.docc/HandlingErrors.md | 10 +++--- .../OpenAPICodeGeneration.md | 10 +++--- .../Documentation.docc/RequestSigning.md | 10 +++--- .../WhatCloudKitGotWrong.md | 12 +++---- .../Documentation.docc/WhatTheAIGotWrong.md | 10 +++--- .../Documentation.docc/WorkingWithRecords.md | 4 +-- .../Documentation.docc/iOSDevUKTalk.md | 2 +- 16 files changed, 88 insertions(+), 88 deletions(-) diff --git a/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md b/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md index 1b5e54f5..e625b3df 100644 --- a/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md +++ b/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md @@ -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 @@ -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. @@ -260,5 +260,5 @@ A few intentional non-features that show up in many wrapper libraries but not th - - - -- [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) diff --git a/Sources/MistKit/Documentation.docc/AuthenticationAndDatabases.md b/Sources/MistKit/Documentation.docc/AuthenticationAndDatabases.md index e48f4564..1db52f54 100644 --- a/Sources/MistKit/Documentation.docc/AuthenticationAndDatabases.md +++ b/Sources/MistKit/Documentation.docc/AuthenticationAndDatabases.md @@ -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 | | --- | :-: | :-: | :-: | @@ -12,7 +12,7 @@ CloudKit Web Services accepts three authentication schemes, and only some scheme | `.private` | — | ✓ | — | | `.shared` | — | ✓ | — | -The same backend legitimately needs both attribution paths — server-attributed writes against the public database (catalog seeds, moderation actions) and user-attributed reads against `users/caller` (knowing which iCloud user a session belongs to). MistKit models this by: +The same backend legitimately needs both attribution paths — server-attributed writes against the public database (catalog seeds, moderation actions) and user-attributed reads against [`users/caller`](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/GetCurrentUser.html) (knowing which iCloud user a session belongs to). MistKit models this by: 1. Letting ``CloudKitService`` hold a ``Credentials`` value that carries either or both credential sets. 2. Making the target ``Database`` an argument on each operation, with `.public` carrying a ``PublicAuthPreference`` that picks the signing method *for that call*. @@ -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( @@ -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`](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/GetCurrentUser.html), [`/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 @@ -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. @@ -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 { @@ -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 @@ -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 `::`, 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. walks through the implementation. +Every request is then signed with the key: MistKit builds the payload `::`, 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. walks through the implementation. ### Rotating a server-to-server key @@ -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 | | --- | --- | diff --git a/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md b/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md index f8f43edf..b08c9e3e 100644 --- a/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md +++ b/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md @@ -8,7 +8,7 @@ CloudKit Web Services is a remote API with per-request size limits and per-accou | Concern | Enforced where | Notes | | --- | --- | --- | -| Records per query response | CloudKit | Max 200; the `limit` parameter is validated 1–200. | +| Records per query response | CloudKit | [Max 200](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/QueryingRecords.html); the `limit` parameter is validated 1–200. | | Pages per auto-paginated query | MistKit | `maxPages: 1_000` on ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:zoneID:database:)``. | | Records per modify batch | CloudKit | Practical cap around 200; chunk larger batches client-side. | | Asset upload size / connection pool | MistKit (transport separation) | `URLSession.shared` used for CDN uploads to avoid HTTP/2 reuse with the API host. | @@ -37,7 +37,7 @@ Raise `maxPages` when you know the result set is genuinely large. Narrow filters ## Batching writes -CloudKit's `/records/modify` endpoint accepts a batch of operations in a single round-trip. The practical server-side cap is around 200 operations per request. ``CloudKitService/modifyRecords(_:atomic:zoneID:desiredKeys:numbersAsStrings:database:)`` does not chunk for you — split larger batches yourself: +CloudKit's [`/records/modify`](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ModifyRecords.html) endpoint accepts a batch of operations in a single round-trip. The practical server-side cap is around 200 operations per request. ``CloudKitService/modifyRecords(_:atomic:zoneID:desiredKeys:numbersAsStrings:database:)`` does not chunk for you — split larger batches yourself: ```swift let chunked = stride(from: 0, to: operations.count, by: 200).map { @@ -58,7 +58,7 @@ for chunk in chunked { ## Asset upload transport -Asset uploads are a two-step workflow: ``CloudKitService/requestAssetUploadURL(recordType:fieldName:recordName:zoneID:database:)`` returns a one-time URL on `cvws.icloud-content.com`, then ``CloudKitService/uploadAssetData(_:to:using:)`` PUTs the bytes there. MistKit's high-level ``CloudKitService/uploadAssets(data:recordType:fieldName:recordName:zoneID:using:database:)`` chains both steps. +Asset uploads are a [two-step workflow](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/UploadAssets.html): ``CloudKitService/requestAssetUploadURL(recordType:fieldName:recordName:zoneID:database:)`` returns a one-time URL on `cvws.icloud-content.com`, then ``CloudKitService/uploadAssetData(_:to:using:)`` PUTs the bytes there. MistKit's high-level ``CloudKitService/uploadAssets(data:recordType:fieldName:recordName:zoneID:using:database:)`` chains both steps. The CDN upload deliberately does **not** flow through the service's `ClientTransport`. It uses `URLSession.shared` directly: @@ -88,7 +88,7 @@ let receipt = try await service.uploadAssets( ) ``` -CloudKit imposes a per-asset size cap (in the tens of megabytes, exact figure documented in [CloudKit Web Services](https://developer.apple.com/documentation/cloudkitwebservices)). Oversized uploads surface as a bare ``CloudKitError/httpError(statusCode:)`` from the CDN, which returns raw HTTP errors rather than CloudKit's JSON failure body; the upload path upgrades a 413 to ``CloudKitError/quotaExceeded(reason:hint:)`` with the byte count attached. +CloudKit imposes a per-asset size cap (in the tens of megabytes, exact figure documented in [CloudKit Web Services](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/UploadAssets.html)). Oversized uploads surface as a bare ``CloudKitError/httpError(statusCode:)`` from the CDN, which returns raw HTTP errors rather than CloudKit's JSON failure body; the upload path upgrades a 413 to ``CloudKitError/quotaExceeded(reason:hint:)`` with the byte count attached. ## Rate limiting @@ -104,7 +104,7 @@ See for the retry helper pattern. Read ``CloudKitError/http ## Connection reuse for the API host -By default ``CloudKitService`` uses `URLSessionTransport` from `swift-openapi-urlsession`, which gives you HTTP/2 multiplexing against `api.apple-cloudkit.com` automatically. There is no per-call session — every operation through one ``CloudKitService`` shares the underlying URLSession's connection pool, so a burst of operations does not pay TCP/TLS setup per call. +By default ``CloudKitService`` uses `URLSessionTransport` from [`swift-openapi-urlsession`](https://github.com/apple/swift-openapi-urlsession), which gives you HTTP/2 multiplexing against `api.apple-cloudkit.com` automatically. There is no per-call session — every operation through one ``CloudKitService`` shares the underlying URLSession's connection pool, so a burst of operations does not pay TCP/TLS setup per call. For custom transports, prefer one transport per `CloudKitService` and reuse the same `CloudKitService` across calls. Creating a fresh service per request defeats connection reuse. diff --git a/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md b/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md index 62457367..c0f859ba 100644 --- a/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md +++ b/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md @@ -30,7 +30,7 @@ Everything else — which ``Database`` to use, which signing method on the publi ## Container identifier -The container identifier is the iCloud container your records live in. It is the same string you see in the CloudKit Dashboard under **Container ID**, prefixed with `iCloud.`: +The container identifier is the iCloud container your records live in. It is the same string you see in the [CloudKit Console](https://icloud.developer.apple.com/dashboard/) under **Container ID**, prefixed with `iCloud.`: ```swift "iCloud.com.example.MyApp" @@ -38,7 +38,7 @@ The container identifier is the iCloud container your records live in. It is the A single container has separate `development` and `production` schemas, separate record stores, and separate user data. You do not switch containers between environments — you switch ``Environment``. -> Tip: Containers are configured in the [CloudKit Dashboard](https://icloud.developer.apple.com). The container identifier is also visible in your Xcode app target's CloudKit capability. +> Tip: Containers are configured in the [CloudKit Console](https://icloud.developer.apple.com/dashboard/). The container identifier is also visible in your Xcode app target's CloudKit capability. ## Environment selection @@ -60,7 +60,7 @@ let environment: Environment = ProcessInfo.processInfo ``Environment/init(caseInsensitive:)`` accepts `"development"` / `"production"` regardless of letter case and returns `nil` on anything else, so a misspelled env var fails closed at startup rather than silently shipping a dev build to prod. -> Warning: CloudKit promotes schema from `development` to `production` explicitly via the Dashboard. Code referencing fields that exist only in dev will succeed against `.development` and fail against `.production` with ``CloudKitError/badRequest(reason:)`` or ``CloudKitError/notFound(reason:)``, depending on which lookup misses. +> Warning: CloudKit promotes schema from `development` to `production` explicitly via the [CloudKit Console](https://icloud.developer.apple.com/dashboard/). Code referencing fields that exist only in dev will succeed against `.development` and fail against `.production` with ``CloudKitError/badRequest(reason:)`` or ``CloudKitError/notFound(reason:)``, depending on which lookup misses. ## Database scope at configuration time @@ -79,7 +79,7 @@ The configuration question for your app is: which credentials does the deploymen ## Custom transport -The public initializers use `URLSessionTransport` from `swift-openapi-urlsession` and are available on every platform except WASI (`#if !os(WASI)`). ``CloudKitService`` stores its `ClientTransport` internally, but the initializers that accept a transport are not part of the public surface today — MistKit's own tests use them to substitute a mock transport that asserts on outgoing requests and returns canned responses. +The public initializers use `URLSessionTransport` from [`swift-openapi-urlsession`](https://github.com/apple/swift-openapi-urlsession) and are available on every platform except WASI (`#if !os(WASI)`). ``CloudKitService`` stores its `ClientTransport` internally, but the initializers that accept a transport are not part of the public surface today — MistKit's own tests use them to substitute a mock transport that asserts on outgoing requests and returns canned responses. Consequences for consumers: @@ -87,7 +87,7 @@ Consequences for consumers: - **Instrumentation** — configure the `middleware` logging subsystem (below) rather than wrapping the transport. - **WASI** — has no public ``CloudKitService`` initializer yet, and the web-services API needs ECDSA signing and an HTTP transport that WASI lacks. For CloudKit access from a browser, use [CloudKit JS](https://developer.apple.com/documentation/cloudkitjs). -A public transport-accepting initializer (for AsyncHTTPClient on the server, for example) is tracked on the project roadmap in the README. +A public transport-accepting initializer (for AsyncHTTPClient on the server, for example) is tracked on the [project roadmap in the README](https://github.com/brightdigit/MistKit#roadmap). > Warning: Asset uploads do **not** flow through the configured `transport`. They use `URLSession.shared` directly to avoid HTTP/2 connection reuse between CloudKit's API host and the CDN, which surfaces as 421 Misdirected Request errors. See for the full rationale. diff --git a/Sources/MistKit/Documentation.docc/DeployingMistKit.md b/Sources/MistKit/Documentation.docc/DeployingMistKit.md index 574ac5d9..bc4711ac 100644 --- a/Sources/MistKit/Documentation.docc/DeployingMistKit.md +++ b/Sources/MistKit/Documentation.docc/DeployingMistKit.md @@ -4,7 +4,7 @@ From a local CLI to a scheduled CloudKit job in CI — building a static Linux b ## Overview -The hard part of using MistKit on a backend is not writing the code. It is deciding where the code runs, how the credentials get there, and what happens when nobody is watching. This article picks up where leaves off and covers the operational side, using two production deployments as worked examples: [BushelCloud](https://github.com/brightdigit/BushelCloud) and [CelestraCloud](https://github.com/brightdigit/CelestraCloud). Both live in this repository under `Examples/`, and both ship today as scheduled GitHub Actions jobs writing to a CloudKit public database from stock Ubuntu runners. +The hard part of using MistKit on a backend is not writing the code. It is deciding where the code runs, how the credentials get there, and what happens when nobody is watching. This article picks up where leaves off and covers the operational side, using two production deployments as worked examples: [BushelCloud](https://github.com/brightdigit/BushelCloud) and [CelestraCloud](https://github.com/brightdigit/CelestraCloud). Both live in this repository under `Examples/`, and both ship today as scheduled [GitHub Actions](https://docs.github.com/en/actions) jobs writing to a CloudKit public database from stock Ubuntu runners. "Deploying" a MistKit-based service means one of three things: @@ -17,7 +17,7 @@ The first is a normal web-app deployment where MistKit is just another HTTP clie | Concern | Long-running service | Scheduled job | | --- | --- | --- | | **Auth** | Web auth token (per user), API token (public reads), or server-to-server | Server-to-server (or API token for read-only public sync) | -| **Runtime** | Vapor/Hummingbird host, kept warm | Container or `runs-on:` runner, exits on completion | +| **Runtime** | [Vapor](https://vapor.codes)/[Hummingbird](https://hummingbird.codes) host, kept warm | Container or `runs-on:` runner, exits on completion | | **Credentials** | Long-lived secrets in the process environment | Injected per run from CI secrets | | **Idempotency** | Per request | Per run — "what if this fires twice?" | | **Observability** | Existing APM / logs | Job summary, artifacts, optional notification | @@ -49,15 +49,15 @@ MistKit targets cross-platform Swift, so the deployment artifact for Linux is a swift build -c release --static-swift-stdlib ``` -Both examples build inside the official Swift container image so the binary is portable across any modern Ubuntu runner. BushelCloud's build workflow runs the job in `container: swiftlang/swift:nightly-6.4.x-noble`; its sync action's fallback path does the same with `docker run` inside a `runs-on: ubuntu-latest` step. CelestraCloud sets the container at the job level. Either works; the job-level form is slightly cleaner when every step needs the toolchain. (Both currently pin a Swift 6.4 nightly because their manifests declare `swift-tools-version: 6.4`; move to a release image when one ships.) +Both examples build inside the [official Swift container image](https://hub.docker.com/_/swift) so the binary is portable across any modern Ubuntu runner. BushelCloud's build workflow runs the job in `container: swiftlang/swift:nightly-6.4.x-noble`; its sync action's fallback path does the same with `docker run` inside a `runs-on: ubuntu-latest` step. CelestraCloud sets the container at the job level. Either works; the job-level form is slightly cleaner when every step needs the toolchain. (Both currently pin a Swift 6.4 nightly because their manifests declare `swift-tools-version: 6.4`; move to a release image when one ships.) -The same binary drops into a distroless or `ubuntu:noble` image for Kubernetes, Fly.io, or a plain `systemd` unit on a VPS. +The same binary drops into a distroless or `ubuntu:noble` image for [Kubernetes](https://kubernetes.io), [Fly.io](https://fly.io), or a plain `systemd` unit on a VPS. ### Binary caching in CI A release build from scratch takes a couple of minutes on a stock runner. For a job that fires three times a day that is wasted time — and time during which a transient toolchain or network hiccup can fail a scheduled production run. Both repos build once and reuse: -- **CelestraCloud** caches the binary with `actions/cache@v4`, keyed on the hash of `Sources/**/*.swift` and `Package.swift`, and passes it to each downstream tier job through `actions/upload-artifact@v4` / `download-artifact@v4`. +- **CelestraCloud** caches the binary with [`actions/cache@v4`](https://github.com/actions/cache), keyed on the hash of `Sources/**/*.swift` and `Package.swift`, and passes it to each downstream tier job through [`actions/upload-artifact@v4`](https://github.com/actions/upload-artifact) / `download-artifact@v4`. - **BushelCloud** publishes the binary from a separate `bushel-cloud-build.yml` workflow and has the sync action download that artifact, falling back to an inline build when the artifact has expired: ```yaml @@ -150,7 +150,7 @@ let service = CloudKitService( CLOUDKIT_CONTAINER_ID: ${{ inputs.container-id }} ``` -- **File path** (`CLOUDKIT_PRIVATE_KEY_PATH`) is what you want when the platform mounts the credential as a file — Kubernetes secrets, `systemd`'s `LoadCredential=`, Docker secrets, a secrets-manager CSI driver — because you inherit its encryption-at-rest and rotation. CelestraCloud writes the PEM to a temp file first: +- **File path** (`CLOUDKIT_PRIVATE_KEY_PATH`) is what you want when the platform mounts the credential as a file — [Kubernetes secrets](https://kubernetes.io/docs/concepts/configuration/secret/), `systemd`'s [`LoadCredential=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#LoadCredential=), [Docker secrets](https://docs.docker.com/engine/swarm/secrets/), a secrets-manager CSI driver — because you inherit its encryption-at-rest and rotation. CelestraCloud writes the PEM to a temp file first: ```yaml env: @@ -203,15 +203,15 @@ For a long-running service the same check belongs in the startup health check ### Wiring it up in different runtimes - **Local development** — a `.env` file in the project root (add it to `.gitignore`), loaded by MistKitConfiguration or `source`d into the shell. -- **GitHub Actions / GitLab CI** — the project's secret store, exposed through `env:` blocks or `${{ secrets.NAME }}`. +- **[GitHub Actions](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions) / [GitLab CI](https://docs.gitlab.com/ci/variables/)** — the project's secret store, exposed through `env:` blocks or `${{ secrets.NAME }}`. - **Docker / Compose** — `environment:`, `env_file:`, or `--env-file`. - **Kubernetes** — `Secret` resources projected as env vars (`envFrom: secretRef:`) or files (`volumeMounts` + `secret:`); the file form pairs with `CLOUDKIT_PRIVATE_KEY_PATH`. - **systemd on a VPS** — `EnvironmentFile=` for plain variables; `LoadCredential=` for keys that should stay encrypted at rest. -- **Managed platforms** (Fly.io, Railway, Render, Lambda) — each has a secrets tab; the values land in `ProcessInfo.processInfo.environment` the same way. +- **Managed platforms** ([Fly.io](https://fly.io), Railway, [Render](https://render.com), [Lambda](https://aws.amazon.com/lambda/)) — each has a secrets tab; the values land in `ProcessInfo.processInfo.environment` the same way. ## Scheduling strategies -`on: schedule:` is the easy part. The design decisions are *what* to schedule and *how often*. +[`on: schedule:`](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule) is the easy part. The design decisions are *what* to schedule and *how often*. ### Single cron: BushelCloud @@ -227,7 +227,7 @@ on: workflow_dispatch: # Manual trigger for testing ``` -The offsets give roughly eight-hour spacing, aligned with the twelve-hour cache of one upstream source (the VirtualBuddy TSS API). `workflow_dispatch` stays on for ad-hoc reruns. +The offsets give roughly eight-hour spacing, aligned with the twelve-hour cache of one upstream source (the [VirtualBuddy](https://github.com/insidegui/VirtualBuddy) TSS API). `workflow_dispatch` stays on for ad-hoc reruns. The **production** sync (`cloudkit-sync-prod.yml`) is `workflow_dispatch` only: the live production container is updated when a human clicks the button, after the development environment has had a clean run. Commit to that policy early. @@ -260,7 +260,7 @@ Those `--update-*` flags map straight onto ``QueryFilter`` values in the CLI. Th ### Avoiding the thundering herd -BushelCloud schedules at `:17`, `:43`, and `:29`. GitHub documents that scheduled workflows can be delayed during periods of high load, particularly at the top of the hour when half the world's crons fire. A non-zero minute typically lands closer to the intended time. +BushelCloud schedules at `:17`, `:43`, and `:29`. [GitHub documents that scheduled workflows can be delayed](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule) during periods of high load, particularly at the top of the hour when half the world's crons fire. A non-zero minute typically lands closer to the intended time. ## Concurrency, idempotency, and retries @@ -276,7 +276,7 @@ This is safe **only because the job is idempotent**. BushelCloud uses determinis If your job is not idempotent — it appends to a log, or increments a counter — keep the default `cancel-in-progress: false` and add an application-level lock (a CloudKit record acting as a leader-election token, for instance). -MistKit deliberately does **not** retry transient errors for you. For `THROTTLED` (429) and `TRY_AGAIN_LATER` (503) the pattern is a small wrapper at the operation site with exponential backoff: +MistKit deliberately does **not** retry transient errors for you. For [`THROTTLED`](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ErrorCodes.html) (429) and `TRY_AGAIN_LATER` (503) the pattern is a small wrapper at the operation site with exponential backoff: ```swift func withBackoff(_ operation: () async throws -> T) async throws -> T { @@ -300,7 +300,7 @@ func withBackoff(_ operation: () async throws -> T) async throws -> T { The hardest part of a quiet scheduled job is knowing whether it ran and what it did. Both repos use two-step reporting: the CLI writes a structured JSON report, and a CI step turns it into the workflow's summary page via `$GITHUB_STEP_SUMMARY`. -CelestraCloud passes `--update-json-output-path ./feed-update-standard.json` to the CLI and a `summary` job `jq`s the results into Markdown: +CelestraCloud passes `--update-json-output-path ./feed-update-standard.json` to the CLI and a `summary` job [`jq`](https://jqlang.github.io/jq/)s the results into Markdown: ```bash total_feeds=$(jq -r '.summary.totalFeeds // 0' "$json_file") @@ -311,17 +311,17 @@ echo "- **Successful:** $success_count" >> $GITHUB_STEP_SUMMARY BushelCloud does the same through the `BUSHEL_SYNC_JSON_OUTPUT_FILE` environment variable, with a per-record-type table of created / updated / failed counts. Both retain the JSON as a workflow artifact (`actions/upload-artifact@v4`, 7–30 days) so a separate process — a daily digest, a dashboard scrape, a manual audit — can read historical results without re-running the job. -For a long-running service the equivalent is the request logging you already have (MistKit emits through swift-log — see ) plus a health-check endpoint that exercises a representative MistKit call so auth or schema drift shows up before users notice. +For a long-running service the equivalent is the request logging you already have (MistKit emits through [swift-log](https://github.com/apple/swift-log) — see ) plus a health-check endpoint that exercises a representative MistKit call so auth or schema drift shows up before users notice. ## Development vs. production environments CloudKit containers expose two parallel environments, and ``Environment`` on ``CloudKitService`` (or `CLOUDKIT_ENVIRONMENT` in the example CLIs) selects one. The pattern that works: 1. **Two workflows or deployments**, one per environment. BushelCloud has `cloudkit-sync-dev.yml` (scheduled) and `cloudkit-sync-prod.yml` (`workflow_dispatch` only). -2. **Two sets of secrets**, suffixed `_DEV` and `_PROD`, referenced explicitly. No shared default that one environment can accidentally cross-contaminate. Server-to-server keys are created per environment in the CloudKit Console, so the production key is a different key. -3. **Schema changes go through development first**, deployed with `cktool` and verified by the next scheduled dev sync. Once dev has been clean for a day, promote the schema to production and trigger the prod deployment. +2. **Two sets of secrets**, suffixed `_DEV` and `_PROD`, referenced explicitly. No shared default that one environment can accidentally cross-contaminate. Server-to-server keys are created per environment in the [CloudKit Console](https://icloud.developer.apple.com/dashboard/), so the production key is a different key. +3. **Schema changes go through development first**, deployed with [`cktool`](https://developer.apple.com/documentation/cloudkit/integrating-a-text-based-schema-into-your-workflow) and verified by the next scheduled dev sync. Once dev has been clean for a day, promote the schema to production and trigger the prod deployment. -This is ordinary dev/prod hygiene with one CloudKit-specific quirk: the schema lives on Apple's infrastructure and must be promoted explicitly, from the console or with `xcrun cktool`. +This is ordinary dev/prod hygiene with one CloudKit-specific quirk: the schema lives on Apple's infrastructure and must be promoted explicitly, from the console or with [`xcrun cktool`](https://developer.apple.com/icloud/ck-tool/). ## Topics diff --git a/Sources/MistKit/Documentation.docc/Documentation.md b/Sources/MistKit/Documentation.docc/Documentation.md index 5511823f..0d5c861a 100644 --- a/Sources/MistKit/Documentation.docc/Documentation.md +++ b/Sources/MistKit/Documentation.docc/Documentation.md @@ -6,9 +6,9 @@ A Swift package for server-side and command-line access to CloudKit Web Services ## Overview -MistKit wraps Apple's [CloudKit Web Services REST API](https://developer.apple.com/documentation/cloudkitwebservices) with a modern Swift surface so server-side code, CLIs, and platforms without the native CloudKit framework (Linux, WASI, Windows) can read and write the same containers as your Apple apps. +MistKit wraps Apple's [CloudKit Web Services REST API](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/index.html) with a modern Swift surface so server-side code, CLIs, and platforms without the native CloudKit framework (Linux, WASI, Windows) can read and write the same containers as your Apple apps. -The library is built on `swift-openapi-generator` against an OpenAPI document written for CloudKit Web Services, with a hand-written abstraction layer on top that exposes typed records, async iteration, structured errors, and three authentication schemes. +The library is built on [`swift-openapi-generator`](https://github.com/apple/swift-openapi-generator) against an OpenAPI document written for CloudKit Web Services, with a hand-written abstraction layer on top that exposes typed records, async iteration, structured errors, and three authentication schemes. New to server-side CloudKit? Start with , the written form of the conference talk that explains why the library exists and how its pieces fit together. @@ -67,7 +67,7 @@ The wrapper layer is described in . The code-g ## Platform support -MistKit runs on macOS, iOS, tvOS, watchOS, visionOS, Linux, WASI, and Windows. Server-to-server signing depends on Crypto / swift-crypto, so it is unavailable on Windows and WASI — those targets must use API-token + web-auth credentials. URL-loading conveniences and asset upload use `URLSession`; the public initializers are compiled only for non-WASI platforms, and a public transport-accepting initializer for WASI is not available yet (see ). +MistKit runs on macOS, iOS, tvOS, watchOS, visionOS, Linux, [WASI](https://wasi.dev), and Windows. Server-to-server signing depends on Crypto / [swift-crypto](https://github.com/apple/swift-crypto), so it is unavailable on Windows and WASI — those targets must use API-token + web-auth credentials. URL-loading conveniences and asset upload use `URLSession`; the public initializers are compiled only for non-WASI platforms, and a public transport-accepting initializer for WASI is not available yet (see ). > Tip: On native Apple platforms (macOS, iOS, tvOS, watchOS, visionOS) prefer the native [CloudKit framework](https://developer.apple.com/documentation/cloudkit). It integrates with the system account, handles push notifications and long-lived operations, and avoids the per-request signing overhead of the web-services API. MistKit is intended for environments where the native framework isn't available — server-side Swift, CLIs, Linux, and Windows. @@ -180,6 +180,6 @@ MistKit runs on macOS, iOS, tvOS, watchOS, visionOS, Linux, WASI, and Windows. S ## See Also -- [CloudKit Web Services documentation](https://developer.apple.com/documentation/cloudkitwebservices) -- [Apple Developer Console](https://developer.apple.com) +- [CloudKit Web Services documentation](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/index.html) +- [CloudKit Console](https://icloud.developer.apple.com/dashboard/) - [swift-openapi-generator](https://github.com/apple/swift-openapi-generator) diff --git a/Sources/MistKit/Documentation.docc/FieldTypePolymorphism.md b/Sources/MistKit/Documentation.docc/FieldTypePolymorphism.md index 3d608e09..9b823fcb 100644 --- a/Sources/MistKit/Documentation.docc/FieldTypePolymorphism.md +++ b/Sources/MistKit/Documentation.docc/FieldTypePolymorphism.md @@ -4,7 +4,7 @@ How MistKit maps CloudKit's nine dynamically-typed field values onto one Swift e ## Overview -A CloudKit field value is a JSON object with a `value` and an optional `type`: +A CloudKit [field value](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/Types.html) is a JSON object with a `value` and an optional `type`: ```json { "value": 1747999812347, "type": "TIMESTAMP" } @@ -36,7 +36,7 @@ public enum FieldValue: Codable, Equatable, Sendable { } ``` -``Location``, ``Reference``, and ``Asset`` are MistKit's own value types, so the package has no dependency on Core Location or the CloudKit framework. There is no boolean case: CloudKit stores booleans as `INT64` `0`/`1`, and ``FieldValue/init(booleanValue:)`` / ``FieldValue/boolValue`` bridge that convention. +``Location``, ``Reference``, and ``Asset`` are MistKit's own value types, so the package has no dependency on [Core Location](https://developer.apple.com/documentation/corelocation) or the [CloudKit framework](https://developer.apple.com/documentation/cloudkit). There is no boolean case: CloudKit stores booleans as `INT64` `0`/`1`, and ``FieldValue/init(booleanValue:)`` / ``FieldValue/boolValue`` bridge that convention. ## Request and response are different schemas @@ -80,7 +80,7 @@ CloudKit infers a field's type from the JSON shape of `value`, so most values ar | `.bytes` (`BYTES`) | base64 string | `STRING` | | `.double` (`DOUBLE`) | whole-valued number (`3.0` serializes as `3`) | `INT64` | -Untagged, CloudKit infers the wrong type and rejects the write with `BAD_REQUEST "Invalid value, expected type TIMESTAMP"`. The request conversion is a single `default`-free switch that tags exactly these three: +Untagged, CloudKit infers the wrong type and rejects the write with [`BAD_REQUEST "Invalid value, expected type TIMESTAMP"`](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ErrorCodes.html). The request conversion is a single `default`-free switch that tags exactly these three: ```swift internal init(from fieldValue: FieldValue) { @@ -127,7 +127,7 @@ The eight `*_LIST` request tags exist for one caller: ``QueryFilter`` `IN`/`NOT_ ## Reads: recovering the type from an undiscriminated oneOf -The response `value` is an undiscriminated `oneOf`. swift-openapi-generator decodes such a union by trying each case in declaration order and keeping the first that succeeds: +The response `value` is an undiscriminated `oneOf`. [swift-openapi-generator](https://github.com/apple/swift-openapi-generator) decodes such a union by trying each case in declaration order and keeping the first that succeeds: ``` String → Int64 → Double → Bytes → Date → Location → Reference → Asset → List @@ -200,7 +200,7 @@ The payload is semantically asymmetric even though the type is not: | `downloadURL` | Ignored if sent | Where to fetch the bytes | | `fileChecksum`, `size` | Optional metadata | Returned by CloudKit | -The service layer contains the asymmetry instead of the type system: ``CloudKitService/uploadAssets(data:recordType:fieldName:recordName:zoneID:using:database:)`` returns an ``AssetUploadReceipt`` after the two-step upload, from which the write-side `Asset` is built, and reads construct an `Asset` from only what CloudKit returned. Splitting the schema would either break the nine-case symmetry of ``FieldValue`` or force a read/write distinction into the public API that nothing else needs. +The service layer contains the asymmetry instead of the type system: ``CloudKitService/uploadAssets(data:recordType:fieldName:recordName:zoneID:using:database:)`` returns an ``AssetUploadReceipt`` after the [two-step upload](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/UploadAssets.html), from which the write-side `Asset` is built, and reads construct an `Asset` from only what CloudKit returned. Splitting the schema would either break the nine-case symmetry of ``FieldValue`` or force a read/write distinction into the public API that nothing else needs. `fileChecksum` is an opaque, server-minted identity token — a version byte plus a 20-byte digest — not a SHA-256 of the plaintext. It cannot be recomputed client-side to verify a download; use ``Asset/size`` as a guard against truncation. diff --git a/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md b/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md index 5883290e..9a719d8c 100644 --- a/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md +++ b/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md @@ -1,6 +1,6 @@ # Generated Code Structure Analysis -What `swift-openapi-generator` produces from `openapi.yaml`, how those types are organised, and where the hand-written wrapper plugs in. +What [`swift-openapi-generator`](https://github.com/apple/swift-openapi-generator) produces from `openapi.yaml`, how those types are organised, and where the hand-written wrapper plugs in. ## Overview @@ -26,9 +26,9 @@ Both files begin with: ``` - `do not modify` — manual edits are overwritten on the next regeneration. -- `periphery:ignore:all` — `mise exec -- periphery` skips the file. Generated code legitimately has unreferenced members for unused operations. -- `swift-format-ignore-file` — `mise exec -- swift-format` leaves the file untouched. The generator's output is already canonical. -- `@_spi(Generated)` — pulls in SPI helpers from `OpenAPIRuntime` that aren't part of its public API. +- `periphery:ignore:all` — [`mise exec -- periphery`](https://github.com/peripheryapp/periphery) skips the file. Generated code legitimately has unreferenced members for unused operations. +- `swift-format-ignore-file` — [`mise exec -- swift-format`](https://github.com/swiftlang/swift-format) leaves the file untouched. The generator's output is already canonical. +- `@_spi(Generated)` — pulls in SPI helpers from [`OpenAPIRuntime`](https://github.com/apple/swift-openapi-runtime) that aren't part of its public API. ## Client.swift diff --git a/Sources/MistKit/Documentation.docc/GeneratedCodeWorkflow.md b/Sources/MistKit/Documentation.docc/GeneratedCodeWorkflow.md index 83330e87..60fabeea 100644 --- a/Sources/MistKit/Documentation.docc/GeneratedCodeWorkflow.md +++ b/Sources/MistKit/Documentation.docc/GeneratedCodeWorkflow.md @@ -95,7 +95,7 @@ git commit -m "feat(records): add /records/lookupChanges endpoint" ## Commit message style -MistKit follows the conventional-commits flavour visible in `git log`: +MistKit follows the [conventional-commits](https://www.conventionalcommits.org/) flavour visible in `git log`: ``` (): @@ -129,7 +129,7 @@ When reviewing a PR that touches `openapi.yaml`: 2. **Check that generated code matches the spec.** A regenerated `Client.swift` / `Types.swift` should follow mechanically from the spec change. If the diff looks larger than the spec change explains, suspect either an unintended spec edit or a stale generator version. 3. **Review the wrapper.** This is where reviewer effort pays off: ergonomic API shape, error mapping, conversion correctness, test coverage. -Avoid review comments that target generated code style — that's the generator's output, not the author's choice. If the generated shape is genuinely problematic, file an issue against `swift-openapi-generator` or change the spec. +Avoid review comments that target generated code style — that's the generator's output, not the author's choice. If the generated shape is genuinely problematic, file an issue against [`swift-openapi-generator`](https://github.com/apple/swift-openapi-generator/issues) or change the spec. ## Breaking changes @@ -142,7 +142,7 @@ A change is "breaking" when it requires consumers of MistKit to update their cod | Enum case removed or renamed | Switches in consumer code stop compiling | | Parameter type changed | Existing call sites break | -For MistKit-API breaking changes, prefer the `feat!` / `BREAKING CHANGE:` convention in the commit body, and document the migration in `CHANGELOG.md`. While the package is pre-1.0 (currently 1.0.0-alpha/beta), some flexibility is acceptable — but the wrapper team has been careful to flag user-visible breakage explicitly. +For MistKit-API breaking changes, prefer the `feat!` / `BREAKING CHANGE:` convention in the commit body, and note the migration in `ReleaseNotes.md`. While the package is pre-1.0 (currently 1.0.0-alpha/beta), some flexibility is acceptable — but the wrapper team has been careful to flag user-visible breakage explicitly. If only the generated layer changes and the wrapper preserves its public shape, the change is *not* breaking for consumers — they never see the generated types. @@ -183,7 +183,7 @@ unaffected. ## CI verification -`.github/workflows/check-generated-openapi.yml` runs on every push to `main` and every pull request. It regenerates inside a `swift:latest` container — using the generator fallback built from `Scripts/OpenAPITools`, so no mise is needed on the runner — and fails if the committed output differs: +[`.github/workflows/check-generated-openapi.yml`](https://github.com/brightdigit/MistKit/blob/main/.github/workflows/check-generated-openapi.yml) runs on every push to `main` and every pull request. It regenerates inside a `swift:latest` container — using the generator fallback built from `Scripts/OpenAPITools`, so no mise is needed on the runner — and fails if the committed output differs: ```yaml jobs: diff --git a/Sources/MistKit/Documentation.docc/HandlingErrors.md b/Sources/MistKit/Documentation.docc/HandlingErrors.md index 0c9cafac..72ac15c9 100644 --- a/Sources/MistKit/Documentation.docc/HandlingErrors.md +++ b/Sources/MistKit/Documentation.docc/HandlingErrors.md @@ -13,7 +13,7 @@ Every MistKit failure is one of a small set of typed errors thrown at a specific | Token storage | ``TokenStorageError`` | Custom ``TokenStorage`` implementations | | Request | ``CloudKitError`` | Every ``CloudKitService`` operation | -Operation methods declare typed throws — `async throws(CloudKitError)` — so the compiler enforces exhaustive switching at the call site if you choose to switch. +Operation methods declare [typed throws](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0413-typed-throws.md) — `async throws(CloudKitError)` — so the compiler enforces exhaustive switching at the call site if you choose to switch. ## Construction-time validation @@ -109,7 +109,7 @@ do { ### Every documented `serverErrorCode` has its own case -CloudKit's top-level failure body carries a `serverErrorCode`. Rather than hand +CloudKit's top-level failure body carries a [`serverErrorCode`](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ErrorCodes.html). Rather than hand callers that string to match on, MistKit maps each of the fourteen codes the OpenAPI spec enumerates onto a dedicated case: @@ -181,7 +181,7 @@ do { ### Subscription duplicates surface as `INTERNAL_ERROR` -CloudKit Web Services enforces subscription uniqueness on the **`(recordType, firesOn)`** tuple, *not* on `subscriptionID`. A second subscription that repeats an existing `(recordType, firesOn)` pair under a *different* ID is rejected — but the rejection arrives as a generic ``CloudKitError/internalServerError(reason:)`` (`serverErrorCode` `INTERNAL_ERROR`) with the misleading reason `"could not find subscription we just created"`. CloudKit does not use its `CONFLICT`/`EXISTS` server codes for this case. +CloudKit Web Services enforces [subscription uniqueness](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ModifySubscriptions.html) on the **`(recordType, firesOn)`** tuple, *not* on `subscriptionID`. A second subscription that repeats an existing `(recordType, firesOn)` pair under a *different* ID is rejected — but the rejection arrives as a generic ``CloudKitError/internalServerError(reason:)`` (`serverErrorCode` `INTERNAL_ERROR`) with the misleading reason `"could not find subscription we just created"`. CloudKit does not use its `CONFLICT`/`EXISTS` server codes for this case. MistKit infers the duplicate from that reason string and surfaces it through two hedged hints: @@ -205,7 +205,7 @@ do { ## Under the hood: from HTTP response to CloudKitError -CloudKit returns every failure as JSON with the same shape, whatever the status code: +CloudKit returns every failure as JSON with [the same shape](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ErrorCodes.html), whatever the status code: ```json { @@ -215,7 +215,7 @@ CloudKit returns every failure as JSON with the same shape, whatever the status } ``` -`openapi.yaml` models that as one `Failure` response, and swift-openapi-generator turns each operation's status codes into an `Output` enum with a case per status plus `.undocumented`. Turning that into a ``CloudKitError`` is a short, fully typed pipeline: +`openapi.yaml` models that as one `Failure` response, and [swift-openapi-generator](https://github.com/apple/swift-openapi-generator) turns each operation's status codes into an `Output` enum with a case per status plus `.undocumented`. Turning that into a ``CloudKitError`` is a short, fully typed pipeline: ``` Operations.queryRecords.Output (generated: .ok / .badRequest / … / .undocumented) diff --git a/Sources/MistKit/Documentation.docc/OpenAPICodeGeneration.md b/Sources/MistKit/Documentation.docc/OpenAPICodeGeneration.md index ce1a2858..3be1369b 100644 --- a/Sources/MistKit/Documentation.docc/OpenAPICodeGeneration.md +++ b/Sources/MistKit/Documentation.docc/OpenAPICodeGeneration.md @@ -4,7 +4,7 @@ How MistKit turns `openapi.yaml` into a type-safe Swift client at development ti ## Overview -MistKit ships a hand-written wrapper layer on top of code generated from Apple's CloudKit Web Services OpenAPI specification by [`swift-openapi-generator`](https://github.com/apple/swift-openapi-generator). The generator runs at development time — not at consumer build time — so library users get a working package without having to install any generation tooling. +MistKit ships a hand-written wrapper layer on top of code generated from [Apple's CloudKit Web Services OpenAPI specification](https://github.com/brightdigit/MistKit/blob/main/openapi.yaml) by [`swift-openapi-generator`](https://github.com/apple/swift-openapi-generator). The generator runs at development time — not at consumer build time — so library users get a working package without having to install any generation tooling. This article documents the toolchain (mise + the generator), the configuration file, and the request/response asymmetry that drives MistKit's custom type setup. @@ -41,7 +41,7 @@ Hand-written wrapper (Sources/MistKit/, committed) ## Toolchain: mise -MistKit pins build-time tools in `mise.toml`: +MistKit pins build-time tools in [`mise.toml`](https://mise.jdx.dev): ```toml [tools] @@ -59,7 +59,7 @@ mise exec -- swiftlint --fix mise exec -- swift-openapi-generator --version ``` -`./Scripts/generate-openapi.sh` puts mise's `$PATH` shims in front of the user's shell and calls `swift-openapi-generator generate`. When the generator is not on `$PATH` (CI containers, remote sessions) it falls back to `swift run --package-path Scripts/OpenAPITools swift-openapi-generator`, a tiny package that pins the same generator version, so regeneration works without mise. There is no Mintfile; references in older documentation to `mint`/`Mintfile` are out of date. +`./Scripts/generate-openapi.sh` puts mise's `$PATH` shims in front of the user's shell and calls `swift-openapi-generator generate`. When the generator is not on `$PATH` (CI containers, remote sessions) it falls back to `swift run --package-path Scripts/OpenAPITools swift-openapi-generator`, a tiny package that pins the same generator version, so regeneration works without mise. There is no Mintfile; references in older documentation to [`mint`](https://github.com/yonaskolb/Mint)/`Mintfile` are out of date. ## Generation script @@ -135,7 +135,7 @@ The runtime dependencies pulled in by the generated client: .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.2.0"), ``` -Plus MistKit's other dependencies: `swift-crypto` (server-to-server signing) and `swift-log`. `HTTPTypes` arrives transitively through `swift-openapi-runtime`. +Plus MistKit's other dependencies: [`swift-crypto`](https://github.com/apple/swift-crypto) (server-to-server signing) and [`swift-log`](https://github.com/apple/swift-log). [`HTTPTypes`](https://github.com/apple/swift-http-types) arrives transitively through `swift-openapi-runtime`. ## Swift language settings @@ -215,4 +215,4 @@ Never edit anything under `Sources/MistKitOpenAPI/` by hand — change `openapi. - - [`swift-openapi-generator` documentation](https://swiftpackageindex.com/apple/swift-openapi-generator/documentation/swift-openapi-generator) - [OpenAPI Specification 3.0.3](https://spec.openapis.org/oas/v3.0.3) -- [CloudKit Web Services API](https://developer.apple.com/documentation/cloudkitwebservices) +- [CloudKit Web Services API](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/index.html) diff --git a/Sources/MistKit/Documentation.docc/RequestSigning.md b/Sources/MistKit/Documentation.docc/RequestSigning.md index a55865c7..1841e1cb 100644 --- a/Sources/MistKit/Documentation.docc/RequestSigning.md +++ b/Sources/MistKit/Documentation.docc/RequestSigning.md @@ -47,7 +47,7 @@ The `init(decoding:)` / `encoded()` pair is the on-disk format used by ``TokenSt ## The middleware -`AuthenticationMiddleware` conforms to OpenAPIRuntime's `ClientMiddleware` and intercepts every outgoing request: +`AuthenticationMiddleware` conforms to OpenAPIRuntime's [`ClientMiddleware`](https://swiftpackageindex.com/apple/swift-openapi-runtime/documentation/openapiruntime/clientmiddleware) and intercepts every outgoing request: ```swift internal struct AuthenticationMiddleware: ClientMiddleware { @@ -161,7 +161,7 @@ This scheme grants access to the private and shared databases for the authentica ### Rotation -Every CloudKit response carries an `X-Apple-CloudKit-Web-Auth-Token` header with a fresh token, and Apple documents the previous token as invalid once the response is received. The middleware forwards the header to the manager; ``WebAuthTokenManager`` validates and stores it, and ``AdaptiveTokenManager`` additionally persists it to its ``TokenStorage``: +Every CloudKit response carries an `X-Apple-CloudKit-Web-Auth-Token` header with a fresh token, and [Apple documents the previous token as invalid](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/SettingUpWebServices.html) once the response is received. The middleware forwards the header to the manager; ``WebAuthTokenManager`` validates and stores it, and ``AdaptiveTokenManager`` additionally persists it to its ``TokenStorage``: ```swift public func didReceiveRotatedWebAuthToken(_ token: String) async throws(TokenManagerError) { @@ -242,13 +242,13 @@ internal struct RequestSignature: Sendable { It is a transport-format value, not a domain value, and two storage choices follow from that: - **The date is stored as a `String`, not a `Date`.** The ISO 8601 string is part of the signed payload. Re-formatting a `Date` on every header access risks a wire string that differs from what was signed (formatter options, fractional seconds), and every such mismatch is an indistinguishable `401`. Storing the string locks the wire form to the signed form. -- **The signature is stored as DER `Data`, not a base64 `String`.** The bytes are the natural form; base64 is computed on demand, and the struct stays free of the `@available` constraints that come with `P256.Signing.ECDSASignature`. +- **The signature is stored as DER `Data`, not a base64 `String`.** The bytes are the natural form; base64 is computed on demand, and the struct stays free of the `@available` constraints that come with [`P256.Signing.ECDSASignature`](https://github.com/apple/swift-crypto). ### Signing process The convenience initializer `init(keyID:privateKey:requestBody:webServiceSubpath:date:)` does: -1. **Format the ISO 8601 date.** `Date.ISO8601FormatStyle` on macOS 12 / iOS 15 / tvOS 15 / watchOS 8 and later; a shared `ISO8601DateFormatter` (documented thread-safe for `string(from:)`) on older systems. +1. **Format the ISO 8601 date.** `Date.ISO8601FormatStyle` on macOS 12 / iOS 15 / tvOS 15 / watchOS 8 and later; a shared [`ISO8601DateFormatter`](https://developer.apple.com/documentation/foundation/iso8601dateformatter) (documented thread-safe for `string(from:)`) on older systems. 2. **Hash the body.** `SHA256.cloudKitBodyHash(of:)` returns `base64(SHA256(body))`, or the **empty string** when the body is `nil` — not the hash of empty data. Both are defensible; only one is what CloudKit accepts. 3. **Build the payload:** `"::"`. 4. **Sign with P-256.** `privateKey.signature(for: Data(payload.utf8))` → DER bytes. @@ -283,7 +283,7 @@ X-Apple-CloudKit-Request-ISO8601Date: 2026-05-15T14:30:00Z X-Apple-CloudKit-Request-SignatureV1: ``` -There is no `Authorization` header. The `HTTPField.Name` constants for these three headers — and for the `X-Apple-CloudKit-Web-Auth-Token` response header — live in `Sources/MistKit/Authentication/HTTPField.Name+CloudKit.swift`. +There is no `Authorization` header. The [`HTTPField.Name`](https://github.com/apple/swift-http-types) constants for these three headers — and for the `X-Apple-CloudKit-Web-Auth-Token` response header — live in `Sources/MistKit/Authentication/HTTPField.Name+CloudKit.swift`. ## AdaptiveTokenManager diff --git a/Sources/MistKit/Documentation.docc/WhatCloudKitGotWrong.md b/Sources/MistKit/Documentation.docc/WhatCloudKitGotWrong.md index e778b3b5..7dc0a19c 100644 --- a/Sources/MistKit/Documentation.docc/WhatCloudKitGotWrong.md +++ b/Sources/MistKit/Documentation.docc/WhatCloudKitGotWrong.md @@ -4,7 +4,7 @@ Where CloudKit Web Services itself was hard: the places Apple's documentation an ## Overview -This is a field guide to the parts of CloudKit Web Services that cost real time, written from MistKit's issue tracker, its `openapi.yaml` annotations, and live runs against a development container. Its companion, , asks the orthogonal question of how the *assistant* behaved while the library was being built; the two barely overlap. +This is a field guide to the parts of [CloudKit Web Services](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/index.html) that cost real time, written from MistKit's issue tracker, its [`openapi.yaml`](https://github.com/brightdigit/MistKit/blob/main/openapi.yaml) annotations, and live runs against a development container. Its companion, , asks the orthogonal question of how the *assistant* behaved while the library was being built; the two barely overlap. The thesis, stated once: @@ -12,7 +12,7 @@ The thesis, stated once: Every significant finding below was settled by *running a request*, never by reading harder. The worst ones return **HTTP 200**: a wrong sync-token key is ignored rather than rejected, a mis-cased record type blames a different record, an unmodeled response key decodes to `nil`, a stale auth token keeps working. Nothing throws. -Its corollary: **when the archived REST reference and observed behavior disagree, CloudKit JS's source is a primary oracle.** Reading `setApiModuleName("device")` in CloudKit JS is what cracked the APNs-token routing bug below, and CloudKit JS beat the archived reference more than once. +Its corollary: **when the archived REST reference and observed behavior disagree, [CloudKit JS](https://developer.apple.com/documentation/cloudkitjs)'s source is a primary oracle.** Reading `setApiModuleName("device")` in CloudKit JS is what cracked the APNs-token routing bug below, and CloudKit JS beat the archived reference more than once. ## Two kinds of hard @@ -109,10 +109,10 @@ The conversion layer bets that a loud failure beats silently wrong data, but the | # | Finding | Receipt | | --- | --- | --- | | 1 | **`zones/changes` uses `metaSyncToken`, not `syncToken`.** The wrong key is *silently ignored* — page one replays forever, so pagination had **never** worked. Live proof: `syncToken` → 40 zones again; `metaSyncToken` → 0. Only this endpoint differs; `changes/database`, `changes/zone`, and `records/changes` genuinely use `syncToken`. | [#430](https://github.com/brightdigit/MistKit/issues/430) | -| 2 | **APNs tokens live under `/device/`, not `/database/`.** The documented path answers only `OPTIONS` and returns `405` on POST, with no `{database}` segment. Auth was ruled out by elimination across four passing endpoints; the answer came from CloudKit JS's source. | [#382](https://github.com/brightdigit/MistKit/issues/382) | +| 2 | **APNs tokens live under `/device/`, not `/database/`.** The documented path answers only `OPTIONS` and returns `405` on POST, with no `{database}` segment. Auth was ruled out by elimination across four passing endpoints; the answer came from [CloudKit JS](https://developer.apple.com/documentation/cloudkitjs)'s source. | [#382](https://github.com/brightdigit/MistKit/issues/382) | | 3 | **`ownerRecordName` vs `ownerName` — zone owners never decoded.** Live `zones/list` returns `ownerRecordName`; the spec declared `ownerName`, so every zone read its owner back as `nil`. | [#444](https://github.com/brightdigit/MistKit/issues/444) | | 4 | **`cloudkit.share`, not `cloudKit.share`.** One letter's case. The error — *"Cannot share - no such record exists to share"* — blames the root record, not the type string. | [#437](https://github.com/brightdigit/MistKit/issues/437) | -| 5 | **`GET users/discover` is broken server-side at Apple** — a 100% reproducible `500`. Proving it was Apple's bug took a five-rung ladder: `OPTIONS` returns 200; a typo'd path returns a clean 404; `POST` reaches body validation; and Apple's own CloudKit JS fails identically from a browser. Filed as Feedback FB22754466. | [#28](https://github.com/brightdigit/MistKit/issues/28) | +| 5 | **[`GET users/discover`](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/DiscoveringAllUserIdentities.html) is broken server-side at Apple** — a 100% reproducible `500`. Proving it was Apple's bug took a five-rung ladder: `OPTIONS` returns 200; a typo'd path returns a clean 404; `POST` reaches body validation; and Apple's own CloudKit JS fails identically from a browser. Filed as [Feedback FB22754466](https://feedbackassistant.apple.com/). | [#28](https://github.com/brightdigit/MistKit/issues/28) | On #1: the recommendation *from documents alone* had been to close the issue as not planned, on a two-versus-one documentation count. Five words — "Can we run a quick test for this?" — reversed it. tells the same episode from the collaboration side. @@ -126,7 +126,7 @@ The whole thesis in one arc. Docs wrong → `404` → "fix" applied per the docs - **The query index is eventually consistent; `lookup` is not.** Create → immediate query returns **0 records**; three seconds later it returns them; `lookup` by name returns them immediately. - **A read gives you an asset you cannot re-attach.** Three of six fields come back; the writable ones come only from the upload step or `assets/rereference`, which is absent from the current documentation entirely. - **Subscription uniqueness is keyed on `(recordType, firesOn)`, not `subscriptionID`.** The same ID twice *succeeds*; uniqueness is exact-set match, not overlap; and a duplicate surfaces as a generic `INTERNAL_ERROR` with no `CONFLICT` code. MistKit detects it by matching Apple's prose string, which breaks silently if Apple rewords it — see . -- **Per-zone partial failure.** `changes/database`, `changes/zone`, and `zones/modify` return success-or-failure *per zone*, and the failure variant must be listed **first** in each `oneOf` or the permissive success schema swallows it. +- **Per-zone partial failure.** `changes/database`, `changes/zone`, and [`zones/modify`](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ModifyZones.html) return success-or-failure *per zone*, and the failure variant must be listed **first** in each `oneOf` or the permissive success schema swallows it. - **`modifyRecords` never reports create-versus-update.** The only workable approach is a pre-fetch plus client-side classification — roughly 600 ms extra per sync in BushelCloud's measured case. ## Still open @@ -135,7 +135,7 @@ Live-verified through MistDemo (the web UI plus `test-public` / `test-private`) Still open on Apple's side, or as research gaps: -- `GET users/discover` still returns `500`. The MistKit issue is closed as a Feedback Assistant filing; the operation is generated from the spec but not surfaced by ``CloudKitService``. +- `GET users/discover` still returns `500`. The MistKit issue is closed as a [Feedback Assistant](https://feedbackassistant.apple.com/) filing; the operation is generated from the spec but not surfaced by ``CloudKitService``. - Whether a `database` subscription type exists; the full `zoneType` enum; and the fact that subscriptions cannot configure alert, badge, or sound at all (no `NotificationInfo` schema in the reference). ## Limitations diff --git a/Sources/MistKit/Documentation.docc/WhatTheAIGotWrong.md b/Sources/MistKit/Documentation.docc/WhatTheAIGotWrong.md index 164ac724..0f5eb1be 100644 --- a/Sources/MistKit/Documentation.docc/WhatTheAIGotWrong.md +++ b/Sources/MistKit/Documentation.docc/WhatTheAIGotWrong.md @@ -4,7 +4,7 @@ An evidence-backed catalogue of the recurring failure modes of AI-assisted devel ## Overview -MistKit was rebuilt with heavy use of AI coding assistants — first to translate Apple's archived CloudKit Web Services reference into `openapi.yaml`, then to build the wrapper, tests, and example projects on top. The published narrative is in *Rebuilding MistKit with Claude Code* ([part 1](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-1/), [part 2](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-2/)), written from memory. This article is written from the transcripts and the pull-request record: 191 closed PRs, 229 closed issues, six years of git history, 46 editor-assistant conversations, eight days of Claude Code transcripts, and 956 typed prompts spanning July 2025 to September 2026. In three places the transcripts sharpen or complicate the published article. +MistKit was rebuilt with heavy use of AI coding assistants — first to translate Apple's archived CloudKit Web Services reference into `openapi.yaml`, then to build the wrapper, tests, and example projects on top. The published narrative is in *Rebuilding MistKit with Claude Code* ([part 1](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-1/), [part 2](https://brightdigit.com/tutorials/rebuilding-mistkit-claude-code-part-2/)), written from memory. This article is written from the transcripts and the pull-request record: 191 closed PRs, 229 closed issues, six years of git history, 46 editor-assistant conversations, eight days of [Claude Code](https://claude.com/claude-code) transcripts, and 956 typed prompts spanning July 2025 to September 2026. In three places the transcripts sharpen or complicate the published article. Its companion, , asks the orthogonal question — which parts of *CloudKit Web Services itself* were hard. The two barely overlap, for a structural reason this article opens with. **Read the limitations at the end before quoting any number.** @@ -71,7 +71,7 @@ Introduced unprompted: `SecureMemory`, `RegexCache`, `RetryPolicy`, token-refres > **Human:** **"Could not retrieve session token: TypeError: container.getSession is not a function"** > **AI:** *"I see the issue. The `getSession()` method doesn't exist in the CloudKit JS API."* -Also invented: Swift Testing `.tags()` syntax, `swift-format:disable:all`, a non-existent Docker tag, test parameters that did not exist, and a claim to have read a GitHub URL it never fetched. +Also invented: Swift Testing [`.tags()`](https://developer.apple.com/documentation/testing/traits) syntax, `swift-format:disable:all`, a non-existent Docker tag, test parameters that did not exist, and a claim to have read a GitHub URL it never fetched. **The inverse case is the most expensive.** The AI invented an *impossibility*: @@ -92,7 +92,7 @@ The cleanest instance: after arguing at length against a new package (*"most of **4 / 3 · high** -Reclassified remaining failures as out of scope, then declared completion. In one session it did this three times consecutively — Core Data errors "separate from this task", then OSLog "isn't available on Linux", then *"Build succeeded. Only a warning remains"* while SwiftUI errors were still present. +Reclassified remaining failures as out of scope, then declared completion. In one session it did this three times consecutively — [Core Data](https://developer.apple.com/documentation/coredata) errors "separate from this task", then [OSLog](https://developer.apple.com/documentation/oslog) "isn't available on Linux", then *"Build succeeded. Only a warning remains"* while SwiftUI errors were still present. > **AI:** *"Many warnings in generated files, but these are expected and acceptable for generated code"* > **Human:** **"No that's incorrect we should not receive any warnings or errors."** @@ -108,7 +108,7 @@ Hunted for a Makefile when `lint.sh` was documented. Ran the OpenAPI generator a > **Human:** **"just run @lint.sh"** > **Human:** **"Instead of commenting out the disabled tests use the new TestTrait `disabledOniOSWithXcode16_2OrOlder()`"** -This is the direct ancestor of the current instruction: *"do NOT invoke them from PATH directly. Run them THROUGH mise."* +This is the direct ancestor of the current instruction: *"do NOT invoke them from PATH directly. Run them THROUGH [mise](https://mise.jdx.dev)."* ### 7. Partial application — doing the sweep on a sample @@ -142,7 +142,7 @@ New test files immediately violated `file_length`; five files were all named `Ba - **Fix the root cause, not the instance** — told CI was green despite a lint violation, it began splitting the offending file. *"Don't fix the error. Fix the workflow to fail on linting failure."* The project's "fix `openapi.yaml`, not the Swift" rule is the domain-specific form of the same reflex. - **Deleting working coverage while adding new coverage** — asked to *add* Swift versions to a CI matrix, it replaced the matrix and silently dropped the nightlies, describing the result as "comprehensive". It had been rewriting whole YAML files rather than editing them. -- **Unnecessary conditional-compilation ceremony** — added `import FoundationNetworking`, `import Crypto` and `#if canImport(Crypto)` guards it didn't need, then wrote a confident defense before reversing one turn later. +- **Unnecessary conditional-compilation ceremony** — added `import FoundationNetworking`, [`import Crypto`](https://github.com/apple/swift-crypto) and `#if canImport(Crypto)` guards it didn't need, then wrote a confident defense before reversing one turn later. - **Wrong granularity for suppressions and guards** — per-line annotations where a file-level one was right. Persisted ten weeks across two codebases with near-identical human phrasing: *"put the ignores on the entire block"* → *"just gate the whole type or file."* - **Hand-editing generated files** — asked to get ignore directives onto generated output, it opened `Client.swift` and typed them in. The earliest boundary correction in the corpus. diff --git a/Sources/MistKit/Documentation.docc/WorkingWithRecords.md b/Sources/MistKit/Documentation.docc/WorkingWithRecords.md index 9b537b1b..5deb0c26 100644 --- a/Sources/MistKit/Documentation.docc/WorkingWithRecords.md +++ b/Sources/MistKit/Documentation.docc/WorkingWithRecords.md @@ -8,7 +8,7 @@ CRUD, batch, and lookup against CloudKit records — the operations you'll reach ## Querying -Use ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneID:zoneWide:numbersAsStrings:database:)`` for a single page of results. Filters are built with ``QueryFilter`` factories, sorts with ``QuerySort/ascending(_:)`` / ``QuerySort/descending(_:)``: +Use ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneID:zoneWide:numbersAsStrings:database:)`` for a single page of results. [Filters](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/QueryingRecords.html) are built with ``QueryFilter`` factories, sorts with ``QuerySort/ascending(_:)`` / ``QuerySort/descending(_:)``: ```swift let result = try await service.queryRecords( @@ -162,7 +162,7 @@ for result in results { Choose `atomic: true` when the operations are semantically linked (paired updates, a transactional rename) and `false` when independent operations are batched purely for throughput. -> Note: CloudKit caps batch size around 200 operations per request. See for batching guidance. +> Note: CloudKit caps batch size [around 200 operations per request](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ModifyRecords.html). See for batching guidance. ## Looking up diff --git a/Sources/MistKit/Documentation.docc/iOSDevUKTalk.md b/Sources/MistKit/Documentation.docc/iOSDevUKTalk.md index f36bc776..a32404ea 100644 --- a/Sources/MistKit/Documentation.docc/iOSDevUKTalk.md +++ b/Sources/MistKit/Documentation.docc/iOSDevUKTalk.md @@ -4,6 +4,6 @@ Slides from the iOSDevUK 2026 talk — downloadable PDF deck that pairs with the ## Overview -Slides from **CloudKit as Your Backend: From iOS to Server-Side Swift** (iOSDevUK 2026). The written form of this talk is . +Slides from **CloudKit as Your Backend: From iOS to Server-Side Swift** ([iOSDevUK](https://www.iosdevuk.com) 2026). The written form of this talk is . **[Download PDF](https://github.com/brightdigit/MistKit/releases/download/1.0.0-beta.5/CloudKit-Backend-iOSDevUK.pdf)** (~12 MB) From d03e33ae92f2f711ab12dd99d3646a537a960570 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 16:56:45 +0000 Subject: [PATCH 12/12] Fix the README's 404 Web Services link and console link; unlink users/caller from the users/current page The archived GetCurrentUser page documents the deprecated users/current route, not users/caller, so those mentions go back to plain text. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CZstUEGAAPuxzTjiAPii4S --- README.md | 4 ++-- .../MistKit/Documentation.docc/AuthenticationAndDatabases.md | 4 ++-- Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1c380147..7eb82fd5 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/Sources/MistKit/Documentation.docc/AuthenticationAndDatabases.md b/Sources/MistKit/Documentation.docc/AuthenticationAndDatabases.md index 1db52f54..ec6b966f 100644 --- a/Sources/MistKit/Documentation.docc/AuthenticationAndDatabases.md +++ b/Sources/MistKit/Documentation.docc/AuthenticationAndDatabases.md @@ -12,7 +12,7 @@ Configure ``CloudKitService`` once with the credentials it needs, then pick a `` | `.private` | — | ✓ | — | | `.shared` | — | ✓ | — | -The same backend legitimately needs both attribution paths — server-attributed writes against the public database (catalog seeds, moderation actions) and user-attributed reads against [`users/caller`](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/GetCurrentUser.html) (knowing which iCloud user a session belongs to). MistKit models this by: +The same backend legitimately needs both attribution paths — server-attributed writes against the public database (catalog seeds, moderation actions) and user-attributed reads against `users/caller` (knowing which iCloud user a session belongs to). MistKit models this by: 1. Letting ``CloudKitService`` hold a ``Credentials`` value that carries either or both credential sets. 2. Making the target ``Database`` an argument on each operation, with `.public` carrying a ``PublicAuthPreference`` that picks the signing method *for that call*. @@ -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`](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/GetCurrentUser.html), [`/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``. +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 diff --git a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md index c958d8d8..bca3f000 100644 --- a/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md +++ b/Sources/MistKit/Documentation.docc/CloudKitAsYourBackend.md @@ -360,7 +360,7 @@ There is no `Authorization` header. Every mistake in any of those pieces produce | Web auth token | ✓ user-attributed | ✓ | ✓ | | Server-to-server | ✓ developer-attributed | — | — | -The public database accepts two methods and they are **not interchangeable**: the same record written via web auth and via server-to-server ends up with two different creators, and the [`/users/*` routes](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/GetCurrentUser.html) accept web auth only. MistKit therefore makes every public call say which it wants — ``Database/public(_:)`` carries a ``PublicAuthPreference`` — rather than defaulting silently. covers the model; the deeper "why" is in . +The public database accepts two methods and they are **not interchangeable**: the same record written via web auth and via server-to-server ends up with two different creators, and the `/users/*` routes accept web auth only. MistKit therefore makes every public call say which it wants — ``Database/public(_:)`` carries a ``PublicAuthPreference`` — rather than defaulting silently. covers the model; the deeper "why" is in . ### OpenAPI middleware