@tinkerlandai/longplay-react-native 0.10.7 → 0.10.11

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 (31) hide show
  1. package/README.md +1 -1
  2. package/dist/index.cjs +2 -2
  3. package/dist/index.d.cts +26549 -12669
  4. package/dist/index.d.ts +26549 -12669
  5. package/dist/index.js +2 -2
  6. package/native-sdk/android/assets/indiana/index.html +40 -40
  7. package/native-sdk/android/java/ai/tinkerland/indiana/BundledWebView.kt +6 -2
  8. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSession.kt +9 -3
  9. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelConfigurationRelay.kt +47 -21
  10. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelProcess.kt +120 -0
  11. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelSession.kt +57 -16
  12. package/native-sdk/android/java/ai/tinkerland/indiana/HTTPTransport.kt +48 -20
  13. package/native-sdk/android/java/ai/tinkerland/indiana/IndianaClient.kt +8 -2
  14. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +82 -59
  15. package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +1 -1
  16. package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +3 -1
  17. package/native-sdk/android/java/ai/tinkerland/longplay/Longplay.kt +3 -0
  18. package/native-sdk/android/resources/funnel-wire-schemas.json +1 -1
  19. package/native-sdk/ios/BundledWebView.swift +5 -1
  20. package/native-sdk/ios/CustomerSession.swift +7 -3
  21. package/native-sdk/ios/FunnelConfigurationRelay.swift +1 -0
  22. package/native-sdk/ios/FunnelProcess.swift +148 -0
  23. package/native-sdk/ios/FunnelSession.swift +37 -9
  24. package/native-sdk/ios/HTTPTransport.swift +2 -0
  25. package/native-sdk/ios/IndianaClient.swift +7 -1
  26. package/native-sdk/ios/NativeReceiptRelay.swift +3 -1
  27. package/native-sdk/ios/PaywallSession.swift +5 -4
  28. package/native-sdk/ios/SDKVersion.swift +1 -1
  29. package/native-sdk/ios/WebView/index.html +40 -40
  30. package/native-sdk/ios/funnel-wire-schemas.json +1 -1
  31. package/package.json +1 -1
@@ -56,7 +56,11 @@ import UIKit
56
56
  let data=try? JSONSerialization.data(withJSONObject:message.body), data.count <= 262144,
57
57
  let value=try? JSONDecoder().decode(IndianaJSON.self,from:data).object,
58
58
  Set(value.keys) == Set(["id","method","payload"]), let id=value["id"]?.string.flatMap(UUID.init(uuidString:)),
