From b20cc092d9b722204a2ab6ac33f1488e70a62068 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 11 May 2026 17:06:22 -0400 Subject: [PATCH 1/9] Resolve #288: extract AuthTokenServer + LoopbackAuthority from AuthTokenCommand AuthTokenCommand now only owns lifecycle and channel plumbing. Route construction moves to a reusable AuthTokenServer; loopback validation moves to a standalone LoopbackAuthority helper. Both gain dedicated unit + router-level tests via HummingbirdTesting. Co-Authored-By: Claude Opus 4.7 (1M context) --- Examples/MistDemo/Package.swift | 14 ++ .../Commands/AuthTokenCommand.swift | 47 +---- .../AuthTokenServer.swift} | 118 ++++++----- .../Utilities/LoopbackAuthority.swift | 84 ++++++++ ...andTests+LoopbackAuthorityValidation.swift | 76 ------- .../Server/AuthTokenServerTests.swift | 190 ++++++++++++++++++ .../Utilities/LoopbackAuthorityTests.swift | 89 ++++++++ 7 files changed, 441 insertions(+), 177 deletions(-) rename Examples/MistDemo/Sources/MistDemoKit/{Commands/AuthTokenCommand+Routes.swift => Server/AuthTokenServer.swift} (57%) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Utilities/LoopbackAuthority.swift delete mode 100644 Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+LoopbackAuthorityValidation.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Server/AuthTokenServerTests.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Utilities/LoopbackAuthorityTests.swift diff --git a/Examples/MistDemo/Package.swift b/Examples/MistDemo/Package.swift index 4b89becd..9951b93e 100644 --- a/Examples/MistDemo/Package.swift +++ b/Examples/MistDemo/Package.swift @@ -175,6 +175,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/Sources/MistDemoKit/Commands/AuthTokenCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift index 1762ccf9..32714a0b 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 AsyncAlgorithms + internal import Foundation + internal import Hummingbird /// Command to obtain web authentication token via browser flow. public struct AuthTokenCommand: MistDemoCommand { @@ -66,35 +63,6 @@ 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 - } - } else { - host = String( - authority.split(separator: ":").first - ?? Substring(authority) - ) - } - 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)") @@ -102,17 +70,18 @@ let tokenChannel = AsyncChannel() let responseCompleteChannel = AsyncChannel() - let router = try buildRouter( + let server = AuthTokenServer( + apiToken: config.apiToken, + containerIdentifier: config.containerIdentifier, tokenChannel: tokenChannel, responseCompleteChannel: responseCompleteChannel ) + let router = try server.makeRouter() let app = Application( router: router, configuration: .init( - address: .hostname( - config.host, port: config.port - ) + address: .hostname(config.host, port: config.port) ) ) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand+Routes.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/AuthTokenServer.swift similarity index 57% rename from Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand+Routes.swift rename to Examples/MistDemo/Sources/MistDemoKit/Server/AuthTokenServer.swift index 094143c5..adc4a753 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand+Routes.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/AuthTokenServer.swift @@ -1,5 +1,5 @@ // -// AuthTokenCommand+Routes.swift +// AuthTokenServer.swift // MistDemo // // Created by Leo Dion. @@ -28,99 +28,98 @@ // #if canImport(Hummingbird) - import AsyncAlgorithms - import Foundation - import HTTPTypes - import Hummingbird - import Logging - import MistKit + internal import AsyncAlgorithms + internal import Foundation + internal import HTTPTypes + internal import Hummingbird + internal import Logging - extension AuthTokenCommand { - fileprivate struct CloudKitClientConfig: Encodable { - let apiToken: String - let containerIdentifier: String + /// Routing surface for the auth-token loopback flow. + /// + /// Owns the index, config, and authentication endpoints used by the + /// browser-side script during a CloudKit web-auth round trip. The owning + /// command (`AuthTokenCommand`) provides credentials and the rendezvous + /// channels and is responsible for the `Application` lifecycle; this type + /// only knows how to assemble a `Router`. + internal struct AuthTokenServer { + /// JSON payload returned from `GET /api/config`, consumed by the + /// browser-side script to configure CloudKit JS. + internal struct CloudKitClientConfig: Encodable { + internal let apiToken: String + internal let containerIdentifier: String } - internal func buildRouter( - tokenChannel: AsyncChannel, - responseCompleteChannel: AsyncChannel - ) throws -> Router { + internal let apiToken: String + internal let containerIdentifier: String + internal let tokenChannel: AsyncChannel + internal let responseCompleteChannel: AsyncChannel + + /// Build the router for this server. + internal func makeRouter() throws -> Router { let router = Router(context: BasicRequestContext.self) router.middlewares.add(LogRequestsMiddleware(.info)) - let indexBytes = ByteBuffer( - string: AuthTokenIndexHTML.content + addIndexEndpoint(router: router) + + let api = router.group("api") + let configData = try JSONEncoder().encode( + CloudKitClientConfig( + apiToken: apiToken, + containerIdentifier: containerIdentifier + ) ) + addConfigEndpoint(api: api, configData: configData) + addAuthEndpoint(api: api) + + return router + } + + private func addIndexEndpoint( + router: Router + ) { + let indexBytes = ByteBuffer(string: AuthTokenIndexHTML.content) let indexResponseBuilder: @Sendable () -> Response = { Response( status: .ok, - headers: [ - .contentType: "text/html; charset=utf-8" - ], + 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("/") { _, _ -> 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( + private func addConfigEndpoint( api: RouterGroup, configData: Data ) { api.get("config") { request, _ -> Response in let authority = request.head.authority ?? "" - guard Self.isLoopbackAuthority(authority) else { + guard LoopbackAuthority.isLoopback(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.write(ByteBuffer(bytes: configData)) try await writer.finish(nil) } ) } } - internal func addAuthEndpoint( - api: RouterGroup, - tokenChannel: AsyncChannel, - responseCompleteChannel: AsyncChannel + private func addAuthEndpoint( + api: RouterGroup ) { - api.post("authenticate") { - request, context -> Response in + let tokenChannel = self.tokenChannel + let responseCompleteChannel = self.responseCompleteChannel + api.post("authenticate") { request, context -> Response in let authRequest = try await request.decode( as: AuthRequest.self, context: context ) @@ -128,12 +127,9 @@ let response = AuthResponse( userRecordName: authRequest.userRecordName, - cloudKitData: .init( - user: nil, zones: [], error: nil - ), + cloudKitData: .init(user: nil, zones: [], error: nil), message: "Authentication successful!" ) - let jsonData = try JSONEncoder().encode(response) Task { @@ -145,9 +141,7 @@ status: .ok, headers: [.contentType: "application/json"], body: ResponseBody { writer in - try await writer.write( - ByteBuffer(bytes: jsonData) - ) + try await writer.write(ByteBuffer(bytes: jsonData)) try await writer.finish(nil) } ) 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/Commands/AuthTokenCommand/AuthTokenCommandTests+LoopbackAuthorityValidation.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+LoopbackAuthorityValidation.swift deleted file mode 100644 index 7400cbbb..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Commands/AuthTokenCommand/AuthTokenCommandTests+LoopbackAuthorityValidation.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// AuthTokenCommandTests+LoopbackAuthorityValidation.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 Testing - - @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)) - } - - @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", - ] - ) - internal func isLoopbackAuthorityRejectsBypassAttempts(authority: String) { - #expect(!AuthTokenCommand.isLoopbackAuthority(authority)) - } - } - } -#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/AuthTokenServerTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/AuthTokenServerTests.swift new file mode 100644 index 00000000..d23135d6 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/AuthTokenServerTests.swift @@ -0,0 +1,190 @@ +// +// AuthTokenServerTests.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 AsyncAlgorithms + import Foundation + import HTTPTypes + import Hummingbird + import HummingbirdTesting + import Testing + + @testable import MistDemoKit + + @Suite("AuthTokenServer Tests") + internal struct AuthTokenServerTests { + private struct Fixture { + let server: AuthTokenServer + let tokenChannel: AsyncChannel + let responseChannel: AsyncChannel + } + + private struct ConfigPayload: Decodable { + let apiToken: String + let containerIdentifier: String + } + + private struct AuthRequestPayload: Encodable { + let sessionToken: String + let userRecordName: String + } + + private struct AuthResponsePayload: Decodable { + let userRecordName: String + let message: String + } + + private static func makeFixture() -> Fixture { + let tokenChannel = AsyncChannel() + let responseChannel = AsyncChannel() + let server = AuthTokenServer( + apiToken: "test-api-token", + containerIdentifier: "iCloud.test.container", + tokenChannel: tokenChannel, + responseCompleteChannel: responseChannel + ) + return Fixture( + server: server, + tokenChannel: tokenChannel, + responseChannel: responseChannel + ) + } + + @Test("GET / returns HTML index") + 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) + #expect( + response.headers[.contentType] + == "text/html; charset=utf-8" + ) + let body = String(buffer: response.body) + #expect(body.contains(" String? in + var iterator = tokenChannel.makeAsyncIterator() + return await iterator.next() + } + let receivedComplete = Task { + var iterator = responseChannel.makeAsyncIterator() + _ = await iterator.next() + } + + let requestPayload = AuthRequestPayload( + sessionToken: "session-tok-xyz", + userRecordName: "_abc123" + ) + let body = try JSONEncoder().encode(requestPayload) + + 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 == .ok) + #expect( + response.headers[.contentType] == "application/json" + ) + let decoded = try JSONDecoder().decode( + AuthResponsePayload.self, + from: Data(response.body.readableBytesView) + ) + #expect(decoded.userRecordName == "_abc123") + #expect(decoded.message == "Authentication successful!") + } + } + + #expect(await receivedToken.value == "session-tok-xyz") + await receivedComplete.value + } + } +#endif 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")) + } +} From 10ce5388486aabfb105fdd51e7390d286adbf8ca Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 11 May 2026 17:11:37 -0400 Subject: [PATCH 2/9] Resolve #289: load auth-token HTML/JS from Bundle.module resource The CloudKit auth-flow page moves out of four Swift raw-string files and into a single auth-token-index.html resource in MistDemoKit's bundle. AuthTokenIndexHTML becomes a thin Bundle.module loader. The original CodeSign concern doesn't apply: MistDemoApp (the iOS target) has no dependency on MistDemoKit, so the resource never ships in an iOS app bundle. The mistdemo CLI executable that does consume MistDemoKit is macOS / Linux only. Co-Authored-By: Claude Opus 4.7 (1M context) --- Examples/MistDemo/Package.swift | 3 + .../AuthTokenIndexHTML+ScriptAuth.swift | 162 ----- .../AuthTokenIndexHTML+ScriptDisplay.swift | 152 ----- .../AuthTokenIndexHTML+ScriptInit.swift | 217 ------- .../Commands/AuthTokenIndexHTML.swift | 202 ------- .../Resources/auth-token-index.html | 572 ++++++++++++++++++ .../Server/AuthTokenIndexHTML.swift | 57 ++ 7 files changed, 632 insertions(+), 733 deletions(-) delete mode 100644 Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenIndexHTML+ScriptAuth.swift delete mode 100644 Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenIndexHTML+ScriptDisplay.swift delete mode 100644 Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenIndexHTML+ScriptInit.swift delete mode 100644 Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenIndexHTML.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Resources/auth-token-index.html create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Server/AuthTokenIndexHTML.swift diff --git a/Examples/MistDemo/Package.swift b/Examples/MistDemo/Package.swift index 9951b93e..157c9f7a 100644 --- a/Examples/MistDemo/Package.swift +++ b/Examples/MistDemo/Package.swift @@ -158,6 +158,9 @@ let package = Package( condition: asyncAlgorithmsCondition ), ], + resources: [ + .copy("Resources/auth-token-index.html") + ], swiftSettings: swiftSettings ), .executableTarget( 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/Resources/auth-token-index.html b/Examples/MistDemo/Sources/MistDemoKit/Resources/auth-token-index.html new file mode 100644 index 00000000..1ca0f955 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/auth-token-index.html @@ -0,0 +1,572 @@ + + + + + + MistKit CloudKit Authentication Example + + + + +
+

MistKit CloudKit Example

+

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

+ +
+ + +
Authenticating...
+
+ + + + +
+
+ + + + diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/AuthTokenIndexHTML.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/AuthTokenIndexHTML.swift new file mode 100644 index 00000000..ca97605b --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/AuthTokenIndexHTML.swift @@ -0,0 +1,57 @@ +// +// 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) + internal import Foundation + + /// Loader for the CloudKit auth-flow page served by `AuthTokenServer`. + /// + /// The HTML+JS lives in `Resources/auth-token-index.html` and is read + /// from `Bundle.module` on first access. The resource only ships in the + /// MistDemoKit bundle, which is consumed exclusively by the `mistdemo` + /// CLI executable (macOS / Linux); the iOS MistDemoApp target does not + /// depend on MistDemoKit and therefore never bundles this resource. + internal enum AuthTokenIndexHTML { + internal static let content: String = { + guard + let url = Bundle.module.url( + forResource: "auth-token-index", + withExtension: "html" + ), + let data = try? Data(contentsOf: url), + let string = String(data: data, encoding: .utf8) + else { + preconditionFailure( + "auth-token-index.html missing from MistDemoKit bundle" + ) + } + return string + }() + } +#endif From 77715e04562f58b2a6f57fefae515767b151d1c4 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Tue, 12 May 2026 06:13:13 -0400 Subject: [PATCH 3/9] Fix PR #332: simplify HTML loader + gate flaky timeout test on visionOS - AuthTokenIndexHTML drops the guard-let-else pyramid in favor of a single try! / force-unwrap; the resource is shipped in MistDemoKit's bundle so failure here is a build-system bug, not a runtime condition. (review nit r3225504184) - AuthTokenCommandTests+Timeout's "throws on timeout" duplicates AsyncHelpersTests+Timeout's coverage but lacked its withKnownIssue gate. Under visionOS-simulator CI load the operation's single 1s Task.sleep can outrun the polling timeout's many short sleeps, so the test recorded "Should have timed out" instead of catching AsyncTimeoutError. Mirror the AsyncHelpers gate. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Server/AuthTokenIndexHTML.swift | 21 ++++--------- .../AuthTokenCommandTests+Timeout.swift | 30 +++++++++++-------- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/AuthTokenIndexHTML.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/AuthTokenIndexHTML.swift index ca97605b..e5f5b0fd 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/AuthTokenIndexHTML.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/AuthTokenIndexHTML.swift @@ -38,20 +38,11 @@ /// CLI executable (macOS / Linux); the iOS MistDemoApp target does not /// depend on MistDemoKit and therefore never bundles this resource. internal enum AuthTokenIndexHTML { - internal static let content: String = { - guard - let url = Bundle.module.url( - forResource: "auth-token-index", - withExtension: "html" - ), - let data = try? Data(contentsOf: url), - let string = String(data: data, encoding: .utf8) - else { - preconditionFailure( - "auth-token-index.html missing from MistDemoKit bundle" - ) - } - return string - }() + internal static let content: String = try! String( + contentsOf: Bundle.module.url( + forResource: "auth-token-index", withExtension: "html" + )!, + encoding: .utf8 + ) } #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)") } } From 62c7b6c46b900a6f0d9b38a63ea77303aada8c42 Mon Sep 17 00:00:00 2001 From: leogdion Date: Tue, 12 May 2026 14:10:02 -0400 Subject: [PATCH 4/9] Resolve #274: mistdemo web command with Hummingbird CRUD routes (#333) --- CLAUDE.md | 4 +- Examples/MistDemo/Native-README.md | 114 ---- Examples/MistDemo/Package.swift | 2 +- Examples/MistDemo/README.md | 267 ++++++++ .../Commands/AuthTokenCommand.swift | 130 ++-- .../Commands/TestPrivateCommand.swift | 10 +- ...nCommand.swift => TestPublicCommand.swift} | 30 +- .../MistDemoKit/Commands/WebCommand.swift | 126 ++++ .../Configuration/AuthTokenConfig.swift | 13 + ...MistDemoConfig+DatabaseConfiguration.swift | 18 +- ...ionConfig.swift => TestPublicConfig.swift} | 6 +- .../MistDemoKit/Configuration/WebConfig.swift | 122 ++++ .../Sources/MistDemoKit/MistDemoRunner.swift | 3 +- .../Resources/auth-token-index.html | 572 ------------------ .../Sources/MistDemoKit/Resources/index.html | 494 +++++++++++++++ ...TML.swift => LoopbackOnlyMiddleware.swift} | 35 +- .../Server/WebAuthTokenStore.swift | 66 ++ .../MistDemoKit/Server/WebBackend.swift | 115 ++++ .../Server/WebBackendFactory.swift | 65 ++ .../WebIndexHTML.swift} | 46 +- .../MistDemoKit/Server/WebRequests.swift | 75 +++ .../WebResponse.swift} | 35 +- .../MistDemoKit/Server/WebServer+CRUD.swift | 138 +++++ ...{AuthTokenServer.swift => WebServer.swift} | 121 ++-- .../MistDemoKit/Utilities/AsyncHelpers.swift | 13 +- .../AuthTokenCommandTests+MockServer.swift | 14 - .../Configuration/AuthTokenConfigTests.swift | 19 + ...ests.swift => TestPublicConfigTests.swift} | 14 +- .../Server/AuthTokenServerTests.swift | 190 ------ .../MistDemoTests/Server/MockBackend.swift | 164 +++++ .../Server/WebAuthTokenStoreTests.swift | 68 +++ .../Server/WebServerTests+CRUD.swift | 173 ++++++ .../MistDemoTests/Server/WebServerTests.swift | 214 +++++++ 33 files changed, 2374 insertions(+), 1102 deletions(-) delete mode 100644 Examples/MistDemo/Native-README.md create mode 100644 Examples/MistDemo/README.md rename Examples/MistDemo/Sources/MistDemoKit/Commands/{TestIntegrationCommand.swift => TestPublicCommand.swift} (79%) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift rename Examples/MistDemo/Sources/MistDemoKit/Configuration/{TestIntegrationConfig.swift => TestPublicConfig.swift} (95%) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift delete mode 100644 Examples/MistDemo/Sources/MistDemoKit/Resources/auth-token-index.html create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Resources/index.html rename Examples/MistDemo/Sources/MistDemoKit/Server/{AuthTokenIndexHTML.swift => LoopbackOnlyMiddleware.swift} (60%) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Server/WebAuthTokenStore.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift rename Examples/MistDemo/Sources/MistDemoKit/{Models/AuthResponse.swift => Server/WebIndexHTML.swift} (55%) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift rename Examples/MistDemo/Sources/MistDemoKit/{Models/CloudKitData.swift => Server/WebResponse.swift} (61%) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift rename Examples/MistDemo/Sources/MistDemoKit/Server/{AuthTokenServer.swift => WebServer.swift} (52%) rename Examples/MistDemo/Tests/MistDemoTests/Configuration/{TestIntegrationConfigTests.swift => TestPublicConfigTests.swift} (88%) delete mode 100644 Examples/MistDemo/Tests/MistDemoTests/Server/AuthTokenServerTests.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Server/WebAuthTokenStoreTests.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+CRUD.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index db29e7b3..beb1a392 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 @@ -327,7 +327,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/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 157c9f7a..463af89c 100644 --- a/Examples/MistDemo/Package.swift +++ b/Examples/MistDemo/Package.swift @@ -159,7 +159,7 @@ let package = Package( ), ], resources: [ - .copy("Resources/auth-token-index.html") + .copy("Resources/index.html"), ], swiftSettings: swiftSettings ), diff --git a/Examples/MistDemo/README.md b/Examples/MistDemo/README.md new file mode 100644 index 00000000..38bbca3c --- /dev/null +++ b/Examples/MistDemo/README.md @@ -0,0 +1,267 @@ +# 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 and opens your browser automatically. +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 | +| `--no-browser` | off | Don't auto-open the browser | + +Configuration is read via `MistDemoConfiguration`, so the same keys +(`api.token`, `container.identifier`, `environment`, `port`, `host`, +`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`, `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 +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/MistDemoKit/Commands/AuthTokenCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift index 32714a0b..cbda4aa4 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift @@ -28,9 +28,9 @@ // #if canImport(Hummingbird) - internal import AsyncAlgorithms internal import Foundation internal import Hummingbird + internal import MistKit /// Command to obtain web authentication token via browser flow. public struct AuthTokenCommand: MistDemoCommand { @@ -50,10 +50,11 @@ 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) + --no-browser Don't open browser automatically """ internal let config: AuthTokenConfig @@ -63,80 +64,83 @@ self.config = config } + private static func captureToken( + runService: @escaping @Sendable () async throws -> Void, + tokenStore: WebAuthTokenStore, + host: String, + port: Int, + noBrowser: 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 !noBrowser { + 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" + ) + } + } + } catch let error as AsyncTimeoutError { + throw AuthTokenError.timeout(error.localizedDescription) + } + } + /// Executes the command. public func execute() async throws { print("📍 Server URL: http://\(config.host):\(config.port)") - let tokenChannel = AsyncChannel() - let responseCompleteChannel = AsyncChannel() - - let server = AuthTokenServer( + let tokenStore = WebAuthTokenStore() + let server = WebServer( apiToken: config.apiToken, containerIdentifier: config.containerIdentifier, - tokenChannel: tokenChannel, - responseCompleteChannel: responseCompleteChannel + environment: config.environment, + tokenStore: tokenStore, + backendFactory: .live( + apiToken: config.apiToken, + containerIdentifier: config.containerIdentifier, + environment: config.environment + ), + terminatesAfterAuth: true ) - let router = try server.makeRouter() - let app = Application( - router: router, + router: try server.makeRouter(), configuration: .init( 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, + noBrowser: config.noBrowser ) - 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/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/WebCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift new file mode 100644 index 00000000..a23956db --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift @@ -0,0 +1,126 @@ +// +// 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) + --no-browser Don't open browser automatically + + The page authenticates against CloudKit via the browser, then + exposes a CRUD UI that calls MistKit on the server. 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)") + print("Press Ctrl+C to stop.") + + let tokenStore = WebAuthTokenStore() + let server = WebServer( + apiToken: config.apiToken, + containerIdentifier: config.containerIdentifier, + environment: config.environment, + tokenStore: tokenStore, + backendFactory: .live( + apiToken: config.apiToken, + containerIdentifier: config.containerIdentifier, + environment: config.environment + ), + terminatesAfterAuth: false + ) + let router = try server.makeRouter() + + let app = Application( + router: router, + configuration: .init( + address: .hostname(config.host, port: config.port) + ) + ) + + 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() + } + } + } + + private func openBrowserIfNeeded() async { + guard !config.noBrowser 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..757c116f 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift @@ -42,6 +42,8 @@ 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. @@ -54,12 +56,14 @@ public struct AuthTokenConfig: Sendable, ConfigurationParseable { 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 ) { self.apiToken = apiToken self.containerIdentifier = containerIdentifier + self.environment = environment self.port = port self.host = host self.noBrowser = noBrowser @@ -90,6 +94,14 @@ 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 = @@ -101,6 +113,7 @@ public struct AuthTokenConfig: Sendable, ConfigurationParseable { self.init( apiToken: apiToken, containerIdentifier: containerIdentifier, + environment: environment, port: port, host: host, noBrowser: noBrowser 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/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..4316a0a0 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift @@ -0,0 +1,122 @@ +// +// 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. +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 skip opening the browser. + public let noBrowser: Bool + + /// 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", + noBrowser: Bool = false + ) { + self.apiToken = apiToken + self.containerIdentifier = containerIdentifier + self.environment = environment + self.port = port + self.host = host + self.noBrowser = noBrowser + } + + /// 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 noBrowser = + configReader.bool(forKey: "no.browser", default: false) + + self.init( + apiToken: apiToken, + containerIdentifier: containerIdentifier, + environment: environment, + port: port, + host: host, + noBrowser: noBrowser + ) + } +} 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/auth-token-index.html b/Examples/MistDemo/Sources/MistDemoKit/Resources/auth-token-index.html deleted file mode 100644 index 1ca0f955..00000000 --- a/Examples/MistDemo/Sources/MistDemoKit/Resources/auth-token-index.html +++ /dev/null @@ -1,572 +0,0 @@ - - - - - - MistKit CloudKit Authentication Example - - - - -
-

