diff --git a/Clave.xcodeproj/project.pbxproj b/Clave.xcodeproj/project.pbxproj index 75db3e1..97833f0 100644 --- a/Clave.xcodeproj/project.pbxproj +++ b/Clave.xcodeproj/project.pbxproj @@ -8,8 +8,12 @@ /* Begin PBXBuildFile section */ 006B17A84C114EDF9B129CAE /* NostrConnectParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B139A8E147475F9B40B3D5 /* NostrConnectParser.swift */; }; + 2423CB18D50B2E24C6A6FF4A /* EntitlementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2399A369793AE0D3D33EADA4 /* EntitlementService.swift */; }; + 34CBA7C30F31BE62223F0338 /* Entitlement.swift in Sources */ = {isa = PBXBuildFile; fileRef = E671D1F2204302E539745585 /* Entitlement.swift */; }; 4963A6373F0641F1B1A1455A330BCB5B /* DeeplinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7835E9DD22C448C3893D6D945AFFEA16 /* DeeplinkRouter.swift */; }; 6D2C503B9EF64C8EA5101CDB /* NostrConnectParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B139A8E147475F9B40B3D5 /* NostrConnectParser.swift */; }; + 77CF80E014C7761C7D80E997 /* EntitlementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2399A369793AE0D3D33EADA4 /* EntitlementService.swift */; }; + 925E218DD834A18B7EAA45D3 /* Entitlement.swift in Sources */ = {isa = PBXBuildFile; fileRef = E671D1F2204302E539745585 /* Entitlement.swift */; }; AE01F2A3B4C5D6E700000002 /* ActivitySummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE01F2A3B4C5D6E700000001 /* ActivitySummary.swift */; }; AE01F2A3B4C5D6E700000003 /* ActivitySummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE01F2A3B4C5D6E700000001 /* ActivitySummary.swift */; }; AE01F2A3B4C5D6E700000005 /* Nip19.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE01F2A3B4C5D6E700000004 /* Nip19.swift */; }; @@ -90,6 +94,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 2399A369793AE0D3D33EADA4 /* EntitlementService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EntitlementService.swift; sourceTree = ""; }; 34B139A8E147475F9B40B3D5 /* NostrConnectParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrConnectParser.swift; sourceTree = ""; }; 7835E9DD22C448C3893D6D945AFFEA16 /* DeeplinkRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeeplinkRouter.swift; sourceTree = ""; }; 894FE9FF88CD485ABCD31C05 /* ClientPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientPermissions.swift; sourceTree = ""; }; @@ -101,6 +106,7 @@ DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettings.swift; sourceTree = ""; }; DE7E10C1DE7E10C1DE7E10C1 /* LogExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogExporter.swift; sourceTree = ""; }; DE7E10C4DE7E10C4DE7E10C4 /* RelayUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayUtils.swift; sourceTree = ""; }; + E671D1F2204302E539745585 /* Entitlement.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Entitlement.swift; sourceTree = ""; }; EF3D7A0F2F8BCAE3005A6545 /* Clave.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Clave.app; sourceTree = BUILT_PRODUCTS_DIR; }; EF3D7A1C2F8BCAE6005A6545 /* ClaveTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ClaveTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EF3D7A262F8BCAE6005A6545 /* ClaveUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ClaveUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -145,11 +151,15 @@ }; EF3D7A1F2F8BCAE6005A6545 /* ClaveTests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = ClaveTests; sourceTree = ""; }; EF3D7A292F8BCAE6005A6545 /* ClaveUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = ClaveUITests; sourceTree = ""; }; @@ -245,6 +255,8 @@ AE01F2A3B4C5D6E700000007 /* SharedKeychain+Enumeration.swift */, EFCA55B463A619311A35AF3C /* SharedModels.swift */, EFEE4316AC522CDDA35AFAC1 /* SharedStorage.swift */, + E671D1F2204302E539745585 /* Entitlement.swift */, + 2399A369793AE0D3D33EADA4 /* EntitlementService.swift */, ); path = Shared; sourceTree = ""; @@ -295,8 +307,6 @@ EF3D7A1F2F8BCAE6005A6545 /* ClaveTests */, ); name = ClaveTests; - packageProductDependencies = ( - ); productName = ClaveTests; productReference = EF3D7A1C2F8BCAE6005A6545 /* ClaveTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -318,8 +328,6 @@ EF3D7A292F8BCAE6005A6545 /* ClaveUITests */, ); name = ClaveUITests; - packageProductDependencies = ( - ); productName = ClaveUITests; productReference = EF3D7A262F8BCAE6005A6545 /* ClaveUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; @@ -456,6 +464,8 @@ AE01F2A3B4C5D6E700000008 /* SharedKeychain+Enumeration.swift in Sources */, C1A4F2B3C5D6E700000011 /* AccountTheme.swift in Sources */, 4963A6373F0641F1B1A1455A330BCB5B /* DeeplinkRouter.swift in Sources */, + 34CBA7C30F31BE62223F0338 /* Entitlement.swift in Sources */, + 77CF80E014C7761C7D80E997 /* EntitlementService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -494,6 +504,8 @@ AE01F2A3B4C5D6E700000006 /* Nip19.swift in Sources */, B2551C1A4D99451B8AABCA1763DF333C /* DeeplinkRouter.swift in Sources */, B0EBA01E2F90AB01000A0003 /* PendingApprovalBanner.swift in Sources */, + 925E218DD834A18B7EAA45D3 /* Entitlement.swift in Sources */, + 2423CB18D50B2E24C6A6FF4A /* EntitlementService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ClaveTests/EntitlementServiceTests.swift b/ClaveTests/EntitlementServiceTests.swift new file mode 100644 index 0000000..8020570 --- /dev/null +++ b/ClaveTests/EntitlementServiceTests.swift @@ -0,0 +1,340 @@ +import XCTest +@testable import Clave + +final class EntitlementServiceTests: XCTestCase { + + // ---------- helpers ---------- + + /// Each test gets its own UserDefaults suite so cache state doesn't leak. + private func makeDefaults(file: StaticString = #file, line: UInt = #line) -> UserDefaults { + let suiteName = "EntitlementServiceTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("UserDefaults suite init failed", file: file, line: line) + return UserDefaults.standard + } + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + + private func makeService( + defaults: UserDefaults? = nil, + cacheTTL: TimeInterval = EntitlementService.defaultCacheTTL, + now: @Sendable @escaping () -> Date = { Date() }, + sessionConfigurator: ((URLSessionConfiguration) -> Void)? = nil + ) -> EntitlementService { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + sessionConfigurator?(config) + let session = URLSession(configuration: config) + return EntitlementService( + proxyURL: URL(string: "https://proxy-test.clave.casa")!, + session: session, + userDefaults: defaults ?? makeDefaults(), + cacheTTL: cacheTTL, + now: now + ) + } + + private let pubkeyA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + private let pubkeyB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + // ---------- Entitlement decoder ---------- + + func test_entitlement_decodesPremiumResponse() throws { + let json = """ + { + "pubkey": "\(pubkeyA)", + "tier": "premium", + "max_accounts": 10, + "max_clients": 30, + "granted_at": 1714000000, + "expires_at": null, + "granted_by": "admin:cli" + } + """.data(using: .utf8)! + let ent = try JSONDecoder().decode(Entitlement.self, from: json) + XCTAssertEqual(ent.pubkey, pubkeyA) + XCTAssertEqual(ent.tier, .premium) + XCTAssertEqual(ent.maxAccounts, 10) + XCTAssertEqual(ent.maxClients, 30) + XCTAssertEqual(ent.grantedAt, 1714000000) + XCTAssertNil(ent.expiresAt) + XCTAssertEqual(ent.grantedBy, "admin:cli") + } + + func test_entitlement_decodesFreeResponseWithoutOptionalFields() throws { + // Server returns just {pubkey, tier, max_accounts, max_clients} when + // there's no entitlement record (everyone defaults to free). + let json = """ + {"pubkey":"\(pubkeyA)","tier":"free","max_accounts":4,"max_clients":5} + """.data(using: .utf8)! + let ent = try JSONDecoder().decode(Entitlement.self, from: json) + XCTAssertEqual(ent.tier, .free) + XCTAssertNil(ent.grantedAt) + XCTAssertNil(ent.grantedBy) + } + + func test_entitlement_ignoresUnknownFutureFields() throws { + // Forward-compat: Phase 2 may add fields like "lightning_invoice_id". + // Older iOS must not break. + let json = """ + { + "pubkey": "\(pubkeyA)", + "tier": "premium", + "max_accounts": 10, + "max_clients": 30, + "lightning_invoice_id": "lnbc1...", + "billing_cycle": "lifetime", + "future_field": {"nested": "object"} + } + """.data(using: .utf8)! + let ent = try JSONDecoder().decode(Entitlement.self, from: json) + XCTAssertEqual(ent.tier, .premium) + } + + func test_entitlement_effectiveTier_lifetime() { + let ent = Entitlement(pubkey: pubkeyA, tier: .premium, maxAccounts: 10, maxClients: 30, + grantedAt: 1700000000, expiresAt: nil) + XCTAssertEqual(ent.effectiveTier(), .premium) + } + + func test_entitlement_effectiveTier_unexpired() { + let future = Date().timeIntervalSince1970 + 86400 + let ent = Entitlement(pubkey: pubkeyA, tier: .premium, maxAccounts: 10, maxClients: 30, + expiresAt: future) + XCTAssertEqual(ent.effectiveTier(), .premium) + } + + func test_entitlement_effectiveTier_expiredPremiumDowngrades() { + // expires_at is in the past → effectiveTier reads as free without + // mutating the stored record. + let past = Date().timeIntervalSince1970 - 86400 + let ent = Entitlement(pubkey: pubkeyA, tier: .premium, maxAccounts: 10, maxClients: 30, + expiresAt: past) + XCTAssertEqual(ent.effectiveTier(), .free) + XCTAssertEqual(ent.tier, .premium, "stored tier preserved") + } + + func test_entitlement_effectiveTier_freeStaysFree() { + let ent = Entitlement(pubkey: pubkeyA, tier: .free, maxAccounts: 4, maxClients: 5) + XCTAssertEqual(ent.effectiveTier(), .free) + } + + // ---------- cache layer ---------- + + func test_cachedTier_returnsNilWhenNoEntry() { + let svc = makeService() + XCTAssertNil(svc.cachedTier(for: pubkeyA)) + } + + func test_cachedTier_returnsTierAfterSave() { + let svc = makeService() + let ent = Entitlement(pubkey: pubkeyA, tier: .premium, maxAccounts: 10, maxClients: 30) + svc.saveCache(ent, for: pubkeyA) + XCTAssertEqual(svc.cachedTier(for: pubkeyA), .premium) + } + + func test_cachedTier_returnsNilWhenTTLExceeded() { + // Pin "now" to a later time so the existing cache (saved at "real" now) + // appears to be older than the 1-second TTL. + let defaults = makeDefaults() + let svc1 = makeService(defaults: defaults, cacheTTL: 1) + let ent = Entitlement(pubkey: pubkeyA, tier: .premium, maxAccounts: 10, maxClients: 30) + svc1.saveCache(ent, for: pubkeyA) + + let later = Date(timeIntervalSinceNow: 60) // 60s later — well past 1s TTL + let svc2 = makeService(defaults: defaults, cacheTTL: 1, now: { later }) + XCTAssertNil(svc2.cachedTier(for: pubkeyA), "expired cache reads as nil") + } + + func test_cachedTier_invalidatesOnVersionMismatch() { + // Manually inject a CachedEntitlement with a stale version number. + let defaults = makeDefaults() + let svc = makeService(defaults: defaults) + let payload: [String: Any] = [ + "version": 0, // old + "entitlement": [ + "pubkey": pubkeyA, + "tier": "premium", + "max_accounts": 10, + "max_clients": 30, + ], + "cachedAt": Date().timeIntervalSince1970 + ] + let data = try! JSONSerialization.data(withJSONObject: payload) + defaults.set(data, forKey: svc.cacheKey(for: pubkeyA)) + XCTAssertNil(svc.cachedTier(for: pubkeyA)) + } + + func test_cachedTier_downgradesExpiredPremiumOnRead() { + // Saved with future expiry; read after expiry passes — should downgrade. + let defaults = makeDefaults() + let now1 = Date(timeIntervalSince1970: 1_700_000_000) + let expiry = Date(timeIntervalSince1970: 1_700_000_100) // 100s after now1 + let now2 = Date(timeIntervalSince1970: 1_700_000_200) // 100s after expiry + + let svc1 = makeService(defaults: defaults, now: { now1 }) + let ent = Entitlement(pubkey: pubkeyA, tier: .premium, maxAccounts: 10, maxClients: 30, + expiresAt: expiry.timeIntervalSince1970) + svc1.saveCache(ent, for: pubkeyA) + + let svc2 = makeService(defaults: defaults, now: { now2 }) + XCTAssertEqual(svc2.cachedTier(for: pubkeyA), .free, + "expired premium reads as free even when cache is fresh") + } + + func test_clearCache_removesEntry() { + let svc = makeService() + let ent = Entitlement(pubkey: pubkeyA, tier: .premium, maxAccounts: 10, maxClients: 30) + svc.saveCache(ent, for: pubkeyA) + XCTAssertEqual(svc.cachedTier(for: pubkeyA), .premium) + svc.clearCache(for: pubkeyA) + XCTAssertNil(svc.cachedTier(for: pubkeyA)) + } + + func test_cacheKey_isCaseInsensitive() { + let svc = makeService() + XCTAssertEqual(svc.cacheKey(for: pubkeyA), svc.cacheKey(for: pubkeyA.uppercased())) + } + + // ---------- effectiveTier composition ---------- + + func test_effectiveTier_anyPremiumPubkeyElevatesDevice() { + let svc = makeService() + svc.saveCache(Entitlement(pubkey: pubkeyA, tier: .free, maxAccounts: 4, maxClients: 5), for: pubkeyA) + svc.saveCache(Entitlement(pubkey: pubkeyB, tier: .premium, maxAccounts: 10, maxClients: 30), for: pubkeyB) + XCTAssertEqual(svc.effectiveTier(for: [pubkeyA, pubkeyB]), .premium) + } + + func test_effectiveTier_allFreeReturnsFree() { + let svc = makeService() + svc.saveCache(Entitlement(pubkey: pubkeyA, tier: .free, maxAccounts: 4, maxClients: 5), for: pubkeyA) + svc.saveCache(Entitlement(pubkey: pubkeyB, tier: .free, maxAccounts: 4, maxClients: 5), for: pubkeyB) + XCTAssertEqual(svc.effectiveTier(for: [pubkeyA, pubkeyB]), .free) + } + + func test_effectiveTier_emptyPubkeysReturnsFree() { + let svc = makeService() + XCTAssertEqual(svc.effectiveTier(for: []), .free) + } + + func test_effectiveTier_uncachedPubkeysIgnored() { + // Two pubkeys; only one has cache. Other contributes nothing. + let svc = makeService() + svc.saveCache(Entitlement(pubkey: pubkeyA, tier: .premium, maxAccounts: 10, maxClients: 30), for: pubkeyA) + XCTAssertEqual(svc.effectiveTier(for: [pubkeyA, pubkeyB]), .premium) + } + + // ---------- network refresh ---------- + + func test_refresh_storesEntitlementOnSuccess() async { + let defaults = makeDefaults() + let svc = makeService(defaults: defaults) + MockURLProtocol.requestHandler = { req in + let body = """ + {"pubkey":"\(self.pubkeyA)","tier":"premium","max_accounts":10,"max_clients":30,"granted_at":1714000000,"expires_at":null,"granted_by":"admin:cli"} + """.data(using: .utf8)! + return (Self.httpResponse(req, status: 200), body) + } + let result = await svc.refresh(pubkey: pubkeyA, signer: Self.constantSigner) + if case .ok(let ent) = result { + XCTAssertEqual(ent.tier, .premium) + XCTAssertEqual(svc.cachedTier(for: pubkeyA), .premium) + } else { + XCTFail("expected .ok, got \(result)") + } + } + + func test_refresh_passesXClaveAuthHeader() async { + let svc = makeService() + var capturedAuth: String? + MockURLProtocol.requestHandler = { req in + capturedAuth = req.value(forHTTPHeaderField: "X-Clave-Auth") + let body = """ + {"pubkey":"\(self.pubkeyA)","tier":"free","max_accounts":4,"max_clients":5} + """.data(using: .utf8)! + return (Self.httpResponse(req, status: 200), body) + } + _ = await svc.refresh(pubkey: pubkeyA, signer: { _, _ in "Nostr base64-mock" }) + XCTAssertEqual(capturedAuth, "Nostr base64-mock", "X-Clave-Auth header is set from signer return") + } + + func test_refresh_returnsHttpErrorOnNon200() async { + let svc = makeService() + MockURLProtocol.requestHandler = { req in + return (Self.httpResponse(req, status: 401), Data("{\"error\":\"bad sig\"}".utf8)) + } + let result = await svc.refresh(pubkey: pubkeyA, signer: Self.constantSigner) + if case .httpError(let status, _) = result { + XCTAssertEqual(status, 401) + } else { + XCTFail("expected .httpError, got \(result)") + } + XCTAssertNil(svc.cachedTier(for: pubkeyA), "cache untouched on failure") + } + + func test_refresh_returnsSignerErrorWhenSignerThrows() async { + let svc = makeService() + struct SignerError: Error {} + let result = await svc.refresh(pubkey: pubkeyA, signer: { _, _ in throw SignerError() }) + if case .signerError = result { /* ok */ } else { XCTFail("expected .signerError, got \(result)") } + } + + func test_refresh_returnsDecodeErrorOnGarbageJson() async { + let svc = makeService() + MockURLProtocol.requestHandler = { req in + return (Self.httpResponse(req, status: 200), Data("not json".utf8)) + } + let result = await svc.refresh(pubkey: pubkeyA, signer: Self.constantSigner) + if case .decodeError = result { /* ok */ } else { XCTFail("expected .decodeError, got \(result)") } + } + + func test_refresh_rejectsPubkeyMismatch() async { + // Server returns a different pubkey than we asked about — signal of + // misconfigured/malicious proxy. Reject without caching. + let svc = makeService() + MockURLProtocol.requestHandler = { req in + let body = """ + {"pubkey":"\(self.pubkeyB)","tier":"premium","max_accounts":10,"max_clients":30} + """.data(using: .utf8)! + return (Self.httpResponse(req, status: 200), body) + } + let result = await svc.refresh(pubkey: pubkeyA, signer: Self.constantSigner) + if case .decodeError(let msg) = result { + XCTAssertTrue(msg.contains("pubkey mismatch"), "got \(msg)") + } else { + XCTFail("expected .decodeError, got \(result)") + } + XCTAssertNil(svc.cachedTier(for: pubkeyA), "cache not poisoned") + } + + // ---------- helpers (static) ---------- + + static let constantSigner: EntitlementSigner = { _, _ in "Nostr mock-base64" } + + static func httpResponse(_ req: URLRequest, status: Int) -> HTTPURLResponse { + HTTPURLResponse(url: req.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil)! + } +} + +// ---------- URLSession mock ---------- + +/// `URLProtocol` subclass for stubbing `URLSession` responses in tests. +/// Tests set `MockURLProtocol.requestHandler` to control the response. +final class MockURLProtocol: URLProtocol, @unchecked Sendable { + nonisolated(unsafe) static var requestHandler: ((URLRequest) -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + guard let handler = MockURLProtocol.requestHandler else { + client?.urlProtocol(self, didFailWithError: NSError(domain: "MockURLProtocol", code: -1)) + return + } + let (response, data) = handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} diff --git a/ClaveTests/LogExporterFormattingTests.swift b/ClaveTests/LogExporterFormattingTests.swift index a2b44f8..bfc01d0 100644 --- a/ClaveTests/LogExporterFormattingTests.swift +++ b/ClaveTests/LogExporterFormattingTests.swift @@ -58,7 +58,8 @@ final class LogExporterFormattingTests: XCTestCase { // by main-app OSLogStore; intentionally excluded. let expected: Set = [ "relay", "signer", "storage", "apns", "app", - "fg-sub", "banner", "nc-sweep", "pair" + "fg-sub", "banner", "nc-sweep", "pair", + "entitlement" ] let actual = Set(LogExporter.allCategories) XCTAssertEqual(actual, expected, diff --git a/Shared/Entitlement.swift b/Shared/Entitlement.swift new file mode 100644 index 0000000..7a3446f --- /dev/null +++ b/Shared/Entitlement.swift @@ -0,0 +1,61 @@ +import Foundation + +/// User's entitlement tier. Default for any pubkey without a server record. +enum Tier: String, Codable, Sendable { + case free + case premium +} + +/// Server-side entitlement record returned by `GET /entitlement?pubkey=`. +/// +/// Phase 1 only writes `expiresAt: nil` (lifetime). Phase 2 may use timestamps +/// for time-bounded grants — the `effectiveTier` accessor downgrades expired +/// premium to free transparently, so call sites don't have to special-case. +/// +/// Forward-compat: the JSON decoder ignores unknown keys by default. Phase 2 +/// schema additions on the proxy side won't break older iOS clients. +struct Entitlement: Codable, Sendable, Equatable { + let pubkey: String + let tier: Tier + let maxAccounts: Int + let maxClients: Int + let grantedAt: TimeInterval? + let expiresAt: TimeInterval? + let grantedBy: String? + + enum CodingKeys: String, CodingKey { + case pubkey + case tier + case maxAccounts = "max_accounts" + case maxClients = "max_clients" + case grantedAt = "granted_at" + case expiresAt = "expires_at" + case grantedBy = "granted_by" + } + + init( + pubkey: String, + tier: Tier, + maxAccounts: Int, + maxClients: Int, + grantedAt: TimeInterval? = nil, + expiresAt: TimeInterval? = nil, + grantedBy: String? = nil + ) { + self.pubkey = pubkey + self.tier = tier + self.maxAccounts = maxAccounts + self.maxClients = maxClients + self.grantedAt = grantedAt + self.expiresAt = expiresAt + self.grantedBy = grantedBy + } + + /// Effective tier accounting for expiry. Expired premium reads as free + /// without mutating the stored record. `now` is injectable for tests. + func effectiveTier(now: Date = Date()) -> Tier { + guard let exp = expiresAt else { return tier } + if exp <= now.timeIntervalSince1970 { return .free } + return tier + } +} diff --git a/Shared/EntitlementService.swift b/Shared/EntitlementService.swift new file mode 100644 index 0000000..1775458 --- /dev/null +++ b/Shared/EntitlementService.swift @@ -0,0 +1,221 @@ +import Foundation +import OSLog + +/// Async signer closure: produces the `X-Clave-Auth` header value for a +/// NIP-98-authed GET to `url`, signed by the nsec for `signerPubkey`. +/// +/// `EntitlementService` deliberately doesn't touch the Keychain — the caller +/// (typically AppState) owns nsec access and provides this closure. Keeps the +/// service test-friendly (the closure is mocked) and avoids tight coupling +/// between entitlement queries and the multi-account key-load path. +typealias EntitlementSigner = @Sendable (_ signerPubkey: String, _ url: URL) async throws -> String + +/// Outcome of `refresh(pubkey:)`. Surfaced for diagnostics; callers usually +/// just rely on the cache being updated as a side effect. +enum EntitlementRefreshResult: Sendable { + case ok(Entitlement) + case httpError(status: Int, body: String) + case decodeError(String) + case signerError(String) + case transportError(String) +} + +/// Thin client over `GET /entitlement?pubkey=` plus a 24h app-group cache +/// readable from both main app and the NSE. +/// +/// Design notes: +/// +/// - The cache uses `SharedConstants.sharedDefaults` (app-group UserDefaults) +/// keyed `entitlementCache.`. NSE reads via `cachedTier(for:)` — +/// no network is needed during background-launched signing. +/// +/// - Network failures fall back to last-known cache (premium users on a plane +/// keep their elevated caps). Cache TTL is 24h; once stale and offline, we +/// degrade to the .free default. +/// +/// - The class is intentionally not `@MainActor`. UserDefaults is documented +/// as thread-safe; URLSession is concurrent. NSE runs off the main actor. +/// +/// - JSON `tier` values (`"free"`, `"premium"`) match `Tier.RawValue` +/// verbatim. JSON snake_case is mapped via explicit `CodingKeys` in +/// `Entitlement` (preferred over `.convertFromSnakeCase` for explicitness). +final class EntitlementService: @unchecked Sendable { + + // Bumped whenever the cache schema changes — invalidates stale entries. + static let cacheVersion = 1 + + static let defaultCacheTTL: TimeInterval = 24 * 60 * 60 + + private let logger = Logger(subsystem: "dev.nostr.clave", category: "entitlement") + private let proxyURL: URL + private let session: URLSession + private let userDefaults: UserDefaults + private let cacheTTL: TimeInterval + private let now: @Sendable () -> Date + + init( + proxyURL: URL, + session: URLSession = .shared, + userDefaults: UserDefaults = SharedConstants.sharedDefaults, + cacheTTL: TimeInterval = EntitlementService.defaultCacheTTL, + now: @Sendable @escaping () -> Date = { Date() } + ) { + self.proxyURL = proxyURL + self.session = session + self.userDefaults = userDefaults + self.cacheTTL = cacheTTL + self.now = now + } + + // MARK: - reads (sync, NSE-safe) + + /// Cached effective tier for one pubkey. `nil` if no cache entry exists or + /// the entry is expired beyond `cacheTTL`. Expired-grant downgrade is + /// handled by `Entitlement.effectiveTier` so we don't return stale premium. + func cachedTier(for pubkey: String) -> Tier? { + guard let cached = loadCache(for: pubkey) else { return nil } + let age = now().timeIntervalSince1970 - cached.cachedAt + if age > cacheTTL { return nil } + return cached.entitlement.effectiveTier(now: now()) + } + + /// Highest tier across the supplied pubkeys. The "billing npub" model: + /// any one premium pubkey on the device elevates the device's effective + /// tier. Returns `.free` if no cached entry is currently valid. + func effectiveTier(for pubkeys: [String]) -> Tier { + for pubkey in pubkeys { + if cachedTier(for: pubkey) == .premium { + return .premium + } + } + return .free + } + + // MARK: - writes (async) + + /// Refresh entitlement for every pubkey in parallel. Failures for one + /// pubkey don't block others; the existing cache entry (if any) is kept + /// untouched so we degrade gracefully on transient network errors. + func refreshAll(pubkeys: [String], signer: @escaping EntitlementSigner) async { + await withTaskGroup(of: Void.self) { group in + for pubkey in pubkeys { + group.addTask { [self] in + let result = await refresh(pubkey: pubkey, signer: signer) + switch result { + case .ok(let ent): + logger.info("refresh ok pubkey=\(pubkey.prefix(8)) tier=\(ent.tier.rawValue)") + case .httpError(let status, let body): + logger.warning("refresh http pubkey=\(pubkey.prefix(8)) status=\(status) body=\(body)") + case .decodeError(let msg): + logger.error("refresh decode pubkey=\(pubkey.prefix(8)) err=\(msg)") + case .signerError(let msg): + logger.warning("refresh signer pubkey=\(pubkey.prefix(8)) err=\(msg)") + case .transportError(let msg): + logger.warning("refresh transport pubkey=\(pubkey.prefix(8)) err=\(msg)") + } + } + } + } + } + + /// Refresh a single pubkey. Public for callers that want to refresh a + /// just-added account immediately rather than waiting for the next + /// `refreshAll` cycle. + func refresh(pubkey: String, signer: @escaping EntitlementSigner) async -> EntitlementRefreshResult { + var components = URLComponents(url: proxyURL.appendingPathComponent("entitlement"), resolvingAgainstBaseURL: false) + components?.queryItems = [URLQueryItem(name: "pubkey", value: pubkey)] + guard let url = components?.url else { + return .transportError("invalid_url") + } + + let authHeader: String + do { + authHeader = try await signer(pubkey, url) + } catch { + return .signerError(String(describing: error)) + } + + var req = URLRequest(url: url) + req.httpMethod = "GET" + // iOS URLSession silently strips `Authorization` — use the project's + // X-Clave-Auth convention (see Gotcha #1 in OVERVIEW.md). + req.setValue(authHeader, forHTTPHeaderField: "X-Clave-Auth") + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: req) + } catch { + return .transportError(String(describing: error)) + } + + guard let http = response as? HTTPURLResponse else { + return .transportError("non-http response") + } + if http.statusCode != 200 { + let body = String(data: data, encoding: .utf8) ?? "<\(data.count) bytes>" + return .httpError(status: http.statusCode, body: body) + } + + let decoded: Entitlement + do { + decoded = try JSONDecoder().decode(Entitlement.self, from: data) + } catch { + return .decodeError(String(describing: error)) + } + + // Sanity check: server-returned pubkey must match what we asked for. + // Defends against a misconfigured/malicious proxy returning someone + // else's tier under our cached key. + guard decoded.pubkey.lowercased() == pubkey.lowercased() else { + return .decodeError("pubkey mismatch: requested=\(pubkey.prefix(8)) returned=\(decoded.pubkey.prefix(8))") + } + + saveCache(decoded, for: pubkey) + return .ok(decoded) + } + + /// Drop the cached entry for one pubkey (e.g. after `deleteAccount`). + func clearCache(for pubkey: String) { + userDefaults.removeObject(forKey: cacheKey(for: pubkey)) + } + + /// Drop all cached entries. Dev-menu / sign-out path. + func clearAllCache(pubkeys: [String]) { + for pubkey in pubkeys { + clearCache(for: pubkey) + } + } + + // MARK: - cache layer + + /// Wraps a stored `Entitlement` with the timestamp it was fetched at, so + /// we can age it independently of any `expires_at` the server returned. + /// `version` lets us invalidate stale entries if we change the schema. + struct CachedEntitlement: Codable { + let version: Int + let entitlement: Entitlement + let cachedAt: TimeInterval + } + + func loadCache(for pubkey: String) -> CachedEntitlement? { + guard let data = userDefaults.data(forKey: cacheKey(for: pubkey)) else { return nil } + guard let decoded = try? JSONDecoder().decode(CachedEntitlement.self, from: data) else { return nil } + if decoded.version != Self.cacheVersion { return nil } + return decoded + } + + func saveCache(_ entitlement: Entitlement, for pubkey: String) { + let cached = CachedEntitlement( + version: Self.cacheVersion, + entitlement: entitlement, + cachedAt: now().timeIntervalSince1970 + ) + guard let data = try? JSONEncoder().encode(cached) else { return } + userDefaults.set(data, forKey: cacheKey(for: pubkey)) + } + + func cacheKey(for pubkey: String) -> String { + "entitlementCache.\(pubkey.lowercased())" + } +} diff --git a/Shared/LogExporter.swift b/Shared/LogExporter.swift index 695a626..bbee4dd 100644 --- a/Shared/LogExporter.swift +++ b/Shared/LogExporter.swift @@ -18,7 +18,8 @@ enum LogExporter { /// are silently filtered out of "Copy Recent Logs" — the user becomes blind /// to that code path's activity. static let allCategories: [String] = [ - "relay", "signer", "storage", "apns", "app", "fg-sub", "banner", "nc-sweep", "pair" + "relay", "signer", "storage", "apns", "app", "fg-sub", "banner", "nc-sweep", "pair", + "entitlement" ] /// Fetch main-app logs from the unified log store within the given time window.