59
- let method=value["method"]?.string, requests.count < 8, requests.insert(id).inserted else { return }
59
+ let method=value["method"]?.string, !requests.contains(id) else { return }
60
+ guard requests.count < 8 else {
61
+ send(.object(["id":.string(id.uuidString.lowercased()),"error":.object(["code":.string("NATIVE_BUSY"),"status":.number(429)])])); return
62
+ }
63
+ requests.insert(id)
60
64
  Task {
61
65
  defer { requests.remove(id) }
62
66
  do {
@@ -2,6 +2,8 @@ import Foundation
2
2
 
3
3
  /// Opaque credentials issued by the merchant backend; keep them in memory.
4
4
  public struct IndianaCustomerSession: Codable, Sendable {
5
+ private static let fractionalDate = Date.ISO8601FormatStyle(includingFractionalSeconds: true)
6
+ private static let wholeDate = Date.ISO8601FormatStyle()
5
7
  public let customerId: UUID
6
8
  public let sessionId: UUID
7
9
  public let accessToken: String
@@ -12,8 +14,7 @@ public struct IndianaCustomerSession: Codable, Sendable {
12
14
  self.customerId = customerId; self.sessionId = sessionId; self.accessToken = accessToken; self.refreshToken = refreshToken; self.expiresAt = expiresAt; self.refreshExpiresAt = refreshExpiresAt
13
15
  }
14
16
  internal func deadline(_ value: String) -> Date? {
15
- let parser = ISO8601DateFormatter(); parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
16
- return parser.date(from: value) ?? ISO8601DateFormatter().date(from: value)
17
+ (try? Self.fractionalDate.parse(value)) ?? (try? Self.wholeDate.parse(value))
17
18
  }
18
19
  internal func validate(fresh: Bool = false) throws {
19
20
  guard accessToken.range(of: "^gg_ct_[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil,
@@ -39,6 +40,7 @@ internal actor IndianaSessionTransport: IndianaHTTP {
39
40
  init(_ upstream: any IndianaHTTP, scope: IndianaScope, now: @escaping @Sendable () -> Date = { Date() }) { self.upstream = upstream; self.scope = scope; self.now = now }
40
41
  func identify(_ access: String?, revision: UInt64? = nil, onInvalidated: (@Sendable () async -> Void)? = nil) {
41
42
  if let revision { guard revision >= identityRevision else { return }; identityRevision = revision } else { identityRevision += 1 }
43
+ pending?.1.cancel()
42
44
  epoch = UUID(); credentials = nil; originalAccess = access; pending = nil; self.onInvalidated = onInvalidated
43
45
  }
44
46
  func identify(_ value: IndianaCustomerSession, revision: UInt64? = nil, onInvalidated: (@Sendable () async -> Void)? = nil) throws {
@@ -54,6 +56,7 @@ internal actor IndianaSessionTransport: IndianaHTTP {
54
56
  do {
55
57
  let next = try await task.value
56
58
  guard version == epoch else { throw IndianaClientError.identityChanged }
59
+ try Task.checkCancellation()
57
60
  return next
58
61
  } catch {
59
62
  guard revision == identityRevision else { throw IndianaClientError.identityChanged }
@@ -62,6 +65,7 @@ internal actor IndianaSessionTransport: IndianaHTTP {
62
65
  }
63
66
  func refresh() async throws -> IndianaCustomerSession {
64
67
  guard accessProvider == nil else { throw IndianaClientError.unsupportedCapability }
68
+ try Task.checkCancellation()
65
69
  if let pending {
66
70
  return try await awaitRefresh(pending.1, epoch: epoch, revision: identityRevision)
67
71
  }
@@ -97,7 +101,7 @@ internal actor IndianaSessionTransport: IndianaHTTP {
97
101
  guard originalAccess != nil else { throw IndianaClientError.unauthenticated }
98
102
  let token = try await accessProvider()
99
103
  guard version == epoch else { throw IndianaClientError.identityChanged }
100
- guard token.range(of: "^gg_ct_[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil else { throw IndianaClientError.invalidResponse }
104
+ guard token.range(of: "^gg_ct_[A-Za-z0-9_-]{32,128}$", options: .regularExpression) != nil else { throw IndianaClientError.invalidResponse }
101
105
  return token
102
106
  }
103
107
  if let current = credentials, (current.deadline(current.expiresAt) ?? .distantPast) <= now().addingTimeInterval(30) { _ = try await refresh() }
@@ -23,6 +23,7 @@ final class IndianaFunnelConfigurationRelay: Sendable {
23
23
  request.cachePolicy = path.contains("/artifacts/") ? .useProtocolCachePolicy : .reloadIgnoringLocalCacheData
24
24
  do {
25
25
  let (bytes, response) = try await session.bytes(for: request)
26
+ defer { bytes.task.cancel() }
26
27
  guard let http = response as? HTTPURLResponse else { throw IndianaClientError.temporaryUnavailable }
27
28
  var headers: [String: IndianaJSON] = [:]
28
29
  for (key, value) in http.allHeaderFields {
@@ -0,0 +1,148 @@
1
+ import Foundation
2
+
3
+ /// Calls the merchant backend's process adapter with its acknowledged durable handle and authenticated merchant session.
4
+ /// The backend resolves the subject; no Server Key, Guest proof or Customer token enters this interface.
5
+ public protocol IndianaFunnelProcessBridge: Sendable {
6
+ func invoke(_ operation: String, input: [String: IndianaJSON]) async throws -> Data
7
+ func detach() async throws
8
+ }
9
+
10
+ /// Process identity and its credential-free command journal survive Customer-session replacement.
11
+ public actor IndianaFunnelProcess {
12
+ private let configuration: IndianaConfiguration
13
+ private let bridge: any IndianaFunnelProcessBridge
14
+ private let storage: any IndianaFunnelCommandStorage
15
+ private var epoch = UUID()
16
+ private var presentation: IndianaFunnelSession?
17
+ private var runToken: String?
18
+ public private(set) var current: [String: IndianaJSON]?
19
+ private let onAuthenticationRequired: (@Sendable ([String: IndianaJSON]) -> Void)?
20
+ public init(configuration: IndianaConfiguration, bridge: any IndianaFunnelProcessBridge, commandStorage: any IndianaFunnelCommandStorage,
21
+ onAuthenticationRequired: (@Sendable ([String: IndianaJSON]) -> Void)? = nil) {
22
+ self.configuration=configuration; self.bridge=bridge; storage=commandStorage; self.onAuthenticationRequired=onAuthenticationRequired
23
+ }
24
+ private func bytes(_ value: [String: IndianaJSON]) throws -> Data { try JSONEncoder().encode(value) }
25
+ private func update(_ change: ([String: IndianaJSON]) throws -> [String: IndianaJSON]) async throws {
26
+ for _ in 0..<8 {
27
+ let saved=try await storage.read()
28
+ let record=try saved.entry.map { try JSONDecoder().decode([String: IndianaJSON].self,from:$0) } ?? [:]
29
+ let next=try change(record)
30
+ if next==record { return }
31
+ if try await storage.write(expectedRevision:saved.revision,entry:bytes(next)) != nil { return }
32
+ }
33
+ throw IndianaFunnelError.commandPending
34
+ }
35
+ private func record() async throws -> [String: IndianaJSON] {
36
+ let saved=try await storage.read()
37
+ return try saved.entry.map { try JSONDecoder().decode([String: IndianaJSON].self,from:$0) } ?? [:]
38
+ }
39
+ private func accept(_ data: Data, epoch original: UUID) async throws -> [String: IndianaJSON] {
40
+ guard original==epoch else { throw IndianaFunnelError.identityChanged }
41
+ try IndianaSchema.validate("processResponse",data:data)
42
+ let reply=try JSONDecoder().decode([String: IndianaJSON].self,from:data)
43
+ guard let run=reply["run"]?.object, let identity=run["identity"]?.object,
44
+ let scope=reply["scope"], try JSONDecoder().decode(IndianaScope.self,from:JSONEncoder().encode(scope))==configuration.scope else { throw IndianaFunnelError.invalidResponse }
45
+ let tuple: [String: IndianaJSON] = ["scope":scope,"runId":run["id"]!,"source":run["source"]!,"participantId":identity["participantId"]!,"startedAs":identity["startedAs"]!,"runExpiresAt":run["runExpiresAt"]!]
46
+ let binding=identity["binding"]?.object?["id"] ?? .null
47
+ try await update { record in
48
+ var next=record
49
+ if let previous=record["origin"], previous != .object(tuple) { throw IndianaFunnelError.invalidResponse }
50
+ if let old=record["bindingId"], old != .null, binding != old { throw IndianaFunnelError.identityChanged }
51
+ next["origin"] = .object(tuple); next["bindingId"] = binding
52
+ if let settlement=reply["settlement"]?.object, settlement["state"] == .string("settled"),
53
+ let id=settlement["requestId"] {
54
+ for lane in ["pending","close"] where record[lane]?.object?["command"]?.object?["requestId"]==id { next.removeValue(forKey:lane) }
55
+ }
56
+ return next
57
+ }
58
+ guard original==epoch else { throw IndianaFunnelError.identityChanged }
59
+ let revision: (IndianaJSON?) -> Double = { if case .number(let n)=$0 { return n }; return -1 }
60
+ if current==nil || revision(run["revision"])>=revision(current?["revision"]) { current=run }
61
+ if let value=reply["runToken"]?.string { if let old=runToken,old != value { throw IndianaFunnelError.invalidResponse };runToken=value }
62
+ if run["instruction"]?.object?["type"] == .string("authentication_required") { onAuthenticationRequired?(run) }
63
+ return reply
64
+ }
65
+ private func invoke(_ operation: String, _ input: [String: IndianaJSON]) async throws -> [String: IndianaJSON] {
66
+ let original=epoch
67
+ do { return try await accept(bridge.invoke(operation,input:input),epoch:original) }
68
+ catch {
69
+ if let failure=error as? IndianaClientError {
70
+ switch failure { case .identityChanged,.unauthenticated: current=nil;await presentation?.reset();presentation=nil;default:break }
71
+ }
72
+ throw error
73
+ }
74
+ }
75
+ public func open(_ input: [String: IndianaJSON]) async throws -> [String: IndianaJSON] {
76
+ try IndianaSchema.validate("processOpen",data:bytes(input));_ = try await invoke("open",input)
77
+ try await settlePending(); return try await invoke("get",[:])
78
+ }
79
+ public func syncIdentity() async throws -> [String: IndianaJSON] {
80
+ _ = try await invoke("syncIdentity",[:]);try await settlePending()
81
+ let result=try await invoke("get",[:]);_ = try await presentation?.get();return result
82
+ }
83
+ public func get(_ input: [String: IndianaJSON] = [:]) async throws -> [String: IndianaJSON] {
84
+ var input=input
85
+ if let id=input["settlementRequestId"] {
86
+ let saved=try await record()
87
+ for lane in ["pending","close"] where saved[lane]?.object?["command"]?.object?["requestId"]==id { input["settlementBindingId"]=saved[lane]?.object?["bindingId"] ?? .null }
88
+ }
89
+ return try await invoke("get",input)
90
+ }
91
+ public func command(_ command: [String: IndianaJSON]) async throws -> [String: IndianaJSON] {
92
+ try IndianaSchema.validate("processMutation",data:bytes(command))
93
+ guard let current else { throw IndianaFunnelError.invalidCommand }
94
+ let original=epoch
95
+ let envelope: [String: IndianaJSON] = ["command":.object(command),"bindingId":current["identity"]?.object?["binding"]?.object?["id"] ?? .null]
96
+ let lane=command["operation"] == .string("close") ? "close" : "pending"
97
+ var accepted=envelope
98
+ try await update { record in
99
+ var next=record
100
+ if let pending=record[lane]?.object {
101
+ guard pending["command"]==envelope["command"] else { throw IndianaFunnelError.commandPending };accepted=pending
102
+ } else { next[lane] = .object(envelope) }
103
+ return next
104
+ }
105
+ guard original==epoch else { throw IndianaFunnelError.identityChanged }
106
+ return try await invoke("command",accepted)
107
+ }
108
+ private func settlePending() async throws {
109
+ let saved=try await record()
110
+ for lane in ["pending","close"] {
111
+ guard let pending=saved[lane]?.object, let id=pending["command"]?.object?["requestId"] else { continue }
112
+ _ = try await get(["settlementRequestId":id,"settlementBindingId":pending["bindingId"] ?? .null])
113
+ }
114
+ }
115
+ /// Reuses the native renderer without a second command journal or a Customer-token installation.
116
+ public func presentationSession() throws -> IndianaFunnelSession {
117
+ if let presentation { return presentation }
118
+ guard let runToken else { throw IndianaFunnelError.invalidCommand }
119
+ let value=try IndianaFunnelSession(configuration:configuration,http:ProcessHTTP(process:self),runToken:runToken)
120
+ presentation=value;return value
121
+ }
122
+ public func detach() async throws {
123
+ epoch=UUID();current=nil;await presentation?.reset();presentation=nil;runToken=nil
124
+ try await bridge.detach()
125
+ }
126
+ fileprivate func rendered(_ command: [String: IndianaJSON]) async throws -> Data {
127
+ let operation=command["operation"]?.string ?? ""
128
+ var input=command;input.removeValue(forKey:"operation");input.removeValue(forKey:"customerToken")
129
+ var reply: [String: IndianaJSON]
130
+ if operation=="get" { reply=try await get(input) }
131
+ else if operation=="review" { reply=try await invoke("review",input) }
132
+ else { var mutation=command;mutation.removeValue(forKey:"customerToken");reply=try await self.command(mutation) }
133
+ reply.removeValue(forKey:"contract")
134
+ var run=reply["run"]!.object!;let identity=run.removeValue(forKey:"identity")!.object!
135
+ run["authenticated"] = .bool(identity["binding"] != .null)
136
+ if run["instruction"]?.object?["type"] == .string("authentication_required") { run["instruction"] = .null }
137
+ reply["run"] = .object(run)
138
+ if var settlement=reply["settlement"]?.object { settlement.removeValue(forKey:"accepted");reply["settlement"] = .object(settlement) }
139
+ return try bytes(reply)
140
+ }
141
+ }
142
+ private struct ProcessHTTP: IndianaHTTP {
143
+ let process: IndianaFunnelProcess
144
+ func send(path: String,method: String,authorization: String,body: Data?) async throws -> Data {
145
+ guard path=="funnels",method=="POST",let body else { throw IndianaFunnelError.invalidCommand }
146
+ return try await process.rendered(JSONDecoder().decode([String: IndianaJSON].self,from:body))
147
+ }
148
+ }
@@ -40,6 +40,7 @@ public actor IndianaFunnelSession {
40
40
  public private(set) var pendingPurchase: [String: IndianaJSON]?
41
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
42
  private var invalidated = false; private var invalidation: (@Sendable () -> Void)?; private var response: Data?
43
+ private var closingSending = false
43
44
  public private(set) var current: IndianaFunnelRun?
44
45
  public init(configuration: IndianaConfiguration, runToken: String, commandStorage: (any IndianaFunnelCommandStorage)? = nil) throws {
45
46
  guard runToken.range(of: "^gg_fr_[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil else { throw IndianaFunnelError.invalidCommand }
@@ -59,7 +60,10 @@ public actor IndianaFunnelSession {
59
60
  let record = try await restoration!.value
60
61
  guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
61
62
  if restored { return }; restored = true
62
- pending = record?.command; closingCommand = record?.closeCommand; pendingPurchase = record?.purchase; closeRequested = record?.closed == true || pending?["operation"] == .string("close")
63
+ let command = record?.command
64
+ pending = command?["operation"] == .string("close") ? nil : command
65
+ closingCommand = command?["operation"] == .string("close") ? command : record?.closeCommand
66
+ pendingPurchase = record?.purchase; closeRequested = record?.closed == true || closingCommand != nil
63
67
  for command in [pending, closingCommand].compactMap({ $0 }) { if let id = command["requestId"]?.string { uncertain.insert(id) } }
64
68
  }
65
69
  public func identify(customerToken: String) throws {
@@ -70,10 +74,11 @@ public actor IndianaFunnelSession {
70
74
  func hostConfiguration() -> IndianaConfiguration { configuration }
71
75
  func onInvalidation(_ callback: @escaping @Sendable () -> Void) { invalidation = callback;if invalidated { callback() } }
72
76
  func hostPendingCommand() -> IndianaJSON { (closingCommand ?? pending).map(IndianaJSON.object) ?? .null }
73
- private func request(_ command: [String: IndianaJSON]) async throws -> IndianaFunnelRun {
77
+ private func request(_ command: [String: IndianaJSON], expectedEpoch: UUID? = nil) async throws -> IndianaFunnelRun {
74
78
  try await restoreCommands()
75
79
  guard !invalidated else { throw IndianaFunnelError.identityChanged }
76
80
  let original = epoch;var body = command;if let customerToken { body["customerToken"] = .string(customerToken) }
81
+ guard expectedEpoch == nil || expectedEpoch == original else { throw IndianaFunnelError.identityChanged }
77
82
  let encoder = JSONEncoder();encoder.outputFormatting = [.sortedKeys];let input = try encoder.encode(body)
78
83
  guard input.count <= 262144 else { throw IndianaFunnelError.invalidCommand };try IndianaSchema.validate("command", data: input)
79
84
  let bytes = try await http.send(path: "funnels", method: "POST", authorization: runToken, body: input)
@@ -133,38 +138,61 @@ public actor IndianaFunnelSession {
133
138
  }
134
139
  private func mutate(_ command: [String: IndianaJSON]) async throws -> IndianaFunnelRun {
135
140
  try await restoreCommands()
141
+ guard !invalidated else { throw IndianaFunnelError.identityChanged }
136
142
  let closing = command["operation"] == .string("close"), id = command["requestId"]?.string ?? ""
137
143
  if closeRequested && !closing { throw IndianaFunnelError.commandPending }
138
- if sending && !closing { throw IndianaFunnelError.commandPending }
144
+ if closing ? closingSending : sending { throw IndianaFunnelError.commandPending }
139
145
  if closing {
146
+ if pending?["requestId"] == .string(id) { throw IndianaFunnelError.invalidCommand }
140
147
  closeRequested = true
141
148
  if let closingCommand, closingCommand != command { throw IndianaFunnelError.commandPending }
142
149
  closingCommand = command
150
+ closingSending = true
143
151
  } else {
144
152
  if let pending, pending != command { throw IndianaFunnelError.commandPending };pending = command
153
+ sending = true
145
154
  }
146
- let wasUncertain = uncertain.contains(id);uncertain.insert(id);sending = true;let original = epoch
147
- defer { sending = false }
155
+ let wasUncertain = uncertain.contains(id), original = epoch
156
+ defer { if closing { closingSending = false } else { sending = false } }
148
157
  do {
149
158
  if let journal { guard let current else { throw IndianaFunnelError.invalidCommand }; try await journal.retain(command, run: current) }
150
- let run = try await request(command)
159
+ guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
160
+ uncertain.insert(id)
161
+ let run = try await request(command, expectedEpoch: original)
151
162
  let settled = settlements[id] == "settled"
152
163
  if original == epoch, run.pendingRequest?.requestId.uuidString.lowercased() != id,
153
164
  settled,
154
165
  (!closeRequested || closing || settled && !["running", "waiting"].contains(run.status)) {
155
166
  try await journal?.settle(id)
167
+ guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
156
168
  if pending == command { pending = nil };if closingCommand == command { closingCommand = nil };uncertain.remove(id)
157
169
  }
170
+ guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
158
171
  return run
159
172
  } 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) }
173
+ if original == epoch, !invalidated, !wasUncertain,
174
+ pending == command || closingCommand == command,
175
+ let failure = error as? IndianaClientError, case .requestFailed(let status) = failure,
176
+ status < 500, status != 401, status != 425 {
177
+ try await journal?.settle(id)
178
+ guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
179
+ if pending == command { pending = nil };if closingCommand == command { closingCommand = nil };uncertain.remove(id)
180
+ }
161
181
  throw error
162
182
  }
163
183
  }
164
184
  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)])
185
+ try await restoreCommands()
186
+ let original = epoch, id = requestId.uuidString.lowercased();settlements.removeValue(forKey: id)
187
+ let run = try await request(["operation": .string("get"), "settlementRequestId": .string(id)], expectedEpoch: original)
166
188
  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
189
+ if state == "settled", run.pendingRequest?.requestId != requestId {
190
+ try await journal?.settle(id)
191
+ guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
192
+ if pending?["requestId"] == .string(id) { pending = nil };if closingCommand?["requestId"] == .string(id) { closingCommand = nil };uncertain.remove(id)
193
+ }
194
+ guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
195
+ return run
168
196
  }
169
197
  private func base(_ operation: String, requestId: UUID) throws -> [String: IndianaJSON] {
170
198
  guard let current, !invalidated else { throw IndianaFunnelError.invalidCommand }
@@ -42,6 +42,7 @@ final class IndianaURLTransport: IndianaHTTP, Sendable {
42
42
  config.timeoutIntervalForRequest = configuration.timeout; config.timeoutIntervalForResource = configuration.timeout
43
43
  self.session = URLSession(configuration: config, delegate: NoRedirect(), delegateQueue: nil)
44
44
  }
45
+ deinit { session.invalidateAndCancel() }
45
46
  func send(path: String, method: String, authorization: String, body: Data?) async throws -> Data {
46
47
  guard !path.contains(".."), !path.contains("?"), !path.contains("#"), !path.hasPrefix("/") else { throw IndianaClientError.invalidConfiguration }
47
48
  var request = URLRequest(url: requestOrigin.appendingPathComponent("api/v1").appendingPathComponent(path))
@@ -54,6 +55,7 @@ final class IndianaURLTransport: IndianaHTTP, Sendable {
54
55
  request.setValue(IndianaSDK.version, forHTTPHeaderField: "X-Indiana-SDK-Version")
55
56
  do {
56
57
  let (bytes, response) = try await session.bytes(for: request)
58
+ defer { bytes.task.cancel() }
57
59
  guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
58
60
  throw IndianaClientError.requestFailed((response as? HTTPURLResponse)?.statusCode ?? 0)
59
61
  }
@@ -498,7 +498,13 @@ public actor IndianaClient {
498
498
  guard (1...20).contains(plans.count), Set(plans.map(\.productVersionId)).count == plans.count,
499
499
  currency.range(of: "^[A-Z]{3}$", options: .regularExpression) != nil,
500
500
  plans.allSatisfy({ $0.featureQuantities.count <= 100 && $0.featureQuantities.values.allSatisfy { (0...10000).contains($0) } && $0.seatQuantities.count <= 20 && $0.seatQuantities.allSatisfy { $0.key.range(of: "^[a-z][a-z0-9_]{0,63}$", options: .regularExpression) != nil && (0...10000).contains($0.value) } }) else { throw IndianaClientError.invalidResponse }
501
- let entries = plans.map { plan in IndianaJSON.object(["productVersionId":.string(plan.productVersionId.uuidString.lowercased()), "seatQuantities":.object(plan.seatQuantities.mapValues { .number(Double($0)) }), "featureQuantities":.object(Dictionary(uniqueKeysWithValues: plan.featureQuantities.map { ($0.key.uuidString.lowercased(), IndianaJSON.number(Double($0.value))) }))]) }
501
+ // Empty quantity maps are omitted: the contract treats them as absent and the deployed runtime refuses an empty object.
502
+ let entries = plans.map { plan -> IndianaJSON in
503
+ var entry: [String: IndianaJSON] = ["productVersionId": .string(plan.productVersionId.uuidString.lowercased())]
504
+ if !plan.seatQuantities.isEmpty { entry["seatQuantities"] = .object(plan.seatQuantities.mapValues { .number(Double($0)) }) }
505
+ if !plan.featureQuantities.isEmpty { entry["featureQuantities"] = .object(Dictionary(uniqueKeysWithValues: plan.featureQuantities.map { ($0.key.uuidString.lowercased(), IndianaJSON.number(Double($0.value))) })) }
506
+ return .object(entry)
507
+ }
502
508
  var fields: [String: IndianaJSON] = ["requestId":.string(requestId.uuidString.lowercased()), "plans":.array(entries), "provider":.string(provider.rawValue), "currency":.string(currency)]
503
509
  if let billingSubscriptionId { fields["billingSubscriptionId"] = .string(billingSubscriptionId.uuidString.lowercased()) }
504
510
  guard nativeOffer == nil || (nativeOffer!.matches(provider) && plans.count == 1),
@@ -8,15 +8,17 @@ public final class IndianaNativeReceiptRelay: Sendable {
8
8
  public init(endpoint: URL) throws {
9
9
  guard endpoint.scheme == "https", endpoint.host != nil, endpoint.user == nil, endpoint.password == nil, endpoint.query == nil, endpoint.fragment == nil else { throw IndianaClientError.invalidConfiguration }
10
10
  self.endpoint = endpoint
11
- let config = URLSessionConfiguration.ephemeral; config.urlCache = nil; config.httpCookieStorage = nil; config.requestCachePolicy = .reloadIgnoringLocalCacheData; config.timeoutIntervalForRequest = 35
11
+ let config = URLSessionConfiguration.ephemeral; config.urlCache = nil; config.httpCookieStorage = nil; config.requestCachePolicy = .reloadIgnoringLocalCacheData; config.timeoutIntervalForRequest = 35; config.timeoutIntervalForResource = 35
12
12
  session = URLSession(configuration: config, delegate: NoRedirect(), delegateQueue: nil)
13
13
  }
14
+ deinit { session.invalidateAndCancel() }
14
15
  public func send(_ body: Data) async throws -> Data {
15
16
  guard body.count <= 262144, let input = try? JSONDecoder().decode(IndianaJSON.self, from: body).object, input["operation"] == .string("native_receipt"), let token = input["customerToken"]?.string, token.range(of: "^gg_ct_[A-Za-z0-9_-]{32,128}$", options: .regularExpression) != nil else { throw IndianaClientError.invalidConfiguration }
16
17
  var request = URLRequest(url: endpoint); request.httpMethod = "POST"; request.httpBody = body
17
18
  request.setValue("application/json", forHTTPHeaderField: "Content-Type"); request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
18
19
  do {
19
20
  let (stream, response) = try await session.bytes(for: request)
21
+ defer { stream.task.cancel() }
20
22
  guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw IndianaClientError.requestFailed((response as? HTTPURLResponse)?.statusCode ?? 0) }
21
23
  guard response.expectedContentLength <= 262144 else { throw IndianaClientError.invalidResponse }
22
24
  var result = Data(); for try await byte in stream { guard result.count < 262144 else { throw IndianaClientError.invalidResponse }; result.append(byte) }; return result
@@ -52,7 +52,8 @@ public actor IndianaPaywallSession {
52
52
  private func canonical(_ value: IndianaJSON) throws -> Data { let encoder = JSONEncoder(); encoder.outputFormatting = .sortedKeys; return try encoder.encode(value) }
53
53
  public func refresh() async throws -> IndianaJSON { try await hostCommand(.object(["operation":.string("resolve"),"input":.object([:])])) }
54
54
  internal func hostCommand(_ payload: IndianaJSON) async throws -> IndianaJSON {
55
- guard !closed, !busy, let envelope = payload.object, Set(envelope.keys) == Set(["operation","input"]), let action = envelope["operation"]?.string, let input = envelope["input"]?.object else { throw IndianaFunnelError.invalidCommand }
55
+ guard !closed, let envelope = payload.object, Set(envelope.keys) == Set(["operation","input"]), let action = envelope["operation"]?.string, let input = envelope["input"]?.object else { throw IndianaFunnelError.invalidCommand }
56
+ guard !busy else { throw IndianaFunnelError.commandPending }
56
57
  busy = true; defer { busy = false }
57
58
  switch action {
58
59
  case "resolve":
@@ -78,7 +79,6 @@ public actor IndianaPaywallSession {
78
79
  let fingerprint = try canonical(payload)
79
80
  if let pendingPreview, pendingPreview != fingerprint {
80
81
  guard let status = operation?.object?["status"]?.string, status != "pending" else { throw IndianaFunnelError.commandPending }
81
- quote = nil; operation = nil
82
82
  }
83
83
  if pendingPreview == fingerprint, let quote { return quote }
84
84
  var selectedPlan: [String:IndianaJSON] = ["productVersionId":.string(product)]
@@ -87,6 +87,7 @@ public actor IndianaPaywallSession {
87
87
  fields["offerId"] = input["offerId"]; fields["nativeOffer"] = input["nativeOffer"]
88
88
  let body = IndianaJSON.object(fields)
89
89
  try IndianaSchema.validate("previewAttachRequest", data: JSONEncoder().encode(body))
90
+ if pendingPreview != fingerprint { quote = nil; operation = nil }
90
91
  pendingPreview = fingerprint
91
92
  let reply = try await send("previewAttach", path:"subscriptions/preview-attach",body:body)
92
93
  guard let value = reply.object?["quote"], let q = value.object, q["currency"]?.string == currency, q["provider"]?.string == provider.rawValue,
@@ -97,9 +98,9 @@ public actor IndianaPaywallSession {
97
98
  let body = action == "read" ? IndianaJSON.object(["operationId":id]) : .object(["operationId":id,"intent":.string(action)])
98
99
  let reply = try await send(action == "read" ? "readSubscriptionOperation" : "attach", path: action == "read" ? "subscriptions/operations/read" : "subscriptions/attach", body:body)
99
100
  guard let value = reply.object?["operation"], value.object?["operationId"] == id, value.object?["currency"]?.string == currency else { throw IndianaClientError.invalidResponse }
100
- if operation?.object?["status"]?.string == "complete" { return operation! }
101
+ if let previous = operation, let status = previous.object?["status"]?.string,
102
+ status == "complete" || status != "pending" && value.object?["status"]?.string == "pending" { return previous }
101
103
  operation = value
102
- if value.object?["status"]?.string != "pending" && value.object?["status"]?.string != "complete" { pendingPreview = nil; quote = nil }
103
104
  return value
104
105
  default: throw IndianaFunnelError.invalidCommand
105
106
  }
@@ -1,4 +1,4 @@
1
1
  // Generated by scripts/sdk/generate-native-protocol.mjs. Do not edit.
2
2
  public enum IndianaSDK {
3
- public static let version = "0.10.7"
3
+ public static let version = "0.10.11"
4
4
  }