diff --git a/CLAUDE.md b/CLAUDE.md index db29e7b3..9896cb18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ swift run mistdemo lookup-zones swift run mistdemo fetch-changes swift run mistdemo demo-in-filter swift run mistdemo demo-errors -swift run mistdemo test-integration +swift run mistdemo test-public swift run mistdemo test-private # Run with specific configuration @@ -290,13 +290,34 @@ A `ClientTransport` extension could provide a generic upload method, but would n ### CloudKit Web Services Integration - Base URL: `https://api.apple-cloudkit.com` - Authentication: - - **Public database**: `CLOUDKIT_KEY_ID` + `CLOUDKIT_PRIVATE_KEY` or `CLOUDKIT_PRIVATE_KEY_PATH` → server-to-server signing - - **Private database**: `CLOUDKIT_API_TOKEN` + `CLOUDKIT_WEB_AUTH_TOKEN` → web authentication + - **Public database**: caller picks per-call via `PublicAuthPreference` carried on `Database.public(_:)`. Either `.requires(.serverToServer)` (key-pair signing — needs `CLOUDKIT_KEY_ID` + `CLOUDKIT_PRIVATE_KEY` or `CLOUDKIT_PRIVATE_KEY_PATH`) or `.requires(.webAuth)` (user-attributed — needs `CLOUDKIT_API_TOKEN` + `CLOUDKIT_WEB_AUTH_TOKEN`). Use `.prefers(_:)` to fall back to whichever cred is configured. + - **Private / Shared database**: always `CLOUDKIT_API_TOKEN` + `CLOUDKIT_WEB_AUTH_TOKEN` → web-auth (CloudKit rejects S2S on these scopes). - All operations should reference the OpenAPI spec in `openapi.yaml` - URL Pattern: `/database/{version}/{container}/{environment}/{database}/{operation}` -- Supported databases: `public`, `private`, `shared` +- Supported databases: `Database.public(PublicAuthPreference)`, `Database.private`, `Database.shared` - Environments: `development`, `production` +### Per-call attribution for `.public` + +`Database` carries the signing choice when targeting public: + +```swift +public enum Database { + case `public`(PublicAuthPreference) + case `private` + case shared +} +``` + +`PublicAuthPreference` is constructed via two factories — never via the (internal) memberwise init: + +- `.prefers(.serverToServer)` — try S2S, fall back to web-auth/API-token if S2S isn't configured. +- `.prefers(.webAuth)` — try web-auth, fall back to S2S if web-auth isn't configured. +- `.requires(.serverToServer)` — must use S2S; throw `missingCredentials(.preferenceRequired)` otherwise. +- `.requires(.webAuth)` — must use web-auth; throw `missingCredentials(.preferenceRequired)` otherwise. + +There is **no default** on the operation `database:` parameter — every call must pick explicitly. The `requiresUserContext` flag on the dispatcher is gone; user-context routes (`users/*`) pass `.public(.requires(.webAuth))` directly. See `Sources/MistKit/Authentication/PublicAuthPreference.swift` and `Sources/MistKit/Authentication/Credentials/Credentials+TokenManager.swift`. + ### Testing Strategy - Use Swift Testing framework (`@Test` macro) for all tests - Unit tests for all public APIs @@ -327,7 +348,7 @@ A `ClientTransport` extension could provide a generic upload method, but would n - `IntegrationTestError.swift` — typed errors for test failures - `IntegrationTest.swift`, `PhasedIntegrationTest.swift`, and `Tests/` subdirectory — protocol-based phase pipeline introduced in #283 -Run via `swift run mistdemo test-integration` or `swift run mistdemo test-private` (private database variant). Both commands require valid CloudKit credentials in the config file. +Run via `swift run mistdemo test-public` or `swift run mistdemo test-private` (private database variant). Both commands require valid CloudKit credentials in the config file. ## Important Implementation Notes diff --git a/Examples/MistDemo/App/MistDemoApp.swift b/Examples/MistDemo/App/MistDemoApp.swift index c2a4e808..b4e97089 100644 --- a/Examples/MistDemo/App/MistDemoApp.swift +++ b/Examples/MistDemo/App/MistDemoApp.swift @@ -32,14 +32,14 @@ import SwiftUI @main internal struct MistDemoAppMain: App { - @StateObject private var service = NativeCloudKitService( - containerIdentifier: NativeCloudKitService.demoContainerIdentifier + @State private var service = CloudKitStore( + containerIdentifier: CloudKitStore.demoContainerIdentifier ) internal var body: some Scene { WindowGroup("MistDemo (Native CloudKit)") { RootView() - .environmentObject(service) + .environment(service) } #if os(macOS) .defaultSize(width: 880, height: 600) diff --git a/Examples/MistDemo/Native-README.md b/Examples/MistDemo/Native-README.md deleted file mode 100644 index 146bde5d..00000000 --- a/Examples/MistDemo/Native-README.md +++ /dev/null @@ -1,114 +0,0 @@ -# MistDemoApp — Native CloudKit Demo - -A SwiftUI demo app that talks to the same CloudKit container as the -MistDemo CLI/web tool, but uses **Apple's native CloudKit framework** -(`CKContainer`, `CKDatabase`, `CKQuery`) instead of MistKit. - -The two demos are intended to be shown side-by-side in presentations: - -| Surface | Stack | Use case | -|---|---|---| -| `MistDemo` CLI / web (`mistdemo`) | MistKit (CloudKit Web Services REST) | Server, Linux, command line, web | -| `MistDemoApp` (this directory) | Apple CloudKit framework | Native macOS / iOS apps | - -Both target the container `iCloud.com.brightdigit.MistDemo` and the same -`Note` record schema (see `schema.ckdb`). - -## What's included (read-side parity with MistDemo CLI) - -- **iCloud Account view** — `CKContainer.accountStatus()` -- **Zones list** — `CKDatabase.allRecordZones()` (parity with `mistdemo lookup-zones`) -- **Notes query** — `CKDatabase.records(matching:)` for `Note` records, sorted by `index` -- **Note detail** — typed view of `title`, `index`, `image`, `createdAt`, `modified` -- **Create / update / delete** — `CKDatabase.save(_:)` and `deleteRecord(withID:)` - -The `Note` model in `Sources/MistDemoApp/Models/CloudKitModels.swift` -mirrors the `Note` record type in `schema.ckdb`. - -## Layout - -The reusable code lives in the `MistDemoApp` library target of the -local Swift package. The Xcode project only references a thin `@main` -shell: - -``` -Examples/MistDemo/ -├── Package.swift # mistdemo CLI + MistDemoApp library -├── project.yml # XcodeGen config -├── App/ -│ └── MistDemoApp.swift # @main App + WindowGroup -├── Sources/ -│ ├── MistDemo/ # CLI entry point -│ ├── MistDemoKit/ # CLI library (used by mistdemo) -│ ├── ConfigKeyKit/ # Configuration parsing -│ └── MistDemoApp/ # SwiftUI library used by the Xcode app -│ ├── Models/CloudKitModels.swift -│ ├── Services/NativeCloudKitService.swift -│ └── Views/{RootView,AccountView,ZoneListView,QueryView,NoteEditView,RecordDetailView}.swift -└── schema.ckdb # CloudKit schema for Note record -``` - -The same `MistDemoApp` source files compile for both macOS and iOS; -only `App/MistDemoApp.swift`'s `defaultSize(...)` is gated to macOS. - -## Recommended path: open in Xcode - -CloudKit requires an `.app` bundle with the iCloud + CloudKit -entitlement. The Xcode project is generated from `project.yml` via -[XcodeGen](https://github.com/yonaskolb/XcodeGen): - -```bash -brew install xcodegen # one-time -cd Examples/MistDemo -cp .env.example .env # one-time — fill in CLOUDKIT_API_TOKEN, BUNDLE_ID_PREFIX, DEVELOPMENT_TEAM -make generate # sources .env, runs xcodegen -open MistDemoApp.xcodeproj -``` - -Two schemes ship in the project: - -- `MistDemoApp-macOS` — runs as a native macOS app -- `MistDemoApp-iOS` — runs on iOS / iPadOS (simulator or device) - -Before running, in **Signing & Capabilities** for each target, sign in -to your Apple Developer account so Xcode can request the `iCloud + -CloudKit` entitlement against the -`iCloud.com.brightdigit.MistDemo` container. - -The entitlements file (`MistDemoApp.entitlements`) is checked in and -already lists the container. If you don't have access to the -BrightDigit signing identity, set `BUNDLE_ID_PREFIX` in `.env` to a -prefix you own and `DEVELOPMENT_TEAM` to your team ID before running -`make generate`. - -## Setting the CloudKit API token - -The app's iCloud Account view exchanges your **public CloudKit API -token** (from CloudKit Dashboard) for a web auth token via -`CKFetchWebAuthTokenOperation`. The token is the same value the -MistDemo CLI reads from `$CLOUDKIT_API_TOKEN`, so one source covers -both halves of the demo. - -There are three ways to provide it, ranked by ergonomics: - -1. **`.env` → `make generate` (recommended).** Copy `.env.example` to - `.env` (gitignored) and fill in `CLOUDKIT_API_TOKEN`. Then run - `make generate` from `Examples/MistDemo`. The Makefile sources - `.env`; XcodeGen substitutes `${CLOUDKIT_API_TOKEN}` into the - generated scheme's `environmentVariables`, so when you run the app - from Xcode the value reaches it through - `ProcessInfo.processInfo.environment`. The whole `.xcodeproj` is - gitignored repo-wide, so the substituted value never lands in git. - Survives Xcode debug runs and iOS Simulator runs. - -2. **Ad-hoc terminal env var.** Useful when launching from a shell: - `CLOUDKIT_API_TOKEN= open MistDemoApp.xcodeproj`. The app - reads `ProcessInfo.processInfo.environment` on launch. - -3. **Manual paste in the app.** The TextField in iCloud Account still - accepts ad-hoc values; they persist via `@AppStorage` - (`UserDefaults`) until cleared. - -The `.env` file is gitignored, the `.xcodeproj` is gitignored repo-wide, -and `.env.example` only names the variable — so the secret never lands -in the repo at any stage of the pipeline. diff --git a/Examples/MistDemo/Package.swift b/Examples/MistDemo/Package.swift index 4b89becd..463af89c 100644 --- a/Examples/MistDemo/Package.swift +++ b/Examples/MistDemo/Package.swift @@ -158,6 +158,9 @@ let package = Package( condition: asyncAlgorithmsCondition ), ], + resources: [ + .copy("Resources/index.html"), + ], swiftSettings: swiftSettings ), .executableTarget( @@ -175,6 +178,20 @@ let package = Package( "MistDemoKit", "ConfigKeyKit", .product(name: "MistKit", package: "MistKit"), + .product( + name: "Hummingbird", + package: "hummingbird", + condition: .when(platforms: [ + .macOS, .iOS, .tvOS, .visionOS, .macCatalyst, .linux, + ]) + ), + .product( + name: "HummingbirdTesting", + package: "hummingbird", + condition: .when(platforms: [ + .macOS, .iOS, .tvOS, .visionOS, .macCatalyst, .linux, + ]) + ), .product( name: "AsyncAlgorithms", package: "swift-async-algorithms", diff --git a/Examples/MistDemo/README.md b/Examples/MistDemo/README.md new file mode 100644 index 00000000..87e199a0 --- /dev/null +++ b/Examples/MistDemo/README.md @@ -0,0 +1,271 @@ +# MistDemo + +Three runnable demos that exercise the same CloudKit container from +three different stacks, intended to be shown side-by-side: + +| Surface | Stack | Use case | +|---|---|---| +| `mistdemo` CLI (`query`, `create`, `update`, `delete`, …) | MistKit (CloudKit Web Services REST) | Command-line, scripts, CI, Linux | +| `mistdemo web` | MistKit + Hummingbird server + browser UI | Interactive demo, presentations | +| `MistDemoApp` | Apple CloudKit framework (`CKContainer`, `CKDatabase`) | Native macOS / iOS apps | + +All three target the container `iCloud.com.brightdigit.MistDemo` and the +same `Note` record schema (see [`schema.ckdb`](schema.ckdb)). The same +`$CLOUDKIT_API_TOKEN` covers the CLI/web and is also exchanged for a +web-auth token by the native app, so one source of credentials feeds +every surface. + +## Prerequisites + +1. An Apple Developer account with a CloudKit container. +2. A CloudKit **API token** for that container (from the CloudKit + Console). The web and native demos use the web-auth flow, so + server-to-server signing keys are not needed. +3. Swift 6+ toolchain (for the CLI/web). The native app additionally + requires Xcode and [XcodeGen](https://github.com/yonaskolb/XcodeGen). + +--- + +## CLI — `mistdemo` + +The CLI is the broadest surface — every CloudKit operation MistKit +supports has a subcommand. See `swift run mistdemo --help` for the full +list. The most common commands: + +```bash +cd Examples/MistDemo +swift run mistdemo query --record-type Note +swift run mistdemo create --record-type Note --fields '{"title":"Hi"}' +swift run mistdemo auth-token # capture a web-auth token +swift run mistdemo test-public # integration suite, public DB +swift run mistdemo test-private # integration suite, private DB +``` + +Configuration comes from `MistDemoConfiguration` — flags, +`CLOUDKIT_*` env vars, or `--config-file ~/.mistdemo/config.json` all +work. + +--- + +## Web — `mistdemo web` + +A long-running Hummingbird server that pairs the CloudKit browser-side +auth round trip with a CRUD UI driven by MistKit on the server. Run +`mistdemo web`, complete the iCloud sign-in in the browser, then drive +record create / query / update / delete from the same page until you +Ctrl+C the server. + +### Quick start + +```bash +cd Examples/MistDemo +swift run mistdemo web --api-token "$CLOUDKIT_API_TOKEN" +``` + +Or via env var: + +```bash +CLOUDKIT_API_TOKEN=… swift run mistdemo web +``` + +The CLI prints the server URL. The `web` command does **not** open the +browser by default (the server is long-running and often driven from a +different machine); pass `--browser` to opt in. The `auth-token` command +**does** open the browser by default — the captured token is the whole +point of running it. Sign in with your Apple ID; the server captures the +web-auth token and the CRUD UI on the page becomes live. + +### Options + +| Flag | Default | Notes | +|---|---|---| +| `--api-token ` | (required) | Or set `CLOUDKIT_API_TOKEN` | +| `--container-identifier ` | `iCloud.com.brightdigit.MistDemo` | Your CloudKit container | +| `--environment ` | `development` | `development` or `production` | +| `--host ` | `127.0.0.1` | Bind address | +| `--port ` | `8080` | Server port | +| `--browser` | on for `auth-token`, off for `web` | Open browser on startup | +| `--no-browser` | — | Suppress the open (wins if both flags set) | + +Configuration is read via `MistDemoConfiguration`, so the same keys +(`api.token`, `container.identifier`, `environment`, `port`, `host`, +`browser`, `no.browser`) can be supplied through `--config-file ~/.mistdemo/config.json` +or environment variables. + +### What the server exposes + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/` and `/index.html` | Interactive demo page | +| `GET` | `/api/config` | CloudKit JS config (loopback-only) | +| `POST` | `/api/authenticate` | Capture web-auth token from the browser | +| `POST` | `/api/records/query` | Query records | +| `POST` | `/api/records/create` | Create record | +| `POST` | `/api/records/update` | Update record | +| `POST` | `/api/records/delete` | Delete record | + +The page has a **mode toggle** that compares the two stacks against the +same container: + +- **MistKit (server-side)** — the page calls `/api/records/*` on this + server, which talks to CloudKit Web Services via MistKit. +- **CloudKit JS (browser-side)** — the page talks directly to CloudKit + from the browser using the config returned by `/api/config`. + +### Calling the API directly + +Once the browser has completed the auth round trip, the same endpoints +can be exercised from a terminal: + +```bash +curl -X POST http://127.0.0.1:8080/api/records/query \ + -H 'Content-Type: application/json' \ + -d '{"recordType":"Note"}' +``` + +### Tests + +```bash +cd Examples/MistDemo +swift test --filter WebServerTests +swift test --filter WebAuthTokenStoreTests +``` + +`WebServerTests` uses `MockBackend` to drive the routes without +hitting CloudKit. `WebAuthTokenStoreTests` covers the token-capture +stream that backs the auth response. + +### Layout + +The web command's code lives under `Sources/MistDemoKit/`: + +``` +Sources/MistDemoKit/ +├── Commands/WebCommand.swift # `mistdemo web` entry point +├── Configuration/WebConfig.swift # Flags / env / config-file binding +├── Resources/index.html # Served at GET / +└── Server/ + ├── WebServer.swift # Hummingbird router + handlers + ├── WebBackend.swift # MistKit-backed backend + ├── WebRequests.swift # Request payloads + ├── WebResponse.swift # Response payloads + ├── WebIndexHTML.swift # Loads index HTML from Bundle.module + └── WebAuthTokenStore.swift # Captures the token from /api/authenticate +``` + +Tests are under `Tests/MistDemoTests/Server/`. + +### Security notes + +- The server binds to `127.0.0.1` by default and rejects non-loopback + requests to `/api/config`. Override `--host` with care. +- The web-auth token is short-lived. Re-run `mistdemo web` to refresh it. +- Never commit your CloudKit API token; prefer `CLOUDKIT_API_TOKEN` or a + config file outside the repo. + +--- + +## Native app — `MistDemoApp` + +A SwiftUI demo app that talks to the same CloudKit container, but uses +**Apple's native CloudKit framework** (`CKContainer`, `CKDatabase`, +`CKQuery`) instead of MistKit. + +### What's included (read-side parity with the CLI) + +- **iCloud Account view** — `CKContainer.accountStatus()` +- **Zones list** — `CKDatabase.allRecordZones()` (parity with `mistdemo lookup-zones`) +- **Notes query** — `CKDatabase.records(matching:)` for `Note` records, sorted by `index` +- **Note detail** — typed view of `title`, `index`, `image`; created/modified come from CloudKit system metadata +- **Create / update / delete** — `CKDatabase.save(_:)` and `deleteRecord(withID:)` + +The `Note` model in `Sources/MistDemoApp/Models/CloudKitModels.swift` +mirrors the `Note` record type in `schema.ckdb`. + +### Layout + +The reusable code lives in the `MistDemoApp` library target of the +local Swift package. The Xcode project only references a thin `@main` +shell: + +``` +Examples/MistDemo/ +├── Package.swift # mistdemo CLI + MistDemoApp library +├── project.yml # XcodeGen config +├── App/ +│ └── MistDemoApp.swift # @main App + WindowGroup +├── Sources/ +│ ├── MistDemo/ # CLI entry point +│ ├── MistDemoKit/ # CLI library (used by mistdemo) +│ ├── ConfigKeyKit/ # Configuration parsing +│ └── MistDemoApp/ # SwiftUI library used by the Xcode app +│ ├── Models/CloudKitModels.swift +│ ├── Services/NativeCloudKitService.swift +│ └── Views/{RootView,AccountView,ZoneListView,QueryView,NoteEditView,RecordDetailView}.swift +└── schema.ckdb # CloudKit schema for Note record +``` + +The same `MistDemoApp` source files compile for both macOS and iOS; +only `App/MistDemoApp.swift`'s `defaultSize(...)` is gated to macOS. + +### Recommended path: open in Xcode + +CloudKit requires an `.app` bundle with the iCloud + CloudKit +entitlement. The Xcode project is generated from `project.yml` via +[XcodeGen](https://github.com/yonaskolb/XcodeGen): + +```bash +brew install xcodegen # one-time +cd Examples/MistDemo +cp .env.example .env # one-time — fill in CLOUDKIT_API_TOKEN, BUNDLE_ID_PREFIX, DEVELOPMENT_TEAM +make generate # sources .env, runs xcodegen +open MistDemoApp.xcodeproj +``` + +Two schemes ship in the project: + +- `MistDemoApp-macOS` — runs as a native macOS app +- `MistDemoApp-iOS` — runs on iOS / iPadOS (simulator or device) + +Before running, in **Signing & Capabilities** for each target, sign in +to your Apple Developer account so Xcode can request the `iCloud + +CloudKit` entitlement against the +`iCloud.com.brightdigit.MistDemo` container. + +The entitlements file (`MistDemoApp.entitlements`) is checked in and +already lists the container. If you don't have access to the +BrightDigit signing identity, set `BUNDLE_ID_PREFIX` in `.env` to a +prefix you own and `DEVELOPMENT_TEAM` to your team ID before running +`make generate`. + +### Setting the CloudKit API token + +The app's iCloud Account view exchanges your **public CloudKit API +token** (from CloudKit Dashboard) for a web auth token via +`CKFetchWebAuthTokenOperation`. The token is the same value the +CLI/web reads from `$CLOUDKIT_API_TOKEN`, so one source covers every +surface. + +There are three ways to provide it, ranked by ergonomics: + +1. **`.env` → `make generate` (recommended).** Copy `.env.example` to + `.env` (gitignored) and fill in `CLOUDKIT_API_TOKEN`. Then run + `make generate` from `Examples/MistDemo`. The Makefile sources + `.env`; XcodeGen substitutes `${CLOUDKIT_API_TOKEN}` into the + generated scheme's `environmentVariables`, so when you run the app + from Xcode the value reaches it through + `ProcessInfo.processInfo.environment`. The whole `.xcodeproj` is + gitignored repo-wide, so the substituted value never lands in git. + Survives Xcode debug runs and iOS Simulator runs. + +2. **Ad-hoc terminal env var.** Useful when launching from a shell: + `CLOUDKIT_API_TOKEN= open MistDemoApp.xcodeproj`. The app + reads `ProcessInfo.processInfo.environment` on launch. + +3. **Manual paste in the app.** The TextField in iCloud Account still + accepts ad-hoc values; they persist via `@AppStorage` + (`UserDefaults`) until cleared. + +The `.env` file is gitignored, the `.xcodeproj` is gitignored repo-wide, +and `.env.example` only names the variable — so the secret never lands +in the repo at any stage of the pipeline. diff --git a/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift b/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift index bca03c81..6b3e396f 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift @@ -34,20 +34,20 @@ /// Note record, mirroring the `Note` type defined in `schema.ckdb`: /// /// RECORD TYPE Note ( - /// "title" STRING QUERYABLE SORTABLE SEARCHABLE, - /// "index" INT64 QUERYABLE SORTABLE, - /// "image" ASSET, - /// "createdAt" TIMESTAMP QUERYABLE SORTABLE, - /// "modified" INT64 QUERYABLE + /// "title" STRING QUERYABLE SORTABLE SEARCHABLE, + /// "index" INT64 QUERYABLE SORTABLE, + /// "image" ASSET /// ); + /// + /// Created / modified timestamps come from CloudKit's system metadata + /// (`CKRecord.creationDate` / `.modificationDate`), so there's no need + /// for custom `createdAt` / `modified` schema fields. internal struct Note: Identifiable, Hashable { /// Known field name constants for `Note` records. internal enum Fields { internal static let title = "title" internal static let index = "index" internal static let image = "image" - internal static let createdAt = "createdAt" - internal static let modified = "modified" } /// CloudKit record type identifier. @@ -57,13 +57,12 @@ internal let title: String? internal let index: Int64? internal let imageAssetURL: URL? - internal let createdAt: Date? - internal let modified: Int64? /// CloudKit-managed metadata internal let modificationDate: Date? internal let creationDate: Date? internal let recordChangeTag: String? + internal let creatorUserRecordName: String? internal init?(_ record: CKRecord) { guard record.recordType == Self.recordType else { @@ -73,11 +72,10 @@ self.title = record[Fields.title] as? String self.index = (record[Fields.index] as? NSNumber)?.int64Value self.imageAssetURL = (record[Fields.image] as? CKAsset)?.fileURL - self.createdAt = record[Fields.createdAt] as? Date - self.modified = (record[Fields.modified] as? NSNumber)?.int64Value self.modificationDate = record.modificationDate self.creationDate = record.creationDate self.recordChangeTag = record.recordChangeTag + self.creatorUserRecordName = record.creatorUserRecordID?.recordName } // Identity-based equality: two Notes with the same recordID are equal diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabaseScope+Demo.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabaseScope+Demo.swift new file mode 100644 index 00000000..37ff7b20 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabaseScope+Demo.swift @@ -0,0 +1,47 @@ +// +// CKDatabaseScope+Demo.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(CloudKit) && !os(tvOS) && !os(watchOS) + import CloudKit + + extension CKDatabase.Scope { + /// Scopes exposed in the MistDemoApp picker. `.shared` is intentionally + /// excluded because the demo's `schema.ckdb` has no shared zones. + internal static let selectable: [CKDatabase.Scope] = [.public, .private] + + internal var label: String { + switch self { + case .public: return "Public" + case .private: return "Private" + case .shared: return "Shared" + @unknown default: return "Unknown" + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitService.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift similarity index 59% rename from Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitService.swift rename to Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift index 5d4f99d5..d183db28 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitService.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift @@ -1,5 +1,5 @@ // -// NativeCloudKitService.swift +// CloudKitStore.swift // MistDemo // // Created by Leo Dion. @@ -29,27 +29,34 @@ #if canImport(CloudKit) && !os(tvOS) && !os(watchOS) import CloudKit - public import Combine import Foundation - - /// Thin wrapper around Apple's CloudKit framework that mirrors the read-side - /// operations the MistKit-driven MistDemo CLI exposes. The two demos hit the - /// same CloudKit container, so a presentation can flip between them and show - /// identical data accessed through different stacks. + public import Observation + + /// Observable source of truth for the MistDemo app's CloudKit state. + /// + /// Wraps `CKContainer`/`CKDatabase` directly. MistKit's REST surface is + /// reserved for server/Linux/WASI/Windows contexts where the CloudKit + /// framework isn't available. + @Observable @MainActor - public final class NativeCloudKitService: ObservableObject { + public final class CloudKitStore { /// The shared demo container identifier — must match `MistDemoConfig.containerIdentifier`. public static let demoContainerIdentifier = "iCloud.com.brightdigit.MistDemo" - @Published internal var accountStatus: CKAccountStatus = .couldNotDetermine - @Published internal var lastError: String? + internal var accountStatus: CKAccountStatus = .couldNotDetermine + internal var lastError: String? + internal var databaseScope: CKDatabase.Scope = .private + + /// The signed-in iCloud user's record name. Mirrors `currentUserRecordName` + /// in the web demo and is used to flag the "You" badge on notes the + /// current user created. + internal var currentUserRecordName: String? internal let containerIdentifier: String - private let container: CKContainer + @ObservationIgnored private let container: CKContainer - /// Convenience: which database we want to demo against. The MistDemo CLI - /// defaults to `.private`, so mirror that here. - internal var database: CKDatabase { container.privateCloudDatabase } + /// The CloudKit database for the current `databaseScope`. + internal var database: CKDatabase { container.database(with: databaseScope) } /// Creates a new service for the given CloudKit container. /// - Parameter containerIdentifier: The CloudKit container identifier. @@ -58,7 +65,9 @@ self.container = CKContainer(identifier: containerIdentifier) } - /// Apply the editable fields onto a CKRecord. Always refreshes `modified`. + /// Apply the editable fields onto a CKRecord. CloudKit's system metadata + /// (`creationDate`, `modificationDate`) is refreshed by the server on save, + /// so no manual timestamping is needed. private static func apply( title: String, index: Int64, imageURL: URL?, to record: CKRecord ) { @@ -67,9 +76,6 @@ if let imageURL { record[Note.Fields.image] = CKAsset(fileURL: imageURL) } - record[Note.Fields.modified] = NSNumber( - value: Int64(Date().timeIntervalSince1970 * 1_000) - ) } internal func refreshAccountStatus() async { @@ -80,21 +86,37 @@ self.accountStatus = .couldNotDetermine self.lastError = error.localizedDescription } + if accountStatus == .available { + do { + let recordID = try await container.userRecordID() + self.currentUserRecordName = recordID.recordName + } catch { + self.currentUserRecordName = nil + self.lastError = error.localizedDescription + } + } else { + self.currentUserRecordName = nil + } } - /// List all record zones in the private database (parity with `mistdemo lookup-zones`). + /// List all record zones in the selected database (parity with `mistdemo lookup-zones`). internal func loadZones() async throws -> [ZoneRow] { let zones = try await database.allRecordZones() return zones.map(ZoneRow.init).sorted { $0.zoneName < $1.zoneName } } - /// Query `Note` records from the demo container's private database, sorted - /// by `index` (parity with `mistdemo query --record-type Note --sort index`). - /// Note's schema is defined in `schema.ckdb`. + /// Query `Note` records from the selected database, newest first — + /// primary sort on creation date desc, modification date desc as the + /// tiebreaker. Matches the web demo's default sort. + /// Note's schema is defined in `schema.ckdb` (`___createTime` and + /// `___modTime` are both `SORTABLE`). internal func queryNotes(limit: Int = 50) async throws -> [Note] { let predicate = NSPredicate(value: true) let query = CKQuery(recordType: Note.recordType, predicate: predicate) - query.sortDescriptors = [NSSortDescriptor(key: Note.Fields.index, ascending: true)] + query.sortDescriptors = [ + NSSortDescriptor(key: "creationDate", ascending: false), + NSSortDescriptor(key: "modificationDate", ascending: false), + ] let (matchResults, _) = try await database.records( matching: query, @@ -130,20 +152,21 @@ // MARK: - Write operations (parity with `mistdemo create / update / delete`) - /// Create a new Note in the private database. + /// Create a new Note in the selected database. internal func createNote(title: String, index: Int64, imageURL: URL?) async throws -> Note { let record = CKRecord(recordType: Note.recordType) Self.apply(title: title, index: index, imageURL: imageURL, to: record) - record[Note.Fields.createdAt] = Date() as NSDate let saved = try await database.save(record) guard let note = Note(saved) else { - throw NativeCloudKitError.unexpectedSaveResult + throw CloudKitStoreError.unexpectedSaveResult } return note } - /// Update an existing Note. Fetches the current record (so the change tag - /// is fresh), mutates the fields, and saves. + /// Update an existing Note: fetch the underlying record by ID, apply the + /// new field values, and save. The fetch picks up the current change tag + /// so the save is rejected (rather than blindly clobbering) if the record + /// has been modified since the caller read it. internal func updateNote( _ existing: Note, title: String, index: Int64, imageURL: URL? ) async throws -> Note { @@ -152,41 +175,32 @@ Self.apply(title: title, index: index, imageURL: imageURL, to: record) let saved = try await database.save(record) guard let note = Note(saved) else { - throw NativeCloudKitError.unexpectedSaveResult + throw CloudKitStoreError.unexpectedSaveResult } return note } - /// Delete a Note by record name. + /// Delete a Note by record ID. internal func deleteNote(_ note: Note) async throws { - let recordID = CKRecord.ID(recordName: note.id) - _ = try await database.deleteRecord(withID: recordID) + _ = try await database.deleteRecord( + withID: CKRecord.ID(recordName: note.id) + ) } - // MARK: - Web auth token (parity with `mistdemo auth-token`) - - /// Fetch a CloudKit web auth token (the `158__...` value that MistKit / - /// the MistDemo CLI consume). Demonstrates that a native app and a - /// REST-based MistKit consumer can share the same auth surface. - /// - /// `apiToken` is the public CloudKit API token from CloudKit Dashboard, - /// not the user's iCloud password. It must match the configured container. - internal func fetchWebAuthToken(apiToken: String) async throws -> String { + /// Capture a web-auth token via `CKFetchWebAuthTokenOperation` for the + /// given CloudKit API token. Issues the same `158__…` value that + /// MistKit / `mistdemo auth-token` consume. + nonisolated internal func fetchWebAuthToken(apiToken: String) async throws -> String { try await withCheckedThrowingContinuation { continuation in let operation = CKFetchWebAuthTokenOperation(apiToken: apiToken) operation.qualityOfService = .userInitiated - operation.fetchWebAuthTokenCompletionBlock = { token, error in - if let token { - continuation.resume(returning: token) - } else { - continuation.resume( - throwing: error ?? NativeCloudKitError.webAuthTokenUnavailable - ) - } + operation.fetchWebAuthTokenResultBlock = { result in + continuation.resume(with: result) } - // CKFetchWebAuthTokenOperation is a CKDatabaseOperation; running - // it against the private database picks up the demo container. - database.add(operation) + // CKFetchWebAuthTokenOperation must run against the private database + // regardless of the user's scope selection — running it on the public + // database fails or returns an unattributed token. + container.privateCloudDatabase.add(operation) } } } diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitError.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift similarity index 84% rename from Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitError.swift rename to Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift index 2925516d..8e334fd6 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitError.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift @@ -1,5 +1,5 @@ // -// NativeCloudKitError.swift +// CloudKitStoreError.swift // MistDemo // // Created by Leo Dion. @@ -30,17 +30,14 @@ #if canImport(CloudKit) && !os(tvOS) && !os(watchOS) import Foundation - /// Errors specific to native CloudKit operations. - internal enum NativeCloudKitError: Error, LocalizedError { + /// Errors specific to `CloudKitStore` operations. + internal enum CloudKitStoreError: Error, LocalizedError { case unexpectedSaveResult - case webAuthTokenUnavailable internal var errorDescription: String? { switch self { case .unexpectedSaveResult: return "CloudKit returned a record that couldn't be parsed as a Note." - case .webAuthTokenUnavailable: - return "CloudKit returned no web auth token and no error." } } } diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift index a3f9e568..eb052668 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift @@ -37,7 +37,9 @@ import UIKit #endif - /// View for managing the iCloud account and web auth token. + /// View showing the iCloud account status, the public/private database + /// selector, and a web-auth-token capture flow that mirrors + /// `mistdemo auth-token`. internal struct AccountView: View { /// Where the current `apiToken` value came from on this launch. internal enum TokenSource { @@ -48,7 +50,7 @@ /// Env var name the MistDemo CLI also reads. internal static let envVarName = "CLOUDKIT_API_TOKEN" - @EnvironmentObject internal var service: NativeCloudKitService + @Environment(CloudKitStore.self) internal var service @AppStorage("MistDemoApp.cloudKitApiToken") internal var apiToken: String = "" @State internal var webAuthToken: String? @State internal var fetchingWebAuthToken = false @@ -56,8 +58,17 @@ @State internal var tokenSource: TokenSource = .manual internal var body: some View { + @Bindable var bindable = service Form { - containerSection + Section("Container") { + LabeledContent("Container", value: service.containerIdentifier) + Picker("Database", selection: $bindable.databaseScope) { + ForEach(CKDatabase.Scope.selectable, id: \.self) { scope in + Text(scope.label).tag(scope) + } + } + LabeledContent("iCloud Status", value: statusLabel) + } webAuthTokenSection if let error = service.lastError { Section("Last Service Error") { @@ -98,14 +109,6 @@ } } - private var containerSection: some View { - Section("Container") { - LabeledContent("Container", value: service.containerIdentifier) - LabeledContent("Database", value: "Private") - LabeledContent("iCloud Status", value: statusLabel) - } - } - private var webAuthTokenSection: some View { Section { tokenTextField diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/NoteEditView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/NoteEditView.swift index f9d607be..f285f4ac 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/NoteEditView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/NoteEditView.swift @@ -42,7 +42,7 @@ internal let mode: Mode internal let onSaved: (Note) -> Void - @EnvironmentObject private var service: NativeCloudKitService + @Environment(CloudKitStore.self) private var service @Environment(\.dismiss) private var dismiss @State private var title: String = "" diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift index 154acf90..fc88696d 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift @@ -32,7 +32,7 @@ /// View for querying Note records from CloudKit. internal struct QueryView: View { - @EnvironmentObject private var service: NativeCloudKitService + @Environment(CloudKitStore.self) private var service @State private var limit: Int = 50 @State private var notes: [Note] = [] @State private var loading = false @@ -69,16 +69,21 @@ List(notes, selection: $selectedNote) { note in NavigationLink(value: note) { VStack(alignment: .leading, spacing: 2) { - Text(note.title ?? note.id).font(.body) + HStack(spacing: 8) { + Text(note.title ?? note.id).font(.body) + if isOwnedByCurrentUser(note) { + ownerBadge(creator: note.creatorUserRecordName) + } + } HStack(spacing: 12) { if let index = note.index { Label("\(index)", systemImage: "number") .font(.caption) .foregroundStyle(.secondary) } - if let createdAt = note.createdAt { + if let creationDate = note.creationDate { Label( - createdAt.formatted(date: .abbreviated, time: .omitted), + creationDate.formatted(date: .abbreviated, time: .omitted), systemImage: "calendar" ) .font(.caption) @@ -98,7 +103,11 @@ .navigationDestination(for: Note.self) { note in RecordDetailView(note: note, onChange: { Task { await runQuery() } }) } - .navigationTitle("Notes") + .navigationTitle("Notes — \(service.databaseScope.label)") + .onChange(of: service.databaseScope) { _, _ in + notes = [] + Task { await runQuery() } + } .toolbar { ToolbarItem { Button { @@ -112,7 +121,7 @@ NoteEditView(mode: .create) { _ in Task { await runQuery() } } - .environmentObject(service) + .environment(service) } } @@ -132,6 +141,26 @@ } } + /// Mirrors the web demo's "You" badge — flag notes the signed-in user + /// created. CloudKit may stamp the creator as `__defaultOwner__` for + /// records the caller just created, so accept that sentinel as well. + private func isOwnedByCurrentUser(_ note: Note) -> Bool { + guard let creator = note.creatorUserRecordName else { return false } + if creator == "__defaultOwner__" { return true } + return creator == service.currentUserRecordName + } + + private func ownerBadge(creator: String?) -> some View { + Text("You") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.green.opacity(0.2), in: Capsule()) + .foregroundStyle(.green) + .accessibilityLabel("Created by you") + .help(creator.map { "Created by \($0)" } ?? "Created by you") + } + private func runQuery() async { loading = true loadError = nil diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift index 9077f6b7..d3cb9afb 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift @@ -35,7 +35,7 @@ @State internal var note: Note internal let onChange: () -> Void - @EnvironmentObject private var service: NativeCloudKitService + @Environment(CloudKitStore.self) private var service @Environment(\.dismiss) private var dismiss @State private var showEditSheet = false @@ -78,7 +78,7 @@ note = updated onChange() } - .environmentObject(service) + .environment(service) } .confirmationDialog( "Delete \(note.title ?? note.id)?", @@ -124,13 +124,6 @@ Section("Note Fields") { LabeledContent("title", value: note.title ?? "—") LabeledContent("index", value: note.index.map(String.init) ?? "—") - LabeledContent( - "createdAt", - value: note.createdAt?.formatted( - date: .abbreviated, time: .standard - ) ?? "—" - ) - LabeledContent("modified", value: note.modified.map(String.init) ?? "—") LabeledContent( "image", value: note.imageAssetURL?.lastPathComponent ?? "—" diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/RootView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/RootView.swift index 9178baea..e44fc969 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/RootView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/RootView.swift @@ -32,7 +32,7 @@ /// Root view hosting the navigation split between sidebar and detail. public struct RootView: View { - @EnvironmentObject private var service: NativeCloudKitService + @Environment(CloudKitStore.self) private var service @State private var selection: SidebarItem? = .account /// The view body. diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/ZoneListView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/ZoneListView.swift index 498a32de..ceca163a 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/ZoneListView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/ZoneListView.swift @@ -32,7 +32,7 @@ /// View listing all CloudKit record zones. internal struct ZoneListView: View { - @EnvironmentObject private var service: NativeCloudKitService + @Environment(CloudKitStore.self) private var service @State private var zones: [ZoneRow] = [] @State private var loading = false @State private var loadError: String? @@ -67,13 +67,17 @@ } } } - .navigationTitle("Zones") + .navigationTitle("Zones — \(service.databaseScope.label)") .toolbar { ToolbarItem { Button("Refresh") { Task { await refresh() } } } } .task { await refresh() } + .onChange(of: service.databaseScope) { _, _ in + zones = [] + Task { await refresh() } + } } private func refresh() async { diff --git a/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift b/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift index 31de7cac..b3b6d955 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift @@ -66,7 +66,7 @@ public struct MistKitClientFactory: Sendable { ) #else if config.badCredentials { - guard config.database != .public else { + if case .public = config.database { throw ConfigurationError.badCredentialsOnPublicDB } return try create(from: config, tokenManager: makeBadCredentialsTokenManager()) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand+Routes.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand+Routes.swift deleted file mode 100644 index 094143c5..00000000 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand+Routes.swift +++ /dev/null @@ -1,157 +0,0 @@ -// -// AuthTokenCommand+Routes.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -#if canImport(Hummingbird) - import AsyncAlgorithms - import Foundation - import HTTPTypes - import Hummingbird - import Logging - import MistKit - - extension AuthTokenCommand { - fileprivate struct CloudKitClientConfig: Encodable { - let apiToken: String - let containerIdentifier: String - } - - internal func buildRouter( - tokenChannel: AsyncChannel, - responseCompleteChannel: AsyncChannel - ) throws -> Router { - let router = Router(context: BasicRequestContext.self) - router.middlewares.add(LogRequestsMiddleware(.info)) - - let indexBytes = ByteBuffer( - string: AuthTokenIndexHTML.content - ) - let indexResponseBuilder: @Sendable () -> Response = { - Response( - status: .ok, - headers: [ - .contentType: "text/html; charset=utf-8" - ], - body: ResponseBody { writer in - try await writer.write(indexBytes) - try await writer.finish(nil) - } - ) - } - router.get("/") { _, _ -> Response in - indexResponseBuilder() - } - router.get("/index.html") { _, _ -> Response in - indexResponseBuilder() - } - - let api = router.group("api") - - let configPayload = CloudKitClientConfig( - apiToken: config.apiToken, - containerIdentifier: config.containerIdentifier - ) - let configData = try JSONEncoder().encode( - configPayload - ) - - addConfigEndpoint( - api: api, configData: configData - ) - addAuthEndpoint( - api: api, - tokenChannel: tokenChannel, - responseCompleteChannel: responseCompleteChannel - ) - - return router - } - - internal func addConfigEndpoint( - api: RouterGroup, - configData: Data - ) { - api.get("config") { request, _ -> Response in - let authority = request.head.authority ?? "" - guard Self.isLoopbackAuthority(authority) else { - return Response(status: .forbidden) - } - return Response( - status: .ok, - headers: [.contentType: "application/json"], - body: ResponseBody { writer in - try await writer.write( - ByteBuffer(bytes: configData) - ) - try await writer.finish(nil) - } - ) - } - } - - internal func addAuthEndpoint( - api: RouterGroup, - tokenChannel: AsyncChannel, - responseCompleteChannel: AsyncChannel - ) { - api.post("authenticate") { - request, context -> Response in - let authRequest = try await request.decode( - as: AuthRequest.self, context: context - ) - await tokenChannel.send(authRequest.sessionToken) - - let response = AuthResponse( - userRecordName: authRequest.userRecordName, - cloudKitData: .init( - user: nil, zones: [], error: nil - ), - message: "Authentication successful!" - ) - - let jsonData = try JSONEncoder().encode(response) - - Task { - try await Task.sleep(nanoseconds: 200_000_000) - await responseCompleteChannel.send(()) - } - - return Response( - status: .ok, - headers: [.contentType: "application/json"], - body: ResponseBody { writer in - try await writer.write( - ByteBuffer(bytes: jsonData) - ) - try await writer.finish(nil) - } - ) - } - } - } -#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift index 1762ccf9..596e65fa 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift @@ -28,12 +28,9 @@ // #if canImport(Hummingbird) - import AsyncAlgorithms - import Foundation - import HTTPTypes - import Hummingbird - import Logging - import MistKit + internal import Foundation + internal import Hummingbird + internal import MistKit /// Command to obtain web authentication token via browser flow. public struct AuthTokenCommand: MistDemoCommand { @@ -53,10 +50,12 @@ mistdemo auth-token [options] OPTIONS: - --api-token CloudKit API token - --port Server port (default: 8080) - --host Server host (default: 127.0.0.1) - --no-browser Don't open browser automatically + --api-token CloudKit API token + --environment development (default) | production + --port Server port (default: 8080) + --host Server host (default: 127.0.0.1) + --browser Open browser on startup (default for auth-token) + --no-browser Don't open browser on startup (overrides --browser) """ internal let config: AuthTokenConfig @@ -66,108 +65,84 @@ self.config = config } - // Exact-match host validation against an allowlist - // after stripping any port. - internal static func isLoopbackAuthority( - _ authority: String - ) -> Bool { - let host: String - if authority.hasPrefix("["), - let endBracket = authority.firstIndex(of: "]") - { - host = String( - authority[authority.startIndex...endBracket] - ) - let afterBracket = - authority[authority.index(after: endBracket)...] - if !afterBracket.isEmpty, - !afterBracket.hasPrefix(":") - { - return false + private static func captureToken( + runService: @escaping @Sendable () async throws -> Void, + tokenStore: WebAuthTokenStore, + host: String, + port: Int, + openBrowser: Bool + ) async throws -> String { + do { + return try await withTimeoutAndSignals(seconds: 300) { + try await withThrowingTaskGroup(of: String?.self) { group in + group.addTask { + try await runService() + return nil + } + group.addTask { + if openBrowser { + try? await Task.sleep(nanoseconds: 1_000_000_000) + BrowserOpener.openBrowser(url: "http://\(host):\(port)") + } + return nil + } + group.addTask { + var iterator = tokenStore.tokenUpdates.makeAsyncIterator() + return await iterator.next() + } + + while let result = try await group.next() { + if let captured = result { + group.cancelAll() + return captured + } + } + throw AuthTokenError.serverError( + "Token capture failed unexpectedly" + ) + } } - } else { - host = String( - authority.split(separator: ":").first - ?? Substring(authority) - ) + } catch let error as AsyncTimeoutError { + throw AuthTokenError.timeout(error.localizedDescription) } - return ["localhost", "127.0.0.1", "[::1]"] - .contains(host) } /// Executes the command. public func execute() async throws { print("📍 Server URL: http://\(config.host):\(config.port)") - let tokenChannel = AsyncChannel() - let responseCompleteChannel = AsyncChannel() - - let router = try buildRouter( - tokenChannel: tokenChannel, - responseCompleteChannel: responseCompleteChannel + let tokenStore = WebAuthTokenStore() + let server = WebServer( + apiToken: config.apiToken, + containerIdentifier: config.containerIdentifier, + environment: config.environment, + publicDatabaseAvailable: false, + tokenStore: tokenStore, + backendFactory: .live( + apiToken: config.apiToken, + containerIdentifier: config.containerIdentifier, + environment: config.environment + ), + terminatesAfterAuth: true ) - let app = Application( - router: router, + router: try server.makeRouter(), configuration: .init( - address: .hostname( - config.host, port: config.port - ) + address: .hostname(config.host, port: config.port) ) ) - let serverTask = Task { try await app.runService() } - - openBrowserIfNeeded() - let token = try await waitForToken( - channel: tokenChannel, serverTask: serverTask + let token = try await Self.captureToken( + runService: { try await app.runService() }, + tokenStore: tokenStore, + host: config.host, + port: config.port, + openBrowser: config.openBrowser ) - var responseIterator = - responseCompleteChannel.makeAsyncIterator() - _ = await responseIterator.next() - - serverTask.cancel() - try await Task.sleep(nanoseconds: 500_000_000) + // Let the 205 response reach the browser before the process exits. + try? await Task.sleep(nanoseconds: 500_000_000) print(token) } - - private func openBrowserIfNeeded() { - if !config.noBrowser { - Task { - try await Task.sleep(nanoseconds: 1_000_000_000) - BrowserOpener.openBrowser( - url: "http://\(config.host):\(config.port)" - ) - } - } - } - - private func waitForToken( - channel: AsyncChannel, - serverTask: Task - ) async throws -> String { - do { - return try await withTimeoutAndSignals( - seconds: 300 - ) { - var iterator = channel.makeAsyncIterator() - guard let value = await iterator.next() else { - throw AuthTokenError.serverError( - "Token channel closed" - ) - } - return value - } - } catch let error as AsyncTimeoutError { - serverTask.cancel() - throw AuthTokenError.timeout( - error.localizedDescription - ) - } catch { - serverTask.cancel() - throw error - } - } } #endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenIndexHTML+ScriptAuth.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenIndexHTML+ScriptAuth.swift deleted file mode 100644 index 74e89a6b..00000000 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenIndexHTML+ScriptAuth.swift +++ /dev/null @@ -1,162 +0,0 @@ -// -// AuthTokenIndexHTML+ScriptAuth.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -#if canImport(Hummingbird) - // swiftlint:disable indentation_width - extension AuthTokenIndexHTML { - /// JS for the auth flow: setup, sign-in handlers, manual token paste, - /// and the sign-in-state UI helper. - internal static let scriptAuth: String = #""" - async function loadServerConfig() { - const response = await fetch('/api/config'); - if (!response.ok) throw new Error('Failed to load server config: ' + response.status); - return response.json(); - } - - let container = null; - const statusDiv = document.getElementById('status'); - const userInfoDiv = document.getElementById('user-info'); - const signinButton = document.getElementById('signin-button'); - const signoutButton = document.getElementById('signout-button'); - const loadingDiv = document.getElementById('loading'); - - // Store the web auth token when received - let webAuthToken = null; - let currentUserIdentity = null; - let tokenPromiseResolve = null; - let tokenPromiseReject = null; - - // Track if authentication is already in progress - let authenticationInProgress = false; - - function showStatus(message, isError = false) { - statusDiv.className = 'status ' + (isError ? 'error' : 'success'); - statusDiv.textContent = message; - statusDiv.style.display = 'block'; - } - - function showLoading(show) { - loadingDiv.style.display = show ? 'block' : 'none'; - } - - async function handleAuthentication(userIdentity) { - console.log('=== Authentication Successful ==='); - console.log('User Identity:', userIdentity); - currentUserIdentity = userIdentity; - authenticationInProgress = false; - - // Update UI - showStatus('Signed in successfully! Waiting for web auth token...', false); - updateSignInState(true); - - // Poll container._auth._ckSession — populated by CloudKit JS itself. - const tokenPromise = new Promise((resolve, reject) => { - tokenPromiseResolve = resolve; - tokenPromiseReject = reject; - - // Poll the CloudKit JS auth object for its session token. - const pollIntervalMs = 250; - const pollDeadlineMs = 10_000; - const pollStart = Date.now(); - const pollHandle = setInterval(() => { - const sessionToken = container?._auth?._ckSession; - if (sessionToken) { - clearInterval(pollHandle); - console.log('✅ Token captured from container._auth._ckSession (poll)'); - webAuthToken = sessionToken; - window.cloudKitWebAuthToken = sessionToken; - if (tokenPromiseResolve) { - tokenPromiseResolve(sessionToken); - tokenPromiseResolve = null; - tokenPromiseReject = null; - } - return; - } - if (Date.now() - pollStart >= pollDeadlineMs) { - clearInterval(pollHandle); - } - }, pollIntervalMs); - - setTimeout(() => { - clearInterval(pollHandle); - reject(new Error('Timeout waiting for web auth token after 10 seconds')); - }, pollDeadlineMs); - }); - - try { - const token = await tokenPromise; - console.log('✅ Token received, sending to server...'); - await handleAuthenticationWithToken(userIdentity, token); - } catch (error) { - console.error('Token wait timeout or error:', error); - showStatus('Automatic token capture failed. Paste the token manually below.', true); - showManualTokenForm(userIdentity); - } - } - - // Surface the manual-paste form when automatic capture has failed. - function showManualTokenForm(userIdentity) { - const form = document.getElementById('manual-token-form'); - const input = document.getElementById('manual-token-input'); - const submit = document.getElementById('manual-token-submit'); - if (!form || !input || !submit) return; - - form.style.display = 'block'; - input.value = ''; - input.focus(); - - const handler = async () => { - const token = input.value.trim(); - if (!token) { - showStatus('Please paste a token first.', true); - return; - } - form.style.display = 'none'; - webAuthToken = token; - window.cloudKitWebAuthToken = token; - authenticationInProgress = true; - await handleAuthenticationWithToken(userIdentity, token); - }; - - // Replace any prior listeners by cloning the button (idempotent across timeouts) - const cloned = submit.cloneNode(true); - submit.parentNode.replaceChild(cloned, submit); - cloned.addEventListener('click', handler); - input.addEventListener('keydown', (event) => { - if (event.key === 'Enter') handler(); - }); - } - - function updateSignInState(isSignedIn) { - signoutButton.style.display = isSignedIn ? 'inline-block' : 'none'; - } - """# - } -// swiftlint:enable indentation_width -#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenIndexHTML+ScriptDisplay.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenIndexHTML+ScriptDisplay.swift deleted file mode 100644 index 5ee0c56c..00000000 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenIndexHTML+ScriptDisplay.swift +++ /dev/null @@ -1,152 +0,0 @@ -// -// AuthTokenIndexHTML+ScriptDisplay.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -#if canImport(Hummingbird) - // swiftlint:disable line_length indentation_width - extension AuthTokenIndexHTML { - /// JS for token-based authentication, user info display, and clipboard - /// helpers. - internal static let scriptDisplay: String = #""" - async function handleAuthenticationWithToken(userIdentity, token) { - try { - console.log('Starting authentication with token...'); - showLoading(true); - statusDiv.style.display = 'none'; - userInfoDiv.innerHTML = ''; - - if (userIdentity && token) { - showStatus('Successfully authenticated with web token!'); - - // Show sign out button - signoutButton.style.display = 'inline-block'; - - console.log('User Identity:', userIdentity); - console.log('Web Auth Token:', token); - - // Send token to our server - const response = await fetch('/api/authenticate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - sessionToken: token, - userRecordName: userIdentity.userRecordName - }) - }); - - if (response.ok) { - const data = await response.json(); - displayUserInfo(data); - } else { - const errorText = await response.text(); - throw new Error(`Server authentication failed: ${errorText}`); - } - } else { - throw new Error('Missing user identity or authentication token'); - } - } catch (error) { - showStatus('Authentication failed: ' + error.message, true); - console.error('Authentication error:', error); - } finally { - showLoading(false); - authenticationInProgress = false; // Reset the flag - } - } - - function displayUserInfo(data) { - let html = ''; - - // Display web auth token prominently - if (webAuthToken) { - html += ` -
-

