krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

963ce3fb34bd6c6bd72f1aa956ce602267b90e9c

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2026-07-25T17:14:58Z

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.
 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(-)

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