From 963ce3fb34bd6c6bd72f1aa956ce602267b90e9c Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sat, 25 Jul 2026 12:14:58 -0500 Subject: feat: owner Pro+ allowlist via CloudKit, cut v5.0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grants the app owner Pro+ without a purchase, keyed to their CloudKit user-record ID so it works on the release App Store build. - OwnerAccess holds the owner's CloudKit user-record ID (opaque, per-Apple-ID, scoped to the app's container; safe to publish — CloudKit verifies identity server-side, so it can't be presented by anyone else). - PurchaseService resolves the allowlist against CloudKit once per launch and, on a match, records a persisted owner grant so it applies instantly and offline thereafter. The grant only ever elevates the tier to .proPlus and defers to the existing #if DEBUG overrides, so real purchases and free/pro testing are unaffected. cachedTier / cachedEntitlement were refactored to fall back to the owner grant only when no debug override or stored purchase applies. - Supersedes the DEBUG record-ID reveal (PR #60): its only purpose was to read the owner's ID, which is now hardcoded, so the reveal is not shipped. Release cut: MARKETING_VERSION 5.0.0 -> 5.0.1, CURRENT_PROJECT_VERSION 45 -> 46, AppVersion.current -> 5.0.1, roadmap updated. App builds clean; unit suite 63/63. --- DomainDig.xcodeproj/project.pbxproj | 40 ++++++++++++++-------------- DomainDig/AppVersion.swift | 2 +- DomainDig/OwnerAccess.swift | 34 ++++++++++++++++++++++++ DomainDig/PurchaseService.swift | 53 ++++++++++++++++++++++++++++++++++--- RELEASE_ROADMAP.md | 13 ++++++++- 5 files changed, 117 insertions(+), 25 deletions(-) create mode 100644 DomainDig/OwnerAccess.swift diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj index da11c1e..3d89d80 100644 --- a/DomainDig.xcodeproj/project.pbxproj +++ b/DomainDig.xcodeproj/project.pbxproj @@ -567,11 +567,11 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 45; + CURRENT_PROJECT_VERSION = 46; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; - MARKETING_VERSION = 5.0.0; + MARKETING_VERSION = 5.0.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -714,7 +714,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 45; + CURRENT_PROJECT_VERSION = 46; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -731,7 +731,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.0.0; + MARKETING_VERSION = 5.0.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -751,7 +751,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 45; + CURRENT_PROJECT_VERSION = 46; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -768,7 +768,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.0.0; + MARKETING_VERSION = 5.0.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -787,7 +787,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 45; + CURRENT_PROJECT_VERSION = 46; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = DomainDigWidget/Info.plist; @@ -798,7 +798,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.0.0; + MARKETING_VERSION = 5.0.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -816,7 +816,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 45; + CURRENT_PROJECT_VERSION = 46; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = DomainDigWidget/Info.plist; @@ -827,7 +827,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.0.0; + MARKETING_VERSION = 5.0.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -845,7 +845,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = DomainDigShareExtension/DomainDigShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 45; + CURRENT_PROJECT_VERSION = 46; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = DomainDigShareExtension/Info.plist; @@ -856,7 +856,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.0.0; + MARKETING_VERSION = 5.0.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -874,7 +874,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = DomainDigShareExtension/DomainDigShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 45; + CURRENT_PROJECT_VERSION = 46; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = DomainDigShareExtension/Info.plist; @@ -885,7 +885,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.0.0; + MARKETING_VERSION = 5.0.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -901,11 +901,11 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 45; + CURRENT_PROJECT_VERSION = 46; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; - MARKETING_VERSION = 5.0.0; + MARKETING_VERSION = 5.0.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -920,11 +920,11 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 45; + CURRENT_PROJECT_VERSION = 46; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; - MARKETING_VERSION = 5.0.0; + MARKETING_VERSION = 5.0.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -941,11 +941,11 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 45; + CURRENT_PROJECT_VERSION = 46; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; - MARKETING_VERSION = 5.0.0; + MARKETING_VERSION = 5.0.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift index 479695a..5fb8925 100644 --- a/DomainDig/AppVersion.swift +++ b/DomainDig/AppVersion.swift @@ -2,6 +2,6 @@ import Foundation enum AppVersion { nonisolated static var current: String { - "5.0.0" + "5.0.1" } } diff --git a/DomainDig/OwnerAccess.swift b/DomainDig/OwnerAccess.swift new file mode 100644 index 0000000..eca2bab --- /dev/null +++ b/DomainDig/OwnerAccess.swift @@ -0,0 +1,34 @@ +import CloudKit + +/// Owner-only entitlement support. The app owner is identified by their CloudKit +/// user-record ID — a stable, opaque per-Apple-ID value for this app's container. +/// `PurchaseService` grants the owner Pro+ when the signed-in iCloud user matches, +/// so the owner does not need a purchase. +/// +/// Publishing the record ID here is safe: it is not an Apple ID or any personal +/// identifier, it is scoped to the `iCloud.net.cleberg.DomainDig` container, and +/// CloudKit identity is verified server-side — another user cannot present it as +/// their own. An empty value makes the allowlist inert. +enum OwnerAccess { + static let ownerUserRecordID = "_1c35d6a25540b3ef00023cc0425ec373" + + static var isConfigured: Bool { !ownerUserRecordID.isEmpty } + + /// The current iCloud user's record name for this app's container, or nil if + /// it is unavailable (not signed into iCloud, restricted, or offline before + /// the first fetch). + static func currentUserRecordName() async -> String? { + do { + return try await CKContainer.default().userRecordID().recordName + } catch { + return nil + } + } + + /// True only when the allowlist is configured and the current iCloud user is + /// the owner. + static func isOwner() async -> Bool { + guard isConfigured else { return false } + return await currentUserRecordName() == ownerUserRecordID + } +} diff --git a/DomainDig/PurchaseService.swift b/DomainDig/PurchaseService.swift index 4ab8ef4..91ce211 100644 --- a/DomainDig/PurchaseService.swift +++ b/DomainDig/PurchaseService.swift @@ -34,6 +34,20 @@ final class PurchaseService { private static let debugForceProPlusArgument = "DOMAIN_DIG_FORCE_PRO_PLUS" #endif + private static let ownerEntitlementKey = "purchase.ownerEntitlement" + + /// Whether the owner allowlist has confirmed this device's iCloud user as the + /// owner. Persisted so the grant is instant on later launches and survives + /// offline, when CloudKit cannot be reached. + static var ownerEntitlementGranted: Bool { + UserDefaults.standard.bool(forKey: ownerEntitlementKey) + } + + private static var storedEntitlement: CachedEntitlement? { + guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil } + return try? JSONDecoder().decode(CachedEntitlement.self, from: data) + } + static var cachedEntitlement: CachedEntitlement? { #if DEBUG if let forcedEntitlement = debugForcedEntitlement { @@ -41,12 +55,17 @@ final class PurchaseService { } #endif - guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil } - return try? JSONDecoder().decode(CachedEntitlement.self, from: data) + return storedEntitlement } static var cachedTier: FeatureTier { - cachedEntitlement?.tier ?? .free + #if DEBUG + // A debug override wins outright so free/pro tiers remain testable on the + // owner's own device. + if let forcedEntitlement = debugForcedEntitlement { return forcedEntitlement.tier } + #endif + if ownerEntitlementGranted { return .proPlus } + return storedEntitlement?.tier ?? .free } var products: [Product] = [] @@ -64,8 +83,10 @@ final class PurchaseService { currentTier = Self.cachedTier activeProductID = Self.cachedEntitlement?.activeProductID applyDebugOverrideIfNeeded() + applyOwnerOverrideIfNeeded() updatesTask = observeTransactionUpdates() Task { + await resolveOwnerEntitlementIfNeeded() await refreshProducts() await refreshEntitlements() } @@ -118,6 +139,7 @@ final class PurchaseService { currentTier = tier(for: activeProductID) persistCurrentEntitlement() applyDebugOverrideIfNeeded() + applyOwnerOverrideIfNeeded() } func purchase(_ product: Product) async { @@ -251,6 +273,31 @@ final class PurchaseService { #endif } + /// Elevates the current tier to Pro+ when the device's iCloud user has been + /// confirmed as the owner. Only ever elevates, and defers to a debug override + /// so free/pro tiers stay testable on the owner's own device. + private func applyOwnerOverrideIfNeeded() { + #if DEBUG + if Self.debugForcedEntitlement != nil { return } + #endif + guard Self.ownerEntitlementGranted else { return } + currentTier = .proPlus + } + + /// Resolves the owner allowlist against CloudKit once per launch. On a match + /// it records the grant so future launches apply it synchronously and offline. + private func resolveOwnerEntitlementIfNeeded() async { + guard OwnerAccess.isConfigured else { return } + if Self.ownerEntitlementGranted { + applyOwnerOverrideIfNeeded() + return + } + if await OwnerAccess.isOwner() { + UserDefaults.standard.set(true, forKey: Self.ownerEntitlementKey) + applyOwnerOverrideIfNeeded() + } + } + private func verifiedTransaction(from result: VerificationResult) throws -> Transaction { switch result { case .verified(let transaction): diff --git a/RELEASE_ROADMAP.md b/RELEASE_ROADMAP.md index 3b46d32..eeb3350 100644 --- a/RELEASE_ROADMAP.md +++ b/RELEASE_ROADMAP.md @@ -6,7 +6,7 @@ ports, geolocation, subdomains, availability). The next several releases invest in *reach and surfacing* — getting that data onto more iOS surfaces and into more workflows — rather than adding raw protocol checks. -Current version: `v5.0.0`. +Current version: `v5.0.1`. ## v4.4.1 Patch: Release Readiness — ✅ shipped @@ -310,6 +310,17 @@ Release cut: `MARKETING_VERSION` 4.9.0 → 5.0.0, `CURRENT_PROJECT_VERSION` 44 → 45, and `AppVersion.current` bumped in lockstep. App Store archive/submit is the only step left, and it is a manual action outside the repo. +## v5.0.1 Patch: Owner entitlement — ✅ shipped + +- **Owner Pro+ allowlist.** `OwnerAccess` identifies the app owner by their + CloudKit user-record ID (an opaque, per-Apple-ID value scoped to the app's + container). `PurchaseService` resolves it against CloudKit once per launch and, + on a match, grants `.proPlus` — persisted so it applies instantly and offline + thereafter. It only ever elevates the tier and defers to the existing `#if + DEBUG` overrides, so real purchases and free/pro testing are unaffected. +- Release cut: `MARKETING_VERSION` 5.0.0 → 5.0.1, `CURRENT_PROJECT_VERSION` + 45 → 46, `AppVersion.current` in lockstep. + ## Cross-cutting note New feature surfaces (widgets, intents, extensions) each add a target and a -- cgit v1.2.3