@tinkerlandai/indiana-react-native 0.9.1 → 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/IndianaReactNative.podspec +19 -0
- package/README.md +61 -4
- package/THIRD_PARTY_NOTICES.md +74 -0
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgeModule.kt +237 -0
- package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgePackage.kt +11 -0
- package/dist/index.cjs +17 -1
- package/dist/index.d.cts +57408 -604
- package/dist/index.d.ts +57408 -604
- package/dist/index.js +17 -1
- package/ios/IndianaBridge.m +11 -0
- package/ios/IndianaBridge.swift +275 -0
- package/native-sdk/android/assets/indiana/index.html +268 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/BillingAuthorizationTerms.kt +55 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/BundledWebView.kt +90 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/ContentSession.kt +45 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/ContentView.kt +36 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerOperations.generated.kt +84 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerResponseSemantics.kt +193 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSemantics.kt +120 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSession.kt +106 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FeatureClient.kt +65 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelCommandStorage.kt +163 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelConfigurationRelay.kt +50 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelSession.kt +208 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelView.kt +71 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/GeneratedProtocol.kt +16 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/GoogleStorefront.kt +165 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/HTTPTransport.kt +43 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/IndianaClient.kt +526 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativePendingKeystore.kt +81 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativeReceiptRelay.kt +28 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativeStoreOffer.kt +24 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallContent.kt +75 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +144 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallView.kt +74 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/Protocol.kt +28 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PushInstallation.kt +170 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/RendererNavigation.kt +13 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +6 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/StoreSubscriptionManagement.kt +19 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +76 -0
- package/native-sdk/android/resources/funnel-wire-schemas.json +1 -0
- package/native-sdk/android/resources/indiana-third-party-notices.txt +101 -0
- package/native-sdk/android/resources/renderer-data-url.txt +1 -0
- package/native-sdk/ios/AnalyticsReceipt.swift +10 -0
- package/native-sdk/ios/AppleStorefront.swift +218 -0
- package/native-sdk/ios/BillingAuthorizationTerms.swift +57 -0
- package/native-sdk/ios/BundledWebView.swift +87 -0
- package/native-sdk/ios/ContentSession.swift +48 -0
- package/native-sdk/ios/ContentView.swift +51 -0
- package/native-sdk/ios/CustomerOperations.generated.swift +166 -0
- package/native-sdk/ios/CustomerResponseSemantics.swift +232 -0
- package/native-sdk/ios/CustomerSemantics.swift +145 -0
- package/native-sdk/ios/CustomerSession.swift +129 -0
- package/native-sdk/ios/FeatureClient.swift +86 -0
- package/native-sdk/ios/FunnelCommandStorage.swift +163 -0
- package/native-sdk/ios/FunnelConfigurationRelay.swift +48 -0
- package/native-sdk/ios/FunnelSession.swift +232 -0
- package/native-sdk/ios/FunnelView.swift +125 -0
- package/native-sdk/ios/GeneratedProtocol.swift +16 -0
- package/native-sdk/ios/HTTPTransport.swift +74 -0
- package/native-sdk/ios/IndianaClient.swift +601 -0
- package/native-sdk/ios/NativePendingKeychain.swift +52 -0
- package/native-sdk/ios/NativeReceiptRelay.swift +28 -0
- package/native-sdk/ios/NativeStoreOffer.swift +34 -0
- package/native-sdk/ios/PaywallContent.swift +75 -0
- package/native-sdk/ios/PaywallSession.swift +124 -0
- package/native-sdk/ios/PaywallView.swift +87 -0
- package/native-sdk/ios/Protocol.swift +49 -0
- package/native-sdk/ios/PushInstallation.swift +154 -0
- package/native-sdk/ios/RendererNavigation.swift +8 -0
- package/native-sdk/ios/Resources.swift +15 -0
- package/native-sdk/ios/SDKVersion.swift +4 -0
- package/native-sdk/ios/StoreSubscriptionManagement.swift +11 -0
- package/native-sdk/ios/WebView/THIRD_PARTY_NOTICES.txt +101 -0
- package/native-sdk/ios/WebView/index.html +268 -0
- package/native-sdk/ios/WebView/renderer-data-url.txt +1 -0
- package/native-sdk/ios/WireSchema.swift +107 -0
- package/native-sdk/ios/funnel-wire-schemas.json +1 -0
- package/package.json +8 -3
- package/react-native.config.cjs +12 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
package ai.tinkerland.indiana
|
|
2
|
+
|
|
3
|
+
import org.json.JSONArray
|
|
4
|
+
import org.json.JSONObject
|
|
5
|
+
import java.math.BigDecimal
|
|
6
|
+
import java.math.MathContext
|
|
7
|
+
import java.math.RoundingMode
|
|
8
|
+
|
|
9
|
+
/** Current Customer Zod refinements/transforms not representable in JSON Schema. */
|
|
10
|
+
internal object IndianaCustomerSemantics {
|
|
11
|
+
fun preflight(operation: String, value: JSONObject) {
|
|
12
|
+
if (operation == "evaluateFeatures") value.optJSONObject("attributes")?.let { if (it.length() > 128) throw IndianaClientException("invalid_request"); jsonBudget(it) }
|
|
13
|
+
if (operation == "trackEvents") value.optJSONArray("events")?.let { events -> for (n in 0 until events.length()) events.optJSONObject(n)?.optJSONObject("properties")?.let { properties -> for (key in properties.keys()) jsonBudget(properties.get(key)) } }
|
|
14
|
+
}
|
|
15
|
+
fun defaults(operation: String, input: JSONObject): JSONObject {
|
|
16
|
+
val value = JSONObject(input.toString())
|
|
17
|
+
if (operation in listOf("previewAttach", "previewUpdate")) value.optJSONObject("invoiceMode")?.let {
|
|
18
|
+
if (!it.has("enable_plan_immediately")) it.put("enable_plan_immediately", false)
|
|
19
|
+
if (!it.has("finalize")) it.put("finalize", true)
|
|
20
|
+
}
|
|
21
|
+
if (operation == "trackEvents") value.optJSONArray("events")?.let { events ->
|
|
22
|
+
for (n in 0 until events.length()) events.optJSONObject(n)?.let { if (!it.has("properties")) it.put("properties", JSONObject()) }
|
|
23
|
+
}
|
|
24
|
+
return value
|
|
25
|
+
}
|
|
26
|
+
fun request(operation: String, value: JSONObject): JSONObject {
|
|
27
|
+
fun require(allowed: Boolean) { if (!allowed) throw IndianaClientException("invalid_request") }
|
|
28
|
+
if (operation == "previewAttach") {
|
|
29
|
+
val plans = objects(value.getJSONArray("plans")); val provider = value.getString("provider")
|
|
30
|
+
require(plans.map { it.getString("productVersionId") }.distinct().size == plans.size)
|
|
31
|
+
require(plans.all { (it.optJSONObject("featureQuantities")?.length() ?: 0) <= 100 })
|
|
32
|
+
require(!value.has("offerId") || !value.has("billingSubscriptionId") && (provider == "stripe" || value.has("nativeOffer")))
|
|
33
|
+
require(!value.has("nativeOffer") || provider != "stripe" && plans.size == 1)
|
|
34
|
+
require(!value.has("newBillingSubscription") || provider == "stripe" && !value.has("billingSubscriptionId"))
|
|
35
|
+
require(value.optJSONObject("invoiceMode")?.optBoolean("enabled") != true || provider == "stripe" && !value.has("entityId"))
|
|
36
|
+
value.optJSONObject("nativeOffer")?.let { offer ->
|
|
37
|
+
require(offer.getString("offerKind") != "none" || offer.isNull("offerId") && offer.getInt("discountMinor") == 0 && offer.isNull("trialDays"))
|
|
38
|
+
require(offer.isNull("trialDays") || offer.getInt("discountMinor") == 0)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (operation == "previewUpdate") {
|
|
42
|
+
val change = value.getJSONObject("change"); val type = change.getString("type"); val timing = value.getString("timing")
|
|
43
|
+
require(timing != "store" || type == "plan")
|
|
44
|
+
require(type != "billing_cycle" || timing == if (change.opt("billingCycleAnchor") == "now") "immediate" else "renewal")
|
|
45
|
+
value.optJSONArray("membershipIds")?.let { group -> val ids = strings(group); require(type in listOf("cancel", "uncancel") && value.getString("membershipId") in ids && ids.distinct().size == ids.size) }
|
|
46
|
+
require(!value.has("carryOverBalances") && !value.has("carryOverUsages") || type == "plan" && timing == "immediate")
|
|
47
|
+
require(!value.has("prorationBehavior") || type == "quantity")
|
|
48
|
+
for (field in listOf("carryOverBalances", "carryOverUsages")) value.optJSONObject(field)?.optJSONArray("featureIds")?.let { val ids = strings(it); require(ids.distinct().size == ids.size) }
|
|
49
|
+
}
|
|
50
|
+
if (operation == "setBillingCoupons") { val ids = strings(value.getJSONArray("grantIds")); require(ids.distinct().size == ids.size) }
|
|
51
|
+
if (operation == "prepareBillingAuthorization" && value.getString("kind") == "auto_topup") {
|
|
52
|
+
require(decimal(value, "quantity") >= BigDecimal.ONE); normalize(value, "quantity"); normalize(value, "threshold")
|
|
53
|
+
}
|
|
54
|
+
if (operation in listOf("checkEntitlement", "resolvePaywall", "resolveOffering")) value.optJSONObject("requirement")?.let {
|
|
55
|
+
require(decimal(it, "quantity") > BigDecimal.ZERO); normalize(it, "quantity"); properties(it.getJSONObject("properties"))
|
|
56
|
+
}
|
|
57
|
+
if (operation == "quoteBalance") value.getJSONObject("command").let {
|
|
58
|
+
require(decimal(it, "quantity") > BigDecimal.ZERO); normalize(it, "quantity"); properties(it.getJSONObject("properties"))
|
|
59
|
+
}
|
|
60
|
+
if (operation == "readLedger") value.getJSONObject("query").let { if (!it.isNull("from") && !it.isNull("to")) require(it.getString("from") <= it.getString("to")) }
|
|
61
|
+
if (operation == "readCreditStatement") {
|
|
62
|
+
val elapsed = utcMillis(value.getString("to")) - utcMillis(value.getString("from"))
|
|
63
|
+
require(elapsed > 0 && elapsed <= 366L * 86400000L); require(value.isNull("after") || !value.isNull("snapshotAt"))
|
|
64
|
+
}
|
|
65
|
+
return value
|
|
66
|
+
}
|
|
67
|
+
fun response(operation: String, value: JSONObject) {
|
|
68
|
+
fun require(allowed: Boolean) { if (!allowed) throw IndianaClientException("invalid_response") }
|
|
69
|
+
val checks = listOfNotNull(
|
|
70
|
+
value.optJSONObject("check"),
|
|
71
|
+
if (operation == "resolvePaywall") value.optJSONObject("paywall")?.optJSONObject("check") else null,
|
|
72
|
+
)
|
|
73
|
+
for (check in checks) {
|
|
74
|
+
require(check.getBoolean("allowed") == check.isNull("reason")); require(!check.getBoolean("allowed") || check.isNull("preview"))
|
|
75
|
+
val candidates = check.optJSONObject("preview")?.optJSONArray("candidates")?.let(::objects) ?: emptyList()
|
|
76
|
+
require(candidates.map { it.getString("productVersionId") }.distinct().size == candidates.size)
|
|
77
|
+
require(candidates.all { (it.getString("action") == "upgrade") == !it.isNull("membershipId") })
|
|
78
|
+
}
|
|
79
|
+
if (operation == "quoteBalance") value.getJSONObject("quote").let { quote ->
|
|
80
|
+
require(if (quote.getBoolean("allowed")) quote.isNull("reason") else !quote.isNull("reason") && quote.getJSONArray("allocations").length() == 0)
|
|
81
|
+
if (!quote.has("overageBehavior")) require(!quote.has("requestedQuantity") && decimal(quote, "quantity") > BigDecimal.ZERO)
|
|
82
|
+
else require(quote.has("requestedQuantity") && decimal(quote, "requestedQuantity") >= decimal(quote, "quantity") && (quote.getString("overageBehavior") == "cap" || quote.getString("requestedQuantity") == quote.getString("quantity")))
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
private fun objects(value: JSONArray) = (0 until value.length()).map { value.getJSONObject(it) }
|
|
86
|
+
private fun strings(value: JSONArray) = (0 until value.length()).map { value.getString(it) }
|
|
87
|
+
private fun decimal(value: JSONObject, key: String) = value.getString(key).toBigDecimal()
|
|
88
|
+
private fun normalize(value: JSONObject, key: String) { value.put(key, value.getString(key).toBigDecimal().stripTrailingZeros().toPlainString()) }
|
|
89
|
+
private fun properties(value: JSONObject) {
|
|
90
|
+
if (value.length() > 16) throw IndianaClientException("invalid_request")
|
|
91
|
+
for (key in value.keys()) {
|
|
92
|
+
val item = value.get(key)
|
|
93
|
+
value.put(key, if (item is Number) {
|
|
94
|
+
val number = item.toDouble(); if (number == 0.0) "0" else {
|
|
95
|
+
// ECMAScript selects the closest shortest decimal that round-trips.
|
|
96
|
+
// JDK 17's Double.toString alone differs for e.g. 5e-324 and 1e18+128.
|
|
97
|
+
val exact = BigDecimal(number)
|
|
98
|
+
val decimal = (1..17).asSequence().map { exact.round(MathContext(it, RoundingMode.HALF_EVEN)) }.first { it.toDouble() == number }.stripTrailingZeros()
|
|
99
|
+
if (kotlin.math.abs(number) >= 1e-6 && kotlin.math.abs(number) < 1e21) decimal.toPlainString()
|
|
100
|
+
else decimal.toString().lowercase().replace(Regex("e([+-]?)0+(\\d+)"), "e$1$2")
|
|
101
|
+
}
|
|
102
|
+
} else item.toString())
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
private fun jsonBudget(value: Any?) {
|
|
106
|
+
var count = 0
|
|
107
|
+
fun visit(value: Any?, depth: Int) {
|
|
108
|
+
if (++count > 10000 || depth > 20) throw IndianaClientException("invalid_request")
|
|
109
|
+
fun text(value: String) { if ('\u0000' in value || value.indices.any { (Character.isHighSurrogate(value[it]) && (it + 1 >= value.length || !Character.isLowSurrogate(value[it + 1]))) || (Character.isLowSurrogate(value[it]) && (it == 0 || !Character.isHighSurrogate(value[it - 1]))) }) throw IndianaClientException("invalid_request") }
|
|
110
|
+
when (value) {
|
|
111
|
+
is String -> text(value)
|
|
112
|
+
is JSONArray -> for (n in 0 until value.length()) visit(value.opt(n), depth + 1)
|
|
113
|
+
is JSONObject -> for (key in value.keys()) { text(key); if (key in listOf("__proto__", "constructor", "prototype")) throw IndianaClientException("invalid_request"); visit(value.opt(key), depth + 1) }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
visit(value, 0)
|
|
117
|
+
val encoded = if (value is JSONObject || value is JSONArray) value.toString() else JSONArray().put(value).toString().drop(1).dropLast(1)
|
|
118
|
+
if (encoded.toByteArray(Charsets.UTF_8).size > 65536) throw IndianaClientException("invalid_request")
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
package ai.tinkerland.indiana
|
|
2
|
+
|
|
3
|
+
import java.time.Instant
|
|
4
|
+
import java.util.UUID
|
|
5
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
6
|
+
import kotlinx.coroutines.CompletableDeferred
|
|
7
|
+
import org.json.JSONObject
|
|
8
|
+
|
|
9
|
+
/** Server-issued credentials. This SDK keeps the pair in memory only. */
|
|
10
|
+
data class IndianaCustomerSession(val customerId: UUID, val sessionId: UUID, val accessToken: String, val refreshToken: String, val expiresAt: Instant, val refreshExpiresAt: Instant) {
|
|
11
|
+
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")
|
|
13
|
+
}
|
|
14
|
+
companion object {
|
|
15
|
+
@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() }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
internal class IndianaSessionTransport(private val upstream: IndianaHTTP, private val expectedScope: IndianaScope, private val now: () -> Instant = Instant::now): IndianaHTTP {
|
|
19
|
+
private data class State(val original: String?, val pair: IndianaCustomerSession?, val epoch: UUID, val onInvalidated: (() -> Unit)? = null)
|
|
20
|
+
private val state = AtomicReference(State(null, null, UUID.randomUUID()))
|
|
21
|
+
private data class Flight(val state: State, val result: CompletableDeferred<IndianaCustomerSession>)
|
|
22
|
+
private val rotation = Any()
|
|
23
|
+
private var pending: Flight? = null
|
|
24
|
+
@Volatile private var accessProvider: (suspend () -> String)? = null
|
|
25
|
+
fun setAccessTokenProvider(provider: (suspend () -> String)?) { accessProvider = provider }
|
|
26
|
+
fun identify(access: String?, onInvalidated: (() -> Unit)? = null) { state.set(State(access, null, UUID.randomUUID(), onInvalidated)) }
|
|
27
|
+
fun identify(pair: IndianaCustomerSession, onInvalidated: (() -> Unit)? = null) { pair.validate(); state.set(State(pair.accessToken, pair, UUID.randomUUID(), onInvalidated)) }
|
|
28
|
+
private fun invalidate(current: State) {
|
|
29
|
+
if (state.compareAndSet(current, State(null, null, UUID.randomUUID()))) current.onInvalidated?.invoke()
|
|
30
|
+
}
|
|
31
|
+
suspend fun refresh(force: Boolean = true): IndianaCustomerSession {
|
|
32
|
+
if (accessProvider != null) throw IndianaClientException("unsupported_capability")
|
|
33
|
+
val observed = state.get()
|
|
34
|
+
var owner = false
|
|
35
|
+
val flight = synchronized(rotation) {
|
|
36
|
+
val current = state.get()
|
|
37
|
+
if (current.epoch != observed.epoch) throw IndianaClientException("identity_changed")
|
|
38
|
+
val pair = current.pair ?: throw IndianaClientException("unauthenticated")
|
|
39
|
+
if (current.pair != observed.pair || (!force && pair.expiresAt > now().plusSeconds(30))) return pair
|
|
40
|
+
if (pair.refreshExpiresAt <= now()) {
|
|
41
|
+
invalidate(current)
|
|
42
|
+
throw IndianaClientException("unauthenticated")
|
|
43
|
+
}
|
|
44
|
+
pending?.takeIf { it.state.epoch == current.epoch } ?: Flight(current, CompletableDeferred()).also { pending = it; owner = true }
|
|
45
|
+
}
|
|
46
|
+
if (!owner) {
|
|
47
|
+
try {
|
|
48
|
+
val next = flight.result.await()
|
|
49
|
+
if (state.get().epoch != flight.state.epoch) throw IndianaClientException("identity_changed")
|
|
50
|
+
return next
|
|
51
|
+
} catch (error: Exception) {
|
|
52
|
+
if (state.get().epoch != flight.state.epoch) throw IndianaClientException("identity_changed")
|
|
53
|
+
throw error
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
val current = flight.state
|
|
57
|
+
val pair = current.pair ?: throw IndianaClientException("unauthenticated")
|
|
58
|
+
try {
|
|
59
|
+
val result = upstream.send("customer-sessions/refresh", "POST", pair.refreshToken, JSONObject())
|
|
60
|
+
if (safe { scope(result.getJSONObject("scope")) } != expectedScope) throw IndianaClientException("scope_mismatch")
|
|
61
|
+
val next = IndianaCustomerSession.fromJSON(result); next.validate(true)
|
|
62
|
+
if (next.customerId != pair.customerId || next.sessionId != pair.sessionId) throw IndianaClientException("scope_mismatch")
|
|
63
|
+
if (!state.compareAndSet(current, current.copy(pair = next))) throw IndianaClientException("identity_changed")
|
|
64
|
+
flight.result.complete(next); return next
|
|
65
|
+
} catch (cause: Exception) {
|
|
66
|
+
val error = if (state.get().epoch != current.epoch) IndianaClientException("identity_changed") else cause
|
|
67
|
+
if (error is IndianaClientException && error.code == "request_failed_401") invalidate(current)
|
|
68
|
+
flight.result.completeExceptionally(error); throw error
|
|
69
|
+
} finally { synchronized(rotation) { if (pending === flight) pending = null } }
|
|
70
|
+
}
|
|
71
|
+
suspend fun access(): String {
|
|
72
|
+
val current = state.get()
|
|
73
|
+
accessProvider?.let { provider ->
|
|
74
|
+
if (current.original == null) throw IndianaClientException("unauthenticated")
|
|
75
|
+
val token = provider()
|
|
76
|
+
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")
|
|
78
|
+
return token
|
|
79
|
+
}
|
|
80
|
+
if (current.pair != null && current.pair.expiresAt <= now().plusSeconds(30)) refresh(false)
|
|
81
|
+
val active = state.get()
|
|
82
|
+
if (current.epoch != active.epoch) throw IndianaClientException("identity_changed")
|
|
83
|
+
return active.pair?.accessToken ?: active.original ?: throw IndianaClientException("unauthenticated")
|
|
84
|
+
}
|
|
85
|
+
override suspend fun send(path: String, method: String, authorization: String, body: JSONObject?): JSONObject {
|
|
86
|
+
val actor = state.get()
|
|
87
|
+
val proof = body?.optString("customerToken")
|
|
88
|
+
if (path.startsWith("push/") && body?.has("credentialDigest") == true) {
|
|
89
|
+
if (actor.original == null) throw IndianaClientException("unauthenticated")
|
|
90
|
+
val result = upstream.send(path, method, authorization, body)
|
|
91
|
+
if (state.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
|
|
92
|
+
return result
|
|
93
|
+
}
|
|
94
|
+
if (actor.original == null && (authorization.startsWith("gg_ct_") || proof?.startsWith("gg_ct_") == true)) throw IndianaClientException("unauthenticated")
|
|
95
|
+
val customer = actor.original != null && (authorization == actor.original || proof == actor.original)
|
|
96
|
+
if (!customer) return upstream.send(path, method, authorization, body)
|
|
97
|
+
val token = access()
|
|
98
|
+
val active = state.get()
|
|
99
|
+
if (active.epoch != actor.epoch) throw IndianaClientException("identity_changed")
|
|
100
|
+
val payload = body?.let { JSONObject(it.toString()) }
|
|
101
|
+
if (proof == actor.original) payload?.put("customerToken", token)
|
|
102
|
+
val result = upstream.send(path, method, if (authorization == actor.original) token else authorization, payload)
|
|
103
|
+
if (state.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
|
|
104
|
+
return result
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
package ai.tinkerland.indiana
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import kotlinx.coroutines.CompletableDeferred
|
|
5
|
+
import kotlinx.coroutines.CoroutineScope
|
|
6
|
+
import kotlinx.coroutines.Dispatchers
|
|
7
|
+
import kotlinx.coroutines.SupervisorJob
|
|
8
|
+
import kotlinx.coroutines.cancel
|
|
9
|
+
import kotlinx.coroutines.withContext
|
|
10
|
+
import kotlinx.coroutines.withTimeout
|
|
11
|
+
import org.json.JSONObject
|
|
12
|
+
import java.util.UUID
|
|
13
|
+
|
|
14
|
+
/** Customer-scoped GB evaluator using the packaged official engine, without JS credentials. */
|
|
15
|
+
class IndianaFeatureClient internal constructor(private val activity: Activity, private val configuration: IndianaConfiguration, attributes: JSONObject, private val operation: suspend (IndianaCustomerOperation, JSONObject) -> JSONObject) {
|
|
16
|
+
private val attributes = JSONObject(attributes.toString())
|
|
17
|
+
private val work = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
|
18
|
+
private val readiness = CompletableDeferred<Unit>()
|
|
19
|
+
private val pending = mutableMapOf<UUID, CompletableDeferred<Any?>>()
|
|
20
|
+
var onError: ((Exception) -> Unit)? = null
|
|
21
|
+
private var stopped = false
|
|
22
|
+
private val host = IndianaBundledWebView(activity, "Indiana Feature evaluator", work, ::dispatch) { error -> if (!readiness.isCompleted) close() else onError?.invoke(error) }
|
|
23
|
+
init { host.start() }
|
|
24
|
+
suspend fun evalFeature(key: String): JSONObject {
|
|
25
|
+
val result = call(JSONObject().put("method", "evalFeature").put("key", key)) as? JSONObject ?: throw IndianaClientException("invalid_response")
|
|
26
|
+
IndianaSchema.validate("nativeFeatureResult", result); return JSONObject(result.toString())
|
|
27
|
+
}
|
|
28
|
+
suspend fun isOn(key: String): Boolean = evalFeature(key).getBoolean("on")
|
|
29
|
+
suspend fun isOff(key: String): Boolean = evalFeature(key).getBoolean("off")
|
|
30
|
+
suspend fun getFeatureValue(key: String, fallback: Any? = JSONObject.NULL): Any? = call(JSONObject().put("method", "getFeatureValue").put("key", key).put("fallback", fallback ?: JSONObject.NULL))
|
|
31
|
+
suspend fun setAttributes(attributes: JSONObject) { call(JSONObject().put("method", "setAttributes").put("attributes", JSONObject(attributes.toString()))) }
|
|
32
|
+
suspend fun refresh() { call(JSONObject().put("method", "refresh")) }
|
|
33
|
+
suspend fun flush() { call(JSONObject().put("method", "flush")) }
|
|
34
|
+
fun close() { activity.runOnUiThread {
|
|
35
|
+
if (!stopped) {
|
|
36
|
+
stopped = true; host.destroy()
|
|
37
|
+
val error = IndianaClientException("identity_changed")
|
|
38
|
+
readiness.completeExceptionally(error); pending.values.forEach { it.completeExceptionally(error) }; pending.clear(); work.cancel()
|
|
39
|
+
}
|
|
40
|
+
} }
|
|
41
|
+
private suspend fun call(input: JSONObject): Any? = withContext(Dispatchers.Main.immediate) {
|
|
42
|
+
if (stopped) throw IndianaClientException("identity_changed")
|
|
43
|
+
withTimeout(45_000) { readiness.await() }
|
|
44
|
+
if (stopped) throw IndianaClientException("identity_changed")
|
|
45
|
+
val id = UUID.randomUUID(); val result = CompletableDeferred<Any?>(); pending[id] = result
|
|
46
|
+
try { host.send(JSONObject().put("event", "feature_call").put("id", id.toString()).put("input", input)); withTimeout(45_000) { result.await() } }
|
|
47
|
+
finally { pending.remove(id) }
|
|
48
|
+
}
|
|
49
|
+
private suspend fun dispatch(method: String, raw: Any?): Any? = when (method) {
|
|
50
|
+
"ready" -> { host.send(JSONObject().put("event", "init").put("surface", "features").put("scope", JSONObject().put("projectId", configuration.scope.projectId.toString()).put("environment", configuration.scope.environment.value)).put("apiOrigin", configuration.apiOrigin).put("attributes", attributes)); null }
|
|
51
|
+
"featureOperation" -> {
|
|
52
|
+
val value = raw as? JSONObject ?: throw IndianaClientException("invalid_command")
|
|
53
|
+
val name = value.getString("operation")
|
|
54
|
+
if (stopped || name !in setOf("getFeatures", "evaluateFeatures", "trackEvents")) throw IndianaClientException("invalid_command")
|
|
55
|
+
operation(IndianaCustomerOperation.valueOf(name), value.getJSONObject("input"))
|
|
56
|
+
}
|
|
57
|
+
"featuresReady" -> { if (stopped) throw IndianaClientException("identity_changed"); readiness.complete(Unit); null }
|
|
58
|
+
"featureResult" -> {
|
|
59
|
+
val value = raw as? JSONObject ?: throw IndianaClientException("invalid_command")
|
|
60
|
+
val response = pending.remove(UUID.fromString(value.getString("id")))
|
|
61
|
+
if (value.has("error")) response?.completeExceptionally(IndianaClientException("invalid_response")) else response?.complete(value.opt("result")); null
|
|
62
|
+
}
|
|
63
|
+
else -> throw IndianaClientException("invalid_command")
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
package ai.tinkerland.indiana
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.security.keystore.KeyGenParameterSpec
|
|
5
|
+
import android.security.keystore.KeyProperties
|
|
6
|
+
import android.util.AtomicFile
|
|
7
|
+
import android.util.Base64
|
|
8
|
+
import kotlinx.coroutines.Dispatchers
|
|
9
|
+
import kotlinx.coroutines.withContext
|
|
10
|
+
import kotlinx.coroutines.sync.Mutex
|
|
11
|
+
import kotlinx.coroutines.sync.withLock
|
|
12
|
+
import org.json.JSONObject
|
|
13
|
+
import java.io.File
|
|
14
|
+
import java.io.RandomAccessFile
|
|
15
|
+
import java.security.KeyStore
|
|
16
|
+
import java.security.MessageDigest
|
|
17
|
+
import javax.crypto.Cipher
|
|
18
|
+
import javax.crypto.KeyGenerator
|
|
19
|
+
import javax.crypto.SecretKey
|
|
20
|
+
import javax.crypto.spec.GCMParameterSpec
|
|
21
|
+
|
|
22
|
+
data class IndianaFunnelStorageSnapshot(val revision: Long, val entry: String?)
|
|
23
|
+
/** One original Run per adapter; revision CAS must be atomic across hosts. */
|
|
24
|
+
interface IndianaFunnelCommandStorage {
|
|
25
|
+
fun read(): IndianaFunnelStorageSnapshot
|
|
26
|
+
fun write(expectedRevision: Long, entry: String?): Long?
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Encrypted, atomic, excluded from backup. Stores no Run/Customer credential. */
|
|
30
|
+
internal class IndianaFunnelCommandKeystore(context: Context, configuration: IndianaConfiguration, runToken: String): IndianaFunnelCommandStorage {
|
|
31
|
+
private val namespace = "${context.packageName}:${configuration.baseUrl}:${configuration.scope.projectId}:${configuration.scope.environment.value}:$runToken"
|
|
32
|
+
private val hash = MessageDigest.getInstance("SHA-256").digest(namespace.toByteArray()).joinToString("") { "%02x".format(it) }
|
|
33
|
+
private val directory = File(context.noBackupFilesDir, "indiana-funnel-commands-$hash")
|
|
34
|
+
private val file = AtomicFile(File(directory, "snapshot"))
|
|
35
|
+
private val alias = "indiana.funnel.commands.v1.$hash"
|
|
36
|
+
private fun exists() = file.baseFile.exists() || File(file.baseFile.path + ".bak").exists()
|
|
37
|
+
companion object { private val lock = Any() }
|
|
38
|
+
private fun <T> locked(action: () -> T): T = synchronized(lock) {
|
|
39
|
+
try {
|
|
40
|
+
if (!directory.exists() && !directory.mkdirs()) throw IndianaClientException("protected_storage_unavailable")
|
|
41
|
+
RandomAccessFile(File(directory,"lock"), "rw").use { handle -> handle.channel.lock().use { action() } }
|
|
42
|
+
} catch (error: IndianaClientException) { throw error }
|
|
43
|
+
catch (_: Exception) { throw IndianaClientException("protected_storage_unavailable") }
|
|
44
|
+
}
|
|
45
|
+
private fun key(create: Boolean): SecretKey {
|
|
46
|
+
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
|
47
|
+
(store.getKey(alias, null) as? SecretKey)?.let { return it }
|
|
48
|
+
if (!create || exists()) throw IndianaClientException("protected_storage_unavailable")
|
|
49
|
+
val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT).setKeySize(256).setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).setRandomizedEncryptionRequired(true).build()
|
|
50
|
+
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES,"AndroidKeyStore").apply { init(spec) }.generateKey()
|
|
51
|
+
}
|
|
52
|
+
private fun stored(): IndianaFunnelStorageSnapshot {
|
|
53
|
+
if (!exists()) return IndianaFunnelStorageSnapshot(0, null)
|
|
54
|
+
val bytes = file.openRead().use { input ->
|
|
55
|
+
val output = java.io.ByteArrayOutputStream(); val buffer = ByteArray(8192)
|
|
56
|
+
while (true) { val n = input.read(buffer); if (n < 0) break; if (output.size()+n > 1_048_576) throw IndianaClientException("protected_storage_unavailable"); output.write(buffer,0,n) }; output.toByteArray()
|
|
57
|
+
}
|
|
58
|
+
val encrypted = JSONObject(bytes.toString(Charsets.UTF_8))
|
|
59
|
+
if (encrypted.keys().asSequence().toSet() != setOf("format","nonce","ciphertext") || encrypted.getString("format") != "indiana_funnel_commands_v1") throw IndianaClientException("protected_storage_unavailable")
|
|
60
|
+
val nonce = Base64.decode(encrypted.getString("nonce"),Base64.NO_WRAP)
|
|
61
|
+
if (nonce.size != 12) throw IndianaClientException("protected_storage_unavailable")
|
|
62
|
+
val cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.DECRYPT_MODE,key(false),GCMParameterSpec(128,nonce)); cipher.updateAAD(hash.toByteArray())
|
|
63
|
+
val plain = cipher.doFinal(Base64.decode(encrypted.getString("ciphertext"),Base64.NO_WRAP))
|
|
64
|
+
try {
|
|
65
|
+
val value = JSONObject(plain.toString(Charsets.UTF_8)); val revision = value.getLong("revision")
|
|
66
|
+
if (revision < 0 || value.keys().asSequence().toSet() != setOf("revision","entry") || !value.isNull("entry") && value.optJSONObject("entry") == null) throw IndianaClientException("protected_storage_unavailable")
|
|
67
|
+
return IndianaFunnelStorageSnapshot(revision, value.optJSONObject("entry")?.toString())
|
|
68
|
+
} finally { plain.fill(0) }
|
|
69
|
+
}
|
|
70
|
+
override fun read(): IndianaFunnelStorageSnapshot = locked { stored() }
|
|
71
|
+
override fun write(expectedRevision: Long, entry: String?): Long? = locked {
|
|
72
|
+
if (stored().revision != expectedRevision) return@locked null
|
|
73
|
+
if (expectedRevision == Long.MAX_VALUE || (entry?.toByteArray()?.size ?: 0) > 600_000) throw IndianaClientException("protected_storage_unavailable")
|
|
74
|
+
val plain = JSONObject().put("revision",expectedRevision+1).put("entry",entry?.let { JSONObject(it) } ?: JSONObject.NULL).toString().toByteArray()
|
|
75
|
+
val cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE,key(true)); cipher.updateAAD(hash.toByteArray())
|
|
76
|
+
try {
|
|
77
|
+
val bytes = JSONObject().put("format","indiana_funnel_commands_v1").put("nonce",Base64.encodeToString(cipher.iv,Base64.NO_WRAP)).put("ciphertext",Base64.encodeToString(cipher.doFinal(plain),Base64.NO_WRAP)).toString().toByteArray()
|
|
78
|
+
val stream = file.startWrite()
|
|
79
|
+
try { stream.write(bytes); file.finishWrite(stream) } catch (error: Exception) { file.failWrite(stream); throw error }
|
|
80
|
+
} finally { plain.fill(0) }
|
|
81
|
+
expectedRevision+1
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
internal class IndianaFunnelCommandJournal(private val storage: IndianaFunnelCommandStorage, private val scope: IndianaScope) {
|
|
86
|
+
private val updates = Mutex()
|
|
87
|
+
@Volatile private var revision = 0L
|
|
88
|
+
@Volatile private var binding: JSONObject? = null
|
|
89
|
+
private fun same(a: JSONObject, b: JSONObject) = IndianaSchema.equal(a,b)
|
|
90
|
+
private fun matches(entry: JSONObject, run: JSONObject): Boolean = entry.getString("runId") == run.getString("id") && entry.getString("funnelId") == run.getJSONObject("source").getString("id") && entry.getString("versionId") == run.getJSONObject("source").getString("versionId")
|
|
91
|
+
private fun read(): Pair<Long,JSONObject?> {
|
|
92
|
+
val saved = storage.read()
|
|
93
|
+
if (saved.revision < 0 || (saved.entry?.toByteArray()?.size ?: 0) > 600_000) throw IndianaClientException("invalid_command_storage")
|
|
94
|
+
val value = saved.entry?.let { JSONObject(it) } ?: return saved.revision to null
|
|
95
|
+
val allowed = setOf("schemaVersion","scope","runId","funnelId","versionId","command","closeCommand","purchase","closed")
|
|
96
|
+
if (value.keys().asSequence().any { it !in allowed } || value.getInt("schemaVersion") != 1 || value.getJSONObject("scope").getString("projectId") != scope.projectId.toString() || value.getJSONObject("scope").getString("environment") != scope.environment.value || value.optJSONObject("command") == null && value.optJSONObject("purchase") == null) throw IndianaClientException("invalid_command_storage")
|
|
97
|
+
for (field in listOf("runId","funnelId","versionId")) java.util.UUID.fromString(value.getString(field))
|
|
98
|
+
for (field in listOf("command","closeCommand")) value.optJSONObject(field)?.let { command ->
|
|
99
|
+
if (command.has("customerToken") || command.optString("operation") in setOf("start","get","review")) throw IndianaClientException("invalid_command_storage")
|
|
100
|
+
IndianaSchema.validate("command",command)
|
|
101
|
+
}
|
|
102
|
+
value.optJSONObject("closeCommand")?.let { close -> val command = value.optJSONObject("command") ?: throw IndianaClientException("invalid_command_storage"); if (close.optString("operation") != "close" || command.optString("operation") == "close" || close.getString("requestId") == command.getString("requestId")) throw IndianaClientException("invalid_command_storage") }
|
|
103
|
+
value.optJSONObject("purchase")?.let { purchase ->
|
|
104
|
+
if (purchase.keys().asSequence().toSet() != setOf("receiptId","operationId","generation") || purchase.getLong("generation") < 0) throw IndianaClientException("invalid_command_storage")
|
|
105
|
+
java.util.UUID.fromString(purchase.getString("receiptId")); java.util.UUID.fromString(purchase.getString("operationId"))
|
|
106
|
+
}
|
|
107
|
+
return saved.revision to value
|
|
108
|
+
}
|
|
109
|
+
suspend fun load(): JSONObject? = updates.withLock { withContext(Dispatchers.IO) { val saved = read(); revision = saved.first; binding = saved.second; saved.second?.let { JSONObject(it.toString()) } } }
|
|
110
|
+
fun bind(run: JSONObject) { binding?.let { if (!matches(it,run)) throw IndianaClientException("invalid_response") } }
|
|
111
|
+
private suspend fun update(change: (JSONObject?,Long) -> JSONObject?) = updates.withLock { withContext(Dispatchers.IO) {
|
|
112
|
+
var written = false
|
|
113
|
+
for (attempt in 0..3) {
|
|
114
|
+
val (currentRevision,saved) = read()
|
|
115
|
+
binding?.let { original -> if (saved != null && (original.getString("runId") != saved.getString("runId") || original.getString("funnelId") != saved.getString("funnelId") || original.getString("versionId") != saved.getString("versionId"))) throw IndianaClientException("command_storage_conflict") }
|
|
116
|
+
val original = saved?.let { JSONObject(it.toString()) }
|
|
117
|
+
val next = change(saved,currentRevision)
|
|
118
|
+
if (next == null && original == null || next != null && original != null && same(next,original)) { revision = currentRevision; written = true; break }
|
|
119
|
+
val nextRevision = storage.write(currentRevision,next?.toString()) ?: continue
|
|
120
|
+
revision = nextRevision; binding = next ?: binding; written = true; break
|
|
121
|
+
}
|
|
122
|
+
if (!written) throw IndianaClientException("command_storage_conflict")
|
|
123
|
+
} }
|
|
124
|
+
private fun fresh(run: JSONObject) = JSONObject().put("schemaVersion",1).put("scope",JSONObject().put("projectId",scope.projectId.toString()).put("environment",scope.environment.value)).put("runId",run.getString("id")).put("funnelId",run.getJSONObject("source").getString("id")).put("versionId",run.getJSONObject("source").getString("versionId")).put("closed",false)
|
|
125
|
+
suspend fun retain(command: JSONObject, run: JSONObject) {
|
|
126
|
+
if (command.has("customerToken") || command.toString().toByteArray().size > 262144 || command.optString("operation") in setOf("start","get","review")) throw IndianaClientException("invalid_command")
|
|
127
|
+
IndianaSchema.validate("command",command)
|
|
128
|
+
bind(run); val known = revision
|
|
129
|
+
update { saved,currentRevision ->
|
|
130
|
+
val value = saved ?: fresh(run); val original = value.optJSONObject("command")
|
|
131
|
+
if (!matches(value,run) || saved == null && currentRevision != known && command.optString("operation") != "close") throw IndianaClientException("command_storage_conflict")
|
|
132
|
+
if (command.optString("operation") == "close") {
|
|
133
|
+
value.put("closed",true)
|
|
134
|
+
if (original != null && original.optString("operation") != "close") {
|
|
135
|
+
val previous = value.optJSONObject("closeCommand")
|
|
136
|
+
if (original.getString("requestId") == command.getString("requestId") || previous != null && !same(previous,command)) throw IndianaClientException("command_storage_conflict")
|
|
137
|
+
value.put("closeCommand",JSONObject(command.toString()))
|
|
138
|
+
} else { if (original != null && !same(original,command)) throw IndianaClientException("command_storage_conflict"); value.put("command",JSONObject(command.toString())) }
|
|
139
|
+
} else { if (original != null && !same(original,command)) throw IndianaClientException("command_storage_conflict"); value.put("command",JSONObject(command.toString())) }
|
|
140
|
+
value
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
suspend fun purchase(purchase: JSONObject, terminal: Boolean, run: JSONObject) {
|
|
144
|
+
bind(run); val known = revision
|
|
145
|
+
update { saved,currentRevision ->
|
|
146
|
+
if (saved == null && currentRevision != known) throw IndianaClientException("command_storage_conflict")
|
|
147
|
+
val value = saved ?: fresh(run); val previous = value.optJSONObject("purchase")
|
|
148
|
+
if (previous != null) {
|
|
149
|
+
if (previous.getLong("generation") > purchase.getLong("generation")) return@update saved
|
|
150
|
+
if (previous.getLong("generation") == purchase.getLong("generation") && previous.getString("operationId") != purchase.getString("operationId")) throw IndianaClientException("command_storage_conflict")
|
|
151
|
+
}
|
|
152
|
+
if (terminal) { if (previous?.getString("operationId") == purchase.getString("operationId")) value.remove("purchase") }
|
|
153
|
+
else value.put("purchase",JSONObject(purchase.toString()))
|
|
154
|
+
if (value.optJSONObject("command") == null && value.optJSONObject("purchase") == null) null else value
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
suspend fun settle(id: String) = update { saved,_ ->
|
|
158
|
+
if (saved == null) return@update null
|
|
159
|
+
if (saved.optJSONObject("command")?.optString("requestId") == id) { val close = saved.optJSONObject("closeCommand"); if (close == null) saved.remove("command") else saved.put("command",close); saved.remove("closeCommand") }
|
|
160
|
+
else if (saved.optJSONObject("closeCommand")?.optString("requestId") == id) saved.remove("closeCommand")
|
|
161
|
+
if (saved.optJSONObject("command") == null && saved.optJSONObject("purchase") == null) null else saved
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
package ai.tinkerland.indiana
|
|
2
|
+
|
|
3
|
+
import kotlinx.coroutines.Dispatchers
|
|
4
|
+
import kotlinx.coroutines.withContext
|
|
5
|
+
import org.json.JSONObject
|
|
6
|
+
import java.io.ByteArrayOutputStream
|
|
7
|
+
import java.net.HttpURLConnection
|
|
8
|
+
import java.net.URI
|
|
9
|
+
import java.nio.ByteBuffer
|
|
10
|
+
import java.nio.charset.CodingErrorAction
|
|
11
|
+
|
|
12
|
+
/** The Funnel shell has no network access (connect-src 'none'). For Local rules it asks the native host for public
|
|
13
|
+
* configuration: one exact GET, without credentials, cookies or redirects. */
|
|
14
|
+
internal class IndianaFunnelConfigurationRelay(private val configuration: IndianaConfiguration, loopbackFixtureOrigin: URI? = null) {
|
|
15
|
+
// Internal test seam: the public configuration remains HTTPS-only.
|
|
16
|
+
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)
|
|
17
|
+
suspend fun read(payload: Any?): JSONObject {
|
|
18
|
+
val request = payload as? JSONObject
|
|
19
|
+
val path = request?.opt("path") as? String
|
|
20
|
+
if (request == null || request.length() != 1 || path == null || !PATH.matches(path)) throw IndianaClientException("invalid_command")
|
|
21
|
+
return withContext(Dispatchers.IO) {
|
|
22
|
+
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(", "))
|
|
31
|
+
}
|
|
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() }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
companion object {
|
|
46
|
+
const val MAX_BYTES = 1_048_576
|
|
47
|
+
private val PATH = Regex("/api/v1/configuration/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(/artifacts/[a-f0-9]{64})?")
|
|
48
|
+
private val HEADERS = setOf("age", "content-type", "date", "etag", "retry-after", "x-indiana-config-valid-until", "x-indiana-config-validated-at")
|
|
49
|
+
}
|
|
50
|
+
}
|