MistKit CloudKit Example

-

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

- -
- - -
Authenticating...
-
- - - - -
-
- - - - diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html new file mode 100644 index 00000000..d6bd3466 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html @@ -0,0 +1,494 @@ + + + + + + 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 through Hummingbird → CloudKit Web Services. CloudKit JS + mode (browser → CloudKit Web Services) is wired in by #329. +
+ +

Auth

+
+
+ +
+
+
+ +
+

Records MistKit

+
+
+

Query

+ + + + +
+ +
+
+ +
+ +
+

Create

+ + + + +
+ +
+
+ +
+ +
+

Update

+ + + + + + +
+ +
+
+ +
+ +
+

Delete

+ + + + +
+ +
+
+ +
+
+
+
+ + + + diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/AuthTokenIndexHTML.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/LoopbackOnlyMiddleware.swift similarity index 60% rename from Examples/MistDemo/Sources/MistDemoKit/Server/AuthTokenIndexHTML.swift rename to Examples/MistDemo/Sources/MistDemoKit/Server/LoopbackOnlyMiddleware.swift index e5f5b0fd..869e0a07 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/AuthTokenIndexHTML.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/LoopbackOnlyMiddleware.swift @@ -1,5 +1,5 @@ // -// AuthTokenIndexHTML.swift +// LoopbackOnlyMiddleware.swift // MistDemo // // Created by Leo Dion. @@ -28,21 +28,24 @@ // #if canImport(Hummingbird) - internal import Foundation + internal import Hummingbird - /// Loader for the CloudKit auth-flow page served by `AuthTokenServer`. - /// - /// The HTML+JS lives in `Resources/auth-token-index.html` and is read - /// from `Bundle.module` on first access. The resource only ships in the - /// MistDemoKit bundle, which is consumed exclusively by the `mistdemo` - /// CLI executable (macOS / Linux); the iOS MistDemoApp target does not - /// depend on MistDemoKit and therefore never bundles this resource. - internal enum AuthTokenIndexHTML { - internal static let content: String = try! String( - contentsOf: Bundle.module.url( - forResource: "auth-token-index", withExtension: "html" - )!, - encoding: .utf8 - ) + /// 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..69f56bd5 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift @@ -0,0 +1,115 @@ +// +// 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`. +internal protocol WebBackend: Sendable { + func webQuery( + recordType: String, + limit: Int? + ) async throws -> [RecordInfo] + + func webCreate( + recordType: String, + fields: [String: FieldValue] + ) async throws -> RecordInfo + + func webUpdate( + recordType: String, + recordName: String, + fields: [String: FieldValue] + ) async throws -> RecordInfo + + func webDelete( + recordType: String, + recordName: String + ) 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? + ) async throws -> [RecordInfo] { + let result = try await queryRecords( + recordType: recordType, + filters: nil, + sortBy: nil, + limit: limit, + desiredKeys: nil, + continuationMarker: nil, + database: .private + ) + return result.records + } + + internal func webCreate( + recordType: String, + fields: [String: FieldValue] + ) async throws -> RecordInfo { + try await createRecord( + recordType: recordType, + fields: fields, + database: .private + ) + } + + internal func webUpdate( + recordType: String, + recordName: String, + fields: [String: FieldValue] + ) async throws -> RecordInfo { + try await updateRecord( + recordType: recordType, + recordName: recordName, + fields: fields, + database: .private + ) + } + + internal func webDelete( + recordType: String, + recordName: String + ) async throws { + try await deleteRecord( + recordType: recordType, + recordName: recordName, + database: .private + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift new file mode 100644 index 00000000..f78fd7d9 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift @@ -0,0 +1,65 @@ +// +// 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. +// + +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. +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 private + /// database with the captured web-auth token paired with the + /// command's API token. + internal static func live( + apiToken: String, + containerIdentifier: String, + environment: MistKit.Environment + ) -> WebBackendFactory { + WebBackendFactory { webAuthToken in + let tokenManager = WebAuthTokenManager( + apiToken: apiToken, + webAuthToken: webAuthToken + ) + return CloudKitService( + containerIdentifier: containerIdentifier, + tokenManager: tokenManager, + environment: environment + ) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Models/AuthResponse.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebIndexHTML.swift similarity index 55% rename from Examples/MistDemo/Sources/MistDemoKit/Models/AuthResponse.swift rename to Examples/MistDemo/Sources/MistDemoKit/Server/WebIndexHTML.swift index 1a63f026..080456a3 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Models/AuthResponse.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebIndexHTML.swift @@ -1,5 +1,5 @@ // -// AuthResponse.swift +// WebIndexHTML.swift // MistDemo // // Created by Leo Dion. @@ -27,23 +27,31 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +#if canImport(Hummingbird) + internal import Foundation -/// Response model for authentication callback endpoints. -/// -/// 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 + /// 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() - /// CloudKit data retrieved during authentication (user info and zones). - internal let cloudKitData: CloudKitData - - /// Human-readable message describing the authentication result. - internal let message: String -} + 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/Server/WebRequests.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift new file mode 100644 index 00000000..f8f4e57a --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift @@ -0,0 +1,75 @@ +// +// 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. +/// +/// Field values are limited to string in the request body so the HTML form +/// schema stays trivial. Wider FieldValue coverage (numbers, dates, refs) +/// can land later once the demo UI exposes typed input controls. +internal enum WebRequests { + /// `POST /api/records/query` + internal struct Query: Decodable { + internal let recordType: String + internal let limit: Int? + } + + /// `POST /api/records/create` + internal struct Create: Decodable { + internal let recordType: String + internal let fields: [String: String] + } + + /// `POST /api/records/update` + internal struct Update: Decodable { + internal let recordType: String + internal let recordName: String + internal let fields: [String: String] + } + + /// `POST /api/records/delete` + internal struct Delete: Decodable { + internal let recordType: String + internal let recordName: String + } + + /// Convert a JSON `[String: String]` request payload into the + /// `FieldValue` map MistKit expects. + internal static func stringFields( + _ raw: [String: String] + ) -> [String: FieldValue] { + var result: [String: FieldValue] = [:] + for (name, value) in raw { + result[name] = .string(value) + } + return result + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Models/CloudKitData.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift similarity index 61% rename from Examples/MistDemo/Sources/MistDemoKit/Models/CloudKitData.swift rename to Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift index e0504d06..1fadb4f9 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Models/CloudKitData.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift @@ -1,5 +1,5 @@ // -// CloudKitData.swift +// WebResponse.swift // MistDemo // // Created by Leo Dion. @@ -27,22 +27,25 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import MistKit +internal import Foundation +internal import MistKit -/// CloudKit user and zone data for authentication response. -/// -/// 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? +/// 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] + } - /// List of available zones in the user's container. - internal let zones: [ZoneInfo] + /// Body returned by `delete` (no record payload). + internal struct Delete: Encodable { + internal let recordName: String + internal let deleted: Bool + } - /// Error message if any part of the CloudKit data retrieval failed. - internal let error: String? + /// 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..abc11c84 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift @@ -0,0 +1,138 @@ +// +// 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 + ) + return try JSONEncoder().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: WebRequests.stringFields(body.fields) + ) + return try JSONEncoder().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: WebRequests.stringFields(body.fields) + ) + return try JSONEncoder().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 + ) + return try JSONEncoder().encode( + WebResponse.Delete( + recordName: body.recordName, deleted: true + ) + ) + } + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/AuthTokenServer.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift similarity index 52% rename from Examples/MistDemo/Sources/MistDemoKit/Server/AuthTokenServer.swift rename to Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift index adc4a753..91fa8a95 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/AuthTokenServer.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift @@ -1,5 +1,5 @@ // -// AuthTokenServer.swift +// WebServer.swift // MistDemo // // Created by Leo Dion. @@ -28,31 +28,72 @@ // #if canImport(Hummingbird) - internal import AsyncAlgorithms internal import Foundation internal import HTTPTypes internal import Hummingbird internal import Logging + internal import MistKit - /// Routing surface for the auth-token loopback flow. + /// Routing surface for the long-running `mistdemo web` command. /// - /// Owns the index, config, and authentication endpoints used by the - /// browser-side script during a CloudKit web-auth round trip. The owning - /// command (`AuthTokenCommand`) provides credentials and the rendezvous - /// channels and is responsible for the `Application` lifecycle; this type - /// only knows how to assemble a `Router`. - internal struct AuthTokenServer { - /// JSON payload returned from `GET /api/config`, consumed by the - /// browser-side script to configure CloudKit JS. + /// 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. internal struct CloudKitClientConfig: Encodable { internal let apiToken: String internal let containerIdentifier: String + internal let environment: String } internal let apiToken: String internal let containerIdentifier: String - internal let tokenChannel: AsyncChannel - internal let responseCompleteChannel: AsyncChannel + internal let environment: MistKit.Environment + 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 { @@ -62,14 +103,20 @@ addIndexEndpoint(router: router) let api = router.group("api") + .add(middleware: LoopbackOnlyMiddleware()) let configData = try JSONEncoder().encode( CloudKitClientConfig( apiToken: apiToken, - containerIdentifier: containerIdentifier + containerIdentifier: containerIdentifier, + environment: environment.rawValue ) ) addConfigEndpoint(api: api, configData: configData) addAuthEndpoint(api: api) + addQueryEndpoint(api: api) + addCreateEndpoint(api: api) + addUpdateEndpoint(api: api) + addDeleteEndpoint(api: api) return router } @@ -77,7 +124,7 @@ private func addIndexEndpoint( router: Router ) { - let indexBytes = ByteBuffer(string: AuthTokenIndexHTML.content) + let indexBytes = ByteBuffer(string: WebIndexHTML.content) let indexResponseBuilder: @Sendable () -> Response = { Response( status: .ok, @@ -98,53 +145,23 @@ api: RouterGroup, configData: Data ) { - api.get("config") { request, _ -> Response in - let authority = request.head.authority ?? "" - guard LoopbackAuthority.isLoopback(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) - } - ) + api.get("config") { _, _ -> Response in + Self.jsonResponse(status: .ok, bytes: configData) } } private func addAuthEndpoint( api: RouterGroup ) { - let tokenChannel = self.tokenChannel - let responseCompleteChannel = self.responseCompleteChannel + 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 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) - } - ) + await tokenStore.update(authRequest.sessionToken) + return Response(status: successStatus) } } } 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/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/Configuration/AuthTokenConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift index 3c8c8ff2..55304780 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 @@ -55,6 +56,7 @@ internal struct AuthTokenConfigTests { #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) @@ -65,6 +67,7 @@ internal struct AuthTokenConfigTests { let config = AuthTokenConfig( apiToken: "tok", containerIdentifier: "iCloud.custom.id", + environment: .production, port: 9_000, host: "0.0.0.0", noBrowser: true @@ -72,6 +75,7 @@ internal struct AuthTokenConfigTests { #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) @@ -107,6 +111,7 @@ 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) @@ -117,6 +122,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 +132,21 @@ 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) } + + @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/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/Server/AuthTokenServerTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/AuthTokenServerTests.swift deleted file mode 100644 index d23135d6..00000000 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/AuthTokenServerTests.swift +++ /dev/null @@ -1,190 +0,0 @@ -// -// AuthTokenServerTests.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 AsyncAlgorithms - import Foundation - import HTTPTypes - import Hummingbird - import HummingbirdTesting - import Testing - - @testable import MistDemoKit - - @Suite("AuthTokenServer Tests") - internal struct AuthTokenServerTests { - private struct Fixture { - let server: AuthTokenServer - let tokenChannel: AsyncChannel - let responseChannel: AsyncChannel - } - - private struct ConfigPayload: Decodable { - let apiToken: String - let containerIdentifier: String - } - - private struct AuthRequestPayload: Encodable { - let sessionToken: String - let userRecordName: String - } - - private struct AuthResponsePayload: Decodable { - let userRecordName: String - let message: String - } - - private static func makeFixture() -> Fixture { - let tokenChannel = AsyncChannel() - let responseChannel = AsyncChannel() - let server = AuthTokenServer( - apiToken: "test-api-token", - containerIdentifier: "iCloud.test.container", - tokenChannel: tokenChannel, - responseCompleteChannel: responseChannel - ) - return Fixture( - server: server, - tokenChannel: tokenChannel, - responseChannel: responseChannel - ) - } - - @Test("GET / returns HTML index") - 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) - #expect( - response.headers[.contentType] - == "text/html; charset=utf-8" - ) - let body = String(buffer: response.body) - #expect(body.contains(" String? in - var iterator = tokenChannel.makeAsyncIterator() - return await iterator.next() - } - let receivedComplete = Task { - var iterator = responseChannel.makeAsyncIterator() - _ = await iterator.next() - } - - let requestPayload = AuthRequestPayload( - sessionToken: "session-tok-xyz", - userRecordName: "_abc123" - ) - let body = try JSONEncoder().encode(requestPayload) - - 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 == .ok) - #expect( - response.headers[.contentType] == "application/json" - ) - let decoded = try JSONDecoder().decode( - AuthResponsePayload.self, - from: Data(response.body.readableBytesView) - ) - #expect(decoded.userRecordName == "_abc123") - #expect(decoded.message == "Authentication successful!") - } - } - - #expect(await receivedToken.value == "session-tok-xyz") - await receivedComplete.value - } - } -#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift new file mode 100644 index 00000000..3d2ab4c6 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift @@ -0,0 +1,164 @@ +// +// 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 struct CreateCall: Sendable { + internal let recordType: String + internal let fields: [String: String] + } + + internal struct UpdateCall: Sendable { + internal let recordType: String + internal let recordName: String + internal let fields: [String: String] + } + + internal struct DeleteCall: Sendable { + internal let recordType: String + internal let recordName: String + } + + 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.string entries back to plain strings so tests + /// can `#expect(captured.fields["title"] == "Hi")` without unwrapping. + private static func flatten( + _ fields: [String: FieldValue] + ) -> [String: String] { + var result: [String: String] = [:] + for (name, value) in fields { + if case .string(let string) = value { + result[name] = string + } + } + return result + } + + internal func failNext(message: String) { + pendingError = message + } + + internal func webQuery( + recordType: String, limit: Int? + ) async throws -> [RecordInfo] { + lastQuery = QueryCall(recordType: recordType, limit: limit) + try consumePendingError() + return [ + Self.stubRecord(recordType: recordType, recordName: "stub-1") + ] + } + + internal func webCreate( + recordType: String, fields: [String: FieldValue] + ) async throws -> RecordInfo { + lastCreate = CreateCall( + recordType: recordType, + fields: Self.flatten(fields) + ) + try consumePendingError() + return Self.stubRecord( + recordType: recordType, recordName: "created-1" + ) + } + + internal func webUpdate( + recordType: String, + recordName: String, + fields: [String: FieldValue] + ) async throws -> RecordInfo { + lastUpdate = UpdateCall( + recordType: recordType, + recordName: recordName, + fields: Self.flatten(fields) + ) + try consumePendingError() + return Self.stubRecord( + recordType: recordType, recordName: recordName + ) + } + + internal func webDelete( + recordType: String, recordName: String + ) async throws { + lastDelete = DeleteCall( + recordType: recordType, recordName: recordName + ) + 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/Server/WebServerTests+CRUD.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+CRUD.swift new file mode 100644 index 00000000..406549b8 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+CRUD.swift @@ -0,0 +1,173 @@ +// +// 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) + } + + @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/update forwards record name + fields") + 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"}} + """ + + 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") + } + + @Test("POST /api/records/delete forwards record name") + 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"}"# + + 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") + } + + @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.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests.swift new file mode 100644 index 00000000..83c97953 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests.swift @@ -0,0 +1,214 @@ +// +// 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 + } + + internal static func makeFixture( + authenticated: Bool = false, + terminatesAfterAuth: 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, + tokenStore: store, + backendFactory: factory, + terminatesAfterAuth: terminatesAfterAuth + ) + return Fixture(server: server, tokenStore: store, backend: backend) + } + + @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("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") + } + } + } + + @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 From 1e8b90722091e6f38d18a2a21c84c09efa2bb2cf Mon Sep 17 00:00:00 2001 From: leogdion Date: Tue, 12 May 2026 14:58:03 -0400 Subject: [PATCH 5/9] Resolve #329: CloudKit JS alternate backend + browser-flag defaults (#335) --- Examples/MistDemo/README.md | 14 ++- .../Commands/AuthTokenCommand.swift | 9 +- .../MistDemoKit/Commands/WebCommand.swift | 5 +- .../Configuration/AuthTokenConfig.swift | 19 ++-- .../Configuration/BrowserFlagResolver.swift | 53 +++++++++ .../MistDemoKit/Configuration/WebConfig.swift | 19 ++-- .../Sources/MistDemoKit/Resources/index.html | 106 +++++++++++++++--- .../AuthTokenCommandTests+Configuration.swift | 6 +- ...ionTests+AuthTokenCommandIntegration.swift | 2 +- ...rationTests+RealWorldUsageSimulation.swift | 2 +- .../Configuration/AuthTokenConfigTests.swift | 26 ++++- .../Server/WebServerTests+Index.swift | 74 ++++++++++++ .../MistDemoTests/Server/WebServerTests.swift | 14 --- 13 files changed, 283 insertions(+), 66 deletions(-) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift diff --git a/Examples/MistDemo/README.md b/Examples/MistDemo/README.md index 38bbca3c..e53a11fa 100644 --- a/Examples/MistDemo/README.md +++ b/Examples/MistDemo/README.md @@ -68,9 +68,12 @@ Or via env var: CLOUDKIT_API_TOKEN=… swift run mistdemo web ``` -The CLI prints the server URL and opens your browser automatically. -Sign in with your Apple ID; the server captures the web-auth token and -the CRUD UI on the page becomes live. +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 @@ -81,11 +84,12 @@ the CRUD UI on the page becomes live. | `--environment ` | `development` | `development` or `production` | | `--host ` | `127.0.0.1` | Bind address | | `--port ` | `8080` | Server port | -| `--no-browser` | off | Don't auto-open the browser | +| `--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`, -`no.browser`) can be supplied through `--config-file ~/.mistdemo/config.json` +`browser`, `no.browser`) can be supplied through `--config-file ~/.mistdemo/config.json` or environment variables. ### What the server exposes diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift index cbda4aa4..71f93837 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift @@ -54,7 +54,8 @@ --environment development (default) | production --port Server port (default: 8080) --host Server host (default: 127.0.0.1) - --no-browser Don't open browser automatically + --browser Open browser on startup (default for auth-token) + --no-browser Don't open browser on startup (overrides --browser) """ internal let config: AuthTokenConfig @@ -69,7 +70,7 @@ tokenStore: WebAuthTokenStore, host: String, port: Int, - noBrowser: Bool + openBrowser: Bool ) async throws -> String { do { return try await withTimeoutAndSignals(seconds: 300) { @@ -79,7 +80,7 @@ return nil } group.addTask { - if !noBrowser { + if openBrowser { try? await Task.sleep(nanoseconds: 1_000_000_000) BrowserOpener.openBrowser(url: "http://\(host):\(port)") } @@ -135,7 +136,7 @@ tokenStore: tokenStore, host: config.host, port: config.port, - noBrowser: config.noBrowser + openBrowser: config.openBrowser ) // Let the 205 response reach the browser before the process exits. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift index a23956db..7640c4be 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift @@ -60,7 +60,8 @@ --environment development (default) | production --port Server port (default: 8080) --host Server host (default: 127.0.0.1) - --no-browser Don't open browser automatically + --browser Open browser on startup (overrides default) + --no-browser Don't open browser on startup (default for web) The page authenticates against CloudKit via the browser, then exposes a CRUD UI that calls MistKit on the server. Ctrl+C to exit. @@ -114,7 +115,7 @@ } private func openBrowserIfNeeded() async { - guard !config.noBrowser else { + guard config.openBrowser else { return } try? await Task.sleep(nanoseconds: 1_000_000_000) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift index 757c116f..856b03b7 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift @@ -48,8 +48,11 @@ public struct AuthTokenConfig: Sendable, ConfigurationParseable { 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( @@ -59,14 +62,14 @@ public struct AuthTokenConfig: Sendable, ConfigurationParseable { 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. @@ -107,8 +110,10 @@ public struct AuthTokenConfig: Sendable, ConfigurationParseable { 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, @@ -116,7 +121,7 @@ public struct AuthTokenConfig: Sendable, ConfigurationParseable { environment: environment, port: port, host: host, - noBrowser: noBrowser + openBrowser: openBrowser ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift new file mode 100644 index 00000000..5c39f1d2 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift @@ -0,0 +1,53 @@ +// +// BrowserFlagResolver.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. +// + +import Foundation + +/// Resolves the "should we open the browser on startup?" decision from +/// the two mutually-exclusive CLI flags into a single boolean. +/// +/// - `--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/WebConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift index 4316a0a0..3d8423e6 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift @@ -52,8 +52,11 @@ public struct WebConfig: Sendable, ConfigurationParseable { public let port: Int /// The server host. 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 `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 /// Creates a new instance. public init( @@ -62,14 +65,14 @@ public struct WebConfig: Sendable, ConfigurationParseable { environment: MistKit.Environment = .development, port: Int = 8_080, host: String = "127.0.0.1", - noBrowser: Bool = false + openBrowser: Bool = false ) { 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. @@ -107,8 +110,10 @@ public struct WebConfig: Sendable, ConfigurationParseable { 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: false + ) self.init( apiToken: apiToken, @@ -116,7 +121,7 @@ public struct WebConfig: Sendable, ConfigurationParseable { environment: environment, port: port, host: host, - noBrowser: noBrowser + openBrowser: openBrowser ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html index d6bd3466..83fd1aaf 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html @@ -130,11 +130,13 @@

