@latchway/react-native 0.0.0-bootstrap.0 → 1.0.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 (95) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/LatchwayReactNative.podspec +33 -0
  3. package/NOTICE +7 -0
  4. package/README.md +286 -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/contract.lock +7 -0
  19. package/docs/architecture.md +145 -0
  20. package/docs/conformance.md +68 -0
  21. package/docs/native-installation.md +172 -0
  22. package/docs/physical-device-evidence.md +559 -0
  23. package/docs/releasing.md +360 -0
  24. package/docs/security.md +55 -0
  25. package/ios/LatchwayNativeBridge.swift +1551 -0
  26. package/ios/RCTNativeLatchway.h +9 -0
  27. package/ios/RCTNativeLatchway.mm +175 -0
  28. package/lib/client.d.ts +29 -0
  29. package/lib/client.d.ts.map +1 -0
  30. package/lib/client.js +939 -0
  31. package/lib/client.js.map +1 -0
  32. package/lib/component-client.d.ts +15 -0
  33. package/lib/component-client.d.ts.map +1 -0
  34. package/lib/component-client.js +141 -0
  35. package/lib/component-client.js.map +1 -0
  36. package/lib/config.d.ts +25 -0
  37. package/lib/config.d.ts.map +1 -0
  38. package/lib/config.js +261 -0
  39. package/lib/config.js.map +1 -0
  40. package/lib/coordinator.d.ts +19 -0
  41. package/lib/coordinator.d.ts.map +1 -0
  42. package/lib/coordinator.js +167 -0
  43. package/lib/coordinator.js.map +1 -0
  44. package/lib/errors.d.ts +5 -0
  45. package/lib/errors.d.ts.map +1 -0
  46. package/lib/errors.js +201 -0
  47. package/lib/errors.js.map +1 -0
  48. package/lib/index.d.ts +8 -0
  49. package/lib/index.d.ts.map +1 -0
  50. package/lib/index.js +13 -0
  51. package/lib/index.js.map +1 -0
  52. package/lib/native/NativeLatchway.d.ts +25 -0
  53. package/lib/native/NativeLatchway.d.ts.map +1 -0
  54. package/lib/native/NativeLatchway.js +3 -0
  55. package/lib/native/NativeLatchway.js.map +1 -0
  56. package/lib/native/bridge.d.ts +5 -0
  57. package/lib/native/bridge.d.ts.map +1 -0
  58. package/lib/native/bridge.js +17 -0
  59. package/lib/native/bridge.js.map +1 -0
  60. package/lib/native-output.d.ts +3 -0
  61. package/lib/native-output.d.ts.map +1 -0
  62. package/lib/native-output.js +43 -0
  63. package/lib/native-output.js.map +1 -0
  64. package/lib/request-id.d.ts +2 -0
  65. package/lib/request-id.d.ts.map +1 -0
  66. package/lib/request-id.js +5 -0
  67. package/lib/request-id.js.map +1 -0
  68. package/lib/testing.d.ts +7 -0
  69. package/lib/testing.d.ts.map +1 -0
  70. package/lib/testing.js +9 -0
  71. package/lib/testing.js.map +1 -0
  72. package/lib/types.d.ts +200 -0
  73. package/lib/types.d.ts.map +1 -0
  74. package/lib/types.js +2 -0
  75. package/lib/types.js.map +1 -0
  76. package/lib/version.d.ts +8 -0
  77. package/lib/version.d.ts.map +1 -0
  78. package/lib/version.js +8 -0
  79. package/lib/version.js.map +1 -0
  80. package/package.json +130 -6
  81. package/react-native.config.cjs +7 -0
  82. package/release-compatibility.json +64 -0
  83. package/src/client.ts +1022 -0
  84. package/src/component-client.ts +158 -0
  85. package/src/config.ts +368 -0
  86. package/src/coordinator.ts +195 -0
  87. package/src/errors.ts +225 -0
  88. package/src/index.ts +53 -0
  89. package/src/native/NativeLatchway.ts +75 -0
  90. package/src/native/bridge.ts +23 -0
  91. package/src/native-output.ts +43 -0
  92. package/src/request-id.ts +5 -0
  93. package/src/testing.ts +11 -0
  94. package/src/types.ts +241 -0
  95. 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/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
