@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,86 @@
1
+ import Foundation
2
+
3
+ public struct IndianaFeatureResult: Codable, Sendable {
4
+ public let value: IndianaJSON
5
+ public let source: String
6
+ public let on: Bool
7
+ public let off: Bool
8
+ public let ruleId: String
9
+ public let experimentResult: IndianaJSON?
10
+ }
11
+
12
+ #if canImport(UIKit)
13
+ import UIKit
14
+
15
+ /// Local and private/remote Feature evaluation share the SDK's packaged GB engine.
16
+ /// The hidden shell receives no Customer Token; HTTP and identity remain native.
17
+ @MainActor public final class IndianaFeatureClient {
18
+ private let configuration: IndianaConfiguration
19
+ private let attributes: [String: IndianaJSON]
20
+ private let operation: @Sendable (IndianaCustomerOperation, [String: IndianaJSON]) async throws -> IndianaJSON
21
+ private var host: IndianaBundledWebView!
22
+ public var onError: (@MainActor (Error) -> Void)?
23
+ private var ready = false
24
+ private var stopped = false
25
+ private var readiness: [CheckedContinuation<Void, Error>] = []
26
+ private var pending: [UUID: CheckedContinuation<IndianaJSON, Error>] = [:]
27
+ init(configuration: IndianaConfiguration, attributes: [String: IndianaJSON], operation: @escaping @Sendable (IndianaCustomerOperation, [String: IndianaJSON]) async throws -> IndianaJSON) {
28
+ self.configuration = configuration; self.attributes = attributes; self.operation = operation
29
+ host = IndianaBundledWebView(label: "Indiana Feature evaluator", dispatch: { [weak self] method, payload in
30
+ guard let self else { throw IndianaClientError.identityChanged }; return try await self.dispatch(method, payload)
31
+ }, onError: { [weak self] error in guard let self else { return }; if !self.ready { self.close() } else { self.onError?(error) } })
32
+ host.start()
33
+ Task { [weak self] in try? await Task.sleep(nanoseconds: 45_000_000_000); if let self, !self.ready { self.close() } }
34
+ }
35
+ public func evalFeature(_ key: String) async throws -> IndianaFeatureResult {
36
+ let value = try await call(["method": .string("evalFeature"), "key": .string(key)])
37
+ let data = try JSONEncoder().encode(value)
38
+ try IndianaSchema.validate("nativeFeatureResult", data: data)
39
+ return try JSONDecoder().decode(IndianaFeatureResult.self, from: data)
40
+ }
41
+ public func isOn(_ key: String) async throws -> Bool { try await evalFeature(key).on }
42
+ public func isOff(_ key: String) async throws -> Bool { try await evalFeature(key).off }
43
+ public func getFeatureValue(_ key: String, fallback: IndianaJSON = .null) async throws -> IndianaJSON { try await call(["method": .string("getFeatureValue"), "key": .string(key), "fallback": fallback]) }
44
+ public func setAttributes(_ value: [String: IndianaJSON]) async throws { _ = try await call(["method": .string("setAttributes"), "attributes": .object(value)]) }
45
+ public func refresh() async throws { _ = try await call(["method": .string("refresh")]) }
46
+ public func flush() async throws { _ = try await call(["method": .string("flush")]) }
47
+ public func close() {
48
+ guard !stopped else { return }; stopped = true; host?.destroy()
49
+ let waiting = readiness; readiness = []; for continuation in waiting { continuation.resume(throwing: IndianaClientError.identityChanged) }
50
+ let calls = pending; pending = [:]; for continuation in calls.values { continuation.resume(throwing: IndianaClientError.identityChanged) }
51
+ }
52
+ private func call(_ command: [String: IndianaJSON]) async throws -> IndianaJSON {
53
+ guard !stopped else { throw IndianaClientError.identityChanged }
54
+ if !ready { try await withCheckedThrowingContinuation { readiness.append($0) } }
55
+ guard !stopped else { throw IndianaClientError.identityChanged }
56
+ let id = UUID()
57
+ return try await withCheckedThrowingContinuation { continuation in
58
+ pending[id] = continuation
59
+ host.send(.object(["event": .string("feature_call"), "id": .string(id.uuidString.lowercased()), "input": .object(command)]))
60
+ Task { [weak self] in
61
+ try? await Task.sleep(nanoseconds: 45_000_000_000)
62
+ self?.pending.removeValue(forKey: id)?.resume(throwing: IndianaClientError.temporaryUnavailable)
63
+ }
64
+ }
65
+ }
66
+ private func dispatch(_ method: String, _ value: IndianaJSON) async throws -> IndianaJSON {
67
+ switch method {
68
+ case "ready":
69
+ host.send(.object(["event": .string("init"), "surface": .string("features"), "scope": .object(["projectId": .string(configuration.scope.projectId.uuidString.lowercased()), "environment": .string(configuration.scope.environment.rawValue)]), "apiOrigin": .string(configuration.apiOrigin), "attributes": .object(attributes)])); return .null
70
+ case "featureOperation":
71
+ guard !stopped, let name = value.object?["operation"]?.string,
72
+ ["getFeatures", "evaluateFeatures", "trackEvents"].contains(name), let selected = IndianaCustomerOperation(rawValue: name),
73
+ let input = value.object?["input"]?.object else { throw IndianaFunnelError.invalidCommand }
74
+ return try await operation(selected, input)
75
+ case "featuresReady":
76
+ guard !stopped else { throw IndianaClientError.identityChanged }; ready = true
77
+ let waiting = readiness; readiness = []; for continuation in waiting { continuation.resume() }; return .null
78
+ case "featureResult":
79
+ guard !stopped, let id = value.object?["id"]?.string.flatMap(UUID.init(uuidString:)), let continuation = pending.removeValue(forKey: id) else { return .null }
80
+ if value.object?["error"] != nil { continuation.resume(throwing: IndianaClientError.invalidResponse) }
81
+ else { continuation.resume(returning: value.object?["result"] ?? .null) }; return .null
82
+ default: throw IndianaFunnelError.invalidCommand
83
+ }
84
+ }
85
+ }
86
+ #endif
@@ -0,0 +1,163 @@
1
+ import Foundation
2
+ import Security
3
+ import CryptoKit
4
+ import Darwin
5
+
6
+ public struct IndianaFunnelStorageSnapshot: Sendable {
7
+ public let revision: Int
8
+ public let entry: Data?
9
+ public init(revision: Int, entry: Data?) { self.revision = revision; self.entry = entry }
10
+ }
11
+ /// One original Run per adapter. CAS must be atomic across all participating hosts.
12
+ public protocol IndianaFunnelCommandStorage: Sendable {
13
+ func read() async throws -> IndianaFunnelStorageSnapshot
14
+ func write(expectedRevision: Int, entry: Data?) async throws -> Int?
15
+ }
16
+
17
+ /// This-device-only storage. Neither Run nor Customer credentials are persisted.
18
+ actor IndianaFunnelCommandKeychain: IndianaFunnelCommandStorage {
19
+ private let service: String
20
+ private let lockURL: URL
21
+ private struct Stored: Codable { let revision: Int; let entry: Data? }
22
+ init(configuration: IndianaConfiguration, runToken: String) throws {
23
+ let namespace = "\(configuration.baseURL.absoluteString)|\(configuration.scope.projectId)|\(configuration.scope.environment.rawValue)|\(runToken)"
24
+ let hash = SHA256.hash(data: Data(namespace.utf8)).map { String(format: "%02x", $0) }.joined()
25
+ service = "\(Bundle.main.bundleIdentifier ?? "Indiana").indiana.funnel.commands.v1.\(hash)"
26
+ let folder = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true).appendingPathComponent("IndianaFunnelCommandLocks", isDirectory: true)
27
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
28
+ lockURL = folder.appendingPathComponent(hash + ".lock")
29
+ }
30
+ private var query: [String: Any] { [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: "snapshot"] }
31
+ private func locked<T>(_ work: () throws -> T) throws -> T {
32
+ let fd = open(lockURL.path, O_CREAT | O_RDWR, 0o600)
33
+ guard fd >= 0 else { throw IndianaClientError.protectedStorageUnavailable }
34
+ defer { Darwin.close(fd) }
35
+ guard flock(fd, LOCK_EX) == 0 else { throw IndianaClientError.protectedStorageUnavailable }
36
+ defer { flock(fd, LOCK_UN) }
37
+ return try work()
38
+ }
39
+ private func stored() throws -> Stored {
40
+ var result: CFTypeRef?
41
+ let status = SecItemCopyMatching(query.merging([kSecReturnData as String: true]) { _, new in new } as CFDictionary, &result)
42
+ if status == errSecItemNotFound { return Stored(revision: 0, entry: nil) }
43
+ guard status == errSecSuccess, let bytes = result as? Data, bytes.count <= 1_048_576,
44
+ let value = try? JSONDecoder().decode(Stored.self, from: bytes), value.revision >= 0 else { throw IndianaClientError.protectedStorageUnavailable }
45
+ return value
46
+ }
47
+ func read() throws -> IndianaFunnelStorageSnapshot { try locked { let value = try stored(); return IndianaFunnelStorageSnapshot(revision: value.revision, entry: value.entry) } }
48
+ func write(expectedRevision: Int, entry: Data?) throws -> Int? {
49
+ try locked {
50
+ let previous = try stored()
51
+ guard previous.revision == expectedRevision else { return nil }
52
+ guard expectedRevision < Int.max, (entry?.count ?? 0) <= 600_000 else { throw IndianaClientError.protectedStorageUnavailable }
53
+ let bytes = try JSONEncoder().encode(Stored(revision: expectedRevision + 1, entry: entry))
54
+ let status = SecItemUpdate(query as CFDictionary, [kSecValueData as String: bytes] as CFDictionary)
55
+ if status == errSecItemNotFound {
56
+ let added = SecItemAdd(query.merging([kSecValueData as String: bytes, kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly]) { _, new in new } as CFDictionary, nil)
57
+ guard added == errSecSuccess else { throw IndianaClientError.protectedStorageUnavailable }
58
+ } else if status != errSecSuccess { throw IndianaClientError.protectedStorageUnavailable }
59
+ return expectedRevision + 1
60
+ }
61
+ }
62
+ }
63
+
64
+ struct IndianaFunnelRecoveryRecord: Codable, Sendable, Equatable {
65
+ let schemaVersion: Int
66
+ let scope: IndianaScope
67
+ let runId: UUID
68
+ let funnelId: UUID
69
+ let versionId: UUID
70
+ var command: [String: IndianaJSON]?
71
+ var closeCommand: [String: IndianaJSON]?
72
+ var purchase: [String: IndianaJSON]?
73
+ var closed: Bool
74
+ func matches(_ run: IndianaFunnelRun) -> Bool { run.id == runId && run.source.id == funnelId && run.source.versionId == versionId }
75
+ }
76
+ actor IndianaFunnelCommandJournal {
77
+ private let storage: any IndianaFunnelCommandStorage
78
+ private let scope: IndianaScope
79
+ private var revision = 0
80
+ private var binding: IndianaFunnelRecoveryRecord?
81
+ private var updateTail: Task<Void, Never>?
82
+ init(storage: any IndianaFunnelCommandStorage, scope: IndianaScope) { self.storage = storage; self.scope = scope }
83
+ private func read() async throws -> (Int, IndianaFunnelRecoveryRecord?) {
84
+ let saved = try await storage.read()
85
+ guard saved.revision >= 0 else { throw IndianaFunnelError.invalidResponse }
86
+ guard let bytes = saved.entry else { return (saved.revision, nil) }
87
+ guard bytes.count <= 600_000 else { throw IndianaFunnelError.invalidResponse }
88
+ let record = try JSONDecoder().decode(IndianaFunnelRecoveryRecord.self, from: bytes)
89
+ guard record.schemaVersion == 1, record.scope == scope, record.command != nil || record.purchase != nil,
90
+ record.closeCommand == nil || record.command != nil && record.command?["operation"] != .string("close") && record.command?["requestId"] != record.closeCommand?["requestId"] else { throw IndianaFunnelError.invalidResponse }
91
+ for command in [record.command, record.closeCommand].compactMap({ $0 }) {
92
+ guard command["customerToken"] == nil, !["start", "get", "review"].contains(command["operation"]?.string ?? "") else { throw IndianaFunnelError.invalidCommand }
93
+ try IndianaSchema.validate("command", data: JSONEncoder().encode(IndianaJSON.object(command)))
94
+ }
95
+ if let purchase = record.purchase {
96
+ guard Set(purchase.keys) == Set(["receiptId", "operationId", "generation"]),
97
+ UUID(uuidString: purchase["receiptId"]?.string ?? "") != nil, UUID(uuidString: purchase["operationId"]?.string ?? "") != nil,
98
+ case .number(let generation) = purchase["generation"], generation >= 0, generation.rounded() == generation else { throw IndianaFunnelError.invalidResponse }
99
+ }
100
+ return (saved.revision, record)
101
+ }
102
+ func load() async throws -> IndianaFunnelRecoveryRecord? { let saved = try await read(); revision = saved.0; binding = saved.1; return saved.1 }
103
+ func bind(_ run: IndianaFunnelRun) throws { if let binding, !binding.matches(run) { throw IndianaFunnelError.invalidResponse } }
104
+ private func update(_ change: @escaping @Sendable (IndianaFunnelRecoveryRecord?, Int) throws -> IndianaFunnelRecoveryRecord?) async throws {
105
+ let previous = updateTail
106
+ let job = Task { if let previous { await previous.value }; try await apply(change) }
107
+ updateTail = Task { _ = try? await job.value }
108
+ try await job.value
109
+ }
110
+ private func apply(_ change: @Sendable (IndianaFunnelRecoveryRecord?, Int) throws -> IndianaFunnelRecoveryRecord?) async throws {
111
+ for _ in 0..<4 {
112
+ let saved = try await read()
113
+ if let binding, let current = saved.1, binding.runId != current.runId || binding.versionId != current.versionId || binding.funnelId != current.funnelId { throw IndianaFunnelError.commandPending }
114
+ let next = try change(saved.1, saved.0)
115
+ if next == saved.1 { revision = saved.0; return }
116
+ let bytes = try next.map { try JSONEncoder().encode($0) }
117
+ if let nextRevision = try await storage.write(expectedRevision: saved.0, entry: bytes) { revision = nextRevision; binding = next ?? binding; return }
118
+ }
119
+ throw IndianaFunnelError.commandPending
120
+ }
121
+ func retain(_ command: [String: IndianaJSON], run: IndianaFunnelRun) async throws {
122
+ let bytes = try JSONEncoder().encode(IndianaJSON.object(command))
123
+ guard command["customerToken"] == nil, bytes.count <= 262144, !["start", "get", "review"].contains(command["operation"]?.string ?? "") else { throw IndianaFunnelError.invalidCommand }
124
+ try IndianaSchema.validate("command", data: bytes)
125
+ try bind(run)
126
+ let scope = self.scope
127
+ let knownRevision = revision
128
+ try await update { saved, currentRevision in
129
+ var value = saved ?? IndianaFunnelRecoveryRecord(schemaVersion: 1, scope: scope, runId: run.id, funnelId: run.source.id, versionId: run.source.versionId, closed: false)
130
+ guard value.matches(run), saved != nil || currentRevision == knownRevision || command["operation"] == .string("close") else { throw IndianaFunnelError.commandPending }
131
+ if command["operation"] == .string("close") {
132
+ value.closed = true
133
+ if let original = value.command, original["operation"] != .string("close") {
134
+ guard original["requestId"] != command["requestId"], value.closeCommand == nil || value.closeCommand == command else { throw IndianaFunnelError.commandPending }; value.closeCommand = command
135
+ } else { guard value.command == nil || value.command == command else { throw IndianaFunnelError.commandPending }; value.command = command }
136
+ } else { guard value.command == nil || value.command == command else { throw IndianaFunnelError.commandPending }; value.command = command }
137
+ return value
138
+ }
139
+ }
140
+ func purchase(_ purchase: [String: IndianaJSON], terminal: Bool, run: IndianaFunnelRun) async throws {
141
+ try bind(run); let knownRevision = revision
142
+ let scope = self.scope
143
+ try await update { saved, currentRevision in
144
+ if saved == nil && currentRevision != knownRevision { throw IndianaFunnelError.commandPending }
145
+ var value = saved ?? IndianaFunnelRecoveryRecord(schemaVersion: 1, scope: scope, runId: run.id, funnelId: run.source.id, versionId: run.source.versionId, closed: false)
146
+ if case .number(let old) = value.purchase?["generation"], case .number(let next) = purchase["generation"] {
147
+ if old > next { return saved }
148
+ if old == next && value.purchase?["operationId"] != purchase["operationId"] { throw IndianaFunnelError.commandPending }
149
+ }
150
+ if terminal { if value.purchase?["operationId"] == purchase["operationId"] { value.purchase = nil } }
151
+ else { value.purchase = purchase }
152
+ return value.command == nil && value.purchase == nil ? nil : value
153
+ }
154
+ }
155
+ func settle(_ id: String) async throws {
156
+ try await update { saved, _ in
157
+ guard var value = saved else { return nil }
158
+ if value.command?["requestId"] == .string(id) { value.command = value.closeCommand; value.closeCommand = nil }
159
+ else if value.closeCommand?["requestId"] == .string(id) { value.closeCommand = nil }
160
+ return value.command == nil && value.purchase == nil ? nil : value
161
+ }
162
+ }
163
+ }
@@ -0,0 +1,48 @@
1
+ import Foundation
2
+
3
+ /// The Funnel shell has no network access (`connect-src 'none'`). For Local rules it asks the native host
4
+ /// for public configuration: one exact GET, without credentials, cookies or redirects.
5
+ final class IndianaFunnelConfigurationRelay: Sendable {
6
+ static let maxBytes = 1_048_576
7
+ static let headers: Set<String> = ["age", "content-type", "date", "etag", "retry-after", "x-indiana-config-valid-until", "x-indiana-config-validated-at"]
8
+ private let baseURL: URL
9
+ private let session: URLSession
10
+ init(configuration: IndianaConfiguration, sessionConfiguration: URLSessionConfiguration = .ephemeral) {
11
+ baseURL = configuration.baseURL
12
+ let config = sessionConfiguration.copy() as! URLSessionConfiguration
13
+ config.urlCache = nil; config.httpCookieStorage = nil; config.httpShouldSetCookies = false; config.requestCachePolicy = .reloadIgnoringLocalCacheData
14
+ config.timeoutIntervalForRequest = configuration.timeout; config.timeoutIntervalForResource = configuration.timeout
15
+ session = URLSession(configuration: config, delegate: RefuseRedirect(), delegateQueue: nil)
16
+ }
17
+ deinit { session.invalidateAndCancel() }
18
+ func read(_ payload: IndianaJSON) async throws -> IndianaJSON {
19
+ guard let object = payload.object, object.count == 1, let path = object["path"]?.string,
20
+ path.range(of: "^/api/v1/configuration/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(/artifacts/[a-f0-9]{64})?$", options: .regularExpression) != nil,
21
+ let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else { throw IndianaFunnelError.invalidCommand }
22
+ var request = URLRequest(url: url); request.httpMethod = "GET"
23
+ request.cachePolicy = path.contains("/artifacts/") ? .useProtocolCachePolicy : .reloadIgnoringLocalCacheData
24
+ do {
25
+ let (bytes, response) = try await session.bytes(for: request)
26
+ guard let http = response as? HTTPURLResponse else { throw IndianaClientError.temporaryUnavailable }
27
+ var headers: [String: IndianaJSON] = [:]
28
+ for (key, value) in http.allHeaderFields {
29
+ if let key = (key as? String)?.lowercased(), Self.headers.contains(key), let value = value as? String { headers[key] = .string(value) }
30
+ }
31
+ var data = Data()
32
+ // The shell reports other statuses (including 429 Retry-After) by status alone.
33
+ if (200..<300).contains(http.statusCode) {
34
+ if response.expectedContentLength > Self.maxBytes { throw IndianaClientError.invalidResponse }
35
+ for try await byte in bytes {
36
+ guard data.count < Self.maxBytes else { throw IndianaClientError.invalidResponse }
37
+ data.append(byte)
38
+ }
39
+ }
40
+ guard let body = String(data: data, encoding: .utf8) else { throw IndianaClientError.invalidResponse }
41
+ return .object(["status": .number(Double(http.statusCode)), "headers": .object(headers), "body": .string(body)])
42
+ } catch let error as IndianaClientError { throw error }
43
+ catch { throw IndianaClientError.temporaryUnavailable }
44
+ }
45
+ private final class RefuseRedirect: NSObject, URLSessionTaskDelegate, Sendable {
46
+ func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) }
47
+ }
48
+ }
@@ -0,0 +1,232 @@
1
+ import Foundation
2
+
3
+ public indirect enum IndianaJSON: Codable, Sendable, Equatable {
4
+ case object([String: IndianaJSON]), array([IndianaJSON]), string(String), number(Double), bool(Bool), null
5
+ public init(from decoder: Decoder) throws {
6
+ let c = try decoder.singleValueContainer()
7
+ if c.decodeNil() { self = .null }
8
+ else if let v = try? c.decode(Bool.self) { self = .bool(v) }
9
+ else if let v = try? c.decode(String.self) { self = .string(v) }
10
+ else if let v = try? c.decode(Double.self), v.isFinite { self = .number(v) }
11
+ else if let v = try? c.decode([String: IndianaJSON].self) { self = .object(v) }
12
+ else { self = .array(try c.decode([IndianaJSON].self)) }
13
+ }
14
+ public func encode(to encoder: Encoder) throws {
15
+ var c = encoder.singleValueContainer()
16
+ switch self { case .object(let v): try c.encode(v); case .array(let v): try c.encode(v); case .string(let v): try c.encode(v); case .number(let v): try c.encode(v); case .bool(let v): try c.encode(v); case .null: try c.encodeNil() }
17
+ }
18
+ var object: [String: IndianaJSON]? { if case .object(let value) = self { return value }; return nil }
19
+ var string: String? { if case .string(let value) = self { return value }; return nil }
20
+ }
21
+ public struct IndianaExecutionSource: Codable, Sendable, Equatable { public let type: String; public let id: UUID; public let versionId: UUID }
22
+ public struct IndianaFunnelRun: Codable, Sendable {
23
+ public let id: UUID; public let source: IndianaExecutionSource
24
+ public let revision: Int; public let visit: Int; public let nodeId: String; public let nodeType: String; public let status: String
25
+ public let canBack: Bool; public let authenticated: Bool; public let locale: String
26
+ public let answers: [String: IndianaJSON]; public let parameters: [String: IndianaJSON]; public let variables: [String: IndianaJSON]
27
+ public let output: IndianaJSON?; public let screen: IndianaJSON?; public let paywall: IndianaJSON?; public let instruction: IndianaJSON?
28
+ public let runExpiresAt: String; public let credentialExpiresAt: String?; public let pendingRequest: Pending?
29
+ public struct Pending: Codable, Sendable { public let requestId: UUID; public let authenticationRequired: Bool }
30
+ }
31
+ public enum IndianaFunnelError: Error { case invalidCommand, commandPending, invalidResponse, identityChanged, pendingPayment }
32
+ /// A Funnel session owns navigation only. Native receipt recovery and financial operations belong to IndianaClient.
33
+ public actor IndianaFunnelSession {
34
+ private let configuration: IndianaConfiguration; private let http: any IndianaHTTP; private let runToken: String
35
+ private var publicPaywallContent: (@Sendable (IndianaJSON) async throws -> IndianaJSON)?
36
+ private var publicPresentation: [String: IndianaJSON]?
37
+ private let journal: IndianaFunnelCommandJournal?
38
+ private var restoration: Task<IndianaFunnelRecoveryRecord?, Error>?
39
+ private var restored = false
40
+ public private(set) var pendingPurchase: [String: IndianaJSON]?
41
+ private var customerToken: String?; private var epoch = UUID(); private var pending: [String: IndianaJSON]?; private var sending = false; private var closingCommand: [String: IndianaJSON]?; private var closeRequested = false; private var uncertain = Set<String>(); private var settlements: [String: String] = [:]
42
+ private var invalidated = false; private var invalidation: (@Sendable () -> Void)?; private var response: Data?
43
+ public private(set) var current: IndianaFunnelRun?
44
+ public init(configuration: IndianaConfiguration, runToken: String, commandStorage: (any IndianaFunnelCommandStorage)? = nil) throws {
45
+ guard runToken.range(of: "^gg_fr_[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil else { throw IndianaFunnelError.invalidCommand }
46
+ self.configuration = configuration;self.http = IndianaURLTransport(configuration: configuration);self.runToken = runToken
47
+ journal = IndianaFunnelCommandJournal(storage: try commandStorage ?? IndianaFunnelCommandKeychain(configuration: configuration, runToken: runToken), scope: configuration.scope)
48
+ }
49
+ init(configuration: IndianaConfiguration, http: any IndianaHTTP, runToken: String, commandStorage: (any IndianaFunnelCommandStorage)? = nil, publicPaywallContent: (@Sendable (IndianaJSON) async throws -> IndianaJSON)? = nil) throws {
50
+ self.publicPaywallContent = publicPaywallContent
51
+ guard runToken.range(of: "^gg_fr_[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil else { throw IndianaFunnelError.invalidCommand }
52
+ self.configuration = configuration;self.http = http;self.runToken = runToken
53
+ journal = commandStorage.map { IndianaFunnelCommandJournal(storage: $0, scope: configuration.scope) }
54
+ }
55
+ private func restoreCommands() async throws {
56
+ guard !restored, let journal else { return }
57
+ if restoration == nil { restoration = Task { try await journal.load() } }
58
+ let original = epoch
59
+ let record = try await restoration!.value
60
+ guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
61
+ if restored { return }; restored = true
62
+ pending = record?.command; closingCommand = record?.closeCommand; pendingPurchase = record?.purchase; closeRequested = record?.closed == true || pending?["operation"] == .string("close")
63
+ for command in [pending, closingCommand].compactMap({ $0 }) { if let id = command["requestId"]?.string { uncertain.insert(id) } }
64
+ }
65
+ public func identify(customerToken: String) throws {
66
+ guard !invalidated, customerToken.range(of: "^gg_ct_[A-Za-z0-9_-]{32,128}$", options: .regularExpression) != nil else { throw IndianaClientError.unauthenticated }
67
+ epoch = UUID();self.customerToken = customerToken;response = nil;publicPresentation = nil
68
+ }
69
+ public func reset() { epoch = UUID();invalidated = true;customerToken = nil;pending = nil;closingCommand = nil;pendingPurchase = nil;uncertain.removeAll();settlements.removeAll();current = nil;response = nil;publicPresentation = nil;invalidation?();invalidation = nil }
70
+ func hostConfiguration() -> IndianaConfiguration { configuration }
71
+ func onInvalidation(_ callback: @escaping @Sendable () -> Void) { invalidation = callback;if invalidated { callback() } }
72
+ func hostPendingCommand() -> IndianaJSON { (closingCommand ?? pending).map(IndianaJSON.object) ?? .null }
73
+ private func request(_ command: [String: IndianaJSON]) async throws -> IndianaFunnelRun {
74
+ try await restoreCommands()
75
+ guard !invalidated else { throw IndianaFunnelError.identityChanged }
76
+ let original = epoch;var body = command;if let customerToken { body["customerToken"] = .string(customerToken) }
77
+ let encoder = JSONEncoder();encoder.outputFormatting = [.sortedKeys];let input = try encoder.encode(body)
78
+ guard input.count <= 262144 else { throw IndianaFunnelError.invalidCommand };try IndianaSchema.validate("command", data: input)
79
+ let bytes = try await http.send(path: "funnels", method: "POST", authorization: runToken, body: input)
80
+ guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
81
+ try IndianaSchema.validate("response", data: bytes)
82
+ struct WireScope: Decodable { let scope: IndianaScope }
83
+ guard try JSONDecoder().decode(WireScope.self, from: bytes).scope == configuration.scope else { throw IndianaFunnelError.invalidResponse }
84
+ struct Reply: Decodable { let scope: IndianaScope; let run: IndianaFunnelRun }
85
+ let reply = try JSONDecoder().decode(Reply.self, from: bytes)
86
+ let object = try JSONDecoder().decode(IndianaJSON.self, from: bytes).object
87
+ if let id = command["settlementRequestId"]?.string ?? command["requestId"]?.string {
88
+ guard object?["settlement"]?.object?["requestId"] == .string(id) else { throw IndianaFunnelError.invalidResponse }
89
+ }
90
+ guard reply.scope == configuration.scope, reply.run.source.type == "funnel" else { throw IndianaFunnelError.invalidResponse }
91
+ try await journal?.bind(reply.run)
92
+ guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
93
+ if let current, current.id != reply.run.id || current.source != reply.run.source || current.locale != reply.run.locale || current.parameters != reply.run.parameters { throw IndianaFunnelError.invalidResponse }
94
+ if let instruction = reply.run.instruction?.object, instruction["type"] == .string("open_url") {
95
+ guard let text = instruction["url"]?.string, let url = URL(string: text), url.scheme == "https", url.host != nil, url.user == nil, url.password == nil else { throw IndianaFunnelError.invalidResponse }
96
+ }
97
+ if let settlement = object?["settlement"]?.object, let id = settlement["requestId"]?.string, let state = settlement["state"]?.string { settlements[id] = state }
98
+ if let previous = current?.paywall?.object, let incoming = reply.run.paywall?.object, previous["receiptId"] == incoming["receiptId"],
99
+ previous["resolution"]?.object?["content"]?.object?["artifact"]?.object?["sha256"] != incoming["resolution"]?.object?["content"]?.object?["artifact"]?.object?["sha256"] { throw IndianaFunnelError.invalidResponse }
100
+ if let current, let previous = current.paywall?.object, let incoming = reply.run.paywall?.object,
101
+ previous["receiptId"] == incoming["receiptId"], case .number(let old) = previous["selectionGeneration"],
102
+ case .number(let next) = incoming["selectionGeneration"], next < old { return current }
103
+ if journal != nil, command["operation"] != .string("review"), current == nil || reply.run.revision >= current!.revision {
104
+ let purchase = reply.run.paywall?.object ?? object?["purchase"]?.object
105
+ let operation = purchase?["operation"]?.object
106
+ let rawSettlement = object?["settlement"]?.object
107
+ let paywall = reply.run.paywall?.object
108
+ let settlement = (paywall == nil || paywall?["purchaseRequestId"] == rawSettlement?["requestId"]) &&
109
+ (pendingPurchase == nil || pendingPurchase?["operationId"] == rawSettlement?["operationId"]) ? rawSettlement : nil
110
+ if let receipt = purchase?["receiptId"] ?? settlement?["receiptId"], receipt != .null,
111
+ let operationId = operation?["operationId"] ?? purchase?["quote"]?.object?["operationId"] ?? settlement?["operationId"], operationId != .null {
112
+ let generation = reply.run.paywall?.object?["selectionGeneration"] ?? pendingPurchase?["generation"] ?? .number(0)
113
+ let reference: [String: IndianaJSON] = ["receiptId": receipt, "operationId": operationId, "generation": generation]
114
+ let terminal = operation?["status"] == .string("complete") || purchase?["canReselect"] == .bool(true)
115
+ let stale: Bool
116
+ if case .number(let old) = pendingPurchase?["generation"], case .number(let next) = generation { stale = next < old } else { stale = false }
117
+ if !stale {
118
+ try await journal?.purchase(reference, terminal: terminal, run: reply.run)
119
+ guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
120
+ if !terminal { pendingPurchase = reference } else if pendingPurchase?["operationId"] == operationId { pendingPurchase = nil }
121
+ }
122
+ }
123
+ }
124
+ guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
125
+ if current == nil || reply.run.revision >= current!.revision { response = bytes; current = reply.run };return current!
126
+ }
127
+ public func get(data: [String: IndianaJSON]? = nil, purchase: [String: IndianaJSON]? = nil) async throws -> IndianaFunnelRun {
128
+ try await restoreCommands()
129
+ var command: [String: IndianaJSON] = ["operation": .string("get")];if let data { command["data"] = .object(data) }
130
+ if let purchase { command["purchase"] = .object(purchase) }
131
+ else if let reference = pendingPurchase, let receipt = reference["receiptId"], let operation = reference["operationId"] { command["purchase"] = .object(["receiptId": receipt, "operationId": operation]) }
132
+ return try await request(command)
133
+ }
134
+ private func mutate(_ command: [String: IndianaJSON]) async throws -> IndianaFunnelRun {
135
+ try await restoreCommands()
136
+ let closing = command["operation"] == .string("close"), id = command["requestId"]?.string ?? ""
137
+ if closeRequested && !closing { throw IndianaFunnelError.commandPending }
138
+ if sending && !closing { throw IndianaFunnelError.commandPending }
139
+ if closing {
140
+ closeRequested = true
141
+ if let closingCommand, closingCommand != command { throw IndianaFunnelError.commandPending }
142
+ closingCommand = command
143
+ } else {
144
+ if let pending, pending != command { throw IndianaFunnelError.commandPending };pending = command
145
+ }
146
+ let wasUncertain = uncertain.contains(id);uncertain.insert(id);sending = true;let original = epoch
147
+ defer { sending = false }
148
+ do {
149
+ if let journal { guard let current else { throw IndianaFunnelError.invalidCommand }; try await journal.retain(command, run: current) }
150
+ let run = try await request(command)
151
+ let settled = settlements[id] == "settled"
152
+ if original == epoch, run.pendingRequest?.requestId.uuidString.lowercased() != id,
153
+ settled,
154
+ (!closeRequested || closing || settled && !["running", "waiting"].contains(run.status)) {
155
+ try await journal?.settle(id)
156
+ if pending == command { pending = nil };if closingCommand == command { closingCommand = nil };uncertain.remove(id)
157
+ }
158
+ return run
159
+ } catch {
160
+ if original == epoch, !wasUncertain, !closeRequested, let failure = error as? IndianaClientError, case .requestFailed(let status) = failure, status < 500, status != 401, status != 425 { try await journal?.settle(id); if pending == command { pending = nil };uncertain.remove(id) }
161
+ throw error
162
+ }
163
+ }
164
+ public func settle(requestId: UUID) async throws -> IndianaFunnelRun {
165
+ let id = requestId.uuidString.lowercased();settlements.removeValue(forKey: id);let run = try await request(["operation": .string("get"), "settlementRequestId": .string(id)])
166
+ guard let state = settlements[id] else { throw IndianaFunnelError.invalidResponse }
167
+ if state == "settled" { try await journal?.settle(id); if pending?["requestId"] == .string(id) { pending = nil };if closingCommand?["requestId"] == .string(id) { closingCommand = nil };uncertain.remove(id) };return run
168
+ }
169
+ private func base(_ operation: String, requestId: UUID) throws -> [String: IndianaJSON] {
170
+ guard let current, !invalidated else { throw IndianaFunnelError.invalidCommand }
171
+ return ["operation": .string(operation), "requestId": .string(requestId.uuidString.lowercased()), "nodeId": .string(current.nodeId), "visit": .number(Double(current.visit)), "revision": .number(Double(current.revision))]
172
+ }
173
+ public func submit(output: String, answers: [String: IndianaJSON], selection: [String: IndianaJSON] = [:], requestId: UUID = UUID()) async throws -> IndianaFunnelRun {
174
+ var command = try base("submit", requestId: requestId);command["input"] = .object(["type": .string("screen"), "output": .string(output), "answers": .object(answers), "selection": .object(selection)]);return try await mutate(command)
175
+ }
176
+ public func submit(input: [String: IndianaJSON], requestId: UUID = UUID()) async throws -> IndianaFunnelRun { var command = try base("submit", requestId: requestId);command["input"] = .object(input);return try await mutate(command) }
177
+ public func back(requestId: UUID = UUID()) async throws -> IndianaFunnelRun { try await mutate(base("back", requestId: requestId)) }
178
+ public func shown(requestId: UUID = UUID()) async throws -> IndianaFunnelRun { try await mutate(base("shown", requestId: requestId)) }
179
+ public func close(requestId: UUID = UUID()) async throws -> IndianaFunnelRun { try await mutate(base("close", requestId: requestId)) }
180
+ public func retry() async throws -> IndianaFunnelRun { try await restoreCommands();if current == nil { _ = try await get() };if let closingCommand { return try await mutate(closingCommand) };guard let pending else { throw IndianaFunnelError.invalidCommand };if closeRequested, let id = pending["requestId"]?.string, let requestId = UUID(uuidString: id) { return try await settle(requestId: requestId) };return try await mutate(pending) }
181
+ public func resume(requestId: UUID) async throws -> IndianaFunnelRun { try await restoreCommands();if current == nil { _ = try await get() };if closeRequested { return try await settle(requestId: requestId) };if let pending { guard pending["requestId"] == .string(requestId.uuidString.lowercased()) else { throw IndianaFunnelError.commandPending };return try await mutate(pending) };return try await mutate(["operation": .string("resume"), "requestId": .string(requestId.uuidString.lowercased())]) }
182
+ func hostCommand(_ command: [String: IndianaJSON]) async throws -> Data {
183
+ guard let operation = command["operation"]?.string, operation != "start", command["customerToken"] == nil else { throw IndianaFunnelError.invalidCommand }
184
+ if operation == "get" { if let id = command["settlementRequestId"]?.string, let requestId = UUID(uuidString: id) { _ = try await settle(requestId: requestId) } else { _ = try await get(data: command["data"]?.object, purchase: command["purchase"]?.object) } }
185
+ else if operation == "review" { _ = try await request(command) }
186
+ else { _ = try await mutate(command) };return try hostResponse()
187
+ }
188
+ func hostResponse() throws -> Data { guard let response, !invalidated else { throw IndianaFunnelError.identityChanged };return response }
189
+ /// Display-only read. Never called by command settlement or purchase recovery.
190
+ func hostPaywallContent(_ payload: IndianaJSON) async throws -> IndianaJSON {
191
+ guard !invalidated, let request = payload.object, Set(request.keys) == ["id", "sha256"],
192
+ let paywall = current?.paywall?.object, let delivery = paywall["resolution"], let value = delivery.object,
193
+ request["id"] == value["id"], request["sha256"] == value["content"]?.object?["artifact"]?.object?["sha256"] else { throw IndianaFunnelError.invalidCommand }
194
+ let original = epoch
195
+ let loaded = try await (publicPaywallContent ?? { try await IndianaPaywallContent(configuration: self.configuration).resolve($0) })(delivery)
196
+ guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
197
+ try IndianaSchema.validate("paywallResolution", data: JSONEncoder().encode(loaded))
198
+ guard let hydrated = loaded.object, IndianaPaywallContent.validatesHydration(hydrated, delivery: value),
199
+ let active = current?.paywall?.object, active["receiptId"] == paywall["receiptId"],
200
+ var latest = active["resolution"]?.object, latest["id"] == value["id"], latest["content"] == value["content"] else { throw IndianaFunnelError.invalidResponse }
201
+ IndianaPaywallContent.applyPresentation(hydrated, to: &latest)
202
+ publicPresentation = ["receiptId": active["receiptId"]!, "resolution": .object(latest)]
203
+ return .object(latest)
204
+ }
205
+ private func presentationButtons() -> [String: IndianaJSON] {
206
+ guard let active = current?.paywall?.object, let cached = publicPresentation,
207
+ active["receiptId"] == cached["receiptId"], let value = active["resolution"]?.object,
208
+ let loaded = cached["resolution"]?.object, loaded["id"] == value["id"], loaded["content"] == value["content"] else { return [:] }
209
+ return loaded["document"]?.object?["buttons"]?.object ?? [:]
210
+ }
211
+ private func responseObject() throws -> [String: IndianaJSON] { guard let response else { throw IndianaFunnelError.invalidCommand };return try JSONDecoder().decode(IndianaJSON.self, from: response).object ?? [:] }
212
+ func trustedPurchase(_ offered: IndianaJSON) throws -> IndianaJSON {
213
+ let object = try responseObject(), operation = object["purchase"]?.object?["operation"] ?? current?.paywall?.object?["operation"]
214
+ guard operation == offered, offered != .null else { throw IndianaFunnelError.invalidCommand };return offered
215
+ }
216
+ func trustedCheckoutURL(_ offered: String) throws -> URL {
217
+ let object = try responseObject(), operation = object["purchase"]?.object?["operation"] ?? current?.paywall?.object?["operation"]
218
+ guard operation?.object?["checkoutUrl"] == .string(offered), let url = URL(string: offered), url.scheme == "https", url.host != nil, url.user == nil, url.password == nil else { throw IndianaFunnelError.invalidCommand };return url
219
+ }
220
+ func trustedOpen(_ offered: IndianaJSON) throws -> URL {
221
+ guard let object = offered.object, let raw = object["url"]?.string, let url = URL(string: raw), url.scheme == "https", url.host != nil, url.user == nil, url.password == nil else { throw IndianaFunnelError.invalidCommand }
222
+ let instruction = current?.instruction?.object
223
+ let actions = presentationButtons()
224
+ guard instruction?["type"] == .string("open_url") && instruction?["url"] == .string(raw) && instruction?["receiptId"] == object["receiptId"] && instruction?["openIn"] == object["openIn"] || actions.values.contains(where: { $0.object?["type"] == .string("action") && $0.object?["action"]?.object?["type"] == .string("open_url") && $0.object?["action"]?.object?["url"] == .string(raw) }) else { throw IndianaFunnelError.invalidCommand };return url
225
+ }
226
+ func trustedEvent(_ name: String) throws -> String {
227
+ let instruction = current?.instruction?.object
228
+ let buttons = presentationButtons()
229
+ guard instruction?["type"] == .string("event") && instruction?["event"] == .string(name) || buttons.values.contains(where: { $0.object?["type"] == .string("action") && $0.object?["action"]?.object?["type"] == .string("event") && $0.object?["action"]?.object?["event"] == .string(name) }) else { throw IndianaFunnelError.invalidCommand }
230
+ return name
231
+ }
232
+ }