MistKit Web Demo

Backend

- +
- MistKit mode routes through Hummingbird → CloudKit Web Services. CloudKit JS - mode (browser → CloudKit Web Services) is wired in by #329. + 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.

Auth

@@ -302,8 +304,52 @@

Delete

} } - function cloudKitJsNotWired(label, statusEl) { - setStatus(statusEl, `${label} via CloudKit JS is not wired yet — see #329.`, 'error'); + function ckJsDatabase() { + return container.privateCloudDatabase; + } + + // CloudKit JS expects field values as { key: { value: ... } } while the + // MistKit-mode endpoints accept the flat { key: "value" } shape this + // demo's textareas already produce — wrap before saveRecords. + function fieldsToCKJS(fields) { + const wrapped = {}; + for (const [k, v] of Object.entries(fields)) { + wrapped[k] = { value: v }; + } + return wrapped; + } + + async function runCloudKitJsOperation(label, work, statusEl, resultEl) { + clearStatus(statusEl); + resultEl.style.display = 'none'; + try { + const data = await work(); + if (data && data.hasErrors && data.errors && data.errors.length) { + const first = data.errors[0]; + const message = (first && (first.reason || first.serverErrorCode)) + || 'CloudKit JS reported an error'; + setStatus(statusEl, `${label} failed: ${message}`, 'error'); + showResult(resultEl, data); + return; + } + setStatus(statusEl, `${label} succeeded.`, 'success'); + showResult(resultEl, data); + } catch (error) { + const message = (error && error.message) || String(error); + setStatus(statusEl, `${label} failed: ${message}`, 'error'); + if (error && typeof error === 'object') showResult(resultEl, error); + } + } + + async function fetchRecordChangeTag(recordName) { + const result = await ckJsDatabase().fetchRecords([{ recordName }]); + if (result.hasErrors && result.errors.length) { + const first = result.errors[0]; + throw new Error((first && first.reason) || 'fetchRecords failed'); + } + const record = result.records && result.records[0]; + if (!record) throw new Error(`Record "${recordName}" not found`); + return record.recordChangeTag; } document.getElementById('query-btn').addEventListener('click', async () => { @@ -311,20 +357,33 @@

Delete

const limit = parseInt(document.getElementById('query-limit').value, 10); const statusEl = document.getElementById('query-status'); const resultEl = document.getElementById('query-result'); - if (currentMode !== 'mistkit') return cloudKitJsNotWired('Query', statusEl); - await runMistKitOperation('Query', '/api/records/query', { - recordType, limit: isFinite(limit) ? limit : undefined, - }, statusEl, resultEl); + if (currentMode === 'mistkit') { + await runMistKitOperation('Query', '/api/records/query', { + recordType, limit: isFinite(limit) ? limit : undefined, + }, statusEl, resultEl); + return; + } + await runCloudKitJsOperation('Query', () => + ckJsDatabase().performQuery({ recordType }, { + resultsLimit: isFinite(limit) ? limit : undefined, + }), + statusEl, resultEl); }); document.getElementById('create-btn').addEventListener('click', async () => { const recordType = document.getElementById('create-record-type').value.trim(); const statusEl = document.getElementById('create-status'); const resultEl = document.getElementById('create-result'); - if (currentMode !== 'mistkit') return cloudKitJsNotWired('Create', statusEl); const fields = parseFieldsJSON(document.getElementById('create-fields').value, statusEl); if (!fields) return; - await runMistKitOperation('Create', '/api/records/create', { recordType, fields }, statusEl, resultEl); + if (currentMode === 'mistkit') { + await runMistKitOperation('Create', '/api/records/create', + { recordType, fields }, statusEl, resultEl); + return; + } + await runCloudKitJsOperation('Create', () => + ckJsDatabase().saveRecords([{ recordType, fields: fieldsToCKJS(fields) }]), + statusEl, resultEl); }); document.getElementById('update-btn').addEventListener('click', async () => { @@ -332,15 +391,24 @@

Delete

const recordName = document.getElementById('update-record-name').value.trim(); const statusEl = document.getElementById('update-status'); const resultEl = document.getElementById('update-result'); - if (currentMode !== 'mistkit') return cloudKitJsNotWired('Update', statusEl); if (!recordName) { setStatus(statusEl, 'Record name is required.', 'error'); return; } const fields = parseFieldsJSON(document.getElementById('update-fields').value, statusEl); if (!fields) return; - await runMistKitOperation('Update', '/api/records/update', { - recordType, recordName, fields, + if (currentMode === 'mistkit') { + await runMistKitOperation('Update', '/api/records/update', { + recordType, recordName, fields, + }, statusEl, resultEl); + return; + } + await runCloudKitJsOperation('Update', async () => { + const recordChangeTag = await fetchRecordChangeTag(recordName); + return ckJsDatabase().saveRecords([{ + recordType, recordName, recordChangeTag, + fields: fieldsToCKJS(fields), + }]); }, statusEl, resultEl); }); @@ -349,12 +417,18 @@

Delete

const recordName = document.getElementById('delete-record-name').value.trim(); const statusEl = document.getElementById('delete-status'); const resultEl = document.getElementById('delete-result'); - if (currentMode !== 'mistkit') return cloudKitJsNotWired('Delete', statusEl); if (!recordName) { setStatus(statusEl, 'Record name is required.', 'error'); return; } - await runMistKitOperation('Delete', '/api/records/delete', { recordType, recordName }, statusEl, resultEl); + if (currentMode === 'mistkit') { + await runMistKitOperation('Delete', '/api/records/delete', + { recordType, recordName }, statusEl, resultEl); + return; + } + await runCloudKitJsOperation('Delete', () => + ckJsDatabase().deleteRecords([{ recordName }]), + statusEl, resultEl); }); // ---- auth flow ---- 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/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 55304780..5756dc6e 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift @@ -50,7 +50,7 @@ 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") @@ -59,7 +59,8 @@ internal struct AuthTokenConfigTests { #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") @@ -70,7 +71,7 @@ internal struct AuthTokenConfigTests { environment: .production, port: 9_000, host: "0.0.0.0", - noBrowser: true + openBrowser: false ) #expect(config.apiToken == "tok") @@ -78,7 +79,7 @@ internal struct AuthTokenConfigTests { #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") @@ -114,7 +115,7 @@ internal struct AuthTokenConfigTests { #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") @@ -135,7 +136,20 @@ internal struct AuthTokenConfigTests { #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") 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..b7f49254 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift @@ -0,0 +1,74 @@ +// +// 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")) + } + } + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests.swift index 83c97953..8bc41a96 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests.swift @@ -71,20 +71,6 @@ return Fixture(server: server, tokenStore: store, backend: backend) } - @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("GET /api/config returns container + environment") internal func configIncludesEnvironment() async throws { let fixture = Self.makeFixture() From 31a416874565fd9760dc4fad5d1199a25caca973 Mon Sep 17 00:00:00 2001 From: leogdion Date: Tue, 12 May 2026 18:36:41 -0400 Subject: [PATCH 6/9] WebUI: table+form rework, system-metadata timestamps, sortable Created/Modified (#336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rework mistdemo web UI to table+form; sortable Created/Modified Iterates on top of #329's CloudKit JS mode toggle. The single-mode JSON- textarea CRUD grid is replaced by a Notes table beside a Title/Index form: clicking a row loads it for edit, per-row Delete buttons, "New" to clear. Auto-refreshes after every mutation and after mode switches, so the same notes can be observed fetched through either backend. WebUI: - Two-column responsive layout: Notes table left, edit/create form right; stacks to one column below 820px. - Created and Modified columns formatted with the locale's dateStyle:short/timeStyle:short (e.g., "5/12/26, 4:30 PM"); full ISO is in each cell's tooltip. - Clickable Created/Modified column headers cycle unsorted → ascending → descending. Sort forwards to both backends: MistKit body `sortBy:[{field, ascending}]`, CloudKit JS `sortBy:[{fieldName, ascending}]`. Default is no sort, so the demo still lists records before the new schema deploys. - Record name is removed as a column and surfaced as a row tooltip. Note schema: - Drop custom `createdAt` (TIMESTAMP) and `modified` (INT64) — they duplicated CloudKit's system metadata. CKRecord.creationDate / .modificationDate and the Web Services `created.timestamp` / `modified.timestamp` cover the same information without manual bookkeeping. Schema, native Note model, RecordDetailView, QueryView, NativeCloudKitService, integration phases, README, and the CLI query examples are updated. - Add `___createTime` and `___modTime` to the schema with QUERYABLE SORTABLE so the sort feature actually works against the live container (system fields default to non-sortable; the schema must explicitly opt them in). Server: - New WebJSON.encoder()/.decoder() with .millisecondsSince1970 date strategy. The browser receives created/modified timestamps as plain epoch-millis numbers, matching CloudKit JS's Date shape. - WebRequests.Update + .Delete grow optional `recordChangeTag` — the browser holds it from the last query, so MistKit-mode update/delete no longer need a server-side fetch round-trip. Fixes CloudKit's `BadRequestException: missing required field 'recordChangeTag'`. - WebRequests.Create + .Update.fields are `[String: FieldValue]` decoded through MistKit's FieldValue Codable (which accepts raw JSON primitives — string/int/double). Fixes the 400 thrown when the form sent `"index": 5` (a JSON number) against the prior `[String: String]` type. - WebRequests.Query gains `sortBy: [QuerySortField]?`; WebBackend takes the request-shape sort directly (no MistKit-internal type leakage). CloudKitService extension is the only site that knows about MistKit's QuerySort. WebCommand: - Catch AsyncTimeoutError.cancelled so Ctrl+C is a normal shutdown rather than a top-level fatal error. Tests: - WebServerTests+QuerySort — sort forwarding + nil default. - Updated CRUD tests cover recordChangeTag forwarding (update + delete), mixed-type fields (int + double in create), and absent recordChangeTag tolerance on update. - MockBackend.QueryCall.sortBy captures the request-shape sort; flatten() handles int64/double for assertion. The new SORTABLE system fields in schema.ckdb need to be deployed to the live CloudKit container before sort works end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) * Address PR #336 review: route table-Delete status; tidy comments/tests - index.html: deleteNote(note, statusEl = tableStatusEl) so per-row Delete feedback lands above the table; the form panel's Delete passes formStatusEl explicitly. Removes a dead recordType fallback reachable only if normalizeRecords() dropped recordType — which it doesn't. Adds a comment that per-row Delete intentionally skips confirm(); the raw response panel makes accidents visible. - WebJSON.swift: removes unused decoder() (FieldValue's own Codable handles request-side dates; Hummingbird's request.decode runs the framework decoder). Encoder remains as the singular response-side contract; docstring updated accordingly. - WebRequests.swift: doc comment on QuerySortField.field flagging that CloudKit JS calls the same concept `fieldName` and the browser maps between them. - MockBackend.swift: comment on flatten()'s default case explaining the intentional drop of asset/date/reference/location/list/bytes — tests needing those should inspect the FieldValue directly. - WebJSONTests.swift (new): locks the encoder's epoch-millis contract with a round-trip test. The browser's `toDate(value)` in index.html depends on receiving plain millis numbers. Skipped from the review with reasoning preserved in the plan file (/Users/leo/.claude/plans/async-wibbling-pearl.md): - server-side recordChangeTag guard (CloudKit's 400 is already clear, browser is the only realistic caller) - generic CancellationError catch in WebCommand (the explicit AsyncTimeoutError.cancelled is more meaningful) - sort response with timestamps test (encoder round-trip covers the same contract more directly) - schema.ckdb trailing blank line (was already present pre-PR) Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- Examples/MistDemo/README.md | 2 +- .../Sources/MistDemoApp/Models/Note.swift | 18 +- .../Services/NativeCloudKitService.swift | 8 +- .../Sources/MistDemoApp/Views/QueryView.swift | 4 +- .../MistDemoApp/Views/RecordDetailView.swift | 7 - .../MistDemoKit/Commands/WebCommand.swift | 23 +- .../Phases/CreateRecordsPhase.swift | 1 - .../Phases/ModifyRecordsPhase.swift | 3 +- .../Sources/MistDemoKit/Resources/index.html | 725 +++++++++++++----- .../MistDemoKit/Server/WebBackend.swift | 25 +- .../Sources/MistDemoKit/Server/WebJSON.swift | 44 ++ .../MistDemoKit/Server/WebRequests.swift | 48 +- .../MistDemoKit/Server/WebServer+CRUD.swift | 20 +- .../MistDemoTests/Server/MockBackend.swift | 45 +- .../MistDemoTests/Server/WebJSONTests.swift | 56 ++ .../Server/WebServerTests+CRUD.swift | 59 +- .../Server/WebServerTests+QuerySort.swift | 87 +++ Examples/MistDemo/examples/README.md | 4 +- Examples/MistDemo/examples/query-records.sh | 10 +- Examples/MistDemo/schema.ckdb | 4 +- 20 files changed, 896 insertions(+), 297 deletions(-) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Server/WebJSON.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Server/WebJSONTests.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QuerySort.swift diff --git a/Examples/MistDemo/README.md b/Examples/MistDemo/README.md index e53a11fa..87e199a0 100644 --- a/Examples/MistDemo/README.md +++ b/Examples/MistDemo/README.md @@ -176,7 +176,7 @@ A SwiftUI demo app that talks to the same CloudKit container, but uses - **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` +- **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` diff --git a/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift b/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift index bca03c81..1d83c752 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,8 +57,6 @@ 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? @@ -73,8 +71,6 @@ 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 diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitService.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitService.swift index 5d4f99d5..58209591 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitService.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitService.swift @@ -58,7 +58,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 +69,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 { @@ -134,7 +133,6 @@ 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 diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift index 154acf90..c86ea446 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift @@ -76,9 +76,9 @@ .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) diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift index 9077f6b7..58a1bb9f 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift @@ -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/MistDemoKit/Commands/WebCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift index 7640c4be..72183c5d 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift @@ -101,16 +101,23 @@ ) ) - try await withSignalHandling { - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - try await app.runService() + 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() } - 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.") } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift index 3e6f9846..6992e801 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift @@ -59,7 +59,6 @@ internal struct CreateRecordsPhase: IntegrationPhase { "title": .string("Test Record \(recordIndex)"), "index": .int64(recordIndex), "image": .asset(input.asset), - "createdAt": .date(Date()), ] ) createdRecordNames.append(record.recordName) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift index 2548e0dd..82825ba9 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift @@ -51,8 +51,7 @@ 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)") ] ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html index 83fd1aaf..17828d36 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html @@ -12,11 +12,13 @@ --muted: #6e6e73; --accent: #0369a1; --accent-dark: #0c4a6e; + --danger: #c00; + --danger-bg: #fdd; --success-bg: #d1f5d3; --success-fg: #1d5e20; - --error-bg: #fdd; - --error-fg: #c00; --border: #d0d7de; + --row-hover: #f0f4f8; + --row-selected: #dbeafe; } * { box-sizing: border-box; } body { @@ -27,7 +29,7 @@ color: var(--ink); } .layout { - max-width: 920px; + max-width: 1100px; margin: 0 auto; display: flex; flex-direction: column; @@ -44,7 +46,7 @@ h3 { font-size: 15px; margin: 0 0 8px 0; } p { color: var(--muted); margin: 0 0 16px 0; line-height: 1.5; } label { display: block; font-size: 13px; font-weight: 600; margin: 12px 0 4px; } - input, textarea, select { + input { width: 100%; padding: 8px 10px; border: 1px solid var(--border); @@ -52,7 +54,6 @@ font-size: 14px; font-family: inherit; } - textarea { font-family: 'SF Mono', Menlo, monospace; min-height: 80px; } button { background: var(--accent); color: white; @@ -65,15 +66,30 @@ } button:hover:not(:disabled) { background: var(--accent-dark); } button:disabled { opacity: 0.45; cursor: not-allowed; } + button.secondary { + background: transparent; + color: var(--ink); + border: 1px solid var(--border); + font-weight: 500; + } + button.secondary:hover:not(:disabled) { + background: var(--row-hover); + } + button.danger { + background: var(--danger); + } + button.danger:hover:not(:disabled) { + background: #900; + } .status { - margin-top: 16px; + margin-top: 12px; padding: 10px 12px; border-radius: 8px; font-size: 13px; display: none; } .status.success { background: var(--success-bg); color: var(--success-fg); display: block; } - .status.error { background: var(--error-bg); color: var(--error-fg); display: block; } + .status.error { background: var(--danger-bg); color: var(--danger); display: block; } pre { background: #0d1117; color: #c9d1d9; @@ -82,7 +98,7 @@ font-size: 12px; overflow-x: auto; margin: 8px 0 0 0; - max-height: 320px; + max-height: 240px; } .mode-toggle { display: flex; @@ -100,20 +116,92 @@ color: white; border-color: var(--accent); } - .mode-toggle button[disabled] { opacity: 0.5; } .mode-hint { font-size: 12px; color: var(--muted); margin-top: 4px; } - .crud-grid { + .notes-grid { display: grid; - grid-template-columns: 1fr 1fr; - gap: 16px; + grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); + gap: 24px; + align-items: start; } - @media (max-width: 720px) { - .crud-grid { grid-template-columns: 1fr; } + @media (max-width: 820px) { + .notes-grid { grid-template-columns: 1fr; } + } + .table-toolbar { + display: flex; + align-items: end; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; + } + .table-toolbar label { margin: 0 0 4px; } + .table-toolbar .limit-field { width: 100px; } + .table-wrap { + border: 1px solid var(--border); + border-radius: 8px; + overflow: auto; + max-height: 480px; + } + table { width: 100%; border-collapse: collapse; font-size: 13px; } + th, td { + text-align: left; + padding: 8px 10px; + border-bottom: 1px solid var(--border); + } + th { + position: sticky; + top: 0; + background: var(--row-hover); + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); + } + th.sortable { cursor: pointer; user-select: none; white-space: nowrap; } + th.sortable:hover { color: var(--accent); } + th.sortable.active { color: var(--accent); } + .sort-indicator { display: inline-block; width: 12px; margin-left: 4px; } + td.timestamp { + font-size: 12px; + color: var(--muted); + white-space: nowrap; + } + tbody tr { cursor: pointer; } + tbody tr:hover { background: var(--row-hover); } + tbody tr.selected { background: var(--row-selected); } + td.record-name { + font-family: 'SF Mono', Menlo, monospace; + font-size: 12px; + color: var(--muted); + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + td.actions { width: 1%; white-space: nowrap; } + td.actions button { padding: 4px 10px; font-size: 12px; } + .empty-state { + padding: 24px; + text-align: center; + color: var(--muted); + font-size: 13px; + } + .form-actions { + display: flex; + gap: 8px; + margin-top: 12px; + flex-wrap: wrap; + } + .form-mode-label { + font-size: 12px; + color: var(--muted); + font-weight: 500; } .pre-auth { opacity: 0.5; pointer-events: none; } #signin-area { margin-top: 8px; } .badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; background: #eef; color: var(--accent); } - .row { display: flex; align-items: center; gap: 8px; } + .raw-response summary { font-size: 12px; color: var(--muted); cursor: pointer; margin-top: 12px; } + .raw-response[open] summary { margin-bottom: 4px; } @@ -147,61 +235,65 @@

Auth

-
-

Records MistKit

-
+
+

Notes MistKit

+
-

Query

- - - - -
- +
+
+ + +
+
+ + +
+
+ +
-
- -
- -
-

Create

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

Update

- - - - - - -
- +

+ New note + +

+ + + + +
+ + +
-
- -
- -
-

Delete

- - - - -
- -
-
- +
+
+ Last raw response +
(none yet)
+
@@ -211,13 +303,33 @@

Delete

