@tinkerlandai/indiana-react-native 0.9.2 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/IndianaReactNative.podspec +19 -0
- package/README.md +62 -5
- package/THIRD_PARTY_NOTICES.md +74 -0
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgeModule.kt +237 -0
- package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgePackage.kt +11 -0
- package/dist/index.cjs +17 -1
- package/dist/index.d.cts +96889 -6007
- package/dist/index.d.ts +96889 -6007
- package/dist/index.js +17 -1
- package/ios/IndianaBridge.m +11 -0
- package/ios/IndianaBridge.swift +275 -0
- package/native-sdk/android/assets/indiana/index.html +268 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/BillingAuthorizationTerms.kt +55 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/BundledWebView.kt +90 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/ContentSession.kt +45 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/ContentView.kt +36 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerOperations.generated.kt +84 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerResponseSemantics.kt +197 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSemantics.kt +120 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSession.kt +106 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FeatureClient.kt +65 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelCommandStorage.kt +163 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelConfigurationRelay.kt +50 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelSession.kt +208 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelView.kt +71 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/GeneratedProtocol.kt +16 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/GoogleStorefront.kt +165 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/HTTPTransport.kt +43 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/IndianaClient.kt +526 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/MerchantCommerceSemantics.kt +38 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativePendingKeystore.kt +81 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativeReceiptRelay.kt +28 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativeStoreOffer.kt +24 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallContent.kt +75 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +144 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallView.kt +74 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/Protocol.kt +28 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PushInstallation.kt +170 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/RendererNavigation.kt +13 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +6 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/StoreSubscriptionManagement.kt +19 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +76 -0
- package/native-sdk/android/resources/funnel-wire-schemas.json +1 -0
- package/native-sdk/android/resources/indiana-third-party-notices.txt +101 -0
- package/native-sdk/android/resources/renderer-data-url.txt +1 -0
- package/native-sdk/ios/AnalyticsReceipt.swift +10 -0
- package/native-sdk/ios/AppleStorefront.swift +218 -0
- package/native-sdk/ios/BillingAuthorizationTerms.swift +57 -0
- package/native-sdk/ios/BundledWebView.swift +87 -0
- package/native-sdk/ios/ContentSession.swift +48 -0
- package/native-sdk/ios/ContentView.swift +51 -0
- package/native-sdk/ios/CustomerOperations.generated.swift +166 -0
- package/native-sdk/ios/CustomerResponseSemantics.swift +236 -0
- package/native-sdk/ios/CustomerSemantics.swift +145 -0
- package/native-sdk/ios/CustomerSession.swift +129 -0
- package/native-sdk/ios/FeatureClient.swift +86 -0
- package/native-sdk/ios/FunnelCommandStorage.swift +163 -0
- package/native-sdk/ios/FunnelConfigurationRelay.swift +48 -0
- package/native-sdk/ios/FunnelSession.swift +232 -0
- package/native-sdk/ios/FunnelView.swift +125 -0
- package/native-sdk/ios/GeneratedProtocol.swift +16 -0
- package/native-sdk/ios/HTTPTransport.swift +74 -0
- package/native-sdk/ios/IndianaClient.swift +601 -0
- package/native-sdk/ios/MerchantCommerceSemantics.swift +51 -0
- package/native-sdk/ios/NativePendingKeychain.swift +52 -0
- package/native-sdk/ios/NativeReceiptRelay.swift +28 -0
- package/native-sdk/ios/NativeStoreOffer.swift +34 -0
- package/native-sdk/ios/PaywallContent.swift +75 -0
- package/native-sdk/ios/PaywallSession.swift +124 -0
- package/native-sdk/ios/PaywallView.swift +87 -0
- package/native-sdk/ios/Protocol.swift +49 -0
- package/native-sdk/ios/PushInstallation.swift +154 -0
- package/native-sdk/ios/RendererNavigation.swift +8 -0
- package/native-sdk/ios/Resources.swift +15 -0
- package/native-sdk/ios/SDKVersion.swift +4 -0
- package/native-sdk/ios/StoreSubscriptionManagement.swift +11 -0
- package/native-sdk/ios/WebView/THIRD_PARTY_NOTICES.txt +101 -0
- package/native-sdk/ios/WebView/index.html +268 -0
- package/native-sdk/ios/WebView/renderer-data-url.txt +1 -0
- package/native-sdk/ios/WireSchema.swift +107 -0
- package/native-sdk/ios/funnel-wire-schemas.json +1 -0
- package/package.json +8 -3
- package/react-native.config.cjs +12 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public enum IndianaSubscriptionProvider: String, Sendable { case stripe, apple, google, merchant }
|
|
4
|
+
public struct IndianaSubscriptionPlan: Sendable {
|
|
5
|
+
public let productVersionId: UUID
|
|
6
|
+
public let seatQuantities: [String: Int]
|
|
7
|
+
public let featureQuantities: [UUID: Int]
|
|
8
|
+
public init(productVersionId: UUID, seatQuantities: [String: Int] = [:], featureQuantities: [UUID: Int] = [:]) {
|
|
9
|
+
self.productVersionId = productVersionId; self.seatQuantities = seatQuantities; self.featureQuantities = featureQuantities
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
extension IndianaClient {
|
|
14
|
+
/// Enables Push on this client. OS permission requests remain under the host App's control.
|
|
15
|
+
public func enablePush() throws {
|
|
16
|
+
try enablePush(storage: IndianaPushKeychain(scope: configuration.scope))
|
|
17
|
+
}
|
|
18
|
+
func enablePush(storage: any IndianaPushJournalStorage) throws {
|
|
19
|
+
guard pushIdentity == nil else { return }
|
|
20
|
+
let installation = try IndianaPushIdentity(storage: storage)
|
|
21
|
+
if customerToken != nil { pushBindingSequence = try installation.reserve() }
|
|
22
|
+
pushIdentity = installation; schedulePush()
|
|
23
|
+
}
|
|
24
|
+
private func schedulePush() {
|
|
25
|
+
guard pushIdentity != nil else { return }
|
|
26
|
+
if case .protectedStorageUnavailable = pushSynchronizationError { return }
|
|
27
|
+
let original = epoch
|
|
28
|
+
Task {
|
|
29
|
+
do { _ = try await syncPush() }
|
|
30
|
+
catch {
|
|
31
|
+
if epoch == original { pushSynchronizationError = (error as? IndianaClientError) ?? .temporaryUnavailable }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
@discardableResult public func syncPush() async throws -> IndianaPushInstallationState? {
|
|
36
|
+
guard let installation = pushIdentity else { throw IndianaClientError.invalidConfiguration }
|
|
37
|
+
if case .protectedStorageUnavailable = pushSynchronizationError { throw IndianaClientError.protectedStorageUnavailable }
|
|
38
|
+
let original = epoch, token = customerToken, binding = pushBindingSequence
|
|
39
|
+
let state = try installation.state()
|
|
40
|
+
var missingDetach = false
|
|
41
|
+
if let sequence = state.detachSeq {
|
|
42
|
+
let command = try installation.command("detach", scope: configuration.scope, sequence: sequence)
|
|
43
|
+
do {
|
|
44
|
+
let bytes = try await http.send(path: "push/installations/detach", method: "POST", authorization: "", body: command)
|
|
45
|
+
let acknowledged = try decodePush(bytes, installation: state.installationId)
|
|
46
|
+
guard !acknowledged.bound, acknowledged.identitySeq == sequence else { throw IndianaClientError.invalidResponse }
|
|
47
|
+
} catch IndianaClientError.requestFailed(404) { missingDetach = true }
|
|
48
|
+
catch IndianaClientError.requestFailed(412) { /* fenced old logout */ }
|
|
49
|
+
if !missingDetach { try installation.clearDetach(sequence) }
|
|
50
|
+
}
|
|
51
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }
|
|
52
|
+
guard let token else { if missingDetach { throw IndianaClientError.requestFailed(404) }; pushSynchronizationError = nil; return nil }
|
|
53
|
+
let proofCredential = try await preparedCustomerToken()
|
|
54
|
+
let command = try installation.command("bind", scope: configuration.scope, sequence: binding, credential: proofCredential)
|
|
55
|
+
let response = try await authenticated(path: "push/installations/bind", body: command, credential: proofCredential)
|
|
56
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }
|
|
57
|
+
let result = try decodePush(response, installation: state.installationId)
|
|
58
|
+
guard result.bound, result.identitySeq == binding else { throw IndianaClientError.invalidResponse }
|
|
59
|
+
if missingDetach, let sequence = state.detachSeq, binding > sequence { try installation.clearDetach(sequence) }
|
|
60
|
+
pushSynchronizationError = nil; return result
|
|
61
|
+
}
|
|
62
|
+
public func readPushInstallation() async throws -> IndianaPushInstallationState {
|
|
63
|
+
guard let installation = pushIdentity, let token = customerToken else { throw IndianaClientError.unauthenticated }
|
|
64
|
+
let proofCredential = try await preparedCustomerToken()
|
|
65
|
+
let command = try installation.command("read", scope: configuration.scope, credential: proofCredential)
|
|
66
|
+
let bytes = try await authenticated(path: "push/installations/read", body: command, credential: proofCredential)
|
|
67
|
+
return try decodePush(bytes, installation: installation.state().installationId)
|
|
68
|
+
}
|
|
69
|
+
public func capturePushObservation() throws -> IndianaPushObservationTicket {
|
|
70
|
+
guard pushIdentity != nil else { throw IndianaClientError.invalidConfiguration }
|
|
71
|
+
pushObservationID = UUID()
|
|
72
|
+
return IndianaPushObservationTicket(client: pushClientID, identity: epoch, observation: pushObservationID)
|
|
73
|
+
}
|
|
74
|
+
@discardableResult public func recoverPushCounters() async throws -> IndianaPushInstallationState? {
|
|
75
|
+
guard let installation = pushIdentity else { throw IndianaClientError.invalidConfiguration }
|
|
76
|
+
let original = epoch, expected = pushBindingSequence
|
|
77
|
+
pushObservationID = UUID()
|
|
78
|
+
let remote = try await readPushInstallation()
|
|
79
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }
|
|
80
|
+
pushBindingSequence = try installation.recover(remote: remote, expected: expected)
|
|
81
|
+
return try await syncPush()
|
|
82
|
+
}
|
|
83
|
+
@discardableResult public func observePush(_ observation: IndianaPushObservation, ticket: IndianaPushObservationTicket) async throws -> IndianaPushInstallationState? {
|
|
84
|
+
guard ticket.client == pushClientID, ticket.identity == epoch, ticket.observation == pushObservationID else { return nil }
|
|
85
|
+
guard let installation = pushIdentity, let token = customerToken else { throw IndianaClientError.unauthenticated }
|
|
86
|
+
_ = try await syncPush()
|
|
87
|
+
guard ticket.identity == epoch, ticket.observation == pushObservationID else { return nil }
|
|
88
|
+
let sequence = try installation.reserveAddress()
|
|
89
|
+
let proofCredential = try await preparedCustomerToken()
|
|
90
|
+
let command = try installation.command("address", scope: configuration.scope, sequence: sequence, credential: proofCredential, observation: observation)
|
|
91
|
+
let bytes = try await authenticated(path: "push/installations/address", body: command, credential: proofCredential)
|
|
92
|
+
guard ticket.identity == epoch else { throw IndianaClientError.identityChanged }
|
|
93
|
+
return try decodePush(bytes, installation: installation.state().installationId)
|
|
94
|
+
}
|
|
95
|
+
public func readPushPreference() async throws -> Data { try await authenticated(path: "messaging/preferences/read", body: Data(#"{"channel":"push"}"#.utf8)) }
|
|
96
|
+
/// Explicitly requests one security notification to prove control of a conflicting provider token.
|
|
97
|
+
/// The response contains only an expiry; the challenge is delivered by the native notification callback.
|
|
98
|
+
public func requestPushAddressRecovery(_ observation: IndianaPushObservation, ticket: IndianaPushObservationTicket) async throws -> IndianaPushRecoveryRequest {
|
|
99
|
+
let (installation, token) = try recoveryPushIdentity(observation, ticket: ticket)
|
|
100
|
+
_ = try await syncPush()
|
|
101
|
+
_ = try recoveryPushIdentity(observation, ticket: ticket)
|
|
102
|
+
let sequence = try installation.reserveAddress()
|
|
103
|
+
let proofCredential = try await preparedCustomerToken()
|
|
104
|
+
let body = try installation.command("recovery_request", scope: configuration.scope, sequence: sequence, credential: proofCredential, observation: observation)
|
|
105
|
+
let bytes = try await authenticated(path: "push/installations/recovery/request", body: body, credential: proofCredential)
|
|
106
|
+
_ = try recoveryPushIdentity(observation, ticket: ticket)
|
|
107
|
+
struct Reply: Decodable { let scope: IndianaScope; let recovery: IndianaPushRecoveryRequest }
|
|
108
|
+
guard bytes.count <= 32768, let reply = try? JSONDecoder().decode(Reply.self, from: bytes), reply.scope == configuration.scope,
|
|
109
|
+
reply.recovery.requested else { throw IndianaClientError.invalidResponse }
|
|
110
|
+
let formatter = ISO8601DateFormatter(); formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
111
|
+
guard formatter.date(from: reply.recovery.expiresAt) != nil || ISO8601DateFormatter().date(from: reply.recovery.expiresAt) != nil else { throw IndianaClientError.invalidResponse }
|
|
112
|
+
return reply.recovery
|
|
113
|
+
}
|
|
114
|
+
/// Supply the opaque indiana_push_recovery value received by the native notification callback.
|
|
115
|
+
/// Reacquire the current provider token under a fresh observation ticket before confirming.
|
|
116
|
+
@discardableResult public func confirmPushAddressRecovery(_ challenge: String, observation: IndianaPushObservation, ticket: IndianaPushObservationTicket) async throws -> IndianaPushInstallationState {
|
|
117
|
+
let (installation, token) = try recoveryPushIdentity(observation, ticket: ticket)
|
|
118
|
+
let state = try installation.state()
|
|
119
|
+
guard state.addressSeq > 0 else { throw IndianaClientError.invalidConfiguration }
|
|
120
|
+
let proofCredential = try await preparedCustomerToken()
|
|
121
|
+
let body = try installation.command("recovery_confirm", scope: configuration.scope, sequence: state.addressSeq, credential: proofCredential, observation: observation, challenge: challenge)
|
|
122
|
+
let bytes = try await authenticated(path: "push/installations/recovery/confirm", body: body, credential: proofCredential)
|
|
123
|
+
_ = try recoveryPushIdentity(observation, ticket: ticket)
|
|
124
|
+
let result = try decodePush(bytes, installation: state.installationId)
|
|
125
|
+
guard try installation.state().addressSeq == state.addressSeq else { throw IndianaClientError.identityChanged }
|
|
126
|
+
guard result.bound, result.identitySeq == pushBindingSequence, result.addressSeq == state.addressSeq, result.connectionId == observation.connectionId, result.addressStatus == "active" else { throw IndianaClientError.invalidResponse }
|
|
127
|
+
return result
|
|
128
|
+
}
|
|
129
|
+
private func recoveryPushIdentity(_ observation: IndianaPushObservation, ticket: IndianaPushObservationTicket) throws -> (IndianaPushIdentity, String) {
|
|
130
|
+
guard ticket.client == pushClientID, ticket.identity == epoch, ticket.observation == pushObservationID else { throw IndianaClientError.identityChanged }
|
|
131
|
+
guard let installation = pushIdentity, let token = customerToken else { throw IndianaClientError.unauthenticated }
|
|
132
|
+
guard observation.connectionId != nil, observation.kind != nil, observation.token != nil,
|
|
133
|
+
observation.permission == .granted || observation.permission == .provisional else { throw IndianaClientError.invalidConfiguration }
|
|
134
|
+
return (installation, token)
|
|
135
|
+
}
|
|
136
|
+
/// Retain requestId when retrying after an uncertain response.
|
|
137
|
+
public func setPushPreference(allowed: Bool, revision: Int64, requestId: UUID = UUID()) async throws -> Data {
|
|
138
|
+
guard (0..<IndianaPushIdentity.maxSequence).contains(revision) else { throw IndianaClientError.invalidConfiguration }
|
|
139
|
+
let body = try JSONSerialization.data(withJSONObject: ["requestId": requestId.uuidString.lowercased(), "channel": "push", "state":allowed ? "allowed" : "denied", "expectedRevision":revision])
|
|
140
|
+
return try await authenticated(path: "messaging/preferences/set", body: body)
|
|
141
|
+
}
|
|
142
|
+
private func decodePush(_ bytes: Data, installation: UUID) throws -> IndianaPushInstallationState {
|
|
143
|
+
struct Reply: Decodable { let scope: IndianaScope; let installation: IndianaPushInstallationState }
|
|
144
|
+
guard bytes.count <= 32768, let reply = try? JSONDecoder().decode(Reply.self, from: bytes), reply.scope == configuration.scope else { throw IndianaClientError.invalidResponse }
|
|
145
|
+
try reply.installation.validate(installation: installation); return reply.installation
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/// Current customer session; credentials are memory-only.
|
|
149
|
+
public actor IndianaClient {
|
|
150
|
+
private let configuration: IndianaConfiguration
|
|
151
|
+
private let http: IndianaSessionTransport
|
|
152
|
+
private var publicPaywallContent: (@Sendable (IndianaJSON) async throws -> IndianaJSON)?
|
|
153
|
+
private var customerToken: String?
|
|
154
|
+
private var analyticsEvents: [[String: IndianaJSON]] = []
|
|
155
|
+
private var analyticsFlushing = false
|
|
156
|
+
private var analyticsTimer: Task<Void, Never>?
|
|
157
|
+
private var epoch = UUID()
|
|
158
|
+
private var credentialRevision: UInt64 = 0
|
|
159
|
+
private var pushIdentity: IndianaPushIdentity?
|
|
160
|
+
private var pushBindingSequence: Int64 = 0
|
|
161
|
+
private let pushClientID = UUID()
|
|
162
|
+
private var pushObservationID = UUID()
|
|
163
|
+
public private(set) var pushSynchronizationError: IndianaClientError?
|
|
164
|
+
private let nativeStore: any IndianaAppleStore
|
|
165
|
+
private let nativeStorage: any IndianaNativePendingStorage
|
|
166
|
+
private let durableFunnels: Bool
|
|
167
|
+
private var storeRestorePending: [String: (UUID, Data)] = [:]
|
|
168
|
+
private var recoveringNative = false
|
|
169
|
+
private var nativeReceiptPending: [UUID: (UUID, Data)] = [:]
|
|
170
|
+
private struct WeakFunnel { weak var value: IndianaFunnelSession? }
|
|
171
|
+
private var funnels: [WeakFunnel] = []
|
|
172
|
+
private struct WeakPaywall { weak var value: IndianaPaywallSession? }
|
|
173
|
+
private var paywalls: [WeakPaywall] = []
|
|
174
|
+
private struct WeakContent { weak var value: IndianaContentSession? }
|
|
175
|
+
private var contents: [WeakContent] = []
|
|
176
|
+
#if canImport(UIKit)
|
|
177
|
+
private struct WeakFeature { weak var value: IndianaFeatureClient? }
|
|
178
|
+
private var featuresClients: [WeakFeature] = []
|
|
179
|
+
#endif
|
|
180
|
+
public init(configuration: IndianaConfiguration, sessionConfiguration: URLSessionConfiguration = .ephemeral) { self.configuration = configuration; self.http = IndianaSessionTransport(IndianaURLTransport(configuration: configuration, sessionConfiguration: sessionConfiguration), scope: configuration.scope); self.nativeStore = AppleStorefront(); self.nativeStorage = IndianaNativePendingKeychain(scope: configuration.scope); self.durableFunnels = true }
|
|
181
|
+
init(configuration: IndianaConfiguration, http: any IndianaHTTP, store: (any IndianaAppleStore)? = nil, nativeStorage: (any IndianaNativePendingStorage)? = nil, publicPaywallContent: (@Sendable (IndianaJSON) async throws -> IndianaJSON)? = nil, sessionNow: @escaping @Sendable () -> Date = { Date() }) { self.publicPaywallContent = publicPaywallContent; self.configuration = configuration; self.http = IndianaSessionTransport(http, scope: configuration.scope, now: sessionNow); self.nativeStore = store ?? AppleStorefront(); self.nativeStorage = nativeStorage ?? IndianaNativePendingKeychain(scope: configuration.scope); self.durableFunnels = false }
|
|
182
|
+
public func identify(customerToken token: String) async throws {
|
|
183
|
+
guard token.range(of: "^gg_ct_[A-Za-z0-9_-]{32,128}$", options: .regularExpression) != nil else { throw IndianaClientError.unauthenticated }
|
|
184
|
+
try await installIdentity(token)
|
|
185
|
+
}
|
|
186
|
+
private func installIdentity(_ token: String, session: IndianaCustomerSession? = nil) async throws {
|
|
187
|
+
analyticsTimer?.cancel(); analyticsTimer = nil; analyticsEvents = []
|
|
188
|
+
if let pushIdentity { pushBindingSequence = try pushIdentity.reserve(); pushSynchronizationError = nil }
|
|
189
|
+
epoch = UUID(); pushObservationID = UUID(); customerToken = token; nativeReceiptPending = [:]; storeRestorePending = [:]
|
|
190
|
+
let previous = takeViews()
|
|
191
|
+
credentialRevision += 1; let revision = credentialRevision, original = epoch
|
|
192
|
+
let invalidated: @Sendable () async -> Void = { [weak self] in await self?.invalidateSession(revision: revision) }
|
|
193
|
+
if let session { try await http.identify(session, revision: revision, onInvalidated: invalidated) } else { await http.identify(token, revision: revision, onInvalidated: invalidated) }
|
|
194
|
+
if original == epoch { schedulePush() }
|
|
195
|
+
await closeViews(previous)
|
|
196
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
public func identify(session: IndianaCustomerSession) async throws {
|
|
200
|
+
try session.validate()
|
|
201
|
+
try await installIdentity(session.accessToken, session: session)
|
|
202
|
+
}
|
|
203
|
+
@discardableResult public func refreshSession() async throws -> IndianaCustomerSession { try await http.refresh() }
|
|
204
|
+
/// A host with its own credential owner can supply current Access Tokens without sharing Refresh Tokens.
|
|
205
|
+
public func setAccessTokenProvider(_ provider: (@Sendable () async throws -> String)?) async { await http.setAccessTokenProvider(provider) }
|
|
206
|
+
public func funnel(runToken: String, commandStorage: (any IndianaFunnelCommandStorage)? = nil) async throws -> IndianaFunnelSession {
|
|
207
|
+
let original = epoch
|
|
208
|
+
let storage = try commandStorage ?? (durableFunnels ? IndianaFunnelCommandKeychain(configuration: configuration, runToken: runToken) : nil)
|
|
209
|
+
let session = try IndianaFunnelSession(configuration: configuration, http: http, runToken: runToken, commandStorage: storage)
|
|
210
|
+
if let customerToken { try await session.identify(customerToken: customerToken) }
|
|
211
|
+
guard original == epoch else { await session.reset(); throw IndianaClientError.identityChanged }
|
|
212
|
+
funnels.removeAll { $0.value == nil }; funnels.append(WeakFunnel(value: session)); return session
|
|
213
|
+
}
|
|
214
|
+
public func paywall(paywallId: UUID? = nil, paywallVersionId: UUID? = nil, provider: IndianaSubscriptionProvider, currency: String, locale: String = "en") throws -> IndianaPaywallSession {
|
|
215
|
+
guard let token = customerToken else { throw IndianaClientError.unauthenticated }
|
|
216
|
+
let session = try IndianaPaywallSession(configuration: configuration, http: http, customerToken: token, paywallId: paywallId, versionId: paywallVersionId, provider: provider, currency: currency, locale: locale, content: publicPaywallContent)
|
|
217
|
+
paywalls.removeAll { $0.value == nil }; paywalls.append(WeakPaywall(value: session)); return session
|
|
218
|
+
}
|
|
219
|
+
#if canImport(UIKit)
|
|
220
|
+
public func features(attributes: [String: IndianaJSON] = [:]) async throws -> IndianaFeatureClient {
|
|
221
|
+
let original = epoch, configuration = configuration
|
|
222
|
+
guard customerToken != nil else { throw IndianaClientError.unauthenticated }
|
|
223
|
+
let client = await MainActor.run { IndianaFeatureClient(configuration: configuration, attributes: attributes) { [self] operation, input in try await customerOperation(operation, input: input) } }
|
|
224
|
+
guard original == epoch else { await client.close(); throw IndianaClientError.identityChanged }
|
|
225
|
+
featuresClients.removeAll { $0.value == nil }; featuresClients.append(WeakFeature(value: client)); return client
|
|
226
|
+
}
|
|
227
|
+
#endif
|
|
228
|
+
/// Persist requestId and the exact input before retrying an uncertain start.
|
|
229
|
+
public func startFunnel(_ input: [String: IndianaJSON], commandStorage: (any IndianaFunnelCommandStorage)? = nil) async throws -> IndianaFunnelSession {
|
|
230
|
+
guard input["customer"] == nil, input["customerData"] == nil, input["customerId"] == nil,
|
|
231
|
+
input["operation"] == nil || input["operation"]?.string == "start", let token = customerToken else { throw IndianaClientError.invalidConfiguration }
|
|
232
|
+
var command = input; command["operation"] = .string("start")
|
|
233
|
+
let encoder = JSONEncoder(); encoder.outputFormatting = [.sortedKeys]
|
|
234
|
+
let body = try encoder.encode(IndianaJSON.object(command)); try IndianaSchema.validate("command", data: body)
|
|
235
|
+
let original = epoch
|
|
236
|
+
let data = try await http.send(path: "funnels", method: "POST", authorization: token, body: body)
|
|
237
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }; try IndianaSchema.validate("response", data: data)
|
|
238
|
+
let result = try JSONDecoder().decode(IndianaJSON.self, from: data)
|
|
239
|
+
guard let value = result.object, let runToken = value["runToken"]?.string, value["customerId"] == nil,
|
|
240
|
+
value["scope"]?.object?["projectId"]?.string == configuration.scope.projectId.uuidString.lowercased(), value["scope"]?.object?["environment"]?.string == configuration.scope.environment.rawValue,
|
|
241
|
+
value["run"]?.object?["source"]?.object?["id"] == command["funnelId"], value["run"]?.object?["locale"] == command["locale"], value["run"]?.object?["parameters"] == command["parameters"] else { throw IndianaClientError.invalidResponse }
|
|
242
|
+
return try await funnel(runToken: runToken, commandStorage: commandStorage)
|
|
243
|
+
}
|
|
244
|
+
public func track(_ name: String, properties: [String: IndianaJSON] = [:], eventID: UUID = UUID(), timestamp: Date = Date()) async throws {
|
|
245
|
+
guard customerToken != nil, analyticsEvents.count < 1000 else { throw IndianaClientError.unauthenticated }
|
|
246
|
+
let formatter = ISO8601DateFormatter(); formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
247
|
+
let event: [String: IndianaJSON] = ["eventUuid": .string(eventID.uuidString.lowercased()), "timestamp": .string(formatter.string(from: timestamp)), "name": .string(name), "properties": .object(properties)]
|
|
248
|
+
let body = try JSONEncoder().encode(IndianaJSON.object(["events": .array([.object(event)])]))
|
|
249
|
+
guard body.count <= 60_000 else { throw IndianaClientError.invalidConfiguration }
|
|
250
|
+
try IndianaSchema.validate("customertrackEventsRequest", data: body); analyticsEvents.append(event)
|
|
251
|
+
if analyticsTimer == nil { analyticsTimer = Task { [weak self] in try? await Task.sleep(nanoseconds: 2_000_000_000); guard !Task.isCancelled else { return }; await self?.flushScheduledEvents() } }
|
|
252
|
+
if analyticsEvents.count >= 100 { _ = try await flush() }
|
|
253
|
+
}
|
|
254
|
+
private func flushScheduledEvents() async { analyticsTimer = nil; _ = try? await flush() }
|
|
255
|
+
@discardableResult public func flush() async throws -> IndianaAnalyticsReceipt {
|
|
256
|
+
guard customerToken != nil else { throw IndianaClientError.unauthenticated }
|
|
257
|
+
guard !analyticsFlushing else { throw IndianaClientError.temporaryUnavailable }
|
|
258
|
+
analyticsTimer?.cancel(); analyticsTimer = nil; analyticsFlushing = true; defer { analyticsFlushing = false }
|
|
259
|
+
let original = epoch; var remaining = analyticsEvents.count; var total = IndianaAnalyticsReceipt()
|
|
260
|
+
while remaining > 0 {
|
|
261
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }
|
|
262
|
+
var batch: [[String: IndianaJSON]] = []
|
|
263
|
+
for event in analyticsEvents.prefix(min(remaining, 100)) {
|
|
264
|
+
let candidate = try JSONEncoder().encode(IndianaJSON.object(["events": .array((batch + [event]).map(IndianaJSON.object))]))
|
|
265
|
+
if !batch.isEmpty && candidate.count > 64_000 { break }; batch.append(event)
|
|
266
|
+
}
|
|
267
|
+
let result = try await customerOperation(.trackEvents, input: ["events": .array(batch.map(IndianaJSON.object))])
|
|
268
|
+
let receipt = try JSONDecoder().decode(IndianaAnalyticsReceipt.self, from: JSONEncoder().encode(result))
|
|
269
|
+
guard receipt.inserted + receipt.duplicates + receipt.rejected + receipt.skipped == batch.count else { throw IndianaClientError.invalidResponse }
|
|
270
|
+
analyticsEvents.removeFirst(batch.count); remaining -= batch.count; total.add(receipt)
|
|
271
|
+
}
|
|
272
|
+
return total
|
|
273
|
+
}
|
|
274
|
+
public func content(_ input: [String: IndianaJSON], locale: String = "en", inputs: [String: IndianaJSON] = [:]) async throws -> IndianaContentSession {
|
|
275
|
+
let original = epoch
|
|
276
|
+
let session = try IndianaContentSession(configuration: configuration, input: input, locale: locale, inputs: inputs) { [self] request in
|
|
277
|
+
try await customerOperation(.resolveContent, input: request)
|
|
278
|
+
}
|
|
279
|
+
_ = try await session.refresh()
|
|
280
|
+
guard original == epoch else { await session.close(); throw IndianaClientError.identityChanged }
|
|
281
|
+
contents.removeAll { $0.value == nil }; contents.append(WeakContent(value: session)); return session
|
|
282
|
+
}
|
|
283
|
+
private struct CustomerViews {
|
|
284
|
+
var funnels: [WeakFunnel] = []
|
|
285
|
+
var paywalls: [WeakPaywall] = []
|
|
286
|
+
var contents: [WeakContent] = []
|
|
287
|
+
#if canImport(UIKit)
|
|
288
|
+
var features: [WeakFeature] = []
|
|
289
|
+
#endif
|
|
290
|
+
}
|
|
291
|
+
private func takeViews() -> CustomerViews {
|
|
292
|
+
var previous = CustomerViews()
|
|
293
|
+
previous.funnels = funnels; funnels = []
|
|
294
|
+
previous.paywalls = paywalls; paywalls = []
|
|
295
|
+
previous.contents = contents; contents = []
|
|
296
|
+
#if canImport(UIKit)
|
|
297
|
+
previous.features = featuresClients; featuresClients = []
|
|
298
|
+
#endif
|
|
299
|
+
return previous
|
|
300
|
+
}
|
|
301
|
+
private func clearIdentity() -> CustomerViews {
|
|
302
|
+
analyticsTimer?.cancel(); analyticsTimer = nil; analyticsEvents = []
|
|
303
|
+
do { if let pushIdentity { _ = try pushIdentity.reserve(detach: true) }; pushSynchronizationError = nil }
|
|
304
|
+
catch { pushSynchronizationError = .protectedStorageUnavailable }
|
|
305
|
+
epoch = UUID(); pushObservationID = UUID(); customerToken = nil; pushBindingSequence = 0; nativeReceiptPending = [:]; storeRestorePending = [:]
|
|
306
|
+
return takeViews()
|
|
307
|
+
}
|
|
308
|
+
private func closeViews(_ previous: CustomerViews) async {
|
|
309
|
+
for entry in previous.funnels { if let flow = entry.value { await flow.reset() } }
|
|
310
|
+
for entry in previous.paywalls { if let wall = entry.value { await wall.close() } }
|
|
311
|
+
for entry in previous.contents { if let content = entry.value { await content.close() } }
|
|
312
|
+
#if canImport(UIKit)
|
|
313
|
+
for entry in previous.features { if let feature = entry.value { await feature.close() } }
|
|
314
|
+
#endif
|
|
315
|
+
}
|
|
316
|
+
private func invalidateSession(revision: UInt64) async {
|
|
317
|
+
guard revision == credentialRevision, customerToken != nil else { return }
|
|
318
|
+
let previous = clearIdentity()
|
|
319
|
+
schedulePush()
|
|
320
|
+
await closeViews(previous)
|
|
321
|
+
}
|
|
322
|
+
public func reset() async {
|
|
323
|
+
credentialRevision += 1; let revision = credentialRevision
|
|
324
|
+
let previous = clearIdentity()
|
|
325
|
+
await http.identify(nil, revision: revision)
|
|
326
|
+
schedulePush()
|
|
327
|
+
await closeViews(previous)
|
|
328
|
+
}
|
|
329
|
+
/// The relay runs on your backend and adds its API key. A Funnel run token is never used for order recovery.
|
|
330
|
+
public func recoverNativeOrders(using relay: @Sendable (Data) async throws -> Data, synchronizeStore: Bool = false) async throws -> [Data] {
|
|
331
|
+
guard let token = customerToken, configuration.scope.environment == .sandbox, !recoveringNative else { throw IndianaClientError.unauthenticated }
|
|
332
|
+
recoveringNative = true; defer { recoveringNative = false }; let original = epoch
|
|
333
|
+
let storage = nativeStorage
|
|
334
|
+
let records = try await storage.orders()
|
|
335
|
+
let scanned = synchronizeStore ? try await nativeStore.restore() : await nativeStore.scanUnfinished()
|
|
336
|
+
var responses: [Data] = []
|
|
337
|
+
for record in records {
|
|
338
|
+
let matching = scanned.filter { $0.productID == record.productId && $0.accountToken == record.accountToken }
|
|
339
|
+
let items: [ApplePurchaseEvidence?] = matching.isEmpty ? [nil] : matching.map { Optional($0) }
|
|
340
|
+
for item in items {
|
|
341
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }
|
|
342
|
+
let command: (UUID, Data)
|
|
343
|
+
if let pending = nativeReceiptPending[record.orderId] { command = pending }
|
|
344
|
+
else {
|
|
345
|
+
let requestId = UUID()
|
|
346
|
+
var proof: IndianaJSON = .null
|
|
347
|
+
if let item {
|
|
348
|
+
guard item.signedTransaction.utf8.count <= 131072 else { throw IndianaClientError.invalidResponse }
|
|
349
|
+
proof = .object(["provider":.string("apple"), "orderId":.string(record.orderId.uuidString.lowercased()), "receiptId":.string(record.receiptId.uuidString.lowercased()), "signedTransaction":.string(item.signedTransaction)])
|
|
350
|
+
}
|
|
351
|
+
let body: IndianaJSON = .object(["operation":.string("native_receipt"), "requestId":.string(requestId.uuidString.lowercased()), "customerToken":.string(token), "orderId":.string(record.orderId.uuidString.lowercased()), "receiptId":.string(record.receiptId.uuidString.lowercased()), "nativeEvidence":proof])
|
|
352
|
+
command = (requestId, try JSONEncoder().encode(body)); nativeReceiptPending[record.orderId] = command
|
|
353
|
+
}
|
|
354
|
+
let credential = try await preparedCustomerToken()
|
|
355
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }
|
|
356
|
+
var outgoing = try JSONSerialization.jsonObject(with: command.1) as! [String: Any]
|
|
357
|
+
outgoing["customerToken"] = credential
|
|
358
|
+
let payload = try JSONSerialization.data(withJSONObject: outgoing)
|
|
359
|
+
let bytes: Data
|
|
360
|
+
do { bytes = try await relay(payload) }
|
|
361
|
+
catch let error as IndianaClientError {
|
|
362
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }
|
|
363
|
+
if case .requestFailed(let status) = error, status < 500, status != 425 {
|
|
364
|
+
nativeReceiptPending.removeValue(forKey: record.orderId)
|
|
365
|
+
if status == 403 || status == 404 { continue }
|
|
366
|
+
}
|
|
367
|
+
throw error
|
|
368
|
+
}
|
|
369
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }
|
|
370
|
+
struct Reply: Decodable { let scope: IndianaScope; let requestId: UUID; let orderId: UUID; let receiptId: UUID; let orderStatus: String; let refunded: Bool; let nativeCompletion: IndianaNativeCompletion? }
|
|
371
|
+
guard bytes.count <= 262144, let reply = try? JSONDecoder().decode(Reply.self, from: bytes), reply.scope == configuration.scope, reply.requestId == command.0, reply.orderId == record.orderId, reply.receiptId == record.receiptId, ["created", "pending", "trialing", "paid", "cancelled", "failed"].contains(reply.orderStatus) else { throw IndianaClientError.invalidResponse }
|
|
372
|
+
if let completion = reply.nativeCompletion {
|
|
373
|
+
guard completion.orderId == record.orderId, completion.receiptId == record.receiptId, (1...50).contains(completion.transactionIds.count), Set(completion.transactionIds).count == completion.transactionIds.count, completion.transactionIds.allSatisfy({ !$0.isEmpty && $0.count <= 255 }) else { throw IndianaClientError.invalidResponse }
|
|
374
|
+
for transaction in scanned where transaction.productID == record.productId && transaction.accountToken == record.accountToken && completion.transactionIds.contains(transaction.transactionID) {
|
|
375
|
+
try await nativeStore.finish(transactionID: transaction.transactionID, confirmedTransactionIDs: Set(completion.transactionIds))
|
|
376
|
+
}
|
|
377
|
+
if !completion.membership { try await storage.remove(record.orderId) }
|
|
378
|
+
}
|
|
379
|
+
nativeReceiptPending.removeValue(forKey: record.orderId); responses.append(bytes)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return responses
|
|
383
|
+
}
|
|
384
|
+
/// Restores store transactions after reinstall or a device change. No local order journal is required.
|
|
385
|
+
public func restorePurchases(synchronizeStore: Bool = false) async throws -> [Data] {
|
|
386
|
+
guard !recoveringNative else { throw IndianaClientError.temporaryUnavailable }
|
|
387
|
+
recoveringNative = true; defer { recoveringNative = false }
|
|
388
|
+
let original = epoch
|
|
389
|
+
guard customerToken != nil else { throw IndianaClientError.unauthenticated }
|
|
390
|
+
let scanned = synchronizeStore ? try await nativeStore.restore() : await nativeStore.scanCurrent()
|
|
391
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }
|
|
392
|
+
guard scanned.count <= 100 else { throw IndianaClientError.invalidResponse }
|
|
393
|
+
var replies: [Data] = []
|
|
394
|
+
for purchase in scanned {
|
|
395
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }
|
|
396
|
+
// Unbound historical or family-shared transactions are not automatically transferred to an app account.
|
|
397
|
+
guard let intent = purchase.accountToken else { continue }
|
|
398
|
+
replies.append(try await submitStorePurchase(purchase, intent: intent, original: original))
|
|
399
|
+
}
|
|
400
|
+
return replies
|
|
401
|
+
}
|
|
402
|
+
private func submitStorePurchase(_ purchase: ApplePurchaseEvidence, intent: UUID, original: UUID) async throws -> Data {
|
|
403
|
+
guard purchase.signedTransaction.utf8.count <= 131072 else { throw IndianaClientError.invalidResponse }
|
|
404
|
+
let command: (UUID, Data)
|
|
405
|
+
if let old = storeRestorePending[purchase.transactionID] { command = old }
|
|
406
|
+
else {
|
|
407
|
+
let id = UUID()
|
|
408
|
+
let input: IndianaJSON = .object(["requestId":.string(id.uuidString.lowercased()), "provider":.string("apple"), "purchaseIntentId":.string(intent.uuidString.lowercased()), "productId":.string(purchase.productID), "signedTransaction":.string(purchase.signedTransaction)])
|
|
409
|
+
command = (id, try JSONEncoder().encode(input)); storeRestorePending[purchase.transactionID] = command
|
|
410
|
+
}
|
|
411
|
+
let bytes = try await authenticated(path: "subscriptions/native/restore", body: command.1)
|
|
412
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }
|
|
413
|
+
struct Completion: Decodable { let transactionIds: [String]; let membership: Bool }
|
|
414
|
+
struct Operation: Decodable { let requestId: UUID; let orderId: UUID; let purchaseIntentId: UUID; let status: String; let refunded: Bool; let completion: Completion? }
|
|
415
|
+
struct Reply: Decodable { let operation: Operation }
|
|
416
|
+
guard bytes.count <= 262144, let reply = try? JSONDecoder().decode(Reply.self, from: bytes), reply.operation.requestId == command.0, reply.operation.purchaseIntentId == intent, ["created","pending","trialing","paid","cancelled","failed"].contains(reply.operation.status) else { throw IndianaClientError.invalidResponse }
|
|
417
|
+
if let completion = reply.operation.completion {
|
|
418
|
+
guard (1...50).contains(completion.transactionIds.count), Set(completion.transactionIds).count == completion.transactionIds.count, completion.transactionIds.allSatisfy({ !$0.isEmpty && $0.count <= 255 }), completion.transactionIds.contains(purchase.transactionID) else { throw IndianaClientError.invalidResponse }
|
|
419
|
+
try await nativeStore.finish(transactionID: purchase.transactionID, confirmedTransactionIDs: Set(completion.transactionIds))
|
|
420
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }
|
|
421
|
+
}
|
|
422
|
+
storeRestorePending.removeValue(forKey: purchase.transactionID)
|
|
423
|
+
return bytes
|
|
424
|
+
}
|
|
425
|
+
/// Reviews the actual StoreKit quote before opening the payment sheet, then verifies on the server before finish.
|
|
426
|
+
public func purchaseSubscription(operationId: UUID, confirm: @Sendable (AppleStoreQuote) async -> Bool) async throws -> Data {
|
|
427
|
+
try await purchaseSubscriptionOperation(operationId: operationId, replacing:false,confirm:confirm)
|
|
428
|
+
}
|
|
429
|
+
/// Apple determines upgrade/downgrade timing within the same subscription group.
|
|
430
|
+
public func switchSubscription(operationId: UUID, confirm: @Sendable (AppleStoreQuote) async -> Bool) async throws -> Data {
|
|
431
|
+
try await purchaseSubscriptionOperation(operationId: operationId, replacing:true,confirm:confirm)
|
|
432
|
+
}
|
|
433
|
+
private func purchaseSubscriptionOperation(operationId: UUID, replacing: Bool, confirm: @Sendable (AppleStoreQuote) async -> Bool) async throws -> Data {
|
|
434
|
+
guard !recoveringNative else { throw IndianaClientError.temporaryUnavailable }
|
|
435
|
+
recoveringNative = true; defer { recoveringNative = false }
|
|
436
|
+
let original = epoch, response = replacing ? try await update(operationId:operationId) : try await attach(operationId: operationId)
|
|
437
|
+
guard let root = try JSONDecoder().decode(IndianaJSON.self, from: response).object, let operation = root["operation"]?.object,
|
|
438
|
+
operation["operationId"]?.string.flatMap(UUID.init(uuidString:)) == operationId else { throw IndianaClientError.invalidResponse }
|
|
439
|
+
if ["complete", "effective", "cancelled", "failed"].contains(operation["status"]?.string ?? "") { return response }
|
|
440
|
+
guard let instruction = operation["nativePurchase"]?.object, instruction["provider"] == .string("apple"),
|
|
441
|
+
instruction["receiptId"]?.string.flatMap(UUID.init(uuidString:)) == operationId,
|
|
442
|
+
let order = instruction["orderId"]?.string.flatMap(UUID.init(uuidString:)), instruction["appAccountToken"]?.string.flatMap(UUID.init(uuidString:)) == order,
|
|
443
|
+
let product = instruction["productId"]?.string else { throw IndianaClientError.invalidResponse }
|
|
444
|
+
let replacement = instruction["replacement"]?.object
|
|
445
|
+
guard replacing == (replacement != nil), !replacing || replacement?["policy"] == .string("apple_store") else { throw IndianaClientError.invalidResponse }
|
|
446
|
+
let tokens: [UUID]
|
|
447
|
+
if let replacement {
|
|
448
|
+
guard case .array(let raw)? = replacement["sourceAccountTokens"], raw.count <= 50 else { throw IndianaClientError.invalidResponse }
|
|
449
|
+
tokens = try raw.map { guard let value=$0.string.flatMap(UUID.init(uuidString:)) else { throw IndianaClientError.invalidResponse };return value }
|
|
450
|
+
} else { tokens = [] }
|
|
451
|
+
let allowedIntents = Set(tokens + [order])
|
|
452
|
+
let scanned = await nativeStore.scanCurrent()
|
|
453
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }
|
|
454
|
+
if let purchased = scanned.first(where: { $0.accountToken.map(allowedIntents.contains) == true && $0.productID == product }) { return try await submitStorePurchase(purchased, intent: purchased.accountToken!, original: original) }
|
|
455
|
+
let terms = try AppleStoreTerms(instruction), quote = try await nativeStore.quote(productID: product, terms: terms)
|
|
456
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }
|
|
457
|
+
let accepted = await confirm(quote)
|
|
458
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }
|
|
459
|
+
guard accepted else { return replacing ? try await update(operationId:operationId,intent:"cancel") : try await attach(operationId:operationId,intent:"cancel") }
|
|
460
|
+
let result = try await nativeStore.purchase(productID: product, accountToken: order, terms: terms)
|
|
461
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }
|
|
462
|
+
if case .purchased(let purchase) = result {
|
|
463
|
+
// Deferred downgrades retain the current transaction until the next renewal.
|
|
464
|
+
if replacing && purchase.productID == replacement?["sourceProductId"]?.string { return response }
|
|
465
|
+
guard let intent = purchase.accountToken, allowedIntents.contains(intent), purchase.productID == product else { throw IndianaClientError.invalidResponse }
|
|
466
|
+
return try await submitStorePurchase(purchase, intent: intent, original: original)
|
|
467
|
+
}
|
|
468
|
+
if case .canceled = result { return replacing ? try await update(operationId:operationId,intent:"cancel") : try await attach(operationId:operationId,intent:"cancel") }
|
|
469
|
+
return response
|
|
470
|
+
}
|
|
471
|
+
public func readHoldings() async throws -> Data { try await authenticated(path: "customer-holdings", body: Data("{}".utf8)) }
|
|
472
|
+
public func checkEntitlement(internalFeatureId: UUID, quantity: String = "1", properties: [String: IndianaJSON] = [:]) async throws -> Data {
|
|
473
|
+
let requirement: IndianaJSON = .object(["internal_feature_id": .string(internalFeatureId.uuidString.lowercased()), "quantity": .string(quantity), "properties": .object(properties)])
|
|
474
|
+
let body = try JSONEncoder().encode(["requirement": requirement])
|
|
475
|
+
try IndianaSchema.validate("checkRequest", data: body)
|
|
476
|
+
let result = try await authenticated(path: "entitlements/check", body: body)
|
|
477
|
+
try IndianaSchema.validate("checkResponse", data: result)
|
|
478
|
+
return result
|
|
479
|
+
}
|
|
480
|
+
/// The merchant backend authenticates its user, selects the Customer and adds the Server Key.
|
|
481
|
+
/// Retain the command's idempotencyKey after an unknown result. No credential is sent to the relay.
|
|
482
|
+
public func trackUsage(command: [String: IndianaJSON], using relay: @Sendable (Data) async throws -> Data) async throws -> Data {
|
|
483
|
+
guard customerToken != nil else { throw IndianaClientError.unauthenticated }
|
|
484
|
+
let original = epoch
|
|
485
|
+
let body = try JSONEncoder().encode(command)
|
|
486
|
+
try IndianaSchema.validate("usageCommand", data: body)
|
|
487
|
+
let result: Data
|
|
488
|
+
do { result = try await relay(body) }
|
|
489
|
+
catch { guard original == epoch else { throw IndianaClientError.identityChanged }; throw error }
|
|
490
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }
|
|
491
|
+
try IndianaSchema.validate("usageResponse", data: result)
|
|
492
|
+
struct Scoped: Decodable { let scope: IndianaScope }
|
|
493
|
+
guard let returned = try? JSONDecoder().decode(Scoped.self, from: result), returned.scope == configuration.scope else { throw IndianaClientError.invalidResponse }
|
|
494
|
+
return result
|
|
495
|
+
}
|
|
496
|
+
/// Preview persists no payment attempt. Keep requestId when retrying; native execution requires server channel support.
|
|
497
|
+
public func previewAttach(requestId: UUID, plans: [IndianaSubscriptionPlan], provider: IndianaSubscriptionProvider, currency: String, billingSubscriptionId: UUID? = nil, offerId: UUID? = nil, nativeOffer: IndianaNativeStoreOffer? = nil) async throws -> Data {
|
|
498
|
+
guard (1...20).contains(plans.count), Set(plans.map(\.productVersionId)).count == plans.count,
|
|
499
|
+
currency.range(of: "^[A-Z]{3}$", options: .regularExpression) != nil,
|
|
500
|
+
plans.allSatisfy({ $0.featureQuantities.count <= 100 && $0.featureQuantities.values.allSatisfy { (0...10000).contains($0) } && $0.seatQuantities.count <= 20 && $0.seatQuantities.allSatisfy { $0.key.range(of: "^[a-z][a-z0-9_]{0,63}$", options: .regularExpression) != nil && (0...10000).contains($0.value) } }) else { throw IndianaClientError.invalidResponse }
|
|
501
|
+
let entries = plans.map { plan in IndianaJSON.object(["productVersionId":.string(plan.productVersionId.uuidString.lowercased()), "seatQuantities":.object(plan.seatQuantities.mapValues { .number(Double($0)) }), "featureQuantities":.object(Dictionary(uniqueKeysWithValues: plan.featureQuantities.map { ($0.key.uuidString.lowercased(), IndianaJSON.number(Double($0.value))) }))]) }
|
|
502
|
+
var fields: [String: IndianaJSON] = ["requestId":.string(requestId.uuidString.lowercased()), "plans":.array(entries), "provider":.string(provider.rawValue), "currency":.string(currency)]
|
|
503
|
+
if let billingSubscriptionId { fields["billingSubscriptionId"] = .string(billingSubscriptionId.uuidString.lowercased()) }
|
|
504
|
+
guard nativeOffer == nil || (nativeOffer!.matches(provider) && plans.count == 1),
|
|
505
|
+
offerId == nil || (billingSubscriptionId == nil && (provider == .stripe || provider == .merchant || nativeOffer != nil)) else { throw IndianaClientError.invalidResponse }
|
|
506
|
+
if let offerId { fields["offerId"] = .string(offerId.uuidString.lowercased()) }
|
|
507
|
+
if let nativeOffer { fields["nativeOffer"] = nativeOffer.wire }
|
|
508
|
+
let body: IndianaJSON = .object(fields)
|
|
509
|
+
return try await authenticated(path: "subscriptions/preview-attach", body: JSONEncoder().encode(body))
|
|
510
|
+
}
|
|
511
|
+
/// Execute only after reviewing and accepting the frozen quote. Retries recover the original order.
|
|
512
|
+
public func attach(operationId: UUID, intent:String="confirm") async throws -> Data {
|
|
513
|
+
guard ["confirm","cancel"].contains(intent) else { throw IndianaClientError.invalidResponse }
|
|
514
|
+
return try await authenticated(path: "subscriptions/attach", body: JSONEncoder().encode(["operationId": operationId.uuidString.lowercased(),"intent":intent]))
|
|
515
|
+
}
|
|
516
|
+
public func previewUpdate(requestId: UUID, membershipId: UUID, productVersionId: UUID, timing: String) async throws -> Data {
|
|
517
|
+
guard ["immediate", "renewal", "store"].contains(timing) else { throw IndianaClientError.invalidResponse }
|
|
518
|
+
let body: IndianaJSON = .object(["requestId":.string(requestId.uuidString.lowercased()),"membershipId":.string(membershipId.uuidString.lowercased()),"timing":.string(timing),"change":.object(["type":.string("plan"),"productVersionId":.string(productVersionId.uuidString.lowercased())])])
|
|
519
|
+
return try await authenticated(path: "subscriptions/preview-update", body: JSONEncoder().encode(body))
|
|
520
|
+
}
|
|
521
|
+
public func previewSeatUpdate(requestId: UUID, membershipId: UUID, licenseKey: String, quantity: Int, timing: String) async throws -> Data {
|
|
522
|
+
guard ["immediate", "renewal"].contains(timing), (0...10000).contains(quantity), licenseKey.range(of: "^[a-z][a-z0-9_]{0,63}$", options: .regularExpression) != nil else { throw IndianaClientError.invalidResponse }
|
|
523
|
+
let body: IndianaJSON = .object(["requestId":.string(requestId.uuidString.lowercased()),"membershipId":.string(membershipId.uuidString.lowercased()),"timing":.string(timing),"change":.object(["type":.string("seats"),"licenseKey":.string(licenseKey),"quantity":.number(Double(quantity))])])
|
|
524
|
+
return try await authenticated(path: "subscriptions/preview-update", body: JSONEncoder().encode(body))
|
|
525
|
+
}
|
|
526
|
+
public func previewQuantityUpdate(requestId: UUID, membershipId: UUID, internal_feature_id: UUID, quantity: Int, timing: String) async throws -> Data {
|
|
527
|
+
guard ["immediate", "renewal"].contains(timing), (0...10000).contains(quantity) else { throw IndianaClientError.invalidResponse }
|
|
528
|
+
let body: IndianaJSON = .object(["requestId":.string(requestId.uuidString.lowercased()),"membershipId":.string(membershipId.uuidString.lowercased()),"timing":.string(timing),"change":.object(["type":.string("quantity"),"internal_feature_id":.string(internal_feature_id.uuidString.lowercased()),"quantity":.number(Double(quantity))])])
|
|
529
|
+
return try await authenticated(path: "subscriptions/preview-update", body: JSONEncoder().encode(body))
|
|
530
|
+
}
|
|
531
|
+
/// Cancellation follows the billing channel of the held plan; App Store plans require store support.
|
|
532
|
+
public func previewCancellation(requestId: UUID, membershipId: UUID, timing: String) async throws -> Data {
|
|
533
|
+
guard ["immediate", "renewal"].contains(timing) else { throw IndianaClientError.invalidResponse }
|
|
534
|
+
let body: IndianaJSON = .object(["requestId":.string(requestId.uuidString.lowercased()),"membershipId":.string(membershipId.uuidString.lowercased()),"timing":.string(timing),"change":.object(["type":.string("cancel")])])
|
|
535
|
+
return try await authenticated(path: "subscriptions/preview-update", body: JSONEncoder().encode(body))
|
|
536
|
+
}
|
|
537
|
+
public func previewUncancel(requestId: UUID, membershipId: UUID) async throws -> Data {
|
|
538
|
+
let body: IndianaJSON = .object(["requestId":.string(requestId.uuidString.lowercased()),"membershipId":.string(membershipId.uuidString.lowercased()),"timing":.string("immediate"),"change":.object(["type":.string("uncancel")])])
|
|
539
|
+
return try await authenticated(path: "subscriptions/preview-update", body: JSONEncoder().encode(body))
|
|
540
|
+
}
|
|
541
|
+
public func update(operationId: UUID, intent:String="confirm") async throws -> Data { guard ["confirm","cancel"].contains(intent) else { throw IndianaClientError.invalidResponse };return try await authenticated(path: "subscriptions/update", body: JSONEncoder().encode(["operationId":operationId.uuidString.lowercased(),"intent":intent])) }
|
|
542
|
+
public func readSubscriptionUpdate(operationId: UUID) async throws -> Data { try await authenticated(path: "subscriptions/updates/read", body: JSONEncoder().encode(["operationId":operationId.uuidString.lowercased()])) }
|
|
543
|
+
public func readSubscriptionOperation(operationId: UUID) async throws -> Data {
|
|
544
|
+
try await authenticated(path: "subscriptions/operations/read", body: JSONEncoder().encode(["operationId": operationId.uuidString.lowercased()]))
|
|
545
|
+
}
|
|
546
|
+
public func readLedger(query: [String: IndianaJSON]) async throws -> Data {
|
|
547
|
+
let encoder = JSONEncoder(); encoder.outputFormatting = [.sortedKeys]
|
|
548
|
+
return try encoder.encode(try await customerOperation(.readLedger, input: ["query": .object(query)]))
|
|
549
|
+
}
|
|
550
|
+
public func quoteBalance(internal_feature_id: UUID, quantity: String, properties: [String: IndianaJSON] = [:]) async throws -> Data {
|
|
551
|
+
let command: IndianaJSON = .object(["operation":.string("quote"),"internal_feature_id":.string(internal_feature_id.uuidString.lowercased()),"quantity":.string(quantity),"properties":.object(properties)])
|
|
552
|
+
let encoder = JSONEncoder(); encoder.outputFormatting = [.sortedKeys]
|
|
553
|
+
return try await authenticated(path: "balances/quote", body: encoder.encode(["command":command]))
|
|
554
|
+
}
|
|
555
|
+
/// Execute a generated Customer operation under this client's current identity.
|
|
556
|
+
/// Server-only operations are absent from IndianaCustomerOperation.
|
|
557
|
+
public func customerOperation(_ operation: IndianaCustomerOperation, input: [String: IndianaJSON]) async throws -> IndianaJSON {
|
|
558
|
+
guard input["customerId"] == nil, input["customerToken"] == nil else { throw IndianaClientError.invalidConfiguration }
|
|
559
|
+
guard let token = customerToken else { throw IndianaClientError.unauthenticated }
|
|
560
|
+
let encoder = JSONEncoder(); encoder.outputFormatting = [.sortedKeys]
|
|
561
|
+
let defaults = IndianaCustomerSemantics.defaults(operation.rawValue, input)
|
|
562
|
+
try IndianaCustomerSemantics.preflight(operation.rawValue, defaults)
|
|
563
|
+
do { try IndianaSchema.validate("customer" + operation.rawValue + "Request", data: encoder.encode(IndianaJSON.object(defaults))) }
|
|
564
|
+
catch { throw IndianaClientError.invalidConfiguration }
|
|
565
|
+
let body = try encoder.encode(IndianaJSON.object(IndianaCustomerSemantics.request(operation.rawValue, defaults)))
|
|
566
|
+
let original = epoch
|
|
567
|
+
let data: Data
|
|
568
|
+
do { data = try await http.send(path: operation.path, method: operation.method, authorization: token, body: operation.method == "GET" ? nil : body) }
|
|
569
|
+
catch { guard original == epoch else { throw IndianaClientError.identityChanged }; throw error }
|
|
570
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }
|
|
571
|
+
do { try IndianaSchema.validate("customer" + operation.rawValue + "Response", data: data) }
|
|
572
|
+
catch { throw IndianaClientError.invalidResponse }
|
|
573
|
+
let normalized: Data
|
|
574
|
+
do { normalized = try IndianaSchema.customerResponse("customer" + operation.rawValue + "Response", data: data) }
|
|
575
|
+
catch { throw IndianaClientError.invalidResponse }
|
|
576
|
+
let value = try JSONDecoder().decode(IndianaJSON.self, from: normalized)
|
|
577
|
+
try IndianaCustomerSemantics.response(operation.rawValue, value)
|
|
578
|
+
guard let scope = value.object?["scope"]?.object,
|
|
579
|
+
scope["projectId"]?.string.flatMap(UUID.init(uuidString:)) == configuration.scope.projectId,
|
|
580
|
+
scope["environment"]?.string == configuration.scope.environment.rawValue else { throw IndianaClientError.invalidResponse }
|
|
581
|
+
return value
|
|
582
|
+
}
|
|
583
|
+
private func preparedCustomerToken() async throws -> String {
|
|
584
|
+
let original = epoch; let value = try await http.access()
|
|
585
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }; return value
|
|
586
|
+
}
|
|
587
|
+
private func authenticated(path: String, body: Data, credential: String? = nil) async throws -> Data {
|
|
588
|
+
if credential == nil, let operation = IndianaCustomerOperation.allCases.first(where: { $0.path == path }),
|
|
589
|
+
let input = try JSONDecoder().decode(IndianaJSON.self, from: body).object {
|
|
590
|
+
return try JSONEncoder().encode(try await customerOperation(operation, input: input))
|
|
591
|
+
}
|
|
592
|
+
guard customerToken != nil, let token = credential ?? customerToken else { throw IndianaClientError.unauthenticated }; let original = epoch
|
|
593
|
+
let bytes: Data
|
|
594
|
+
do { bytes = try await http.send(path: path, method: "POST", authorization: token, body: body) }
|
|
595
|
+
catch { guard original == epoch else { throw IndianaClientError.identityChanged }; throw error }
|
|
596
|
+
guard original == epoch else { throw IndianaClientError.identityChanged }
|
|
597
|
+
struct Scoped: Decodable { let scope: IndianaScope }
|
|
598
|
+
guard let returned = try? JSONDecoder().decode(Scoped.self, from: bytes), returned.scope == configuration.scope else { throw IndianaClientError.invalidResponse }
|
|
599
|
+
return bytes
|
|
600
|
+
}
|
|
601
|
+
}
|