@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.
- package/README.md +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.d.cts +26549 -12669
- package/dist/index.d.ts +26549 -12669
- package/dist/index.js +2 -2
- package/native-sdk/android/assets/indiana/index.html +40 -40
- package/native-sdk/android/java/ai/tinkerland/indiana/BundledWebView.kt +6 -2
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSession.kt +9 -3
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelConfigurationRelay.kt +47 -21
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelProcess.kt +120 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelSession.kt +57 -16
- package/native-sdk/android/java/ai/tinkerland/indiana/HTTPTransport.kt +48 -20
- package/native-sdk/android/java/ai/tinkerland/indiana/IndianaClient.kt +8 -2
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +82 -59
- package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +1 -1
- package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +3 -1
- package/native-sdk/android/java/ai/tinkerland/longplay/Longplay.kt +3 -0
- package/native-sdk/android/resources/funnel-wire-schemas.json +1 -1
- package/native-sdk/ios/BundledWebView.swift +5 -1
- package/native-sdk/ios/CustomerSession.swift +7 -3
- package/native-sdk/ios/FunnelConfigurationRelay.swift +1 -0
- package/native-sdk/ios/FunnelProcess.swift +148 -0
- package/native-sdk/ios/FunnelSession.swift +37 -9
- package/native-sdk/ios/HTTPTransport.swift +2 -0
- package/native-sdk/ios/IndianaClient.swift +7 -1
- package/native-sdk/ios/NativeReceiptRelay.swift +3 -1
- package/native-sdk/ios/PaywallSession.swift +5 -4
- package/native-sdk/ios/SDKVersion.swift +1 -1
- package/native-sdk/ios/WebView/index.html +40 -40
- package/native-sdk/ios/funnel-wire-schemas.json +1 -1
- 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 (
|
|
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 (!
|
|
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:
|
|
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 (!
|
|
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.
|
|
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
|
|
25
|
+
return suspendCancellableCoroutine { continuation ->
|
|
22
26
|
val connection = origin.resolve(path).toURL().openConnection() as HttpURLConnection
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
120
|
-
|
|
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
|
-
|
|
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"))) {
|
|
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 (!
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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())
|
|
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
|
|
@@ -344,7 +346,11 @@ class IndianaClient internal constructor(private val configuration: IndianaConfi
|
|
|
344
346
|
suspend fun previewAttach(requestId: UUID, plans: List<IndianaSubscriptionPlan>, provider: IndianaSubscriptionProvider, currency: String, billingSubscriptionId: UUID? = null, offerId: UUID? = null, nativeOffer: IndianaNativeStoreOffer? = null): JSONObject {
|
|
345
347
|
if (plans.size !in 1..20 || plans.map { it.productVersionId }.distinct().size != plans.size || !currency.matches(Regex("[A-Z]{3}")) || plans.any { it.featureQuantities.size > 100 || it.featureQuantities.values.any { q -> q !in 0..10000 } || it.seatQuantities.size > 20 || it.seatQuantities.any { (key, quantity) -> !key.matches(Regex("[a-z][a-z0-9_]{0,63}")) || quantity !in 0..10000 } }) throw IndianaClientException("invalid_request")
|
|
346
348
|
val entries = JSONArray()
|
|
347
|
-
|
|
349
|
+
// Empty quantity maps are omitted: the contract treats them as absent and the deployed runtime refuses an empty object.
|
|
350
|
+
plans.forEach { plan -> entries.put(JSONObject().put("productVersionId", plan.productVersionId.toString()).apply {
|
|
351
|
+
if (plan.seatQuantities.isNotEmpty()) put("seatQuantities", JSONObject(plan.seatQuantities))
|
|
352
|
+
if (plan.featureQuantities.isNotEmpty()) put("featureQuantities", JSONObject(plan.featureQuantities.mapKeys { entry -> entry.key.toString() }))
|
|
353
|
+
}) }
|
|
348
354
|
val body = JSONObject().put("requestId", requestId.toString()).put("plans", entries).put("provider", provider.value).put("currency", currency)
|
|
349
355
|
billingSubscriptionId?.let { body.put("billingSubscriptionId", it.toString()) }
|
|
350
356
|
if (nativeOffer != null && (!nativeOffer.matches(provider) || plans.size != 1) ||
|