Web Auth Token

-

Use this token for command-line CloudKit API access:

-
${webAuthToken}
- -
- `; - } - - html += ``; - - userInfoDiv.innerHTML = html; - } - - function copyToken() { - const tokenValue = document.getElementById('token-value').textContent; - navigator.clipboard.writeText(tokenValue).then(() => { - const button = document.querySelector('.copy-button'); - const originalText = button.textContent; - button.textContent = 'Copied!'; - setTimeout(() => { - button.textContent = originalText; - }, 2000); - }); - } - """# - } -// swiftlint:enable line_length indentation_width -#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenIndexHTML+ScriptInit.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenIndexHTML+ScriptInit.swift deleted file mode 100644 index 242cd124..00000000 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenIndexHTML+ScriptInit.swift +++ /dev/null @@ -1,217 +0,0 @@ -// -// AuthTokenIndexHTML+ScriptInit.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -#if canImport(Hummingbird) - // swiftlint:disable line_length indentation_width - extension AuthTokenIndexHTML { - /// JS for sign-out, CloudKit container initialization, and dev-only - /// debug helpers exposed via `window.mistKitDebug`. - internal static let scriptInit: String = #""" - - // Sign out functionality - async function signOutUser() { - try { - console.log('Signing out user...'); - await container.signOut(); - - // Clear application state - webAuthToken = null; - currentUserIdentity = null; - authenticationInProgress = false; - - // Update UI - showStatus('Signed out successfully.'); - userInfoDiv.innerHTML = ''; - signoutButton.style.display = 'none'; - - // Clear any CloudKit cookies - const cookies = document.cookie.split(';'); - for (const cookie of cookies) { - const [name] = cookie.trim().split('='); - if (name && (name.includes('cloudkit') || name.includes('ck') || name.includes('iCloud'))) { - document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; - console.log('Cleared cookie:', name); - } - } - - console.log('Sign out complete'); - } catch (error) { - console.error('Sign out error:', error); - showStatus('Sign out failed: ' + error.message, true); - } - } - - // Add sign out button event listener - signoutButton.addEventListener('click', signOutUser); - - // Initialize CloudKit authentication - async function initializeCloudKit() { - try { - // Check if CloudKit is properly loaded - if (typeof CloudKit === 'undefined') { - throw new Error('CloudKit.js failed to load'); - } - - const serverConfig = await loadServerConfig(); - console.log('Initializing CloudKit with container:', serverConfig.containerIdentifier); - - CloudKit.configure({ - containers: [{ - containerIdentifier: serverConfig.containerIdentifier, - apiTokenAuth: { - apiToken: serverConfig.apiToken, - persist: true, - signInButton: { - id: 'signin-button', - theme: 'black' - } - }, - environment: 'development' - }] - }); - console.log('CloudKit configured successfully'); - container = CloudKit.getDefaultContainer(); - - // Debug: Check authentication state before setUpAuth - console.log('Container auth state before setup:', container._auth); - - // Set up authentication and check if user is already signed in - const userIdentity = await container.setUpAuth(); - - // Debug: Check authentication state after setUpAuth - console.log('Container auth state after setup:', container._auth); - console.log('User identity from setUpAuth:', userIdentity); - - // Check if we have the session token directly from the auth object - const sessionToken = container._auth?._ckSession; - console.log('Session token from auth:', sessionToken); - - if (userIdentity) { - // User is already signed in - showStatus('Already signed in. Processing authentication...'); - - // If we have the session token, use it directly - if (sessionToken && !authenticationInProgress) { - console.log('Using session token from container._auth._ckSession'); - webAuthToken = sessionToken; - authenticationInProgress = true; - await handleAuthenticationWithToken(userIdentity, sessionToken); - } else { - await handleAuthentication(userIdentity); - } - } else { - // User is not signed in, wait for sign-in - showStatus('Please click "Sign In with Apple ID" to authenticate.'); - } - - // Set up event handlers for sign-in and sign-out - container.whenUserSignsIn().then(async (userIdentity) => { - console.log('User signed in:', userIdentity); - await handleAuthentication(userIdentity); - }); - - container.whenUserSignsOut().then(() => { - console.log('User signed out'); - showStatus('Signed out successfully.'); - userInfoDiv.innerHTML = ''; - signoutButton.style.display = 'none'; - }); - - } catch (error) { - console.error('CloudKit setup error:', error); - if (error.message && error.message.includes('421')) { - showStatus('CloudKit container setup issue. Check CloudKit Console for: 1) Container exists 2) Development environment enabled 3) Web services configured', true); - } else { - showStatus('CloudKit setup failed: ' + error.message, true); - } - } - } - - // Add error handling for CloudKit - window.addEventListener('error', function(event) { - console.log('Global error:', event.error, event.filename, event.lineno); - }); - - // Initialize CloudKit when page loads - initializeCloudKit(); - - // Expose debugging helpers on localhost only - if (['localhost', '127.0.0.1'].includes(window.location.hostname)) { - window.mistKitDebug = { - container: () => CloudKit.getDefaultContainer(), - token: () => window.cloudKitWebAuthToken || webAuthToken, - setToken: (token) => { - window.cloudKitWebAuthToken = token; - webAuthToken = token; - console.log('Token manually set'); - }, - sendToServer: () => { - const container = CloudKit.getDefaultContainer(); - if (container && container.userIdentity) { - handleAuthenticationWithToken(container.userIdentity, window.cloudKitWebAuthToken || webAuthToken); - } else { - console.error('Not signed in'); - } - }, - inspectContainer: () => { - const container = CloudKit.getDefaultContainer(); - console.log('Container:', container); - console.log('Container properties:', Object.keys(container)); - console.log('User identity:', container.userIdentity); - - // Try to find token in various places - const locations = { - 'session.webAuthToken': container.session?.webAuthToken, - '_auth.webAuthToken': container._auth?.webAuthToken, - '_auth._ckSession': container._auth?._ckSession, - 'window.cloudKitWebAuthToken': window.cloudKitWebAuthToken, - 'webAuthToken variable': webAuthToken - }; - - console.log('Checked token locations:', locations); - - for (const [path, value] of Object.entries(locations)) { - if (value) { - console.log(`✅ Found at ${path}:`, value); - } - } - } - }; - - console.log('MistKit Debug helpers available:'); - console.log(' mistKitDebug.container() - Get CloudKit container'); - console.log(' mistKitDebug.token() - Get current token'); - console.log(' mistKitDebug.setToken(tok) - Manually set token'); - console.log(' mistKitDebug.sendToServer() - Send token to server'); - console.log(' mistKitDebug.inspectContainer() - Inspect container for token'); - } - """# - } -// swiftlint:enable line_length indentation_width -#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenIndexHTML.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenIndexHTML.swift deleted file mode 100644 index ede31a84..00000000 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenIndexHTML.swift +++ /dev/null @@ -1,202 +0,0 @@ -// -// AuthTokenIndexHTML.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -#if canImport(Hummingbird) - // swiftlint:disable line_length indentation_width - /// Inlined CloudKit auth-flow page served by `AuthTokenCommand`. - /// - /// Held here as a Swift raw string so MistDemoKit doesn't need a SwiftPM resource - /// bundle — that bundle would fail iOS-family CodeSign in CI even though the - /// auth-token CLI flow only runs on macOS / Linux. - internal enum AuthTokenIndexHTML { - internal static let content: String = #""" - - - - - - MistKit CloudKit Authentication Example - - - - -
-

MistKit CloudKit Example

-

Sign in with your Apple ID to test CloudKit Web Services authentication and API access.

- -
- - -
Authenticating...
-
- - - - -
-
- - - - - """# - } -// swiftlint:enable line_length indentation_width -#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift index 87ff9012..cd31f8fd 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift @@ -84,8 +84,9 @@ public struct CreateCommand: MistDemoCommand, OutputFormatting { let recordInfo = try await client.createRecord( recordType: config.recordType, recordName: recordName, - fields: cloudKitFields - // Zone: config.zone - to be added when CloudKitService supports it + fields: cloudKitFields, + // Zone: config.zone - to be added when CloudKitService supports it + database: config.base.database ) // Format and output result diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift index 373512e3..94bf05f3 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift @@ -90,7 +90,8 @@ public struct DeleteCommand: MistDemoCommand, OutputFormatting { try await client.deleteRecord( recordType: config.recordType, recordName: config.recordName, - recordChangeTag: effectiveChangeTag + recordChangeTag: effectiveChangeTag, + database: config.base.database ) let result = DeleteResult( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift index 69301727..17fb2b2b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift @@ -36,7 +36,7 @@ extension DemoErrorsRunner { print("🛑 CloudKit Error Demo — typed CloudKitError handling") print(String(repeating: "=", count: 80)) print("Container: \(config.containerIdentifier)") - print("Database: \(config.database.rawValue)") + print("Database: \(config.database.pathSegment)") print(String(repeating: "=", count: 80)) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift index f096d047..ce259489 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift @@ -145,7 +145,8 @@ internal struct DemoErrorsRunner { let created = try await service.createRecord( recordType: Self.conflictRecordType, recordName: recordName, - fields: ["title": .string("original")] + fields: ["title": .string("original")], + database: config.database ) createdRecordName = created.recordName staleTag = created.recordChangeTag @@ -160,7 +161,8 @@ internal struct DemoErrorsRunner { recordType: Self.conflictRecordType, recordName: recordName, fields: ["title": .string("first-update")], - recordChangeTag: staleTag + recordChangeTag: staleTag, + database: config.database ) } catch { print("❌ Setup update failed: \(error)") @@ -173,7 +175,8 @@ internal struct DemoErrorsRunner { recordType: Self.conflictRecordType, recordName: recordName, fields: ["title": .string("second-update-stale")], - recordChangeTag: staleTag + recordChangeTag: staleTag, + database: config.database ) print("⚠️ Expected 409 but update was accepted.") } catch { @@ -198,7 +201,8 @@ internal struct DemoErrorsRunner { do { try await service.deleteRecord( recordType: Self.conflictRecordType, - recordName: createdRecordName + recordName: createdRecordName, + database: config.database ) print(" ✅ Deleted.") } catch { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift index 09c3b99c..7c3a32e6 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift @@ -106,7 +106,8 @@ public struct DemoInFilterCommand: MistDemoCommand { fields: [ "title": .string("demo-in-filter-\(tag)-idx\(idx)"), "index": .int64(idx), - ] + ], + database: config.database ) createdNames.append(record.recordName) print(" Created \(record.recordName) (index=\(idx))") @@ -122,7 +123,9 @@ public struct DemoInFilterCommand: MistDemoCommand { ) async throws { print("\nVerifying records are queryable...") let allRecords = try await client.queryRecords( - recordType: recordType, limit: 200 + recordType: recordType, + limit: 200, + database: config.database ) let visible = allRecords.filter { createdNames.contains($0.recordName) @@ -136,7 +139,8 @@ public struct DemoInFilterCommand: MistDemoCommand { let results = try await client.queryRecords( recordType: recordType, filters: [.in("index", [.int64(10), .int64(30)])], - limit: 200 + limit: 200, + database: config.database ) let matching = results.filter { @@ -163,7 +167,10 @@ public struct DemoInFilterCommand: MistDemoCommand { recordType: recordType, recordName: name ) - _ = try await client.modifyRecords([operation]) + _ = try await client.modifyRecords( + [operation], + database: config.database + ) print(" Deleted \(name)") } print("Done.") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift index e97c39d4..5f50cdeb 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift @@ -108,7 +108,8 @@ public struct FetchChangesCommand: MistDemoCommand, OutputFormatting { print("\n📦 Fetching all changes (automatic pagination)...") let (records, newToken) = try await service.fetchAllRecordChanges( zoneID: zoneID, - syncToken: config.syncToken + syncToken: config.syncToken, + database: config.base.database ) print("\n✅ Fetched \(records.count) record(s)") displayRecords(records, limit: 5) @@ -125,7 +126,8 @@ public struct FetchChangesCommand: MistDemoCommand, OutputFormatting { let result = try await service.fetchRecordChanges( zoneID: zoneID, syncToken: config.syncToken, - resultsLimit: config.limit ?? 10 + resultsLimit: config.limit ?? 10, + database: config.base.database ) print("\n✅ Fetched \(result.records.count) record(s)") displayRecords(result.records, limit: 5) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupCommand.swift index bd674267..6871ebf5 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupCommand.swift @@ -78,7 +78,8 @@ public struct LookupCommand: MistDemoCommand, OutputFormatting { let records = try await client.lookupRecords( recordNames: config.recordNames, - desiredKeys: config.fields + desiredKeys: config.fields, + database: config.base.database ) // Report missing names to stderr so a JSON/CSV/etc. stdout stream stays parseable diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift index b25ab501..8d11c206 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift @@ -80,7 +80,9 @@ public struct ModifyCommand: MistDemoCommand, OutputFormatting { } let results = try await client.modifyRecords( - operations, atomic: config.atomic + operations, + atomic: config.atomic, + database: config.base.database ) let rows = results.map { record in diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift index 9bd520d5..4d0a9ea2 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift @@ -81,14 +81,16 @@ public struct QueryCommand: MistDemoCommand, OutputFormatting { recordType: config.recordType, filters: filters, sortBy: nil, - limit: config.limit + limit: config.limit, + database: config.base.database ) } else { recordInfos = try await client.queryRecords( recordType: config.recordType, filters: nil, sortBy: nil, - limit: config.limit + limit: config.limit, + database: config.base.database ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift index 44b6ea03..b3b701c9 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift @@ -55,10 +55,10 @@ public struct TestPrivateCommand: MistDemoCommand { --asset-size Asset size in KB (default: 100) --skip-cleanup Skip cleanup after test --verbose Run in verbose mode - --lookup-email Email for users/lookup/email phase - (CLOUDKIT_LOOKUP_EMAIL); must belong - to an iCloud account discoverable to - the caller, otherwise the phase skips + --lookup-email + Email for users/lookup/email phase (CLOUDKIT_LOOKUP_EMAIL). + Must belong to an iCloud account discoverable to the caller, + otherwise the phase skips. EXAMPLES: mistdemo test-private --verbose @@ -68,7 +68,7 @@ public struct TestPrivateCommand: MistDemoCommand { NOTES: - Requires CLOUDKIT_API_TOKEN and CLOUDKIT_WEB_AUTH_TOKEN - - Use 'test-integration' for public-database tests + - Use 'test-public' for public-database tests """ private let config: TestPrivateConfig diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestIntegrationCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPublicCommand.swift similarity index 79% rename from Examples/MistDemo/Sources/MistDemoKit/Commands/TestIntegrationCommand.swift rename to Examples/MistDemo/Sources/MistDemoKit/Commands/TestPublicCommand.swift index 8c849d6a..408114b8 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestIntegrationCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPublicCommand.swift @@ -1,5 +1,5 @@ // -// TestIntegrationCommand.swift +// TestPublicCommand.swift // MistDemo // // Created by Leo Dion. @@ -31,23 +31,23 @@ import Foundation import MistKit /// Command to run comprehensive integration tests for all CloudKit operations -public struct TestIntegrationCommand: MistDemoCommand { +public struct TestPublicCommand: MistDemoCommand { /// The configuration type. - public typealias Config = TestIntegrationConfig + public typealias Config = TestPublicConfig /// The command name. - public static let commandName = "test-integration" + public static let commandName = "test-public" /// The command abstract. public static let abstract = "Run integration tests for all CloudKit operations" /// The command help text. public static let helpText = """ - TEST-INTEGRATION - Integration tests (public database) + TEST-PUBLIC - Integration tests (public database) Tests all non-user-scoped CloudKit API methods against the public database. Use 'test-private' for user APIs. USAGE: - mistdemo test-integration [options] + mistdemo test-public [options] OPTIONS: --database Database (default: public) @@ -55,25 +55,25 @@ public struct TestIntegrationCommand: MistDemoCommand { --asset-size Asset size in KB (default: 100) --skip-cleanup Skip cleanup after test --verbose Run in verbose mode - --lookup-email Email for users/lookup/email phase - (CLOUDKIT_LOOKUP_EMAIL); must belong - to an iCloud account discoverable to - the caller, otherwise the phase skips + --lookup-email + Email for users/lookup/email phase (CLOUDKIT_LOOKUP_EMAIL). + Must belong to an iCloud account discoverable to the caller, + otherwise the phase skips. EXAMPLES: - mistdemo test-integration --verbose - mistdemo test-integration --skip-cleanup --verbose - mistdemo test-integration --lookup-email me@example.com + mistdemo test-public --verbose + mistdemo test-public --skip-cleanup --verbose + mistdemo test-public --lookup-email me@example.com NOTES: - Requires CLOUDKIT_KEY_ID and CLOUDKIT_PRIVATE_KEY - Use 'test-private' for user-identity coverage """ - private let config: TestIntegrationConfig + private let config: TestPublicConfig /// Creates a new instance. - public init(config: TestIntegrationConfig) { + public init(config: TestPublicConfig) { self.config = config } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift index 6fd3b5de..eabce926 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift @@ -106,7 +106,8 @@ public struct UpdateCommand: MistDemoCommand, OutputFormatting { recordType: config.recordType, recordName: config.recordName, fields: cloudKitFields, - recordChangeTag: effectiveChangeTag + recordChangeTag: effectiveChangeTag, + database: config.base.database ) try await outputResult(recordInfo, format: config.output) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/UploadAssetCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/UploadAssetCommand.swift index 7d98c7ce..84c0b683 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/UploadAssetCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/UploadAssetCommand.swift @@ -137,7 +137,8 @@ public struct UploadAssetCommand: MistDemoCommand, OutputFormatting { data: data, recordType: config.recordType, fieldName: config.fieldName, - recordName: config.recordName + recordName: config.recordName, + database: config.base.database ) print("\n✅ Asset uploaded!") print(" Record Name: \(result.recordName)") @@ -186,7 +187,8 @@ public struct UploadAssetCommand: MistDemoCommand, OutputFormatting { return try await service.createRecord( recordType: config.recordType, recordName: newRecordName, - fields: fields + fields: fields, + database: config.base.database ) } } @@ -197,7 +199,8 @@ public struct UploadAssetCommand: MistDemoCommand, OutputFormatting { service: CloudKitService ) async throws -> RecordInfo { let existingRecords = try await service.lookupRecords( - recordNames: [recordName] + recordNames: [recordName], + database: config.base.database ) guard let existingRecord = existingRecords.first else { throw UploadAssetError.operationFailed( @@ -208,7 +211,8 @@ public struct UploadAssetCommand: MistDemoCommand, OutputFormatting { recordType: config.recordType, recordName: recordName, fields: fields, - recordChangeTag: existingRecord.recordChangeTag + recordChangeTag: existingRecord.recordChangeTag, + database: config.base.database ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift new file mode 100644 index 00000000..abb63ace --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift @@ -0,0 +1,183 @@ +// +// WebCommand.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + internal import Foundation + internal import Hummingbird + internal import MistKit + + /// Long-running interactive web demo: serves a single HTML page that + /// performs the CloudKit auth round trip and then exposes a CRUD UI + /// driven by MistKit on the server. + /// + /// Unlike `AuthTokenCommand`, this command does not exit after the + /// browser-side auth completes — the server keeps running so the user + /// can exercise the CRUD endpoints until they Ctrl+C. + public struct WebCommand: MistDemoCommand { + /// The configuration type. + public typealias Config = WebConfig + + /// The command name. + public static let commandName = "web" + /// The command abstract. + public static let abstract = + "Run the interactive MistKit web demo (CRUD + auth)" + /// The command help text. + public static let helpText = """ + WEB - Interactive MistKit web demo + + USAGE: + mistdemo web [options] + + OPTIONS: + --api-token CloudKit API token + --environment development (default) | production + --port Server port (default: 8080) + --host Server host (default: 127.0.0.1) + --browser Open browser on startup (overrides default) + --no-browser Don't open browser on startup (default for web) + + OPTIONAL — public database (server-to-server): + --key-id CloudKit server-to-server key ID + --private-key Server-to-server private key (inline PEM) + --private-key-path

Path to server-to-server private key file + + The page authenticates against CloudKit via the browser, then + exposes a CRUD UI that calls MistKit on the server. When key + material is provided, the UI also exposes a public-database mode + that signs requests with the key pair instead of the browser- + captured web auth token. Ctrl+C to exit. + """ + + internal let config: WebConfig + + /// Creates a new instance. + public init(config: WebConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + print("📍 Server URL: http://\(config.host):\(config.port)") + if config.publicDatabaseAvailable { + print("🌐 Public database (server-to-server) mode available.") + } + print("Press Ctrl+C to stop.") + + let tokenStore = WebAuthTokenStore() + let server = WebServer( + apiToken: config.apiToken, + containerIdentifier: config.containerIdentifier, + environment: config.environment, + publicDatabaseAvailable: config.publicDatabaseAvailable, + tokenStore: tokenStore, + backendFactory: .live( + apiToken: config.apiToken, + containerIdentifier: config.containerIdentifier, + environment: config.environment, + serverToServer: try makeServerToServerCredentials() + ), + terminatesAfterAuth: false + ) + let router = try server.makeRouter() + + let app = Application( + router: router, + configuration: .init( + address: .hostname(config.host, port: config.port) + ) + ) + + do { + try await withSignalHandling { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await app.runService() + } + group.addTask { + await openBrowserIfNeeded() + } + try await group.waitForAll() + } + } + } catch AsyncTimeoutError.cancelled { + // Ctrl+C / SIGTERM is the intended exit path for the long-running + // web server — `withSignalHandling` throws cancelled to unwind the + // task group. Treat it as a clean shutdown. + print("Server stopped.") + } + } + + /// Build server-to-server credentials when the user supplied key + /// material. Returns `nil` (i.e. private-only mode) when nothing is + /// provided; throws only if an incomplete combination is supplied so + /// silent misconfigurations don't masquerade as "public unavailable". + private func makeServerToServerCredentials() throws + -> ServerToServerCredentials? + { + let hasKeyID = (config.keyID?.isEmpty == false) + let hasInlineKey = (config.privateKey?.isEmpty == false) + let hasKeyFile = (config.privateKeyFile?.isEmpty == false) + + guard hasKeyID || hasInlineKey || hasKeyFile else { + return nil + } + guard let keyID = config.keyID, !keyID.isEmpty else { + throw ConfigurationError.missingRequired( + "key.id", + suggestion: "Provide via --key-id or CLOUDKIT_KEY_ID environment variable" + ) + } + + let material: PrivateKeyMaterial + if let inline = config.privateKey, !inline.isEmpty { + material = .raw(inline) + } else if let path = config.privateKeyFile, !path.isEmpty { + material = .file(path: path) + } else { + throw ConfigurationError.missingRequired( + "private.key", + suggestion: "Provide via --private-key or --private-key-path" + ) + } + + return ServerToServerCredentials(keyID: keyID, privateKey: material) + } + + private func openBrowserIfNeeded() async { + guard config.openBrowser else { + return + } + try? await Task.sleep(nanoseconds: 1_000_000_000) + BrowserOpener.openBrowser( + url: "http://\(config.host):\(config.port)" + ) + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift index 3c335053..856b03b7 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift @@ -42,27 +42,34 @@ public struct AuthTokenConfig: Sendable, ConfigurationParseable { public let apiToken: String /// The CloudKit container identifier. public let containerIdentifier: String + /// The CloudKit environment (development / production). + public let environment: MistKit.Environment /// The server port for authentication. public let port: Int /// The server host for authentication. public let host: String - /// Whether to skip opening the browser. - public let noBrowser: Bool + /// Whether to open the browser to the demo URL on startup. + /// Defaults to `true` for `auth-token` — the captured token is the + /// command's whole reason for existing, so a hands-off flow is the + /// expected UX. + public let openBrowser: Bool /// Creates a new instance. public init( apiToken: String, // Demo default — override via --container-identifier or config key "container.identifier" containerIdentifier: String = MistDemoConstants.Defaults.containerIdentifier, + environment: MistKit.Environment = .development, port: Int = 8_080, host: String = "127.0.0.1", - noBrowser: Bool = false + openBrowser: Bool = true ) { self.apiToken = apiToken self.containerIdentifier = containerIdentifier + self.environment = environment self.port = port self.host = host - self.noBrowser = noBrowser + self.openBrowser = openBrowser } /// Parse configuration from command line arguments. @@ -90,20 +97,31 @@ public struct AuthTokenConfig: Sendable, ConfigurationParseable { forKey: "container.identifier", default: MistDemoConstants.Defaults.containerIdentifier ) ?? MistDemoConstants.Defaults.containerIdentifier + + let envString = + configReader.string(forKey: "environment", default: "development") + ?? "development" + guard let environment = MistKit.Environment(caseInsensitive: envString) else { + throw ConfigurationError.invalidEnvironment(envString) + } + let port = configReader.int(forKey: "port", default: 8_080) ?? 8_080 let host = configReader.string(forKey: "host", default: "127.0.0.1") ?? "127.0.0.1" - let noBrowser = - configReader.bool(forKey: "no.browser", default: false) + let openBrowser = BrowserFlagResolver.resolve( + configReader: configReader, + default: true + ) self.init( apiToken: apiToken, containerIdentifier: containerIdentifier, + environment: environment, port: port, host: host, - noBrowser: noBrowser + openBrowser: openBrowser ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Models/AuthResponse.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift similarity index 60% rename from Examples/MistDemo/Sources/MistDemoKit/Models/AuthResponse.swift rename to Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift index 1a63f026..5c39f1d2 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Models/AuthResponse.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift @@ -1,5 +1,5 @@ // -// AuthResponse.swift +// BrowserFlagResolver.swift // MistDemo // // Created by Leo Dion. @@ -29,21 +29,25 @@ import Foundation -/// Response model for authentication callback endpoints. +/// Resolves the "should we open the browser on startup?" decision from +/// the two mutually-exclusive CLI flags into a single boolean. /// -/// This model is returned by the AuthTokenCommand's Hummingbird routes after -/// processing CloudKit authentication callbacks. It provides comprehensive -/// feedback about the authentication result, including user information and -/// available zones. -/// -/// - Note: Used in AuthTokenCommand.swift line 88 for route responses -internal struct AuthResponse: Encodable { - /// The authenticated user's CloudKit record name. - internal let userRecordName: String - - /// CloudKit data retrieved during authentication (user info and zones). - internal let cloudKitData: CloudKitData - - /// Human-readable message describing the authentication result. - internal let message: String +/// - `--no-browser` sets `no.browser=true` → resolves to `false` (wins). +/// - `--browser` sets `browser=true` → resolves to `true`. +/// - Neither set → falls back to the per-command default. +internal enum BrowserFlagResolver { + internal static func resolve( + configReader: MistDemoConfiguration, + default defaultValue: Bool + ) -> Bool { + let noBrowser = configReader.bool(forKey: "no.browser", default: false) + if noBrowser { + return false + } + let browser = configReader.bool(forKey: "browser", default: false) + if browser { + return true + } + return defaultValue + } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift index b863680f..243e5f58 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift @@ -31,6 +31,15 @@ internal import Foundation internal import MistKit extension MistDemoConfig { + /// Indicates whether `toPrimaryCredentials()` will produce credentials that + /// can satisfy user-identity endpoints (`fetchCaller`, `lookupUsers*`). + /// + /// Those routes require web-auth even on `.public`. Used by the integration + /// runner to decide whether to schedule user-identity phases. + internal var hasUserContextCredentials: Bool { + (try? resolveAPICredentials()) != nil + } + /// Build `Credentials` for the primary `CloudKitService` targeting /// `self.database`. /// @@ -67,15 +76,6 @@ extension MistDemoConfig { } } - /// Indicates whether `toPrimaryCredentials()` will produce credentials that - /// can satisfy user-identity endpoints (`fetchCaller`, `lookupUsers*`). - /// - /// Those routes require web-auth even on `.public`. Used by the integration - /// runner to decide whether to schedule user-identity phases. - internal var hasUserContextCredentials: Bool { - (try? resolveAPICredentials()) != nil - } - // MARK: - Resolution helpers private func resolveServerToServerCredentials() throws -> ServerToServerCredentials { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift index 5df8a721..64fe379a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift @@ -103,7 +103,7 @@ public struct MistDemoConfig: Sendable, ConfigurationParseable { let databaseString = config.string(forKey: "database", default: "public") ?? "public" - guard let database = MistKit.Database(rawValue: databaseString) else { + guard let database = MistDemoConfig.parseDatabase(databaseString) else { throw ConfigurationError.invalidDatabase(databaseString) } self.database = database @@ -167,6 +167,27 @@ public struct MistDemoConfig: Sendable, ConfigurationParseable { self.badCredentials = badCredentials } + /// Map a `"public" | "private" | "shared"` string to a `MistKit.Database`. + /// + /// `"public"` resolves to `.public(.prefers(.serverToServer))` to match + /// `toPrimaryCredentials()`'s "S2S-preferred, web-auth augments" policy. + /// Returns `nil` for unrecognized strings so callers can raise a + /// configuration error. + internal static func parseDatabase( + _ raw: String + ) -> MistKit.Database? { + switch raw { + case "public": + return .public(.prefers(.serverToServer)) + case "private": + return .private + case "shared": + return .shared + default: + return nil + } + } + /// Returns a copy with the given database override. internal func with( database: MistKit.Database diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestIntegrationConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPublicConfig.swift similarity index 95% rename from Examples/MistDemo/Sources/MistDemoKit/Configuration/TestIntegrationConfig.swift rename to Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPublicConfig.swift index 02c0a227..86b663c5 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestIntegrationConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPublicConfig.swift @@ -1,5 +1,5 @@ // -// TestIntegrationConfig.swift +// TestPublicConfig.swift // MistDemo // // Created by Leo Dion. @@ -29,8 +29,8 @@ public import ConfigKeyKit -/// Configuration for test-integration command. -public struct TestIntegrationConfig: Sendable, ConfigurationParseable { +/// Configuration for test-public command. +public struct TestPublicConfig: Sendable, ConfigurationParseable { /// The configuration reader type. public typealias ConfigReader = MistDemoConfiguration /// The base configuration type. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift new file mode 100644 index 00000000..8103853e --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift @@ -0,0 +1,164 @@ +// +// WebConfig.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit +import Foundation +public import MistKit + +/// Configuration for the long-running `web` demo command. +/// +/// Pairs the same auth-flow inputs as `AuthTokenConfig` with the CloudKit +/// environment so the server can build a `CloudKitService` after the user +/// completes the browser-side auth round trip. If server-to-server key +/// material is also supplied (`keyID` + either `privateKey` or +/// `privateKeyFile`), the demo additionally enables the public database +/// path so the UI can compare web-auth vs S2S signing side-by-side. +public struct WebConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = Never + + /// The CloudKit API token. + public let apiToken: String + /// The CloudKit container identifier. + public let containerIdentifier: String + /// The CloudKit environment (development / production). + public let environment: MistKit.Environment + /// The server port. + public let port: Int + /// The server host. + public let host: String + /// Whether to open the browser to the demo URL on startup. + /// Defaults to `false` for `web` — the long-running server is often + /// driven from another machine (or a non-default browser), so silent + /// startup is the safer UX. Override with `--browser`. + public let openBrowser: Bool + /// Server-to-server key identifier (optional). When paired with + /// `privateKey` or `privateKeyFile`, unlocks the public-database path. + public let keyID: String? + /// Server-to-server private key material (optional, secret). + public let privateKey: String? + /// Path to a server-to-server private key file (optional). + public let privateKeyFile: String? + + /// Whether the configuration carries the credentials needed to target + /// the public database via server-to-server signing. + public var publicDatabaseAvailable: Bool { + guard let keyID, !keyID.isEmpty else { + return false + } + let hasInlineKey = (privateKey?.isEmpty == false) + let hasKeyFile = (privateKeyFile?.isEmpty == false) + return hasInlineKey || hasKeyFile + } + + /// Creates a new instance. + public init( + apiToken: String, + containerIdentifier: String = MistDemoConstants.Defaults.containerIdentifier, + environment: MistKit.Environment = .development, + port: Int = 8_080, + host: String = "127.0.0.1", + openBrowser: Bool = false, + keyID: String? = nil, + privateKey: String? = nil, + privateKeyFile: String? = nil + ) { + self.apiToken = apiToken + self.containerIdentifier = containerIdentifier + self.environment = environment + self.port = port + self.host = host + self.openBrowser = openBrowser + self.keyID = keyID + self.privateKey = privateKey + self.privateKeyFile = privateKeyFile + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: Never? = nil + ) async throws { + let configReader = configuration + + let apiToken = + configReader.string(forKey: "api.token", isSecret: true) ?? "" + guard !apiToken.isEmpty else { + throw ConfigurationError.missingRequired( + "api.token", + suggestion: + "Provide via --api-token or CLOUDKIT_API_TOKEN environment variable" + ) + } + + let containerIdentifier = + configReader.string( + forKey: "container.identifier", + default: MistDemoConstants.Defaults.containerIdentifier + ) ?? MistDemoConstants.Defaults.containerIdentifier + + let envString = + configReader.string(forKey: "environment", default: "development") + ?? "development" + guard let environment = MistKit.Environment(caseInsensitive: envString) else { + throw ConfigurationError.invalidEnvironment(envString) + } + + let port = + configReader.int(forKey: "port", default: 8_080) ?? 8_080 + let host = + configReader.string(forKey: "host", default: "127.0.0.1") + ?? "127.0.0.1" + let openBrowser = BrowserFlagResolver.resolve( + configReader: configReader, + default: false + ) + + let keyID = configReader.string(forKey: "key.id") + let privateKey = configReader.string( + forKey: "private.key", + isSecret: true + ) + let privateKeyFile = configReader.string(forKey: "private.key.path") + + self.init( + apiToken: apiToken, + containerIdentifier: containerIdentifier, + environment: environment, + port: port, + host: host, + openBrowser: openBrowser, + keyID: keyID, + privateKey: privateKey, + privateKeyFile: privateKeyFile + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift index 6a2b0ca5..3e483c75 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift @@ -97,7 +97,7 @@ extension PhasedIntegrationTest { print("\u{1F9EA} Integration Test Suite: \(name)") print(String(repeating: "=", count: 80)) print("Container: \(context.containerIdentifier)") - let dbLabel = database == .public ? "public" : "private" + let dbLabel = database.pathSegment == "public" ? "public" : "private" print("Database: \(dbLabel)") print("Record Count: \(context.recordCount)") print("Asset Size: \(context.assetSizeKB) KB") @@ -118,7 +118,7 @@ extension PhasedIntegrationTest { ) let cid = context.containerIdentifier print(" 2. Select your container: \(cid)") - let dbName = database == .public ? "Public" : "Private" + let dbName = database.pathSegment == "public" ? "Public" : "Private" print( " 3. Navigate to \(dbName) Database \u{2192} Records" ) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CleanupPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CleanupPhase.swift index 8dfcff3b..6016b9cc 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CleanupPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CleanupPhase.swift @@ -59,7 +59,10 @@ internal struct CleanupPhase: IntegrationPhase, CleanupPhaseMarker { } do { - _ = try await context.service.modifyRecords(deleteOps) + _ = try await context.service.modifyRecords( + deleteOps, + database: context.database + ) deletedCount = input.names.count if context.verbose { for name in input.names { print(" ✅ Deleted: \(name)") } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift index 3e6f9846..ef527616 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift @@ -59,8 +59,8 @@ internal struct CreateRecordsPhase: IntegrationPhase { "title": .string("Test Record \(recordIndex)"), "index": .int64(recordIndex), "image": .asset(input.asset), - "createdAt": .date(Date()), - ] + ], + database: context.database ) createdRecordNames.append(record.recordName) if context.verbose { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/IncrementalSyncPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/IncrementalSyncPhase.swift index 52b8cc37..4fc3ae3b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/IncrementalSyncPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/IncrementalSyncPhase.swift @@ -56,7 +56,10 @@ internal struct IncrementalSyncPhase: IntegrationPhase { } do { - let incrementalResult = try await context.service.fetchRecordChanges(syncToken: token) + let incrementalResult = try await context.service.fetchRecordChanges( + syncToken: token, + database: context.database + ) print("✅ Fetched \(incrementalResult.records.count) changed records") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/InitialSyncPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/InitialSyncPhase.swift index ae592a64..3a8ef274 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/InitialSyncPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/InitialSyncPhase.swift @@ -44,7 +44,9 @@ internal struct InitialSyncPhase: IntegrationPhase { print("\n\(Self.emoji) \(Self.title)") do { - let initialResult = try await context.service.fetchRecordChanges() + let initialResult = try await context.service.fetchRecordChanges( + database: context.database + ) print("✅ Fetched \(initialResult.records.count) records") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift index 46424f8c..6f91ac79 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift @@ -48,7 +48,10 @@ internal struct LookupRecordsPhase: IntegrationPhase { print(" Looking up \(lookupNames.count) of \(input.names.count) record(s) by name") } - let records = try await context.service.lookupRecords(recordNames: lookupNames) + let records = try await context.service.lookupRecords( + recordNames: lookupNames, + database: context.database + ) print("✅ Looked up \(records.count) record(s)") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift index 2548e0dd..a2b19d1e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift @@ -51,13 +51,15 @@ internal struct ModifyRecordsPhase: IntegrationPhase { recordType: IntegrationTestData.recordType, recordName: recordName, fields: [ - "title": .string("Updated Record \(offset + 1)"), - "modified": .int64(1), + "title": .string("Updated Record \(offset + 1)") ] ) } - _ = try await context.service.modifyRecords(operations) + _ = try await context.service.modifyRecords( + operations, + database: context.database + ) if context.verbose { for recordName in recordsToUpdate { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/UploadAssetPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/UploadAssetPhase.swift index 3fc0e04b..999c9045 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/UploadAssetPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/UploadAssetPhase.swift @@ -53,7 +53,8 @@ internal struct UploadAssetPhase: IntegrationPhase { let receipt = try await context.service.uploadAssets( data: testData, recordType: IntegrationTestData.recordType, - fieldName: "image" + fieldName: "image", + database: context.database ) print("✅ Uploaded asset: \(testData.count) bytes") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift index 5b23f7db..e8cdceca 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift @@ -43,13 +43,13 @@ internal struct PublicDatabaseTest: PhasedIntegrationTest { /// call from the service's `Credentials`. The runner sets this based on /// whether web-auth credentials are configured. internal init( - database: MistKit.Database = .public, + database: MistKit.Database = .public(.prefers(.serverToServer)), includeUserContextPhases: Bool = false ) { - precondition( - database == .public, - "PublicDatabaseTest only supports the public database" - ) + if case .public = database { + } else { + preconditionFailure("PublicDatabaseTest only supports the public database") + } self.database = database var phases: [any IntegrationPhase] = [ diff --git a/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift b/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift index 8735a270..170f5b3c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift @@ -42,6 +42,7 @@ public enum MistDemoRunner { // Register available commands #if canImport(Hummingbird) await registry.register(AuthTokenCommand.self) + await registry.register(WebCommand.self) #endif await registry.register(CurrentUserCommand.self) await registry.register(QueryCommand.self) @@ -54,7 +55,7 @@ public enum MistDemoRunner { await registry.register(DemoInFilterCommand.self) await registry.register(LookupZonesCommand.self) await registry.register(FetchChangesCommand.self) - await registry.register(TestIntegrationCommand.self) + await registry.register(TestPublicCommand.self) await registry.register(TestPrivateCommand.self) await registry.register(DemoErrorsCommand.self) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html new file mode 100644 index 00000000..2bf13fb0 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html @@ -0,0 +1,1055 @@ + + + + + + MistKit Web Demo + + + + +

+
+

MistKit Web Demo

+

+ Authenticate with your Apple ID, then exercise the same CloudKit + operations through MistKit (server) or CloudKit JS (browser) and + compare the wire-level behavior. +

+ +

Backend

+
+ + +
+
+ MistKit mode routes browser → Hummingbird → CloudKit Web Services. + CloudKit JS mode routes browser → CloudKit Web Services directly. + Both share the same Apple ID session token, hit the same container, + and exercise the same REST surface — only the SDK shape differs. +
+ +

Database

+
+ + +
+
+ Private uses the captured Apple ID web-auth token; Public uses + server-to-server signing on the MistKit side and the API token on + the CloudKit JS side. Browsers can't perform S2S signing, so + "MistKit + Public" is unique to the server path. +
+ +

Auth

+
+
+ +
+
+
+ +
+

Notes MistKit Private

+
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + + + + + + + + + + + + +
TitleIndex + Created + + Modified +
No notes loaded — click Refresh.
+
+
+
+ +
+

+ New note + +

+ + + + +
+ + + +
+
+
+ Last raw response +
(none yet)
+
+
+
+
+
+ + + + diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/LoopbackOnlyMiddleware.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/LoopbackOnlyMiddleware.swift new file mode 100644 index 00000000..869e0a07 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/LoopbackOnlyMiddleware.swift @@ -0,0 +1,51 @@ +// +// LoopbackOnlyMiddleware.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + internal import Hummingbird + + /// Rejects requests whose `:authority` is not a loopback host with + /// `403 Forbidden`. Scoped to the `/api` router group so the local-only + /// surface (config, auth capture, CRUD) can't be reached from a + /// non-loopback origin while the index page itself stays unguarded. + internal struct LoopbackOnlyMiddleware: + RouterMiddleware + { + internal func handle( + _ request: Request, + context: Context, + next: (Request, Context) async throws -> Response + ) async throws -> Response { + guard LoopbackAuthority.isLoopback(request.head.authority ?? "") else { + return Response(status: .forbidden) + } + return try await next(request, context) + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebAuthTokenStore.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebAuthTokenStore.swift new file mode 100644 index 00000000..b04b8b99 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebAuthTokenStore.swift @@ -0,0 +1,66 @@ +// +// WebAuthTokenStore.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Thread-safe holder for the captured `ckWebAuthToken`. +/// +/// The web-demo's `/api/authenticate` route writes here when the browser +/// completes the CloudKit auth flow; the CRUD routes read here on each +/// request to authorize themselves against the captured session. +/// +/// `tokenUpdates` yields each captured token so one-shot consumers (e.g. +/// the auth-token command) can await the first emission and shut down. +internal actor WebAuthTokenStore { + private var token: String? + private let updatesContinuation: AsyncStream.Continuation + nonisolated internal let tokenUpdates: AsyncStream + + internal var currentToken: String? { + self.token + } + + internal init(token: String? = nil) { + self.token = token + let (stream, continuation) = AsyncStream.makeStream() + self.tokenUpdates = stream + self.updatesContinuation = continuation + } + + internal func update(_ token: String) { + self.token = token + updatesContinuation.yield(token) + } + + internal func clear() { + self.token = nil + } + + deinit { + updatesContinuation.finish() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift new file mode 100644 index 00000000..ff8039fd --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift @@ -0,0 +1,135 @@ +// +// WebBackend.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKit + +/// Narrow abstraction over the MistKit `CloudKitService` methods the web +/// demo's CRUD routes call. Lets the routes be tested without a live +/// CloudKit container — tests supply a mock conformer. +/// +/// The production implementation is `CloudKitService` itself via +/// extension; the web demo builds a new service per request using the +/// captured `ckWebAuthToken` (and, when configured, server-to-server +/// signing material for the public database). +internal protocol WebBackend: Sendable { + func webQuery( + recordType: String, + limit: Int?, + sortBy: [WebRequests.QuerySortField]?, + database: MistKit.Database + ) async throws -> [RecordInfo] + + func webCreate( + recordType: String, + fields: [String: FieldValue], + database: MistKit.Database + ) async throws -> RecordInfo + + func webUpdate( + recordType: String, + recordName: String, + fields: [String: FieldValue], + recordChangeTag: String?, + database: MistKit.Database + ) async throws -> RecordInfo + + func webDelete( + recordType: String, + recordName: String, + recordChangeTag: String?, + database: MistKit.Database + ) async throws +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +extension CloudKitService: WebBackend { + internal func webQuery( + recordType: String, + limit: Int?, + sortBy: [WebRequests.QuerySortField]?, + database: MistKit.Database + ) async throws -> [RecordInfo] { + let querySorts = sortBy?.map { sort in + QuerySort.sort(sort.field, ascending: sort.ascending) + } + let result = try await queryRecords( + recordType: recordType, + filters: nil, + sortBy: querySorts, + limit: limit, + desiredKeys: nil, + continuationMarker: nil, + database: database + ) + return result.records + } + + internal func webCreate( + recordType: String, + fields: [String: FieldValue], + database: MistKit.Database + ) async throws -> RecordInfo { + try await createRecord( + recordType: recordType, + fields: fields, + database: database + ) + } + + internal func webUpdate( + recordType: String, + recordName: String, + fields: [String: FieldValue], + recordChangeTag: String?, + database: MistKit.Database + ) async throws -> RecordInfo { + try await updateRecord( + recordType: recordType, + recordName: recordName, + fields: fields, + recordChangeTag: recordChangeTag, + database: database + ) + } + + internal func webDelete( + recordType: String, + recordName: String, + recordChangeTag: String?, + database: MistKit.Database + ) async throws { + try await deleteRecord( + recordType: recordType, + recordName: recordName, + recordChangeTag: recordChangeTag, + database: database + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift new file mode 100644 index 00000000..05aa7374 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift @@ -0,0 +1,78 @@ +// +// WebBackendFactory.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + internal import Foundation + internal import MistKit + + /// Factory that returns a `WebBackend` configured with the captured + /// web-auth token. Injected into `WebServer` so tests can supply a + /// mock without going through MistKit. + /// + /// When server-to-server credentials are present, the produced service + /// holds both auth flavors and `CloudKitService` picks the right one + /// per operation based on the request's `database`. + internal struct WebBackendFactory: Sendable { + internal let make: @Sendable (_ webAuthToken: String) throws -> any WebBackend + + internal init( + make: + @escaping @Sendable (_ webAuthToken: String) throws -> any WebBackend + ) { + self.make = make + } + + /// Production factory: builds a `CloudKitService` for the captured + /// web-auth token paired with the command's API token. If + /// `serverToServer` is non-nil, the same service can also satisfy + /// public-database routes via S2S signing. + internal static func live( + apiToken: String, + containerIdentifier: String, + environment: MistKit.Environment, + serverToServer: ServerToServerCredentials? = nil + ) -> WebBackendFactory { + WebBackendFactory { webAuthToken in + let apiAuth = APICredentials( + apiToken: apiToken, + webAuthToken: webAuthToken + ) + let credentials = try Credentials( + serverToServer: serverToServer, + apiAuth: apiAuth + ) + return CloudKitService( + containerIdentifier: containerIdentifier, + credentials: credentials, + environment: environment + ) + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebIndexHTML.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebIndexHTML.swift new file mode 100644 index 00000000..080456a3 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebIndexHTML.swift @@ -0,0 +1,57 @@ +// +// WebIndexHTML.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + internal import Foundation + + /// Loader for the web command's interactive page served by `WebServer`. + /// + /// The HTML+JS lives in `Resources/index.html` and is read from + /// `Bundle.module` on first access. The mode toggle in this page lets + /// users compare MistKit (server-side) and CloudKit JS (browser-side) + /// against the same CloudKit container; the CloudKit JS side is wired + /// in by #329. + internal enum WebIndexHTML { + internal static let content: String = loadContent() + + private static func loadContent() -> String { + guard + let url = Bundle.module.url( + forResource: "index", withExtension: "html" + ), + let html = try? String(contentsOf: url, encoding: .utf8) + else { + preconditionFailure( + "Resources/index.html missing from MistDemoKit bundle" + ) + } + return html + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Models/CloudKitData.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebJSON.swift similarity index 62% rename from Examples/MistDemo/Sources/MistDemoKit/Models/CloudKitData.swift rename to Examples/MistDemo/Sources/MistDemoKit/Server/WebJSON.swift index e0504d06..d492da04 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Models/CloudKitData.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebJSON.swift @@ -1,5 +1,5 @@ // -// CloudKitData.swift +// WebJSON.swift // MistDemo // // Created by Leo Dion. @@ -27,22 +27,18 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import MistKit +internal import Foundation -/// CloudKit user and zone data for authentication response. +/// Shared JSON encoder for the web demo's CRUD response bodies. /// -/// This model encapsulates CloudKit information retrieved during the -/// authentication flow, including user details and available zones. -/// It is used to serialize CloudKit information in auth flow responses. -/// -/// - Note: Used in AuthResponse.swift line 13 for encoding auth response data -internal struct CloudKitData: Encodable { - /// User information retrieved from CloudKit (nil if retrieval failed). - internal let user: UserInfo? - - /// List of available zones in the user's container. - internal let zones: [ZoneInfo] - - /// Error message if any part of the CloudKit data retrieval failed. - internal let error: String? +/// Uses `.millisecondsSince1970` so timestamps in `RecordInfo.created` / +/// `RecordInfo.modified` arrive in the browser as epoch-millis numbers +/// that JS can pass to `new Date(ms)` — the same shape CloudKit Web +/// Services returns to CloudKit JS. +internal enum WebJSON { + internal static func encoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .millisecondsSince1970 + return encoder + } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift new file mode 100644 index 00000000..27b03945 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift @@ -0,0 +1,198 @@ +// +// WebRequests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKit + +/// Request payloads for the web command's CRUD endpoints. +/// +/// `fields` decodes directly into MistKit's `FieldValue`, which has a custom +/// Codable that accepts raw JSON primitives (string → `.string`, integer → +/// `.int64`, floating-point → `.double`) along with the complex CloudKit +/// shapes (location, reference, asset, list). So the browser can send the +/// natural `{"title":"Hi","index":5}` shape without a custom request type. +internal enum WebRequests { + /// One sort descriptor: a field name plus a direction. Field names follow + /// CloudKit Web Services / CloudKit JS naming — including the implicit + /// system fields `___createTime` and `___modTime`, which must be marked + /// SORTABLE in the schema. + internal struct QuerySortField: Decodable, Sendable { + /// CloudKit Web Services field name. Note: CloudKit JS's + /// `performQuery({ sortBy })` uses `fieldName` for the same concept — + /// the browser-side code maps this property to `fieldName` when issuing + /// CloudKit-JS-mode queries (see `queryNotes` in `index.html`). + internal let field: String + internal let ascending: Bool + } + + /// `POST /api/records/query` + internal struct Query: Decodable { + private enum CodingKeys: String, CodingKey { + case recordType + case limit + case sortBy + case database + } + + internal let recordType: String + internal let limit: Int? + internal let sortBy: [QuerySortField]? + internal let database: MistKit.Database + + internal init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.recordType = try container.decode(String.self, forKey: .recordType) + self.limit = try container.decodeIfPresent(Int.self, forKey: .limit) + self.sortBy = try container.decodeIfPresent( + [QuerySortField].self, forKey: .sortBy + ) + self.database = try WebRequests.decodeDatabase( + from: container, forKey: .database + ) + } + } + + /// `POST /api/records/create` + internal struct Create: Decodable { + private enum CodingKeys: String, CodingKey { + case recordType + case fields + case database + } + + internal let recordType: String + internal let fields: [String: FieldValue] + internal let database: MistKit.Database + + internal init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.recordType = try container.decode(String.self, forKey: .recordType) + self.fields = try container.decode( + [String: FieldValue].self, forKey: .fields + ) + self.database = try WebRequests.decodeDatabase( + from: container, forKey: .database + ) + } + } + + /// `POST /api/records/update` + /// + /// `recordChangeTag` carries the optimistic-locking token CloudKit returns + /// on every record. The browser already holds it from the last query, so + /// it forwards directly to MistKit without a server-side fetch round-trip. + internal struct Update: Decodable { + private enum CodingKeys: String, CodingKey { + case recordType + case recordName + case fields + case recordChangeTag + case database + } + + internal let recordType: String + internal let recordName: String + internal let fields: [String: FieldValue] + internal let recordChangeTag: String? + internal let database: MistKit.Database + + internal init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.recordType = try container.decode(String.self, forKey: .recordType) + self.recordName = try container.decode(String.self, forKey: .recordName) + self.fields = try container.decode( + [String: FieldValue].self, forKey: .fields + ) + self.recordChangeTag = try container.decodeIfPresent( + String.self, forKey: .recordChangeTag + ) + self.database = try WebRequests.decodeDatabase( + from: container, forKey: .database + ) + } + } + + /// `POST /api/records/delete` + /// + /// `recordChangeTag` is required by CloudKit Web Services to delete an + /// existing record. Omitting it produces `BadRequestException: missing + /// required field 'recordChangeTag'`. + internal struct Delete: Decodable { + private enum CodingKeys: String, CodingKey { + case recordType + case recordName + case recordChangeTag + case database + } + + internal let recordType: String + internal let recordName: String + internal let recordChangeTag: String? + internal let database: MistKit.Database + + internal init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.recordType = try container.decode(String.self, forKey: .recordType) + self.recordName = try container.decode(String.self, forKey: .recordName) + self.recordChangeTag = try container.decodeIfPresent( + String.self, forKey: .recordChangeTag + ) + self.database = try WebRequests.decodeDatabase( + from: container, forKey: .database + ) + } + } + + /// CloudKit database targeted by a request. Defaults to `.private` when + /// the field is omitted so legacy clients (pre-database-picker) keep + /// working. + internal static let defaultDatabase: MistKit.Database = .private + + /// Decode `database` (string raw-value) from a keyed container. Falls back + /// to `defaultDatabase` when the key is absent and throws when present but + /// unrecognized so a typo surfaces as a `400` rather than a silent default. + fileprivate static func decodeDatabase( + from container: KeyedDecodingContainer, + forKey key: Key + ) throws -> MistKit.Database { + guard let raw = try container.decodeIfPresent(String.self, forKey: key) + else { + return defaultDatabase + } + guard let database = MistDemoConfig.parseDatabase(raw) else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: + "Unrecognized database '\(raw)' — expected one of: public, private, shared" + ) + } + return database + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift new file mode 100644 index 00000000..1fadb4f9 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift @@ -0,0 +1,51 @@ +// +// WebResponse.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKit + +/// Response payloads for the web command's CRUD endpoints. +internal enum WebResponse { + /// Body returned by record-shaped routes (query / create / update). + internal struct Records: Encodable { + internal let records: [RecordInfo] + } + + /// Body returned by `delete` (no record payload). + internal struct Delete: Encodable { + internal let recordName: String + internal let deleted: Bool + } + + /// Body returned for any handled CloudKit/MistKit error so the UI can + /// surface the message without parsing transport-level failures. + internal struct Error: Encodable { + internal let message: String + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift new file mode 100644 index 00000000..998763cc --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift @@ -0,0 +1,146 @@ +// +// WebServer+CRUD.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + internal import Foundation + internal import Hummingbird + internal import MistKit + + extension WebServer { + internal func addQueryEndpoint( + api: RouterGroup + ) { + let tokenStore = self.tokenStore + let backendFactory = self.backendFactory + api.post("records/query") { request, context -> Response in + guard let token = await tokenStore.currentToken else { + return Response(status: .unauthorized) + } + let body = try await request.decode( + as: WebRequests.Query.self, context: context + ) + return try await Self.runOperation { () -> Data in + let backend = try backendFactory.make(token) + let records = try await backend.webQuery( + recordType: body.recordType, + limit: body.limit, + sortBy: body.sortBy, + database: body.database + ) + return try WebJSON.encoder().encode( + WebResponse.Records(records: records) + ) + } + } + } + + internal func addCreateEndpoint( + api: RouterGroup + ) { + let tokenStore = self.tokenStore + let backendFactory = self.backendFactory + api.post("records/create") { request, context -> Response in + guard let token = await tokenStore.currentToken else { + return Response(status: .unauthorized) + } + let body = try await request.decode( + as: WebRequests.Create.self, context: context + ) + return try await Self.runOperation { () -> Data in + let backend = try backendFactory.make(token) + let record = try await backend.webCreate( + recordType: body.recordType, + fields: body.fields, + database: body.database + ) + return try WebJSON.encoder().encode( + WebResponse.Records(records: [record]) + ) + } + } + } + + internal func addUpdateEndpoint( + api: RouterGroup + ) { + let tokenStore = self.tokenStore + let backendFactory = self.backendFactory + api.post("records/update") { request, context -> Response in + guard let token = await tokenStore.currentToken else { + return Response(status: .unauthorized) + } + let body = try await request.decode( + as: WebRequests.Update.self, context: context + ) + return try await Self.runOperation { () -> Data in + let backend = try backendFactory.make(token) + let record = try await backend.webUpdate( + recordType: body.recordType, + recordName: body.recordName, + fields: body.fields, + recordChangeTag: body.recordChangeTag, + database: body.database + ) + return try WebJSON.encoder().encode( + WebResponse.Records(records: [record]) + ) + } + } + } + + internal func addDeleteEndpoint( + api: RouterGroup + ) { + let tokenStore = self.tokenStore + let backendFactory = self.backendFactory + api.post("records/delete") { request, context -> Response in + guard let token = await tokenStore.currentToken else { + return Response(status: .unauthorized) + } + let body = try await request.decode( + as: WebRequests.Delete.self, context: context + ) + return try await Self.runOperation { () -> Data in + let backend = try backendFactory.make(token) + try await backend.webDelete( + recordType: body.recordType, + recordName: body.recordName, + recordChangeTag: body.recordChangeTag, + database: body.database + ) + return try WebJSON.encoder().encode( + WebResponse.Delete( + recordName: body.recordName, deleted: true + ) + ) + } + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift new file mode 100644 index 00000000..0e7b0da4 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift @@ -0,0 +1,177 @@ +// +// WebServer.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + internal import Foundation + internal import HTTPTypes + internal import Hummingbird + internal import Logging + internal import MistKit + + /// Routing surface for the long-running `mistdemo web` command. + /// + /// Owns the index page, the CloudKit JS config endpoint, the auth-capture + /// endpoint, and the CRUD record endpoints. Mode-toggle between MistKit + /// (server-side, this server's routes) and CloudKit JS (browser-side, + /// served from Apple's CDN) lives in the HTML; this server only + /// implements the MistKit side. + internal struct WebServer { + /// JSON payload returned by `GET /api/config`, consumed by the + /// browser-side script to configure both CloudKit JS and the mode- + /// toggle's MistKit handlers. + /// + /// `publicDatabaseAvailable` lets the browser know whether the server + /// holds server-to-server credentials and can therefore route MistKit + /// requests against `.public`. CloudKit JS can always target the public + /// database from the browser (it only needs the API token), so the flag + /// gates only the MistKit + public profile. + internal struct CloudKitClientConfig: Encodable { + internal let apiToken: String + internal let containerIdentifier: String + internal let environment: String + internal let publicDatabaseAvailable: Bool + } + + internal let apiToken: String + internal let containerIdentifier: String + internal let environment: MistKit.Environment + internal let publicDatabaseAvailable: Bool + internal let tokenStore: WebAuthTokenStore + internal let backendFactory: WebBackendFactory + /// When `true`, `POST /api/authenticate` returns `205 Reset Content` to + /// signal the browser that the server is about to shut down (auth-token + /// flow). When `false`, returns `204 No Content` (web flow stays up). + internal let terminatesAfterAuth: Bool + + internal static func jsonResponse( + status: HTTPResponse.Status, bytes: Data + ) -> Response { + Response( + status: status, + headers: [.contentType: "application/json"], + body: ResponseBody { writer in + try await writer.write(ByteBuffer(bytes: bytes)) + try await writer.finish(nil) + } + ) + } + + /// Run a route operation that produces a success JSON body. Any thrown + /// error becomes a `500` response with a JSON error payload so the UI + /// can surface the failure without parsing transport-level errors. + internal static func runOperation( + _ operation: @Sendable () async throws -> Data + ) async throws -> Response { + do { + let bytes = try await operation() + return jsonResponse(status: .ok, bytes: bytes) + } catch { + let errorBody = try JSONEncoder().encode( + WebResponse.Error( + message: error.localizedDescription + ) + ) + return jsonResponse( + status: .internalServerError, bytes: errorBody + ) + } + } + + /// Build the router for this server. + internal func makeRouter() throws -> Router { + let router = Router(context: BasicRequestContext.self) + router.middlewares.add(LogRequestsMiddleware(.info)) + + addIndexEndpoint(router: router) + + let api = router.group("api") + .add(middleware: LoopbackOnlyMiddleware()) + let configData = try JSONEncoder().encode( + CloudKitClientConfig( + apiToken: apiToken, + containerIdentifier: containerIdentifier, + environment: environment.rawValue, + publicDatabaseAvailable: publicDatabaseAvailable + ) + ) + addConfigEndpoint(api: api, configData: configData) + addAuthEndpoint(api: api) + addQueryEndpoint(api: api) + addCreateEndpoint(api: api) + addUpdateEndpoint(api: api) + addDeleteEndpoint(api: api) + + return router + } + + private func addIndexEndpoint( + router: Router + ) { + let indexBytes = ByteBuffer(string: WebIndexHTML.content) + let indexResponseBuilder: @Sendable () -> Response = { + Response( + status: .ok, + headers: [.contentType: "text/html; charset=utf-8"], + body: ResponseBody { writer in + try await writer.write(indexBytes) + try await writer.finish(nil) + } + ) + } + router.get("/") { _, _ -> Response in indexResponseBuilder() } + router.get("/index.html") { _, _ -> Response in + indexResponseBuilder() + } + } + + private func addConfigEndpoint( + api: RouterGroup, + configData: Data + ) { + api.get("config") { _, _ -> Response in + Self.jsonResponse(status: .ok, bytes: configData) + } + } + + private func addAuthEndpoint( + api: RouterGroup + ) { + let tokenStore = self.tokenStore + let successStatus: HTTPResponse.Status = + terminatesAfterAuth ? .resetContent : .noContent + api.post("authenticate") { request, context -> Response in + let authRequest = try await request.decode( + as: AuthRequest.self, context: context + ) + await tokenStore.update(authRequest.sessionToken) + return Response(status: successStatus) + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Utilities/AsyncHelpers.swift b/Examples/MistDemo/Sources/MistDemoKit/Utilities/AsyncHelpers.swift index 9d2c855a..a4845b16 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Utilities/AsyncHelpers.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Utilities/AsyncHelpers.swift @@ -105,13 +105,20 @@ public func withSignalHandling( #endif } -/// Execute an async operation with both timeout and signal handling +/// Execute an async operation with signal handling and an optional timeout. +/// +/// Pass `seconds: nil` to run until a signal (Ctrl+C / SIGTERM) arrives — +/// used by long-running commands like `mistdemo web`. Pass a positive value +/// to cap the wait — used by one-shot commands like `mistdemo auth-token`. public func withTimeoutAndSignals( - seconds: Double, + seconds: Double?, operation: @escaping @Sendable () async throws -> T ) async throws -> T { try await withSignalHandling { - try await withTimeout(seconds: seconds, operation: operation) + if let seconds { + return try await withTimeout(seconds: seconds, operation: operation) + } + return try await operation() } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationHelper+SetupHelpers.swift b/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationHelper+SetupHelpers.swift index 51690729..2470e8a9 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationHelper+SetupHelpers.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationHelper+SetupHelpers.swift @@ -38,7 +38,7 @@ extension AuthenticationHelper { privateKeyFile: String?, databaseOverride: MistKit.Database? ) async throws -> AuthenticationResult { - let database = MistKit.Database.public + let database: MistKit.Database = .public(.prefers(.serverToServer)) if databaseOverride == .private { throw AuthenticationError.serverToServerRequiresPublicDatabase @@ -85,7 +85,7 @@ extension AuthenticationHelper { apiToken: String, databaseOverride: MistKit.Database? ) async throws -> AuthenticationResult { - let database = MistKit.Database.public + let database: MistKit.Database = .public(.prefers(.serverToServer)) if databaseOverride == .private { throw AuthenticationError.privateRequiresWebAuth diff --git a/Examples/MistDemo/Sources/MistDemoKit/Utilities/LoopbackAuthority.swift b/Examples/MistDemo/Sources/MistDemoKit/Utilities/LoopbackAuthority.swift new file mode 100644 index 00000000..87575122 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Utilities/LoopbackAuthority.swift @@ -0,0 +1,84 @@ +// +// LoopbackAuthority.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +/// Helper for validating that an HTTP `:authority` value identifies a +/// loopback host. +/// +/// Used by the auth-token server to reject requests that target the +/// loopback callback from non-loopback hosts (e.g. forwarded ports or +/// remote browsers proxying into the process). +internal enum LoopbackAuthority { + /// Hosts treated as loopback. Bracketed form is used for IPv6 because + /// that is the canonical authority shape. + internal static let allowed: Set = [ + "localhost", + "127.0.0.1", + "[::1]", + ] + + /// Returns `true` when the authority's host (port stripped) matches one + /// of the recognized loopback hosts. + /// + /// - Parameter authority: An HTTP `:authority` value such as + /// `"127.0.0.1:8080"`, `"localhost"`, or `"[::1]:8080"`. + /// - Returns: `true` if the authority is loopback; `false` otherwise. + internal static func isLoopback(_ authority: String) -> Bool { + guard let host = host(in: authority) else { + return false + } + return allowed.contains(host) + } + + /// Returns the host portion of `authority`, stripping a trailing port. + /// Returns `nil` for malformed bracketed IPv6 authorities. + private static func host(in authority: String) -> String? { + if authority.hasPrefix("[") { + return bracketedHost(in: authority) + } + let host = authority.split(separator: ":", maxSplits: 1).first + return host.map(String.init) ?? authority + } + + private static func bracketedHost(in authority: String) -> String? { + guard let endBracket = authority.firstIndex(of: "]") else { + return nil + } + let host = String(authority[authority.startIndex...endBracket]) + let afterBracket = authority[authority.index(after: endBracket)...] + if afterBracket.isEmpty { + return host + } + guard afterBracket.hasPrefix(":") else { + return nil + } + return host + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+BadCredentials.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+BadCredentials.swift index a053bed0..1544f0cc 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+BadCredentials.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+BadCredentials.swift @@ -66,7 +66,7 @@ extension MistKitClientFactoryTests { internal func badCredentialsOnPublicDatabaseThrows() async throws { let config = try await MistKitClientFactoryTests.makeConfig( apiToken: "real-config-token", - database: .public, + database: .public(.prefers(.serverToServer)), keyID: "real-key-id", privateKey: MistKitClientFactoryTests.validPrivateKey, badCredentials: true diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+CustomTokenManager.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+CustomTokenManager.swift index b86f734f..1768ba67 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+CustomTokenManager.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+CustomTokenManager.swift @@ -52,7 +52,7 @@ extension MistKitClientFactoryTests { @Test("Create client with custom token manager for public database") internal func createWithCustomTokenManagerPublicDB() async throws { let config = try await MistKitClientFactoryTests.makeConfig( - apiToken: "api-token", database: .public + apiToken: "api-token", database: .public(.prefers(.serverToServer)) ) let tokenManager = APITokenManager(apiToken: "custom-token") diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+PublicDatabase.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+PublicDatabase.swift index e66865d8..867e38da 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+PublicDatabase.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+PublicDatabase.swift @@ -39,7 +39,7 @@ extension MistKitClientFactoryTests { @Test("Create client for public database") internal func createForPublicDatabaseTest() async throws { let config = try await MistKitClientFactoryTests.makeConfig( - apiToken: "api-token", database: .public + apiToken: "api-token", database: .public(.prefers(.serverToServer)) ) let tokenManager = APITokenManager(apiToken: "api-token") @@ -53,7 +53,9 @@ extension MistKitClientFactoryTests { @Test("Public database creation requires API token") internal func publicDatabaseRequiresAPIToken() async throws { - let config = try await MistKitClientFactoryTests.makeConfig(apiToken: "", database: .public) + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "", database: .public(.prefers(.serverToServer)) + ) #expect(throws: ConfigurationError.self) { try MistKitClientFactory.create(for: config) diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+Configuration.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+Configuration.swift index 4ecc9eef..b07e39a5 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+Configuration.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+Configuration.swift @@ -43,7 +43,7 @@ #expect(config.apiToken == "test-token") #expect(config.port == 8_080) #expect(config.host == "127.0.0.1") - #expect(config.noBrowser == false) + #expect(config.openBrowser == true) } @Test("AuthTokenConfig accepts custom values") @@ -52,13 +52,13 @@ apiToken: "custom-token", port: 3_000, host: "localhost", - noBrowser: true + openBrowser: false ) #expect(config.apiToken == "custom-token") #expect(config.port == 3_000) #expect(config.host == "localhost") - #expect(config.noBrowser == true) + #expect(config.openBrowser == false) } } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+MockServer.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+MockServer.swift index 08c38ff0..4eff29f5 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+MockServer.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+MockServer.swift @@ -51,20 +51,6 @@ #expect(request.sessionToken == "mock-session-token") #expect(request.userRecordName == "user123") } - - @Test("AuthResponse encodes correctly") - internal func authResponseEncodesCorrectly() throws { - let response = AuthResponse( - userRecordName: "user123", - cloudKitData: CloudKitData(user: nil, zones: [], error: nil), - message: "Success" - ) - - let data = try JSONEncoder().encode(response) - - // Verify the encoded data is not empty - #expect(!data.isEmpty) - } } } #endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+Timeout.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+Timeout.swift index ae3c4374..3c9dc7b7 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+Timeout.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+Timeout.swift @@ -36,19 +36,25 @@ extension AuthTokenCommandTests { @Suite("Timeout") internal struct TimeoutTests { - @Test("Timeout helper throws on timeout") - internal func timeoutHelperThrowsOnTimeout() async throws { - do { - _ = try await withTimeoutAndSignals(seconds: 0.1) { - try await Task.sleep(nanoseconds: 1_000_000_000) // Sleep for 1 second - return "should-not-return" + @Test( + "Timeout helper throws on timeout", + .enabled( + if: !TestPlatform.isWasm32, + "wasm32 CooperativeExecutor doesn't fire the timeout race against an inner Task.sleep" + ) + ) + internal func timeoutHelperThrowsOnTimeout() async { + // Mirrors AsyncHelpersTests+Timeout's gate: on simulator cooperative + // executors (notably visionOS / watchOS under CI load) the operation's + // single long Task.sleep can complete before the polling timeout + // task's many short sleeps detect the deadline. + await withKnownIssue(isIntermittent: true) { + await #expect(throws: AsyncTimeoutError.self) { + try await withTimeoutAndSignals(seconds: 0.1) { + try await Task.sleep(nanoseconds: 1_000_000_000) + return "should-not-return" + } } - Issue.record("Should have timed out") - } catch is AsyncTimeoutError { - // Expected timeout error - #expect(Bool(true)) - } catch { - Issue.record("Unexpected error: \(error)") } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+AuthTokenCommandIntegration.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+AuthTokenCommandIntegration.swift index e09efc93..899e769b 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+AuthTokenCommandIntegration.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+AuthTokenCommandIntegration.swift @@ -43,7 +43,7 @@ apiToken: "test-api-token-123", port: 8_080, host: "127.0.0.1", - noBrowser: true + openBrowser: false ) _ = AuthTokenCommand(config: config) diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+RealWorldUsageSimulation.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+RealWorldUsageSimulation.swift index 65977456..a9a1f579 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+RealWorldUsageSimulation.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/CommandIntegration/CommandIntegrationTests+RealWorldUsageSimulation.swift @@ -46,7 +46,7 @@ extension CommandIntegrationTests { #if canImport(Hummingbird) let authConfig = AuthTokenConfig( apiToken: "mock-api-token-for-test", - noBrowser: true + openBrowser: false ) _ = AuthTokenCommand(config: authConfig) #endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift index 3c8c8ff2..5756dc6e 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift @@ -29,6 +29,7 @@ import Configuration import Foundation +import MistKit import Testing @testable import MistDemoKit @@ -49,15 +50,17 @@ internal struct AuthTokenConfigTests { return MistDemoConfiguration(testProvider: InMemoryProvider(values: mapped)) } - @Test("Memberwise init applies defaults for port, host, noBrowser, container") + @Test("Memberwise init applies defaults for port, host, openBrowser, container") internal func memberwiseDefaults() { let config = AuthTokenConfig(apiToken: "tok") #expect(config.apiToken == "tok") #expect(config.containerIdentifier == MistDemoConstants.Defaults.containerIdentifier) + #expect(config.environment == .development) #expect(config.port == 8_080) #expect(config.host == "127.0.0.1") - #expect(config.noBrowser == false) + // auth-token defaults to opening the browser. + #expect(config.openBrowser == true) } @Test("Memberwise init accepts custom values for every field") @@ -65,16 +68,18 @@ internal struct AuthTokenConfigTests { let config = AuthTokenConfig( apiToken: "tok", containerIdentifier: "iCloud.custom.id", + environment: .production, port: 9_000, host: "0.0.0.0", - noBrowser: true + openBrowser: false ) #expect(config.apiToken == "tok") #expect(config.containerIdentifier == "iCloud.custom.id") + #expect(config.environment == .production) #expect(config.port == 9_000) #expect(config.host == "0.0.0.0") - #expect(config.noBrowser == true) + #expect(config.openBrowser == false) } @Test("Configuration init throws missingRequired when api.token is absent") @@ -107,9 +112,10 @@ internal struct AuthTokenConfigTests { #expect(config.apiToken == "tok-xyz") #expect(config.containerIdentifier == MistDemoConstants.Defaults.containerIdentifier) + #expect(config.environment == .development) #expect(config.port == 8_080) #expect(config.host == "127.0.0.1") - #expect(config.noBrowser == false) + #expect(config.openBrowser == true) } @Test("Configuration init honors every override key") @@ -117,6 +123,7 @@ internal struct AuthTokenConfigTests { let configuration = Self.configuration(values: [ "api.token": .init(stringLiteral: "tok-xyz"), "container.identifier": .init(stringLiteral: "iCloud.custom.id"), + "environment": .init(stringLiteral: "production"), "port": .init(integerLiteral: 9_090), "host": .init(stringLiteral: "192.168.1.10"), "no.browser": .init(booleanLiteral: true), @@ -126,8 +133,34 @@ internal struct AuthTokenConfigTests { #expect(config.apiToken == "tok-xyz") #expect(config.containerIdentifier == "iCloud.custom.id") + #expect(config.environment == .production) #expect(config.port == 9_090) #expect(config.host == "192.168.1.10") - #expect(config.noBrowser == true) + #expect(config.openBrowser == false) + } + + @Test("--no-browser wins when both browser flags are set") + internal func noBrowserWinsOverBrowser() async throws { + let configuration = Self.configuration(values: [ + "api.token": .init(stringLiteral: "tok-xyz"), + "browser": .init(booleanLiteral: true), + "no.browser": .init(booleanLiteral: true), + ]) + + let config = try await AuthTokenConfig(configuration: configuration) + + #expect(config.openBrowser == false) + } + + @Test("Configuration init throws on invalid environment") + internal func invalidEnvironmentThrows() async { + let configuration = Self.configuration(values: [ + "api.token": .init(stringLiteral: "tok-xyz"), + "environment": .init(stringLiteral: "staging"), + ]) + + await #expect(throws: ConfigurationError.self) { + _ = try await AuthTokenConfig(configuration: configuration) + } } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift index 1ec9d3af..b54071ad 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift @@ -45,7 +45,7 @@ extension AuthenticationCredentialsTests { @Test("public with raw private key produces serverToServer with .raw material") internal func publicWithRawKey() async throws { let config = try await MistKitClientFactoryTests.makeConfig( - database: .public, + database: .public(.prefers(.serverToServer)), keyID: "test-key-id", privateKey: MistKitClientFactoryTests.validPrivateKey ) @@ -69,7 +69,7 @@ extension AuthenticationCredentialsTests { containerIdentifier: "iCloud.com.test.App", apiToken: "test-api-token", environment: .development, - database: .public, + database: .public(.prefers(.serverToServer)), webAuthToken: nil, keyID: "test-key-id", privateKey: nil, @@ -100,7 +100,7 @@ extension AuthenticationCredentialsTests { @Test("public missing keyID throws missingRequired(\"key.id\")") internal func publicMissingKeyIDThrows() async throws { let config = try await MistKitClientFactoryTests.makeConfig( - database: .public, + database: .public(.prefers(.serverToServer)), keyID: "", privateKey: MistKitClientFactoryTests.validPrivateKey ) @@ -120,7 +120,7 @@ extension AuthenticationCredentialsTests { @Test("public missing private key material throws missingRequired(\"private.key\")") internal func publicMissingPrivateKeyThrows() async throws { let config = try await MistKitClientFactoryTests.makeConfig( - database: .public, + database: .public(.prefers(.serverToServer)), keyID: "test-key-id" ) @@ -177,7 +177,7 @@ extension AuthenticationCredentialsTests { internal func publicEmbedsAPIAuthWhenAvailable() async throws { let config = try await MistKitClientFactoryTests.makeConfig( apiToken: "api", - database: .public, + database: .public(.prefers(.serverToServer)), webAuthToken: "web", keyID: "k", privateKey: MistKitClientFactoryTests.validPrivateKey @@ -194,7 +194,7 @@ extension AuthenticationCredentialsTests { internal func publicOmitsAPIAuthWhenWebAuthMissing() async throws { let config = try await MistKitClientFactoryTests.makeConfig( apiToken: "", - database: .public, + database: .public(.prefers(.serverToServer)), webAuthToken: nil, keyID: "k", privateKey: MistKitClientFactoryTests.validPrivateKey diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift index bf3047a4..16155ba3 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift @@ -71,12 +71,12 @@ internal struct TestPrivateConfigTests { // Even though we configure the base for the public DB, TestPrivateConfig // must override to `.private`. The init also requires web-auth credentials. let baseConfig = try await MistDemoConfig( - database: .public, + database: .public(.prefers(.serverToServer)), webAuthToken: "wat-xyz" ) let config = TestPrivateConfig(base: baseConfig.with(database: .private)) - #expect(config.base.database == .private) + #expect(config.base.database == MistKit.Database.private) } @Test("Memberwise init preserves base configuration values") diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestIntegrationConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPublicConfigTests.swift similarity index 88% rename from Examples/MistDemo/Tests/MistDemoTests/Configuration/TestIntegrationConfigTests.swift rename to Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPublicConfigTests.swift index 94bc2ffa..69451f7c 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestIntegrationConfigTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPublicConfigTests.swift @@ -1,5 +1,5 @@ // -// TestIntegrationConfigTests.swift +// TestPublicConfigTests.swift // MistDemoTests // // Created by Leo Dion. @@ -32,12 +32,12 @@ import Testing @testable import MistDemoKit -@Suite("TestIntegrationConfig Tests") -internal struct TestIntegrationConfigTests { +@Suite("TestPublicConfig Tests") +internal struct TestPublicConfigTests { @Test("Memberwise defaults: recordCount=10, assetSizeKB=100, flags false, lookupEmail nil") internal func defaults() async throws { let baseConfig = try await MistDemoConfig() - let config = TestIntegrationConfig(base: baseConfig) + let config = TestPublicConfig(base: baseConfig) #expect(config.recordCount == 10) #expect(config.assetSizeKB == 100) @@ -49,7 +49,7 @@ internal struct TestIntegrationConfigTests { @Test("Memberwise init accepts custom values") internal func customValues() async throws { let baseConfig = try await MistDemoConfig() - let config = TestIntegrationConfig( + let config = TestPublicConfig( base: baseConfig, recordCount: 25, assetSizeKB: 512, @@ -68,7 +68,7 @@ internal struct TestIntegrationConfigTests { @Test("Memberwise init preserves base configuration values") internal func preservesBase() async throws { let baseConfig = try await MistDemoConfig(containerIdentifier: "iCloud.integration.test") - let config = TestIntegrationConfig(base: baseConfig) + let config = TestPublicConfig(base: baseConfig) #expect(config.base.containerIdentifier == "iCloud.integration.test") } @@ -76,7 +76,7 @@ internal struct TestIntegrationConfigTests { @Test("Memberwise init accepts zero recordCount") internal func zeroRecordCount() async throws { let baseConfig = try await MistDemoConfig() - let config = TestIntegrationConfig(base: baseConfig, recordCount: 0) + let config = TestPublicConfig(base: baseConfig, recordCount: 0) #expect(config.recordCount == 0) } diff --git a/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift b/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift index 90d0a737..75c90c3f 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift @@ -92,7 +92,7 @@ extension MistDemoConfig { key("container.identifier"): .init(stringLiteral: containerIdentifier), key("api.token"): .init(stringLiteral: apiToken), key("environment"): .init(stringLiteral: envString), - key("database"): .init(stringLiteral: database.rawValue), + key("database"): .init(stringLiteral: database.pathSegment), key("host"): .init(stringLiteral: host), key("port"): .init(integerLiteral: port), key("auth.timeout"): .init(integerLiteral: Int(authTimeout)), diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift new file mode 100644 index 00000000..93222e77 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift @@ -0,0 +1,206 @@ +// +// MockBackend.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Foundation + import MistKit + + @testable import MistDemoKit + + /// In-memory `WebBackend` for routing-level tests. Records the last + /// call to each operation and returns deterministic stub records. + internal final actor MockBackend: WebBackend { + internal struct QueryCall: Sendable { + internal let recordType: String + internal let limit: Int? + internal let sortBy: [WebRequests.QuerySortField]? + internal let database: MistKit.Database + } + + internal struct CreateCall: Sendable { + internal let recordType: String + internal let fields: [String: String] + internal let database: MistKit.Database + } + + internal struct UpdateCall: Sendable { + internal let recordType: String + internal let recordName: String + internal let fields: [String: String] + internal let recordChangeTag: String? + internal let database: MistKit.Database + } + + internal struct DeleteCall: Sendable { + internal let recordType: String + internal let recordName: String + internal let recordChangeTag: String? + internal let database: MistKit.Database + } + + internal private(set) var lastQuery: QueryCall? + internal private(set) var lastCreate: CreateCall? + internal private(set) var lastUpdate: UpdateCall? + internal private(set) var lastDelete: DeleteCall? + private var pendingError: String? + + private static func stubRecord( + recordType: String, recordName: String + ) -> RecordInfo { + let json = """ + { + "recordName": "\(recordName)", + "recordType": "\(recordType)", + "recordChangeTag": null, + "fields": {}, + "created": null, + "modified": null, + "deleted": false + } + """ + // RecordInfo is Codable; round-trip through JSON keeps the stub + // independent of MistKit's internal initializer. + // swiftlint:disable:next force_try + return try! JSONDecoder().decode( + RecordInfo.self, from: Data(json.utf8) + ) + } + + /// Flatten FieldValue entries into a printable form so tests can write + /// `#expect(captured.fields["title"] == "Hi")` for strings or + /// `#expect(captured.fields["index"] == "5")` for numbers without + /// pattern-matching on FieldValue in every assertion. + /// + /// Non-primitive cases (asset, date, reference, location, list, bytes) + /// are intentionally dropped — they yield no useful String form for an + /// equality assertion. Tests that need to assert those types should + /// inspect the FieldValue directly rather than going through `flatten`. + private static func flatten( + _ fields: [String: FieldValue] + ) -> [String: String] { + var result: [String: String] = [:] + for (name, value) in fields { + switch value { + case .string(let string): + result[name] = string + case .int64(let int): + result[name] = String(int) + case .double(let double): + result[name] = String(double) + default: + continue + } + } + return result + } + + internal func failNext(message: String) { + pendingError = message + } + + internal func webQuery( + recordType: String, + limit: Int?, + sortBy: [WebRequests.QuerySortField]?, + database: MistKit.Database + ) async throws -> [RecordInfo] { + lastQuery = QueryCall( + recordType: recordType, + limit: limit, + sortBy: sortBy, + database: database + ) + try consumePendingError() + return [ + Self.stubRecord(recordType: recordType, recordName: "stub-1") + ] + } + + internal func webCreate( + recordType: String, + fields: [String: FieldValue], + database: MistKit.Database + ) async throws -> RecordInfo { + lastCreate = CreateCall( + recordType: recordType, + fields: Self.flatten(fields), + database: database + ) + try consumePendingError() + return Self.stubRecord( + recordType: recordType, recordName: "created-1" + ) + } + + internal func webUpdate( + recordType: String, + recordName: String, + fields: [String: FieldValue], + recordChangeTag: String?, + database: MistKit.Database + ) async throws -> RecordInfo { + lastUpdate = UpdateCall( + recordType: recordType, + recordName: recordName, + fields: Self.flatten(fields), + recordChangeTag: recordChangeTag, + database: database + ) + try consumePendingError() + return Self.stubRecord( + recordType: recordType, recordName: recordName + ) + } + + internal func webDelete( + recordType: String, + recordName: String, + recordChangeTag: String?, + database: MistKit.Database + ) async throws { + lastDelete = DeleteCall( + recordType: recordType, + recordName: recordName, + recordChangeTag: recordChangeTag, + database: database + ) + try consumePendingError() + } + + private func consumePendingError() throws { + if let message = pendingError { + pendingError = nil + struct StubError: LocalizedError { + let errorDescription: String? + } + throw StubError(errorDescription: message) + } + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebAuthTokenStoreTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebAuthTokenStoreTests.swift new file mode 100644 index 00000000..c83dca9e --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebAuthTokenStoreTests.swift @@ -0,0 +1,68 @@ +// +// WebAuthTokenStoreTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Testing + + @testable import MistDemoKit + + @Suite("WebAuthTokenStore Tests") + internal struct WebAuthTokenStoreTests { + @Test("Starts empty when initialized without a token") + internal func startsEmpty() async { + let store = WebAuthTokenStore() + let value = await store.currentToken + #expect(value == nil) + } + + @Test("Returns the token passed to the initializer") + internal func preSeeded() async { + let store = WebAuthTokenStore(token: "seed") + let value = await store.currentToken + #expect(value == "seed") + } + + @Test("update(_:) replaces the stored token") + internal func updateReplaces() async { + let store = WebAuthTokenStore() + await store.update("first") + await store.update("second") + let value = await store.currentToken + #expect(value == "second") + } + + @Test("clear() removes the stored token") + internal func clearRemoves() async { + let store = WebAuthTokenStore(token: "tok") + await store.clear() + let value = await store.currentToken + #expect(value == nil) + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+LoopbackAuthorityValidation.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebJSONTests.swift similarity index 52% rename from Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+LoopbackAuthorityValidation.swift rename to Examples/MistDemo/Tests/MistDemoTests/Server/WebJSONTests.swift index 7400cbbb..aa90f059 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+LoopbackAuthorityValidation.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebJSONTests.swift @@ -1,5 +1,5 @@ // -// AuthTokenCommandTests+LoopbackAuthorityValidation.swift +// WebJSONTests.swift // MistDemoTests // // Created by Leo Dion. @@ -33,44 +33,24 @@ @testable import MistDemoKit - extension AuthTokenCommandTests { - @Suite("Loopback Authority Validation") - internal struct LoopbackAuthorityValidation { - @Test( - "isLoopbackAuthority accepts loopback hosts", - arguments: [ - "localhost", - "localhost:8080", - "127.0.0.1", - "127.0.0.1:3000", - "[::1]", - "[::1]:8080", - ] - ) - internal func isLoopbackAuthorityAcceptsLoopback(authority: String) { - #expect(AuthTokenCommand.isLoopbackAuthority(authority)) - } + @Suite("WebJSON") + internal struct WebJSONTests { + private struct DateWrapper: Codable { + let date: Date + } + + @Test("encoder writes Date as epoch-millis numbers") + internal func encoderEmitsEpochMillis() throws { + // 1500ms since 1970-01-01T00:00:00Z — chosen so the expected JSON + // value is a plain integer the browser's `new Date(1500)` can consume. + let date = Date(timeIntervalSince1970: 1.5) + + let data = try WebJSON.encoder().encode(DateWrapper(date: date)) - @Test( - "isLoopbackAuthority rejects non-loopback and bypass attempts", - arguments: [ - "", - "evil.com", - "evil.com:8080", - "localhost.evil.com", - "localhost.evil.com:8080", - "127.0.0.1.evil.com", - "127.0.0.1.evil.com:8080", - "127.0.0.2", - "0.0.0.0", - "[::2]", - "[::1].evil.com", - "api.apple-cloudkit.com", - ] + let json = try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] ) - internal func isLoopbackAuthorityRejectsBypassAttempts(authority: String) { - #expect(!AuthTokenCommand.isLoopbackAuthority(authority)) - } + #expect(json["date"] as? Double == 1_500) } } #endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+CRUD.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+CRUD.swift new file mode 100644 index 00000000..28afc0f9 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+CRUD.swift @@ -0,0 +1,225 @@ +// +// WebServerTests+CRUD.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Foundation + import HTTPTypes + import Hummingbird + import HummingbirdTesting + import MistKit + import Testing + + @testable import MistDemoKit + + extension WebServerTests { + private struct RecordsPayload: Decodable { + let records: [RecordInfo] + } + + private struct DeletePayload: Decodable { + let recordName: String + let deleted: Bool + } + + @Test("POST /api/records/query forwards to the backend") + internal func queryForwards() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = #"{"recordType":"Note","limit":10}"# + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/query", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + let payload = try JSONDecoder().decode( + RecordsPayload.self, + from: Data(response.body.readableBytesView) + ) + #expect(payload.records.count == 1) + #expect(payload.records.first?.recordType == "Note") + } + } + + let captured = await fixture.backend.lastQuery + #expect(captured?.recordType == "Note") + #expect(captured?.limit == 10) + #expect(captured?.database == .private) + } + + @Test("POST /api/records/create forwards fields to the backend") + internal func createForwards() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = #"{"recordType":"Note","fields":{"title":"Hi"}}"# + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/create", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastCreate + #expect(captured?.recordType == "Note") + #expect(captured?.fields["title"] == "Hi") + } + + @Test("POST /api/records/create accepts JSON-number fields (Int + Double)") + internal func createAcceptsNumericFields() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = """ + {"recordType":"Note","fields":{"title":"Hi","index":5,"score":1.5}} + """ + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/create", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + let captured = await fixture.backend.lastCreate + #expect(captured?.fields["title"] == "Hi") + #expect(captured?.fields["index"] == "5") + #expect(captured?.fields["score"] == "1.5") + } + + @Test("POST /api/records/update forwards recordName, fields, changeTag") + internal func updateForwards() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = """ + {"recordType":"Note","recordName":"abc","fields":{"title":"Up"},\ + "recordChangeTag":"tag-1"} + """ + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/update", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastUpdate + #expect(captured?.recordType == "Note") + #expect(captured?.recordName == "abc") + #expect(captured?.fields["title"] == "Up") + #expect(captured?.recordChangeTag == "tag-1") + } + + @Test("POST /api/records/update accepts a missing recordChangeTag") + internal func updateAcceptsAbsentChangeTag() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = """ + {"recordType":"Note","recordName":"abc","fields":{"title":"Up"}} + """ + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/update", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastUpdate + #expect(captured?.recordChangeTag == nil) + } + + @Test("POST /api/records/delete forwards recordName + changeTag") + internal func deleteForwards() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = #""" + {"recordType":"Note","recordName":"abc","recordChangeTag":"tag-9"} + """# + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/delete", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + let payload = try JSONDecoder().decode( + DeletePayload.self, + from: Data(response.body.readableBytesView) + ) + #expect(payload.recordName == "abc") + #expect(payload.deleted) + } + } + + let captured = await fixture.backend.lastDelete + #expect(captured?.recordType == "Note") + #expect(captured?.recordName == "abc") + #expect(captured?.recordChangeTag == "tag-9") + } + + @Test("Backend errors surface as 500 with a JSON message body") + internal func backendErrorIsSurfaced() async throws { + let fixture = Self.makeFixture(authenticated: true) + await fixture.backend.failNext(message: "boom") + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/query", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: #"{"recordType":"Note"}"#) + ) { response in + #expect(response.status == .internalServerError) + let body = String(buffer: response.body) + #expect(body.contains("boom")) + } + } + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Database.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Database.swift new file mode 100644 index 00000000..d1a7106f --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Database.swift @@ -0,0 +1,133 @@ +// +// WebServerTests+Database.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Foundation + import HTTPTypes + import Hummingbird + import HummingbirdTesting + import MistKit + import Testing + + @testable import MistDemoKit + + extension WebServerTests { + @Test("CRUD requests omit `database` → backend receives .private") + internal func crudDefaultsDatabaseToPrivate() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/query", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: #"{"recordType":"Note"}"#) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastQuery + #expect(captured?.database == .private) + } + + @Test( + "CRUD requests forward `database`: public → backend", + arguments: [ + ("/api/records/query", #"{"recordType":"Note","database":"public"}"#), + ( + "/api/records/create", + #"{"recordType":"Note","database":"public","fields":{"title":"X"}}"# + ), + ( + "/api/records/update", + #""" + {"recordType":"Note","database":"public",\# + "recordName":"r1","fields":{"title":"X"}} + """# + ), + ( + "/api/records/delete", + #"{"recordType":"Note","database":"public","recordName":"r1"}"# + ), + ] + ) + internal func crudForwardsPublicDatabase( + path: String, + jsonBody: String + ) async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: path, + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured: MistKit.Database? + switch path { + case "/api/records/query": + captured = await fixture.backend.lastQuery?.database + case "/api/records/create": + captured = await fixture.backend.lastCreate?.database + case "/api/records/update": + captured = await fixture.backend.lastUpdate?.database + case "/api/records/delete": + captured = await fixture.backend.lastDelete?.database + default: + captured = nil + } + #expect(captured == .public(.prefers(.serverToServer))) + } + + @Test("CRUD requests with an unknown `database` value return 400") + internal func crudRejectsUnknownDatabase() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/query", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: #"{"recordType":"Note","database":"bogus"}"#) + ) { response in + #expect(response.status == .badRequest) + } + } + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift new file mode 100644 index 00000000..c58a5c22 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift @@ -0,0 +1,121 @@ +// +// WebServerTests+Index.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Foundation + import HTTPTypes + import Hummingbird + import HummingbirdTesting + import MistKit + import Testing + + @testable import MistDemoKit + + extension WebServerTests { + @Test("GET / returns the web demo HTML") + internal func indexReturnsHtml() async throws { + let fixture = Self.makeFixture() + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute(uri: "/", method: .get) { response in + #expect(response.status == .ok) + let body = String(buffer: response.body) + #expect(body.contains("MistKit Web Demo")) + } + } + } + + @Test("Index HTML wires CloudKit JS as an alternate backend") + internal func indexExposesCloudKitJsHandlers() async throws { + let fixture = Self.makeFixture() + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute(uri: "/", method: .get) { response in + #expect(response.status == .ok) + let body = String(buffer: response.body) + #expect(body.contains("cdn.apple-cloudkit.com/ck/2/cloudkit.js")) + #expect(!body.contains("id=\"mode-cloudkitjs\" type=\"button\" disabled")) + #expect(body.contains("performQuery")) + #expect(body.contains("saveRecords")) + #expect(body.contains("deleteRecords")) + #expect(!body.contains("cloudKitJsNotWired")) + } + } + } + + @Test("Index HTML exposes a public/private database picker") + internal func indexExposesDatabasePicker() async throws { + let fixture = Self.makeFixture() + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute(uri: "/", method: .get) { response in + #expect(response.status == .ok) + let body = String(buffer: response.body) + #expect(body.contains(#"id="db-private""#)) + #expect(body.contains(#"id="db-public""#)) + #expect(body.contains("publicCloudDatabase")) + #expect(body.contains("privateCloudDatabase")) + } + } + } + + @Test("Index HTML carries the post-database-picker UX additions") + internal func indexCarriesUxPolish() async throws { + let fixture = Self.makeFixture() + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute(uri: "/", method: .get) { response in + #expect(response.status == .ok) + let body = String(buffer: response.body) + // 1. Loading state + #expect(body.contains(".status.loading")) + #expect(body.contains("queryInFlight")) + #expect(body.contains("setQueryControlsDisabled")) + // 2. Post-create delay + #expect(body.contains("REFRESH_DELAY_MS")) + #expect(body.contains("waiting")) + // 3. "You" badge wired to the captured user identity + #expect(body.contains("currentUserRecordName")) + #expect(body.contains("badge-you")) + #expect(body.contains("extractUserRecordName")) + // 4. Default sort = ___createTime descending + #expect( + body.contains( + "currentSort = { field: '___createTime', ascending: false }" + ) + ) + } + } + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QuerySort.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QuerySort.swift new file mode 100644 index 00000000..8db89013 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QuerySort.swift @@ -0,0 +1,87 @@ +// +// WebServerTests+QuerySort.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Foundation + import HTTPTypes + import Hummingbird + import HummingbirdTesting + import MistKit + import Testing + + @testable import MistDemoKit + + extension WebServerTests { + @Test("POST /api/records/query forwards sortBy to the backend") + internal func queryForwardsSort() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = """ + {"recordType":"Note","sortBy":[\ + {"field":"___modTime","ascending":false}]} + """ + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/query", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastQuery + #expect(captured?.sortBy?.count == 1) + #expect(captured?.sortBy?.first?.field == "___modTime") + #expect(captured?.sortBy?.first?.ascending == false) + } + + @Test("POST /api/records/query without sortBy passes nil") + internal func queryWithoutSortIsNil() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/query", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: #"{"recordType":"Note"}"#) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastQuery + #expect(captured?.sortBy == nil) + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests.swift new file mode 100644 index 00000000..4d1b1bee --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests.swift @@ -0,0 +1,222 @@ +// +// WebServerTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + import Foundation + import HTTPTypes + import Hummingbird + import HummingbirdTesting + import MistKit + import Testing + + @testable import MistDemoKit + + @Suite("WebServer Tests") + internal struct WebServerTests { + internal struct Fixture { + internal let server: WebServer + internal let tokenStore: WebAuthTokenStore + internal let backend: MockBackend + } + + private struct ConfigPayload: Decodable { + let apiToken: String + let containerIdentifier: String + let environment: String + let publicDatabaseAvailable: Bool + } + + internal static func makeFixture( + authenticated: Bool = false, + terminatesAfterAuth: Bool = false, + publicDatabaseAvailable: Bool = false + ) -> Fixture { + let backend = MockBackend() + let store = WebAuthTokenStore( + token: authenticated ? "captured-token" : nil + ) + let factory = WebBackendFactory { _ in backend } + let server = WebServer( + apiToken: "test-api-token", + containerIdentifier: "iCloud.test.container", + environment: .development, + publicDatabaseAvailable: publicDatabaseAvailable, + tokenStore: store, + backendFactory: factory, + terminatesAfterAuth: terminatesAfterAuth + ) + return Fixture(server: server, tokenStore: store, backend: backend) + } + + @Test("GET /api/config returns container + environment") + internal func configIncludesEnvironment() async throws { + let fixture = Self.makeFixture() + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute(uri: "/api/config", method: .get) { + response in + #expect(response.status == .ok) + let payload = try JSONDecoder().decode( + ConfigPayload.self, + from: Data(response.body.readableBytesView) + ) + #expect(payload.apiToken == "test-api-token") + #expect(payload.containerIdentifier == "iCloud.test.container") + #expect(payload.environment == "development") + #expect(payload.publicDatabaseAvailable == false) + } + } + } + + @Test("GET /api/config advertises publicDatabaseAvailable when S2S configured") + internal func configAdvertisesPublicDatabase() async throws { + let fixture = Self.makeFixture(publicDatabaseAvailable: true) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute(uri: "/api/config", method: .get) { + response in + #expect(response.status == .ok) + let payload = try JSONDecoder().decode( + ConfigPayload.self, + from: Data(response.body.readableBytesView) + ) + #expect(payload.publicDatabaseAvailable == true) + } + } + } + + @Test("POST /api/authenticate captures the token and returns 204") + internal func authenticateCapturesToken() async throws { + let fixture = Self.makeFixture() + let app = Application(router: try fixture.server.makeRouter()) + + let body = try JSONEncoder().encode([ + "sessionToken": "session-xyz", + "userRecordName": "_abc", + ]) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/authenticate", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(bytes: body) + ) { response in + #expect(response.status == .noContent) + #expect(response.body.readableBytes == 0) + } + } + + let stored = await fixture.tokenStore.currentToken + #expect(stored == "session-xyz") + } + + @Test("POST /api/authenticate returns 205 when terminatesAfterAuth") + internal func authenticateReturns205WhenTerminating() async throws { + let fixture = Self.makeFixture(terminatesAfterAuth: true) + let app = Application(router: try fixture.server.makeRouter()) + + let body = try JSONEncoder().encode([ + "sessionToken": "session-xyz", + "userRecordName": "_abc", + ]) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/authenticate", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(bytes: body) + ) { response in + #expect(response.status == .resetContent) + #expect(response.body.readableBytes == 0) + } + } + + let stored = await fixture.tokenStore.currentToken + #expect(stored == "session-xyz") + } + + @Test("tokenUpdates yields the captured token after authenticate") + internal func authenticateYieldsToTokenUpdates() async throws { + let fixture = Self.makeFixture() + let app = Application(router: try fixture.server.makeRouter()) + + let body = try JSONEncoder().encode([ + "sessionToken": "session-xyz", + "userRecordName": "_abc", + ]) + + try await app.test(.router) { client in + async let firstToken: String? = { + var iterator = fixture.tokenStore.tokenUpdates.makeAsyncIterator() + return await iterator.next() + }() + + try await client.execute( + uri: "/api/authenticate", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(bytes: body) + ) { response in + #expect(response.status == .noContent) + } + + #expect(await firstToken == "session-xyz") + } + } + + @Test( + "CRUD routes return 401 when no auth token has been captured", + arguments: [ + "/api/records/query", + "/api/records/create", + "/api/records/update", + "/api/records/delete", + ] + ) + internal func crudRejectsPreAuth(path: String) async throws { + let fixture = Self.makeFixture(authenticated: false) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: path, + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: "{}") + ) { response in + #expect(response.status == .unauthorized) + } + } + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+APIOnlyAuthentication.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+APIOnlyAuthentication.swift index dbe421db..efc186be 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+APIOnlyAuthentication.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+APIOnlyAuthentication.swift @@ -48,7 +48,7 @@ extension AuthenticationHelperTests { databaseOverride: nil ) - #expect(result.database == .public) + #expect(result.database == .public(.prefers(.serverToServer))) #expect(result.authMethod.contains("API-only")) } catch AuthenticationError.invalidAPIToken { // Expected with test token diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+AuthenticationMethodPriority.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+AuthenticationMethodPriority.swift index 5fdf8d32..c2f5bc2d 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+AuthenticationMethodPriority.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+AuthenticationMethodPriority.swift @@ -51,7 +51,7 @@ extension AuthenticationHelperTests { databaseOverride: nil ) - #expect(result.database == .public) + #expect(result.database == .public(.prefers(.serverToServer))) #expect(result.authMethod.contains("Server-to-server")) } catch AuthenticationError.invalidServerToServerCredentials { // Expected with test credentials diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+ServerToServerAuthentication.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+ServerToServerAuthentication.swift index 929d4345..89771f4f 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+ServerToServerAuthentication.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+ServerToServerAuthentication.swift @@ -70,7 +70,7 @@ extension AuthenticationHelperTests { ) // If we get here, validation succeeded (unlikely with test key) - #expect(result.database == .public) + #expect(result.database == .public(.prefers(.serverToServer))) #expect(result.authMethod.contains("Server-to-server")) } catch AuthenticationError.invalidServerToServerCredentials { // Expected - test key won't validate @@ -93,7 +93,7 @@ extension AuthenticationHelperTests { databaseOverride: nil ) - #expect(result.database == .public) + #expect(result.database == .public(.prefers(.serverToServer))) #expect(result.authMethod.contains("Server-to-server")) } catch AuthenticationError.invalidServerToServerCredentials { // Expected with test key diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+WebAuthentication.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+WebAuthentication.swift index ebe7960f..1379cb67 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+WebAuthentication.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+WebAuthentication.swift @@ -69,10 +69,10 @@ extension AuthenticationHelperTests { keyID: nil, privateKey: nil, privateKeyFile: nil, - databaseOverride: .public + databaseOverride: .public(.prefers(.serverToServer)) ) - #expect(result.database == .public) + #expect(result.database == .public(.prefers(.serverToServer))) #expect(result.authMethod.contains("Web authentication")) #expect(result.authMethod.contains("public")) } catch AuthenticationError.invalidWebAuthCredentials { diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/LoopbackAuthorityTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/LoopbackAuthorityTests.swift new file mode 100644 index 00000000..ecabbaab --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/LoopbackAuthorityTests.swift @@ -0,0 +1,89 @@ +// +// LoopbackAuthorityTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@testable import MistDemoKit + +@Suite("LoopbackAuthority Tests") +internal struct LoopbackAuthorityTests { + @Test( + "Accepts recognized loopback authorities", + arguments: [ + "localhost", + "127.0.0.1", + "[::1]", + "localhost:8080", + "127.0.0.1:8080", + "[::1]:8080", + ] + ) + internal func accepts(authority: String) { + #expect(LoopbackAuthority.isLoopback(authority)) + } + + @Test( + "Rejects non-loopback authorities", + arguments: [ + "", + "evil.com", + "evil.com:8080", + "example.com", + "example.com:443", + "api.apple-cloudkit.com", + "localhost.evil.com", + "localhost.evil.com:8080", + "localhostx", + "127.0.0.1.evil.com", + "127.0.0.1.evil.com:8080", + "127.0.0.2", + "10.0.0.1", + "10.0.0.1:8080", + "192.168.1.1:8080", + "0.0.0.0", + "[::2]", + "[2001:db8::1]", + "[2001:db8::1]:8080", + "[::1].evil.com", + ] + ) + internal func rejects(authority: String) { + #expect(!LoopbackAuthority.isLoopback(authority)) + } + + @Test("Rejects malformed bracketed IPv6 (missing closing bracket)") + internal func malformedMissingCloseBracket() { + #expect(!LoopbackAuthority.isLoopback("[::1")) + } + + @Test("Rejects bracketed IPv6 with trailing junk instead of port") + internal func malformedTrailingJunk() { + #expect(!LoopbackAuthority.isLoopback("[::1]junk")) + } +} diff --git a/Examples/MistDemo/examples/README.md b/Examples/MistDemo/examples/README.md index bafaaba8..4bef5063 100644 --- a/Examples/MistDemo/examples/README.md +++ b/Examples/MistDemo/examples/README.md @@ -94,10 +94,10 @@ swift run mistdemo query --record-type Note --limit 10 swift run mistdemo query --filter "title:contains:important" --filter "priority:gt:5" # With sorting -swift run mistdemo query --sort "createdAt:desc" --limit 5 +swift run mistdemo query --sort "index:desc" --limit 5 # Field selection -swift run mistdemo query --fields "title,createdAt,priority" +swift run mistdemo query --fields "title,index" ``` ### 📤 upload-asset.sh diff --git a/Examples/MistDemo/examples/query-records.sh b/Examples/MistDemo/examples/query-records.sh index b38a7356..7774540e 100755 --- a/Examples/MistDemo/examples/query-records.sh +++ b/Examples/MistDemo/examples/query-records.sh @@ -63,18 +63,18 @@ swift run mistdemo query $COMMON_ARGS --record-type Note \ --output-format table echo "" -echo -e "${GREEN}Example 4: Query with sorting (newest first)${NC}" -echo "Command: swift run mistdemo query $COMMON_ARGS --sort \"createdAt:desc\"" +echo -e "${GREEN}Example 4: Query with sorting (by index, descending)${NC}" +echo "Command: swift run mistdemo query $COMMON_ARGS --sort \"index:desc\"" swift run mistdemo query $COMMON_ARGS --record-type Note \ - --sort "createdAt:desc" \ + --sort "index:desc" \ --limit 5 \ --output-format table echo "" echo -e "${GREEN}Example 5: Query with field selection${NC}" -echo "Command: swift run mistdemo query $COMMON_ARGS --fields \"title,createdAt,priority\"" +echo "Command: swift run mistdemo query $COMMON_ARGS --fields \"title,index\"" swift run mistdemo query $COMMON_ARGS --record-type Note \ - --fields "title,createdAt,priority" \ + --fields "title,index" \ --limit 5 \ --output-format table diff --git a/Examples/MistDemo/project.yml b/Examples/MistDemo/project.yml index 535e7e8c..3e9f6b46 100644 --- a/Examples/MistDemo/project.yml +++ b/Examples/MistDemo/project.yml @@ -73,12 +73,6 @@ schemes: MistDemoApp-macOS: all run: config: Debug - # Baked from $CLOUDKIT_API_TOKEN at xcodegen-generate time. The .env - # file at Examples/MistDemo/.env (gitignored) is sourced by the - # `make generate` target. The whole *.xcodeproj is gitignored - # repo-wide, so the substituted value never lands in git. Empty - # string when the env var isn't set — AccountView falls back to the - # in-app TextField. environmentVariables: CLOUDKIT_API_TOKEN: ${CLOUDKIT_API_TOKEN} test: diff --git a/Examples/MistDemo/schema.ckdb b/Examples/MistDemo/schema.ckdb index 84b4b3f5..b8243bff 100644 --- a/Examples/MistDemo/schema.ckdb +++ b/Examples/MistDemo/schema.ckdb @@ -2,11 +2,11 @@ DEFINE SCHEMA RECORD TYPE Note ( "___recordID" REFERENCE QUERYABLE, + "___createTime" TIMESTAMP QUERYABLE SORTABLE, + "___modTime" TIMESTAMP QUERYABLE SORTABLE, "title" STRING QUERYABLE SORTABLE SEARCHABLE, "index" INT64 QUERYABLE SORTABLE, "image" ASSET, - "createdAt" TIMESTAMP QUERYABLE SORTABLE, - "modified" INT64 QUERYABLE, GRANT READ, CREATE, WRITE TO "_creator", GRANT READ, CREATE, WRITE TO "_icloud", diff --git a/Sources/MistKit/Authentication/Credentials/Credentials+TokenManager.swift b/Sources/MistKit/Authentication/Credentials/Credentials+TokenManager.swift index 242c0797..ee0fa22b 100644 --- a/Sources/MistKit/Authentication/Credentials/Credentials+TokenManager.swift +++ b/Sources/MistKit/Authentication/Credentials/Credentials+TokenManager.swift @@ -31,20 +31,19 @@ extension Credentials { /// Resolve the appropriate token manager for an outgoing request. /// - /// Picks among the populated `serverToServer` and `apiAuth` credentials - /// based on the target `database` and whether the route requires - /// user-context authentication: + /// The signing choice is encoded in `database`: + /// - `.public(let auth)` consults `auth` and the populated credential sets + /// per the table below. + /// - `.private` / `.shared` always use web-auth — CloudKit rejects + /// server-to-server signing on those scopes — and require + /// `apiAuth.webAuthToken`. /// - /// - `requiresUserContext == true`: web-auth is mandatory regardless of - /// database. CloudKit's user-identity routes (`fetchCaller`, - /// `lookupUsersByEmail`, `lookupUsersByRecordName`, - /// `discoverAllUserIdentities`) live on `.public` but still need - /// web-auth to identify the caller. - /// - `.public` + no user context: prefers server-to-server signing, falls - /// back to web-auth, then bare API-token. - /// - `.private` / `.shared`: requires `apiAuth.webAuthToken`. CloudKit - /// rejects server-to-server signing for these databases, so any - /// `serverToServer` material is ignored on this path. + /// Resolution for `.public(let auth)`: + /// - `auth.required` + mode's creds present → use `auth.mode`. + /// - `auth.required` + mode's creds absent → throw `.preferenceRequired`. + /// - `auth.prefers` + mode's creds present → use `auth.mode`. + /// - `auth.prefers` + mode's creds absent → fall back to the other mode. + /// - `auth.prefers` + neither mode configured → throw `.notConfigured`. /// /// - Throws: `CloudKitError.missingCredentials` when no populated credential /// set can satisfy the requested combination, @@ -52,63 +51,78 @@ extension Credentials { /// read, or any error from `ServerToServerAuthManager.init` when the PEM /// is malformed. internal func makeTokenManager( - for database: Database, - requiresUserContext: Bool = false + for database: Database ) throws -> any TokenManager { - if requiresUserContext { - return try makeUserContextTokenManager(database: database) - } switch database { - case .public: - return try makePublicTokenManager() + case .public(let auth): + return try makePublicTokenManager(auth: auth) case .private, .shared: return try makePrivateOrSharedTokenManager(database) } } - private func makeUserContextTokenManager( - database: Database + private func makePublicTokenManager( + auth: PublicAuthPreference ) throws -> any TokenManager { - guard let api = apiAuth, let webAuthToken = api.webAuthToken else { + switch auth.mode { + case .serverToServer: + return try makePublicWithS2SPreference(auth: auth) + case .webAuth: + return try makePublicWithWebAuthPreference(auth: auth) + } + } + + private func makePublicWithS2SPreference( + auth: PublicAuthPreference + ) throws -> any TokenManager { + if let s2s = serverToServer { + return try makeServerToServerManager(s2s) + } + if auth.required { throw CloudKitError.missingCredentials( - database: database, - reason: "user-context routes require apiAuth with a webAuthToken" + database: .public(auth), + availability: .preferenceRequired, + reason: "PublicAuthPreference.requires(.serverToServer) " + + "but no serverToServer credentials are configured" ) } - return WebAuthTokenManager( - apiToken: api.apiToken, - webAuthToken: webAuthToken + if let api = apiAuth { + return makeAPITokenManager(api) + } + throw CloudKitError.missingCredentials( + database: .public(auth), + availability: .notConfigured, + reason: "expected serverToServer or apiAuth credentials" ) } - private func makePublicTokenManager() throws -> any TokenManager { - if let s2s = serverToServer { - let pem: String - do { - pem = try s2s.privateKey.loadPEM() - } catch { - throw CloudKitError.invalidPrivateKey( - path: s2s.privateKey.filePath, - underlying: error - ) - } - return try ServerToServerAuthManager( - keyID: s2s.keyID, - pemString: pem + private func makePublicWithWebAuthPreference( + auth: PublicAuthPreference + ) throws -> any TokenManager { + if let api = apiAuth, let webAuthToken = api.webAuthToken { + return WebAuthTokenManager( + apiToken: api.apiToken, + webAuthToken: webAuthToken ) } + if auth.required { + throw CloudKitError.missingCredentials( + database: .public(auth), + availability: .preferenceRequired, + reason: "PublicAuthPreference.requires(.webAuth) " + + "but no apiAuth.webAuthToken is configured" + ) + } + if let s2s = serverToServer { + return try makeServerToServerManager(s2s) + } if let api = apiAuth { - if let webAuthToken = api.webAuthToken { - return WebAuthTokenManager( - apiToken: api.apiToken, - webAuthToken: webAuthToken - ) - } - return APITokenManager(apiToken: api.apiToken) + return makeAPITokenManager(api) } throw CloudKitError.missingCredentials( - database: .public, - reason: "expected serverToServer or apiAuth credentials" + database: .public(auth), + availability: .notConfigured, + reason: "expected apiAuth.webAuthToken or serverToServer credentials" ) } @@ -118,6 +132,7 @@ extension Credentials { guard let api = apiAuth, let webAuthToken = api.webAuthToken else { throw CloudKitError.missingCredentials( database: database, + availability: .notConfigured, reason: "private and shared databases require apiAuth with a webAuthToken" ) @@ -127,4 +142,34 @@ extension Credentials { webAuthToken: webAuthToken ) } + + private func makeServerToServerManager( + _ s2s: ServerToServerCredentials + ) throws -> any TokenManager { + let pem: String + do { + pem = try s2s.privateKey.loadPEM() + } catch { + throw CloudKitError.invalidPrivateKey( + path: s2s.privateKey.filePath, + underlying: error + ) + } + return try ServerToServerAuthManager( + keyID: s2s.keyID, + pemString: pem + ) + } + + private func makeAPITokenManager( + _ api: APICredentials + ) -> any TokenManager { + if let webAuthToken = api.webAuthToken { + return WebAuthTokenManager( + apiToken: api.apiToken, + webAuthToken: webAuthToken + ) + } + return APITokenManager(apiToken: api.apiToken) + } } diff --git a/Sources/MistKit/Authentication/PublicAuthPreference.swift b/Sources/MistKit/Authentication/PublicAuthPreference.swift new file mode 100644 index 00000000..74845464 --- /dev/null +++ b/Sources/MistKit/Authentication/PublicAuthPreference.swift @@ -0,0 +1,79 @@ +// +// PublicAuthPreference.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Per-call attribution choice for `Database.public` requests. +/// +/// CloudKit's public database accepts two signing methods: +/// server-to-server (key-pair signed, attributed to the developer key) and +/// web-auth (user session token, attributed to the iCloud user). The same +/// server legitimately writes some records as "the app" and others as +/// "this user", so the choice is genuinely per-call. +/// +/// Construct via the static factories — `internal init` keeps the four +/// valid `(mode, required)` combinations the only reachable ones. +/// +/// ```swift +/// // Server-attributed write, fall back to web-auth if S2S isn't configured. +/// service.createRecord(..., database: .public(.prefers(.serverToServer))) +/// +/// // User-attributed write, throw if web-auth credentials aren't configured. +/// service.createRecord(..., database: .public(.requires(.webAuth))) +/// ``` +public struct PublicAuthPreference: Sendable, Hashable { + /// Which signing material to use for a `.public` request. + public enum Mode: Sendable, Hashable { + /// Sign with the server-to-server key pair. Records are attributed to + /// the developer key, not an end user. + case serverToServer + + /// Sign with the user's web-auth token. Records are attributed to the + /// iCloud user that issued the token. + case webAuth + } + + /// The signing material the caller wants. + public let mode: Mode + + /// Whether to throw if `mode`'s credentials aren't configured. + /// + /// - `true` → throw `CloudKitError.missingCredentials(availability: .preferenceRequired)`. + /// - `false` → fall back to the other configured credential set when possible. + public let required: Bool + + /// Prefer the given mode; fall back to the other if it isn't configured. + public static func prefers(_ mode: Mode) -> Self { + .init(mode: mode, required: false) + } + + /// Require the given mode; throw `missingCredentials(.preferenceRequired)` + /// if its credentials aren't configured. + public static func requires(_ mode: Mode) -> Self { + .init(mode: mode, required: true) + } +} diff --git a/Sources/MistKit/Database.swift b/Sources/MistKit/Database.swift index edfb9037..b357a819 100644 --- a/Sources/MistKit/Database.swift +++ b/Sources/MistKit/Database.swift @@ -27,11 +27,36 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +/// CloudKit database scope plus, for `.public`, the per-call attribution +/// choice between server-to-server signing and web-auth signing. +/// +/// The auth payload is part of `.public` rather than a separate parameter +/// because it only matters there — CloudKit rejects server-to-server signing +/// on `.private` and `.shared`, so those cases carry no payload. Encoding +/// the choice in the type means call sites either pick one explicitly +/// (`Database.public(.requires(.webAuth))`) or use a scope where the choice +/// doesn't exist (`Database.private`). +public enum Database: Sendable, Hashable { + /// Public database. Caller must pick a signing method via + /// `PublicAuthPreference`. + case `public`(PublicAuthPreference) -/// CloudKit database types -public enum Database: String, Sendable { - case `public` + /// Private database. Web-auth is the only valid signing method. case `private` + + /// Shared database. Web-auth is the only valid signing method. case shared + + /// The path segment used to build CloudKit Web Services URLs + /// (`/database/{version}/{container}/{environment}/{database}/…`). + public var pathSegment: String { + switch self { + case .public: + return "public" + case .private: + return "private" + case .shared: + return "shared" + } + } } diff --git a/Sources/MistKit/MistKitConfiguration+ConvenienceInitializers.swift b/Sources/MistKit/MistKitConfiguration+ConvenienceInitializers.swift index 1887d1c0..96d22a87 100644 --- a/Sources/MistKit/MistKitConfiguration+ConvenienceInitializers.swift +++ b/Sources/MistKit/MistKitConfiguration+ConvenienceInitializers.swift @@ -100,7 +100,8 @@ extension MistKitConfiguration { MistKitConfiguration( container: container, environment: environment, - database: .public, // Server-to-server only supports public database + database: .public(.requires(.serverToServer)), + // Server-to-server only supports public database apiToken: "", // Not used with server-to-server auth webAuthToken: nil, keyID: keyID, diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+AssetOperations.swift b/Sources/MistKit/Service/Extensions/CloudKitService+AssetOperations.swift index 647c31ef..ed2ada45 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+AssetOperations.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+AssetOperations.swift @@ -75,7 +75,7 @@ extension CloudKitService { fieldName: String, recordName: String? = nil, using uploader: AssetUploader? = nil, - database: Database = .public + database: Database ) async throws(CloudKitError) -> AssetUploadReceipt { let maxSize: Int = 15 * 1_024 * 1_024 guard data.count <= maxSize else { @@ -138,7 +138,7 @@ extension CloudKitService { fieldName: String, recordName: String? = nil, zoneID: ZoneID? = nil, - database: Database = .public + database: Database ) async throws(CloudKitError) -> AssetUploadToken { do { let tokenRequest = diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+Classification.swift b/Sources/MistKit/Service/Extensions/CloudKitService+Classification.swift index 9663b26b..03e621d9 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+Classification.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+Classification.swift @@ -64,11 +64,13 @@ extension CloudKitService { /// - Throws: `CloudKitError` if the underlying query fails. public func fetchExistingRecordNames( recordType: String, - limit: Int? = nil + limit: Int? = nil, + database: Database ) async throws(CloudKitError) -> Set { let result: QueryResult = try await queryRecords( recordType: recordType, - limit: limit ?? Self.maxRecordsPerRequest + limit: limit ?? Self.maxRecordsPerRequest, + database: database ) return Set(result.records.map(\.recordName)) } @@ -108,9 +110,14 @@ extension CloudKitService { public func modifyRecords( _ operations: [RecordOperation], classification: OperationClassification, - atomic: Bool = false + atomic: Bool = false, + database: Database ) async throws(CloudKitError) -> BatchSyncResult { - let records = try await modifyRecords(operations, atomic: atomic) + let records = try await modifyRecords( + operations, + atomic: atomic, + database: database + ) return BatchSyncResult(records: records, classification: classification) } } diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+ClientDispatch.swift b/Sources/MistKit/Service/Extensions/CloudKitService+ClientDispatch.swift index b8ffdf7a..88b041e2 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+ClientDispatch.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+ClientDispatch.swift @@ -35,29 +35,29 @@ extension CloudKitService { /// Resolve the token manager for an outgoing request and build a fresh /// OpenAPI `Client` whose middleware chain authenticates against it. /// - /// Called once per dispatched operation. When the service was built with a - /// caller-supplied `tokenManager:`, that fixed manager is used regardless of - /// `database` / `requiresUserContext`. Otherwise `Credentials` picks an - /// appropriate manager via its `makeTokenManager(for:requiresUserContext:)` - /// extension. + /// Called once per dispatched operation. The signing choice for `.public` + /// requests is carried by the `Database` value itself + /// (`.public(PublicAuthPreference)`); `.private` / `.shared` always use + /// web-auth. + /// + /// When the service was built with a caller-supplied `tokenManager:`, that + /// fixed manager is used regardless of `database`. Otherwise `Credentials` + /// resolves the manager via `makeTokenManager(for:)`. /// /// - Throws: `CloudKitError.missingCredentials` when `Credentials` cannot /// satisfy the requested combination. internal func client( - for database: Database, - requiresUserContext: Bool = false + for database: Database ) throws -> Client { let tokenManager: any TokenManager if let fixedTokenManager { tokenManager = fixedTokenManager } else if let credentials { - tokenManager = try credentials.makeTokenManager( - for: database, - requiresUserContext: requiresUserContext - ) + tokenManager = try credentials.makeTokenManager(for: database) } else { throw CloudKitError.missingCredentials( database: database, + availability: .notConfigured, reason: "service has neither credentials nor a fixed token manager" ) } diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+LookupOperations.swift b/Sources/MistKit/Service/Extensions/CloudKitService+LookupOperations.swift index 99b0e91a..6ec8adc6 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+LookupOperations.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+LookupOperations.swift @@ -39,7 +39,7 @@ extension CloudKitService { internal func modifyRecords( operations: [Components.Schemas.RecordOperation], atomic: Bool = true, - database: Database = .public + database: Database ) async throws(CloudKitError) -> [RecordInfo] { do { let client = try self.client(for: database) @@ -71,7 +71,7 @@ extension CloudKitService { public func lookupRecords( recordNames: [String], desiredKeys: [String]? = nil, - database: Database = .public + database: Database ) async throws(CloudKitError) -> [RecordInfo] { do { let client = try self.client(for: database) diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+Operations.swift b/Sources/MistKit/Service/Extensions/CloudKitService+Operations.swift index 21292185..32eebec6 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+Operations.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+Operations.swift @@ -96,7 +96,7 @@ extension CloudKitService { sortBy: [QuerySort]? = nil, limit: Int? = nil, desiredKeys: [String]? = nil, - database: Database = .public + database: Database ) async throws(CloudKitError) -> [RecordInfo] { let result: QueryResult = try await queryRecords( recordType: recordType, @@ -149,7 +149,7 @@ extension CloudKitService { limit: Int? = nil, desiredKeys: [String]? = nil, continuationMarker: String? = nil, - database: Database = .public + database: Database ) async throws(CloudKitError) -> QueryResult { let effectiveLimit = limit ?? defaultQueryLimit diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+QueryPagination.swift b/Sources/MistKit/Service/Extensions/CloudKitService+QueryPagination.swift index 0ce61153..7c423aea 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+QueryPagination.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+QueryPagination.swift @@ -62,7 +62,7 @@ extension CloudKitService { pageSize: Int? = nil, desiredKeys: [String]? = nil, maxPages: Int = 1_000, - database: Database = .public + database: Database ) async throws(CloudKitError) -> [RecordInfo] { var allRecords: [RecordInfo] = [] var currentMarker: String? diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+RecordManaging.swift b/Sources/MistKit/Service/Extensions/CloudKitService+RecordManaging.swift index c6558538..ecf1e0c1 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+RecordManaging.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+RecordManaging.swift @@ -36,6 +36,12 @@ import Foundation @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) extension CloudKitService: RecordManaging { /// Query records of a specific type from CloudKit (deprecated single-page form) + /// + /// `RecordManaging` is a database-agnostic abstraction predating per-call + /// `PublicAuthPreference`; this conformance targets the public database + /// with `.requires(.serverToServer)` to preserve the previous "S2S when + /// configured" behavior. Callers who need different attribution should + /// call `CloudKitService` directly with an explicit `Database` value. @available( *, deprecated, message: "Silently truncates at one page. Use queryAllRecords or queryRecords -> QueryResult." @@ -47,7 +53,8 @@ extension CloudKitService: RecordManaging { sortBy: nil, limit: 200, desiredKeys: nil, - continuationMarker: nil + continuationMarker: nil, + database: .public(.prefers(.serverToServer)) ) return result.records } @@ -57,7 +64,10 @@ extension CloudKitService: RecordManaging { _ operations: [RecordOperation], recordType: String ) async throws { - _ = try await self.modifyRecords(operations) + _ = try await self.modifyRecords( + operations, + database: .public(.prefers(.serverToServer)) + ) } /// Query all records of a specific type, automatically paginating @@ -66,7 +76,8 @@ extension CloudKitService: RecordManaging { recordType: recordType, filters: nil, sortBy: nil, - pageSize: nil + pageSize: nil, + database: .public(.prefers(.serverToServer)) ) } } diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+SyncOperations.swift b/Sources/MistKit/Service/Extensions/CloudKitService+SyncOperations.swift index d7af0c32..a6a5b0eb 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+SyncOperations.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+SyncOperations.swift @@ -81,7 +81,7 @@ extension CloudKitService { zoneID: ZoneID? = nil, syncToken: String? = nil, resultsLimit: Int? = nil, - database: Database = .public + database: Database ) async throws(CloudKitError) -> RecordChangesResult { if let limit = resultsLimit { guard limit > 0 && limit <= 200 else { @@ -166,7 +166,7 @@ extension CloudKitService { syncToken: String? = nil, resultsLimit: Int? = nil, maxPages: Int = 1_000, - database: Database = .public + database: Database ) async throws(CloudKitError) -> (records: [RecordInfo], syncToken: String?) { var allRecords: [RecordInfo] = [] var currentToken = syncToken diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+UserOperations.swift b/Sources/MistKit/Service/Extensions/CloudKitService+UserOperations.swift index 7156981b..d119473e 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+UserOperations.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+UserOperations.swift @@ -50,13 +50,13 @@ extension CloudKitService { /// `Credentials` must include an `apiAuth` with a `webAuthToken`. public func fetchCaller() async throws(CloudKitError) -> UserInfo { do { - let client = try self.client(for: .public, requiresUserContext: true) + let client = try self.client(for: .public(.requires(.webAuth))) let response = try await client.getCaller( .init( path: Operations.getCaller.Input.Path( containerIdentifier: containerIdentifier, environment: environment, - database: .public + database: .public(.requires(.webAuth)) ) ) ) @@ -91,13 +91,13 @@ extension CloudKitService { ) public func discoverAllUserIdentities() async throws(CloudKitError) -> [UserIdentity] { do { - let client = try self.client(for: .public, requiresUserContext: true) + let client = try self.client(for: .public(.requires(.webAuth))) let response = try await client.discoverAllUserIdentities( .init( path: Operations.discoverAllUserIdentities.Input.Path( containerIdentifier: containerIdentifier, environment: environment, - database: .public + database: .public(.requires(.webAuth)) ) ) ) @@ -121,13 +121,13 @@ extension CloudKitService { _ emails: [String] ) async throws(CloudKitError) -> [UserIdentity] { do { - let client = try self.client(for: .public, requiresUserContext: true) + let client = try self.client(for: .public(.requires(.webAuth))) let response = try await client.lookupUsersByEmail( .init( path: Operations.lookupUsersByEmail.Input.Path( containerIdentifier: containerIdentifier, environment: environment, - database: .public + database: .public(.requires(.webAuth)) ), body: .json( .init(users: emails.map { .init(emailAddress: $0) }) @@ -151,13 +151,13 @@ extension CloudKitService { _ recordNames: [String] ) async throws(CloudKitError) -> [UserIdentity] { do { - let client = try self.client(for: .public, requiresUserContext: true) + let client = try self.client(for: .public(.requires(.webAuth))) let response = try await client.lookupUsersByRecordName( .init( path: Operations.lookupUsersByRecordName.Input.Path( containerIdentifier: containerIdentifier, environment: environment, - database: .public + database: .public(.requires(.webAuth)) ), body: .json( .init(users: recordNames.map { .init(userRecordName: $0) }) @@ -181,13 +181,13 @@ extension CloudKitService { lookupInfos: [UserIdentityLookupInfo] ) async throws(CloudKitError) -> [UserIdentity] { do { - let client = try self.client(for: .public, requiresUserContext: true) + let client = try self.client(for: .public(.requires(.webAuth))) let response = try await client.discoverUserIdentities( .init( path: Operations.discoverUserIdentities.Input.Path( containerIdentifier: containerIdentifier, environment: environment, - database: .public + database: .public(.requires(.webAuth)) ), body: .json( .init( diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+WriteOperations.swift b/Sources/MistKit/Service/Extensions/CloudKitService+WriteOperations.swift index 2cf7874c..801d3783 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+WriteOperations.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+WriteOperations.swift @@ -49,7 +49,7 @@ extension CloudKitService { public func modifyRecords( _ operations: [RecordOperation], atomic: Bool = false, - database: Database = .public + database: Database ) async throws(CloudKitError) -> [RecordInfo] { do { let apiOperations = try operations.map { @@ -97,7 +97,7 @@ extension CloudKitService { recordType: String, recordName: String? = nil, fields: [String: FieldValue], - database: Database = .public + database: Database ) async throws(CloudKitError) -> RecordInfo { let operation = RecordOperation.create( recordType: recordType, @@ -125,7 +125,7 @@ extension CloudKitService { recordName: String, fields: [String: FieldValue], recordChangeTag: String? = nil, - database: Database = .public + database: Database ) async throws(CloudKitError) -> RecordInfo { let operation = RecordOperation.update( recordType: recordType, @@ -151,7 +151,7 @@ extension CloudKitService { recordType: String, recordName: String, recordChangeTag: String? = nil, - database: Database = .public + database: Database ) async throws(CloudKitError) { let operation = RecordOperation.delete( recordType: recordType, diff --git a/Sources/MistKit/Service/ResponseProcessing/CloudKitError.swift b/Sources/MistKit/Service/ResponseProcessing/CloudKitError.swift index ef57a1e4..4d5d416e 100644 --- a/Sources/MistKit/Service/ResponseProcessing/CloudKitError.swift +++ b/Sources/MistKit/Service/ResponseProcessing/CloudKitError.swift @@ -45,7 +45,11 @@ public enum CloudKitError: LocalizedError, Sendable { case networkError(URLError) case unsupportedOperationType(String) case paginationLimitExceeded(maxPages: Int, records: [RecordInfo]) - case missingCredentials(database: Database, reason: String) + case missingCredentials( + database: Database, + availability: CredentialAvailability = .notConfigured, + reason: String + ) case invalidPrivateKey(path: String?, underlying: any Error) /// HTTP status code if this error originated from an HTTP response, otherwise nil. @@ -127,9 +131,17 @@ public enum CloudKitError: LocalizedError, Sendable { return "CloudKit query exceeded pagination limit of \(maxPages) pages " + "(collected \(records.count) records)" - case .missingCredentials(let database, let reason): + case .missingCredentials(let database, let availability, let reason): + let availabilityLabel: String + switch availability { + case .notConfigured: + availabilityLabel = "not configured" + case .preferenceRequired: + availabilityLabel = "required by preference but not configured" + } return - "Missing credentials for database '\(database.rawValue)': \(reason)" + "Missing credentials for database '\(database.pathSegment)' " + + "(\(availabilityLabel)): \(reason)" case .invalidPrivateKey(let path, let underlying): let location = path.map { "from '\($0)'" } ?? "from inline material" return diff --git a/Sources/MistKit/Service/ResponseProcessing/CredentialAvailability.swift b/Sources/MistKit/Service/ResponseProcessing/CredentialAvailability.swift new file mode 100644 index 00000000..a5d8eb2d --- /dev/null +++ b/Sources/MistKit/Service/ResponseProcessing/CredentialAvailability.swift @@ -0,0 +1,46 @@ +// +// CredentialAvailability.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Why a credential set was missing when the dispatcher tried to satisfy +/// a request. +/// +/// Attached to `CloudKitError.missingCredentials(_:availability:reason:)` so +/// callers can distinguish a misconfiguration ("no credentials at all") from +/// a deliberate `PublicAuthPreference.requires(...)` that couldn't be +/// satisfied ("we have web-auth but the caller required server-to-server"). +public enum CredentialAvailability: Sendable, Hashable { + /// No credential of the type the route needs is configured on + /// `Credentials`. + case notConfigured + + /// A credential type was required by `PublicAuthPreference.requires(_:)` + /// but is not configured. The dispatcher refuses to silently substitute + /// the other credential set. + case preferenceRequired +} diff --git a/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PrivateKeyLoad.swift b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PrivateKeyLoad.swift index 0d8db709..2560f94f 100644 --- a/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PrivateKeyLoad.swift +++ b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PrivateKeyLoad.swift @@ -48,7 +48,7 @@ extension CredentialsTokenManagerTests { ) ) do { - _ = try credentials.makeTokenManager(for: .public) + _ = try credentials.makeTokenManager(for: .public(.requires(.serverToServer))) Issue.record("expected makeTokenManager to throw .invalidPrivateKey") } catch let error as CloudKitError { guard case .invalidPrivateKey(let path, _) = error else { diff --git a/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PublicDatabase.swift b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PublicDatabase.swift index b0b72c24..7e2354b4 100644 --- a/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PublicDatabase.swift +++ b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+PublicDatabase.swift @@ -35,8 +35,10 @@ import Testing extension CredentialsTokenManagerTests { @Suite("Public Database") internal struct PublicDatabase { - @Test(".public + serverToServer → ServerToServerAuthManager") - internal func publicPicksServerToServer() async throws { + // MARK: - prefers(.serverToServer) + + @Test(".public(.prefers(.serverToServer)) + S2S only → S2S") + internal func prefersS2SOnlyS2SPicksS2S() async throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { Issue.record("ServerToServerAuthManager is not available on this operating system.") return @@ -44,36 +46,104 @@ extension CredentialsTokenManagerTests { let credentials = try Credentials( serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials() ) - let manager = try credentials.makeTokenManager(for: .public) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.serverToServer)) + ) + #expect(manager is ServerToServerAuthManager) + } + + @Test(".public(.prefers(.serverToServer)) + both creds → S2S") + internal func prefersS2SBothCredsPicksS2S() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials(), + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() + ) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.serverToServer)) + ) #expect(manager is ServerToServerAuthManager) } - @Test(".public + apiAuth.webAuthToken → WebAuthTokenManager") - internal func publicPicksWebAuthOverAPIToken() async throws { + @Test(".public(.prefers(.serverToServer)) + web-auth only → falls back to web-auth") + internal func prefersS2SOnlyWebAuthFallsBackToWebAuth() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() + ) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.serverToServer)) + ) + #expect(manager is WebAuthTokenManager) + } + + @Test(".public(.prefers(.serverToServer)) + API token only → APITokenManager") + internal func prefersS2SAPITokenOnlyFallsBackToAPIToken() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsTokenOnly() + ) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.serverToServer)) + ) + #expect(manager is APITokenManager) + } + + // MARK: - prefers(.webAuth) + + @Test(".public(.prefers(.webAuth)) + both creds → web-auth") + internal func prefersWebAuthBothCredsPicksWebAuth() async throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials(), apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() ) - let manager = try credentials.makeTokenManager(for: .public) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.webAuth)) + ) #expect(manager is WebAuthTokenManager) } - @Test(".public + apiAuth (token only) → APITokenManager") - internal func publicPicksAPITokenWhenNoWebAuth() async throws { + @Test(".public(.prefers(.webAuth)) + S2S only → falls back to S2S") + internal func prefersWebAuthOnlyS2SFallsBackToS2S() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials() + ) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.webAuth)) + ) + #expect(manager is ServerToServerAuthManager) + } + + @Test(".public(.prefers(.webAuth)) + API token only → APITokenManager") + internal func prefersWebAuthAPITokenOnlyFallsBackToAPIToken() async throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } let credentials = try Credentials( apiAuth: CredentialsTokenManagerTests.makeAPICredentialsTokenOnly() ) - let manager = try credentials.makeTokenManager(for: .public) + let manager = try credentials.makeTokenManager( + for: .public(.prefers(.webAuth)) + ) #expect(manager is APITokenManager) } - @Test(".public + serverToServer prefers S2S over apiAuth") - internal func publicPrefersServerToServerOverAPIAuth() async throws { + // MARK: - requires(.serverToServer) + + @Test(".public(.requires(.serverToServer)) + both creds → S2S") + internal func requiresS2SBothCredsPicksS2S() async throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } @@ -81,8 +151,76 @@ extension CredentialsTokenManagerTests { serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials(), apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() ) - let manager = try credentials.makeTokenManager(for: .public) + let manager = try credentials.makeTokenManager( + for: .public(.requires(.serverToServer)) + ) #expect(manager is ServerToServerAuthManager) } + + @Test(".public(.requires(.serverToServer)) without S2S → throws preferenceRequired") + internal func requiresS2SWithoutS2SThrowsPreferenceRequired() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() + ) + #expect { + _ = try credentials.makeTokenManager( + for: .public(.requires(.serverToServer)) + ) + } throws: { error in + guard + let cloudKitError = error as? CloudKitError, + case .missingCredentials(_, let availability, _) = cloudKitError + else { return false } + return availability == .preferenceRequired + } + } + + // MARK: - requires(.webAuth) + + @Test(".public(.requires(.webAuth)) + both creds → web-auth") + internal func requiresWebAuthBothCredsPicksWebAuth() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials(), + apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() + ) + let manager = try credentials.makeTokenManager( + for: .public(.requires(.webAuth)) + ) + #expect(manager is WebAuthTokenManager) + } + + @Test(".public(.requires(.webAuth)) without web-auth → throws preferenceRequired") + internal func requiresWebAuthWithoutWebAuthThrowsPreferenceRequired() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + let credentials = try Credentials( + serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials() + ) + #expect { + _ = try credentials.makeTokenManager( + for: .public(.requires(.webAuth)) + ) + } throws: { error in + guard + let cloudKitError = error as? CloudKitError, + case .missingCredentials(_, let availability, _) = cloudKitError + else { return false } + return availability == .preferenceRequired + } + } + + // Note: The "no creds at all" path in the dispatcher's resolution table + // (".prefers + neither mode configured → throws notConfigured") is not + // tested here because `Credentials.init` asserts that at least one of + // `serverToServer` or `apiAuth` is populated. Reaching `notConfigured` + // would require constructing an empty `Credentials`, which the type + // doesn't permit. } } diff --git a/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+UserContext.swift b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+UserContext.swift index 3beecfe5..4774b0bf 100644 --- a/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+UserContext.swift +++ b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests+UserContext.swift @@ -33,10 +33,15 @@ import Testing @testable import MistKit extension CredentialsTokenManagerTests { + /// Coverage for the "user-context" routes (`users/caller`, + /// `users/lookup/*`, `users/discover`). With the per-call + /// `PublicAuthPreference` rewrite these no longer take a separate + /// `requiresUserContext` flag — they pass `.public(.requires(.webAuth))` + /// directly to the dispatcher. @Suite("User-Context Branch") internal struct UserContext { - @Test("requiresUserContext on .public → WebAuthTokenManager") - internal func userContextOnPublicPicksWebAuth() async throws { + @Test(".public(.requires(.webAuth)) + both creds → web-auth (S2S ignored)") + internal func requiresWebAuthOnPublicIgnoresS2S() async throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } @@ -45,14 +50,13 @@ extension CredentialsTokenManagerTests { apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() ) let manager = try credentials.makeTokenManager( - for: .public, requiresUserContext: true + for: .public(.requires(.webAuth)) ) - // S2S is present, but user-context routes ignore it — must pick web-auth. #expect(manager is WebAuthTokenManager) } - @Test("requiresUserContext without web-auth → throws missingCredentials") - internal func userContextWithoutWebAuthThrows() async throws { + @Test(".public(.requires(.webAuth)) + S2S only → throws preferenceRequired") + internal func requiresWebAuthWithoutWebAuthThrows() async throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } @@ -61,13 +65,13 @@ extension CredentialsTokenManagerTests { ) #expect(throws: CloudKitError.self) { _ = try credentials.makeTokenManager( - for: .public, requiresUserContext: true + for: .public(.requires(.webAuth)) ) } } - @Test("requiresUserContext with apiAuth (token only) → throws missingCredentials") - internal func userContextWithAPITokenOnlyThrows() async throws { + @Test(".public(.requires(.webAuth)) + API token only → throws preferenceRequired") + internal func requiresWebAuthWithAPITokenOnlyThrows() async throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } @@ -76,65 +80,7 @@ extension CredentialsTokenManagerTests { ) #expect(throws: CloudKitError.self) { _ = try credentials.makeTokenManager( - for: .public, requiresUserContext: true - ) - } - } - - @Test("requiresUserContext on .private + web-auth → WebAuthTokenManager") - internal func userContextOnPrivatePicksWebAuth() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - let credentials = try Credentials( - apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() - ) - let manager = try credentials.makeTokenManager( - for: .private, requiresUserContext: true - ) - #expect(manager is WebAuthTokenManager) - } - - @Test("requiresUserContext on .shared + web-auth → WebAuthTokenManager") - internal func userContextOnSharedPicksWebAuth() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - let credentials = try Credentials( - apiAuth: CredentialsTokenManagerTests.makeAPICredentialsWithWebAuth() - ) - let manager = try credentials.makeTokenManager( - for: .shared, requiresUserContext: true - ) - #expect(manager is WebAuthTokenManager) - } - - @Test("requiresUserContext on .private + S2S only → throws missingCredentials") - internal func userContextOnPrivateRejectsServerToServerOnly() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - let credentials = try Credentials( - serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials() - ) - #expect(throws: CloudKitError.self) { - _ = try credentials.makeTokenManager( - for: .private, requiresUserContext: true - ) - } - } - - @Test("requiresUserContext on .shared + S2S only → throws missingCredentials") - internal func userContextOnSharedRejectsServerToServerOnly() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - let credentials = try Credentials( - serverToServer: CredentialsTokenManagerTests.makeServerToServerCredentials() - ) - #expect(throws: CloudKitError.self) { - _ = try credentials.makeTokenManager( - for: .shared, requiresUserContext: true + for: .public(.requires(.webAuth)) ) } } diff --git a/Tests/MistKitTests/Core/DatabaseTests.swift b/Tests/MistKitTests/Core/DatabaseTests.swift index be56e064..679290f5 100644 --- a/Tests/MistKitTests/Core/DatabaseTests.swift +++ b/Tests/MistKitTests/Core/DatabaseTests.swift @@ -6,11 +6,12 @@ import Testing /// Test suite for Database enum functionality and behavior validation @Suite("Database") internal struct DatabaseTests { - /// Tests Database enum raw values - @Test("Database enum raw values") - internal func databaseRawValues() { - #expect(Database.public.rawValue == "public") - #expect(Database.private.rawValue == "private") - #expect(Database.shared.rawValue == "shared") + /// Tests that each Database scope produces the expected URL path segment. + @Test("Database pathSegment values") + internal func databasePathSegments() { + #expect(Database.public(.prefers(.serverToServer)).pathSegment == "public") + #expect(Database.public(.requires(.webAuth)).pathSegment == "public") + #expect(Database.private.pathSegment == "private") + #expect(Database.shared.pathSegment == "shared") } } diff --git a/Tests/MistKitTests/PublicTypes/CloudKitErrorTests.swift b/Tests/MistKitTests/PublicTypes/CloudKitErrorTests.swift new file mode 100644 index 00000000..b3ebc9c6 --- /dev/null +++ b/Tests/MistKitTests/PublicTypes/CloudKitErrorTests.swift @@ -0,0 +1,65 @@ +// +// CloudKitErrorTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +@Suite("CloudKitError") +internal struct CloudKitErrorTests { + @Test(".missingCredentials with .notConfigured describes as not configured") + internal func missingCredentialsNotConfiguredDescribesAsNotConfigured() throws { + let error = CloudKitError.missingCredentials( + database: .public(.prefers(.webAuth)), + availability: .notConfigured, + reason: "no API token provided" + ) + + let description = try #require(error.errorDescription) + #expect(description.contains("public")) + #expect(description.contains("not configured")) + #expect(!description.contains("required by preference")) + #expect(description.contains("no API token provided")) + } + + @Test(".missingCredentials with .preferenceRequired describes as preference required") + internal func missingCredentialsPreferenceRequiredDescribesAsPreferenceRequired() throws { + let error = CloudKitError.missingCredentials( + database: .public(.requires(.webAuth)), + availability: .preferenceRequired, + reason: "web-auth preference required" + ) + + let description = try #require(error.errorDescription) + #expect(description.contains("public")) + #expect(description.contains("required by preference but not configured")) + #expect(description.contains("web-auth preference required")) + } +} diff --git a/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+Concurrent.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+Concurrent.swift index c50c1dca..82cb0ba9 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+Concurrent.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+Concurrent.swift @@ -53,7 +53,7 @@ extension CloudKitServiceTests.FetchChanges { ) { group in for _ in 0..