@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.
- package/CHANGELOG.md +122 -0
- package/LatchwayReactNative.podspec +33 -0
- package/NOTICE +7 -0
- package/README.md +293 -3
- package/SECURITY.md +69 -0
- package/android/build.gradle.kts +62 -0
- package/android/consumer-rules.pro +2 -0
- package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/android/gradle/wrapper/gradle-wrapper.properties +9 -0
- package/android/gradle.properties +4 -0
- package/android/gradlew +251 -0
- package/android/gradlew.bat +94 -0
- package/android/settings.gradle.kts +33 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/dev/latchway/reactnative/LatchwayReactNativePackage.kt +25 -0
- package/android/src/main/java/dev/latchway/reactnative/NativeLatchwayModule.kt +964 -0
- package/android/src/test/java/dev/latchway/reactnative/NativeLatchwayModuleTest.kt +521 -0
- package/babel.cjs +22 -0
- package/babel.d.cts +7 -0
- package/contract.lock +7 -0
- package/docs/architecture.md +145 -0
- package/docs/conformance.md +68 -0
- package/docs/langchain.md +135 -0
- package/docs/native-installation.md +172 -0
- package/docs/physical-device-evidence.md +559 -0
- package/docs/releasing.md +360 -0
- package/docs/security.md +55 -0
- package/ios/LatchwayNativeBridge.swift +1551 -0
- package/ios/RCTNativeLatchway.h +9 -0
- package/ios/RCTNativeLatchway.mm +175 -0
- package/lib/client.d.ts +29 -0
- package/lib/client.d.ts.map +1 -0
- package/lib/client.js +939 -0
- package/lib/client.js.map +1 -0
- package/lib/component-client.d.ts +15 -0
- package/lib/component-client.d.ts.map +1 -0
- package/lib/component-client.js +141 -0
- package/lib/component-client.js.map +1 -0
- package/lib/config.d.ts +25 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +261 -0
- package/lib/config.js.map +1 -0
- package/lib/coordinator.d.ts +19 -0
- package/lib/coordinator.d.ts.map +1 -0
- package/lib/coordinator.js +167 -0
- package/lib/coordinator.js.map +1 -0
- package/lib/errors.d.ts +5 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +201 -0
- package/lib/errors.js.map +1 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +13 -0
- package/lib/index.js.map +1 -0
- package/lib/native/NativeLatchway.d.ts +25 -0
- package/lib/native/NativeLatchway.d.ts.map +1 -0
- package/lib/native/NativeLatchway.js +3 -0
- package/lib/native/NativeLatchway.js.map +1 -0
- package/lib/native/bridge.d.ts +5 -0
- package/lib/native/bridge.d.ts.map +1 -0
- package/lib/native/bridge.js +17 -0
- package/lib/native/bridge.js.map +1 -0
- package/lib/native-output.d.ts +3 -0
- package/lib/native-output.d.ts.map +1 -0
- package/lib/native-output.js +43 -0
- package/lib/native-output.js.map +1 -0
- package/lib/polyfills.d.ts +8 -0
- package/lib/polyfills.d.ts.map +1 -0
- package/lib/polyfills.js +56 -0
- package/lib/polyfills.js.map +1 -0
- package/lib/request-id.d.ts +2 -0
- package/lib/request-id.d.ts.map +1 -0
- package/lib/request-id.js +5 -0
- package/lib/request-id.js.map +1 -0
- package/lib/runtime-symbols.d.ts +2 -0
- package/lib/runtime-symbols.d.ts.map +1 -0
- package/lib/runtime-symbols.js +9 -0
- package/lib/runtime-symbols.js.map +1 -0
- package/lib/testing.d.ts +7 -0
- package/lib/testing.d.ts.map +1 -0
- package/lib/testing.js +9 -0
- package/lib/testing.js.map +1 -0
- package/lib/types.d.ts +200 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/version.d.ts +8 -0
- package/lib/version.d.ts.map +1 -0
- package/lib/version.js +8 -0
- package/lib/version.js.map +1 -0
- package/package.json +152 -6
- package/react-native.config.cjs +7 -0
- package/release-compatibility.json +64 -0
- package/src/client.ts +1022 -0
- package/src/component-client.ts +158 -0
- package/src/config.ts +368 -0
- package/src/coordinator.ts +195 -0
- package/src/errors.ts +225 -0
- package/src/index.ts +53 -0
- package/src/native/NativeLatchway.ts +75 -0
- package/src/native/bridge.ts +23 -0
- package/src/native-output.ts +43 -0
- package/src/polyfills.ts +50 -0
- package/src/request-id.ts +5 -0
- package/src/runtime-symbols.ts +9 -0
- package/src/testing.ts +11 -0
- package/src/types.ts +241 -0
- package/src/version.ts +7 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
package dev.latchway.reactnative
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.Promise
|
|
4
|
+
import com.facebook.react.bridge.BridgeReactContext
|
|
5
|
+
import com.facebook.react.bridge.JavaOnlyMap
|
|
6
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
7
|
+
import com.facebook.react.bridge.WritableMap
|
|
8
|
+
import dev.latchway.core.LATCHWAY_CONTRACT_VERSION
|
|
9
|
+
import dev.latchway.core.LATCHWAY_PROTOCOL_VERSION
|
|
10
|
+
import dev.latchway.core.LatchwayErrorCode
|
|
11
|
+
import dev.latchway.core.LatchwayException
|
|
12
|
+
import dev.latchway.okhttp.LATCHWAY_REACT_NATIVE_FRAMEWORK_ID
|
|
13
|
+
import dev.latchway.okhttp.LATCHWAY_REACT_NATIVE_FRAMEWORK_VERSION
|
|
14
|
+
import okhttp3.Authenticator
|
|
15
|
+
import okhttp3.MediaType.Companion.toMediaType
|
|
16
|
+
import okhttp3.OkHttpClient
|
|
17
|
+
import okhttp3.Protocol
|
|
18
|
+
import okhttp3.Request
|
|
19
|
+
import okhttp3.Response
|
|
20
|
+
import okhttp3.ResponseBody.Companion.toResponseBody
|
|
21
|
+
import okhttp3.mockwebserver.MockResponse
|
|
22
|
+
import okhttp3.mockwebserver.MockWebServer
|
|
23
|
+
import org.json.JSONArray
|
|
24
|
+
import org.json.JSONObject
|
|
25
|
+
import org.junit.After
|
|
26
|
+
import org.junit.Assert.assertEquals
|
|
27
|
+
import org.junit.Assert.assertFalse
|
|
28
|
+
import org.junit.Assert.assertNotEquals
|
|
29
|
+
import org.junit.Assert.assertNull
|
|
30
|
+
import org.junit.Assert.assertThrows
|
|
31
|
+
import org.junit.Assert.assertTrue
|
|
32
|
+
import org.junit.Test
|
|
33
|
+
import org.junit.runner.RunWith
|
|
34
|
+
import org.robolectric.RobolectricTestRunner
|
|
35
|
+
import org.robolectric.RuntimeEnvironment
|
|
36
|
+
import org.robolectric.annotation.Config
|
|
37
|
+
import okio.Buffer
|
|
38
|
+
import java.lang.reflect.Proxy
|
|
39
|
+
import java.nio.charset.StandardCharsets
|
|
40
|
+
import java.util.ArrayDeque
|
|
41
|
+
import java.util.Base64
|
|
42
|
+
import java.util.concurrent.CopyOnWriteArrayList
|
|
43
|
+
import java.util.concurrent.CountDownLatch
|
|
44
|
+
import java.util.concurrent.TimeUnit
|
|
45
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
46
|
+
|
|
47
|
+
@RunWith(RobolectricTestRunner::class)
|
|
48
|
+
@Config(manifest = Config.NONE, sdk = [35])
|
|
49
|
+
public class NativeLatchwayModuleTest {
|
|
50
|
+
private val fixtures = mutableListOf<ModuleFixture>()
|
|
51
|
+
|
|
52
|
+
@After
|
|
53
|
+
public fun closeFixtures() {
|
|
54
|
+
fixtures.reversed().forEach(ModuleFixture::close)
|
|
55
|
+
fixtures.clear()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@Test
|
|
59
|
+
public fun fwAuth101And102GeneratedSpecForwardsBootstrapAndDpopDispatchToNativeSdk() {
|
|
60
|
+
// FW-AUTH-101 / FW-AUTH-102: this is compiled bridge evidence. Pair it
|
|
61
|
+
// with the exact-AAR session/DPoP tests for the native cryptography.
|
|
62
|
+
val fake = FakeNativeClientOperations()
|
|
63
|
+
val fixture = configuredFixture(fake)
|
|
64
|
+
|
|
65
|
+
val response = fixture.startRequest(
|
|
66
|
+
identityToken = "external-identity-bootstrap",
|
|
67
|
+
requestJSON = nativeRequest(fixture.baseURL, "bootstrap through generated spec"),
|
|
68
|
+
)
|
|
69
|
+
val metadata = JSONObject(response.resolvedString())
|
|
70
|
+
|
|
71
|
+
assertEquals(200, metadata.getInt("status"))
|
|
72
|
+
assertEquals(1, fake.bootstrapCalls.get())
|
|
73
|
+
assertEquals(listOf("external-identity-bootstrap"), fake.networkIdentityTokens)
|
|
74
|
+
assertEquals(1, fake.protectedRequests.size)
|
|
75
|
+
val protected = fake.protectedRequests.single()
|
|
76
|
+
assertTrue(protected.header("Authorization")?.startsWith("DPoP ") == true)
|
|
77
|
+
assertEquals(2, protected.header("DPoP")?.count { it == '.' })
|
|
78
|
+
val protectedBody = Buffer()
|
|
79
|
+
requireNotNull(protected.body).writeTo(protectedBody)
|
|
80
|
+
assertEquals("bootstrap through generated spec", protectedBody.readUtf8())
|
|
81
|
+
assertTokenCleared(fake)
|
|
82
|
+
fixture.closeResponse(metadata.getString("responseID")).assertResolved()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@Test
|
|
86
|
+
public fun fwAuth103AndBeh104OneGeneratedSpecCallKeepsSafeRetryAndFreshProofNative() {
|
|
87
|
+
// FW-AUTH-103 / FW-BEH-104: the compiled TurboModule boundary owns one
|
|
88
|
+
// operation while OkHttp performs both attempts. Pair with the exact
|
|
89
|
+
// Android AAR proof test to establish real DPoP generation.
|
|
90
|
+
MockWebServer().use { server ->
|
|
91
|
+
server.enqueue(MockResponse()
|
|
92
|
+
.setResponseCode(401)
|
|
93
|
+
.setHeader("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\", dpop_nonce=\"nonce-2\"")
|
|
94
|
+
.setBody("{}"))
|
|
95
|
+
server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
|
|
96
|
+
server.start()
|
|
97
|
+
|
|
98
|
+
val proofSequence = AtomicInteger()
|
|
99
|
+
val nativeRefreshes = AtomicInteger()
|
|
100
|
+
val identities = CopyOnWriteArrayList<String>()
|
|
101
|
+
val fake = FakeNativeClientOperations { operations ->
|
|
102
|
+
val authorization = "DPoP native-access-token"
|
|
103
|
+
nativeApplicationClientBuilder()
|
|
104
|
+
.addInterceptor { chain ->
|
|
105
|
+
identities += operations.currentIdentity()
|
|
106
|
+
val request = chain.request().newBuilder()
|
|
107
|
+
.header("Authorization", authorization)
|
|
108
|
+
.header("DPoP", semanticProof(proofSequence.incrementAndGet()))
|
|
109
|
+
.build()
|
|
110
|
+
chain.proceed(request)
|
|
111
|
+
}
|
|
112
|
+
.authenticator(Authenticator { _, response ->
|
|
113
|
+
identities += operations.currentIdentity()
|
|
114
|
+
nativeRefreshes.incrementAndGet()
|
|
115
|
+
response.request.newBuilder()
|
|
116
|
+
.header("Authorization", authorization)
|
|
117
|
+
.header("DPoP", semanticProof(proofSequence.incrementAndGet()))
|
|
118
|
+
.build()
|
|
119
|
+
})
|
|
120
|
+
.build()
|
|
121
|
+
}
|
|
122
|
+
val fixture = configuredFixture(fake, server.url("/").toString().removeSuffix("/"))
|
|
123
|
+
|
|
124
|
+
val response = fixture.startRequest(
|
|
125
|
+
identityToken = "external-identity-for-native-retry",
|
|
126
|
+
requestJSON = nativeRequest(fixture.baseURL, "one replayable body"),
|
|
127
|
+
)
|
|
128
|
+
val metadata = JSONObject(response.resolvedString())
|
|
129
|
+
val first = requireNotNull(server.takeRequest(5, TimeUnit.SECONDS))
|
|
130
|
+
val second = requireNotNull(server.takeRequest(5, TimeUnit.SECONDS))
|
|
131
|
+
|
|
132
|
+
assertEquals(200, metadata.getInt("status"))
|
|
133
|
+
assertEquals(1, nativeRefreshes.get())
|
|
134
|
+
assertEquals(2, server.requestCount)
|
|
135
|
+
assertEquals(first.body.readUtf8(), second.body.readUtf8())
|
|
136
|
+
assertEquals(first.headers["Authorization"], second.headers["Authorization"])
|
|
137
|
+
assertNotEquals(first.headers["DPoP"], second.headers["DPoP"])
|
|
138
|
+
assertEquals(
|
|
139
|
+
listOf("external-identity-for-native-retry", "external-identity-for-native-retry"),
|
|
140
|
+
identities,
|
|
141
|
+
)
|
|
142
|
+
assertTokenCleared(fake)
|
|
143
|
+
fixture.closeResponse(metadata.getString("responseID")).assertResolved()
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
@Test
|
|
148
|
+
public fun fwAuth104GeneratedSpecClearsRejectedIdentityBeforeReauthentication() {
|
|
149
|
+
// FW-AUTH-104
|
|
150
|
+
val fake = FakeNativeClientOperations()
|
|
151
|
+
fake.refreshFailures += LatchwayException(
|
|
152
|
+
code = LatchwayErrorCode.IDENTITY_REAUTHENTICATION_REQUIRED,
|
|
153
|
+
safeMessage = "synthetic native reauthentication request",
|
|
154
|
+
)
|
|
155
|
+
val fixture = configuredFixture(fake)
|
|
156
|
+
|
|
157
|
+
val rejected = fixture.refresh("external-identity-stale")
|
|
158
|
+
assertEquals("identity_reauthentication_required", rejected.rejection().code)
|
|
159
|
+
assertTokenCleared(fake)
|
|
160
|
+
|
|
161
|
+
fixture.refresh("external-identity-fresh").assertResolved()
|
|
162
|
+
assertEquals(
|
|
163
|
+
listOf("external-identity-stale", "external-identity-fresh"),
|
|
164
|
+
fake.refreshIdentityTokens,
|
|
165
|
+
)
|
|
166
|
+
assertTokenCleared(fake)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
@Test
|
|
170
|
+
public fun fwAuth105GeneratedSpecDelegatesFamilyRetirementAndSurfacesTerminalState() {
|
|
171
|
+
// FW-AUTH-105: terminal-state enforcement is implemented by the exact
|
|
172
|
+
// native SDK; this compiled test proves the generated bridge forwards it.
|
|
173
|
+
val fake = FakeNativeClientOperations()
|
|
174
|
+
val fixture = configuredFixture(fake)
|
|
175
|
+
|
|
176
|
+
fixture.revokeFamily("external-identity-family-owner").assertResolved()
|
|
177
|
+
assertEquals(1, fake.familyRevocations.get())
|
|
178
|
+
assertTokenCleared(fake)
|
|
179
|
+
|
|
180
|
+
val rejected = fixture.quota("external-identity-after-family-retirement")
|
|
181
|
+
assertEquals("installation_family_revoked", rejected.rejection().code)
|
|
182
|
+
assertTrue(fake.protectedRequests.isEmpty())
|
|
183
|
+
assertTokenCleared(fake)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
@Test
|
|
187
|
+
public fun fwAuth106GeneratedSpecSurfacesNativeComponentRevocationWithoutDispatch() {
|
|
188
|
+
// FW-AUTH-106: the Android SDK owns component state. The React Native
|
|
189
|
+
// TurboModule must preserve its closed terminal failure unchanged.
|
|
190
|
+
val fake = FakeNativeClientOperations().apply { componentRevoked = true }
|
|
191
|
+
val fixture = configuredFixture(fake)
|
|
192
|
+
|
|
193
|
+
val rejected = fixture.quota("external-identity-component-owner")
|
|
194
|
+
|
|
195
|
+
assertEquals("component_revoked", rejected.rejection().code)
|
|
196
|
+
assertTrue(fake.protectedRequests.isEmpty())
|
|
197
|
+
assertTokenCleared(fake)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
@Test
|
|
201
|
+
public fun fwSec103ProductionResponseTargetRevalidationRejectsCrossOriginRedirectResult() {
|
|
202
|
+
// FW-SEC-103
|
|
203
|
+
val fake = FakeNativeClientOperations { operations ->
|
|
204
|
+
nativeApplicationClientBuilder()
|
|
205
|
+
.addInterceptor { chain ->
|
|
206
|
+
operations.redirectResponseAttempts.incrementAndGet()
|
|
207
|
+
val redirected = chain.request().newBuilder()
|
|
208
|
+
.url("https://redirect-attacker.invalid/v1/responses")
|
|
209
|
+
.build()
|
|
210
|
+
Response.Builder()
|
|
211
|
+
.request(redirected)
|
|
212
|
+
.protocol(Protocol.HTTP_1_1)
|
|
213
|
+
.code(200)
|
|
214
|
+
.message("OK")
|
|
215
|
+
.body("{}".toResponseBody(JSON_MEDIA_TYPE))
|
|
216
|
+
.build()
|
|
217
|
+
}
|
|
218
|
+
.build()
|
|
219
|
+
}
|
|
220
|
+
val fixture = configuredFixture(fake)
|
|
221
|
+
val policyClient = nativeApplicationClientBuilder().build()
|
|
222
|
+
try {
|
|
223
|
+
assertFalse(policyClient.followRedirects)
|
|
224
|
+
assertFalse(policyClient.followSslRedirects)
|
|
225
|
+
} finally {
|
|
226
|
+
policyClient.closeCompletely()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
val rejected = fixture.startRequest(
|
|
230
|
+
identityToken = "external-identity-redirect-check",
|
|
231
|
+
requestJSON = nativeRequest(fixture.baseURL, "redirect must be revalidated"),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
assertEquals("request_invalid", rejected.rejection().code)
|
|
235
|
+
assertEquals(1, fake.redirectResponseAttempts.get())
|
|
236
|
+
assertTokenCleared(fake)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private fun configuredFixture(
|
|
240
|
+
fake: FakeNativeClientOperations,
|
|
241
|
+
baseURL: String = "https://gateway.example.test",
|
|
242
|
+
): ModuleFixture {
|
|
243
|
+
val reactContext: ReactApplicationContext = BridgeReactContext(RuntimeEnvironment.getApplication())
|
|
244
|
+
val factory = NativeClientFactory { configuration, _, _, tokenProvider, _ ->
|
|
245
|
+
fake.configuration = configuration
|
|
246
|
+
fake.tokenProvider = tokenProvider
|
|
247
|
+
fake
|
|
248
|
+
}
|
|
249
|
+
val module = NativeLatchwayModule(reactContext, factory) { JavaOnlyMap() }
|
|
250
|
+
val fixture = ModuleFixture(module, fake, baseURL)
|
|
251
|
+
fixtures += fixture
|
|
252
|
+
|
|
253
|
+
val configured = fixture.configure()
|
|
254
|
+
val compatibility = JSONObject(configured.resolvedString())
|
|
255
|
+
assertEquals("react_native_android", compatibility.getString("platform"))
|
|
256
|
+
assertEquals(LATCHWAY_CONTRACT_VERSION, compatibility.getString("contractVersion"))
|
|
257
|
+
assertEquals(LATCHWAY_PROTOCOL_VERSION, compatibility.getInt("protocolVersion"))
|
|
258
|
+
assertEquals(LATCHWAY_REACT_NATIVE_FRAMEWORK_ID, fake.configuration?.frameworkID)
|
|
259
|
+
assertEquals(LATCHWAY_REACT_NATIVE_FRAMEWORK_VERSION, fake.configuration?.frameworkVersion)
|
|
260
|
+
return fixture
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private fun assertTokenCleared(fake: FakeNativeClientOperations) {
|
|
264
|
+
assertThrows(IllegalStateException::class.java) { fake.currentIdentity() }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private class ModuleFixture(
|
|
269
|
+
private val module: NativeLatchwayModule,
|
|
270
|
+
private val operations: FakeNativeClientOperations,
|
|
271
|
+
val baseURL: String,
|
|
272
|
+
) {
|
|
273
|
+
private val operationSequence = AtomicInteger()
|
|
274
|
+
private var closed = false
|
|
275
|
+
|
|
276
|
+
fun configure(): RecordingPromise = RecordingPromise().also { promise ->
|
|
277
|
+
module.configure(CLIENT_ID, nativeConfiguration(baseURL), promise.value)
|
|
278
|
+
promise.await()
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
fun startRequest(identityToken: String, requestJSON: String): RecordingPromise =
|
|
282
|
+
RecordingPromise().also { promise ->
|
|
283
|
+
module.startRequest(
|
|
284
|
+
CLIENT_ID,
|
|
285
|
+
operationID("request"),
|
|
286
|
+
identityToken,
|
|
287
|
+
requestJSON,
|
|
288
|
+
promise.value,
|
|
289
|
+
)
|
|
290
|
+
promise.await()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
fun refresh(identityToken: String): RecordingPromise = RecordingPromise().also { promise ->
|
|
294
|
+
module.refresh(CLIENT_ID, operationID("refresh"), identityToken, promise.value)
|
|
295
|
+
promise.await()
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
fun revokeFamily(identityToken: String): RecordingPromise = RecordingPromise().also { promise ->
|
|
299
|
+
module.revokeFamily(CLIENT_ID, operationID("family"), identityToken, promise.value)
|
|
300
|
+
promise.await()
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
fun quota(identityToken: String): RecordingPromise = RecordingPromise().also { promise ->
|
|
304
|
+
module.quota(CLIENT_ID, operationID("quota"), identityToken, "assistant", promise.value)
|
|
305
|
+
promise.await()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
fun closeResponse(responseID: String): RecordingPromise = RecordingPromise().also { promise ->
|
|
309
|
+
module.closeResponse(CLIENT_ID, responseID, promise.value)
|
|
310
|
+
promise.await()
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
fun close() {
|
|
314
|
+
if (closed) return
|
|
315
|
+
closed = true
|
|
316
|
+
val disposed = RecordingPromise()
|
|
317
|
+
module.dispose(CLIENT_ID, disposed.value)
|
|
318
|
+
disposed.await()
|
|
319
|
+
module.invalidate()
|
|
320
|
+
if (!operations.closed) operations.close()
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private fun operationID(kind: String): String =
|
|
324
|
+
"rn-android-$kind-${operationSequence.incrementAndGet()}"
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private class FakeNativeClientOperations(
|
|
328
|
+
createApplicationClient: (FakeNativeClientOperations) -> OkHttpClient = { operations ->
|
|
329
|
+
nativeApplicationClientBuilder()
|
|
330
|
+
.addInterceptor { chain -> operations.defaultDispatch(chain.request()) }
|
|
331
|
+
.build()
|
|
332
|
+
},
|
|
333
|
+
) : NativeClientOperations {
|
|
334
|
+
lateinit var tokenProvider: TransientIdentityTokenProvider
|
|
335
|
+
var configuration: NativeConfiguration? = null
|
|
336
|
+
var familyRevoked: Boolean = false
|
|
337
|
+
var componentRevoked: Boolean = false
|
|
338
|
+
var closed: Boolean = false
|
|
339
|
+
val bootstrapCalls = AtomicInteger()
|
|
340
|
+
val familyRevocations = AtomicInteger()
|
|
341
|
+
val redirectResponseAttempts = AtomicInteger()
|
|
342
|
+
val networkIdentityTokens = CopyOnWriteArrayList<String>()
|
|
343
|
+
val refreshIdentityTokens = CopyOnWriteArrayList<String>()
|
|
344
|
+
val protectedRequests = CopyOnWriteArrayList<Request>()
|
|
345
|
+
val refreshFailures = ArrayDeque<Throwable>()
|
|
346
|
+
|
|
347
|
+
override val applicationClient: OkHttpClient by lazy { createApplicationClient(this) }
|
|
348
|
+
|
|
349
|
+
override suspend fun refresh() {
|
|
350
|
+
refreshIdentityTokens += currentIdentity()
|
|
351
|
+
refreshFailures.pollFirst()?.let { throw it }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
override suspend fun quota(feature: String): String {
|
|
355
|
+
if (familyRevoked) {
|
|
356
|
+
throw LatchwayException(
|
|
357
|
+
code = LatchwayErrorCode.INSTALLATION_FAMILY_REVOKED,
|
|
358
|
+
safeMessage = "synthetic native family terminal state",
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
if (componentRevoked) {
|
|
362
|
+
throw LatchwayException(
|
|
363
|
+
code = LatchwayErrorCode.COMPONENT_REVOKED,
|
|
364
|
+
safeMessage = "synthetic native component terminal state",
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
return JSONObject()
|
|
368
|
+
.put("feature", feature)
|
|
369
|
+
.put("observed_at", "2026-09-02T00:00:00Z")
|
|
370
|
+
.put("limits", JSONArray())
|
|
371
|
+
.toString()
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
override suspend fun diagnostics(): String = JSONObject()
|
|
375
|
+
.put("contractVersion", LATCHWAY_CONTRACT_VERSION)
|
|
376
|
+
.put("protocolVersion", LATCHWAY_PROTOCOL_VERSION)
|
|
377
|
+
.toString()
|
|
378
|
+
|
|
379
|
+
override suspend fun revokeCurrentInstallation() = Unit
|
|
380
|
+
|
|
381
|
+
override suspend fun revokeCurrentInstallationFamily() {
|
|
382
|
+
currentIdentity()
|
|
383
|
+
familyRevocations.incrementAndGet()
|
|
384
|
+
familyRevoked = true
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
fun currentIdentity(): String = tokenProvider.current()
|
|
388
|
+
|
|
389
|
+
override fun close() {
|
|
390
|
+
if (closed) return
|
|
391
|
+
closed = true
|
|
392
|
+
applicationClient.closeCompletely()
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private fun defaultDispatch(request: Request): Response {
|
|
396
|
+
if (familyRevoked) {
|
|
397
|
+
throw LatchwayException(
|
|
398
|
+
code = LatchwayErrorCode.INSTALLATION_FAMILY_REVOKED,
|
|
399
|
+
safeMessage = "synthetic native family terminal state",
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
if (componentRevoked) {
|
|
403
|
+
throw LatchwayException(
|
|
404
|
+
code = LatchwayErrorCode.COMPONENT_REVOKED,
|
|
405
|
+
safeMessage = "synthetic native component terminal state",
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
networkIdentityTokens += currentIdentity()
|
|
409
|
+
bootstrapCalls.compareAndSet(0, 1)
|
|
410
|
+
val protected = request.newBuilder()
|
|
411
|
+
.header("Authorization", "DPoP native-access-token")
|
|
412
|
+
.header("DPoP", semanticProof(protectedRequests.size + 1))
|
|
413
|
+
.build()
|
|
414
|
+
protectedRequests += protected
|
|
415
|
+
return Response.Builder()
|
|
416
|
+
.request(protected)
|
|
417
|
+
.protocol(Protocol.HTTP_1_1)
|
|
418
|
+
.code(200)
|
|
419
|
+
.message("OK")
|
|
420
|
+
.body("{}".toResponseBody(JSON_MEDIA_TYPE))
|
|
421
|
+
.build()
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private class RecordingPromise {
|
|
426
|
+
private val terminal = CountDownLatch(1)
|
|
427
|
+
@Volatile private var resolved: Any? = UNSET
|
|
428
|
+
@Volatile private var rejected: NativeRejection? = null
|
|
429
|
+
|
|
430
|
+
val value: Promise = Proxy.newProxyInstance(
|
|
431
|
+
Promise::class.java.classLoader,
|
|
432
|
+
arrayOf(Promise::class.java),
|
|
433
|
+
) { _, method, arguments ->
|
|
434
|
+
when (method.name) {
|
|
435
|
+
"resolve" -> {
|
|
436
|
+
resolved = arguments?.firstOrNull()
|
|
437
|
+
terminal.countDown()
|
|
438
|
+
}
|
|
439
|
+
"reject" -> {
|
|
440
|
+
val values = arguments.orEmpty()
|
|
441
|
+
rejected = NativeRejection(
|
|
442
|
+
code = values.firstOrNull() as? String ?: "unspecified",
|
|
443
|
+
message = values.drop(1).filterIsInstance<String>().firstOrNull(),
|
|
444
|
+
userInfo = values.filterIsInstance<WritableMap>().firstOrNull(),
|
|
445
|
+
)
|
|
446
|
+
terminal.countDown()
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
null
|
|
450
|
+
} as Promise
|
|
451
|
+
|
|
452
|
+
fun await(): RecordingPromise {
|
|
453
|
+
assertTrue("native promise did not settle", terminal.await(10, TimeUnit.SECONDS))
|
|
454
|
+
return this
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
fun assertResolved() {
|
|
458
|
+
assertNull(rejected)
|
|
459
|
+
assertTrue(resolved !== UNSET)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
fun resolvedString(): String {
|
|
463
|
+
assertResolved()
|
|
464
|
+
return resolved as String
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
fun rejection(): NativeRejection {
|
|
468
|
+
assertTrue(resolved === UNSET)
|
|
469
|
+
return requireNotNull(rejected)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private data class NativeRejection(
|
|
474
|
+
val code: String,
|
|
475
|
+
val message: String?,
|
|
476
|
+
val userInfo: WritableMap?,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
private fun nativeConfiguration(baseURL: String): String = JSONObject()
|
|
480
|
+
.put("baseURL", baseURL)
|
|
481
|
+
.put("applicationID", "app_react_native_android_test")
|
|
482
|
+
.put("environment", "development")
|
|
483
|
+
.put("identityProvider", "mock_oidc")
|
|
484
|
+
.put("appVersion", "1.0.0-test")
|
|
485
|
+
.put("sdkVersion", "1.0.0")
|
|
486
|
+
.put("frameworkID", LATCHWAY_REACT_NATIVE_FRAMEWORK_ID)
|
|
487
|
+
.put("frameworkVersion", LATCHWAY_REACT_NATIVE_FRAMEWORK_VERSION)
|
|
488
|
+
.put("contractVersion", LATCHWAY_CONTRACT_VERSION)
|
|
489
|
+
.put("protocolVersion", LATCHWAY_PROTOCOL_VERSION)
|
|
490
|
+
.put("allowInsecureLoopback", baseURL.startsWith("http://127.0.0.1:"))
|
|
491
|
+
.put("apple", JSONObject()
|
|
492
|
+
.put("appAttestEnabled", true)
|
|
493
|
+
.put("softwareKeyFallbackPolicy", "forbidden"))
|
|
494
|
+
.put("android", JSONObject()
|
|
495
|
+
.put("playIntegrityCloudProjectNumber", "123456789")
|
|
496
|
+
.put("keyPolicy", "software_allowed"))
|
|
497
|
+
.toString()
|
|
498
|
+
|
|
499
|
+
private fun nativeRequest(baseURL: String, body: String): String = JSONObject()
|
|
500
|
+
.put("url", "$baseURL/v1/responses")
|
|
501
|
+
.put("method", "POST")
|
|
502
|
+
.put("feature", "assistant")
|
|
503
|
+
.put("headers", JSONArray().put(JSONArray().put("content-type").put("application/json")))
|
|
504
|
+
.put(
|
|
505
|
+
"bodyBase64",
|
|
506
|
+
Base64.getEncoder().encodeToString(body.toByteArray(StandardCharsets.UTF_8)),
|
|
507
|
+
)
|
|
508
|
+
.toString()
|
|
509
|
+
|
|
510
|
+
private fun semanticProof(sequence: Int): String =
|
|
511
|
+
"eyJhbGciOiJFUzI1NiIsInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiJqdGktJHNlcXVlbmNlIn0.signature-$sequence"
|
|
512
|
+
|
|
513
|
+
private fun OkHttpClient.closeCompletely() {
|
|
514
|
+
dispatcher.cancelAll()
|
|
515
|
+
connectionPool.evictAll()
|
|
516
|
+
dispatcher.executorService.shutdown()
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private val JSON_MEDIA_TYPE = "application/json".toMediaType()
|
|
520
|
+
private const val CLIENT_ID = "rn-android-compiled-test-client"
|
|
521
|
+
private val UNSET = Any()
|
package/babel.cjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Opt-in compiler compatibility for LangChain on the supported RN baseline. */
|
|
2
|
+
function withLatchwayBabel(config = {}) {
|
|
3
|
+
if (config.assumptions?.noClassCalls === false) {
|
|
4
|
+
throw new Error("LangChain on React Native requires assumptions.noClassCalls=true. Remove the conflicting assumption.");
|
|
5
|
+
}
|
|
6
|
+
const namespacePlugin = require.resolve("@babel/plugin-transform-export-namespace-from");
|
|
7
|
+
const plugins = config.plugins ?? [];
|
|
8
|
+
const hasNamespacePlugin = plugins.some((entry) => {
|
|
9
|
+
const plugin = Array.isArray(entry) ? entry[0] : entry;
|
|
10
|
+
return plugin === namespacePlugin || plugin === "@babel/plugin-transform-export-namespace-from";
|
|
11
|
+
});
|
|
12
|
+
return {
|
|
13
|
+
...config,
|
|
14
|
+
// LangChain's Symbol.hasInstance reads fields initialized by super(). The
|
|
15
|
+
// Babel pre-constructor instanceof assertion runs too early. Classes must
|
|
16
|
+
// still be called with new; LangChain's own type checks remain unchanged.
|
|
17
|
+
assumptions: { ...config.assumptions, noClassCalls: true },
|
|
18
|
+
plugins: hasNamespacePlugin ? [...plugins] : [namespacePlugin, ...plugins],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = { withLatchwayBabel };
|
package/babel.d.cts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
interface BabelConfiguration {
|
|
2
|
+
assumptions?: Record<string, boolean>;
|
|
3
|
+
plugins?: unknown[];
|
|
4
|
+
[key: string]: unknown;
|
|
5
|
+
}
|
|
6
|
+
/** Preserves existing configuration and adds explicit LangChain compatibility. */
|
|
7
|
+
export function withLatchwayBabel(config?: BabelConfiguration): BabelConfiguration;
|
package/contract.lock
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
contract_version: 1.0.0
|
|
2
|
+
wire_protocol: 2
|
|
3
|
+
core_release: v1.0.0
|
|
4
|
+
core_commit: d260e3d7485e9e1487b5e03922b79c7089d94ce2
|
|
5
|
+
bundle_sha256: "4866aec1ff70e78d70f07847448161c2b59970fe102d95393b051444536d29a4"
|
|
6
|
+
minimum_server_version: 1.0.0
|
|
7
|
+
maximum_tested_server_version: 1.0.x
|