summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-25 12:14:58 -0500
committerChristian Cleberg <[email protected]>2026-07-25 12:19:37 -0500
commit963ce3fb34bd6c6bd72f1aa956ce602267b90e9c (patch)
treeba2c18e4c2bebba8d07a6715e07bd1fccbea4581
parent7156c088a438526182c517fafa35d31990d44f18 (diff)
downloaddomain-dig-963ce3fb34bd6c6bd72f1aa956ce602267b90e9c.tar.gz
domain-dig-963ce3fb34bd6c6bd72f1aa956ce602267b90e9c.tar.bz2
domain-dig-963ce3fb34bd6c6bd72f1aa956ce602267b90e9c.zip
feat: owner Pro+ allowlist via CloudKit, cut v5.0.1v5.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.
-rw-r--r--DomainDig.xcodeproj/project.pbxproj40
-rw-r--r--DomainDig/AppVersion.swift2
-rw-r--r--DomainDig/OwnerAccess.swift34
-rw-r--r--DomainDig/PurchaseService.swift53
-rw-r--r--RELEASE_ROADMAP.md13
5 files changed, 117 insertions, 25 deletions
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<Transaction>) 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