@tinkerlandai/indiana-react-native 0.9.2 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/IndianaReactNative.podspec +19 -0
- package/README.md +62 -5
- package/THIRD_PARTY_NOTICES.md +74 -0
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgeModule.kt +237 -0
- package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgePackage.kt +11 -0
- package/dist/index.cjs +17 -1
- package/dist/index.d.cts +96889 -6007
- package/dist/index.d.ts +96889 -6007
- package/dist/index.js +17 -1
- package/ios/IndianaBridge.m +11 -0
- package/ios/IndianaBridge.swift +275 -0
- package/native-sdk/android/assets/indiana/index.html +268 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/BillingAuthorizationTerms.kt +55 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/BundledWebView.kt +90 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/ContentSession.kt +45 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/ContentView.kt +36 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerOperations.generated.kt +84 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerResponseSemantics.kt +197 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSemantics.kt +120 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSession.kt +106 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FeatureClient.kt +65 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelCommandStorage.kt +163 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelConfigurationRelay.kt +50 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelSession.kt +208 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelView.kt +71 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/GeneratedProtocol.kt +16 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/GoogleStorefront.kt +165 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/HTTPTransport.kt +43 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/IndianaClient.kt +526 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/MerchantCommerceSemantics.kt +38 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativePendingKeystore.kt +81 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativeReceiptRelay.kt +28 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativeStoreOffer.kt +24 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallContent.kt +75 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +144 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallView.kt +74 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/Protocol.kt +28 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PushInstallation.kt +170 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/RendererNavigation.kt +13 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +6 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/StoreSubscriptionManagement.kt +19 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +76 -0
- package/native-sdk/android/resources/funnel-wire-schemas.json +1 -0
- package/native-sdk/android/resources/indiana-third-party-notices.txt +101 -0
- package/native-sdk/android/resources/renderer-data-url.txt +1 -0
- package/native-sdk/ios/AnalyticsReceipt.swift +10 -0
- package/native-sdk/ios/AppleStorefront.swift +218 -0
- package/native-sdk/ios/BillingAuthorizationTerms.swift +57 -0
- package/native-sdk/ios/BundledWebView.swift +87 -0
- package/native-sdk/ios/ContentSession.swift +48 -0
- package/native-sdk/ios/ContentView.swift +51 -0
- package/native-sdk/ios/CustomerOperations.generated.swift +166 -0
- package/native-sdk/ios/CustomerResponseSemantics.swift +236 -0
- package/native-sdk/ios/CustomerSemantics.swift +145 -0
- package/native-sdk/ios/CustomerSession.swift +129 -0
- package/native-sdk/ios/FeatureClient.swift +86 -0
- package/native-sdk/ios/FunnelCommandStorage.swift +163 -0
- package/native-sdk/ios/FunnelConfigurationRelay.swift +48 -0
- package/native-sdk/ios/FunnelSession.swift +232 -0
- package/native-sdk/ios/FunnelView.swift +125 -0
- package/native-sdk/ios/GeneratedProtocol.swift +16 -0
- package/native-sdk/ios/HTTPTransport.swift +74 -0
- package/native-sdk/ios/IndianaClient.swift +601 -0
- package/native-sdk/ios/MerchantCommerceSemantics.swift +51 -0
- package/native-sdk/ios/NativePendingKeychain.swift +52 -0
- package/native-sdk/ios/NativeReceiptRelay.swift +28 -0
- package/native-sdk/ios/NativeStoreOffer.swift +34 -0
- package/native-sdk/ios/PaywallContent.swift +75 -0
- package/native-sdk/ios/PaywallSession.swift +124 -0
- package/native-sdk/ios/PaywallView.swift +87 -0
- package/native-sdk/ios/Protocol.swift +49 -0
- package/native-sdk/ios/PushInstallation.swift +154 -0
- package/native-sdk/ios/RendererNavigation.swift +8 -0
- package/native-sdk/ios/Resources.swift +15 -0
- package/native-sdk/ios/SDKVersion.swift +4 -0
- package/native-sdk/ios/StoreSubscriptionManagement.swift +11 -0
- package/native-sdk/ios/WebView/THIRD_PARTY_NOTICES.txt +101 -0
- package/native-sdk/ios/WebView/index.html +268 -0
- package/native-sdk/ios/WebView/renderer-data-url.txt +1 -0
- package/native-sdk/ios/WireSchema.swift +107 -0
- package/native-sdk/ios/funnel-wire-schemas.json +1 -0
- package/package.json +8 -3
- package/react-native.config.cjs +12 -0
|
@@ -0,0 +1,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
|
+
}
|