@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.
Files changed (85) hide show
  1. package/IndianaReactNative.podspec +19 -0
  2. package/README.md +62 -5
  3. package/THIRD_PARTY_NOTICES.md +74 -0
  4. package/android/build.gradle +24 -0
  5. package/android/src/main/AndroidManifest.xml +3 -0
  6. package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgeModule.kt +237 -0
  7. package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgePackage.kt +11 -0
  8. package/dist/index.cjs +17 -1
  9. package/dist/index.d.cts +96889 -6007
  10. package/dist/index.d.ts +96889 -6007
  11. package/dist/index.js +17 -1
  12. package/ios/IndianaBridge.m +11 -0
  13. package/ios/IndianaBridge.swift +275 -0
  14. package/native-sdk/android/assets/indiana/index.html +268 -0
  15. package/native-sdk/android/java/ai/tinkerland/indiana/BillingAuthorizationTerms.kt +55 -0
  16. package/native-sdk/android/java/ai/tinkerland/indiana/BundledWebView.kt +90 -0
  17. package/native-sdk/android/java/ai/tinkerland/indiana/ContentSession.kt +45 -0
  18. package/native-sdk/android/java/ai/tinkerland/indiana/ContentView.kt +36 -0
  19. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerOperations.generated.kt +84 -0
  20. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerResponseSemantics.kt +197 -0
  21. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSemantics.kt +120 -0
  22. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSession.kt +106 -0
  23. package/native-sdk/android/java/ai/tinkerland/indiana/FeatureClient.kt +65 -0
  24. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelCommandStorage.kt +163 -0
  25. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelConfigurationRelay.kt +50 -0
  26. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelSession.kt +208 -0
  27. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelView.kt +71 -0
  28. package/native-sdk/android/java/ai/tinkerland/indiana/GeneratedProtocol.kt +16 -0
  29. package/native-sdk/android/java/ai/tinkerland/indiana/GoogleStorefront.kt +165 -0
  30. package/native-sdk/android/java/ai/tinkerland/indiana/HTTPTransport.kt +43 -0
  31. package/native-sdk/android/java/ai/tinkerland/indiana/IndianaClient.kt +526 -0
  32. package/native-sdk/android/java/ai/tinkerland/indiana/MerchantCommerceSemantics.kt +38 -0
  33. package/native-sdk/android/java/ai/tinkerland/indiana/NativePendingKeystore.kt +81 -0
  34. package/native-sdk/android/java/ai/tinkerland/indiana/NativeReceiptRelay.kt +28 -0
  35. package/native-sdk/android/java/ai/tinkerland/indiana/NativeStoreOffer.kt +24 -0
  36. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallContent.kt +75 -0
  37. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +144 -0
  38. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallView.kt +74 -0
  39. package/native-sdk/android/java/ai/tinkerland/indiana/Protocol.kt +28 -0
  40. package/native-sdk/android/java/ai/tinkerland/indiana/PushInstallation.kt +170 -0
  41. package/native-sdk/android/java/ai/tinkerland/indiana/RendererNavigation.kt +13 -0
  42. package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +6 -0
  43. package/native-sdk/android/java/ai/tinkerland/indiana/StoreSubscriptionManagement.kt +19 -0
  44. package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +76 -0
  45. package/native-sdk/android/resources/funnel-wire-schemas.json +1 -0
  46. package/native-sdk/android/resources/indiana-third-party-notices.txt +101 -0
  47. package/native-sdk/android/resources/renderer-data-url.txt +1 -0
  48. package/native-sdk/ios/AnalyticsReceipt.swift +10 -0
  49. package/native-sdk/ios/AppleStorefront.swift +218 -0
  50. package/native-sdk/ios/BillingAuthorizationTerms.swift +57 -0
  51. package/native-sdk/ios/BundledWebView.swift +87 -0
  52. package/native-sdk/ios/ContentSession.swift +48 -0
  53. package/native-sdk/ios/ContentView.swift +51 -0
  54. package/native-sdk/ios/CustomerOperations.generated.swift +166 -0
  55. package/native-sdk/ios/CustomerResponseSemantics.swift +236 -0
  56. package/native-sdk/ios/CustomerSemantics.swift +145 -0
  57. package/native-sdk/ios/CustomerSession.swift +129 -0
  58. package/native-sdk/ios/FeatureClient.swift +86 -0
  59. package/native-sdk/ios/FunnelCommandStorage.swift +163 -0
  60. package/native-sdk/ios/FunnelConfigurationRelay.swift +48 -0
  61. package/native-sdk/ios/FunnelSession.swift +232 -0
  62. package/native-sdk/ios/FunnelView.swift +125 -0
  63. package/native-sdk/ios/GeneratedProtocol.swift +16 -0
  64. package/native-sdk/ios/HTTPTransport.swift +74 -0
  65. package/native-sdk/ios/IndianaClient.swift +601 -0
  66. package/native-sdk/ios/MerchantCommerceSemantics.swift +51 -0
  67. package/native-sdk/ios/NativePendingKeychain.swift +52 -0
  68. package/native-sdk/ios/NativeReceiptRelay.swift +28 -0
  69. package/native-sdk/ios/NativeStoreOffer.swift +34 -0
  70. package/native-sdk/ios/PaywallContent.swift +75 -0
  71. package/native-sdk/ios/PaywallSession.swift +124 -0
  72. package/native-sdk/ios/PaywallView.swift +87 -0
  73. package/native-sdk/ios/Protocol.swift +49 -0
  74. package/native-sdk/ios/PushInstallation.swift +154 -0
  75. package/native-sdk/ios/RendererNavigation.swift +8 -0
  76. package/native-sdk/ios/Resources.swift +15 -0
  77. package/native-sdk/ios/SDKVersion.swift +4 -0
  78. package/native-sdk/ios/StoreSubscriptionManagement.swift +11 -0
  79. package/native-sdk/ios/WebView/THIRD_PARTY_NOTICES.txt +101 -0
  80. package/native-sdk/ios/WebView/index.html +268 -0
  81. package/native-sdk/ios/WebView/renderer-data-url.txt +1 -0
  82. package/native-sdk/ios/WireSchema.swift +107 -0
  83. package/native-sdk/ios/funnel-wire-schemas.json +1 -0
  84. package/package.json +8 -3
  85. 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
+ }