let container = null; let webAuthToken = null; let authenticationInProgress = false; - let currentMode = 'mistkit'; // 'mistkit' | 'cloudkitjs' + let currentMode = 'mistkit'; // 'mistkit' | 'cloudkitjs' + let notes = []; // [{ recordName, title, index, created, modified, recordChangeTag, raw }] + let selectedRecordName = null; + let authComplete = false; + // null until the user clicks a sortable column header. Once set, + // both MistKit-mode and CloudKit-JS-mode queries forward it. + let currentSort = null; // { field, ascending } | null const authStatusDiv = document.getElementById('auth-status'); const signinButton = document.getElementById('signin-button'); const signoutButton = document.getElementById('signout-button'); - const crudCard = document.getElementById('crud-card'); - const modeBadge = document.getElementById('crud-mode-badge'); + const notesCard = document.getElementById('notes-card'); + const modeBadge = document.getElementById('mode-badge'); + const tbody = document.getElementById('notes-tbody'); + const tableStatusEl = document.getElementById('table-status'); + const formStatusEl = document.getElementById('form-status'); + const formHeading = document.getElementById('form-heading'); + const formRecordName = document.getElementById('form-record-name'); + const titleInput = document.getElementById('form-title'); + const indexInput = document.getElementById('form-index'); + const saveBtn = document.getElementById('save-btn'); + const clearBtn = document.getElementById('clear-btn'); + const deleteBtn = document.getElementById('delete-btn'); + const refreshBtn = document.getElementById('refresh-btn'); + const recordTypeInput = document.getElementById('record-type'); + const queryLimitInput = document.getElementById('query-limit'); + const rawResponseEl = document.getElementById('raw-response'); // ---- shared helpers ---- @@ -233,9 +345,8 @@

Delete