@@ -0,0 +1,145 @@
1
+ # React Native SDK architecture
2
+
3
+ ## Dependency and trust boundary
4
+
5
+ ```text
6
+ React Native application
7
+ └─ @latchway/react-native
8
+ ├─ @latchway/client 1.0.0 (errors and shared transport concepts)
9
+ ├─ Latchway/AppAttest 1.0.0 (iOS)
10
+ ├─ Latchway/AppExtensions 1.0.0 (optional Debug extension target)
11
+ └─ dev.latchway:latchway-okhttp + latchway-play-integrity 1.0.0 (Android)
12
+ ```
13
+
14
+ The core repository owns OpenAPI, error codes, attestation binding, DPoP behavior, and compatibility. This package owns the handwritten React Native API, fetch integration, TurboModule schema, cross-instance lease, abort propagation, stable error projection, and redacted diagnostics. Native SDKs exclusively own installation keys, secure session persistence, platform attestation, DPoP signing, and native single-flight.
15
+
16
+ The gateway, not the SDK, derives user, organization, plan, trust, routing,
17
+ pricing, and quota facts. The SDK never receives an upstream provider
18
+ credential or treats application-supplied values as trusted server facts.
19
+
20
+ ## Contract ownership
21
+
22
+ The Latchway core repository exclusively owns the client OpenAPI, error-code
23
+ registry, protocol compatibility manifest, canonical attestation binding, DPoP
24
+ vectors, canonical request examples, and checksummed contract bundle. A
25
+ contract update must verify checksums, update `contract.lock`, regenerate only
26
+ internal types, rerun shared vectors, and pass conformance against the exact
27
+ core revision. Generated bridge and wire types do not become public API.
28
+
29
+ ## Operation flow
30
+
31
+ 1. JavaScript validates the origin, feature, configuration, request state, and decoded query names; provider-credential names fail before identity acquisition or dispatch.
32
+ 2. The application identity callback returns an external identity JWT.
33
+ 3. The TurboModule passes that JWT transiently to the native SDK while native session work runs.
34
+ 4. Native repeats the exact origin and allowed-path checks, establishes or refreshes a device-bound session, signs a DPoP proof, attaches native-owned protocol headers, and dispatches through its private URLSession or OkHttp client.
35
+ 5. Native refuses redirects, retains the credential-bearing request and response task, and returns only an opaque response identifier, status, and allowlisted safe headers.
36
+ 6. A WHATWG `ReadableStream` pulls bounded base64 response chunks through the TurboModule. Pull demand supplies bridge backpressure; abort, reader cancellation, EOF, client disposal, and invalid metadata all finish or cancel the native handle. JavaScript never clones or replays an authenticated request. Android's locked authenticator and iOS's locked feature transport exclusively own the contract-safe, one-time pre-dispatch retry; iOS bounds rejection classification to 64 KiB before any response bytes become visible.
37
+
38
+ Authorization, DPoP, access tokens, refresh tokens, private keys, and attestation evidence never appear in a native return value. Response bodies are application data, not credential envelopes, and remain incrementally delivered rather than eagerly buffered.
39
+
40
+ The containing application's root client owns the public descriptor lifecycle:
41
+ prepare, replace, descriptor diagnostics, descriptor revoke, and whole-family
42
+ retirement. Descriptors are normalized and snapshotted before any identity
43
+ await; native results must match the same snapshot. Identity is acquired only
44
+ transiently for prepare, replace, revoke, and family retirement. Root-side
45
+ component diagnostics are identity-free. A 65,536-byte JavaScript serialization
46
+ limit keeps oversized descriptor sets from reaching the native bridge.
47
+
48
+ The component path uses a separate extension-process client, not the containing
49
+ app's root client or lease. JavaScript running inside a signed `.appex` can
50
+ supply one validated public descriptor; native configuration rejects a
51
+ containing-app process, selects `.reactNativeIOS`, and retains a
52
+ `LatchwayExtensionClient` with no direct App Attest provider. iOS extensions
53
+ cannot call `DCAppAttestService.generateKey`, and the containing application
54
+ must not attest on their behalf. The extension can use only independently
55
+ keyed, component-scoped delegated sessions. React Native v1 exposes no
56
+ JavaScript component request operation. A second bridge operation returns only
57
+ `LatchwayComponentDiagnostics`; direct-attestation trust-source decoders are
58
+ retained for protocol compatibility but are not reachable proof claims. The
59
+ identity callback is not invoked, and component credentials and DPoP material
60
+ never cross the TurboModule. Both platforms report `attestation_unsupported`
61
+ for the legacy direct operation.
62
+
63
+ The example App Intents extension has no React Native runtime or TurboModule
64
+ bridge. Its Debug target optionally links `Latchway/AppExtensions` and calls the
65
+ native extension client directly to prove an independently keyed delegated
66
+ session and one fully consumed bounded Responses request. The root publishes a
67
+ nonsecret exact-run shared-Keychain challenge immediately before waiting. The
68
+ intent captures it before client construction, rechecks it immediately before
69
+ echoing the run in a bounded receipt, and cannot publish for a superseded run.
70
+ The containing app accepts only its native-captured exact run and deletes both
71
+ challenge and receipt before descriptor-bound family retirement and sign-out;
72
+ abort also deletes both artifacts. This is local integration proof. In Release the
73
+ AppExtensions pod is absent, no executable Latchway client path is compiled,
74
+ and the intent fails closed. Both variants retain distinct root/extension
75
+ bundle identities and provisioning profiles, private-first/shared-second root
76
+ Keychain entitlements, and a shared-only extension entitlement. The root's
77
+ first/default group keeps its key, identity, and session state outside the
78
+ extension's reach.
79
+
80
+ The bridge intentionally implements a bounded fetch subset: method, headers,
81
+ an at-most-8-MiB buffered request body, cancellation, response metadata, and a
82
+ pull-driven response stream. Browser cookie/cache modes, service workers,
83
+ redirect following, streaming uploads, response trailers, and native response
84
+ URL metadata are outside this transport. Framework compatibility therefore
85
+ depends on a real custom-fetch seam and the framework's React Native support;
86
+ the presence of `fetchFor` alone is not a version-support claim.
87
+
88
+ `fetchFor(feature)` also maps the canonical safe
89
+ `X-Latchway-Request-ID` response header to the conventional `X-Request-ID`
90
+ alias without reading the body, so provider SDK failures retain server
91
+ correlation. The native runtime remains the framework identity reported to the
92
+ gateway (`react-native-fetch`); an underlying JavaScript library cannot spoof a
93
+ different SDK/framework pair through caller headers.
94
+
95
+ Feature binding is also protocol binding: a Responses feature cannot dispatch
96
+ Chat Completions, Embeddings, or Anthropic Messages. A consumer set spanning
97
+ those protocols constructs a separate `fetchFor` transport for each configured
98
+ feature instead of multiplexing incompatible endpoints through one identifier.
99
+
100
+ ## Coordination
101
+
102
+ A module-global root lease map is keyed by native-module identity plus gateway/application/environment scope. A separate component lease map adds the component definition and never aliases the root map. Equivalent clients reuse one native client and configuration promise; conflicting security configuration for an active scope is rejected. Reference-counted disposal drops the native object only after the last JavaScript client leaves. The native iOS actor and Android coordinator/mutex prevent session establishment and refresh stampedes.
103
+
104
+ Native persistence namespaces include `react_native_ios` or `react_native_android`. The bridge configures the paired runtime identity, so challenge/grant platform and `X-Latchway-SDK: react-native` cannot disagree. Native compatibility JSON is checked against released contract 1.0.0 and current wire protocol 2 before any operation.
105
+
106
+ ## TurboModule boundary
107
+
108
+ The handwritten spec carries root and component configuration as distinct
109
+ operations, a bounded request description, the root client's transient
110
+ application identity token, opaque response-handle start/read/close operations,
111
+ quota/diagnostic results, public component descriptors for lifecycle and
112
+ delegated-session compatibility, cancellation, and disposal. Root lifecycle
113
+ mutations carry identity transiently; root-side and extension-side component
114
+ diagnostics do not. The legacy direct-attestation operation remains
115
+ ABI-compatible but fails closed. Extension-process component operations have no
116
+ identity-token argument. The spec has no authorization-envelope operation and
117
+ does not return or accept provider attestation evidence, Play request hashes,
118
+ App Attest client-data hashes, session tokens, DPoP proofs, or key material.
119
+ Generated Objective-C++ and Java specs are disposable codegen output, not
120
+ public API.
121
+
122
+ ## Native dependencies
123
+
124
+ Published package metadata pins release coordinates. CocoaPods consumes `Latchway/AppAttest` 1.0.0. Gradle consumes `dev.latchway:latchway-okhttp:1.0.0` and `dev.latchway:latchway-play-integrity:1.0.0`. Development may point `LATCHWAY_NATIVE_REPOSITORY` or `-PlatchwayNativeRepository` at a locally published Maven repository; local file links never enter npm metadata.
125
+
126
+ ## Diagnostics and errors
127
+
128
+ Diagnostics contain version compatibility, platform, secure key-storage category, attestation support/provider, session state/expiration, installation ID/status, server version, and last request/error identifiers. Component diagnostics add only family/component IDs, public definition/access-group identifiers, key/session/grant availability, trust provenance/expiry, and a containing-app action flag. Native key IDs, JWK thumbprints, tokens, proofs, and evidence are excluded. Native errors are bounded, control-character stripped, secret-pattern redacted, and mapped to the shared `LatchwayError` taxonomy. A server-originated native error must carry the exact `https://docs.latchway.dev/errors/<hyphenated-code>` documentation URL; missing or mismatched links fail closed. `operation_indeterminate` alone carries a required canonical reconciliation ID through both native bridges; malformed, missing, contradictory, or otherwise attached operation metadata fails closed.
129
+
130
+ ## Verification boundary
131
+
132
+ Unit and Node conformance tests own public request shaping, fail-closed
133
+ credential-output checks, response pull/backpressure, error projection,
134
+ cancellation, coordination, strict-CSP behavior, and
135
+ canonical vectors. Reproducible code generation proves the handwritten schema
136
+ remains valid. Native consumer builds prove released dependency resolution and
137
+ bridge compilation. Physical-device conformance proves real App Attest and Play
138
+ Integrity behavior, session rotation, quota, streaming, diagnostics, and
139
+ revocation against the exact core image.
140
+
141
+ ## Non-goals
142
+
143
+ This package does not own server policy, provider routing, quota enforcement,
144
+ user-authentication UI, AI request modeling, upstream secrets, native
145
+ cryptography, native attestation verification, or an independent session store.