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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/memory/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ Project-scoped agent memory for MistKit. This directory **replaces** any native
- [beta.4 worktree layout](project_beta4_worktree_layout.md) — Remaining v1.0.0-beta.4 issues are developed in parallel worktrees under MistKit.git/wt-<branch>, PR'd to the v1.0.0-beta.4 base
- [#419 already fixed in beta.3](project_419_fixed_in_beta3.md) — MistDemoApp view inits shipped in 5a58120; verified building on macOS Swift 6.3.2, do not re-implement
- [Never git stash in this multi-worktree repo](feedback_never_git_stash_multiworktree.md) — The stash stack is shared across worktrees; a pop in one can bury a sibling branch's WIP. Commit instead.
- [cloudkit.share wire casing](project_cloudkit_share_record_type_casing.md) — Live API wants `cloudkit.share` (lowercase k); archived docs' `cloudKit.share` yields "Cannot share - no such record exists to share"
7 changes: 7 additions & 0 deletions .claude/memory/project_cloudkit_share_record_type_casing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# cloudkit.share wire casing

Apple's archived CloudKit Web Services "Sharing Records" docs write the share record type as `cloudKit.share`. The live `records/modify` API only accepts / returns `cloudkit.share` (lowercase `k`).

Creating with `cloudKit.share` fails with `Cannot share - no such record exists to share` even when the root exists with `shortGUID` + `stableUrl`. Creating with `cloudkit.share` succeeds (share-only atomic modify after root create with `createShortGUID: true` + `forRecord.recordChangeTag`).

MistKit constant: `ShareInfo.recordType == "cloudkit.share"`.
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ MistKit/
| `CloudKitService+LookupAllRecords.swift` | `lookupAllRecords(recordNames:desiredKeys:database:batchSize:)` — auto-chunking convenience over `lookupRecords` |
| `CloudKitService+UserIdentityChunking.swift` | `discoverAllUserIdentities(lookupInfos:batchSize:)` — auto-chunking convenience over `discoverUserIdentities` |
| `CloudKitService+BatchChunking.swift` | internal `chunkedBatches` helper backing the auto-chunking conveniences |
| `CloudKitService+ShareOperations.swift` | `resolveShares(_:)`, `acceptShares(_:)` *(public DB + web-auth, fixed — no `database:` parameter)* |
| `CloudKitService+CreateShare.swift` | `createShare(...)` *(private custom zone + web-auth; returns ``CreatedShare``)* |
| `CloudKitService+AssetOperations.swift` | `uploadAssets`, `requestAssetUploadURL` |
| `CloudKitService+AssetUpload.swift` | `uploadAssetData` |
| `CloudKitService+RecordManaging.swift` | record-managing convenience surface |
Expand All @@ -215,6 +217,15 @@ MistKit/
- `lookupUsersByEmail(_:)` → POST `/users/lookup/email` — returns `[UserIdentity]`.
- `lookupUsersByRecordName(_:)` → POST `/users/lookup/id` — returns `[UserIdentity]`.

**Share Operations (issues #41 / #42 / #437 — create needs private custom zone + web-auth; resolve/accept are public DB + web-auth):**
- `createShare(...)` → `records/modify` — creates a root (`createShortGUID`) plus `cloudkit.share`, returns ``CreatedShare`` (`shortGUID`, share URL, ``ShareInfo``, root ``RecordInfo``).
- `resolveShares(_:)` → POST `/records/resolve` — resolves `[ShortGUIDDictionary]` into `[ShareRecordInfo]` (root record, `cloudkit.share` record, owner identity, the caller's participation).
- `acceptShares(_:)` → POST `/records/accept` — accepts `[ShortGUIDDictionary]` on behalf of the current user; returns the same `[ShareRecordInfo]` shape reporting the caller's resulting participation.

`createShare` writes against the caller's `database:` (typically `.private`) in a custom `zoneID`. Resolve/accept are documented **only** in Apple's archived CloudKit Web Services Reference (`FetchingRecordInformation` / `AcceptingShareRecords`), which fixes the path's database scope to `public`; they act on behalf of the *current* user, so — like `fetchCaller()` — they hard-code `.public(.requires(.webAuth))` and expose **no** `database:` parameter. Both validate the request as a whole: a bad short GUID fails the entire call rather than producing a per-item failure, so there is no `RecordResult`-style failure variant.

Set `ShortGUIDDictionary.shouldFetchRootRecord` to have CloudKit include the shared root record, optionally narrowed by `rootRecordDesiredKeys`. When CloudKit cannot match the caller to exactly one invited participant, `ShareRecordInfo.potentialMatchList` is non-empty and the user must choose which invitation they are claiming. Domain models live in `Sources/MistKit/Models/Sharing/`. The opaque token string is `ShortGUID`; the resolve/accept dictionary shape is `ShortGUIDDictionary`.

**Batch chunking (issue #307):** the two non-deprecated operations capped at CloudKit's 200-item-per-request limit (`CloudKitService.maxRecordsPerRequest`) each pair a single-request primitive with an auto-chunking convenience that splits the input into ≤`batchSize` batches, calls the primitive per batch, and concatenates results in input order. This mirrors the `queryRecords`/`queryAllRecords` page-primitive + auto-paginating-extension pattern. Because chunk count is `ceil(input.count / batchSize)` — deterministic and finite — there is **no** `maxPages`-style throwing ceiling; `batchSize` (default `maxRecordsPerRequest`, clamped to `1...maxRecordsPerRequest`) is the only knob. The shared engine is `chunkedBatches` (`CloudKitService+BatchChunking.swift`).

| Primitive (single request) | Auto-chunking convenience |
Expand Down
124 changes: 124 additions & 0 deletions Examples/MistDemo/Sources/MistDemoKit/Commands/AcceptCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
//
// AcceptCommand.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

/// Command for `records/accept`. Accepts shares identified by short GUID on
/// behalf of the current user, adding the caller as a participant.
public struct AcceptCommand: MistDemoCommand, OutputFormatting {
/// The configuration type.
public typealias Config = AcceptConfig
/// The command name.
public static let commandName = "accept"
/// The command abstract.
public static let abstract = "Accept shares by short GUID (records/accept)"
/// The command help text.
public static let helpText = """
ACCEPT - Accept shares by short GUID

USAGE:
mistdemo accept --short-guid <guid>[,<guid>...] [options]
mistdemo accept --share-url <url>[,<url>...] [options]

INPUT (choose one):
--short-guid <list> Comma-separated short GUIDs
--share-url <list> Comma-separated share URLs — the short
GUID is parsed from each URL's last path
component (e.g. .../share/abc123 → abc123)

OPTIONS:
--fetch-root-record Ask CloudKit to include the root record
--fields <field1,field2,...> Restrict the root record's fields
--output-format <format> Output format (json, table, csv, yaml)

EXAMPLES:
mistdemo accept --short-guid abc123
mistdemo accept --share-url https://www.icloud.com/share/abc123

NOTES:
Requires API + web-auth credentials — CloudKit pins records/accept
to the public database with web-auth regardless of --database.
Accepting an already-accepted or invalid short GUID fails the
entire request.
"""

private let config: AcceptConfig

/// Creates a new instance.
public init(config: AcceptConfig) {
self.config = config
}

/// Executes the command.
public func execute() async throws {
guard config.base.hasUserContextCredentials else {
throw AcceptError.webAuthRequired
}

let service = try MistKitClientFactory.create(for: config.base)
let shortGUIDs = config.shortGUIDs.map {
ShortGUIDDictionary(
value: $0,
shouldFetchRootRecord: config.fetchRootRecord,
rootRecordDesiredKeys: config.fields
)
}

do {
let results = try await service.acceptShares(shortGUIDs)
printSummary(results)
try await outputResults(results, format: config.output)
} catch let error as AcceptError {
throw error
} catch {
throw AcceptError.operationFailed(error.localizedDescription)
}
}

private func printSummary(_ results: [ShareRecordInfo]) {
print(
"✅ Accepted \(results.count) share\(results.count == 1 ? "" : "s")"
)
for result in results {
print(" - shortGUID: \(result.shortGUID.value)")
print(" rootRecordName: \(result.rootRecordName ?? "-")")
print(
" participantStatus: \(result.participantStatus?.rawValue ?? "-")"
)
print(
" participantPermission: "
+ "\(result.participantPermission?.rawValue ?? "-")"
)
if let zoneID = result.zoneID {
print(" zoneID: \(zoneID.zoneName)")
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
--host <host> Server host (default: 127.0.0.1)
--browser Open browser on startup (default for auth-token)
--no-browser Don't open browser on startup (overrides --browser)
--reset-auth Sign out any persisted CloudKit JS session before
capturing (force Apple ID picker)
"""

internal let config: AuthTokenConfig
Expand Down Expand Up @@ -110,6 +112,9 @@
/// Executes the command.
public func execute() async throws {
print("📍 Server URL: http://\(config.host):\(config.port)")
if config.resetAuth {
print("🔄 Reset auth — browser will clear any persisted Apple ID session.")
}

let tokenStore = WebAuthTokenStore()
let server = WebServer(
Expand All @@ -123,7 +128,8 @@
containerIdentifier: config.containerIdentifier,
environment: config.environment
),
terminatesAfterAuth: true
terminatesAfterAuth: true,
resetAuth: config.resetAuth
)
let app = Application(
router: try server.makeRouter(),
Expand Down
82 changes: 66 additions & 16 deletions Examples/MistDemo/Sources/MistDemoKit/Commands/ResolveCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,36 +28,45 @@
//

internal import Foundation
internal import MistKit

/// Stub command for `records/resolve`. Resolves a share URL or record
/// reference to a CloudKit record. The MistKit Swift wrapper is tracked
/// in #41; until that lands, this command prints the standard pending
/// banner and exits 0 so the `--help` shape is discoverable today.
public struct ResolveCommand: MistDemoCommand {
/// Command for `records/resolve`. Resolves share short GUIDs — the handle
/// carried by CloudKit share URLs — into the root record, the governing
/// `cloudKit.share` record, and the caller's participation in each share.
public struct ResolveCommand: MistDemoCommand, OutputFormatting {
/// The configuration type.
public typealias Config = ResolveConfig
/// The command name.
public static let commandName = "resolve"
/// The command abstract.
public static let abstract = "Resolve a share URL or record reference (pending #41)"
public static let abstract = "Resolve share short GUIDs (records/resolve)"
/// The command help text.
public static let helpText = """
RESOLVE - Resolve a share URL or record reference
RESOLVE - Resolve share short GUIDs

USAGE:
mistdemo resolve --share-url <url> [options]
mistdemo resolve --record-name <name> [options]
mistdemo resolve --short-guid <guid>[,<guid>...] [options]
mistdemo resolve --share-url <url>[,<url>...] [options]

INPUT (choose one):
--share-url <url> Share URL to resolve
--record-name <name> Record name to resolve
--short-guid <list> Comma-separated short GUIDs
--share-url <list> Comma-separated share URLs — the short
GUID is parsed from each URL's last path
component (e.g. .../share/abc123 → abc123)

OPTIONS:
--database <type> Database to target
--output-format <format> Output format (json, table, csv, yaml)
--fetch-root-record Ask CloudKit to include the root record
--fields <field1,field2,...> Restrict the root record's fields
--output-format <format> Output format (json, table, csv, yaml)

STATUS:
Not yet implemented — pending MistKit support, tracked in #41.
EXAMPLES:
mistdemo resolve --short-guid abc123
mistdemo resolve --short-guid abc123,def456 --fetch-root-record
mistdemo resolve --share-url https://www.icloud.com/share/abc123

NOTES:
Requires API + web-auth credentials — CloudKit pins records/resolve
to the public database with web-auth regardless of --database.
"""

private let config: ResolveConfig
Expand All @@ -69,6 +78,47 @@ public struct ResolveCommand: MistDemoCommand {

/// Executes the command.
public func execute() async throws {
PendingStub.printPending(endpoint: "records/resolve", trackingIssue: 41)
guard config.base.hasUserContextCredentials else {
throw ResolveError.webAuthRequired
}

let service = try MistKitClientFactory.create(for: config.base)
let shortGUIDs = config.shortGUIDs.map {
ShortGUIDDictionary(
value: $0,
shouldFetchRootRecord: config.fetchRootRecord,
rootRecordDesiredKeys: config.fields
)
}

do {
let results = try await service.resolveShares(shortGUIDs)
printSummary(results)
try await outputResults(results, format: config.output)
} catch let error as ResolveError {
throw error
} catch {
throw ResolveError.operationFailed(error.localizedDescription)
}
}

private func printSummary(_ results: [ShareRecordInfo]) {
print(
"✅ Resolved \(results.count) share\(results.count == 1 ? "" : "s")"
)
for result in results {
print(" - shortGUID: \(result.shortGUID.value)")
print(" rootRecordName: \(result.rootRecordName ?? "-")")
print(
" participantStatus: \(result.participantStatus?.rawValue ?? "-")"
)
print(
" participantPermission: "
+ "\(result.participantPermission?.rawValue ?? "-")"
)
if let zoneID = result.zoneID {
print(" zoneID: \(zoneID.zoneName)")
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ public struct TestPrivateCommand: MistDemoCommand {
Email for users/lookup/email phase (CLOUDKIT_LOOKUP_EMAIL).
Must belong to an iCloud account discoverable to the caller,
otherwise the phase skips.
--sharee-web-auth-token <token>
Web-auth token for the sharee account
(CLOUDKIT_SHAREE_WEB_AUTH_TOKEN). Obtain via `mistdemo auth-token`
while signed in as the sharee. Primary CLOUDKIT_WEB_AUTH_TOKEN
remains the sharer.
--sharee-email <email>
iCloud email of the sharee (CLOUDKIT_SHAREE_EMAIL), used as the
invite lookup info when creating the share. Both sharee options
are required for the create→accept phase; otherwise it skips.

EXAMPLES:
mistdemo test-private --verbose
Expand All @@ -68,7 +77,9 @@ public struct TestPrivateCommand: MistDemoCommand {

NOTES:
- Requires CLOUDKIT_API_TOKEN and
CLOUDKIT_WEB_AUTH_TOKEN
CLOUDKIT_WEB_AUTH_TOKEN (sharer)
- Optional sharee: CLOUDKIT_SHAREE_WEB_AUTH_TOKEN +
CLOUDKIT_SHAREE_EMAIL
- Use 'test-public' for public-database tests
"""

Expand All @@ -90,6 +101,15 @@ public struct TestPrivateCommand: MistDemoCommand {
// them. Per-call resolution picks the right token manager.
let supportsUserContextPhases = config.base.hasUserContextCredentials

let shareeService: CloudKitService?
if let shareeToken = config.shareeWebAuthToken, !shareeToken.isEmpty {
shareeService = try MistKitClientFactory.create(
for: config.base.with(webAuthToken: shareeToken)
)
} else {
shareeService = nil
}

let runner = IntegrationTestRunner(
service: service,
supportsUserContextPhases: supportsUserContextPhases,
Expand All @@ -99,7 +119,10 @@ public struct TestPrivateCommand: MistDemoCommand {
assetSizeKB: config.assetSizeKB,
skipCleanup: config.skipCleanup,
verbose: config.verbose,
lookupEmail: config.lookupEmail
lookupEmail: config.lookupEmail,
shareShortGUID: config.shareShortGUID,
shareeService: shareeService,
shareeEmail: config.shareeEmail
)

try await runner.runPrivateWorkflow()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,16 @@ public struct TestPublicCommand: MistDemoCommand {
Email for users/lookup/email phase (CLOUDKIT_LOOKUP_EMAIL).
Must belong to an iCloud account discoverable to the caller,
otherwise the phase skips.
--share-short-guid <guid>
Short GUID for records/resolve + records/accept phases
(CLOUDKIT_SHARE_SHORT_GUID). Must identify an existing share,
otherwise both phases skip.

EXAMPLES:
mistdemo test-public --verbose
mistdemo test-public --skip-cleanup --verbose
mistdemo test-public --lookup-email me@example.com
mistdemo test-public --share-short-guid abc123

NOTES:
- Requires CLOUDKIT_KEY_ID and CLOUDKIT_PRIVATE_KEY
Expand Down Expand Up @@ -99,7 +104,10 @@ public struct TestPublicCommand: MistDemoCommand {
assetSizeKB: config.assetSizeKB,
skipCleanup: config.skipCleanup,
verbose: config.verbose,
lookupEmail: config.lookupEmail
lookupEmail: config.lookupEmail,
shareShortGUID: config.shareShortGUID,
shareeService: nil,
shareeEmail: nil
)

try await runner.runBasicWorkflow()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@
environment: config.environment,
serverToServer: try makeServerToServerCredentials()
),
terminatesAfterAuth: false
terminatesAfterAuth: false,
resetAuth: false
)
let router = try server.makeRouter()

Expand Down
Loading