Commit 963ce3fb34

963ce3fb34bd6c6bd72f1aa956ce602267b90e9c

parent: 7156c088a4

Unsigned

cmc <hello@cleberg.net> · 2026-07-25 17:14 UTC

feat: owner Pro+ allowlist via CloudKit, cut v5.0.1

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.

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +20 −20
@@ -567,11 +567,11 @@
567567 BUNDLE_LOADER = "$(TEST_HOST)";
568568 CLANG_ENABLE_OBJC_WEAK = NO;
569569 CODE_SIGN_STYLE = Automatic;
570 CURRENT_PROJECT_VERSION = 45;
570 CURRENT_PROJECT_VERSION = 46;
571571 DEVELOPMENT_TEAM = ZCNAX3VL9D;
572572 GENERATE_INFOPLIST_FILE = YES;
573573 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
574 MARKETING_VERSION = 5.0.0;
574 MARKETING_VERSION = 5.0.1;
575575 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigTests;
576576 PRODUCT_NAME = "$(TARGET_NAME)";
577577 SDKROOT = iphoneos;
@@ -714,7 +714,7 @@
714714 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
715715 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
716716 CODE_SIGN_STYLE = Automatic;
717 CURRENT_PROJECT_VERSION = 45;
717 CURRENT_PROJECT_VERSION = 46;
718718 DEVELOPMENT_TEAM = ZCNAX3VL9D;
719719 ENABLE_PREVIEWS = YES;
720720 GENERATE_INFOPLIST_FILE = YES;
@@ -731,7 +731,7 @@
731731 "$(inherited)",
732732 "@executable_path/Frameworks",
733733 );
734 MARKETING_VERSION = 5.0.0;
734 MARKETING_VERSION = 5.0.1;
735735 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
736736 PRODUCT_NAME = "$(TARGET_NAME)";
737737 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -751,7 +751,7 @@
751751 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
752752 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
753753 CODE_SIGN_STYLE = Automatic;
754 CURRENT_PROJECT_VERSION = 45;
754 CURRENT_PROJECT_VERSION = 46;
755755 DEVELOPMENT_TEAM = ZCNAX3VL9D;
756756 ENABLE_PREVIEWS = YES;
757757 GENERATE_INFOPLIST_FILE = YES;
@@ -768,7 +768,7 @@
768768 "$(inherited)",
769769 "@executable_path/Frameworks",
770770 );
771 MARKETING_VERSION = 5.0.0;
771 MARKETING_VERSION = 5.0.1;
772772 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
773773 PRODUCT_NAME = "$(TARGET_NAME)";
774774 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -787,7 +787,7 @@
787787 APPLICATION_EXTENSION_API_ONLY = YES;
788788 CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements;
789789 CODE_SIGN_STYLE = Automatic;
790 CURRENT_PROJECT_VERSION = 45;
790 CURRENT_PROJECT_VERSION = 46;
791791 DEVELOPMENT_TEAM = ZCNAX3VL9D;
792792 GENERATE_INFOPLIST_FILE = NO;
793793 INFOPLIST_FILE = DomainDigWidget/Info.plist;
@@ -798,7 +798,7 @@
798798 "@executable_path/Frameworks",
799799 "@executable_path/../../Frameworks",
800800 );
801 MARKETING_VERSION = 5.0.0;
801 MARKETING_VERSION = 5.0.1;
802802 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget;
803803 PRODUCT_NAME = "$(TARGET_NAME)";
804804 SKIP_INSTALL = YES;
@@ -816,7 +816,7 @@
816816 APPLICATION_EXTENSION_API_ONLY = YES;
817817 CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements;
818818 CODE_SIGN_STYLE = Automatic;
819 CURRENT_PROJECT_VERSION = 45;
819 CURRENT_PROJECT_VERSION = 46;
820820 DEVELOPMENT_TEAM = ZCNAX3VL9D;
821821 GENERATE_INFOPLIST_FILE = NO;
822822 INFOPLIST_FILE = DomainDigWidget/Info.plist;
@@ -827,7 +827,7 @@
827827 "@executable_path/Frameworks",
828828 "@executable_path/../../Frameworks",
829829 );
830 MARKETING_VERSION = 5.0.0;
830 MARKETING_VERSION = 5.0.1;
831831 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget;
832832 PRODUCT_NAME = "$(TARGET_NAME)";
833833 SKIP_INSTALL = YES;
@@ -845,7 +845,7 @@
845845 APPLICATION_EXTENSION_API_ONLY = YES;
846846 CODE_SIGN_ENTITLEMENTS = DomainDigShareExtension/DomainDigShareExtension.entitlements;
847847 CODE_SIGN_STYLE = Automatic;
848 CURRENT_PROJECT_VERSION = 45;
848 CURRENT_PROJECT_VERSION = 46;
849849 DEVELOPMENT_TEAM = ZCNAX3VL9D;
850850 GENERATE_INFOPLIST_FILE = NO;
851851 INFOPLIST_FILE = DomainDigShareExtension/Info.plist;
@@ -856,7 +856,7 @@
856856 "@executable_path/Frameworks",
857857 "@executable_path/../../Frameworks",
858858 );
859 MARKETING_VERSION = 5.0.0;
859 MARKETING_VERSION = 5.0.1;
860860 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigShareExtension;
861861 PRODUCT_NAME = "$(TARGET_NAME)";
862862 SKIP_INSTALL = YES;
@@ -874,7 +874,7 @@
874874 APPLICATION_EXTENSION_API_ONLY = YES;
875875 CODE_SIGN_ENTITLEMENTS = DomainDigShareExtension/DomainDigShareExtension.entitlements;
876876 CODE_SIGN_STYLE = Automatic;
877 CURRENT_PROJECT_VERSION = 45;
877 CURRENT_PROJECT_VERSION = 46;
878878 DEVELOPMENT_TEAM = ZCNAX3VL9D;
879879 GENERATE_INFOPLIST_FILE = NO;
880880 INFOPLIST_FILE = DomainDigShareExtension/Info.plist;
@@ -885,7 +885,7 @@
885885 "@executable_path/Frameworks",
886886 "@executable_path/../../Frameworks",
887887 );
888 MARKETING_VERSION = 5.0.0;
888 MARKETING_VERSION = 5.0.1;
889889 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigShareExtension;
890890 PRODUCT_NAME = "$(TARGET_NAME)";
891891 SKIP_INSTALL = YES;
@@ -901,11 +901,11 @@
901901 isa = XCBuildConfiguration;
902902 buildSettings = {
903903 CODE_SIGN_STYLE = Automatic;
904 CURRENT_PROJECT_VERSION = 45;
904 CURRENT_PROJECT_VERSION = 46;
905905 DEVELOPMENT_TEAM = ZCNAX3VL9D;
906906 GENERATE_INFOPLIST_FILE = YES;
907907 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
908 MARKETING_VERSION = 5.0.0;
908 MARKETING_VERSION = 5.0.1;
909909 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigUITests;
910910 PRODUCT_NAME = "$(TARGET_NAME)";
911911 SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -920,11 +920,11 @@
920920 isa = XCBuildConfiguration;
921921 buildSettings = {
922922 CODE_SIGN_STYLE = Automatic;
923 CURRENT_PROJECT_VERSION = 45;
923 CURRENT_PROJECT_VERSION = 46;
924924 DEVELOPMENT_TEAM = ZCNAX3VL9D;
925925 GENERATE_INFOPLIST_FILE = YES;
926926 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
927 MARKETING_VERSION = 5.0.0;
927 MARKETING_VERSION = 5.0.1;
928928 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigUITests;
929929 PRODUCT_NAME = "$(TARGET_NAME)";
930930 SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -941,11 +941,11 @@
941941 BUNDLE_LOADER = "$(TEST_HOST)";
942942 CLANG_ENABLE_OBJC_WEAK = NO;
943943 CODE_SIGN_STYLE = Automatic;
944 CURRENT_PROJECT_VERSION = 45;
944 CURRENT_PROJECT_VERSION = 46;
945945 DEVELOPMENT_TEAM = ZCNAX3VL9D;
946946 GENERATE_INFOPLIST_FILE = YES;
947947 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
948 MARKETING_VERSION = 5.0.0;
948 MARKETING_VERSION = 5.0.1;
949949 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigTests;
950950 PRODUCT_NAME = "$(TARGET_NAME)";
951951 SDKROOT = iphoneos;
DomainDig/AppVersion.swift +1 −1
@@ -2,6 +2,6 @@ import Foundation
22
33enum AppVersion {
44 nonisolated static var current: String {
5 "5.0.0"
5 "5.0.1"
66 }
77}
DomainDig/OwnerAccess.swift added +34
@@ -0,0 +1,34 @@
1import CloudKit
2
3/// Owner-only entitlement support. The app owner is identified by their CloudKit
4/// user-record ID — a stable, opaque per-Apple-ID value for this app's container.
5/// `PurchaseService` grants the owner Pro+ when the signed-in iCloud user matches,
6/// so the owner does not need a purchase.
7///
8/// Publishing the record ID here is safe: it is not an Apple ID or any personal
9/// identifier, it is scoped to the `iCloud.net.cleberg.DomainDig` container, and
10/// CloudKit identity is verified server-side — another user cannot present it as
11/// their own. An empty value makes the allowlist inert.
12enum OwnerAccess {
13 static let ownerUserRecordID = "_1c35d6a25540b3ef00023cc0425ec373"
14
15 static var isConfigured: Bool { !ownerUserRecordID.isEmpty }
16
17 /// The current iCloud user's record name for this app's container, or nil if
18 /// it is unavailable (not signed into iCloud, restricted, or offline before
19 /// the first fetch).
20 static func currentUserRecordName() async -> String? {
21 do {
22 return try await CKContainer.default().userRecordID().recordName
23 } catch {
24 return nil
25 }
26 }
27
28 /// True only when the allowlist is configured and the current iCloud user is
29 /// the owner.
30 static func isOwner() async -> Bool {
31 guard isConfigured else { return false }
32 return await currentUserRecordName() == ownerUserRecordID
33 }
34}
DomainDig/PurchaseService.swift +50 −3
@@ -34,6 +34,20 @@ final class PurchaseService {
3434 private static let debugForceProPlusArgument = "DOMAIN_DIG_FORCE_PRO_PLUS"
3535 #endif
3636
37 private static let ownerEntitlementKey = "purchase.ownerEntitlement"
38
39 /// Whether the owner allowlist has confirmed this device's iCloud user as the
40 /// owner. Persisted so the grant is instant on later launches and survives
41 /// offline, when CloudKit cannot be reached.
42 static var ownerEntitlementGranted: Bool {
43 UserDefaults.standard.bool(forKey: ownerEntitlementKey)
44 }
45
46 private static var storedEntitlement: CachedEntitlement? {
47 guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil }
48 return try? JSONDecoder().decode(CachedEntitlement.self, from: data)
49 }
50
3751 static var cachedEntitlement: CachedEntitlement? {
3852 #if DEBUG
3953 if let forcedEntitlement = debugForcedEntitlement {
@@ -41,12 +55,17 @@ final class PurchaseService {
4155 }
4256 #endif
4357
44 guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil }
45 return try? JSONDecoder().decode(CachedEntitlement.self, from: data)
58 return storedEntitlement
4659 }
4760
4861 static var cachedTier: FeatureTier {
49 cachedEntitlement?.tier ?? .free
62 #if DEBUG
63 // A debug override wins outright so free/pro tiers remain testable on the
64 // owner's own device.
65 if let forcedEntitlement = debugForcedEntitlement { return forcedEntitlement.tier }
66 #endif
67 if ownerEntitlementGranted { return .proPlus }
68 return storedEntitlement?.tier ?? .free
5069 }
5170
5271 var products: [Product] = []
@@ -64,8 +83,10 @@ final class PurchaseService {
6483 currentTier = Self.cachedTier
6584 activeProductID = Self.cachedEntitlement?.activeProductID
6685 applyDebugOverrideIfNeeded()
86 applyOwnerOverrideIfNeeded()
6787 updatesTask = observeTransactionUpdates()
6888 Task {
89 await resolveOwnerEntitlementIfNeeded()
6990 await refreshProducts()
7091 await refreshEntitlements()
7192 }
@@ -118,6 +139,7 @@ final class PurchaseService {
118139 currentTier = tier(for: activeProductID)
119140 persistCurrentEntitlement()
120141 applyDebugOverrideIfNeeded()
142 applyOwnerOverrideIfNeeded()
121143 }
122144
123145 func purchase(_ product: Product) async {
@@ -251,6 +273,31 @@ final class PurchaseService {
251273 #endif
252274 }
253275
276 /// Elevates the current tier to Pro+ when the device's iCloud user has been
277 /// confirmed as the owner. Only ever elevates, and defers to a debug override
278 /// so free/pro tiers stay testable on the owner's own device.
279 private func applyOwnerOverrideIfNeeded() {
280 #if DEBUG
281 if Self.debugForcedEntitlement != nil { return }
282 #endif
283 guard Self.ownerEntitlementGranted else { return }
284 currentTier = .proPlus
285 }
286
287 /// Resolves the owner allowlist against CloudKit once per launch. On a match
288 /// it records the grant so future launches apply it synchronously and offline.
289 private func resolveOwnerEntitlementIfNeeded() async {
290 guard OwnerAccess.isConfigured else { return }
291 if Self.ownerEntitlementGranted {
292 applyOwnerOverrideIfNeeded()
293 return
294 }
295 if await OwnerAccess.isOwner() {
296 UserDefaults.standard.set(true, forKey: Self.ownerEntitlementKey)
297 applyOwnerOverrideIfNeeded()
298 }
299 }
300
254301 private func verifiedTransaction(from result: VerificationResult<Transaction>) throws -> Transaction {
255302 switch result {
256303 case .verified(let transaction):
RELEASE_ROADMAP.md +12 −1
@@ -6,7 +6,7 @@ ports, geolocation, subdomains, availability). The next several releases invest
66in *reach and surfacing* — getting that data onto more iOS surfaces and into more
77workflows — rather than adding raw protocol checks.
88
9Current version: `v5.0.0`.
9Current version: `v5.0.1`.
1010
1111## v4.4.1 Patch: Release Readiness — ✅ shipped
1212
@@ -310,6 +310,17 @@ Release cut: `MARKETING_VERSION` 4.9.0 → 5.0.0, `CURRENT_PROJECT_VERSION`
31031044 → 45, and `AppVersion.current` bumped in lockstep. App Store archive/submit is
311311the only step left, and it is a manual action outside the repo.
312312
313## v5.0.1 Patch: Owner entitlement — ✅ shipped
314
315- **Owner Pro+ allowlist.** `OwnerAccess` identifies the app owner by their
316 CloudKit user-record ID (an opaque, per-Apple-ID value scoped to the app's
317 container). `PurchaseService` resolves it against CloudKit once per launch and,
318 on a match, grants `.proPlus` — persisted so it applies instantly and offline
319 thereafter. It only ever elevates the tier and defers to the existing `#if
320 DEBUG` overrides, so real purchases and free/pro testing are unaffected.
321- Release cut: `MARKETING_VERSION` 5.0.0 → 5.0.1, `CURRENT_PROJECT_VERSION`
322 45 → 46, `AppVersion.current` in lockstep.
323
313324## Cross-cutting note
314325
315326New feature surfaces (widgets, intents, extensions) each add a target and a