@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,51 @@
1
+ import Foundation
2
+
3
+ /// Merchant refinements dispatch on generated DTO fields, not user JSON.
4
+ enum IndianaMerchantCommerceSemantics {
5
+ static func validate(_ v: [String: Any], fields: Set<String>) throws {
6
+ func has(_ keys: [String]) -> Bool { Set(keys).isSubset(of: fields) }
7
+ let require = IndianaCustomerResponseSemantics.require
8
+ let o = IndianaCustomerResponseSemantics.o, a = IndianaCustomerResponseSemantics.a
9
+ let s = IndianaCustomerResponseSemantics.s, n = IndianaCustomerResponseSemantics.n
10
+ let nilValue = IndianaCustomerResponseSemantics.nilValue
11
+ if has(["configurationRevision", "connectionRevision", "lineItems", "catalogAmountMinor", "discount"]) {
12
+ let lines = a(v["lineItems"]), discount = o(v["discount"])
13
+ try require(lines.reduce(0) { $0 + n($1["totalMinor"]) } == n(v["catalogAmountMinor"]) && (discount.isEmpty || n(discount["amountMinor"]) <= n(v["catalogAmountMinor"])) && Set(lines.map { s($0["priceId"]) }).count == lines.count)
14
+ }
15
+ if has(["phase", "code", "reason", "retryable", "recovery"]), s(v["reason"]) == "RESULT_UNKNOWN" {
16
+ try require(v["retryable"] as? Bool == false && s(v["recovery"]) == "recover_operation")
17
+ }
18
+ if has(["merchantExecutionId", "paymentStatus", "checkoutUrl", "checkoutExpiresAt"]), !nilValue(v["checkoutUrl"]) {
19
+ try require(["not_started", "pending"].contains(s(v["paymentStatus"])) && !nilValue(v["checkoutExpiresAt"]))
20
+ }
21
+ if has(["merchantExecutionId", "scheduledFor", "status"]) || has(["provider", "eventId", "subscription", "scheduledFor", "status"]) {
22
+ try require((s(v["status"]) == "scheduled") == !nilValue(v["scheduledFor"]))
23
+ }
24
+ if has(["merchantExecutionId", "merchantRefundId", "status"]), s(v["status"]) == "succeeded" { try require(!nilValue(v["merchantRefundId"])) }
25
+ if has(["lookup", "operationId", "customerId", "execution"]), let execution = v["execution"] as? [String: Any] {
26
+ try require(s(execution["operationId"]) == s(v["operationId"]) && s(execution["customerId"]) == s(v["customerId"]))
27
+ }
28
+ if has(["scope", "operationId", "customerId", "confirmedAt", "initialExecutionAllowed", "instruction"]) {
29
+ let instruction = o(v["instruction"])
30
+ try require(s(instruction["operationId"]) == s(v["operationId"]) && s(instruction["customerId"]) == s(v["customerId"]))
31
+ }
32
+ if has(["merchantSubscriptionId", "periodStartsAt", "periodEndsAt", "items"]) {
33
+ let items = a(v["items"])
34
+ try require(IndianaCustomerResponseSemantics.time(v["periodEndsAt"]) > IndianaCustomerResponseSemantics.time(v["periodStartsAt"]) && Set(items.map { s($0["priceId"]) }).count == items.count)
35
+ }
36
+ if has(["merchantInvoiceId", "subtotalMinor", "discountMinor", "taxMinor", "totalMinor", "paidMinor"]) {
37
+ try require(n(v["subtotalMinor"]) - n(v["discountMinor"]) + n(v["taxMinor"]) == n(v["totalMinor"]) && n(v["discountMinor"]) <= n(v["subtotalMinor"]) && n(v["paidMinor"]) == n(v["totalMinor"]))
38
+ }
39
+ if has(["merchantInvoiceId", "lines", "paidMinor"]) {
40
+ let lines = a(v["lines"])
41
+ try require(Set(lines.map { s($0["merchantLineId"]) }).count == lines.count)
42
+ for key in ["subtotalMinor", "discountMinor", "taxMinor", "totalMinor"] { try require(lines.reduce(0) { $0 + n($1[key]) } == n(v[key])) }
43
+ }
44
+ if has(["merchantLineId", "subtotalMinor", "discountMinor", "taxMinor", "totalMinor"]) {
45
+ let subtotal = n(v["subtotalMinor"]), discount = n(v["discountMinor"]), tax = n(v["taxMinor"])
46
+ try require(subtotal - discount + tax == n(v["totalMinor"]) && (subtotal < 0 ? discount == 0 && tax == 0 : discount <= subtotal))
47
+ }
48
+ if has(["provider", "eventId", "merchantOrderId", "paymentStatus", "invoice"]), s(v["paymentStatus"]) == "paid" { try require(!nilValue(v["invoice"])) }
49
+ if has(["provider", "merchantInvoiceId", "cancelSubscriptions", "entitlementAction", "amountMinor"]) || has(["provider", "eventId", "merchantRefundId", "amountMinor", "status"]) { try require(n(v["amountMinor"]) > 0) }
50
+ }
51
+ }
@@ -0,0 +1,52 @@
1
+ import Foundation
2
+ import Security
3
+
4
+ struct IndianaNativePendingOrder: Codable, Sendable, Equatable {
5
+ let runId: UUID; let orderId: UUID; let receiptId: UUID; let productId: String; let accountToken: UUID
6
+ }
7
+ /// Stores only current order bindings; never a run token, customer token or JWS.
8
+ protocol IndianaNativePendingStorage: Sendable {
9
+ func read(_ order: UUID) async throws -> IndianaNativePendingOrder?
10
+ func orders() async throws -> [IndianaNativePendingOrder]
11
+ func orders(runId: UUID) async throws -> [IndianaNativePendingOrder]
12
+ func contains(_ record: IndianaNativePendingOrder) async throws -> Bool
13
+ func put(_ record: IndianaNativePendingOrder) async throws
14
+ func remove(_ order: UUID) async throws
15
+ }
16
+ actor IndianaNativePendingKeychain: IndianaNativePendingStorage {
17
+ private let service: String
18
+ init(scope: IndianaScope) { service = "\(Bundle.main.bundleIdentifier ?? "Indiana").grow.funnel.orders.v1.\(scope.projectId.uuidString.lowercased()).\(scope.environment.rawValue)" }
19
+ private func query(_ order: UUID) -> [String: Any] { [kSecClass as String:kSecClassGenericPassword,kSecAttrService as String:service,kSecAttrAccount as String:order.uuidString.lowercased()] }
20
+ func read(_ order: UUID) throws -> IndianaNativePendingOrder? {
21
+ var result: CFTypeRef?
22
+ let status = SecItemCopyMatching(query(order).merging([kSecReturnData as String:true]) { _, new in new } as CFDictionary, &result)
23
+ if status == errSecItemNotFound { return nil }
24
+ guard status == errSecSuccess, let data = result as? Data, data.count <= 4096, let original = try? JSONDecoder().decode(IndianaNativePendingOrder.self, from: data), original.orderId == order else { throw IndianaClientError.protectedStorageUnavailable }
25
+ return original
26
+ }
27
+ private func allOrders() throws -> [IndianaNativePendingOrder] {
28
+ let query: [String: Any] = [kSecClass as String:kSecClassGenericPassword,kSecAttrService as String:service,kSecReturnData as String:true,kSecMatchLimit as String:kSecMatchLimitAll]
29
+ var result: CFTypeRef?
30
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
31
+ if status == errSecItemNotFound { return [] }
32
+ guard status == errSecSuccess, let bytes = result as? [Data], bytes.count <= 100 else { throw IndianaClientError.protectedStorageUnavailable }
33
+ return try bytes.map { data in
34
+ guard data.count <= 4096 else { throw IndianaClientError.protectedStorageUnavailable }
35
+ return try JSONDecoder().decode(IndianaNativePendingOrder.self, from: data)
36
+ }
37
+ }
38
+ func orders() throws -> [IndianaNativePendingOrder] { try allOrders() }
39
+ func orders(runId: UUID) throws -> [IndianaNativePendingOrder] { try allOrders().filter { $0.runId == runId } }
40
+ func contains(_ record: IndianaNativePendingOrder) throws -> Bool {
41
+ guard let original = try read(record.orderId) else { return false }; guard original == record else { throw IndianaClientError.protectedStorageUnavailable }; return true
42
+ }
43
+ func put(_ record: IndianaNativePendingOrder) throws {
44
+ if try contains(record) { return }
45
+ guard try allOrders().count < 100 else { throw IndianaClientError.protectedStorageUnavailable }
46
+ let bytes = try JSONEncoder().encode(record)
47
+ let status = SecItemAdd(query(record.orderId).merging([kSecValueData as String:bytes,kSecAttrAccessible as String:kSecAttrAccessibleWhenUnlockedThisDeviceOnly]) { _, new in new } as CFDictionary, nil)
48
+ if status == errSecDuplicateItem { guard try contains(record) else { throw IndianaClientError.protectedStorageUnavailable }; return }
49
+ guard status == errSecSuccess else { throw IndianaClientError.protectedStorageUnavailable }
50
+ }
51
+ func remove(_ order: UUID) throws { let status = SecItemDelete(query(order) as CFDictionary); guard status == errSecSuccess || status == errSecItemNotFound else { throw IndianaClientError.protectedStorageUnavailable } }
52
+ }
@@ -0,0 +1,28 @@
1
+ import Foundation
2
+
3
+ struct IndianaNativeCompletion: Codable, Sendable { let orderId: UUID; let receiptId: UUID; let membership: Bool; let transactionIds: [String] }
4
+
5
+ /// HTTPS endpoint on your own backend. It authenticates the customer and attaches the Indiana server key there.
6
+ public final class IndianaNativeReceiptRelay: Sendable {
7
+ private let endpoint: URL; private let session: URLSession
8
+ public init(endpoint: URL) throws {
9
+ guard endpoint.scheme == "https", endpoint.host != nil, endpoint.user == nil, endpoint.password == nil, endpoint.query == nil, endpoint.fragment == nil else { throw IndianaClientError.invalidConfiguration }
10
+ self.endpoint = endpoint
11
+ let config = URLSessionConfiguration.ephemeral; config.urlCache = nil; config.httpCookieStorage = nil; config.requestCachePolicy = .reloadIgnoringLocalCacheData; config.timeoutIntervalForRequest = 35
12
+ session = URLSession(configuration: config, delegate: NoRedirect(), delegateQueue: nil)
13
+ }
14
+ public func send(_ body: Data) async throws -> Data {
15
+ guard body.count <= 262144, let input = try? JSONDecoder().decode(IndianaJSON.self, from: body).object, input["operation"] == .string("native_receipt"), let token = input["customerToken"]?.string, token.range(of: "^gg_ct_[A-Za-z0-9_-]{32,128}$", options: .regularExpression) != nil else { throw IndianaClientError.invalidConfiguration }
16
+ var request = URLRequest(url: endpoint); request.httpMethod = "POST"; request.httpBody = body
17
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type"); request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
18
+ do {
19
+ let (stream, response) = try await session.bytes(for: request)
20
+ guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw IndianaClientError.requestFailed((response as? HTTPURLResponse)?.statusCode ?? 0) }
21
+ guard response.expectedContentLength <= 262144 else { throw IndianaClientError.invalidResponse }
22
+ var result = Data(); for try await byte in stream { guard result.count < 262144 else { throw IndianaClientError.invalidResponse }; result.append(byte) }; return result
23
+ } catch let error as IndianaClientError { throw error } catch { throw IndianaClientError.temporaryUnavailable }
24
+ }
25
+ private final class NoRedirect: NSObject, URLSessionTaskDelegate, Sendable {
26
+ func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) }
27
+ }
28
+ }
@@ -0,0 +1,34 @@
1
+ import Foundation
2
+
3
+ public enum IndianaNativeOfferKind: String, Sendable { case none, introductory, promotional }
4
+
5
+ /// The selected store quote belongs to this purchase, not the reusable SKU binding.
6
+ public struct IndianaNativeStoreOffer: Sendable {
7
+ public let offerKind: IndianaNativeOfferKind
8
+ public let offerID: String?
9
+ public let discountMinor: Int
10
+ public let trialDays: Int?
11
+
12
+ public init(offerKind: IndianaNativeOfferKind, offerID: String? = nil, discountMinor: Int = 0, trialDays: Int? = nil) throws {
13
+ guard (0...100000000).contains(discountMinor), trialDays == nil || (1...730).contains(trialDays!),
14
+ offerID == nil || offerID!.range(of: "^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$", options: .regularExpression) != nil,
15
+ trialDays == nil || discountMinor == 0,
16
+ offerKind != .none || (offerID == nil && discountMinor == 0 && trialDays == nil) else { throw IndianaClientError.invalidResponse }
17
+ self.offerKind = offerKind; self.offerID = offerID; self.discountMinor = discountMinor; self.trialDays = trialDays
18
+ }
19
+
20
+ var wire: IndianaJSON { .object([
21
+ "offerKind": .string(offerKind.rawValue), "offerId": offerID.map(IndianaJSON.string) ?? .null,
22
+ "discountMinor": .number(Double(discountMinor)), "trialDays": trialDays.map { .number(Double($0)) } ?? .null
23
+ ]) }
24
+
25
+ func matches(_ provider: IndianaSubscriptionProvider) -> Bool {
26
+ switch provider {
27
+ case .stripe, .merchant: return false
28
+ case .apple: return (offerKind == .promotional) == (offerID != nil)
29
+ case .google:
30
+ return (offerKind == .none) == (offerID == nil) &&
31
+ (offerID == nil || offerID!.range(of: "^[a-z0-9][a-z0-9-]*$", options: .regularExpression) != nil)
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,75 @@
1
+ import Foundation
2
+ import CryptoKit
3
+
4
+ /// Only immutable JSON crosses this public transport. Business credentials never enter it.
5
+ final class IndianaPaywallContent: Sendable {
6
+ private static let presentationFields = ["amountMinor", "interval", "intervalCount", "prepaid"]
7
+ /// Hydration may add catalog presentation fields, but cannot change authenticated delivery facts.
8
+ static func validatesHydration(_ loaded: [String: IndianaJSON], delivery: [String: IndianaJSON]) -> Bool {
9
+ var protected = loaded.filter { !["document", "offering"].contains($0.key) }
10
+ if var capabilities = protected["capabilities"]?.object, var plans = capabilities["plans"]?.object {
11
+ let original = delivery["capabilities"]?.object?["plans"]?.object ?? [:]
12
+ for (id, entry) in plans {
13
+ guard var plan = entry.object else { return false }
14
+ for key in presentationFields where original[id]?.object?[key] == nil { plan.removeValue(forKey: key) }
15
+ plans[id] = .object(plan)
16
+ }
17
+ capabilities["plans"] = .object(plans); protected["capabilities"] = .object(capabilities)
18
+ }
19
+ return protected == delivery
20
+ }
21
+ static func applyPresentation(_ loaded: [String: IndianaJSON], to latest: inout [String: IndianaJSON]) {
22
+ latest["document"] = loaded["document"]; latest["offering"] = loaded["offering"]
23
+ guard var capabilities = latest["capabilities"]?.object, var plans = capabilities["plans"]?.object,
24
+ let hydrated = loaded["capabilities"]?.object?["plans"]?.object else { return }
25
+ for (id, entry) in plans {
26
+ guard var plan = entry.object, let source = hydrated[id]?.object, source["currency"] == plan["currency"] else { continue }
27
+ for key in presentationFields where source[key] != nil { plan[key] = source[key] }
28
+ plans[id] = .object(plan)
29
+ }
30
+ capabilities["plans"] = .object(plans); latest["capabilities"] = .object(capabilities)
31
+ }
32
+ private let relay: IndianaFunnelConfigurationRelay
33
+ init(configuration: IndianaConfiguration) { relay = IndianaFunnelConfigurationRelay(configuration: configuration) }
34
+ func resolve(_ delivery: IndianaJSON) async throws -> IndianaJSON {
35
+ try IndianaSchema.validate("paywallDelivery", data: JSONEncoder().encode(delivery))
36
+ guard let value = delivery.object, let content = value["content"]?.object,
37
+ let environment = content["environmentId"]?.string, let artifact = content["artifact"]?.object,
38
+ let digest = artifact["sha256"]?.string, let path = artifact["url"]?.string,
39
+ artifact["kind"] == .string("paywall"), artifact["formatVersion"] == .number(1),
40
+ path == "/api/v1/configuration/\(environment)/artifacts/\(digest)",
41
+ artifact["byteLength"] != nil else { throw IndianaClientError.invalidResponse }
42
+ let reply = try await relay.read(.object(["path": .string(path)]))
43
+ guard reply.object?["status"] == .number(200),
44
+ let type = reply.object?["headers"]?.object?["content-type"]?.string, type.lowercased().hasPrefix("application/json"),
45
+ let text = reply.object?["body"]?.string, let data = text.data(using: .utf8) else { throw IndianaClientError.invalidResponse }
46
+ return try Self.hydrate(delivery, bytes: data)
47
+ }
48
+ static func hydrate(_ delivery: IndianaJSON, bytes data: Data) throws -> IndianaJSON {
49
+ try IndianaSchema.validate("paywallDelivery", data: JSONEncoder().encode(delivery))
50
+ guard var value = delivery.object, let content = value["content"]?.object, let artifact = content["artifact"]?.object,
51
+ artifact["kind"] == .string("paywall"), artifact["formatVersion"] == .number(1),
52
+ let digest = artifact["sha256"]?.string, case .number(let expected) = artifact["byteLength"], data.count == Int(expected),
53
+ SHA256.hash(data: data).map({ String(format: "%02x", $0) }).joined() == digest else { throw IndianaClientError.invalidResponse }
54
+ try IndianaSchema.validate("publicPaywall", data: data)
55
+ guard let file = try JSONDecoder().decode(IndianaJSON.self, from: data).object,
56
+ file["offeringVersionId"] == content["offeringVersionId"] else { throw IndianaClientError.invalidResponse }
57
+ let keys: Set<String> = ["kind", "name", "presentation", "products", "offeringVersionId", "screen", "buttons"]
58
+ var document = file.filter { keys.contains($0.key) }; document["schemaVersion"] = .number(3)
59
+ value["document"] = .object(document); value["offering"] = .object(["id": file["offeringVersionId"]!])
60
+ if var capabilities = value["capabilities"]?.object, var plans = capabilities["plans"]?.object, let catalog = file["catalog"]?.object {
61
+ for (id, entry) in catalog {
62
+ guard var plan = plans[id]?.object, let product = entry.object,
63
+ let currency = plan["currency"]?.string,
64
+ case .array(let prices) = product["channels"]?.object?["stripe"]?.object?["prices"],
65
+ let price = prices.first(where: { $0.object?["currency"]?.string == currency })?.object else { continue }
66
+ plan["amountMinor"] = price["amountMinor"]; plan["interval"] = product["interval"]; plan["intervalCount"] = product["intervalCount"]
67
+ plan["prepaid"] = product["prepaid"]; plans[id] = .object(plan)
68
+ }
69
+ capabilities["plans"] = .object(plans); value["capabilities"] = .object(capabilities)
70
+ }
71
+ let hydrated = IndianaJSON.object(value)
72
+ try IndianaSchema.validate("paywallResolution", data: JSONEncoder().encode(hydrated))
73
+ return hydrated
74
+ }
75
+ }
@@ -0,0 +1,124 @@
1
+ import Foundation
2
+
3
+ /// Independent Paywall identity and commercial state. No Funnel or browser credential is created.
4
+ public actor IndianaPaywallSession {
5
+ private let configuration: IndianaConfiguration
6
+ private let http: any IndianaHTTP
7
+ private let content: @Sendable (IndianaJSON) async throws -> IndianaJSON
8
+ private var token: String?
9
+ private let selector: IndianaJSON
10
+ private let provider: IndianaSubscriptionProvider
11
+ private let currency: String
12
+ private let locale: String
13
+ private var busy = false
14
+ private var closed = false
15
+ private var invalidation: (@Sendable () -> Void)?
16
+ private var pendingPreview: Data?
17
+ private var quote: IndianaJSON?
18
+ private var operation: IndianaJSON?
19
+ public private(set) var current: IndianaJSON?
20
+
21
+ init(configuration: IndianaConfiguration, http: any IndianaHTTP, customerToken: String, paywallId: UUID?, versionId: UUID?, provider: IndianaSubscriptionProvider, currency: String, locale: String, content: (@Sendable (IndianaJSON) async throws -> IndianaJSON)? = nil) throws {
22
+ guard (paywallId == nil) != (versionId == nil), !locale.isEmpty, locale.count <= 35,
23
+ currency.range(of: "^[A-Z]{3}$", options: .regularExpression) != nil else { throw IndianaClientError.invalidConfiguration }
24
+ self.configuration = configuration; self.http = http; self.token = customerToken
25
+ let reader = IndianaPaywallContent(configuration: configuration)
26
+ self.content = content ?? { try await reader.resolve($0) }
27
+ self.provider = provider; self.currency = currency; self.locale = locale
28
+ selector = paywallId.map { .object(["type":.string("paywall"),"paywallId":.string($0.uuidString.lowercased())]) }
29
+ ?? .object(["type":.string("version"),"versionId":.string(versionId!.uuidString.lowercased())])
30
+ }
31
+ public func close() { closed = true; token = nil; current = nil; quote = nil; operation = nil; pendingPreview = nil; invalidation?(); invalidation = nil }
32
+ internal func onInvalidation(_ callback: @escaping @Sendable () -> Void) { if closed { callback() } else { invalidation = callback } }
33
+ internal func hostConfiguration() -> IndianaConfiguration { configuration }
34
+ internal func hostInit() -> IndianaJSON {
35
+ .object(["event":.string("init"),"surface":.string("paywall"),"scope":.object(["projectId":.string(configuration.scope.projectId.uuidString.lowercased()),"environment":.string(configuration.scope.environment.rawValue)]),"apiOrigin":.string(configuration.apiOrigin)])
36
+ }
37
+ private func send(_ name: String, path: String, body: IndianaJSON) async throws -> IndianaJSON {
38
+ guard let token, !closed else { throw IndianaClientError.identityChanged }
39
+ let data = try JSONEncoder().encode(body)
40
+ try IndianaSchema.validate(name + "Request", data: data)
41
+ let result: Data
42
+ do { result = try await http.send(path: path, method: "POST", authorization: token, body: data) }
43
+ catch { guard !closed else { throw IndianaClientError.identityChanged }; throw error }
44
+ guard !closed else { throw IndianaClientError.identityChanged }
45
+ try IndianaSchema.validate(name + "Response", data: result)
46
+ struct Scoped: Decodable { let scope: IndianaScope }
47
+ guard try JSONDecoder().decode(Scoped.self, from: result).scope == configuration.scope else { throw IndianaClientError.invalidResponse }
48
+ var reply = try JSONDecoder().decode(IndianaJSON.self, from: result).object!
49
+ if name == "resolvePaywall" { reply["paywall"] = try await content(reply["paywall"]!); guard !closed else { throw IndianaClientError.identityChanged } }
50
+ return .object(reply)
51
+ }
52
+ private func canonical(_ value: IndianaJSON) throws -> Data { let encoder = JSONEncoder(); encoder.outputFormatting = .sortedKeys; return try encoder.encode(value) }
53
+ public func refresh() async throws -> IndianaJSON { try await hostCommand(.object(["operation":.string("resolve"),"input":.object([:])])) }
54
+ internal func hostCommand(_ payload: IndianaJSON) async throws -> IndianaJSON {
55
+ guard !closed, !busy, let envelope = payload.object, Set(envelope.keys) == Set(["operation","input"]), let action = envelope["operation"]?.string, let input = envelope["input"]?.object else { throw IndianaFunnelError.invalidCommand }
56
+ busy = true; defer { busy = false }
57
+ switch action {
58
+ case "resolve":
59
+ guard input.isEmpty else { throw IndianaFunnelError.invalidCommand }
60
+ let selected = current?.object?["id"].map { IndianaJSON.object(["type":.string("version"),"versionId":$0]) } ?? selector
61
+ let reply = try await send("resolvePaywall", path: "paywalls/resolve", body: .object(["selector":selected,"commerce":.object(["provider":.string(provider.rawValue),"currency":.string(currency)]),"locale":.string(locale)]))
62
+ guard let value = reply.object?["paywall"], let wall = value.object else { throw IndianaClientError.invalidResponse }
63
+ let key = selector.object?["type"]?.string == "version" ? "id" : "paywallId"
64
+ guard wall[key] == selector.object?[key == "id" ? "versionId" : "paywallId"],
65
+ current == nil || (wall["id"] == current?.object?["id"] && wall["offering"]?.object?["id"] == current?.object?["offering"]?.object?["id"] && wall["content"]?.object?["artifact"]?.object?["sha256"] == current?.object?["content"]?.object?["artifact"]?.object?["sha256"]),
66
+ wall["document"]?.object?["offeringVersionId"] == wall["offering"]?.object?["id"] else { throw IndianaClientError.invalidResponse }
67
+ current = value; return value
68
+ case "preview":
69
+ guard Set(input.keys).isSubset(of: ["actionId","productVersionId","requestId","offerId","nativeOffer","featureQuantities"]),
70
+ let actionId = input["actionId"]?.string, let product = input["productVersionId"]?.string,
71
+ let policy = current?.object?["document"]?.object?["buttons"]?.object?[actionId]?.object, policy["type"]?.string == "checkout",
72
+ let selection = policy["product"]?.object else { throw IndianaFunnelError.invalidCommand }
73
+ let allowed: Bool
74
+ if selection["type"]?.string == "fixed" { allowed = selection["productVersionId"]?.string == product }
75
+ else if case .array(let ids) = selection["allowedProductVersionIds"] { allowed = ids.contains(.string(product)) }
76
+ else { allowed = false }
77
+ guard allowed else { throw IndianaFunnelError.invalidCommand }
78
+ let fingerprint = try canonical(payload)
79
+ if let pendingPreview, pendingPreview != fingerprint {
80
+ guard let status = operation?.object?["status"]?.string, status != "pending" else { throw IndianaFunnelError.commandPending }
81
+ quote = nil; operation = nil
82
+ }
83
+ if pendingPreview == fingerprint, let quote { return quote }
84
+ var selectedPlan: [String:IndianaJSON] = ["productVersionId":.string(product)]
85
+ selectedPlan["featureQuantities"] = input["featureQuantities"]
86
+ var fields: [String:IndianaJSON] = ["requestId":input["requestId"] ?? .null,"plans":.array([.object(selectedPlan)]),"provider":.string(provider.rawValue),"currency":.string(currency)]
87
+ fields["offerId"] = input["offerId"]; fields["nativeOffer"] = input["nativeOffer"]
88
+ let body = IndianaJSON.object(fields)
89
+ try IndianaSchema.validate("previewAttachRequest", data: JSONEncoder().encode(body))
90
+ pendingPreview = fingerprint
91
+ let reply = try await send("previewAttach", path:"subscriptions/preview-attach",body:body)
92
+ guard let value = reply.object?["quote"], let q = value.object, q["currency"]?.string == currency, q["provider"]?.string == provider.rawValue,
93
+ case .array(let lines) = q["lines"], lines.count == 1, lines[0].object?["productVersionId"]?.string == product else { throw IndianaClientError.invalidResponse }
94
+ quote = value; operation = nil; return value
95
+ case "confirm", "cancel", "read":
96
+ guard input.isEmpty, let id = quote?.object?["operationId"] else { throw IndianaFunnelError.invalidCommand }
97
+ let body = action == "read" ? IndianaJSON.object(["operationId":id]) : .object(["operationId":id,"intent":.string(action)])
98
+ let reply = try await send(action == "read" ? "readSubscriptionOperation" : "attach", path: action == "read" ? "subscriptions/operations/read" : "subscriptions/attach", body:body)
99
+ guard let value = reply.object?["operation"], value.object?["operationId"] == id, value.object?["currency"]?.string == currency else { throw IndianaClientError.invalidResponse }
100
+ if operation?.object?["status"]?.string == "complete" { return operation! }
101
+ operation = value
102
+ if value.object?["status"]?.string != "pending" && value.object?["status"]?.string != "complete" { pendingPreview = nil; quote = nil }
103
+ return value
104
+ default: throw IndianaFunnelError.invalidCommand
105
+ }
106
+ }
107
+ internal func trustedPurchase(_ value: IndianaJSON) throws -> IndianaJSON {
108
+ guard !closed, value == operation, value.object?["status"]?.string == "pending" else { throw IndianaFunnelError.invalidCommand }; return value
109
+ }
110
+ internal func trustedCheckout(_ value: String) throws -> URL {
111
+ guard !closed, operation?.object?["status"]?.string == "pending", operation?.object?["checkoutUrl"]?.string == value, let url=URL(string:value), url.scheme == "https", url.user == nil, url.password == nil else { throw IndianaFunnelError.invalidCommand }; return url
112
+ }
113
+ internal func trustedAction(_ value: String, type: String) throws -> String {
114
+ guard !closed, let buttons=current?.object?["document"]?.object?["buttons"]?.object else { throw IndianaFunnelError.invalidCommand }
115
+ for policy in buttons.values {
116
+ let action=policy.object?["action"]?.object
117
+ if policy.object?["type"]?.string == "action", action?["type"]?.string == type, action?[type == "event" ? "event" : "url"]?.string == value { return value }
118
+ }
119
+ throw IndianaFunnelError.invalidCommand
120
+ }
121
+ internal func completedOperation() throws -> IndianaJSON {
122
+ guard !closed, let operation, operation.object?["status"]?.string == "complete" else { throw IndianaFunnelError.invalidCommand }; return operation
123
+ }
124
+ }
@@ -0,0 +1,87 @@
1
+ #if canImport(UIKit)
2
+ import UIKit
3
+ @preconcurrency import SafariServices
4
+ import SwiftUI
5
+
6
+ /// Independent Paywall UI using the same packaged renderer as Funnel.
7
+ @MainActor public final class IndianaPaywallViewController: UIViewController, @preconcurrency SFSafariViewControllerDelegate {
8
+ public let session: IndianaPaywallSession
9
+ public var onPurchase: (@MainActor (IndianaJSON) async throws -> Void)?
10
+ public var onEvent: (@MainActor (String) async throws -> Void)?
11
+ public var onOpenURL: (@MainActor (URL) async throws -> Void)?
12
+ public var onComplete: (@MainActor (IndianaJSON) -> Void)?
13
+ public var onDismiss: (@MainActor () -> Void)?
14
+ public var onError: (@MainActor (Error) -> Void)?
15
+ private var host: IndianaBundledWebView!
16
+ private var stopped = false, dismissed = false
17
+ private var completions = Set<String>()
18
+ private var observer: IndianaForegroundObserver?
19
+ public init(session:IndianaPaywallSession) { self.session=session; super.init(nibName:nil,bundle:nil) }
20
+ required init?(coder:NSCoder) { fatalError("Use init(session:)") }
21
+ public override func loadView() {
22
+ host = IndianaBundledWebView(label:"Paywall",dispatch:{ [weak self] method,payload in
23
+ guard let self, !self.stopped else { throw IndianaClientError.identityChanged }
24
+ return try await self.dispatch(method,payload)
25
+ },onError:{ [weak self] error in self?.onError?(error) })
26
+ view=host.webView
27
+ Task {
28
+ await session.onInvalidation { [weak self] in Task { @MainActor in self?.invalidate() } }
29
+ guard !stopped else { return }; host.start()
30
+ }
31
+ observer=IndianaForegroundObserver { [weak self] in
32
+ Task { @MainActor in self?.host?.event("refresh") }
33
+ }
34
+ }
35
+ public func close() async { await session.close(); invalidate() }
36
+ private func invalidate() {
37
+ guard !stopped else { return }; stopped=true; host?.destroy()
38
+ observer=nil
39
+ presentedViewController?.dismiss(animated:false)
40
+ if !dismissed { dismissed=true; onDismiss?() }
41
+ }
42
+ private func dispatch(_ method:String,_ payload:IndianaJSON) async throws -> IndianaJSON {
43
+ switch method {
44
+ case "ready": host.send(await session.hostInit()); return .null
45
+ case "paywall": return try await session.hostCommand(payload)
46
+ case "purchase":
47
+ guard let value=payload.object?["operation"], let onPurchase else { throw IndianaClientError.unsupportedCapability }
48
+ try await onPurchase(try await session.trustedPurchase(value)); return .null
49
+ case "checkout":
50
+ guard let raw=payload.object?["url"]?.string else { throw IndianaFunnelError.invalidCommand }
51
+ open(try await session.trustedCheckout(raw)); return .null
52
+ case "open":
53
+ guard let raw=payload.object?["url"]?.string else { throw IndianaFunnelError.invalidCommand }
54
+ let trusted=try await session.trustedAction(raw,type:"open_url")
55
+ guard let url=URL(string:trusted),url.scheme == "https" else { throw IndianaFunnelError.invalidCommand }
56
+ if let onOpenURL { try await onOpenURL(url) } else { open(url) }; return .null
57
+ case "event":
58
+ guard let raw=payload.object?["event"]?.string,let onEvent else { throw IndianaClientError.unsupportedCapability }
59
+ try await onEvent(try await session.trustedAction(raw,type:"event")); return .null
60
+ case "complete":
61
+ let result=try await session.completedOperation()
62
+ if let id=result.object?["operationId"]?.string, completions.insert(id).inserted { onComplete?(result) }; return .null
63
+ case "dismiss": await close(); return .null
64
+ case "error": onError?(IndianaClientError.invalidResponse); return .null
65
+ default: throw IndianaFunnelError.invalidCommand
66
+ }
67
+ }
68
+ private func open(_ url:URL) { let browser=SFSafariViewController(url:url);browser.delegate=self;present(browser,animated:true) }
69
+ public func safariViewControllerDidFinish(_ controller:SFSafariViewController) { controller.dismiss(animated:true);host?.event("refresh") }
70
+ }
71
+
72
+ @MainActor public struct IndianaPaywallView: UIViewControllerRepresentable {
73
+ public let session:IndianaPaywallSession
74
+ public var onPurchase:(@MainActor (IndianaJSON) async throws -> Void)?
75
+ public var onEvent:(@MainActor (String) async throws -> Void)?
76
+ public var onOpenURL:(@MainActor (URL) async throws -> Void)?
77
+ public var onComplete:(@MainActor (IndianaJSON) -> Void)?
78
+ public var onDismiss:(@MainActor () -> Void)?
79
+ public var onError:(@MainActor (Error) -> Void)?
80
+ public init(session:IndianaPaywallSession,onPurchase:(@MainActor (IndianaJSON) async throws -> Void)?=nil,onEvent:(@MainActor (String) async throws -> Void)?=nil,onOpenURL:(@MainActor (URL) async throws -> Void)?=nil,onComplete:(@MainActor (IndianaJSON) -> Void)?=nil,onDismiss:(@MainActor () -> Void)?=nil,onError:(@MainActor (Error) -> Void)?=nil) {
81
+ self.session=session;self.onPurchase=onPurchase;self.onEvent=onEvent;self.onOpenURL=onOpenURL;self.onComplete=onComplete;self.onDismiss=onDismiss;self.onError=onError
82
+ }
83
+ public func makeUIViewController(context:Context)->IndianaPaywallViewController { let controller=IndianaPaywallViewController(session:session);updateUIViewController(controller,context:context);return controller }
84
+ public func updateUIViewController(_ controller:IndianaPaywallViewController,context:Context) { controller.onPurchase=onPurchase;controller.onEvent=onEvent;controller.onOpenURL=onOpenURL;controller.onComplete=onComplete;controller.onDismiss=onDismiss;controller.onError=onError }
85
+ public static func dismantleUIViewController(_ controller:IndianaPaywallViewController,coordinator:()) { Task { await controller.close() } }
86
+ }
87
+ #endif
@@ -0,0 +1,49 @@
1
+ import Foundation
2
+
3
+ public struct IndianaScope: Codable, Sendable, Equatable {
4
+ public let projectId: UUID
5
+ public let environment: IndianaEnvironment
6
+ public init(projectId: UUID, environment: IndianaEnvironment) { self.projectId = projectId; self.environment = environment }
7
+ public init(from decoder: Decoder) throws {
8
+ try IndianaWire.keys(decoder, IndianaProtocol.scopeKeys)
9
+ let c = try decoder.container(keyedBy: CodingKeys.self)
10
+ projectId = try IndianaWire.uuid(c.decode(String.self, forKey: .projectId)); environment = try c.decode(IndianaEnvironment.self, forKey: .environment)
11
+ }
12
+ }
13
+ public enum IndianaClientError: Error, Sendable {
14
+ case invalidConfiguration, unauthenticated, identityChanged, unsupportedCapability, invalidResponse, requestFailed(Int), temporaryUnavailable, protectedStorageUnavailable
15
+ }
16
+ enum IndianaDates {
17
+ static func parse(_ value: String) -> Date? {
18
+ guard value.range(of: IndianaProtocol.utcPattern, options: .regularExpression) != nil else { return nil }
19
+ // The current contract permits ISO timestamps with fractional precision and UTC offsets. Preserve the wire
20
+ // string and reduce only the Date conversion to nanoseconds after validating its calendar.
21
+ var parseValue = value
22
+ if let match = value.range(of: "(?<=\\.)[0-9]+", options: .regularExpression) {
23
+ parseValue.replaceSubrange(match, with: value[match].prefix(9))
24
+ }
25
+ let formatter = ISO8601DateFormatter()
26
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
27
+ if let date = formatter.date(from: parseValue) { return date }
28
+ formatter.formatOptions = [.withInternetDateTime]
29
+ return formatter.date(from: parseValue)
30
+ }
31
+ }
32
+ enum IndianaWire {
33
+ static func uuid(_ value: String) throws -> UUID {
34
+ guard value.range(of: IndianaProtocol.uuidPattern, options: .regularExpression) != nil,
35
+ let id = UUID(uuidString: value) else { throw IndianaClientError.invalidResponse }
36
+ return id
37
+ }
38
+ private struct Key: CodingKey { let stringValue: String; let intValue: Int? = nil; init?(stringValue: String) { self.stringValue = stringValue }; init?(intValue: Int) { return nil } }
39
+ static func keys(_ decoder: Decoder, _ expected: Set<String>) throws {
40
+ let c = try decoder.container(keyedBy: Key.self)
41
+ guard Set(c.allKeys.map(\.stringValue)) == expected else { throw IndianaClientError.invalidResponse }
42
+ }
43
+ static func fields(_ decoder: Decoder, allowed: Set<String>, required: Set<String>) throws {
44
+ let c = try decoder.container(keyedBy: Key.self)
45
+ let actual = Set(c.allKeys.map(\.stringValue))
46
+ guard actual.isSubset(of: allowed), required.isSubset(of: actual) else { throw IndianaClientError.invalidResponse }
47
+ }
48
+ }
49
+