@tinkerlandai/indiana-react-native 0.9.2 → 0.9.5

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 (83) hide show
  1. package/IndianaReactNative.podspec +19 -0
  2. package/README.md +61 -4
  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 +57408 -604
  10. package/dist/index.d.ts +57408 -604
  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 +193 -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/NativePendingKeystore.kt +81 -0
  33. package/native-sdk/android/java/ai/tinkerland/indiana/NativeReceiptRelay.kt +28 -0
  34. package/native-sdk/android/java/ai/tinkerland/indiana/NativeStoreOffer.kt +24 -0
  35. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallContent.kt +75 -0
  36. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +144 -0
  37. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallView.kt +74 -0
  38. package/native-sdk/android/java/ai/tinkerland/indiana/Protocol.kt +28 -0
  39. package/native-sdk/android/java/ai/tinkerland/indiana/PushInstallation.kt +170 -0
  40. package/native-sdk/android/java/ai/tinkerland/indiana/RendererNavigation.kt +13 -0
  41. package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +6 -0
  42. package/native-sdk/android/java/ai/tinkerland/indiana/StoreSubscriptionManagement.kt +19 -0
  43. package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +76 -0
  44. package/native-sdk/android/resources/funnel-wire-schemas.json +1 -0
  45. package/native-sdk/android/resources/indiana-third-party-notices.txt +101 -0
  46. package/native-sdk/android/resources/renderer-data-url.txt +1 -0
  47. package/native-sdk/ios/AnalyticsReceipt.swift +10 -0
  48. package/native-sdk/ios/AppleStorefront.swift +218 -0
  49. package/native-sdk/ios/BillingAuthorizationTerms.swift +57 -0
  50. package/native-sdk/ios/BundledWebView.swift +87 -0
  51. package/native-sdk/ios/ContentSession.swift +48 -0
  52. package/native-sdk/ios/ContentView.swift +51 -0
  53. package/native-sdk/ios/CustomerOperations.generated.swift +166 -0
  54. package/native-sdk/ios/CustomerResponseSemantics.swift +232 -0
  55. package/native-sdk/ios/CustomerSemantics.swift +145 -0
  56. package/native-sdk/ios/CustomerSession.swift +129 -0
  57. package/native-sdk/ios/FeatureClient.swift +86 -0
  58. package/native-sdk/ios/FunnelCommandStorage.swift +163 -0
  59. package/native-sdk/ios/FunnelConfigurationRelay.swift +48 -0
  60. package/native-sdk/ios/FunnelSession.swift +232 -0
  61. package/native-sdk/ios/FunnelView.swift +125 -0
  62. package/native-sdk/ios/GeneratedProtocol.swift +16 -0
  63. package/native-sdk/ios/HTTPTransport.swift +74 -0
  64. package/native-sdk/ios/IndianaClient.swift +601 -0
  65. package/native-sdk/ios/NativePendingKeychain.swift +52 -0
  66. package/native-sdk/ios/NativeReceiptRelay.swift +28 -0
  67. package/native-sdk/ios/NativeStoreOffer.swift +34 -0
  68. package/native-sdk/ios/PaywallContent.swift +75 -0
  69. package/native-sdk/ios/PaywallSession.swift +124 -0
  70. package/native-sdk/ios/PaywallView.swift +87 -0
  71. package/native-sdk/ios/Protocol.swift +49 -0
  72. package/native-sdk/ios/PushInstallation.swift +154 -0
  73. package/native-sdk/ios/RendererNavigation.swift +8 -0
  74. package/native-sdk/ios/Resources.swift +15 -0
  75. package/native-sdk/ios/SDKVersion.swift +4 -0
  76. package/native-sdk/ios/StoreSubscriptionManagement.swift +11 -0
  77. package/native-sdk/ios/WebView/THIRD_PARTY_NOTICES.txt +101 -0
  78. package/native-sdk/ios/WebView/index.html +268 -0
  79. package/native-sdk/ios/WebView/renderer-data-url.txt +1 -0
  80. package/native-sdk/ios/WireSchema.swift +107 -0
  81. package/native-sdk/ios/funnel-wire-schemas.json +1 -0
  82. package/package.json +8 -3
  83. package/react-native.config.cjs +12 -0