el.style.display = 'none'; } - function showResult(el, value) { - el.style.display = 'block'; - el.textContent = JSON.stringify(value, null, 2); + function showRaw(value) { + rawResponseEl.textContent = value == null ? '(none)' : JSON.stringify(value, null, 2); } async function postJSON(path, body) { @@ -257,61 +368,213 @@

Delete

return payload; } - // ---- mode toggle ---- + // ---- form state ---- - document.getElementById('mode-mistkit').addEventListener('click', () => setMode('mistkit')); - document.getElementById('mode-cloudkitjs').addEventListener('click', () => setMode('cloudkitjs')); + function selectedNote() { + return notes.find(n => n.recordName === selectedRecordName) || null; + } - function setMode(mode) { - currentMode = mode; - const mistKitBtn = document.getElementById('mode-mistkit'); - const cloudKitJsBtn = document.getElementById('mode-cloudkitjs'); - mistKitBtn.classList.toggle('active', mode === 'mistkit'); - cloudKitJsBtn.classList.toggle('active', mode === 'cloudkitjs'); - modeBadge.textContent = mode === 'mistkit' ? 'MistKit' : 'CloudKit JS'; + function refreshFormState() { + const note = selectedNote(); + if (note) { + formHeading.textContent = 'Edit note'; + formRecordName.textContent = `· ${note.recordName}`; + saveBtn.textContent = 'Save'; + deleteBtn.disabled = false; + } else { + formHeading.textContent = 'New note'; + formRecordName.textContent = ''; + saveBtn.textContent = 'Create'; + deleteBtn.disabled = true; + } + } + + function clearForm() { + selectedRecordName = null; + titleInput.value = ''; + indexInput.value = ''; + clearStatus(formStatusEl); + refreshFormState(); + renderRows(); } - // ---- CRUD ---- + function loadNoteIntoForm(note) { + selectedRecordName = note.recordName; + titleInput.value = note.title ?? ''; + indexInput.value = note.index != null ? String(note.index) : ''; + clearStatus(formStatusEl); + refreshFormState(); + renderRows(); + } - function parseFieldsJSON(raw, statusEl) { - try { - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('Fields must be a JSON object'); + // ---- render ---- + + function renderRows() { + tbody.innerHTML = ''; + if (notes.length === 0) { + const tr = document.createElement('tr'); + const td = document.createElement('td'); + td.colSpan = 5; + td.className = 'empty-state'; + td.textContent = 'No notes — Refresh or Create one.'; + tr.appendChild(td); + tbody.appendChild(tr); + return; + } + for (const note of notes) { + const tr = document.createElement('tr'); + tr.title = note.recordName; + if (note.recordName === selectedRecordName) tr.classList.add('selected'); + tr.addEventListener('click', (e) => { + if (e.target.closest('button')) return; + loadNoteIntoForm(note); + }); + + const titleTd = document.createElement('td'); + titleTd.textContent = note.title ?? ''; + tr.appendChild(titleTd); + + const indexTd = document.createElement('td'); + indexTd.textContent = note.index != null ? String(note.index) : ''; + tr.appendChild(indexTd); + + const createdTd = document.createElement('td'); + createdTd.className = 'timestamp'; + createdTd.textContent = formatTimestamp(note.created); + if (note.created) createdTd.title = note.created.toISOString(); + tr.appendChild(createdTd); + + const modifiedTd = document.createElement('td'); + modifiedTd.className = 'timestamp'; + modifiedTd.textContent = formatTimestamp(note.modified); + if (note.modified) modifiedTd.title = note.modified.toISOString(); + tr.appendChild(modifiedTd); + + const actionsTd = document.createElement('td'); + actionsTd.className = 'actions'; + const delBtn = document.createElement('button'); + delBtn.className = 'danger'; + delBtn.type = 'button'; + delBtn.textContent = 'Delete'; + // No confirm() — the demo prioritizes hands-on iteration; the + // raw response panel below the form shows the wire payload of + // every operation so accidents stay visible. + delBtn.addEventListener('click', () => deleteNote(note)); + actionsTd.appendChild(delBtn); + tr.appendChild(actionsTd); + + tbody.appendChild(tr); + } + } + + function refreshSortIndicators() { + document.querySelectorAll('th.sortable').forEach(th => { + const field = th.dataset.sortField; + const isActive = currentSort && currentSort.field === field; + th.classList.toggle('active', isActive); + const indicator = th.querySelector('.sort-indicator'); + if (isActive) { + indicator.textContent = currentSort.ascending ? '↑' : '↓'; + } else { + indicator.textContent = ''; } - for (const [k, v] of Object.entries(parsed)) { - if (typeof v !== 'string') { - throw new Error(`Field "${k}" must be a string (this demo only supports string fields)`); + }); + } + + document.querySelectorAll('th.sortable').forEach(th => { + th.addEventListener('click', () => { + const field = th.dataset.sortField; + if (currentSort && currentSort.field === field) { + if (currentSort.ascending) { + currentSort = { field, ascending: false }; + } else { + currentSort = null; // third click clears } + } else { + currentSort = { field, ascending: true }; } - return parsed; - } catch (error) { - setStatus(statusEl, error.message, 'error'); - return null; - } + refreshSortIndicators(); + if (authComplete) queryNotes(); + }); + }); + + // ---- backend normalization ---- + + // Project the MistKit-mode `/api/records/query` payload (which mirrors + // CloudKit Web Services on the wire) and the CloudKit JS response + // into the single shape the table renders from. + // + // Timestamps: + // - MistKit-mode wire: `created.timestamp` / `modified.timestamp` + // are epoch-millis numbers (encoded with WebJSON's + // .millisecondsSince1970 strategy on the server). + // - CloudKit JS: `created` / `modified` are typed Date objects. + // The browser normalizes both into a Date instance. + function normalizeRecords(payload) { + const list = (payload && payload.records) || []; + return list.map(record => { + const fields = record.fields || {}; + const titleField = fields.title; + const indexField = fields.index; + return { + recordName: record.recordName, + recordType: record.recordType, + recordChangeTag: record.recordChangeTag, + title: titleField && (titleField.value ?? titleField), + index: indexField && Number(indexField.value ?? indexField), + created: toDate(record.created), + modified: toDate(record.modified), + raw: record, + }; + }); } - async function runMistKitOperation(label, path, body, statusEl, resultEl) { - clearStatus(statusEl); - resultEl.style.display = 'none'; - try { - const data = await postJSON(path, body); - setStatus(statusEl, `${label} succeeded.`, 'success'); - showResult(resultEl, data); - } catch (error) { - setStatus(statusEl, `${label} failed: ${error.message}`, 'error'); - if (error.payload) showResult(resultEl, error.payload); + function toDate(value) { + if (value == null) return null; + // CloudKit JS shape: a Date object directly on record.created/modified, + // OR an envelope { timestamp: Date|number, userRecordName }. + if (value instanceof Date) return value; + if (typeof value === 'number') return new Date(value); + if (typeof value === 'object') { + const inner = value.timestamp; + if (inner == null) return null; + if (inner instanceof Date) return inner; + if (typeof inner === 'number') return new Date(inner); + if (typeof inner === 'string') { + const parsed = Date.parse(inner); + return isNaN(parsed) ? null : new Date(parsed); + } } + return null; + } + + function formatTimestamp(date) { + if (!date) return '—'; + return date.toLocaleString(undefined, { + dateStyle: 'short', timeStyle: 'short', + }); } function ckJsDatabase() { return container.privateCloudDatabase; } - // CloudKit JS expects field values as { key: { value: ... } } while the - // MistKit-mode endpoints accept the flat { key: "value" } shape this - // demo's textareas already produce — wrap before saveRecords. - function fieldsToCKJS(fields) { + function buildFields() { + const out = {}; + const title = titleInput.value.trim(); + if (title.length > 0) out.title = title; + const indexRaw = indexInput.value.trim(); + if (indexRaw.length > 0) { + const parsed = Number(indexRaw); + if (!isFinite(parsed)) { + throw new Error('Index must be a number.'); + } + out.index = parsed; + } + return out; + } + + function ckJsFields(fields) { const wrapped = {}; for (const [k, v] of Object.entries(fields)) { wrapped[k] = { value: v }; @@ -319,122 +582,166 @@

Delete

return wrapped; } - async function runCloudKitJsOperation(label, work, statusEl, resultEl) { - clearStatus(statusEl); - resultEl.style.display = 'none'; + // ---- operations ---- + + async function queryNotes() { + const recordType = recordTypeInput.value.trim(); + const limit = parseInt(queryLimitInput.value, 10); + clearStatus(tableStatusEl); try { - const data = await work(); - if (data && data.hasErrors && data.errors && data.errors.length) { - const first = data.errors[0]; - const message = (first && (first.reason || first.serverErrorCode)) - || 'CloudKit JS reported an error'; - setStatus(statusEl, `${label} failed: ${message}`, 'error'); - showResult(resultEl, data); - return; + let payload; + if (currentMode === 'mistkit') { + payload = await postJSON('/api/records/query', { + recordType, + limit: isFinite(limit) ? limit : undefined, + sortBy: currentSort + ? [{ field: currentSort.field, ascending: currentSort.ascending }] + : undefined, + }); + } else { + const query = { recordType }; + if (currentSort) { + query.sortBy = [{ + fieldName: currentSort.field, + ascending: currentSort.ascending, + }]; + } + payload = await ckJsDatabase().performQuery(query, { + resultsLimit: isFinite(limit) ? limit : undefined, + }); + if (payload && payload.hasErrors && payload.errors.length) { + throw new Error(payload.errors[0].reason || 'CloudKit JS query failed'); + } + } + notes = normalizeRecords(payload); + if (selectedRecordName && !notes.some(n => n.recordName === selectedRecordName)) { + clearForm(); + } else { + refreshFormState(); + renderRows(); } - setStatus(statusEl, `${label} succeeded.`, 'success'); - showResult(resultEl, data); + showRaw(payload); + setStatus(tableStatusEl, `Loaded ${notes.length} record${notes.length === 1 ? '' : 's'}.`, 'success'); } catch (error) { - const message = (error && error.message) || String(error); - setStatus(statusEl, `${label} failed: ${message}`, 'error'); - if (error && typeof error === 'object') showResult(resultEl, error); + setStatus(tableStatusEl, `Query failed: ${error.message}`, 'error'); + showRaw(error.payload || { message: error.message }); } } - async function fetchRecordChangeTag(recordName) { - const result = await ckJsDatabase().fetchRecords([{ recordName }]); - if (result.hasErrors && result.errors.length) { - const first = result.errors[0]; - throw new Error((first && first.reason) || 'fetchRecords failed'); - } - const record = result.records && result.records[0]; - if (!record) throw new Error(`Record "${recordName}" not found`); - return record.recordChangeTag; - } - - document.getElementById('query-btn').addEventListener('click', async () => { - const recordType = document.getElementById('query-record-type').value.trim(); - const limit = parseInt(document.getElementById('query-limit').value, 10); - const statusEl = document.getElementById('query-status'); - const resultEl = document.getElementById('query-result'); - if (currentMode === 'mistkit') { - await runMistKitOperation('Query', '/api/records/query', { - recordType, limit: isFinite(limit) ? limit : undefined, - }, statusEl, resultEl); - return; - } - await runCloudKitJsOperation('Query', () => - ckJsDatabase().performQuery({ recordType }, { - resultsLimit: isFinite(limit) ? limit : undefined, - }), - statusEl, resultEl); - }); - - document.getElementById('create-btn').addEventListener('click', async () => { - const recordType = document.getElementById('create-record-type').value.trim(); - const statusEl = document.getElementById('create-status'); - const resultEl = document.getElementById('create-result'); - const fields = parseFieldsJSON(document.getElementById('create-fields').value, statusEl); - if (!fields) return; - if (currentMode === 'mistkit') { - await runMistKitOperation('Create', '/api/records/create', - { recordType, fields }, statusEl, resultEl); + async function saveNote() { + let fields; + try { + fields = buildFields(); + } catch (error) { + setStatus(formStatusEl, error.message, 'error'); return; } - await runCloudKitJsOperation('Create', () => - ckJsDatabase().saveRecords([{ recordType, fields: fieldsToCKJS(fields) }]), - statusEl, resultEl); - }); - - document.getElementById('update-btn').addEventListener('click', async () => { - const recordType = document.getElementById('update-record-type').value.trim(); - const recordName = document.getElementById('update-record-name').value.trim(); - const statusEl = document.getElementById('update-status'); - const resultEl = document.getElementById('update-result'); - if (!recordName) { - setStatus(statusEl, 'Record name is required.', 'error'); + if (Object.keys(fields).length === 0) { + setStatus(formStatusEl, 'Provide a title or index.', 'error'); return; } - const fields = parseFieldsJSON(document.getElementById('update-fields').value, statusEl); - if (!fields) return; - if (currentMode === 'mistkit') { - await runMistKitOperation('Update', '/api/records/update', { - recordType, recordName, fields, - }, statusEl, resultEl); - return; + const recordType = recordTypeInput.value.trim(); + const note = selectedNote(); + const isUpdate = note != null; + const label = isUpdate ? 'Update' : 'Create'; + clearStatus(formStatusEl); + try { + let payload; + if (currentMode === 'mistkit') { + if (isUpdate) { + payload = await postJSON('/api/records/update', { + recordType, + recordName: note.recordName, + fields, + recordChangeTag: note.recordChangeTag, + }); + } else { + payload = await postJSON('/api/records/create', { + recordType, fields, + }); + } + } else { + const record = { recordType, fields: ckJsFields(fields) }; + if (isUpdate) { + record.recordName = note.recordName; + record.recordChangeTag = note.recordChangeTag; + } + payload = await ckJsDatabase().saveRecords([record]); + if (payload && payload.hasErrors && payload.errors.length) { + throw new Error(payload.errors[0].reason || 'CloudKit JS save failed'); + } + } + showRaw(payload); + setStatus(formStatusEl, `${label} succeeded.`, 'success'); + if (!isUpdate) clearForm(); + await queryNotes(); + } catch (error) { + setStatus(formStatusEl, `${label} failed: ${error.message}`, 'error'); + showRaw(error.payload || { message: error.message }); } - await runCloudKitJsOperation('Update', async () => { - const recordChangeTag = await fetchRecordChangeTag(recordName); - return ckJsDatabase().saveRecords([{ - recordType, recordName, recordChangeTag, - fields: fieldsToCKJS(fields), - }]); - }, statusEl, resultEl); - }); + } - document.getElementById('delete-btn').addEventListener('click', async () => { - const recordType = document.getElementById('delete-record-type').value.trim(); - const recordName = document.getElementById('delete-record-name').value.trim(); - const statusEl = document.getElementById('delete-status'); - const resultEl = document.getElementById('delete-result'); - if (!recordName) { - setStatus(statusEl, 'Record name is required.', 'error'); - return; - } - if (currentMode === 'mistkit') { - await runMistKitOperation('Delete', '/api/records/delete', - { recordType, recordName }, statusEl, resultEl); - return; + // statusEl defaults to tableStatusEl: per-row Delete buttons live in + // the table, so feedback belongs above the table. The form panel's + // Delete button passes formStatusEl explicitly to keep its status + // alongside the form it acts on. + async function deleteNote(note, statusEl = tableStatusEl) { + clearStatus(statusEl); + try { + let payload; + if (currentMode === 'mistkit') { + payload = await postJSON('/api/records/delete', { + recordType: note.recordType, + recordName: note.recordName, + recordChangeTag: note.recordChangeTag, + }); + } else { + payload = await ckJsDatabase().deleteRecords([{ recordName: note.recordName }]); + if (payload && payload.hasErrors && payload.errors.length) { + throw new Error(payload.errors[0].reason || 'CloudKit JS delete failed'); + } + } + showRaw(payload); + setStatus(statusEl, `Deleted ${note.recordName}.`, 'success'); + if (note.recordName === selectedRecordName) clearForm(); + await queryNotes(); + } catch (error) { + setStatus(statusEl, `Delete failed: ${error.message}`, 'error'); + showRaw(error.payload || { message: error.message }); } - await runCloudKitJsOperation('Delete', () => - ckJsDatabase().deleteRecords([{ recordName }]), - statusEl, resultEl); + } + + // ---- mode toggle ---- + + document.getElementById('mode-mistkit').addEventListener('click', () => setMode('mistkit')); + document.getElementById('mode-cloudkitjs').addEventListener('click', () => setMode('cloudkitjs')); + + function setMode(mode) { + if (mode === currentMode) return; + currentMode = mode; + const mistKitBtn = document.getElementById('mode-mistkit'); + const cloudKitJsBtn = document.getElementById('mode-cloudkitjs'); + mistKitBtn.classList.toggle('active', mode === 'mistkit'); + cloudKitJsBtn.classList.toggle('active', mode === 'cloudkitjs'); + modeBadge.textContent = mode === 'mistkit' ? 'MistKit' : 'CloudKit JS'; + if (authComplete) queryNotes(); + } + + // ---- form wiring ---- + + saveBtn.addEventListener('click', saveNote); + clearBtn.addEventListener('click', clearForm); + deleteBtn.addEventListener('click', () => { + const note = selectedNote(); + if (note) deleteNote(note, formStatusEl); }); + refreshBtn.addEventListener('click', queryNotes); // ---- auth flow ---- function setAuthed(authed) { - crudCard.classList.toggle('pre-auth', !authed); + authComplete = authed; + notesCard.classList.toggle('pre-auth', !authed); signoutButton.style.display = authed ? 'inline-block' : 'none'; } @@ -480,7 +787,7 @@

Delete

} function renderTokenDisplay(token) { - crudCard.style.display = 'none'; + notesCard.style.display = 'none'; document.getElementById('signin-area').style.display = 'none'; const card = document.createElement('div'); card.className = 'card'; @@ -507,6 +814,7 @@

Web Auth Token captured

} else { setStatus(authStatusDiv, `Authenticated as ${userIdentity.userRecordName}.`, 'success'); setAuthed(true); + queryNotes(); } } catch (error) { setStatus(authStatusDiv, `Authentication failed: ${error.message}`, 'error'); @@ -520,6 +828,8 @@

Web Auth Token captured

await container.signOut(); webAuthToken = null; setAuthed(false); + notes = []; + clearForm(); setStatus(authStatusDiv, 'Signed out.', 'success'); } catch (error) { setStatus(authStatusDiv, 'Sign out failed: ' + error.message, 'error'); @@ -555,6 +865,8 @@

Web Auth Token captured

container.whenUserSignsOut().then(() => { webAuthToken = null; setAuthed(false); + notes = []; + clearForm(); setStatus(authStatusDiv, 'Signed out.', 'success'); }); } catch (error) { @@ -562,6 +874,7 @@

Web Auth Token captured

} } + refreshFormState(); initializeCloudKit(); diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift index 69f56bd5..22fef87a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift @@ -40,7 +40,8 @@ internal import MistKit internal protocol WebBackend: Sendable { func webQuery( recordType: String, - limit: Int? + limit: Int?, + sortBy: [WebRequests.QuerySortField]? ) async throws -> [RecordInfo] func webCreate( @@ -51,12 +52,14 @@ internal protocol WebBackend: Sendable { func webUpdate( recordType: String, recordName: String, - fields: [String: FieldValue] + fields: [String: FieldValue], + recordChangeTag: String? ) async throws -> RecordInfo func webDelete( recordType: String, - recordName: String + recordName: String, + recordChangeTag: String? ) async throws } @@ -64,12 +67,16 @@ internal protocol WebBackend: Sendable { extension CloudKitService: WebBackend { internal func webQuery( recordType: String, - limit: Int? + limit: Int?, + sortBy: [WebRequests.QuerySortField]? ) 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: nil, + sortBy: querySorts, limit: limit, desiredKeys: nil, continuationMarker: nil, @@ -92,23 +99,27 @@ extension CloudKitService: WebBackend { internal func webUpdate( recordType: String, recordName: String, - fields: [String: FieldValue] + fields: [String: FieldValue], + recordChangeTag: String? ) async throws -> RecordInfo { try await updateRecord( recordType: recordType, recordName: recordName, fields: fields, + recordChangeTag: recordChangeTag, database: .private ) } internal func webDelete( recordType: String, - recordName: String + recordName: String, + recordChangeTag: String? ) async throws { try await deleteRecord( recordType: recordType, recordName: recordName, + recordChangeTag: recordChangeTag, database: .private ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebJSON.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebJSON.swift new file mode 100644 index 00000000..d492da04 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebJSON.swift @@ -0,0 +1,44 @@ +// +// WebJSON.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 + +/// Shared JSON encoder for the web demo's CRUD response bodies. +/// +/// 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 index f8f4e57a..7d262da0 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift @@ -32,44 +32,58 @@ internal import MistKit /// Request payloads for the web command's CRUD endpoints. /// -/// Field values are limited to string in the request body so the HTML form -/// schema stays trivial. Wider FieldValue coverage (numbers, dates, refs) -/// can land later once the demo UI exposes typed input controls. +/// `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 { internal let recordType: String internal let limit: Int? + internal let sortBy: [QuerySortField]? } /// `POST /api/records/create` internal struct Create: Decodable { internal let recordType: String - internal let fields: [String: String] + internal let fields: [String: FieldValue] } /// `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 { internal let recordType: String internal let recordName: String - internal let fields: [String: String] + internal let fields: [String: FieldValue] + internal let recordChangeTag: String? } /// `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 { internal let recordType: String internal let recordName: String - } - - /// Convert a JSON `[String: String]` request payload into the - /// `FieldValue` map MistKit expects. - internal static func stringFields( - _ raw: [String: String] - ) -> [String: FieldValue] { - var result: [String: FieldValue] = [:] - for (name, value) in raw { - result[name] = .string(value) - } - return result + internal let recordChangeTag: String? } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift index abc11c84..cfe66b52 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift @@ -48,9 +48,11 @@ 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 + recordType: body.recordType, + limit: body.limit, + sortBy: body.sortBy ) - return try JSONEncoder().encode( + return try WebJSON.encoder().encode( WebResponse.Records(records: records) ) } @@ -73,9 +75,9 @@ let backend = try backendFactory.make(token) let record = try await backend.webCreate( recordType: body.recordType, - fields: WebRequests.stringFields(body.fields) + fields: body.fields ) - return try JSONEncoder().encode( + return try WebJSON.encoder().encode( WebResponse.Records(records: [record]) ) } @@ -99,9 +101,10 @@ let record = try await backend.webUpdate( recordType: body.recordType, recordName: body.recordName, - fields: WebRequests.stringFields(body.fields) + fields: body.fields, + recordChangeTag: body.recordChangeTag ) - return try JSONEncoder().encode( + return try WebJSON.encoder().encode( WebResponse.Records(records: [record]) ) } @@ -124,9 +127,10 @@ let backend = try backendFactory.make(token) try await backend.webDelete( recordType: body.recordType, - recordName: body.recordName + recordName: body.recordName, + recordChangeTag: body.recordChangeTag ) - return try JSONEncoder().encode( + return try WebJSON.encoder().encode( WebResponse.Delete( recordName: body.recordName, deleted: true ) diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift index 3d2ab4c6..5b2a7d4f 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift @@ -39,6 +39,7 @@ internal struct QueryCall: Sendable { internal let recordType: String internal let limit: Int? + internal let sortBy: [WebRequests.QuerySortField]? } internal struct CreateCall: Sendable { @@ -50,11 +51,13 @@ internal let recordType: String internal let recordName: String internal let fields: [String: String] + internal let recordChangeTag: String? } internal struct DeleteCall: Sendable { internal let recordType: String internal let recordName: String + internal let recordChangeTag: String? } internal private(set) var lastQuery: QueryCall? @@ -85,15 +88,29 @@ ) } - /// Flatten FieldValue.string entries back to plain strings so tests - /// can `#expect(captured.fields["title"] == "Hi")` without unwrapping. + /// 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 { - if case .string(let string) = value { + 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 @@ -104,9 +121,13 @@ } internal func webQuery( - recordType: String, limit: Int? + recordType: String, + limit: Int?, + sortBy: [WebRequests.QuerySortField]? ) async throws -> [RecordInfo] { - lastQuery = QueryCall(recordType: recordType, limit: limit) + lastQuery = QueryCall( + recordType: recordType, limit: limit, sortBy: sortBy + ) try consumePendingError() return [ Self.stubRecord(recordType: recordType, recordName: "stub-1") @@ -129,12 +150,14 @@ internal func webUpdate( recordType: String, recordName: String, - fields: [String: FieldValue] + fields: [String: FieldValue], + recordChangeTag: String? ) async throws -> RecordInfo { lastUpdate = UpdateCall( recordType: recordType, recordName: recordName, - fields: Self.flatten(fields) + fields: Self.flatten(fields), + recordChangeTag: recordChangeTag ) try consumePendingError() return Self.stubRecord( @@ -143,10 +166,14 @@ } internal func webDelete( - recordType: String, recordName: String + recordType: String, + recordName: String, + recordChangeTag: String? ) async throws { lastDelete = DeleteCall( - recordType: recordType, recordName: recordName + recordType: recordType, + recordName: recordName, + recordChangeTag: recordChangeTag ) try consumePendingError() } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebJSONTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebJSONTests.swift new file mode 100644 index 00000000..aa90f059 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebJSONTests.swift @@ -0,0 +1,56 @@ +// +// WebJSONTests.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 Testing + + @testable import MistDemoKit + + @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)) + + let json = try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + #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 index 406549b8..f6ce5d32 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+CRUD.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+CRUD.swift @@ -97,12 +97,36 @@ #expect(captured?.fields["title"] == "Hi") } - @Test("POST /api/records/update forwards record name + fields") + @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"}} + {"recordType":"Note","recordName":"abc","fields":{"title":"Up"},\ + "recordChangeTag":"tag-1"} """ try await app.test(.router) { client in @@ -120,13 +144,39 @@ #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 record name") + @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"}"# + let jsonBody = #""" + {"recordType":"Note","recordName":"abc","recordChangeTag":"tag-9"} + """# try await app.test(.router) { client in try await client.execute( @@ -148,6 +198,7 @@ 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") 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/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/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", From bad7b1eed081886640ff33fe9a0c4382566bac3c Mon Sep 17 00:00:00 2001 From: leogdion Date: Wed, 13 May 2026 08:50:12 -0400 Subject: [PATCH 7/9] Resolve #275 (web side): public/private database picker (#337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Resolve #275 (web side): public/private database picker Adds a database picker to the long-running `mistdemo web` demo so the mode toggle's four profiles can be exercised side-by-side: - MistKit + private → API token + browser-captured web-auth token - MistKit + public → server-to-server signing (CLOUDKIT_KEY_ID + CLOUDKIT_PRIVATE_KEY[_PATH]) - CloudKit JS + private → API token + web-auth (shared with MistKit) - CloudKit JS + public → API token only (browser → CloudKit directly) WebConfig now accepts optional key-id / private-key inputs and computes `publicDatabaseAvailable`. WebBackendFactory.live builds the CloudKitService from `Credentials` so a single service can route operations to either database based on the request's `database` field. The `/api/config` endpoint advertises `publicDatabaseAvailable` so the UI disables the "MistKit + Public" option when the server isn't holding S2S credentials. CloudKit-JS-mode requests pick `container.publicCloudDatabase` vs `privateCloudDatabase` based on the toggle. Unknown database values return 400 rather than silently defaulting. The app side of #275 is intentionally not addressed here; it's absorbed by #328 (replace NativeCloudKitService with the CloudKit framework). Co-Authored-By: Claude Opus 4.7 (1M context) * WebUI: loading states, post-create delay, "You" badge, default sort Four UX papercuts surfaced in first hands-on use of the new public/private database picker: - Switching databases triggered a query with no feedback while the network round-trip was in flight. queryNotes now sets a `.status.loading` message ("Loading via ") and disables refresh/db/mode/save/ delete buttons for the duration; a finally block re-enables them and re-runs refreshDatabasePicker so the public-availability gate wins. - Auto-refresh after Create raced CloudKit's eventual consistency on Public — the new record was often missing. saveNote now pauses REFRESH_DELAY_MS (1.2s) on Create only, with a visible "waiting for CloudKit to settle" status. Update/Delete still refresh immediately. - No way to tell which records the signed-in user owned (most useful on Public). handleAuthentication now stores userIdentity.userRecordName in currentUserRecordName; normalizeRecords projects createdBy from the MistKit-mode `created.userRecordName` envelope; renderRows appends a green "You" badge when they match. Cleared on sign-out. CloudKit-JS mode doesn't surface the creator on records, so the badge is MistKit-only — refreshDatabasePicker now spells that out as a hint in CloudKit-JS mode. - Default sort was "whatever CloudKit returned." currentSort now initializes to ___createTime descending, and refreshSortIndicators fires once during init so the column arrow renders before the first query. Coverage: WebServerTests+Index gains "Index HTML carries the post-database-picker UX additions" asserting all four new strings (.status.loading, REFRESH_DELAY_MS, currentUserRecordName/badge-you, the default-sort initializer). 931 tests pass; swiftlint clean. Co-Authored-By: Claude Opus 4.7 (1M context) * WebUI: rework database-picker hint after MistKit/CloudKit-JS identity audit Two corrections to the hint that appears under the database toggle: - Drop the special CloudKit-JS branch that claimed CloudKit JS doesn't surface the record creator. A real-session diagnostic showed the opposite: CloudKit JS returns `created.userRecordName` in the same envelope shape MistKit does (alongside `timestamp` and `deviceID`), and `normalizeRecords` was already picking it up correctly. - Add a new MistKit + Public hint explaining the iCloud-vs-S2S identity split: records you write via that path are owned by the S2S key's effective identity, not your iCloud user, so they intentionally don't carry the "You" badge. Tracked for broader follow-up in #338. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../Commands/AuthTokenCommand.swift | 1 + .../MistDemoKit/Commands/WebCommand.swift | 53 ++++- .../MistDemoKit/Configuration/WebConfig.swift | 43 +++- .../Sources/MistDemoKit/Resources/index.html | 190 +++++++++++++++++- .../MistDemoKit/Server/WebBackend.swift | 35 ++-- .../Server/WebBackendFactory.swift | 22 +- .../MistDemoKit/Server/WebRequests.swift | 109 ++++++++++ .../MistDemoKit/Server/WebServer+CRUD.swift | 12 +- .../MistDemoKit/Server/WebServer.swift | 11 +- .../MistDemoTests/Server/MockBackend.swift | 31 ++- .../Server/WebServerTests+CRUD.swift | 1 + .../Server/WebServerTests+Database.swift | 133 ++++++++++++ .../Server/WebServerTests+Index.swift | 47 +++++ .../MistDemoTests/Server/WebServerTests.swift | 24 ++- 14 files changed, 666 insertions(+), 46 deletions(-) create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Database.swift diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift index 71f93837..596e65fa 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift @@ -116,6 +116,7 @@ apiToken: config.apiToken, containerIdentifier: config.containerIdentifier, environment: config.environment, + publicDatabaseAvailable: false, tokenStore: tokenStore, backendFactory: .live( apiToken: config.apiToken, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift index 72183c5d..abb63ace 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift @@ -63,8 +63,16 @@ --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. Ctrl+C to exit. + 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 @@ -77,6 +85,9 @@ /// 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() @@ -84,11 +95,13 @@ 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 + environment: config.environment, + serverToServer: try makeServerToServerCredentials() ), terminatesAfterAuth: false ) @@ -121,6 +134,42 @@ } } + /// 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 diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift index 3d8423e6..8103853e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift @@ -35,7 +35,10 @@ public import MistKit /// /// 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. +/// 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 @@ -57,6 +60,24 @@ public struct WebConfig: Sendable, ConfigurationParseable { /// 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( @@ -65,7 +86,10 @@ public struct WebConfig: Sendable, ConfigurationParseable { environment: MistKit.Environment = .development, port: Int = 8_080, host: String = "127.0.0.1", - openBrowser: Bool = false + openBrowser: Bool = false, + keyID: String? = nil, + privateKey: String? = nil, + privateKeyFile: String? = nil ) { self.apiToken = apiToken self.containerIdentifier = containerIdentifier @@ -73,6 +97,9 @@ public struct WebConfig: Sendable, ConfigurationParseable { self.port = port self.host = host self.openBrowser = openBrowser + self.keyID = keyID + self.privateKey = privateKey + self.privateKeyFile = privateKeyFile } /// Parse configuration from command line arguments. @@ -115,13 +142,23 @@ public struct WebConfig: Sendable, ConfigurationParseable { 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 + openBrowser: openBrowser, + keyID: keyID, + privateKey: privateKey, + privateKeyFile: privateKeyFile ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html index 17828d36..061d46ce 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html @@ -90,6 +90,20 @@ } .status.success { background: var(--success-bg); color: var(--success-fg); display: block; } .status.error { background: var(--danger-bg); color: var(--danger); display: block; } + .status.loading { background: var(--row-hover); color: var(--muted); display: block; } + .status.loading::after { + content: '…'; + display: inline-block; + margin-left: 4px; + animation: status-loading-dots 1.2s steps(4, end) infinite; + width: 1ch; + overflow: hidden; + vertical-align: bottom; + } + @keyframes status-loading-dots { + 0% { width: 0; } + 100% { width: 1ch; } + } pre { background: #0d1117; color: #c9d1d9; @@ -200,6 +214,7 @@ .pre-auth { opacity: 0.5; pointer-events: none; } #signin-area { margin-top: 8px; } .badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; background: #eef; color: var(--accent); } + .badge-you { background: var(--success-bg); color: var(--success-fg); margin-left: 8px; } .raw-response summary { font-size: 12px; color: var(--muted); cursor: pointer; margin-top: 12px; } .raw-response[open] summary { margin-bottom: 4px; } @@ -227,6 +242,18 @@

Backend

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

@@ -236,7 +263,7 @@

Auth

-

Notes MistKit

+

Notes MistKit Private

@@ -304,18 +331,40 @@

let webAuthToken = null; let authenticationInProgress = false; let currentMode = 'mistkit'; // 'mistkit' | 'cloudkitjs' - let notes = []; // [{ recordName, title, index, created, modified, recordChangeTag, raw }] + let currentDatabase = 'private'; // 'private' | 'public' + // True only when the server reports server-to-server credentials are + // configured. CloudKit JS can hit `.public` regardless (it only needs + // an API token), so the picker stays usable in cloudkitjs mode even + // when this is false — the constraint is "MistKit + public requires + // S2S on the server." + let publicDatabaseAvailable = false; + let notes = []; // [{ recordName, title, index, created, modified, createdBy, recordChangeTag, raw }] let selectedRecordName = null; let authComplete = false; - // null until the user clicks a sortable column header. Once set, - // both MistKit-mode and CloudKit-JS-mode queries forward it. - let currentSort = null; // { field, ascending } | null + let queryInFlight = false; + // Captured from the userIdentity returned by setUpAuth() so the table + // can flag rows whose creator (created.userRecordName from the + // MistKit-mode payload) matches the signed-in user. + let currentUserRecordName = null; + // Default: latest created first. The third click on the Created header + // still clears the sort to null, after which the next click cycles back + // through ascending → descending → null. + let currentSort = { field: '___createTime', ascending: false }; + // Pause between a successful Create and the auto-refresh that follows. + // CloudKit Web Services is eventually consistent — without this pause + // the new record is often missing from the next list, especially on + // the public database. + const REFRESH_DELAY_MS = 1200; const authStatusDiv = document.getElementById('auth-status'); const signinButton = document.getElementById('signin-button'); const signoutButton = document.getElementById('signout-button'); const notesCard = document.getElementById('notes-card'); const modeBadge = document.getElementById('mode-badge'); + const dbBadge = document.getElementById('db-badge'); + const dbPrivateBtn = document.getElementById('db-private'); + const dbPublicBtn = document.getElementById('db-public'); + const dbHint = document.getElementById('db-hint'); const tbody = document.getElementById('notes-tbody'); const tableStatusEl = document.getElementById('table-status'); const formStatusEl = document.getElementById('form-status'); @@ -432,6 +481,13 @@

const titleTd = document.createElement('td'); titleTd.textContent = note.title ?? ''; + if (note.createdBy && note.createdBy === currentUserRecordName) { + const youBadge = document.createElement('span'); + youBadge.className = 'badge badge-you'; + youBadge.textContent = 'You'; + youBadge.title = `Created by ${note.createdBy}`; + titleTd.appendChild(youBadge); + } tr.appendChild(titleTd); const indexTd = document.createElement('td'); @@ -524,11 +580,23 @@

index: indexField && Number(indexField.value ?? indexField), created: toDate(record.created), modified: toDate(record.modified), + // MistKit-mode wire: `created` is { timestamp, userRecordName }. + // CloudKit JS: `created` is a bare Date object — no creator + // available on the record. Falls back to null in that case; + // the table treats it as "unknown". + createdBy: extractUserRecordName(record.created), raw: record, }; }); } + function extractUserRecordName(value) { + if (value == null || typeof value !== 'object' || value instanceof Date) { + return null; + } + return value.userRecordName ?? null; + } + function toDate(value) { if (value == null) return null; // CloudKit JS shape: a Date object directly on record.created/modified, @@ -556,7 +624,9 @@

} function ckJsDatabase() { - return container.privateCloudDatabase; + return currentDatabase === 'public' + ? container.publicCloudDatabase + : container.privateCloudDatabase; } function buildFields() { @@ -585,14 +655,20 @@

// ---- operations ---- async function queryNotes() { + if (queryInFlight) return; const recordType = recordTypeInput.value.trim(); const limit = parseInt(queryLimitInput.value, 10); - clearStatus(tableStatusEl); + queryInFlight = true; + setQueryControlsDisabled(true); + const dbLabel = currentDatabase === 'public' ? 'public' : 'private'; + const modeLabel = currentMode === 'mistkit' ? 'MistKit' : 'CloudKit JS'; + setStatus(tableStatusEl, `Loading ${dbLabel} via ${modeLabel}`, 'loading'); try { let payload; if (currentMode === 'mistkit') { payload = await postJSON('/api/records/query', { recordType, + database: currentDatabase, limit: isFinite(limit) ? limit : undefined, sortBy: currentSort ? [{ field: currentSort.field, ascending: currentSort.ascending }] @@ -625,6 +701,24 @@

} catch (error) { setStatus(tableStatusEl, `Query failed: ${error.message}`, 'error'); showRaw(error.payload || { message: error.message }); + } finally { + queryInFlight = false; + setQueryControlsDisabled(false); + // Re-evaluate the picker so the public-availability gate + // (which writes to the same buttons we just re-enabled) wins. + refreshDatabasePicker(); + } + } + + function setQueryControlsDisabled(disabled) { + const ids = [ + 'refresh-btn', 'db-private', 'db-public', + 'mode-mistkit', 'mode-cloudkitjs', + 'save-btn', 'delete-btn', + ]; + for (const id of ids) { + const el = document.getElementById(id); + if (el) el.disabled = disabled; } } @@ -651,13 +745,16 @@

if (isUpdate) { payload = await postJSON('/api/records/update', { recordType, + database: currentDatabase, recordName: note.recordName, fields, recordChangeTag: note.recordChangeTag, }); } else { payload = await postJSON('/api/records/create', { - recordType, fields, + recordType, + database: currentDatabase, + fields, }); } } else { @@ -674,6 +771,17 @@

showRaw(payload); setStatus(formStatusEl, `${label} succeeded.`, 'success'); if (!isUpdate) clearForm(); + if (!isUpdate) { + // Public-DB writes routinely take a beat to show up in the + // next list query. Pause once with a visible status so the + // user understands why the table doesn't refresh instantly. + setStatus( + formStatusEl, + `Created — waiting ${REFRESH_DELAY_MS}ms for CloudKit to settle`, + 'loading' + ); + await new Promise(r => setTimeout(r, REFRESH_DELAY_MS)); + } await queryNotes(); } catch (error) { setStatus(formStatusEl, `${label} failed: ${error.message}`, 'error'); @@ -692,6 +800,7 @@

if (currentMode === 'mistkit') { payload = await postJSON('/api/records/delete', { recordType: note.recordType, + database: currentDatabase, recordName: note.recordName, recordChangeTag: note.recordChangeTag, }); @@ -724,9 +833,67 @@

mistKitBtn.classList.toggle('active', mode === 'mistkit'); cloudKitJsBtn.classList.toggle('active', mode === 'cloudkitjs'); modeBadge.textContent = mode === 'mistkit' ? 'MistKit' : 'CloudKit JS'; + // Switching to MistKit while sitting on Public is only valid when + // the server holds S2S material. If not, drop back to Private so + // the user doesn't post requests that will 500 server-side. + refreshDatabasePicker(); + if (authComplete) queryNotes(); + } + + // ---- database toggle ---- + + dbPrivateBtn.addEventListener('click', () => setDatabase('private')); + dbPublicBtn.addEventListener('click', () => setDatabase('public')); + + function setDatabase(database) { + if (database === currentDatabase) return; + if (database === 'public' && !isPublicAllowedForCurrentMode()) { + // Defensive — button should already be disabled in this case. + return; + } + currentDatabase = database; + refreshDatabasePicker(); if (authComplete) queryNotes(); } + function isPublicAllowedForCurrentMode() { + // CloudKit JS can talk to public from the browser regardless of + // server S2S credentials; MistKit + public requires the server to + // hold the key pair. + return currentMode === 'cloudkitjs' || publicDatabaseAvailable; + } + + function refreshDatabasePicker() { + const publicAllowed = isPublicAllowedForCurrentMode(); + dbPublicBtn.disabled = !publicAllowed; + if (!publicAllowed && currentDatabase === 'public') { + currentDatabase = 'private'; + } + dbPrivateBtn.classList.toggle('active', currentDatabase === 'private'); + dbPublicBtn.classList.toggle('active', currentDatabase === 'public'); + dbBadge.textContent = currentDatabase === 'public' ? 'Public' : 'Private'; + if (!publicDatabaseAvailable && currentMode === 'mistkit') { + dbHint.textContent = + 'MistKit + Public requires server-to-server credentials ' + + '(CLOUDKIT_KEY_ID + CLOUDKIT_PRIVATE_KEY[_PATH]). ' + + 'Restart the server with those set to enable Public on this side.'; + } else if (currentMode === 'mistkit' && currentDatabase === 'public') { + dbHint.textContent = + 'Heads-up: on MistKit + Public, records you write are owned ' + + 'by the server-to-server key, not your iCloud user — so they ' + + 'won’t carry a "You" badge. The badge tracks your iCloud ' + + 'identity, which only appears on records written via ' + + 'CloudKit JS or via the web-auth flow on the private database.'; + } else { + dbHint.textContent = + '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.'; + } + } + // ---- form wiring ---- saveBtn.addEventListener('click', saveNote); @@ -803,6 +970,7 @@

Web Auth Token captured

async function handleAuthentication(userIdentity) { if (authenticationInProgress) return; authenticationInProgress = true; + currentUserRecordName = userIdentity?.userRecordName ?? null; setStatus(authStatusDiv, 'Capturing web auth token...', 'success'); try { const token = await pollWebAuthToken(); @@ -827,6 +995,7 @@

Web Auth Token captured

try { await container.signOut(); webAuthToken = null; + currentUserRecordName = null; setAuthed(false); notes = []; clearForm(); @@ -842,6 +1011,8 @@

Web Auth Token captured

throw new Error('CloudKit.js failed to load'); } const serverConfig = await loadServerConfig(); + publicDatabaseAvailable = !!serverConfig.publicDatabaseAvailable; + refreshDatabasePicker(); CloudKit.configure({ containers: [{ containerIdentifier: serverConfig.containerIdentifier, @@ -864,6 +1035,7 @@

Web Auth Token captured

container.whenUserSignsIn().then((identity) => handleAuthentication(identity)); container.whenUserSignsOut().then(() => { webAuthToken = null; + currentUserRecordName = null; setAuthed(false); notes = []; clearForm(); @@ -875,6 +1047,8 @@

Web Auth Token captured

} refreshFormState(); + refreshDatabasePicker(); + refreshSortIndicators(); initializeCloudKit(); diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift index 22fef87a..ff8039fd 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift @@ -36,30 +36,35 @@ internal import MistKit /// /// The production implementation is `CloudKitService` itself via /// extension; the web demo builds a new service per request using the -/// captured `ckWebAuthToken`. +/// 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]? + sortBy: [WebRequests.QuerySortField]?, + database: MistKit.Database ) async throws -> [RecordInfo] func webCreate( recordType: String, - fields: [String: FieldValue] + fields: [String: FieldValue], + database: MistKit.Database ) async throws -> RecordInfo func webUpdate( recordType: String, recordName: String, fields: [String: FieldValue], - recordChangeTag: String? + recordChangeTag: String?, + database: MistKit.Database ) async throws -> RecordInfo func webDelete( recordType: String, recordName: String, - recordChangeTag: String? + recordChangeTag: String?, + database: MistKit.Database ) async throws } @@ -68,7 +73,8 @@ extension CloudKitService: WebBackend { internal func webQuery( recordType: String, limit: Int?, - sortBy: [WebRequests.QuerySortField]? + sortBy: [WebRequests.QuerySortField]?, + database: MistKit.Database ) async throws -> [RecordInfo] { let querySorts = sortBy?.map { sort in QuerySort.sort(sort.field, ascending: sort.ascending) @@ -80,19 +86,20 @@ extension CloudKitService: WebBackend { limit: limit, desiredKeys: nil, continuationMarker: nil, - database: .private + database: database ) return result.records } internal func webCreate( recordType: String, - fields: [String: FieldValue] + fields: [String: FieldValue], + database: MistKit.Database ) async throws -> RecordInfo { try await createRecord( recordType: recordType, fields: fields, - database: .private + database: database ) } @@ -100,27 +107,29 @@ extension CloudKitService: WebBackend { recordType: String, recordName: String, fields: [String: FieldValue], - recordChangeTag: String? + recordChangeTag: String?, + database: MistKit.Database ) async throws -> RecordInfo { try await updateRecord( recordType: recordType, recordName: recordName, fields: fields, recordChangeTag: recordChangeTag, - database: .private + database: database ) } internal func webDelete( recordType: String, recordName: String, - recordChangeTag: String? + recordChangeTag: String?, + database: MistKit.Database ) async throws { try await deleteRecord( recordType: recordType, recordName: recordName, recordChangeTag: recordChangeTag, - database: .private + database: database ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift index f78fd7d9..9a385751 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift @@ -33,6 +33,10 @@ 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 @@ -42,22 +46,28 @@ internal struct WebBackendFactory: Sendable { self.make = make } - /// Production factory: builds a `CloudKitService` for the private - /// database with the captured web-auth token paired with the - /// command's API token. + /// 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 + environment: MistKit.Environment, + serverToServer: ServerToServerCredentials? = nil ) -> WebBackendFactory { WebBackendFactory { webAuthToken in - let tokenManager = WebAuthTokenManager( + let apiAuth = APICredentials( apiToken: apiToken, webAuthToken: webAuthToken ) + let credentials = try Credentials( + serverToServer: serverToServer, + apiAuth: apiAuth + ) return CloudKitService( containerIdentifier: containerIdentifier, - tokenManager: tokenManager, + credentials: credentials, environment: environment ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift index 7d262da0..2aea5ba1 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift @@ -53,15 +53,53 @@ internal enum WebRequests { /// `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` @@ -70,10 +108,34 @@ internal enum WebRequests { /// 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` @@ -82,8 +144,55 @@ internal enum WebRequests { /// 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 = MistKit.Database(rawValue: 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/WebServer+CRUD.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift index cfe66b52..998763cc 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift @@ -50,7 +50,8 @@ let records = try await backend.webQuery( recordType: body.recordType, limit: body.limit, - sortBy: body.sortBy + sortBy: body.sortBy, + database: body.database ) return try WebJSON.encoder().encode( WebResponse.Records(records: records) @@ -75,7 +76,8 @@ let backend = try backendFactory.make(token) let record = try await backend.webCreate( recordType: body.recordType, - fields: body.fields + fields: body.fields, + database: body.database ) return try WebJSON.encoder().encode( WebResponse.Records(records: [record]) @@ -102,7 +104,8 @@ recordType: body.recordType, recordName: body.recordName, fields: body.fields, - recordChangeTag: body.recordChangeTag + recordChangeTag: body.recordChangeTag, + database: body.database ) return try WebJSON.encoder().encode( WebResponse.Records(records: [record]) @@ -128,7 +131,8 @@ try await backend.webDelete( recordType: body.recordType, recordName: body.recordName, - recordChangeTag: body.recordChangeTag + recordChangeTag: body.recordChangeTag, + database: body.database ) return try WebJSON.encoder().encode( WebResponse.Delete( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift index 91fa8a95..0e7b0da4 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift @@ -45,15 +45,23 @@ /// 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 @@ -108,7 +116,8 @@ CloudKitClientConfig( apiToken: apiToken, containerIdentifier: containerIdentifier, - environment: environment.rawValue + environment: environment.rawValue, + publicDatabaseAvailable: publicDatabaseAvailable ) ) addConfigEndpoint(api: api, configData: configData) diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift index 5b2a7d4f..93222e77 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift @@ -40,11 +40,13 @@ 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 { @@ -52,12 +54,14 @@ 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? @@ -123,10 +127,14 @@ internal func webQuery( recordType: String, limit: Int?, - sortBy: [WebRequests.QuerySortField]? + sortBy: [WebRequests.QuerySortField]?, + database: MistKit.Database ) async throws -> [RecordInfo] { lastQuery = QueryCall( - recordType: recordType, limit: limit, sortBy: sortBy + recordType: recordType, + limit: limit, + sortBy: sortBy, + database: database ) try consumePendingError() return [ @@ -135,11 +143,14 @@ } internal func webCreate( - recordType: String, fields: [String: FieldValue] + recordType: String, + fields: [String: FieldValue], + database: MistKit.Database ) async throws -> RecordInfo { lastCreate = CreateCall( recordType: recordType, - fields: Self.flatten(fields) + fields: Self.flatten(fields), + database: database ) try consumePendingError() return Self.stubRecord( @@ -151,13 +162,15 @@ recordType: String, recordName: String, fields: [String: FieldValue], - recordChangeTag: String? + recordChangeTag: String?, + database: MistKit.Database ) async throws -> RecordInfo { lastUpdate = UpdateCall( recordType: recordType, recordName: recordName, fields: Self.flatten(fields), - recordChangeTag: recordChangeTag + recordChangeTag: recordChangeTag, + database: database ) try consumePendingError() return Self.stubRecord( @@ -168,12 +181,14 @@ internal func webDelete( recordType: String, recordName: String, - recordChangeTag: String? + recordChangeTag: String?, + database: MistKit.Database ) async throws { lastDelete = DeleteCall( recordType: recordType, recordName: recordName, - recordChangeTag: recordChangeTag + recordChangeTag: recordChangeTag, + database: database ) try consumePendingError() } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+CRUD.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+CRUD.swift index f6ce5d32..28afc0f9 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+CRUD.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+CRUD.swift @@ -73,6 +73,7 @@ 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") 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..64c36aba --- /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) + } + + @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 index b7f49254..c58a5c22 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift @@ -70,5 +70,52 @@ } } } + + @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.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests.swift index 8bc41a96..4d1b1bee 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests.swift @@ -49,11 +49,13 @@ let apiToken: String let containerIdentifier: String let environment: String + let publicDatabaseAvailable: Bool } internal static func makeFixture( authenticated: Bool = false, - terminatesAfterAuth: Bool = false + terminatesAfterAuth: Bool = false, + publicDatabaseAvailable: Bool = false ) -> Fixture { let backend = MockBackend() let store = WebAuthTokenStore( @@ -64,6 +66,7 @@ apiToken: "test-api-token", containerIdentifier: "iCloud.test.container", environment: .development, + publicDatabaseAvailable: publicDatabaseAvailable, tokenStore: store, backendFactory: factory, terminatesAfterAuth: terminatesAfterAuth @@ -87,6 +90,25 @@ #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) } } } From c891d2fb355c66ff74e1410df38cdc26a44b6458 Mon Sep 17 00:00:00 2001 From: leogdion Date: Thu, 14 May 2026 08:37:05 -0400 Subject: [PATCH 8/9] Resolve #338: per-call PublicAuthPreference encoded in Database (#340) --- CLAUDE.md | 27 ++- .../CloudKit/MistKitClientFactory.swift | 2 +- .../MistDemoKit/Commands/CreateCommand.swift | 5 +- .../MistDemoKit/Commands/DeleteCommand.swift | 3 +- .../Commands/DemoErrorsRunner+Output.swift | 2 +- .../Commands/DemoErrorsRunner.swift | 12 +- .../Commands/DemoInFilterCommand.swift | 15 +- .../Commands/FetchChangesCommand.swift | 6 +- .../MistDemoKit/Commands/LookupCommand.swift | 3 +- .../MistDemoKit/Commands/ModifyCommand.swift | 4 +- .../MistDemoKit/Commands/QueryCommand.swift | 6 +- .../MistDemoKit/Commands/UpdateCommand.swift | 3 +- .../Commands/UploadAssetCommand.swift | 12 +- .../Configuration/MistDemoConfig.swift | 23 ++- .../Integration/PhasedIntegrationTest.swift | 4 +- .../Integration/Phases/CleanupPhase.swift | 5 +- .../Phases/CreateRecordsPhase.swift | 3 +- .../Phases/IncrementalSyncPhase.swift | 5 +- .../Integration/Phases/InitialSyncPhase.swift | 4 +- .../Phases/LookupRecordsPhase.swift | 5 +- .../Phases/ModifyRecordsPhase.swift | 5 +- .../Integration/Phases/UploadAssetPhase.swift | 3 +- .../Tests/PublicDatabaseTest.swift | 10 +- .../MistDemoKit/Server/WebRequests.swift | 2 +- .../AuthenticationHelper+SetupHelpers.swift | 4 +- ...KitClientFactoryTests+BadCredentials.swift | 2 +- ...lientFactoryTests+CustomTokenManager.swift | 2 +- ...KitClientFactoryTests+PublicDatabase.swift | 6 +- ...tionCredentialsTests+ToConfiguration.swift | 12 +- .../TestPrivateConfigTests.swift | 4 +- .../MistDemoConfig+Testing.swift | 2 +- .../Server/WebServerTests+Database.swift | 2 +- ...ionHelperTests+APIOnlyAuthentication.swift | 2 +- ...erTests+AuthenticationMethodPriority.swift | 2 +- ...erTests+ServerToServerAuthentication.swift | 4 +- ...icationHelperTests+WebAuthentication.swift | 4 +- .../Credentials+TokenManager.swift | 147 ++++++++++------ .../Authentication/PublicAuthPreference.swift | 79 +++++++++ Sources/MistKit/Database.swift | 33 +++- ...onfiguration+ConvenienceInitializers.swift | 3 +- .../CloudKitService+AssetOperations.swift | 4 +- .../CloudKitService+Classification.swift | 15 +- .../CloudKitService+ClientDispatch.swift | 22 +-- .../CloudKitService+LookupOperations.swift | 4 +- .../CloudKitService+Operations.swift | 4 +- .../CloudKitService+QueryPagination.swift | 2 +- .../CloudKitService+RecordManaging.swift | 17 +- .../CloudKitService+SyncOperations.swift | 4 +- .../CloudKitService+UserOperations.swift | 20 +-- .../CloudKitService+WriteOperations.swift | 8 +- .../ResponseProcessing/CloudKitError.swift | 18 +- .../CredentialAvailability.swift | 46 +++++ ...ialsTokenManagerTests+PrivateKeyLoad.swift | 2 +- ...ialsTokenManagerTests+PublicDatabase.swift | 162 ++++++++++++++++-- ...entialsTokenManagerTests+UserContext.swift | 82 ++------- Tests/MistKitTests/Core/DatabaseTests.swift | 13 +- .../PublicTypes/CloudKitErrorTests.swift | 65 +++++++ ...ServiceTests.FetchChanges+Concurrent.swift | 2 +- ...viceTests.FetchChanges+ErrorHandling.swift | 11 +- ...rviceTests.FetchChanges+SuccessCases.swift | 41 +++-- ...ServiceTests.FetchChanges+Validation.swift | 26 ++- ...eTests.FetchZoneChanges+SuccessCases.swift | 10 +- ...iceTests.FetchZoneChanges+Validation.swift | 2 +- ...erviceTests.LookupZones+SuccessCases.swift | 6 +- ...CloudKitServiceTests.Query+EdgeCases.swift | 12 +- ...rviceTests.Query+ExistingRecordNames.swift | 78 +++++++++ ...loudKitServiceTests.Query+Validation.swift | 23 ++- ...viceTests.QueryPagination+ErrorCases.swift | 3 +- ...ceTests.QueryPagination+SuccessCases.swift | 9 +- ...KitServiceTests.Upload+ErrorHandling.swift | 6 +- ...KitServiceTests.Upload+NetworkErrors.swift | 9 +- ...dKitServiceTests.Upload+SuccessCases.swift | 15 +- ...oudKitServiceTests.Upload+Validation.swift | 12 +- 73 files changed, 923 insertions(+), 307 deletions(-) create mode 100644 Sources/MistKit/Authentication/PublicAuthPreference.swift create mode 100644 Sources/MistKit/Service/ResponseProcessing/CredentialAvailability.swift create mode 100644 Tests/MistKitTests/PublicTypes/CloudKitErrorTests.swift create mode 100644 Tests/MistKitTests/Service/CloudKitServiceQuery/CloudKitServiceTests.Query+ExistingRecordNames.swift diff --git a/CLAUDE.md b/CLAUDE.md index beb1a392..9896cb18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -290,13 +290,34 @@ A `ClientTransport` extension could provide a generic upload method, but would n ### CloudKit Web Services Integration - Base URL: `https://api.apple-cloudkit.com` - Authentication: - - **Public database**: `CLOUDKIT_KEY_ID` + `CLOUDKIT_PRIVATE_KEY` or `CLOUDKIT_PRIVATE_KEY_PATH` → server-to-server signing - - **Private database**: `CLOUDKIT_API_TOKEN` + `CLOUDKIT_WEB_AUTH_TOKEN` → web authentication + - **Public database**: caller picks per-call via `PublicAuthPreference` carried on `Database.public(_:)`. Either `.requires(.serverToServer)` (key-pair signing — needs `CLOUDKIT_KEY_ID` + `CLOUDKIT_PRIVATE_KEY` or `CLOUDKIT_PRIVATE_KEY_PATH`) or `.requires(.webAuth)` (user-attributed — needs `CLOUDKIT_API_TOKEN` + `CLOUDKIT_WEB_AUTH_TOKEN`). Use `.prefers(_:)` to fall back to whichever cred is configured. + - **Private / Shared database**: always `CLOUDKIT_API_TOKEN` + `CLOUDKIT_WEB_AUTH_TOKEN` → web-auth (CloudKit rejects S2S on these scopes). - All operations should reference the OpenAPI spec in `openapi.yaml` - URL Pattern: `/database/{version}/{container}/{environment}/{database}/{operation}` -- Supported databases: `public`, `private`, `shared` +- Supported databases: `Database.public(PublicAuthPreference)`, `Database.private`, `Database.shared` - Environments: `development`, `production` +### Per-call attribution for `.public` + +`Database` carries the signing choice when targeting public: + +```swift +public enum Database { + case `public`(PublicAuthPreference) + case `private` + case shared +} +``` + +`PublicAuthPreference` is constructed via two factories — never via the (internal) memberwise init: + +- `.prefers(.serverToServer)` — try S2S, fall back to web-auth/API-token if S2S isn't configured. +- `.prefers(.webAuth)` — try web-auth, fall back to S2S if web-auth isn't configured. +- `.requires(.serverToServer)` — must use S2S; throw `missingCredentials(.preferenceRequired)` otherwise. +- `.requires(.webAuth)` — must use web-auth; throw `missingCredentials(.preferenceRequired)` otherwise. + +There is **no default** on the operation `database:` parameter — every call must pick explicitly. The `requiresUserContext` flag on the dispatcher is gone; user-context routes (`users/*`) pass `.public(.requires(.webAuth))` directly. See `Sources/MistKit/Authentication/PublicAuthPreference.swift` and `Sources/MistKit/Authentication/Credentials/Credentials+TokenManager.swift`. + ### Testing Strategy - Use Swift Testing framework (`@Test` macro) for all tests - Unit tests for all public APIs diff --git a/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift b/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift index 31de7cac..b3b6d955 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift @@ -66,7 +66,7 @@ public struct MistKitClientFactory: Sendable { ) #else if config.badCredentials { - guard config.database != .public else { + if case .public = config.database { throw ConfigurationError.badCredentialsOnPublicDB } return try create(from: config, tokenManager: makeBadCredentialsTokenManager()) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift index 87ff9012..cd31f8fd 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift @@ -84,8 +84,9 @@ public struct CreateCommand: MistDemoCommand, OutputFormatting { let recordInfo = try await client.createRecord( recordType: config.recordType, recordName: recordName, - fields: cloudKitFields - // Zone: config.zone - to be added when CloudKitService supports it + fields: cloudKitFields, + // Zone: config.zone - to be added when CloudKitService supports it + database: config.base.database ) // Format and output result diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift index 373512e3..94bf05f3 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift @@ -90,7 +90,8 @@ public struct DeleteCommand: MistDemoCommand, OutputFormatting { try await client.deleteRecord( recordType: config.recordType, recordName: config.recordName, - recordChangeTag: effectiveChangeTag + recordChangeTag: effectiveChangeTag, + database: config.base.database ) let result = DeleteResult( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift index 69301727..17fb2b2b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift @@ -36,7 +36,7 @@ extension DemoErrorsRunner { print("🛑 CloudKit Error Demo — typed CloudKitError handling") print(String(repeating: "=", count: 80)) print("Container: \(config.containerIdentifier)") - print("Database: \(config.database.rawValue)") + print("Database: \(config.database.pathSegment)") print(String(repeating: "=", count: 80)) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift index f096d047..ce259489 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift @@ -145,7 +145,8 @@ internal struct DemoErrorsRunner { let created = try await service.createRecord( recordType: Self.conflictRecordType, recordName: recordName, - fields: ["title": .string("original")] + fields: ["title": .string("original")], + database: config.database ) createdRecordName = created.recordName staleTag = created.recordChangeTag @@ -160,7 +161,8 @@ internal struct DemoErrorsRunner { recordType: Self.conflictRecordType, recordName: recordName, fields: ["title": .string("first-update")], - recordChangeTag: staleTag + recordChangeTag: staleTag, + database: config.database ) } catch { print("❌ Setup update failed: \(error)") @@ -173,7 +175,8 @@ internal struct DemoErrorsRunner { recordType: Self.conflictRecordType, recordName: recordName, fields: ["title": .string("second-update-stale")], - recordChangeTag: staleTag + recordChangeTag: staleTag, + database: config.database ) print("⚠️ Expected 409 but update was accepted.") } catch { @@ -198,7 +201,8 @@ internal struct DemoErrorsRunner { do { try await service.deleteRecord( recordType: Self.conflictRecordType, - recordName: createdRecordName + recordName: createdRecordName, + database: config.database ) print(" ✅ Deleted.") } catch { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift index 09c3b99c..7c3a32e6 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift @@ -106,7 +106,8 @@ public struct DemoInFilterCommand: MistDemoCommand { fields: [ "title": .string("demo-in-filter-\(tag)-idx\(idx)"), "index": .int64(idx), - ] + ], + database: config.database ) createdNames.append(record.recordName) print(" Created \(record.recordName) (index=\(idx))") @@ -122,7 +123,9 @@ public struct DemoInFilterCommand: MistDemoCommand { ) async throws { print("\nVerifying records are queryable...") let allRecords = try await client.queryRecords( - recordType: recordType, limit: 200 + recordType: recordType, + limit: 200, + database: config.database ) let visible = allRecords.filter { createdNames.contains($0.recordName) @@ -136,7 +139,8 @@ public struct DemoInFilterCommand: MistDemoCommand { let results = try await client.queryRecords( recordType: recordType, filters: [.in("index", [.int64(10), .int64(30)])], - limit: 200 + limit: 200, + database: config.database ) let matching = results.filter { @@ -163,7 +167,10 @@ public struct DemoInFilterCommand: MistDemoCommand { recordType: recordType, recordName: name ) - _ = try await client.modifyRecords([operation]) + _ = try await client.modifyRecords( + [operation], + database: config.database + ) print(" Deleted \(name)") } print("Done.") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift index e97c39d4..5f50cdeb 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift @@ -108,7 +108,8 @@ public struct FetchChangesCommand: MistDemoCommand, OutputFormatting { print("\n📦 Fetching all changes (automatic pagination)...") let (records, newToken) = try await service.fetchAllRecordChanges( zoneID: zoneID, - syncToken: config.syncToken + syncToken: config.syncToken, + database: config.base.database ) print("\n✅ Fetched \(records.count) record(s)") displayRecords(records, limit: 5) @@ -125,7 +126,8 @@ public struct FetchChangesCommand: MistDemoCommand, OutputFormatting { let result = try await service.fetchRecordChanges( zoneID: zoneID, syncToken: config.syncToken, - resultsLimit: config.limit ?? 10 + resultsLimit: config.limit ?? 10, + database: config.base.database ) print("\n✅ Fetched \(result.records.count) record(s)") displayRecords(result.records, limit: 5) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupCommand.swift index bd674267..6871ebf5 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupCommand.swift @@ -78,7 +78,8 @@ public struct LookupCommand: MistDemoCommand, OutputFormatting { let records = try await client.lookupRecords( recordNames: config.recordNames, - desiredKeys: config.fields + desiredKeys: config.fields, + database: config.base.database ) // Report missing names to stderr so a JSON/CSV/etc. stdout stream stays parseable diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift index b25ab501..8d11c206 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift @@ -80,7 +80,9 @@ public struct ModifyCommand: MistDemoCommand, OutputFormatting { } let results = try await client.modifyRecords( - operations, atomic: config.atomic + operations, + atomic: config.atomic, + database: config.base.database ) let rows = results.map { record in diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift index 9bd520d5..4d0a9ea2 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift @@ -81,14 +81,16 @@ public struct QueryCommand: MistDemoCommand, OutputFormatting { recordType: config.recordType, filters: filters, sortBy: nil, - limit: config.limit + limit: config.limit, + database: config.base.database ) } else { recordInfos = try await client.queryRecords( recordType: config.recordType, filters: nil, sortBy: nil, - limit: config.limit + limit: config.limit, + database: config.base.database ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift index 6fd3b5de..eabce926 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift @@ -106,7 +106,8 @@ public struct UpdateCommand: MistDemoCommand, OutputFormatting { recordType: config.recordType, recordName: config.recordName, fields: cloudKitFields, - recordChangeTag: effectiveChangeTag + recordChangeTag: effectiveChangeTag, + database: config.base.database ) try await outputResult(recordInfo, format: config.output) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/UploadAssetCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/UploadAssetCommand.swift index 7d98c7ce..84c0b683 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/UploadAssetCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/UploadAssetCommand.swift @@ -137,7 +137,8 @@ public struct UploadAssetCommand: MistDemoCommand, OutputFormatting { data: data, recordType: config.recordType, fieldName: config.fieldName, - recordName: config.recordName + recordName: config.recordName, + database: config.base.database ) print("\n✅ Asset uploaded!") print(" Record Name: \(result.recordName)") @@ -186,7 +187,8 @@ public struct UploadAssetCommand: MistDemoCommand, OutputFormatting { return try await service.createRecord( recordType: config.recordType, recordName: newRecordName, - fields: fields + fields: fields, + database: config.base.database ) } } @@ -197,7 +199,8 @@ public struct UploadAssetCommand: MistDemoCommand, OutputFormatting { service: CloudKitService ) async throws -> RecordInfo { let existingRecords = try await service.lookupRecords( - recordNames: [recordName] + recordNames: [recordName], + database: config.base.database ) guard let existingRecord = existingRecords.first else { throw UploadAssetError.operationFailed( @@ -208,7 +211,8 @@ public struct UploadAssetCommand: MistDemoCommand, OutputFormatting { recordType: config.recordType, recordName: recordName, fields: fields, - recordChangeTag: existingRecord.recordChangeTag + recordChangeTag: existingRecord.recordChangeTag, + database: config.base.database ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift index 5df8a721..64fe379a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift @@ -103,7 +103,7 @@ public struct MistDemoConfig: Sendable, ConfigurationParseable { let databaseString = config.string(forKey: "database", default: "public") ?? "public" - guard let database = MistKit.Database(rawValue: databaseString) else { + guard let database = MistDemoConfig.parseDatabase(databaseString) else { throw ConfigurationError.invalidDatabase(databaseString) } self.database = database @@ -167,6 +167,27 @@ public struct MistDemoConfig: Sendable, ConfigurationParseable { self.badCredentials = badCredentials } + /// Map a `"public" | "private" | "shared"` string to a `MistKit.Database`. + /// + /// `"public"` resolves to `.public(.prefers(.serverToServer))` to match + /// `toPrimaryCredentials()`'s "S2S-preferred, web-auth augments" policy. + /// Returns `nil` for unrecognized strings so callers can raise a + /// configuration error. + internal static func parseDatabase( + _ raw: String + ) -> MistKit.Database? { + switch raw { + case "public": + return .public(.prefers(.serverToServer)) + case "private": + return .private + case "shared": + return .shared + default: + return nil + } + } + /// Returns a copy with the given database override. internal func with( database: MistKit.Database diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift index 6a2b0ca5..3e483c75 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift @@ -97,7 +97,7 @@ extension PhasedIntegrationTest { print("\u{1F9EA} Integration Test Suite: \(name)") print(String(repeating: "=", count: 80)) print("Container: \(context.containerIdentifier)") - let dbLabel = database == .public ? "public" : "private" + let dbLabel = database.pathSegment == "public" ? "public" : "private" print("Database: \(dbLabel)") print("Record Count: \(context.recordCount)") print("Asset Size: \(context.assetSizeKB) KB") @@ -118,7 +118,7 @@ extension PhasedIntegrationTest { ) let cid = context.containerIdentifier print(" 2. Select your container: \(cid)") - let dbName = database == .public ? "Public" : "Private" + let dbName = database.pathSegment == "public" ? "Public" : "Private" print( " 3. Navigate to \(dbName) Database \u{2192} Records" ) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CleanupPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CleanupPhase.swift index 8dfcff3b..6016b9cc 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CleanupPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CleanupPhase.swift @@ -59,7 +59,10 @@ internal struct CleanupPhase: IntegrationPhase, CleanupPhaseMarker { } do { - _ = try await context.service.modifyRecords(deleteOps) + _ = try await context.service.modifyRecords( + deleteOps, + database: context.database + ) deletedCount = input.names.count if context.verbose { for name in input.names { print(" ✅ Deleted: \(name)") } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift index 6992e801..ef527616 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift @@ -59,7 +59,8 @@ internal struct CreateRecordsPhase: IntegrationPhase { "title": .string("Test Record \(recordIndex)"), "index": .int64(recordIndex), "image": .asset(input.asset), - ] + ], + database: context.database ) createdRecordNames.append(record.recordName) if context.verbose { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/IncrementalSyncPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/IncrementalSyncPhase.swift index 52b8cc37..4fc3ae3b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/IncrementalSyncPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/IncrementalSyncPhase.swift @@ -56,7 +56,10 @@ internal struct IncrementalSyncPhase: IntegrationPhase { } do { - let incrementalResult = try await context.service.fetchRecordChanges(syncToken: token) + let incrementalResult = try await context.service.fetchRecordChanges( + syncToken: token, + database: context.database + ) print("✅ Fetched \(incrementalResult.records.count) changed records") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/InitialSyncPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/InitialSyncPhase.swift index ae592a64..3a8ef274 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/InitialSyncPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/InitialSyncPhase.swift @@ -44,7 +44,9 @@ internal struct InitialSyncPhase: IntegrationPhase { print("\n\(Self.emoji) \(Self.title)") do { - let initialResult = try await context.service.fetchRecordChanges() + let initialResult = try await context.service.fetchRecordChanges( + database: context.database + ) print("✅ Fetched \(initialResult.records.count) records") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift index 46424f8c..6f91ac79 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift @@ -48,7 +48,10 @@ internal struct LookupRecordsPhase: IntegrationPhase { print(" Looking up \(lookupNames.count) of \(input.names.count) record(s) by name") } - let records = try await context.service.lookupRecords(recordNames: lookupNames) + let records = try await context.service.lookupRecords( + recordNames: lookupNames, + database: context.database + ) print("✅ Looked up \(records.count) record(s)") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift index 82825ba9..a2b19d1e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift @@ -56,7 +56,10 @@ internal struct ModifyRecordsPhase: IntegrationPhase { ) } - _ = try await context.service.modifyRecords(operations) + _ = try await context.service.modifyRecords( + operations, + database: context.database + ) if context.verbose { for recordName in recordsToUpdate { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/UploadAssetPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/UploadAssetPhase.swift index 3fc0e04b..999c9045 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/UploadAssetPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/UploadAssetPhase.swift @@ -53,7 +53,8 @@ internal struct UploadAssetPhase: IntegrationPhase { let receipt = try await context.service.uploadAssets( data: testData, recordType: IntegrationTestData.recordType, - fieldName: "image" + fieldName: "image", + database: context.database ) print("✅ Uploaded asset: \(testData.count) bytes") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift index 5b23f7db..e8cdceca 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift @@ -43,13 +43,13 @@ internal struct PublicDatabaseTest: PhasedIntegrationTest { /// call from the service's `Credentials`. The runner sets this based on /// whether web-auth credentials are configured. internal init( - database: MistKit.Database = .public, + database: MistKit.Database = .public(.prefers(.serverToServer)), includeUserContextPhases: Bool = false ) { - precondition( - database == .public, - "PublicDatabaseTest only supports the public database" - ) + if case .public = database { + } else { + preconditionFailure("PublicDatabaseTest only supports the public database") + } self.database = database var phases: [any IntegrationPhase] = [ diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift index 2aea5ba1..27b03945 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift @@ -185,7 +185,7 @@ internal enum WebRequests { else { return defaultDatabase } - guard let database = MistKit.Database(rawValue: raw) else { + guard let database = MistDemoConfig.parseDatabase(raw) else { throw DecodingError.dataCorruptedError( forKey: key, in: container, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationHelper+SetupHelpers.swift b/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationHelper+SetupHelpers.swift index 51690729..2470e8a9 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationHelper+SetupHelpers.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Utilities/AuthenticationHelper+SetupHelpers.swift @@ -38,7 +38,7 @@ extension AuthenticationHelper { privateKeyFile: String?, databaseOverride: MistKit.Database? ) async throws -> AuthenticationResult { - let database = MistKit.Database.public + let database: MistKit.Database = .public(.prefers(.serverToServer)) if databaseOverride == .private { throw AuthenticationError.serverToServerRequiresPublicDatabase @@ -85,7 +85,7 @@ extension AuthenticationHelper { apiToken: String, databaseOverride: MistKit.Database? ) async throws -> AuthenticationResult { - let database = MistKit.Database.public + let database: MistKit.Database = .public(.prefers(.serverToServer)) if databaseOverride == .private { throw AuthenticationError.privateRequiresWebAuth diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+BadCredentials.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+BadCredentials.swift index a053bed0..1544f0cc 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+BadCredentials.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+BadCredentials.swift @@ -66,7 +66,7 @@ extension MistKitClientFactoryTests { internal func badCredentialsOnPublicDatabaseThrows() async throws { let config = try await MistKitClientFactoryTests.makeConfig( apiToken: "real-config-token", - database: .public, + database: .public(.prefers(.serverToServer)), keyID: "real-key-id", privateKey: MistKitClientFactoryTests.validPrivateKey, badCredentials: true diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+CustomTokenManager.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+CustomTokenManager.swift index b86f734f..1768ba67 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+CustomTokenManager.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+CustomTokenManager.swift @@ -52,7 +52,7 @@ extension MistKitClientFactoryTests { @Test("Create client with custom token manager for public database") internal func createWithCustomTokenManagerPublicDB() async throws { let config = try await MistKitClientFactoryTests.makeConfig( - apiToken: "api-token", database: .public + apiToken: "api-token", database: .public(.prefers(.serverToServer)) ) let tokenManager = APITokenManager(apiToken: "custom-token") diff --git a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+PublicDatabase.swift b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+PublicDatabase.swift index e66865d8..867e38da 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+PublicDatabase.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/CloudKit/MistKitClientFactory/MistKitClientFactoryTests+PublicDatabase.swift @@ -39,7 +39,7 @@ extension MistKitClientFactoryTests { @Test("Create client for public database") internal func createForPublicDatabaseTest() async throws { let config = try await MistKitClientFactoryTests.makeConfig( - apiToken: "api-token", database: .public + apiToken: "api-token", database: .public(.prefers(.serverToServer)) ) let tokenManager = APITokenManager(apiToken: "api-token") @@ -53,7 +53,9 @@ extension MistKitClientFactoryTests { @Test("Public database creation requires API token") internal func publicDatabaseRequiresAPIToken() async throws { - let config = try await MistKitClientFactoryTests.makeConfig(apiToken: "", database: .public) + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "", database: .public(.prefers(.serverToServer)) + ) #expect(throws: ConfigurationError.self) { try MistKitClientFactory.create(for: config) diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift index 1ec9d3af..b54071ad 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift @@ -45,7 +45,7 @@ extension AuthenticationCredentialsTests { @Test("public with raw private key produces serverToServer with .raw material") internal func publicWithRawKey() async throws { let config = try await MistKitClientFactoryTests.makeConfig( - database: .public, + database: .public(.prefers(.serverToServer)), keyID: "test-key-id", privateKey: MistKitClientFactoryTests.validPrivateKey ) @@ -69,7 +69,7 @@ extension AuthenticationCredentialsTests { containerIdentifier: "iCloud.com.test.App", apiToken: "test-api-token", environment: .development, - database: .public, + database: .public(.prefers(.serverToServer)), webAuthToken: nil, keyID: "test-key-id", privateKey: nil, @@ -100,7 +100,7 @@ extension AuthenticationCredentialsTests { @Test("public missing keyID throws missingRequired(\"key.id\")") internal func publicMissingKeyIDThrows() async throws { let config = try await MistKitClientFactoryTests.makeConfig( - database: .public, + database: .public(.prefers(.serverToServer)), keyID: "", privateKey: MistKitClientFactoryTests.validPrivateKey ) @@ -120,7 +120,7 @@ extension AuthenticationCredentialsTests { @Test("public missing private key material throws missingRequired(\"private.key\")") internal func publicMissingPrivateKeyThrows() async throws { let config = try await MistKitClientFactoryTests.makeConfig( - database: .public, + database: .public(.prefers(.serverToServer)), keyID: "test-key-id" ) @@ -177,7 +177,7 @@ extension AuthenticationCredentialsTests { internal func publicEmbedsAPIAuthWhenAvailable() async throws { let config = try await MistKitClientFactoryTests.makeConfig( apiToken: "api", - database: .public, + database: .public(.prefers(.serverToServer)), webAuthToken: "web", keyID: "k", privateKey: MistKitClientFactoryTests.validPrivateKey @@ -194,7 +194,7 @@ extension AuthenticationCredentialsTests { internal func publicOmitsAPIAuthWhenWebAuthMissing() async throws { let config = try await MistKitClientFactoryTests.makeConfig( apiToken: "", - database: .public, + database: .public(.prefers(.serverToServer)), webAuthToken: nil, keyID: "k", privateKey: MistKitClientFactoryTests.validPrivateKey diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift index bf3047a4..16155ba3 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift @@ -71,12 +71,12 @@ internal struct TestPrivateConfigTests { // Even though we configure the base for the public DB, TestPrivateConfig // must override to `.private`. The init also requires web-auth credentials. let baseConfig = try await MistDemoConfig( - database: .public, + database: .public(.prefers(.serverToServer)), webAuthToken: "wat-xyz" ) let config = TestPrivateConfig(base: baseConfig.with(database: .private)) - #expect(config.base.database == .private) + #expect(config.base.database == MistKit.Database.private) } @Test("Memberwise init preserves base configuration values") diff --git a/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift b/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift index 90d0a737..75c90c3f 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift @@ -92,7 +92,7 @@ extension MistDemoConfig { key("container.identifier"): .init(stringLiteral: containerIdentifier), key("api.token"): .init(stringLiteral: apiToken), key("environment"): .init(stringLiteral: envString), - key("database"): .init(stringLiteral: database.rawValue), + key("database"): .init(stringLiteral: database.pathSegment), key("host"): .init(stringLiteral: host), key("port"): .init(integerLiteral: port), key("auth.timeout"): .init(integerLiteral: Int(authTimeout)), diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Database.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Database.swift index 64c36aba..d1a7106f 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Database.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Database.swift @@ -110,7 +110,7 @@ default: captured = nil } - #expect(captured == .public) + #expect(captured == .public(.prefers(.serverToServer))) } @Test("CRUD requests with an unknown `database` value return 400") diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+APIOnlyAuthentication.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+APIOnlyAuthentication.swift index dbe421db..efc186be 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+APIOnlyAuthentication.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+APIOnlyAuthentication.swift @@ -48,7 +48,7 @@ extension AuthenticationHelperTests { databaseOverride: nil ) - #expect(result.database == .public) + #expect(result.database == .public(.prefers(.serverToServer))) #expect(result.authMethod.contains("API-only")) } catch AuthenticationError.invalidAPIToken { // Expected with test token diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+AuthenticationMethodPriority.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+AuthenticationMethodPriority.swift index 5fdf8d32..c2f5bc2d 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+AuthenticationMethodPriority.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+AuthenticationMethodPriority.swift @@ -51,7 +51,7 @@ extension AuthenticationHelperTests { databaseOverride: nil ) - #expect(result.database == .public) + #expect(result.database == .public(.prefers(.serverToServer))) #expect(result.authMethod.contains("Server-to-server")) } catch AuthenticationError.invalidServerToServerCredentials { // Expected with test credentials diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+ServerToServerAuthentication.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+ServerToServerAuthentication.swift index 929d4345..89771f4f 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+ServerToServerAuthentication.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+ServerToServerAuthentication.swift @@ -70,7 +70,7 @@ extension AuthenticationHelperTests { ) // If we get here, validation succeeded (unlikely with test key) - #expect(result.database == .public) + #expect(result.database == .public(.prefers(.serverToServer))) #expect(result.authMethod.contains("Server-to-server")) } catch AuthenticationError.invalidServerToServerCredentials { // Expected - test key won't validate @@ -93,7 +93,7 @@ extension AuthenticationHelperTests { databaseOverride: nil ) - #expect(result.database == .public) + #expect(result.database == .public(.prefers(.serverToServer))) #expect(result.authMethod.contains("Server-to-server")) } catch AuthenticationError.invalidServerToServerCredentials { // Expected with test key diff --git a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+WebAuthentication.swift b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+WebAuthentication.swift index ebe7960f..1379cb67 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+WebAuthentication.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Utilities/AuthenticationHelper/AuthenticationHelperTests+WebAuthentication.swift @@ -69,10 +69,10 @@ extension AuthenticationHelperTests { keyID: nil, privateKey: nil, privateKeyFile: nil, - databaseOverride: .public + databaseOverride: .public(.prefers(.serverToServer)) ) - #expect(result.database == .public) + #expect(result.database == .public(.prefers(.serverToServer))) #expect(result.authMethod.contains("Web authentication")) #expect(result.authMethod.contains("public")) } catch AuthenticationError.invalidWebAuthCredentials { diff --git a/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.. Date: Thu, 14 May 2026 10:35:45 -0400 Subject: [PATCH 9/9] Resolve #328: MistDemoApp CloudKit refresh (CKRecord-first, @Observable) (#339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Resolve #328: MistDemoApp CloudKit refresh (CKRecord-first, @Observable, public/private picker) - Rename `NativeCloudKitService`/`Error` to `CloudKitStore`/`Error` — the app target no longer depends on MistKit, so the "Native" disambiguator is dead weight; "Store" reads as the SwiftUI source-of-truth idiom. - `Note` wraps `CKRecord` instead of copying fields out of it. Update is now "mutate the held record, save" — no extra fetch round-trip to refresh the change tag. - `@Observable` + `@MainActor` on `CloudKitStore`; views use `@Environment(CloudKitStore.self)` and `@Bindable` for the picker. App entry switches to `@State` + `.environment(_:)`. - Public/private database picker in `AccountView`; `QueryView` and `ZoneListView` re-fetch on `.onChange(of: store.databaseScope)` and show the active scope in their navigation title. - Drop web-auth-token UI (`AccountView+Actions.swift`, related state) and the `CLOUDKIT_API_TOKEN` scheme env var — the native app authenticates via the signed-in iCloud user. Co-Authored-By: Claude Opus 4.7 (1M context) * [CodeFactor] Apply fixes * Gate WebBackendFactory on canImport(Hummingbird) to fix wasm build WebBackendFactory.live calls CloudKitService's URLSession-defaulted convenience init, which is gated behind #if !os(WASI). The rest of the Server/ folder is already wrapped in #if canImport(Hummingbird); this file was missed. Wrapping it the same way unblocks the wasm, wasm 6.2, and wasm-embedded CI jobs. Co-Authored-By: Claude Opus 4.7 (1M context) * Address PR #339 review: roll back Note CKRecord wrapper, restore web-auth-token UI Two review comments from #pullrequestreview-4286058024: 1. Note: revert from CKRecord wrapper back to value-struct (id/title/index/ imageAssetURL + system metadata). updateNote now fetches by ID before apply+save instead of mutating the original record in place; deleteNote reconstructs CKRecord.ID from the recordName. Views switch from note.recordName to note.id. 2. AccountView: restore the API-token TextField, "Fetch Web Auth Token" button, copyable token display, and CLOUDKIT_API_TOKEN env-var seed, ported from the pre-#328 NativeCloudKitService design onto the new @Observable CloudKitStore + @Environment binding. Database picker stays. Adds CloudKitStore.fetchWebAuthToken via CKFetchWebAuthTokenOperation and a webAuthTokenUnavailable error case. Recreates AccountView+Actions.swift (deleted in #328). Co-Authored-By: Claude Opus 4.7 (1M context) * Resolve #338: per-call PublicAuthPreference encoded in Database (#340) * Address PR #339 review: use CKDatabase.Scope, fix web-auth-token routing + scheme env - Replace CloudKitStore.DatabaseScope with CKDatabase.Scope; new CKDatabaseScope+Demo.swift extension provides the demo-scoped selectable list ([.public, .private]) and label. - Route CKFetchWebAuthTokenOperation through container.privateCloudDatabase unconditionally; the operation is documented to require the private database and was previously running against the user-selected scope. - Migrate fetchWebAuthTokenCompletionBlock -> fetchWebAuthTokenResultBlock (the completion-block API is deprecated in macOS 12+); drop the now- unreachable webAuthTokenUnavailable error case. - Bake CLOUDKIT_API_TOKEN into the macOS + iOS scheme run actions so xcodegen substitutes the .env value AccountView already reads from ProcessInfo at launch. Co-Authored-By: Claude Opus 4.7 (1M context) * Mark CloudKitStore.fetchWebAuthToken nonisolated to fix CK callback crash The continuation body inherited @MainActor isolation from CloudKitStore, which tripped a dispatch_assert_queue assertion on com.apple.cloudkit.callback when CKFetchWebAuthTokenOperation's result block fired — crashing with EXC_BREAKPOINT in _dispatch_assert_queue_fail on macOS 26.5. Marking the bridge nonisolated lets the operation enqueue + callback dispatch run off the main actor. Co-Authored-By: Claude Opus 4.7 (1M context) * Add owner "You" badge and newest-first sort to native MistDemo Mirrors the web demo: track the signed-in user's record name via CKContainer.userRecordID, capture each note's creator from CKRecord.creatorUserRecordID, and tag matching rows in QueryView. Also sorts Notes by creationDate desc with modificationDate desc as the tiebreaker, matching the web demo's default ordering. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: codefactor-io --- Examples/MistDemo/App/MistDemoApp.swift | 6 +- .../Sources/MistDemoApp/Models/Note.swift | 2 + .../Services/CKDatabaseScope+Demo.swift | 47 ++++++++ ...udKitService.swift => CloudKitStore.swift} | 110 ++++++++++-------- ...itError.swift => CloudKitStoreError.swift} | 9 +- .../MistDemoApp/Views/AccountView.swift | 25 ++-- .../MistDemoApp/Views/NoteEditView.swift | 2 +- .../Sources/MistDemoApp/Views/QueryView.swift | 37 +++++- .../MistDemoApp/Views/RecordDetailView.swift | 4 +- .../Sources/MistDemoApp/Views/RootView.swift | 2 +- .../MistDemoApp/Views/ZoneListView.swift | 8 +- .../Sources/MistDemoKit/Resources/index.html | 10 +- .../Server/WebBackendFactory.swift | 85 +++++++------- Examples/MistDemo/project.yml | 6 - 14 files changed, 224 insertions(+), 129 deletions(-) create mode 100644 Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabaseScope+Demo.swift rename Examples/MistDemo/Sources/MistDemoApp/Services/{NativeCloudKitService.swift => CloudKitStore.swift} (61%) rename Examples/MistDemo/Sources/MistDemoApp/Services/{NativeCloudKitError.swift => CloudKitStoreError.swift} (84%) diff --git a/Examples/MistDemo/App/MistDemoApp.swift b/Examples/MistDemo/App/MistDemoApp.swift index c2a4e808..b4e97089 100644 --- a/Examples/MistDemo/App/MistDemoApp.swift +++ b/Examples/MistDemo/App/MistDemoApp.swift @@ -32,14 +32,14 @@ import SwiftUI @main internal struct MistDemoAppMain: App { - @StateObject private var service = NativeCloudKitService( - containerIdentifier: NativeCloudKitService.demoContainerIdentifier + @State private var service = CloudKitStore( + containerIdentifier: CloudKitStore.demoContainerIdentifier ) internal var body: some Scene { WindowGroup("MistDemo (Native CloudKit)") { RootView() - .environmentObject(service) + .environment(service) } #if os(macOS) .defaultSize(width: 880, height: 600) diff --git a/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift b/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift index 1d83c752..6b3e396f 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift @@ -62,6 +62,7 @@ internal let modificationDate: Date? internal let creationDate: Date? internal let recordChangeTag: String? + internal let creatorUserRecordName: String? internal init?(_ record: CKRecord) { guard record.recordType == Self.recordType else { @@ -74,6 +75,7 @@ self.modificationDate = record.modificationDate self.creationDate = record.creationDate self.recordChangeTag = record.recordChangeTag + self.creatorUserRecordName = record.creatorUserRecordID?.recordName } // Identity-based equality: two Notes with the same recordID are equal diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabaseScope+Demo.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabaseScope+Demo.swift new file mode 100644 index 00000000..37ff7b20 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabaseScope+Demo.swift @@ -0,0 +1,47 @@ +// +// CKDatabaseScope+Demo.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(CloudKit) && !os(tvOS) && !os(watchOS) + import CloudKit + + extension CKDatabase.Scope { + /// Scopes exposed in the MistDemoApp picker. `.shared` is intentionally + /// excluded because the demo's `schema.ckdb` has no shared zones. + internal static let selectable: [CKDatabase.Scope] = [.public, .private] + + internal var label: String { + switch self { + case .public: return "Public" + case .private: return "Private" + case .shared: return "Shared" + @unknown default: return "Unknown" + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitService.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift similarity index 61% rename from Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitService.swift rename to Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift index 58209591..d183db28 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitService.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift @@ -1,5 +1,5 @@ // -// NativeCloudKitService.swift +// CloudKitStore.swift // MistDemo // // Created by Leo Dion. @@ -29,27 +29,34 @@ #if canImport(CloudKit) && !os(tvOS) && !os(watchOS) import CloudKit - public import Combine import Foundation - - /// Thin wrapper around Apple's CloudKit framework that mirrors the read-side - /// operations the MistKit-driven MistDemo CLI exposes. The two demos hit the - /// same CloudKit container, so a presentation can flip between them and show - /// identical data accessed through different stacks. + public import Observation + + /// Observable source of truth for the MistDemo app's CloudKit state. + /// + /// Wraps `CKContainer`/`CKDatabase` directly. MistKit's REST surface is + /// reserved for server/Linux/WASI/Windows contexts where the CloudKit + /// framework isn't available. + @Observable @MainActor - public final class NativeCloudKitService: ObservableObject { + public final class CloudKitStore { /// The shared demo container identifier — must match `MistDemoConfig.containerIdentifier`. public static let demoContainerIdentifier = "iCloud.com.brightdigit.MistDemo" - @Published internal var accountStatus: CKAccountStatus = .couldNotDetermine - @Published internal var lastError: String? + internal var accountStatus: CKAccountStatus = .couldNotDetermine + internal var lastError: String? + internal var databaseScope: CKDatabase.Scope = .private + + /// The signed-in iCloud user's record name. Mirrors `currentUserRecordName` + /// in the web demo and is used to flag the "You" badge on notes the + /// current user created. + internal var currentUserRecordName: String? internal let containerIdentifier: String - private let container: CKContainer + @ObservationIgnored private let container: CKContainer - /// Convenience: which database we want to demo against. The MistDemo CLI - /// defaults to `.private`, so mirror that here. - internal var database: CKDatabase { container.privateCloudDatabase } + /// The CloudKit database for the current `databaseScope`. + internal var database: CKDatabase { container.database(with: databaseScope) } /// Creates a new service for the given CloudKit container. /// - Parameter containerIdentifier: The CloudKit container identifier. @@ -79,21 +86,37 @@ self.accountStatus = .couldNotDetermine self.lastError = error.localizedDescription } + if accountStatus == .available { + do { + let recordID = try await container.userRecordID() + self.currentUserRecordName = recordID.recordName + } catch { + self.currentUserRecordName = nil + self.lastError = error.localizedDescription + } + } else { + self.currentUserRecordName = nil + } } - /// List all record zones in the private database (parity with `mistdemo lookup-zones`). + /// List all record zones in the selected database (parity with `mistdemo lookup-zones`). internal func loadZones() async throws -> [ZoneRow] { let zones = try await database.allRecordZones() return zones.map(ZoneRow.init).sorted { $0.zoneName < $1.zoneName } } - /// Query `Note` records from the demo container's private database, sorted - /// by `index` (parity with `mistdemo query --record-type Note --sort index`). - /// Note's schema is defined in `schema.ckdb`. + /// Query `Note` records from the selected database, newest first — + /// primary sort on creation date desc, modification date desc as the + /// tiebreaker. Matches the web demo's default sort. + /// Note's schema is defined in `schema.ckdb` (`___createTime` and + /// `___modTime` are both `SORTABLE`). internal func queryNotes(limit: Int = 50) async throws -> [Note] { let predicate = NSPredicate(value: true) let query = CKQuery(recordType: Note.recordType, predicate: predicate) - query.sortDescriptors = [NSSortDescriptor(key: Note.Fields.index, ascending: true)] + query.sortDescriptors = [ + NSSortDescriptor(key: "creationDate", ascending: false), + NSSortDescriptor(key: "modificationDate", ascending: false), + ] let (matchResults, _) = try await database.records( matching: query, @@ -129,19 +152,21 @@ // MARK: - Write operations (parity with `mistdemo create / update / delete`) - /// Create a new Note in the private database. + /// Create a new Note in the selected database. internal func createNote(title: String, index: Int64, imageURL: URL?) async throws -> Note { let record = CKRecord(recordType: Note.recordType) Self.apply(title: title, index: index, imageURL: imageURL, to: record) let saved = try await database.save(record) guard let note = Note(saved) else { - throw NativeCloudKitError.unexpectedSaveResult + throw CloudKitStoreError.unexpectedSaveResult } return note } - /// Update an existing Note. Fetches the current record (so the change tag - /// is fresh), mutates the fields, and saves. + /// Update an existing Note: fetch the underlying record by ID, apply the + /// new field values, and save. The fetch picks up the current change tag + /// so the save is rejected (rather than blindly clobbering) if the record + /// has been modified since the caller read it. internal func updateNote( _ existing: Note, title: String, index: Int64, imageURL: URL? ) async throws -> Note { @@ -150,41 +175,32 @@ Self.apply(title: title, index: index, imageURL: imageURL, to: record) let saved = try await database.save(record) guard let note = Note(saved) else { - throw NativeCloudKitError.unexpectedSaveResult + throw CloudKitStoreError.unexpectedSaveResult } return note } - /// Delete a Note by record name. + /// Delete a Note by record ID. internal func deleteNote(_ note: Note) async throws { - let recordID = CKRecord.ID(recordName: note.id) - _ = try await database.deleteRecord(withID: recordID) + _ = try await database.deleteRecord( + withID: CKRecord.ID(recordName: note.id) + ) } - // MARK: - Web auth token (parity with `mistdemo auth-token`) - - /// Fetch a CloudKit web auth token (the `158__...` value that MistKit / - /// the MistDemo CLI consume). Demonstrates that a native app and a - /// REST-based MistKit consumer can share the same auth surface. - /// - /// `apiToken` is the public CloudKit API token from CloudKit Dashboard, - /// not the user's iCloud password. It must match the configured container. - internal func fetchWebAuthToken(apiToken: String) async throws -> String { + /// Capture a web-auth token via `CKFetchWebAuthTokenOperation` for the + /// given CloudKit API token. Issues the same `158__…` value that + /// MistKit / `mistdemo auth-token` consume. + nonisolated internal func fetchWebAuthToken(apiToken: String) async throws -> String { try await withCheckedThrowingContinuation { continuation in let operation = CKFetchWebAuthTokenOperation(apiToken: apiToken) operation.qualityOfService = .userInitiated - operation.fetchWebAuthTokenCompletionBlock = { token, error in - if let token { - continuation.resume(returning: token) - } else { - continuation.resume( - throwing: error ?? NativeCloudKitError.webAuthTokenUnavailable - ) - } + operation.fetchWebAuthTokenResultBlock = { result in + continuation.resume(with: result) } - // CKFetchWebAuthTokenOperation is a CKDatabaseOperation; running - // it against the private database picks up the demo container. - database.add(operation) + // CKFetchWebAuthTokenOperation must run against the private database + // regardless of the user's scope selection — running it on the public + // database fails or returns an unattributed token. + container.privateCloudDatabase.add(operation) } } } diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitError.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift similarity index 84% rename from Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitError.swift rename to Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift index 2925516d..8e334fd6 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Services/NativeCloudKitError.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift @@ -1,5 +1,5 @@ // -// NativeCloudKitError.swift +// CloudKitStoreError.swift // MistDemo // // Created by Leo Dion. @@ -30,17 +30,14 @@ #if canImport(CloudKit) && !os(tvOS) && !os(watchOS) import Foundation - /// Errors specific to native CloudKit operations. - internal enum NativeCloudKitError: Error, LocalizedError { + /// Errors specific to `CloudKitStore` operations. + internal enum CloudKitStoreError: Error, LocalizedError { case unexpectedSaveResult - case webAuthTokenUnavailable internal var errorDescription: String? { switch self { case .unexpectedSaveResult: return "CloudKit returned a record that couldn't be parsed as a Note." - case .webAuthTokenUnavailable: - return "CloudKit returned no web auth token and no error." } } } diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift index a3f9e568..eb052668 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift @@ -37,7 +37,9 @@ import UIKit #endif - /// View for managing the iCloud account and web auth token. + /// View showing the iCloud account status, the public/private database + /// selector, and a web-auth-token capture flow that mirrors + /// `mistdemo auth-token`. internal struct AccountView: View { /// Where the current `apiToken` value came from on this launch. internal enum TokenSource { @@ -48,7 +50,7 @@ /// Env var name the MistDemo CLI also reads. internal static let envVarName = "CLOUDKIT_API_TOKEN" - @EnvironmentObject internal var service: NativeCloudKitService + @Environment(CloudKitStore.self) internal var service @AppStorage("MistDemoApp.cloudKitApiToken") internal var apiToken: String = "" @State internal var webAuthToken: String? @State internal var fetchingWebAuthToken = false @@ -56,8 +58,17 @@ @State internal var tokenSource: TokenSource = .manual internal var body: some View { + @Bindable var bindable = service Form { - containerSection + Section("Container") { + LabeledContent("Container", value: service.containerIdentifier) + Picker("Database", selection: $bindable.databaseScope) { + ForEach(CKDatabase.Scope.selectable, id: \.self) { scope in + Text(scope.label).tag(scope) + } + } + LabeledContent("iCloud Status", value: statusLabel) + } webAuthTokenSection if let error = service.lastError { Section("Last Service Error") { @@ -98,14 +109,6 @@ } } - private var containerSection: some View { - Section("Container") { - LabeledContent("Container", value: service.containerIdentifier) - LabeledContent("Database", value: "Private") - LabeledContent("iCloud Status", value: statusLabel) - } - } - private var webAuthTokenSection: some View { Section { tokenTextField diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/NoteEditView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/NoteEditView.swift index f9d607be..f285f4ac 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/NoteEditView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/NoteEditView.swift @@ -42,7 +42,7 @@ internal let mode: Mode internal let onSaved: (Note) -> Void - @EnvironmentObject private var service: NativeCloudKitService + @Environment(CloudKitStore.self) private var service @Environment(\.dismiss) private var dismiss @State private var title: String = "" diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift index c86ea446..fc88696d 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift @@ -32,7 +32,7 @@ /// View for querying Note records from CloudKit. internal struct QueryView: View { - @EnvironmentObject private var service: NativeCloudKitService + @Environment(CloudKitStore.self) private var service @State private var limit: Int = 50 @State private var notes: [Note] = [] @State private var loading = false @@ -69,7 +69,12 @@ List(notes, selection: $selectedNote) { note in NavigationLink(value: note) { VStack(alignment: .leading, spacing: 2) { - Text(note.title ?? note.id).font(.body) + HStack(spacing: 8) { + Text(note.title ?? note.id).font(.body) + if isOwnedByCurrentUser(note) { + ownerBadge(creator: note.creatorUserRecordName) + } + } HStack(spacing: 12) { if let index = note.index { Label("\(index)", systemImage: "number") @@ -98,7 +103,11 @@ .navigationDestination(for: Note.self) { note in RecordDetailView(note: note, onChange: { Task { await runQuery() } }) } - .navigationTitle("Notes") + .navigationTitle("Notes — \(service.databaseScope.label)") + .onChange(of: service.databaseScope) { _, _ in + notes = [] + Task { await runQuery() } + } .toolbar { ToolbarItem { Button { @@ -112,7 +121,7 @@ NoteEditView(mode: .create) { _ in Task { await runQuery() } } - .environmentObject(service) + .environment(service) } } @@ -132,6 +141,26 @@ } } + /// Mirrors the web demo's "You" badge — flag notes the signed-in user + /// created. CloudKit may stamp the creator as `__defaultOwner__` for + /// records the caller just created, so accept that sentinel as well. + private func isOwnedByCurrentUser(_ note: Note) -> Bool { + guard let creator = note.creatorUserRecordName else { return false } + if creator == "__defaultOwner__" { return true } + return creator == service.currentUserRecordName + } + + private func ownerBadge(creator: String?) -> some View { + Text("You") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.green.opacity(0.2), in: Capsule()) + .foregroundStyle(.green) + .accessibilityLabel("Created by you") + .help(creator.map { "Created by \($0)" } ?? "Created by you") + } + private func runQuery() async { loading = true loadError = nil diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift index 58a1bb9f..d3cb9afb 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift @@ -35,7 +35,7 @@ @State internal var note: Note internal let onChange: () -> Void - @EnvironmentObject private var service: NativeCloudKitService + @Environment(CloudKitStore.self) private var service @Environment(\.dismiss) private var dismiss @State private var showEditSheet = false @@ -78,7 +78,7 @@ note = updated onChange() } - .environmentObject(service) + .environment(service) } .confirmationDialog( "Delete \(note.title ?? note.id)?", diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/RootView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/RootView.swift index 9178baea..e44fc969 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/RootView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/RootView.swift @@ -32,7 +32,7 @@ /// Root view hosting the navigation split between sidebar and detail. public struct RootView: View { - @EnvironmentObject private var service: NativeCloudKitService + @Environment(CloudKitStore.self) private var service @State private var selection: SidebarItem? = .account /// The view body. diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/ZoneListView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/ZoneListView.swift index 498a32de..ceca163a 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/ZoneListView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/ZoneListView.swift @@ -32,7 +32,7 @@ /// View listing all CloudKit record zones. internal struct ZoneListView: View { - @EnvironmentObject private var service: NativeCloudKitService + @Environment(CloudKitStore.self) private var service @State private var zones: [ZoneRow] = [] @State private var loading = false @State private var loadError: String? @@ -67,13 +67,17 @@ } } } - .navigationTitle("Zones") + .navigationTitle("Zones — \(service.databaseScope.label)") .toolbar { ToolbarItem { Button("Refresh") { Task { await refresh() } } } } .task { await refresh() } + .onChange(of: service.databaseScope) { _, _ in + zones = [] + Task { await refresh() } + } } private func refresh() async { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html index 061d46ce..2bf13fb0 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html @@ -41,10 +41,10 @@ padding: 24px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); } - h1 { font-size: 24px; margin: 0 0 4px 0; } - h2 { font-size: 18px; margin: 0 0 12px 0; } - h3 { font-size: 15px; margin: 0 0 8px 0; } - p { color: var(--muted); margin: 0 0 16px 0; line-height: 1.5; } + h1 { font-size: 24px; margin: 0 0 4px; } + h2 { font-size: 18px; margin: 0 0 12px; } + h3 { font-size: 15px; margin: 0 0 8px; } + p { color: var(--muted); margin: 0 0 16px; line-height: 1.5; } label { display: block; font-size: 13px; font-weight: 600; margin: 12px 0 4px; } input { width: 100%; @@ -111,7 +111,7 @@ border-radius: 6px; font-size: 12px; overflow-x: auto; - margin: 8px 0 0 0; + margin: 8px 0 0; max-height: 240px; } .mode-toggle { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift index 9a385751..05aa7374 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackendFactory.swift @@ -27,49 +27,52 @@ // OTHER DEALINGS IN THE SOFTWARE. // -internal import Foundation -internal import MistKit +#if canImport(Hummingbird) + internal import Foundation + internal import MistKit -/// Factory that returns a `WebBackend` configured with the captured -/// web-auth token. Injected into `WebServer` so tests can supply a -/// mock without going through MistKit. -/// -/// When server-to-server credentials are present, the produced service -/// holds both auth flavors and `CloudKitService` picks the right one -/// per operation based on the request's `database`. -internal struct WebBackendFactory: Sendable { - internal let make: @Sendable (_ webAuthToken: String) throws -> any WebBackend + /// Factory that returns a `WebBackend` configured with the captured + /// web-auth token. Injected into `WebServer` so tests can supply a + /// mock without going through MistKit. + /// + /// When server-to-server credentials are present, the produced service + /// holds both auth flavors and `CloudKitService` picks the right one + /// per operation based on the request's `database`. + internal struct WebBackendFactory: Sendable { + internal let make: @Sendable (_ webAuthToken: String) throws -> any WebBackend - internal init( - make: @escaping @Sendable (_ webAuthToken: String) throws -> any WebBackend - ) { - self.make = make - } + internal init( + make: + @escaping @Sendable (_ webAuthToken: String) throws -> any WebBackend + ) { + self.make = make + } - /// Production factory: builds a `CloudKitService` for the captured - /// web-auth token paired with the command's API token. If - /// `serverToServer` is non-nil, the same service can also satisfy - /// public-database routes via S2S signing. - internal static func live( - apiToken: String, - containerIdentifier: String, - environment: MistKit.Environment, - serverToServer: ServerToServerCredentials? = nil - ) -> WebBackendFactory { - WebBackendFactory { webAuthToken in - let apiAuth = APICredentials( - apiToken: apiToken, - webAuthToken: webAuthToken - ) - let credentials = try Credentials( - serverToServer: serverToServer, - apiAuth: apiAuth - ) - return CloudKitService( - containerIdentifier: containerIdentifier, - credentials: credentials, - environment: environment - ) + /// Production factory: builds a `CloudKitService` for the captured + /// web-auth token paired with the command's API token. If + /// `serverToServer` is non-nil, the same service can also satisfy + /// public-database routes via S2S signing. + internal static func live( + apiToken: String, + containerIdentifier: String, + environment: MistKit.Environment, + serverToServer: ServerToServerCredentials? = nil + ) -> WebBackendFactory { + WebBackendFactory { webAuthToken in + let apiAuth = APICredentials( + apiToken: apiToken, + webAuthToken: webAuthToken + ) + let credentials = try Credentials( + serverToServer: serverToServer, + apiAuth: apiAuth + ) + return CloudKitService( + containerIdentifier: containerIdentifier, + credentials: credentials, + environment: environment + ) + } } } -} +#endif diff --git a/Examples/MistDemo/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: