@latchway/react-native 0.0.0-bootstrap.0 → 1.1.0

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 (108) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/LatchwayReactNative.podspec +33 -0
  3. package/NOTICE +7 -0
  4. package/README.md +293 -3
  5. package/SECURITY.md +69 -0
  6. package/android/build.gradle.kts +62 -0
  7. package/android/consumer-rules.pro +2 -0
  8. package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  9. package/android/gradle/wrapper/gradle-wrapper.properties +9 -0
  10. package/android/gradle.properties +4 -0
  11. package/android/gradlew +251 -0
  12. package/android/gradlew.bat +94 -0
  13. package/android/settings.gradle.kts +33 -0
  14. package/android/src/main/AndroidManifest.xml +3 -0
  15. package/android/src/main/java/dev/latchway/reactnative/LatchwayReactNativePackage.kt +25 -0
  16. package/android/src/main/java/dev/latchway/reactnative/NativeLatchwayModule.kt +964 -0
  17. package/android/src/test/java/dev/latchway/reactnative/NativeLatchwayModuleTest.kt +521 -0
  18. package/babel.cjs +22 -0
  19. package/babel.d.cts +7 -0
  20. package/contract.lock +7 -0
  21. package/docs/architecture.md +145 -0
  22. package/docs/conformance.md +68 -0
  23. package/docs/langchain.md +135 -0
  24. package/docs/native-installation.md +172 -0
  25. package/docs/physical-device-evidence.md +559 -0
  26. package/docs/releasing.md +360 -0
  27. package/docs/security.md +55 -0
  28. package/ios/LatchwayNativeBridge.swift +1551 -0
  29. package/ios/RCTNativeLatchway.h +9 -0
  30. package/ios/RCTNativeLatchway.mm +175 -0
  31. package/lib/client.d.ts +29 -0
  32. package/lib/client.d.ts.map +1 -0
  33. package/lib/client.js +939 -0
  34. package/lib/client.js.map +1 -0
  35. package/lib/component-client.d.ts +15 -0
  36. package/lib/component-client.d.ts.map +1 -0
  37. package/lib/component-client.js +141 -0
  38. package/lib/component-client.js.map +1 -0
  39. package/lib/config.d.ts +25 -0
  40. package/lib/config.d.ts.map +1 -0
  41. package/lib/config.js +261 -0
  42. package/lib/config.js.map +1 -0
  43. package/lib/coordinator.d.ts +19 -0
  44. package/lib/coordinator.d.ts.map +1 -0
  45. package/lib/coordinator.js +167 -0
  46. package/lib/coordinator.js.map +1 -0
  47. package/lib/errors.d.ts +5 -0
  48. package/lib/errors.d.ts.map +1 -0
  49. package/lib/errors.js +201 -0
  50. package/lib/errors.js.map +1 -0
  51. package/lib/index.d.ts +8 -0
  52. package/lib/index.d.ts.map +1 -0
  53. package/lib/index.js +13 -0
  54. package/lib/index.js.map +1 -0
  55. package/lib/native/NativeLatchway.d.ts +25 -0
  56. package/lib/native/NativeLatchway.d.ts.map +1 -0
  57. package/lib/native/NativeLatchway.js +3 -0
  58. package/lib/native/NativeLatchway.js.map +1 -0
  59. package/lib/native/bridge.d.ts +5 -0
  60. package/lib/native/bridge.d.ts.map +1 -0
  61. package/lib/native/bridge.js +17 -0
  62. package/lib/native/bridge.js.map +1 -0
  63. package/lib/native-output.d.ts +3 -0
  64. package/lib/native-output.d.ts.map +1 -0
  65. package/lib/native-output.js +43 -0
  66. package/lib/native-output.js.map +1 -0
  67. package/lib/polyfills.d.ts +8 -0
  68. package/lib/polyfills.d.ts.map +1 -0
  69. package/lib/polyfills.js +56 -0
  70. package/lib/polyfills.js.map +1 -0
  71. package/lib/request-id.d.ts +2 -0
  72. package/lib/request-id.d.ts.map +1 -0
  73. package/lib/request-id.js +5 -0
  74. package/lib/request-id.js.map +1 -0
  75. package/lib/runtime-symbols.d.ts +2 -0
  76. package/lib/runtime-symbols.d.ts.map +1 -0
  77. package/lib/runtime-symbols.js +9 -0
  78. package/lib/runtime-symbols.js.map +1 -0
  79. package/lib/testing.d.ts +7 -0
  80. package/lib/testing.d.ts.map +1 -0
  81. package/lib/testing.js +9 -0
  82. package/lib/testing.js.map +1 -0
  83. package/lib/types.d.ts +200 -0
  84. package/lib/types.d.ts.map +1 -0
  85. package/lib/types.js +2 -0
  86. package/lib/types.js.map +1 -0
  87. package/lib/version.d.ts +8 -0
  88. package/lib/version.d.ts.map +1 -0
  89. package/lib/version.js +8 -0
  90. package/lib/version.js.map +1 -0
  91. package/package.json +152 -6
  92. package/react-native.config.cjs +7 -0
  93. package/release-compatibility.json +64 -0
  94. package/src/client.ts +1022 -0
  95. package/src/component-client.ts +158 -0
  96. package/src/config.ts +368 -0
  97. package/src/coordinator.ts +195 -0
  98. package/src/errors.ts +225 -0
  99. package/src/index.ts +53 -0
  100. package/src/native/NativeLatchway.ts +75 -0
  101. package/src/native/bridge.ts +23 -0
  102. package/src/native-output.ts +43 -0
  103. package/src/polyfills.ts +50 -0
  104. package/src/request-id.ts +5 -0
  105. package/src/runtime-symbols.ts +9 -0
  106. package/src/testing.ts +11 -0
  107. package/src/types.ts +241 -0
  108. package/src/version.ts +7 -0
@@ -0,0 +1,964 @@
1
+ package dev.latchway.reactnative
2
+
3
+ import android.net.Uri
4
+ import android.util.Base64
5
+ import com.facebook.react.bridge.Arguments
6
+ import com.facebook.react.bridge.Promise
7
+ import com.facebook.react.bridge.ReactApplicationContext
8
+ import com.facebook.react.bridge.WritableMap
9
+ import com.facebook.react.module.annotations.ReactModule
10
+ import dev.latchway.core.KeyPolicy
11
+ import dev.latchway.core.LATCHWAY_CONTRACT_VERSION
12
+ import dev.latchway.core.LATCHWAY_PROTOCOL_VERSION
13
+ import dev.latchway.core.LATCHWAY_SDK_VERSION
14
+ import dev.latchway.core.LatchwayClientPlatform
15
+ import dev.latchway.core.LatchwayErrorCode
16
+ import dev.latchway.core.LatchwayException
17
+ import dev.latchway.okhttp.LatchwayClient
18
+ import dev.latchway.okhttp.LatchwayConfiguration
19
+ import dev.latchway.okhttp.LATCHWAY_REACT_NATIVE_FRAMEWORK_ID
20
+ import dev.latchway.okhttp.LATCHWAY_REACT_NATIVE_FRAMEWORK_VERSION
21
+ import dev.latchway.playintegrity.PlayIntegrityAttestationProvider
22
+ import kotlinx.coroutines.CancellationException
23
+ import kotlinx.coroutines.CoroutineScope
24
+ import kotlinx.coroutines.Dispatchers
25
+ import kotlinx.coroutines.Job
26
+ import kotlinx.coroutines.SupervisorJob
27
+ import kotlinx.coroutines.cancel
28
+ import kotlinx.coroutines.currentCoroutineContext
29
+ import kotlinx.coroutines.launch
30
+ import kotlinx.coroutines.suspendCancellableCoroutine
31
+ import kotlinx.coroutines.sync.Mutex
32
+ import kotlinx.coroutines.sync.withLock
33
+ import okhttp3.Call
34
+ import okhttp3.Callback
35
+ import okhttp3.HttpUrl.Companion.toHttpUrl
36
+ import okhttp3.MediaType.Companion.toMediaTypeOrNull
37
+ import okhttp3.OkHttpClient
38
+ import okhttp3.Request
39
+ import okhttp3.RequestBody.Companion.toRequestBody
40
+ import okhttp3.Response
41
+ import org.json.JSONArray
42
+ import org.json.JSONObject
43
+ import java.io.Closeable
44
+ import java.io.IOException
45
+ import java.util.UUID
46
+ import java.util.concurrent.atomic.AtomicBoolean
47
+ import java.util.concurrent.ConcurrentHashMap
48
+ import kotlin.coroutines.resume
49
+ import kotlin.coroutines.resumeWithException
50
+
51
+ internal fun interface NativeClientFactory {
52
+ fun create(
53
+ configuration: NativeConfiguration,
54
+ keyPolicy: KeyPolicy,
55
+ cloudProjectNumber: Long,
56
+ tokenProvider: TransientIdentityTokenProvider,
57
+ reactContext: ReactApplicationContext,
58
+ ): NativeClientOperations
59
+ }
60
+
61
+ internal interface NativeClientOperations : Closeable {
62
+ val applicationClient: OkHttpClient
63
+
64
+ suspend fun refresh()
65
+ suspend fun quota(feature: String): String
66
+ suspend fun diagnostics(): String
67
+ suspend fun revokeCurrentInstallation()
68
+ suspend fun revokeCurrentInstallationFamily()
69
+ }
70
+
71
+ internal fun nativeApplicationClientBuilder(): OkHttpClient.Builder = OkHttpClient.Builder()
72
+ .followRedirects(false)
73
+ .followSslRedirects(false)
74
+
75
+ private class ProductionNativeClientOperations(
76
+ private val client: LatchwayClient,
77
+ ) : NativeClientOperations {
78
+ override val applicationClient: OkHttpClient = client.buildOkHttpClient(
79
+ nativeApplicationClientBuilder(),
80
+ )
81
+
82
+ override suspend fun refresh() {
83
+ client.refresh()
84
+ }
85
+
86
+ override suspend fun quota(feature: String): String {
87
+ val snapshot = client.quota(feature)
88
+ val limits = JSONArray()
89
+ snapshot.limits.forEach { limit ->
90
+ limits.put(JSONObject()
91
+ .put("metric", limit.metric)
92
+ .putNullable("maximum", limit.maximum)
93
+ .putNullable("used", limit.used)
94
+ .putNullable("reserved", limit.reserved)
95
+ .putNullable("remaining", limit.remaining)
96
+ .putNullable("resets_at", limit.resetsAt)
97
+ .put("hard", limit.hard))
98
+ }
99
+ return JSONObject()
100
+ .put("feature", snapshot.feature)
101
+ .put("observed_at", snapshot.observedAt)
102
+ .put("limits", limits)
103
+ .toString()
104
+ }
105
+
106
+ override suspend fun diagnostics(): String {
107
+ val diagnostics = client.diagnostics()
108
+ return JSONObject()
109
+ .put("contractVersion", diagnostics.contractVersion)
110
+ .put("protocolVersion", diagnostics.protocolVersion)
111
+ .put("keyStorage", diagnostics.key.backing.name.lowercase())
112
+ .put("attestation", JSONObject()
113
+ .put("support", "supported")
114
+ .put("provider", diagnostics.trustProvider)
115
+ .put("trustLevel", diagnostics.trustLevel))
116
+ .put("session", JSONObject()
117
+ .put("state", "active")
118
+ .put("expiresAt", diagnostics.sessionExpiresAt)
119
+ .put("refreshAvailable", diagnostics.refreshAvailable))
120
+ .put("installation", JSONObject()
121
+ .put("id", diagnostics.installationId)
122
+ .put("status", diagnostics.installationStatus))
123
+ .put("server", JSONObject()
124
+ .put("version", diagnostics.serverVersion)
125
+ .put("lastRequestID", diagnostics.requestId))
126
+ .toString()
127
+ }
128
+
129
+ override suspend fun revokeCurrentInstallation() {
130
+ client.revokeCurrentInstallation()
131
+ }
132
+
133
+ override suspend fun revokeCurrentInstallationFamily() {
134
+ client.revokeCurrentInstallationFamily()
135
+ }
136
+
137
+ override fun close() {
138
+ applicationClient.dispatcher.cancelAll()
139
+ applicationClient.connectionPool.evictAll()
140
+ applicationClient.dispatcher.executorService.shutdown()
141
+ client.close()
142
+ }
143
+ }
144
+
145
+ private val PRODUCTION_NATIVE_CLIENT_FACTORY = NativeClientFactory {
146
+ configuration,
147
+ keyPolicy,
148
+ cloudProjectNumber,
149
+ tokenProvider,
150
+ reactContext,
151
+ ->
152
+ val nativeConfiguration = LatchwayConfiguration(
153
+ baseUrl = configuration.baseURL.toHttpUrl(),
154
+ applicationId = configuration.applicationID,
155
+ environment = configuration.environment,
156
+ identityProvider = configuration.identityProvider,
157
+ clientPlatform = LatchwayClientPlatform.REACT_NATIVE_ANDROID,
158
+ sdkVersion = configuration.sdkVersion,
159
+ keyPolicy = keyPolicy,
160
+ allowInsecureLoopback = configuration.allowInsecureLoopback,
161
+ )
162
+ ProductionNativeClientOperations(
163
+ LatchwayClient(
164
+ configuration = nativeConfiguration,
165
+ identityTokenProvider = { tokenProvider.current() },
166
+ attestationProvider = PlayIntegrityAttestationProvider(
167
+ context = reactContext,
168
+ cloudProjectNumber = cloudProjectNumber,
169
+ ),
170
+ context = reactContext,
171
+ ),
172
+ )
173
+ }
174
+
175
+ @ReactModule(name = NativeLatchwayModule.NAME)
176
+ public class NativeLatchwayModule internal constructor(
177
+ reactContext: ReactApplicationContext,
178
+ private val clientFactory: NativeClientFactory,
179
+ private val userInfoFactory: () -> WritableMap,
180
+ ) : NativeLatchwaySpec(reactContext) {
181
+ public constructor(reactContext: ReactApplicationContext) : this(
182
+ reactContext,
183
+ PRODUCTION_NATIVE_CLIENT_FACTORY,
184
+ { Arguments.createMap() },
185
+ )
186
+
187
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
188
+ private val clients = ConcurrentHashMap<String, NativeClientContext>()
189
+ private val jobs = ConcurrentHashMap<String, Job>()
190
+
191
+ override fun getName(): String = NAME
192
+
193
+ override fun configure(clientID: String, configurationJSON: String, promise: Promise) {
194
+ launchPromise(clientID, "configure", promise) {
195
+ require(!clients.containsKey(clientID)) { "client identifier is already configured" }
196
+ val configuration = NativeConfiguration.parse(configurationJSON)
197
+ require(configuration.contractVersion == LATCHWAY_CONTRACT_VERSION &&
198
+ configuration.protocolVersion == LATCHWAY_PROTOCOL_VERSION &&
199
+ configuration.frameworkID == LATCHWAY_REACT_NATIVE_FRAMEWORK_ID &&
200
+ configuration.frameworkVersion == LATCHWAY_REACT_NATIVE_FRAMEWORK_VERSION
201
+ ) { "contract version is incompatible" }
202
+ val projectNumber = configuration.playIntegrityCloudProjectNumber
203
+ ?: throw IllegalArgumentException("Play Integrity cloud project number is required on Android")
204
+ val keyPolicy = when (configuration.keyPolicy) {
205
+ "hardware_backed_required" -> KeyPolicy(preferStrongBox = false, allowSoftwareBacked = false)
206
+ "strongbox_preferred" -> KeyPolicy(preferStrongBox = true, allowSoftwareBacked = false)
207
+ "software_allowed" -> KeyPolicy(preferStrongBox = true, allowSoftwareBacked = true)
208
+ else -> throw IllegalArgumentException("Android key policy is invalid")
209
+ }
210
+ val tokenProvider = TransientIdentityTokenProvider()
211
+ val client = clientFactory.create(
212
+ configuration = configuration,
213
+ keyPolicy = keyPolicy,
214
+ cloudProjectNumber = projectNumber,
215
+ tokenProvider = tokenProvider,
216
+ reactContext = reactApplicationContext,
217
+ )
218
+ val context = NativeClientContext(client, tokenProvider, configuration.baseURL.toHttpUrl())
219
+ check(clients.putIfAbsent(clientID, context) == null) { "client identifier is already configured" }
220
+ JSONObject()
221
+ .put("platform", "react_native_android")
222
+ .put("nativeSDKVersion", LATCHWAY_SDK_VERSION)
223
+ .put("contractVersion", LATCHWAY_CONTRACT_VERSION)
224
+ .put("protocolVersion", LATCHWAY_PROTOCOL_VERSION)
225
+ .toString()
226
+ }
227
+ }
228
+
229
+ override fun configureComponent(
230
+ clientID: String,
231
+ configurationJSON: String,
232
+ componentJSON: String,
233
+ promise: Promise,
234
+ ) {
235
+ launchPromise(clientID, "configure-component", promise) {
236
+ throw LatchwayException(
237
+ code = LatchwayErrorCode.ATTESTATION_UNSUPPORTED,
238
+ safeMessage = "React Native direct component attestation is not supported by this Android SDK",
239
+ )
240
+ }
241
+ }
242
+
243
+ override fun startRequest(
244
+ clientID: String,
245
+ operationID: String,
246
+ identityToken: String,
247
+ requestJSON: String,
248
+ promise: Promise,
249
+ ) {
250
+ operate(clientID, operationID, promise) { context ->
251
+ context.startRequest(identityToken, requestJSON)
252
+ }
253
+ }
254
+
255
+ override fun readResponseChunk(
256
+ clientID: String,
257
+ operationID: String,
258
+ responseID: String,
259
+ maximumBytes: Double,
260
+ promise: Promise,
261
+ ) {
262
+ operate(clientID, operationID, promise) { context ->
263
+ context.readResponseChunk(responseID, maximumBytes)
264
+ }
265
+ }
266
+
267
+ override fun closeResponse(clientID: String, responseID: String, promise: Promise) {
268
+ val context = clients[clientID]
269
+ if (context == null) {
270
+ promise.reject("invalid_configuration", "Latchway native client is not configured.")
271
+ return
272
+ }
273
+ context.closeResponse(responseID)
274
+ promise.resolve(null)
275
+ }
276
+
277
+ override fun refresh(clientID: String, operationID: String, identityToken: String, promise: Promise) {
278
+ operate(clientID, operationID, promise) { context ->
279
+ context.withIdentityToken(identityToken) { client -> client.refresh() }
280
+ }
281
+ }
282
+
283
+ override fun quota(
284
+ clientID: String,
285
+ operationID: String,
286
+ identityToken: String,
287
+ feature: String,
288
+ promise: Promise,
289
+ ) {
290
+ operate(clientID, operationID, promise) { context ->
291
+ context.withIdentityToken(identityToken) { client -> client.quota(feature) }
292
+ }
293
+ }
294
+
295
+ override fun diagnostics(clientID: String, operationID: String, identityToken: String, promise: Promise) {
296
+ operate(clientID, operationID, promise) { context ->
297
+ context.withIdentityToken(identityToken) { client -> client.diagnostics() }
298
+ }
299
+ }
300
+
301
+ override fun establishDirectAttestation(
302
+ clientID: String,
303
+ operationID: String,
304
+ promise: Promise,
305
+ ) {
306
+ operate(clientID, operationID, promise) {
307
+ // The v1 Android SDK has independently keyed delegated components,
308
+ // but no direct component-attestation endpoint. Never imitate the
309
+ // iOS App Attest protocol or accept evidence through JavaScript.
310
+ throw LatchwayException(
311
+ code = LatchwayErrorCode.ATTESTATION_UNSUPPORTED,
312
+ safeMessage = "Direct component attestation is not supported by this Android SDK",
313
+ )
314
+ }
315
+ }
316
+
317
+ override fun componentDiagnostics(
318
+ clientID: String,
319
+ operationID: String,
320
+ promise: Promise,
321
+ ) {
322
+ operate(clientID, operationID, promise) {
323
+ throw LatchwayException(
324
+ code = LatchwayErrorCode.ATTESTATION_UNSUPPORTED,
325
+ safeMessage = "Direct-attestation component diagnostics are not supported by this Android SDK",
326
+ )
327
+ }
328
+ }
329
+
330
+ override fun prepareComponents(
331
+ clientID: String,
332
+ operationID: String,
333
+ identityToken: String,
334
+ componentsJSON: String,
335
+ promise: Promise,
336
+ ) {
337
+ operate(clientID, operationID, promise) {
338
+ throw LatchwayException(
339
+ code = LatchwayErrorCode.ATTESTATION_UNSUPPORTED,
340
+ safeMessage = "Native iOS component provisioning is not supported by this Android SDK",
341
+ )
342
+ }
343
+ }
344
+
345
+ override fun replaceComponent(
346
+ clientID: String,
347
+ operationID: String,
348
+ identityToken: String,
349
+ componentJSON: String,
350
+ promise: Promise,
351
+ ) {
352
+ operate(clientID, operationID, promise) {
353
+ throw LatchwayException(
354
+ code = LatchwayErrorCode.ATTESTATION_UNSUPPORTED,
355
+ safeMessage = "Native iOS component replacement is not supported by this Android SDK",
356
+ )
357
+ }
358
+ }
359
+
360
+ override fun rootComponentDiagnostics(
361
+ clientID: String,
362
+ operationID: String,
363
+ componentJSON: String,
364
+ promise: Promise,
365
+ ) {
366
+ operate(clientID, operationID, promise) {
367
+ throw LatchwayException(
368
+ code = LatchwayErrorCode.ATTESTATION_UNSUPPORTED,
369
+ safeMessage = "Native iOS root component diagnostics are not supported by this Android SDK",
370
+ )
371
+ }
372
+ }
373
+
374
+ override fun revokeComponent(
375
+ clientID: String,
376
+ operationID: String,
377
+ identityToken: String,
378
+ componentJSON: String,
379
+ promise: Promise,
380
+ ) {
381
+ operate(clientID, operationID, promise) {
382
+ throw LatchwayException(
383
+ code = LatchwayErrorCode.ATTESTATION_UNSUPPORTED,
384
+ safeMessage = "Native iOS component revocation is not supported by this Android SDK",
385
+ )
386
+ }
387
+ }
388
+
389
+ override fun revoke(clientID: String, operationID: String, identityToken: String, promise: Promise) {
390
+ operate(clientID, operationID, promise) { context ->
391
+ context.withIdentityToken(identityToken) { client -> client.revokeCurrentInstallation() }
392
+ }
393
+ }
394
+
395
+ override fun revokeFamily(clientID: String, operationID: String, identityToken: String, promise: Promise) {
396
+ operate(clientID, operationID, promise) { context ->
397
+ context.withIdentityToken(identityToken) { client -> client.revokeCurrentInstallationFamily() }
398
+ }
399
+ }
400
+
401
+ override fun revokeFamilyWithComponents(
402
+ clientID: String,
403
+ operationID: String,
404
+ identityToken: String,
405
+ componentsJSON: String,
406
+ promise: Promise,
407
+ ) {
408
+ operate(clientID, operationID, promise) {
409
+ throw LatchwayException(
410
+ code = LatchwayErrorCode.ATTESTATION_UNSUPPORTED,
411
+ safeMessage = "Descriptor-bound iOS family retirement is not supported by this Android SDK",
412
+ )
413
+ }
414
+ }
415
+
416
+ override fun cancel(clientID: String, operationID: String) {
417
+ jobs[operationKey(clientID, operationID)]?.cancel()
418
+ }
419
+
420
+ override fun dispose(clientID: String, promise: Promise) {
421
+ clients.remove(clientID)?.close()
422
+ val prefix = "$clientID|"
423
+ jobs.entries.filter { it.key.startsWith(prefix) }.forEach { it.value.cancel() }
424
+ promise.resolve(null)
425
+ }
426
+
427
+ override fun invalidate() {
428
+ jobs.values.forEach { it.cancel() }
429
+ clients.values.forEach(NativeClientContext::close)
430
+ jobs.clear()
431
+ clients.clear()
432
+ scope.cancel()
433
+ super.invalidate()
434
+ }
435
+
436
+ private fun <T> operate(
437
+ clientID: String,
438
+ operationID: String,
439
+ promise: Promise,
440
+ action: suspend (NativeClientContext) -> T,
441
+ ) {
442
+ val context = clients[clientID]
443
+ if (context == null) {
444
+ promise.reject("invalid_configuration", "Latchway native client is not configured.")
445
+ return
446
+ }
447
+ val key = operationKey(clientID, operationID)
448
+ val job = scope.launch(start = kotlinx.coroutines.CoroutineStart.LAZY) {
449
+ try {
450
+ promise.resolve(action(context))
451
+ } catch (failure: Throwable) {
452
+ promise.rejectSafe(failure, userInfoFactory)
453
+ } finally {
454
+ jobs.remove(key)
455
+ }
456
+ }
457
+ if (jobs.putIfAbsent(key, job) != null) {
458
+ job.cancel()
459
+ promise.reject("request_invalid", "Latchway operation identifier is already active.")
460
+ } else {
461
+ job.start()
462
+ }
463
+ }
464
+
465
+ private fun <T> launchPromise(
466
+ clientID: String,
467
+ operationID: String,
468
+ promise: Promise,
469
+ action: suspend () -> T,
470
+ ) {
471
+ val key = operationKey(clientID, operationID)
472
+ val job = scope.launch(start = kotlinx.coroutines.CoroutineStart.LAZY) {
473
+ try { promise.resolve(action()) }
474
+ catch (failure: Throwable) { promise.rejectSafe(failure, userInfoFactory) }
475
+ finally { jobs.remove(key) }
476
+ }
477
+ if (jobs.putIfAbsent(key, job) != null) {
478
+ job.cancel()
479
+ promise.reject("request_invalid", "Latchway operation identifier is already active.")
480
+ } else {
481
+ job.start()
482
+ }
483
+ }
484
+
485
+ public companion object { public const val NAME: String = "NativeLatchway" }
486
+ }
487
+
488
+ internal class NativeClientContext(
489
+ private val client: NativeClientOperations,
490
+ private val tokenProvider: TransientIdentityTokenProvider,
491
+ private val baseURL: okhttp3.HttpUrl,
492
+ ) {
493
+ private val operationMutex = Mutex()
494
+ private val responses = ConcurrentHashMap<String, NativeResponse>()
495
+ private val applicationClient = client.applicationClient
496
+
497
+ suspend fun startRequest(identityToken: String, encoded: String): String =
498
+ withIdentityToken(identityToken) {
499
+ val input = NativeRequestInput.parse(encoded)
500
+ val url = try {
501
+ input.url.toHttpUrl()
502
+ } catch (failure: IllegalArgumentException) {
503
+ throw requestFailure("The request URL is invalid", failure)
504
+ }
505
+ validateNativeTarget(baseURL, url, input.method, input.feature)
506
+ val mediaType = input.headers.firstOrNull { it.first.equals("content-type", ignoreCase = true) }
507
+ ?.second?.toMediaTypeOrNull()
508
+ val body = when {
509
+ input.body != null -> input.body.toRequestBody(mediaType)
510
+ input.method in METHODS_REQUIRING_BODY -> ByteArray(0).toRequestBody(mediaType)
511
+ else -> null
512
+ }
513
+ val request = try {
514
+ val builder = Request.Builder().url(url).method(input.method, body)
515
+ input.headers.forEach { (name, value) -> builder.header(name, value) }
516
+ builder.header("X-Latchway-Feature", input.feature)
517
+ builder.build()
518
+ } catch (failure: IllegalArgumentException) {
519
+ throw requestFailure("The native request is invalid", failure)
520
+ }
521
+ val response = try {
522
+ applicationClient.newCall(request).awaitResponse()
523
+ } catch (failure: IOException) {
524
+ throw LatchwayException(
525
+ code = LatchwayErrorCode.NETWORK_UNAVAILABLE,
526
+ retryable = true,
527
+ safeMessage = "The Latchway data-plane endpoint could not be reached",
528
+ cause = failure,
529
+ )
530
+ }
531
+ try {
532
+ validateNativeTarget(baseURL, response.request.url, input.method, input.feature)
533
+ if (response.code !in 200..599) throw responseFailure("The native response status is invalid")
534
+ } catch (failure: Throwable) {
535
+ response.close()
536
+ throw failure
537
+ }
538
+ val responseID = "rsp_${UUID.randomUUID()}"
539
+ val handle = NativeResponse(response)
540
+ check(responses.putIfAbsent(responseID, handle) == null)
541
+ try {
542
+ responseMetadata(responseID, response)
543
+ } catch (failure: Throwable) {
544
+ responses.remove(responseID)?.close()
545
+ throw failure
546
+ }
547
+ }
548
+
549
+ suspend fun readResponseChunk(responseID: String, maximumBytes: Double): String {
550
+ if (!maximumBytes.isFinite() || maximumBytes % 1.0 != 0.0 || maximumBytes < 1.0 ||
551
+ maximumBytes > MAXIMUM_RESPONSE_CHUNK_BYTES.toDouble()
552
+ ) {
553
+ throw requestFailure("The response chunk limit is invalid")
554
+ }
555
+ val handle = responses[responseID] ?: throw requestFailure("The native response handle is unavailable")
556
+ val bytes = handle.read(maximumBytes.toInt())
557
+ if (bytes == null) {
558
+ responses.remove(responseID, handle)
559
+ handle.close()
560
+ return JSONObject().put("done", true).toString()
561
+ }
562
+ return JSONObject()
563
+ .put("done", false)
564
+ .put("chunk", Base64.encodeToString(bytes, Base64.NO_WRAP))
565
+ .toString()
566
+ }
567
+
568
+ fun closeResponse(responseID: String) {
569
+ responses.remove(responseID)?.close()
570
+ }
571
+
572
+ suspend fun <T> withIdentityToken(token: String, action: suspend (NativeClientOperations) -> T): T {
573
+ if (token.isEmpty() || token.toByteArray(Charsets.UTF_8).size > 65_536 || token.any(Char::isISOControl)) {
574
+ throw LatchwayException(
575
+ code = LatchwayErrorCode.REQUEST_INVALID,
576
+ safeMessage = "The identity token is invalid",
577
+ )
578
+ }
579
+ return operationMutex.withLock {
580
+ tokenProvider.set(token)
581
+ try { action(client) } finally { tokenProvider.clear() }
582
+ }
583
+ }
584
+
585
+ fun close() {
586
+ responses.values.forEach(NativeResponse::close)
587
+ responses.clear()
588
+ client.close()
589
+ }
590
+ }
591
+
592
+ private class NativeResponse(
593
+ private val response: Response,
594
+ ) {
595
+ private val input = response.body.byteStream()
596
+ private val readMutex = Mutex()
597
+ private val closed = AtomicBoolean(false)
598
+
599
+ suspend fun read(maximumBytes: Int): ByteArray? = readMutex.withLock {
600
+ if (closed.get()) return@withLock null
601
+ val cancellation = currentCoroutineContext()[Job]?.invokeOnCompletion { failure ->
602
+ if (failure is CancellationException) close()
603
+ }
604
+ try {
605
+ val buffer = ByteArray(maximumBytes)
606
+ var count: Int
607
+ do {
608
+ count = input.read(buffer)
609
+ } while (count == 0 && !closed.get())
610
+ if (count < 0) null else buffer.copyOf(count)
611
+ } catch (failure: IOException) {
612
+ throw LatchwayException(
613
+ code = LatchwayErrorCode.NETWORK_UNAVAILABLE,
614
+ retryable = true,
615
+ safeMessage = "The Latchway response stream failed",
616
+ cause = failure,
617
+ )
618
+ } finally {
619
+ cancellation?.dispose()
620
+ }
621
+ }
622
+
623
+ fun close() {
624
+ if (closed.compareAndSet(false, true)) response.close()
625
+ }
626
+ }
627
+
628
+ internal class TransientIdentityTokenProvider {
629
+ @Volatile private var value: String? = null
630
+ fun set(token: String) { value = token }
631
+ fun clear() { value = null }
632
+ fun current(): String = value ?: throw IllegalStateException("identity token is unavailable")
633
+ }
634
+
635
+ internal data class NativeConfiguration(
636
+ val baseURL: String,
637
+ val applicationID: String,
638
+ val environment: String,
639
+ val identityProvider: String,
640
+ val sdkVersion: String,
641
+ val frameworkID: String,
642
+ val frameworkVersion: String,
643
+ val contractVersion: String,
644
+ val protocolVersion: Int,
645
+ val allowInsecureLoopback: Boolean,
646
+ val playIntegrityCloudProjectNumber: Long?,
647
+ val keyPolicy: String,
648
+ ) {
649
+ companion object {
650
+ fun parse(encoded: String): NativeConfiguration {
651
+ require(encoded.toByteArray(Charsets.UTF_8).size <= 65_536) { "native configuration is too large" }
652
+ val value = JSONObject(encoded)
653
+ val android = value.getJSONObject("android")
654
+ val apple = value.getJSONObject("apple")
655
+ require(value.keys().asSequence().toSet() == NATIVE_CONFIGURATION_KEYS &&
656
+ android.keys().asSequence().toSet().let { keys ->
657
+ NATIVE_ANDROID_CONFIGURATION_REQUIRED_KEYS.all(keys::contains) &&
658
+ keys.all(NATIVE_ANDROID_CONFIGURATION_KEYS::contains)
659
+ } &&
660
+ apple.keys().asSequence().toSet().let { keys ->
661
+ NATIVE_APPLE_CONFIGURATION_REQUIRED_KEYS.all(keys::contains) &&
662
+ keys.all(NATIVE_APPLE_CONFIGURATION_KEYS::contains)
663
+ }
664
+ ) { "native configuration has unexpected fields" }
665
+ return NativeConfiguration(
666
+ baseURL = value.getString("baseURL"),
667
+ applicationID = value.getString("applicationID"),
668
+ environment = value.getString("environment"),
669
+ identityProvider = value.getString("identityProvider"),
670
+ sdkVersion = value.getString("sdkVersion"),
671
+ frameworkID = value.getString("frameworkID"),
672
+ frameworkVersion = value.getString("frameworkVersion"),
673
+ contractVersion = value.getString("contractVersion"),
674
+ protocolVersion = value.getInt("protocolVersion"),
675
+ allowInsecureLoopback = value.optBoolean("allowInsecureLoopback", false),
676
+ playIntegrityCloudProjectNumber = android.optString("playIntegrityCloudProjectNumber")
677
+ .takeIf(String::isNotEmpty)?.toLongOrNull(),
678
+ keyPolicy = android.getString("keyPolicy"),
679
+ )
680
+ }
681
+ }
682
+ }
683
+
684
+ private data class NativeRequestInput(
685
+ val url: String,
686
+ val method: String,
687
+ val feature: String,
688
+ val headers: List<Pair<String, String>>,
689
+ val body: ByteArray?,
690
+ ) {
691
+ companion object {
692
+ fun parse(encoded: String): NativeRequestInput = try {
693
+ require(encoded.toByteArray(Charsets.UTF_8).size <= MAXIMUM_NATIVE_REQUEST_BYTES) {
694
+ "native request is too large"
695
+ }
696
+ val value = JSONObject(encoded)
697
+ require(value.keys().asSequence().toSet() == setOf("url", "method", "feature", "headers", "bodyBase64")) {
698
+ "native request has unexpected fields"
699
+ }
700
+ val method = value.getString("method")
701
+ require(METHOD_PATTERN.matches(method) && method !in FORBIDDEN_METHODS) { "request method is invalid" }
702
+ val feature = value.getString("feature")
703
+ require(FEATURE_PATTERN.matches(feature)) { "request feature is invalid" }
704
+ val encodedHeaders = value.getJSONArray("headers")
705
+ require(encodedHeaders.length() <= MAXIMUM_HEADERS) { "request has too many headers" }
706
+ var headerBytes = 0
707
+ val headers = buildList {
708
+ for (index in 0 until encodedHeaders.length()) {
709
+ val pair = encodedHeaders.getJSONArray(index)
710
+ require(pair.length() == 2) { "request header is invalid" }
711
+ val name = pair.getString(0).lowercase()
712
+ val headerValue = pair.getString(1)
713
+ require(HEADER_NAME_PATTERN.matches(name) && validHeaderValue(headerValue) &&
714
+ !isForbiddenCredentialName(name)
715
+ ) { "request header is invalid" }
716
+ headerBytes += name.length + headerValue.length
717
+ require(headerBytes <= MAXIMUM_HEADER_BYTES) { "request headers are too large" }
718
+ add(name to headerValue)
719
+ }
720
+ }
721
+ val body = value.optNullableString("bodyBase64")?.let { encodedBody ->
722
+ val decoded = Base64.decode(encodedBody, Base64.NO_WRAP)
723
+ require(decoded.size <= MAXIMUM_REQUEST_BODY_BYTES &&
724
+ Base64.encodeToString(decoded, Base64.NO_WRAP) == encodedBody
725
+ ) { "request body is invalid" }
726
+ decoded
727
+ }
728
+ NativeRequestInput(
729
+ url = value.getString("url"),
730
+ method = method,
731
+ feature = feature,
732
+ headers = headers,
733
+ body = body,
734
+ )
735
+ } catch (failure: LatchwayException) {
736
+ throw failure
737
+ } catch (failure: Exception) {
738
+ throw requestFailure("The native request is invalid", failure)
739
+ }
740
+ }
741
+ }
742
+
743
+ private suspend fun Call.awaitResponse(): Response = suspendCancellableCoroutine { continuation ->
744
+ continuation.invokeOnCancellation { cancel() }
745
+ enqueue(object : Callback {
746
+ override fun onFailure(call: Call, e: IOException) {
747
+ if (continuation.isActive) continuation.resumeWithException(e)
748
+ }
749
+
750
+ override fun onResponse(call: Call, response: Response) {
751
+ if (continuation.isActive) {
752
+ continuation.resume(response)
753
+ } else {
754
+ response.close()
755
+ }
756
+ }
757
+ })
758
+ }
759
+
760
+ private fun responseMetadata(responseID: String, response: Response): String {
761
+ val headers = JSONArray()
762
+ var count = 0
763
+ var size = 0
764
+ for ((name, value) in response.headers) {
765
+ val normalized = name.lowercase()
766
+ if (!safeResponseHeader(normalized)) continue
767
+ if (!validHeaderValue(value)) throw responseFailure("The response header is invalid")
768
+ count += 1
769
+ size += normalized.length + value.length
770
+ if (count > MAXIMUM_HEADERS || size > MAXIMUM_HEADER_BYTES) {
771
+ throw responseFailure("The response headers are too large")
772
+ }
773
+ headers.put(JSONArray().put(normalized).put(value))
774
+ }
775
+ return JSONObject()
776
+ .put("responseID", responseID)
777
+ .put("status", response.code)
778
+ .put("statusText", "")
779
+ .put("headers", headers)
780
+ .toString()
781
+ }
782
+
783
+ internal fun validateNativeTarget(
784
+ baseURL: okhttp3.HttpUrl,
785
+ target: okhttp3.HttpUrl,
786
+ method: String,
787
+ feature: String,
788
+ ) {
789
+ if (target.scheme != baseURL.scheme || target.host != baseURL.host || target.port != baseURL.port ||
790
+ target.username.isNotEmpty() || target.password.isNotEmpty() || target.fragment != null
791
+ ) {
792
+ throw requestFailure("The request destination is not an allowed Latchway data-plane URL")
793
+ }
794
+ val normalizedMethod = method.uppercase(java.util.Locale.US)
795
+ val structured = normalizedMethod == "POST" && target.encodedPath in ALLOWED_DATA_PLANE_PATHS
796
+ val opaquePrefix = "/proxy/$feature/"
797
+ val remaining = target.encodedPath.removePrefix(opaquePrefix)
798
+ val lowerRemaining = remaining.lowercase(java.util.Locale.US)
799
+ val opaque = normalizedMethod in OPAQUE_DATA_PLANE_METHODS && target.query == null &&
800
+ target.encodedPath.startsWith(opaquePrefix) && remaining.length in 1..2_048 &&
801
+ remaining.split('/').all { it.isNotEmpty() && it != "." && it != ".." } &&
802
+ "%2e" !in lowerRemaining && "%2f" !in lowerRemaining && "%5c" !in lowerRemaining &&
803
+ '\\' !in remaining && !remaining.startsWith("http:", ignoreCase = true) &&
804
+ !remaining.startsWith("https:", ignoreCase = true)
805
+ if (!structured && !opaque) {
806
+ throw requestFailure("The request method and path are not allowed by the Latchway client contract")
807
+ }
808
+ if (target.queryParameterNames.any { isForbiddenCredentialName(decodedCredentialName(it)) }) {
809
+ throw requestFailure("Upstream provider credentials must not be supplied in the request URL")
810
+ }
811
+ }
812
+
813
+ private fun decodedCredentialName(value: String): String {
814
+ var decoded = value
815
+ repeat(4) {
816
+ val next = Uri.decode(decoded)
817
+ if (next == decoded) return decoded.lowercase()
818
+ decoded = next
819
+ }
820
+ if (PERCENT_ESCAPE_PATTERN.containsMatchIn(decoded)) return "credential-encoded-name"
821
+ return decoded.lowercase()
822
+ }
823
+
824
+ private fun isForbiddenCredentialName(value: String): Boolean {
825
+ val normalized = value.lowercase()
826
+ if (normalized in FORBIDDEN_REQUEST_HEADERS || normalized in FORBIDDEN_CREDENTIAL_QUERY_NAMES) return true
827
+ val compact = normalized.filter(Char::isLetterOrDigit)
828
+ if (compact in setOf("key", "token", "secret", "bearer", "cookie", "password", "passwd")) return true
829
+ return FORBIDDEN_CREDENTIAL_NAME_FRAGMENTS.any(compact::contains)
830
+ }
831
+
832
+ private fun safeResponseHeader(name: String): Boolean =
833
+ name in SAFE_RESPONSE_HEADERS || name.startsWith("x-ratelimit-") || name.startsWith("ratelimit-")
834
+
835
+ private fun validHeaderValue(value: String): Boolean =
836
+ value.length <= MAXIMUM_HEADER_VALUE_BYTES && value.none { character ->
837
+ character.code in 0x00..0x08 || character.code in 0x0a..0x1f || character.code == 0x7f
838
+ }
839
+
840
+ private fun requestFailure(message: String, cause: Throwable? = null): LatchwayException = LatchwayException(
841
+ code = LatchwayErrorCode.REQUEST_INVALID,
842
+ safeMessage = message,
843
+ cause = cause,
844
+ )
845
+
846
+ private fun responseFailure(message: String): LatchwayException = LatchwayException(
847
+ code = LatchwayErrorCode.RESPONSE_INVALID,
848
+ safeMessage = message,
849
+ )
850
+
851
+ private const val MAXIMUM_REQUEST_BODY_BYTES: Int = 8 * 1024 * 1024
852
+ private const val MAXIMUM_NATIVE_REQUEST_BYTES: Int = 12 * 1024 * 1024
853
+ private const val MAXIMUM_RESPONSE_CHUNK_BYTES: Int = 32 * 1024
854
+ private const val MAXIMUM_HEADERS: Int = 128
855
+ private const val MAXIMUM_HEADER_BYTES: Int = 128 * 1024
856
+ private const val MAXIMUM_HEADER_VALUE_BYTES: Int = 8 * 1024
857
+
858
+ private val NATIVE_CONFIGURATION_KEYS = setOf(
859
+ "baseURL", "applicationID", "environment", "identityProvider", "appVersion", "sdkVersion",
860
+ "frameworkID", "frameworkVersion", "contractVersion", "protocolVersion",
861
+ "allowInsecureLoopback", "apple", "android",
862
+ )
863
+ private val NATIVE_ANDROID_CONFIGURATION_REQUIRED_KEYS = setOf("keyPolicy")
864
+ private val NATIVE_ANDROID_CONFIGURATION_KEYS = NATIVE_ANDROID_CONFIGURATION_REQUIRED_KEYS +
865
+ setOf("playIntegrityCloudProjectNumber")
866
+ private val NATIVE_APPLE_CONFIGURATION_REQUIRED_KEYS = setOf("appAttestEnabled", "softwareKeyFallbackPolicy")
867
+ private val NATIVE_APPLE_CONFIGURATION_KEYS = NATIVE_APPLE_CONFIGURATION_REQUIRED_KEYS + setOf(
868
+ "storageNamespace", "rootKeychainAccessGroup", "legacySharedKeychainAccessGroups",
869
+ )
870
+
871
+ private val METHOD_PATTERN = Regex("^[A-Z][A-Z0-9!#$%&'*+.^_`|~-]{0,31}$")
872
+ private val FEATURE_PATTERN = Regex("^[a-z][a-z0-9_-]{0,62}$")
873
+ private val HEADER_NAME_PATTERN = Regex("^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$")
874
+ private val PERCENT_ESCAPE_PATTERN = Regex("%[0-9A-Fa-f]{2}")
875
+ private val METHODS_REQUIRING_BODY = setOf("POST", "PUT", "PATCH", "PROPPATCH", "REPORT")
876
+ private val FORBIDDEN_METHODS = setOf("CONNECT", "TRACE", "TRACK")
877
+ private val ALLOWED_DATA_PLANE_PATHS = setOf(
878
+ "/v1/responses",
879
+ "/v1/chat/completions",
880
+ "/v1/embeddings",
881
+ "/v1/messages",
882
+ )
883
+ private val OPAQUE_DATA_PLANE_METHODS = setOf("GET", "POST", "PUT", "PATCH", "DELETE")
884
+ private val FORBIDDEN_REQUEST_HEADERS = setOf(
885
+ "authorization", "proxy-authorization", "api-key", "api_key", "apikey", "x-api-key",
886
+ "openai-api-key", "openai_api_key", "x-openai-api-key", "anthropic-api-key", "anthropic_api_key",
887
+ "x-goog-api-key", "x-goog_api_key", "access_token", "auth_token", "x-auth-token", "cookie", "connection",
888
+ "content-length", "expect", "host", "key", "proxy-connection", "te", "trailer", "transfer-encoding",
889
+ "token", "upgrade", "x-amz-credential", "x-amz-security-token", "x-amz-signature", "x-goog-credential",
890
+ "x-goog-signature", "dpop", "dpop-nonce", "x-latchway-feature", "x-latchway-framework",
891
+ "x-latchway-framework-version", "x-latchway-protocol-version", "x-latchway-request-id", "x-latchway-sdk",
892
+ "x-latchway-sdk-version",
893
+ )
894
+ private val FORBIDDEN_CREDENTIAL_QUERY_NAMES = FORBIDDEN_REQUEST_HEADERS + setOf(
895
+ "refresh_token", "identity_token", "private_key", "client_data_hash", "request_hash", "integrity_token",
896
+ )
897
+ private val FORBIDDEN_CREDENTIAL_NAME_FRAGMENTS = setOf(
898
+ "authorization", "dpop", "apikey", "accesstoken", "authtoken", "refreshtoken", "identitytoken",
899
+ "integritytoken", "sessiontoken", "privatekey", "clientsecret", "credential", "attestationevidence",
900
+ "clientdatahash", "requesthash", "xamzsignature", "xgoogsignature",
901
+ )
902
+ private val SAFE_RESPONSE_HEADERS = setOf(
903
+ "accept-ranges", "age", "cache-control", "content-encoding", "content-language", "content-length",
904
+ "content-range", "content-type", "date", "etag", "expires", "last-modified", "request-id", "retry-after",
905
+ "server-timing", "vary", "x-request-id", "x-latchway-request-id", "x-latchway-server-version",
906
+ "x-latchway-operation-id",
907
+ )
908
+
909
+ private fun JSONObject.optNullableString(name: String): String? =
910
+ if (isNull(name)) null else getString(name)
911
+
912
+ private fun JSONObject.putNullable(name: String, value: Any?): JSONObject =
913
+ put(name, value ?: JSONObject.NULL)
914
+
915
+ private fun operationKey(clientID: String, operationID: String): String = "$clientID|$operationID"
916
+
917
+ private fun Promise.rejectSafe(failure: Throwable, userInfoFactory: () -> WritableMap) {
918
+ val code: String
919
+ val message: String
920
+ val requestID: String?
921
+ val operationID: String?
922
+ val status: Int?
923
+ val retryable: Boolean
924
+ when (failure) {
925
+ is CancellationException -> {
926
+ code = "cancelled"; message = "The Latchway native operation was cancelled."
927
+ requestID = null; operationID = null; status = null; retryable = false
928
+ }
929
+ is LatchwayException -> {
930
+ code = failure.code.wireValue
931
+ message = safeNativeErrorMessage(code)
932
+ requestID = failure.requestId
933
+ operationID = failure.operationId
934
+ status = failure.httpStatus
935
+ retryable = failure.retryable
936
+ }
937
+ is IllegalArgumentException -> {
938
+ code = "invalid_configuration"; message = "Latchway native configuration is invalid."
939
+ requestID = null; operationID = null; status = null; retryable = false
940
+ }
941
+ else -> {
942
+ code = "internal_error"; message = "The Latchway native operation failed."
943
+ requestID = null; operationID = null; status = null; retryable = false
944
+ }
945
+ }
946
+ val userInfo = userInfoFactory().apply {
947
+ putString("code", code)
948
+ putString("documentationURL", "https://docs.latchway.dev/errors/${code.replace('_', '-')}")
949
+ requestID?.let { putString("requestID", it) }
950
+ operationID?.let { putString("operationID", it) }
951
+ status?.let { putInt("status", it) }
952
+ putBoolean("retryable", retryable)
953
+ }
954
+ reject(code, message, userInfo)
955
+ }
956
+
957
+ private fun safeNativeErrorMessage(code: String): String = when (code) {
958
+ "request_invalid" -> "The native Latchway request is invalid."
959
+ "configuration_invalid" -> "Latchway native configuration is invalid."
960
+ "network_unavailable" -> "The Latchway native transport is unavailable."
961
+ "response_invalid" -> "Latchway returned an invalid native response."
962
+ "operation_indeterminate" -> "The Latchway operation outcome must be reconciled."
963
+ else -> "The Latchway gateway rejected the request."
964
+ }