@@ -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: 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
+
@@ -0,0 +1,154 @@
1
+ import Foundation
2
+ import CryptoKit
3
+ import Security
4
+
5
+ public enum IndianaPushPermission: String, Codable, Sendable { case unknown, denied, provisional, granted }
6
+ public enum IndianaPushAddressKind: String, Sendable { case apnsToken = "apns_token", fcmToken = "fcm_token" }
7
+ public struct IndianaPushObservation: Sendable {
8
+ public let connectionId: UUID?
9
+ public let kind: IndianaPushAddressKind?
10
+ public let token: String?
11
+ public let permission: IndianaPushPermission
12
+ public init(connectionId: UUID?, kind: IndianaPushAddressKind?, token: String?, permission: IndianaPushPermission) throws {
13
+ guard (connectionId == nil && kind == nil && token == nil) ||
14
+ (connectionId != nil && kind != nil && token != nil && (16...4096).contains(token!.utf8.count) && token!.utf8.allSatisfy({ (33...126).contains($0) })) else { throw IndianaClientError.invalidConfiguration }
15
+ self.connectionId = connectionId; self.kind = kind; self.token = token; self.permission = permission
16
+ }
17
+ }
18
+ public struct IndianaPushInstallationState: Decodable, Sendable {
19
+ public let installationId: UUID
20
+ public let bound: Bool
21
+ public let identitySeq: Int64
22
+ public let bindingGeneration: Int64
23
+ public let addressSeq: Int64
24
+ public let addressRevision: Int64
25
+ public let connectionId: UUID?
26
+ public let addressStatus: String?
27
+ public let permission: IndianaPushPermission
28
+ func validate(installation: UUID) throws {
29
+ guard installationId == installation, (1...IndianaPushIdentity.maxSequence).contains(identitySeq),
30
+ (1...IndianaPushIdentity.maxSequence).contains(bindingGeneration), (0...IndianaPushIdentity.maxSequence).contains(addressSeq),
31
+ (0...IndianaPushIdentity.maxSequence).contains(addressRevision), addressStatus == nil || ["active", "invalid"].contains(addressStatus!) else { throw IndianaClientError.invalidResponse }
32
+ }
33
+ }
34
+ /// Capture before starting asynchronous OS token work; an old callback cannot replace a newer observation.
35
+ public struct IndianaPushObservationTicket: Sendable { let client: UUID; let identity: UUID; let observation: UUID }
36
+ public struct IndianaPushRecoveryRequest: Decodable, Sendable {
37
+ public let requested: Bool
38
+ public let expiresAt: String
39
+ }
40
+
41
+ struct IndianaPushJournal: Codable, Sendable {
42
+ var installationId: UUID
43
+ var privateKey: Data
44
+ var identitySeq: Int64
45
+ var addressSeq: Int64
46
+ var detachSeq: Int64?
47
+ func validate() throws {
48
+ guard (0...IndianaPushIdentity.maxSequence).contains(identitySeq), (0...IndianaPushIdentity.maxSequence).contains(addressSeq),
49
+ detachSeq == nil || (detachSeq! > 0 && detachSeq! <= identitySeq),
50
+ (try? P256.Signing.PrivateKey(rawRepresentation: privateKey)) != nil else { throw IndianaClientError.protectedStorageUnavailable }
51
+ }
52
+ }
53
+ protocol IndianaPushJournalStorage: Sendable {
54
+ /// A synchronous durable write: lifecycle changes cannot cross an await before the intent is saved.
55
+ func transaction(_ update: (IndianaPushJournal) throws -> IndianaPushJournal) throws -> IndianaPushJournal
56
+ }
57
+ final class IndianaPushKeychain: IndianaPushJournalStorage, @unchecked Sendable {
58
+ private let service: String
59
+ private static let lock = NSLock()
60
+ init(scope: IndianaScope) { service = "\(Bundle.main.bundleIdentifier ?? "Indiana").indiana.push.v1.\(scope.projectId.uuidString.lowercased()).\(scope.environment.rawValue)" }
61
+ func transaction(_ update: (IndianaPushJournal) throws -> IndianaPushJournal) throws -> IndianaPushJournal {
62
+ Self.lock.lock(); defer { Self.lock.unlock() }
63
+ let query: [String: Any] = [kSecClass as String:kSecClassGenericPassword, kSecAttrService as String:service, kSecAttrAccount as String:"installation"]
64
+ var result: CFTypeRef?
65
+ let status = SecItemCopyMatching(query.merging([kSecReturnData as String:true]) { _, new in new } as CFDictionary, &result)
66
+ let current: IndianaPushJournal
67
+ if status == errSecItemNotFound {
68
+ current = IndianaPushJournal(installationId: UUID(), privateKey: P256.Signing.PrivateKey().rawRepresentation, identitySeq: 0, addressSeq: 0, detachSeq: nil)
69
+ } else {
70
+ guard status == errSecSuccess, let data = result as? Data, data.count <= 4096,
71
+ let decoded = try? JSONDecoder().decode(IndianaPushJournal.self, from: data) else { throw IndianaClientError.protectedStorageUnavailable }
72
+ current = decoded
73
+ }
74
+ try current.validate()
75
+ let next = try update(current); try next.validate()
76
+ guard next.installationId == current.installationId, next.privateKey == current.privateKey,
77
+ next.identitySeq >= current.identitySeq, next.addressSeq >= current.addressSeq else { throw IndianaClientError.protectedStorageUnavailable }
78
+ if status == errSecItemNotFound || next.identitySeq != current.identitySeq || next.addressSeq != current.addressSeq || next.detachSeq != current.detachSeq {
79
+ let data = try JSONEncoder().encode(next)
80
+ let saved = status == errSecItemNotFound
81
+ ? SecItemAdd(query.merging([kSecValueData as String:data, kSecAttrAccessible as String:kSecAttrAccessibleWhenUnlockedThisDeviceOnly]) { _, new in new } as CFDictionary, nil)
82
+ : SecItemUpdate(query as CFDictionary, [kSecValueData as String:data] as CFDictionary)
83
+ guard saved == errSecSuccess else { throw IndianaClientError.protectedStorageUnavailable }
84
+ }
85
+ return next
86
+ }
87
+ }
88
+
89
+ /// Owned by the existing IndianaClient actor. No credential, transport or provider token is persisted here.
90
+ final class IndianaPushIdentity {
91
+ static let maxSequence: Int64 = 9_007_199_254_740_991
92
+ let storage: any IndianaPushJournalStorage
93
+ init(storage: any IndianaPushJournalStorage) throws { self.storage = storage; _ = try state() }
94
+ func state() throws -> IndianaPushJournal { try storage.transaction { $0 } }
95
+ func reserve(detach: Bool = false) throws -> Int64 {
96
+ try storage.transaction { current in
97
+ guard current.identitySeq < Self.maxSequence else { throw IndianaClientError.protectedStorageUnavailable }
98
+ var next = current; next.identitySeq += 1
99
+ if detach { next.detachSeq = next.identitySeq }
100
+ return next
101
+ }.identitySeq
102
+ }
103
+ func reserveAddress() throws -> Int64 {
104
+ try storage.transaction { current in
105
+ guard current.addressSeq < Self.maxSequence else { throw IndianaClientError.protectedStorageUnavailable }
106
+ var next = current; next.addressSeq += 1; return next
107
+ }.addressSeq
108
+ }
109
+ func clearDetach(_ sequence: Int64) throws {
110
+ _ = try storage.transaction { current in var next = current; if next.detachSeq == sequence { next.detachSeq = nil }; return next }
111
+ }
112
+ func recover(remote: IndianaPushInstallationState, expected: Int64) throws -> Int64 {
113
+ try storage.transaction { current in
114
+ guard current.identitySeq == expected else { throw IndianaClientError.identityChanged }
115
+ let floor = max(current.identitySeq, remote.identitySeq)
116
+ guard floor < Self.maxSequence else { throw IndianaClientError.protectedStorageUnavailable }
117
+ var next = current; next.identitySeq = floor + 1; next.addressSeq = max(current.addressSeq, remote.addressSeq); return next
118
+ }.identitySeq
119
+ }
120
+ static func digest(_ credential: String) -> String { SHA256.hash(data: Data(credential.utf8)).map { String(format: "%02x", $0) }.joined() }
121
+ static func base64url(_ bytes: Data) -> String { bytes.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") }
122
+ func command(_ operation: String, scope: IndianaScope, sequence: Int64? = nil, credential: String? = nil, observation: IndianaPushObservation? = nil, challenge: String? = nil) throws -> Data {
123
+ let journal = try state(), key = try P256.Signing.PrivateKey(rawRepresentation: journal.privateKey)
124
+ let publicKey = Self.base64url(key.publicKey.derRepresentation), installation = journal.installationId.uuidString.lowercased()
125
+ let formatter = ISO8601DateFormatter(); formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
126
+ let issuedAt = formatter.string(from: Date())
127
+ var fields: [Any] = ["indiana_push_v1", operation, scope.projectId.uuidString.lowercased(), scope.environment.rawValue, installation, publicKey, issuedAt]
128
+ var input: [String: Any] = ["installationId":installation, "publicKey":publicKey, "issuedAt":issuedAt]
129
+ let null = NSNull()
130
+ switch operation {
131
+ case "bind", "detach":
132
+ guard let sequence else { throw IndianaClientError.invalidConfiguration }
133
+ fields += [sequence, "ios"]; input["sequence"] = sequence; input["platform"] = "ios"
134
+ if operation == "bind" { guard let credential else { throw IndianaClientError.unauthenticated }; let digest = Self.digest(credential); fields.append(digest); input["credentialDigest"] = digest }
135
+ case "read":
136
+ guard let credential else { throw IndianaClientError.unauthenticated }; let digest = Self.digest(credential); fields.append(digest); input["credentialDigest"] = digest
137
+ case "address", "recovery_request", "recovery_confirm":
138
+ guard let sequence, let credential, let observation else { throw IndianaClientError.invalidConfiguration }
139
+ let digest = Self.digest(credential)
140
+ fields += [sequence, digest, observation.connectionId?.uuidString.lowercased() as Any? ?? null, observation.kind?.rawValue as Any? ?? null, observation.token as Any? ?? null, observation.permission.rawValue]
141
+ input["sequence"] = sequence; input["credentialDigest"] = digest; input["connectionId"] = observation.connectionId?.uuidString.lowercased() ?? null as Any
142
+ input["kind"] = observation.kind?.rawValue ?? null as Any; input["token"] = observation.token ?? null as Any; input["permission"] = observation.permission.rawValue
143
+ if operation == "recovery_confirm" {
144
+ guard let challenge, (100...3500).contains(challenge.utf8.count), challenge.utf8.allSatisfy({ (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) || $0 == 45 || $0 == 95 }) else { throw IndianaClientError.invalidConfiguration }
145
+ fields.append(challenge); input["challenge"] = challenge
146
+ }
147
+ default: throw IndianaClientError.invalidConfiguration
148
+ }
149
+ let bytes = try JSONSerialization.data(withJSONObject: fields, options: [.withoutEscapingSlashes])
150
+ input["signature"] = Self.base64url(try key.signature(for: bytes).rawRepresentation)
151
+ if operation == "detach" { input["projectId"] = scope.projectId.uuidString.lowercased(); input["environment"] = scope.environment.rawValue }
152
+ return try JSONSerialization.data(withJSONObject: input, options: [.withoutEscapingSlashes])
153
+ }
154
+ }
@@ -0,0 +1,8 @@
1
+ import Foundation
2
+
3
+ /// Subframes can execute only the renderer bytes shipped with this SDK.
4
+ func indianaAllowsFunnelRendererNavigation(_ offered: URL, baseURL: URL) -> Bool {
5
+ guard baseURL.absoluteString == "https://indiana-sdk.invalid/",
6
+ let expected = try? IndianaBundledWebViewResources.rendererURL() else { return false }
7
+ return offered.absoluteString.components(separatedBy: "#").first == expected
8
+ }