@tinkerlandai/longplay-react-native 0.10.8 → 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 (29) hide show
  1. package/dist/index.cjs +2 -2
  2. package/dist/index.d.cts +26549 -12669
  3. package/dist/index.d.ts +26549 -12669
  4. package/dist/index.js +2 -2
  5. package/native-sdk/android/assets/indiana/index.html +40 -40
  6. package/native-sdk/android/java/ai/tinkerland/indiana/BundledWebView.kt +6 -2
  7. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSession.kt +9 -3
  8. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelConfigurationRelay.kt +47 -21
  9. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelProcess.kt +120 -0
  10. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelSession.kt +57 -16
  11. package/native-sdk/android/java/ai/tinkerland/indiana/HTTPTransport.kt +48 -20
  12. package/native-sdk/android/java/ai/tinkerland/indiana/IndianaClient.kt +3 -1
  13. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +82 -59
  14. package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +1 -1
  15. package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +3 -1
  16. package/native-sdk/android/java/ai/tinkerland/longplay/Longplay.kt +3 -0
  17. package/native-sdk/android/resources/funnel-wire-schemas.json +1 -1
  18. package/native-sdk/ios/BundledWebView.swift +5 -1
  19. package/native-sdk/ios/CustomerSession.swift +7 -3
  20. package/native-sdk/ios/FunnelConfigurationRelay.swift +1 -0
  21. package/native-sdk/ios/FunnelProcess.swift +148 -0
  22. package/native-sdk/ios/FunnelSession.swift +37 -9
  23. package/native-sdk/ios/HTTPTransport.swift +2 -0
  24. package/native-sdk/ios/NativeReceiptRelay.swift +3 -1
  25. package/native-sdk/ios/PaywallSession.swift +5 -4
  26. package/native-sdk/ios/SDKVersion.swift +1 -1
  27. package/native-sdk/ios/WebView/index.html +40 -40
  28. package/native-sdk/ios/funnel-wire-schemas.json +1 -1
  29. package/package.json +1 -1
@@ -66,14 +66,18 @@ internal class IndianaBundledWebView(
66
66
  val message = try { JSONObject(raw) } catch (_: Exception) { return }
67
67
  if (message.keys().asSequence().toSet() != setOf("id", "method", "payload")) return
68
68
  val id = try { UUID.fromString(message.getString("id")) } catch (_: Exception) { return }
69
- if (requests.size >= 8 || !requests.add(id)) return
69
+ if (message.opt("method") !is String || requests.contains(id)) return
70
+ if (requests.size >= 8) {
71
+ send(JSONObject().put("id",id.toString()).put("error",JSONObject().put("code","NATIVE_BUSY").put("status",429))); return
72
+ }
73
+ requests.add(id)
70
74
  work.launch {
71
75
  try {
72
76
  val result = dispatch(message.getString("method"), message.opt("payload"))
73
77
  send(JSONObject().put("id", id.toString()).put("result", result ?: JSONObject.NULL))
74
78
  } catch (error: Exception) {
75
79
  if (!stopped) {
76
- val detail = JSONObject().put("code", if (error is IndianaClientException) error.code else "NATIVE_OPERATION_FAILED")
80
+ val detail = JSONObject().put("code", if (error is IndianaClientException) { if (error.code == "command_pending") "COMMAND_PENDING" else error.code } else "NATIVE_OPERATION_FAILED")
77
81
  if (error is IndianaClientException) error.code.removePrefix("request_failed_").toIntOrNull()?.let { detail.put("status", it) }
78
82
  send(JSONObject().put("id", id.toString()).put("error", detail)); onError(error)
79
83
  }
@@ -4,12 +4,17 @@ import java.time.Instant
4
4
  import java.util.UUID
5
5
  import java.util.concurrent.atomic.AtomicReference
6
6
  import kotlinx.coroutines.CompletableDeferred
7
+ import kotlinx.coroutines.currentCoroutineContext
8
+ import kotlinx.coroutines.ensureActive
7
9
  import org.json.JSONObject
10
+ private val sessionAccessPattern = Regex("gg_ct_[A-Za-z0-9_-]{43}")
11
+ private val sessionRefreshPattern = Regex("gg_rt_[A-Za-z0-9_-]{43}")
12
+ private val hostAccessPattern = Regex("gg_ct_[A-Za-z0-9_-]{32,128}")
8
13
 
9
14
  /** Server-issued credentials. This SDK keeps the pair in memory only. */
10
15
  data class IndianaCustomerSession(val customerId: UUID, val sessionId: UUID, val accessToken: String, val refreshToken: String, val expiresAt: Instant, val refreshExpiresAt: Instant) {
11
16
  internal fun validate(fresh: Boolean = false) {
12
- if (!accessToken.matches(Regex("gg_ct_[A-Za-z0-9_-]{43}")) || !refreshToken.matches(Regex("gg_rt_[A-Za-z0-9_-]{43}")) || refreshExpiresAt < expiresAt || refreshExpiresAt <= Instant.now() || (fresh && expiresAt <= Instant.now())) throw IndianaClientException("invalid_response")
17
+ if (!sessionAccessPattern.matches(accessToken) || !sessionRefreshPattern.matches(refreshToken) || refreshExpiresAt < expiresAt || refreshExpiresAt <= Instant.now() || (fresh && expiresAt <= Instant.now())) throw IndianaClientException("invalid_response")
13
18
  }
14
19
  companion object {
15
20
  @JvmStatic fun fromJSON(value: JSONObject): IndianaCustomerSession = safe { IndianaCustomerSession(UUID.fromString(value.getString("customerId")), UUID.fromString(value.getString("sessionId")), value.getString("accessToken"), value.getString("refreshToken"), Instant.parse(value.getString("expiresAt")), Instant.parse(value.getString("refreshExpiresAt"))) }.also { it.validate() }
@@ -57,12 +62,13 @@ internal class IndianaSessionTransport(private val upstream: IndianaHTTP, privat
57
62
  val pair = current.pair ?: throw IndianaClientException("unauthenticated")
58
63
  try {
59
64
  val result = upstream.send("customer-sessions/refresh", "POST", pair.refreshToken, JSONObject())
65
+ currentCoroutineContext().ensureActive()
60
66
  if (safe { scope(result.getJSONObject("scope")) } != expectedScope) throw IndianaClientException("scope_mismatch")
61
67
  val next = IndianaCustomerSession.fromJSON(result); next.validate(true)
62
68
  if (next.customerId != pair.customerId || next.sessionId != pair.sessionId) throw IndianaClientException("scope_mismatch")
63
69
  if (!state.compareAndSet(current, current.copy(pair = next))) throw IndianaClientException("identity_changed")
64
70
  flight.result.complete(next); return next
65
- } catch (cause: Exception) {
71
+ } catch (cause: Throwable) {
66
72
  val error = if (state.get().epoch != current.epoch) IndianaClientException("identity_changed") else cause
67
73
  if (error is IndianaClientException && error.code == "request_failed_401") invalidate(current)
68
74
  flight.result.completeExceptionally(error); throw error
@@ -74,7 +80,7 @@ internal class IndianaSessionTransport(private val upstream: IndianaHTTP, privat
74
80
  if (current.original == null) throw IndianaClientException("unauthenticated")
75
81
  val token = provider()
76
82
  if (state.get().epoch != current.epoch) throw IndianaClientException("identity_changed")
77
- if (!token.matches(Regex("gg_ct_[A-Za-z0-9_-]{43}"))) throw IndianaClientException("invalid_response")
83
+ if (!hostAccessPattern.matches(token)) throw IndianaClientException("invalid_response")
78
84
  return token
79
85
  }
80
86
  if (current.pair != null && current.pair.expiresAt <= now().plusSeconds(30)) refresh(false)
@@ -1,7 +1,11 @@
1
1
  package ai.tinkerland.indiana
2
2
 
3
3
  import kotlinx.coroutines.Dispatchers
4
- import kotlinx.coroutines.withContext
4
+ import kotlinx.coroutines.suspendCancellableCoroutine
5
+ import kotlinx.coroutines.CancellationException
6
+ import kotlin.coroutines.resume
7
+ import kotlin.coroutines.resumeWithException
8
+ import kotlin.coroutines.EmptyCoroutineContext
5
9
  import org.json.JSONObject
6
10
  import java.io.ByteArrayOutputStream
7
11
  import java.net.HttpURLConnection
@@ -18,28 +22,50 @@ internal class IndianaFunnelConfigurationRelay(private val configuration: Indian
18
22
  val request = payload as? JSONObject
19
23
  val path = request?.opt("path") as? String
20
24
  if (request == null || request.length() != 1 || path == null || !PATH.matches(path)) throw IndianaClientException("invalid_command")
21
- return withContext(Dispatchers.IO) {
25
+ return suspendCancellableCoroutine { continuation ->
22
26
  val connection = origin.resolve(path).toURL().openConnection() as HttpURLConnection
23
- try {
24
- connection.requestMethod = "GET"; connection.instanceFollowRedirects = false; connection.useCaches = path.contains("/artifacts/")
25
- connection.connectTimeout = configuration.timeoutMs; connection.readTimeout = configuration.timeoutMs
26
- val status = connection.responseCode
27
- val headers = JSONObject()
28
- for ((key, values) in connection.headerFields) {
29
- val name = key?.lowercase() ?: continue
30
- if (name in HEADERS && values.isNotEmpty()) headers.put(name, values.joinToString(", "))
27
+ // Do not run potentially blocking disconnect on the cancelling caller.
28
+ continuation.invokeOnCancellation {
29
+ Dispatchers.IO.dispatch(EmptyCoroutineContext, Runnable { connection.disconnect() })
30
+ }
31
+ Dispatchers.IO.dispatch(continuation.context, Runnable {
32
+ if (!continuation.isActive) return@Runnable
33
+ try {
34
+ connection.requestMethod = "GET"; connection.instanceFollowRedirects = false; connection.useCaches = path.contains("/artifacts/")
35
+ connection.connectTimeout = configuration.timeoutMs; connection.readTimeout = configuration.timeoutMs
36
+ val status = connection.responseCode
37
+ val headers = JSONObject()
38
+ for ((key, values) in connection.headerFields) {
39
+ val name = key?.lowercase() ?: continue
40
+ if (name in HEADERS && values.isNotEmpty()) headers.put(name, values.joinToString(", "))
41
+ }
42
+ // The shell reports other statuses (including 429 Retry-After) by status alone.
43
+ val body = if (status in 200..299) {
44
+ if ((connection.getHeaderField("Content-Length")?.toLongOrNull() ?: 0) > MAX_BYTES) throw IndianaClientException("invalid_response")
45
+ val output = ByteArrayOutputStream()
46
+ connection.inputStream.use { input ->
47
+ val buffer = ByteArray(8192)
48
+ while (true) {
49
+ if (!continuation.isActive) throw CancellationException()
50
+ val size = input.read(buffer)
51
+ if (size < 0) break
52
+ if (output.size() + size > MAX_BYTES) throw IndianaClientException("invalid_response")
53
+ output.write(buffer, 0, size)
54
+ }
55
+ }
56
+ try { Charsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(output.toByteArray())).toString() }
57
+ catch (_: Exception) { throw IndianaClientException("invalid_response") }
58
+ } else ""
59
+ continuation.resume(JSONObject().put("status", status).put("headers", headers).put("body", body))
60
+ } catch (error: Throwable) {
61
+ continuation.resumeWithException(when (error) {
62
+ is IndianaClientException, is CancellationException -> error
63
+ is Exception -> IndianaClientException("temporary_unavailable")
64
+ else -> error
65
+ })
31
66
  }
32
- // The shell reports other statuses (including 429 Retry-After) by status alone.
33
- val body = if (status in 200..299) {
34
- if ((connection.getHeaderField("Content-Length")?.toLongOrNull() ?: 0) > MAX_BYTES) throw IndianaClientException("invalid_response")
35
- val output = ByteArrayOutputStream()
36
- connection.inputStream.use { input -> val buffer = ByteArray(8192); while (true) { val size = input.read(buffer); if (size < 0) break; if (output.size() + size > MAX_BYTES) throw IndianaClientException("invalid_response"); output.write(buffer, 0, size) } }
37
- try { Charsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(output.toByteArray())).toString() }
38
- catch (_: Exception) { throw IndianaClientException("invalid_response") }
39
- } else ""
40
- JSONObject().put("status", status).put("headers", headers).put("body", body)
41
- } catch (e: IndianaClientException) { throw e } catch (_: Exception) { throw IndianaClientException("temporary_unavailable") }
42
- finally { connection.disconnect() }
67
+ finally { connection.disconnect() }
68
+ })
43
69
  }
44
70
  }
45
71
  companion object {
@@ -0,0 +1,120 @@
1
+ package ai.tinkerland.indiana
2
+
3
+ import kotlinx.coroutines.Dispatchers
4
+ import kotlinx.coroutines.withContext
5
+ import org.json.JSONObject
6
+ import java.util.UUID
7
+ import java.util.concurrent.atomic.AtomicReference
8
+
9
+ /** Uses the merchant's acknowledged process handle and authenticated session; the backend resolves the Customer. */
10
+ interface IndianaFunnelProcessBridge {
11
+ suspend fun invoke(operation: String, input: JSONObject): JSONObject
12
+ suspend fun detach()
13
+ }
14
+
15
+ /** Owns one credential-free process command journal. The backend owns Customer/Guest credentials and refresh. */
16
+ class IndianaFunnelProcess(
17
+ private val configuration: IndianaConfiguration,
18
+ private val bridge: IndianaFunnelProcessBridge,
19
+ private val commandStorage: IndianaFunnelCommandStorage,
20
+ private val onAuthenticationRequired: ((JSONObject) -> Unit)? = null,
21
+ ) {
22
+ private val epoch = AtomicReference(UUID.randomUUID())
23
+ private val view = AtomicReference<JSONObject?>(null)
24
+ private val stateLock = Any()
25
+ private var runToken: String? = null
26
+ private var presentation: IndianaFunnelSession? = null
27
+ val current: JSONObject? get() = view.get()?.let { JSONObject(it.toString()) }
28
+ private suspend fun record(): JSONObject = withContext(Dispatchers.IO) { commandStorage.read().entry?.let(::JSONObject) ?: JSONObject() }
29
+ private suspend fun update(change: (JSONObject) -> JSONObject) = withContext(Dispatchers.IO) {
30
+ for (attempt in 0 until 8) {
31
+ val saved=commandStorage.read();val record=saved.entry?.let(::JSONObject) ?: JSONObject();val next=change(JSONObject(record.toString()))
32
+ if (IndianaSchema.equal(record,next) || commandStorage.write(saved.revision,next.toString()) != null) return@withContext
33
+ }
34
+ throw IndianaClientException("command_pending")
35
+ }
36
+ private suspend fun accept(raw: JSONObject, original: UUID): JSONObject {
37
+ if (epoch.get()!=original) throw IndianaClientException("identity_changed")
38
+ val reply=JSONObject(raw.toString());IndianaSchema.validate("processResponse",reply)
39
+ val run=reply.getJSONObject("run");val identity=run.getJSONObject("identity");val scope=reply.getJSONObject("scope")
40
+ if(scope.getString("projectId")!=configuration.scope.projectId.toString() || scope.getString("environment")!=configuration.scope.environment.value) throw IndianaClientException("scope_mismatch")
41
+ val tuple=JSONObject().put("scope",scope).put("runId",run.getString("id")).put("source",run.getJSONObject("source"))
42
+ .put("participantId",identity.getString("participantId")).put("startedAs",identity.getString("startedAs")).put("runExpiresAt",run.getString("runExpiresAt"))
43
+ val binding: Any=identity.optJSONObject("binding")?.getString("id") ?: JSONObject.NULL
44
+ update { saved ->
45
+ saved.optJSONObject("origin")?.let { if(!IndianaSchema.equal(it,tuple)) throw IndianaClientException("invalid_response") }
46
+ if(saved.has("bindingId") && !saved.isNull("bindingId") && saved.get("bindingId")!=binding) throw IndianaClientException("identity_changed")
47
+ saved.put("origin",tuple).put("bindingId",binding)
48
+ reply.optJSONObject("settlement")?.takeIf { it.optString("state")=="settled" }?.let { settlement ->
49
+ for(lane in listOf("pending","close")) if(saved.optJSONObject(lane)?.optJSONObject("command")?.optString("requestId")==settlement.optString("requestId")) saved.remove(lane)
50
+ }
51
+ saved
52
+ }
53
+ synchronized(stateLock) {
54
+ if(epoch.get()!=original) throw IndianaClientException("identity_changed")
55
+ if(view.get()==null || run.getLong("revision")>=view.get()!!.getLong("revision")) view.set(JSONObject(run.toString()))
56
+ if(reply.has("runToken")) {val token=reply.getString("runToken");if(runToken!=null && runToken!=token) throw IndianaClientException("invalid_response");runToken=token}
57
+ }
58
+ if(run.optJSONObject("instruction")?.optString("type")=="authentication_required") onAuthenticationRequired?.invoke(JSONObject(run.toString()))
59
+ return reply
60
+ }
61
+ private suspend fun invoke(operation: String,input: JSONObject): JSONObject {
62
+ val original=epoch.get()
63
+ try {return accept(bridge.invoke(operation,JSONObject(input.toString())),original)}
64
+ catch(error: IndianaClientException) {
65
+ if(error.code in setOf("identity_changed","unauthenticated","customer_binding_conflict","request_failed_401")) synchronized(stateLock){view.set(null);presentation?.reset();presentation=null}
66
+ throw error
67
+ }
68
+ }
69
+ suspend fun open(input: JSONObject): JSONObject {
70
+ IndianaSchema.validate("processOpen",input);invoke("open",input);settlePending();return invoke("get",JSONObject())
71
+ }
72
+ suspend fun syncIdentity(): JSONObject {
73
+ invoke("syncIdentity",JSONObject());settlePending();val result=invoke("get",JSONObject());presentation?.get();return result
74
+ }
75
+ suspend fun get(input: JSONObject = JSONObject()): JSONObject {
76
+ val request=JSONObject(input.toString());val saved=record()
77
+ if(request.has("settlementRequestId")) for(lane in listOf("pending","close")) saved.optJSONObject(lane)?.let {
78
+ if(it.getJSONObject("command").getString("requestId")==request.getString("settlementRequestId")) request.put("settlementBindingId",it.get("bindingId"))
79
+ }
80
+ return invoke("get",request)
81
+ }
82
+ suspend fun command(command: JSONObject): JSONObject {
83
+ IndianaSchema.validate("processMutation",command);val run=current ?: throw IndianaClientException("invalid_command")
84
+ val original=epoch.get();val lane=if(command.getString("operation")=="close") "close" else "pending"
85
+ val envelope=JSONObject().put("command",JSONObject(command.toString())).put("bindingId",run.getJSONObject("identity").optJSONObject("binding")?.getString("id") ?: JSONObject.NULL)
86
+ var accepted=envelope
87
+ update { saved ->
88
+ saved.optJSONObject(lane)?.let { pending ->
89
+ if(!IndianaSchema.equal(pending.getJSONObject("command"),command)) throw IndianaClientException("command_pending")
90
+ accepted=JSONObject(pending.toString())
91
+ } ?: saved.put(lane,envelope)
92
+ saved
93
+ }
94
+ if(epoch.get()!=original) throw IndianaClientException("identity_changed")
95
+ return invoke("command",accepted)
96
+ }
97
+ private suspend fun settlePending() {
98
+ val saved=record()
99
+ for(lane in listOf("pending","close")) saved.optJSONObject(lane)?.let {
100
+ get(JSONObject().put("settlementRequestId",it.getJSONObject("command").getString("requestId")).put("settlementBindingId",it.get("bindingId")))
101
+ }
102
+ }
103
+ /** Reuses the native renderer; this process remains the sole command journal owner. */
104
+ fun presentationSession(): IndianaFunnelSession = synchronized(stateLock) {
105
+ presentation ?: IndianaFunnelSession(configuration,object: IndianaHTTP {
106
+ override suspend fun send(path: String,method: String,authorization: String,body: JSONObject?): JSONObject {
107
+ if(path!="funnels" || method!="POST" || body==null) throw IndianaClientException("invalid_command")
108
+ val input=JSONObject(body.toString());input.remove("customerToken");val operation=input.getString("operation")
109
+ val reply=when(operation){"get"->{input.remove("operation");get(input)};"review"->{input.remove("operation");invoke("review",input)};else->command(input)}
110
+ reply.remove("contract");val run=reply.getJSONObject("run");val identity=run.getJSONObject("identity");run.remove("identity");run.put("authenticated",!identity.isNull("binding"))
111
+ if(run.optJSONObject("instruction")?.optString("type")=="authentication_required")run.put("instruction",JSONObject.NULL)
112
+ reply.optJSONObject("settlement")?.remove("accepted");return reply
113
+ }
114
+ },runToken ?: throw IndianaClientException("invalid_command")).also { presentation=it }
115
+ }
116
+ suspend fun detach() {
117
+ synchronized(stateLock){epoch.set(UUID.randomUUID());view.set(null);presentation?.reset();presentation=null;runToken=null}
118
+ bridge.detach()
119
+ }
120
+ }
@@ -25,6 +25,7 @@ class IndianaFunnelSession internal constructor(private val configuration: India
25
25
  private val settlements = java.util.concurrent.ConcurrentHashMap<String,String>()
26
26
  @Volatile private var closeRequested = false
27
27
  private val mutex = Mutex()
28
+ private val closeMutex = Mutex()
28
29
  private val responseMutex = Mutex()
29
30
  private val responseState = Any()
30
31
  @Volatile private var invalidated = false
@@ -51,16 +52,20 @@ class IndianaFunnelSession internal constructor(private val configuration: India
51
52
  if (restored) return@withLock
52
53
  val actor = identity.get();val record = journal.load()
53
54
  if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
54
- pending.set(record?.optJSONObject("command")?.toString());closingCommand.set(record?.optJSONObject("closeCommand")?.toString());purchaseRecovery.set(record?.optJSONObject("purchase"))
55
- closeRequested = record?.optBoolean("closed") == true || record?.optJSONObject("command")?.optString("operation") == "close"
55
+ val command = record?.optJSONObject("command")
56
+ pending.set(command?.takeIf { it.optString("operation") != "close" }?.toString())
57
+ closingCommand.set((command?.takeIf { it.optString("operation") == "close" } ?: record?.optJSONObject("closeCommand"))?.toString())
58
+ purchaseRecovery.set(record?.optJSONObject("purchase"))
59
+ closeRequested = record?.optBoolean("closed") == true || closingCommand.get() != null
56
60
  for (command in listOf(pending.get(),closingCommand.get())) command?.let { uncertain.add(JSONObject(it).getString("requestId")) }
57
61
  restored = true
58
62
  }
59
63
  }
60
- private suspend fun call(command: JSONObject): JSONObject {
64
+ private suspend fun call(command: JSONObject, expectedEpoch: UUID? = null): JSONObject {
61
65
  restoreCommands()
62
66
  if (invalidated) throw IndianaClientException("identity_changed")
63
67
  val actor = identity.get();val body = JSONObject(command.toString());actor.token?.let { body.put("customerToken", it) }
68
+ if (expectedEpoch != null && expectedEpoch != actor.epoch) throw IndianaClientException("identity_changed")
64
69
  if (body.toString().toByteArray(Charsets.UTF_8).size > 262144) throw IndianaClientException("invalid_command")
65
70
  IndianaSchema.validate("command", body)
66
71
  val next = http.send("funnels", "POST", runToken, body)
@@ -112,33 +117,69 @@ class IndianaFunnelSession internal constructor(private val configuration: India
112
117
  suspend fun get(data: JSONObject? = null, purchase: JSONObject? = null): JSONObject { restoreCommands(); val command = JSONObject().put("operation", "get");data?.let { command.put("data", it) };val reference = purchase ?: purchaseRecovery.get()?.let { JSONObject().put("receiptId",it.getString("receiptId")).put("operationId",it.getString("operationId")) };reference?.let { command.put("purchase",it) };return call(command) }
113
118
  private suspend fun mutate(command: JSONObject): JSONObject {
114
119
  restoreCommands()
115
- val original = command.toString();val closing = command.optString("operation") == "close";val id = command.optString("requestId")
116
- if (!closing && closeRequested) throw IndianaClientException("command_pending")
117
- suspend fun dispatch(): JSONObject {
120
+ val actor = identity.get()
121
+ val original = command.toString()
122
+ val closing = command.optString("operation") == "close"
123
+ val id = command.optString("requestId")
124
+ val lane = if (closing) closeMutex else mutex
125
+ if (!lane.tryLock()) throw IndianaClientException("command_pending")
126
+ try {
118
127
  val slot = if (closing) closingCommand else pending
119
- val prior = slot.get();if (prior != null && !IndianaSchema.equal(JSONObject(prior), command)) throw IndianaClientException("command_pending")
120
- slot.set(original);val wasUncertain = !uncertain.add(id)
128
+ val wasUncertain = synchronized(responseState) {
129
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
130
+ if (!closing && closeRequested) throw IndianaClientException("command_pending")
131
+ if (closing && pending.get()?.let { JSONObject(it).optString("requestId") } == id) throw IndianaClientException("invalid_command")
132
+ val prior = slot.get()
133
+ if (prior != null && !IndianaSchema.equal(JSONObject(prior), command)) throw IndianaClientException("command_pending")
134
+ slot.set(original)
135
+ if (closing) closeRequested = true
136
+ uncertain.contains(id)
137
+ }
121
138
  try {
122
139
  journal?.let { it.retain(command,current ?: throw IndianaClientException("invalid_command")) }
123
- return call(command).also {
140
+ synchronized(responseState) {
141
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
142
+ uncertain.add(id)
143
+ }
144
+ return call(command, actor.epoch).also {
124
145
  val state = settlements[id]
125
- if (it.optJSONObject("pendingRequest")?.optString("requestId") != id && state == "settled" && (!closeRequested || closing || it.optString("status") !in setOf("running","waiting"))) { journal?.settle(id);slot.compareAndSet(original,null);uncertain.remove(id) }
146
+ if (it.optJSONObject("pendingRequest")?.optString("requestId") != id && state == "settled" && (!closeRequested || closing || it.optString("status") !in setOf("running","waiting"))) {
147
+ journal?.settle(id)
148
+ synchronized(responseState) {
149
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
150
+ slot.compareAndSet(original,null);uncertain.remove(id)
151
+ }
152
+ }
153
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
126
154
  }
127
155
  } catch (error: Exception) {
128
156
  val code = (error as? IndianaClientException)?.code?.removePrefix("request_failed_")?.toIntOrNull()
129
- if (!wasUncertain && !closeRequested && code != null && code < 500 && code !in setOf(401,425)) { journal?.settle(id);slot.compareAndSet(original,null);uncertain.remove(id) };throw error
157
+ if (!invalidated && identity.get().epoch == actor.epoch && !wasUncertain && code != null && code < 500 && code !in setOf(401,425)) {
158
+ journal?.settle(id)
159
+ synchronized(responseState) {
160
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
161
+ slot.compareAndSet(original,null);uncertain.remove(id)
162
+ }
163
+ }
164
+ throw error
130
165
  }
166
+ } finally {
167
+ lane.unlock()
131
168
  }
132
- if (closing) { closeRequested = true;return dispatch() }
133
- return mutex.withLock { dispatch() }
134
169
  }
135
170
  suspend fun settle(requestId: UUID): JSONObject {
171
+ restoreCommands()
172
+ val actor = identity.get()
136
173
  val id = requestId.toString();settlements.remove(id)
137
- val result = call(JSONObject().put("operation","get").put("settlementRequestId",id))
138
- if (settlements[id] == "settled") {
174
+ val result = call(JSONObject().put("operation","get").put("settlementRequestId",id), actor.epoch)
175
+ if (settlements[id] == "settled" && result.optJSONObject("pendingRequest")?.optString("requestId") != id) {
139
176
  journal?.settle(id)
140
- for (slot in listOf(pending,closingCommand)) { val old = slot.get();if (old != null && JSONObject(old).optString("requestId") == id) slot.compareAndSet(old,null) };uncertain.remove(id)
177
+ synchronized(responseState) {
178
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
179
+ for (slot in listOf(pending,closingCommand)) { val old = slot.get();if (old != null && JSONObject(old).optString("requestId") == id) slot.compareAndSet(old,null) };uncertain.remove(id)
180
+ }
141
181
  }
182
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
142
183
  return result
143
184
  }
144
185
  private fun base(operation: String, requestId: UUID): JSONObject { val run = current ?: throw IndianaClientException("invalid_command");return JSONObject().put("operation", operation).put("requestId", requestId.toString()).put("nodeId", run.getString("nodeId")).put("visit", run.getInt("visit")).put("revision", run.getInt("revision")) }
@@ -1,7 +1,11 @@
1
1
  package ai.tinkerland.indiana
2
2
 
3
3
  import kotlinx.coroutines.Dispatchers
4
- import kotlinx.coroutines.withContext
4
+ import kotlinx.coroutines.suspendCancellableCoroutine
5
+ import kotlinx.coroutines.CancellationException
6
+ import kotlin.coroutines.resume
7
+ import kotlin.coroutines.resumeWithException
8
+ import kotlin.coroutines.EmptyCoroutineContext
5
9
  import org.json.JSONObject
6
10
  import java.net.URI
7
11
  import java.net.HttpURLConnection
@@ -19,25 +23,49 @@ internal interface IndianaHTTP { suspend fun send(path: String,method: String,au
19
23
  internal fun requireCurrentProtocol(value: String?) {
20
24
  if (value != IndianaProtocol.wireProtocol) throw IndianaClientException("invalid_response")
21
25
  }
22
- internal class IndianaURLTransport(private val configuration: IndianaConfiguration): IndianaHTTP {
23
- override suspend fun send(path: String,method: String,authorization: String,body: JSONObject?): JSONObject = withContext(Dispatchers.IO) {
26
+ internal class IndianaURLTransport(private val configuration: IndianaConfiguration, loopbackFixtureOrigin: URI? = null): IndianaHTTP {
27
+ // Internal loopback fixture only; public configuration remains HTTPS-only.
28
+ private val origin = loopbackFixtureOrigin?.also { require(it.scheme == "http" && it.host == "127.0.0.1" && it.port > 1024 && it.rawUserInfo == null && it.rawQuery == null && it.rawFragment == null && it.rawPath.isNullOrEmpty()) } ?: URI(configuration.baseUrl)
29
+ override suspend fun send(path: String,method: String,authorization: String,body: JSONObject?): JSONObject = suspendCancellableCoroutine { continuation ->
24
30
  require(!path.contains("..")&&!path.contains("?")&&!path.contains("#")&&!path.startsWith("/"))
25
- val connection=URI(configuration.baseUrl.trimEnd('/')+"/api/v1/"+path).toURL().openConnection() as HttpURLConnection
26
- try {
27
- connection.requestMethod=method;connection.instanceFollowRedirects=false;connection.connectTimeout=configuration.timeoutMs;connection.readTimeout=configuration.timeoutMs;connection.useCaches=false
28
- if(authorization.isNotEmpty()) connection.setRequestProperty("Authorization","Bearer $authorization")
29
- connection.setRequestProperty("Content-Type","application/json")
30
- connection.setRequestProperty("X-Indiana-Project",configuration.scope.projectId.toString());connection.setRequestProperty("X-Indiana-Environment",configuration.scope.environment.value)
31
- connection.setRequestProperty("X-Indiana-Protocol",IndianaProtocol.wireProtocol)
32
- connection.setRequestProperty("X-Indiana-SDK-Version",IndianaSDK.version)
33
- if(body!=null){connection.doOutput=true;val bytes=body.toString().toByteArray(Charsets.UTF_8);connection.setFixedLengthStreamingMode(bytes.size);connection.outputStream.use {it.write(bytes)}}
34
- if(connection.responseCode !in 200..299)throw IndianaClientException("request_failed_${connection.responseCode}")
35
- requireCurrentProtocol(connection.getHeaderField("X-Indiana-Protocol"))
36
- if((connection.getHeaderField("Content-Length")?.toLongOrNull() ?: 0)>2_097_152)throw IndianaClientException("invalid_response")
37
- val output=ByteArrayOutputStream()
38
- connection.inputStream.use {input->val buffer=ByteArray(8192);while(true){val size=input.read(buffer);if(size<0)break;if(output.size()+size>2_097_152)throw IndianaClientException("invalid_response");output.write(buffer,0,size)}}
39
- safe{JSONObject(output.toString("UTF-8"))}
40
- } catch(e:IndianaClientException){throw e}catch(_:Exception){throw IndianaClientException("temporary_unavailable")}
41
- finally {connection.disconnect()}
31
+ val connection=URI(origin.toString().trimEnd('/')+"/api/v1/"+path).toURL().openConnection() as HttpURLConnection
32
+ // Some implementations block in disconnect while a read owns the stream.
33
+ continuation.invokeOnCancellation {
34
+ Dispatchers.IO.dispatch(EmptyCoroutineContext, Runnable { connection.disconnect() })
35
+ }
36
+ Dispatchers.IO.dispatch(continuation.context, Runnable {
37
+ if (!continuation.isActive) return@Runnable
38
+ try {
39
+ connection.requestMethod=method;connection.instanceFollowRedirects=false;connection.connectTimeout=configuration.timeoutMs;connection.readTimeout=configuration.timeoutMs;connection.useCaches=false
40
+ if(authorization.isNotEmpty()) connection.setRequestProperty("Authorization","Bearer $authorization")
41
+ connection.setRequestProperty("Content-Type","application/json")
42
+ connection.setRequestProperty("X-Indiana-Project",configuration.scope.projectId.toString());connection.setRequestProperty("X-Indiana-Environment",configuration.scope.environment.value)
43
+ connection.setRequestProperty("X-Indiana-Protocol",IndianaProtocol.wireProtocol)
44
+ connection.setRequestProperty("X-Indiana-SDK-Version",IndianaSDK.version)
45
+ if(body!=null){connection.doOutput=true;val bytes=body.toString().toByteArray(Charsets.UTF_8);connection.setFixedLengthStreamingMode(bytes.size);connection.outputStream.use {it.write(bytes)}}
46
+ if(connection.responseCode !in 200..299)throw IndianaClientException("request_failed_${connection.responseCode}")
47
+ requireCurrentProtocol(connection.getHeaderField("X-Indiana-Protocol"))
48
+ if((connection.getHeaderField("Content-Length")?.toLongOrNull() ?: 0)>2_097_152)throw IndianaClientException("invalid_response")
49
+ val output=ByteArrayOutputStream()
50
+ connection.inputStream.use { input ->
51
+ val buffer = ByteArray(8192)
52
+ while (true) {
53
+ if (!continuation.isActive) throw CancellationException()
54
+ val size = input.read(buffer)
55
+ if (size < 0) break
56
+ if (output.size() + size > 2_097_152) throw IndianaClientException("invalid_response")
57
+ output.write(buffer, 0, size)
58
+ }
59
+ }
60
+ continuation.resume(safe{JSONObject(output.toString("UTF-8"))})
61
+ } catch (error: Throwable) {
62
+ continuation.resumeWithException(when (error) {
63
+ is IndianaClientException, is CancellationException -> error
64
+ is Exception -> IndianaClientException("temporary_unavailable")
65
+ else -> error
66
+ })
67
+ }
68
+ finally {connection.disconnect()}
69
+ })
42
70
  }
43
71
  }
@@ -330,7 +330,9 @@ class IndianaClient internal constructor(private val configuration: IndianaConfi
330
330
  /** The authenticated merchant backend selects the Customer and adds its Server Key; retain idempotencyKey on retry. */
331
331
  suspend fun trackUsage(command: JSONObject, relay: suspend (JSONObject) -> JSONObject): JSONObject {
332
332
  checkOpen(); val actor = identity.get(); if (actor.token == null) throw IndianaClientException("unauthenticated")
333
- val body = JSONObject(command.toString()); IndianaSchema.validate("usageCommand", body)
333
+ val body = JSONObject(command.toString())
334
+ try { IndianaSchema.validate("usageCommand", body) }
335
+ catch (_: Exception) { throw IndianaClientException("invalid_request") }
334
336
  val result = try { relay(body) } catch (error: Exception) {
335
337
  if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
336
338
  throw error