@scalebun/react-native 1.13.0 → 2.0.1

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 (115) hide show
  1. package/android/build.gradle +8 -0
  2. package/android/src/androidTest/java/com/scalebun/rn/ota/ScaleBunOtaVerifierInstrumentedTest.kt +112 -0
  3. package/android/src/main/java/com/scalebun/rn/ota/BundleDownloader.kt +10 -0
  4. package/android/src/main/java/com/scalebun/rn/ota/DeviceIntegrity.kt +75 -0
  5. package/android/src/main/java/com/scalebun/rn/ota/OtaProtocol.kt +244 -0
  6. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaKeyRegistry.kt +100 -0
  7. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +132 -62
  8. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaReleaseVerifier.kt +129 -0
  9. package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +80 -1
  10. package/android/src/main/java/com/scalebun/rn/ota/TlsPinning.kt +118 -0
  11. package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +6 -8
  12. package/dist/scalebun.full.js +964 -533
  13. package/dist/scalebun.slim.js +962 -531
  14. package/ios/Ota/OtaProtocol.swift +242 -0
  15. package/ios/Ota/OtaSlotManager.swift +26 -0
  16. package/ios/Ota/ScaleBunOtaBridge.mm +6 -3
  17. package/ios/Ota/ScaleBunOtaKeyRegistry.swift +147 -0
  18. package/ios/Ota/ScaleBunOtaModule.swift +125 -53
  19. package/ios/Ota/ScaleBunOtaReleaseVerifier.swift +115 -0
  20. package/ios/Ota/ScaleBunOtaVerifierTests.swift +92 -0
  21. package/ios/ReplaySdk.swift +65 -2
  22. package/lib/commonjs/bootstrap/SDKBootstrapper.js +30 -0
  23. package/lib/commonjs/core/config/schema.js +19 -12
  24. package/lib/commonjs/core/constants/version.js +1 -1
  25. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +293 -5
  26. package/lib/commonjs/features/journey/ScaleBunScrollView.js +28 -0
  27. package/lib/commonjs/features/journey/autoInstrumentScroll.js +161 -0
  28. package/lib/commonjs/features/journey/interactionProtocol.js +131 -10
  29. package/lib/commonjs/features/journey/scrollContext.js +146 -0
  30. package/lib/commonjs/features/journey/targetGeometry.js +164 -0
  31. package/lib/commonjs/features/journey/touchTarget.js +152 -24
  32. package/lib/commonjs/features/journey/uiState.js +81 -1
  33. package/lib/commonjs/features/navigation/AutoScreenDetector.js +74 -5
  34. package/lib/commonjs/features/network/NetworkFeature.js +39 -5
  35. package/lib/commonjs/features/network/thirdParty.js +90 -0
  36. package/lib/commonjs/features/ota/OtaOrchestrator.js +175 -80
  37. package/lib/commonjs/features/session/SessionManager.js +63 -0
  38. package/lib/commonjs/public/ScaleBunFacade.js +23 -57
  39. package/lib/module/bootstrap/SDKBootstrapper.js +30 -0
  40. package/lib/module/core/config/schema.js +19 -12
  41. package/lib/module/core/constants/version.js +1 -1
  42. package/lib/module/features/journey/ScaleBunDebugRoot.js +295 -7
  43. package/lib/module/features/journey/ScaleBunScrollView.js +28 -0
  44. package/lib/module/features/journey/autoInstrumentScroll.js +155 -0
  45. package/lib/module/features/journey/interactionProtocol.js +128 -8
  46. package/lib/module/features/journey/scrollContext.js +135 -0
  47. package/lib/module/features/journey/targetGeometry.js +155 -0
  48. package/lib/module/features/journey/touchTarget.js +151 -24
  49. package/lib/module/features/journey/uiState.js +78 -1
  50. package/lib/module/features/navigation/AutoScreenDetector.js +74 -5
  51. package/lib/module/features/network/NetworkFeature.js +38 -4
  52. package/lib/module/features/network/thirdParty.js +83 -0
  53. package/lib/module/features/ota/OtaOrchestrator.js +175 -80
  54. package/lib/module/features/session/SessionManager.js +63 -0
  55. package/lib/module/public/ScaleBunFacade.js +23 -57
  56. package/lib/typescript/core/config/schema.d.ts +2 -3
  57. package/lib/typescript/core/constants/version.d.ts +1 -1
  58. package/lib/typescript/features/journey/autoInstrumentScroll.d.ts +46 -0
  59. package/lib/typescript/features/journey/interactionProtocol.d.ts +139 -1
  60. package/lib/typescript/features/journey/scrollContext.d.ts +85 -0
  61. package/lib/typescript/features/journey/targetGeometry.d.ts +114 -0
  62. package/lib/typescript/features/journey/touchTarget.d.ts +148 -15
  63. package/lib/typescript/features/journey/uiState.d.ts +39 -0
  64. package/lib/typescript/features/navigation/AutoScreenDetector.d.ts +48 -2
  65. package/lib/typescript/features/network/index.d.ts +18 -0
  66. package/lib/typescript/features/network/thirdParty.d.ts +65 -0
  67. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +12 -8
  68. package/lib/typescript/features/ota/OtaTypes.d.ts +22 -0
  69. package/lib/typescript/features/session/SessionManager.d.ts +55 -0
  70. package/lib/typescript/public/ScaleBunFacade.d.ts +5 -11
  71. package/lib/typescript/public/types.d.ts +10 -0
  72. package/lib/typescript/specs/NativeScaleBunOta.d.ts +32 -20
  73. package/package.json +3 -13
  74. package/scalebun-react-native.podspec +4 -0
  75. package/src/bootstrap/SDKBootstrapper.ts +40 -0
  76. package/src/core/config/schema.ts +21 -9
  77. package/src/core/constants/version.ts +1 -1
  78. package/src/features/journey/ScaleBunDebugRoot.tsx +288 -7
  79. package/src/features/journey/ScaleBunScrollView.tsx +29 -0
  80. package/src/features/journey/autoInstrumentScroll.ts +155 -0
  81. package/src/features/journey/interactionProtocol.ts +188 -10
  82. package/src/features/journey/scrollContext.ts +137 -0
  83. package/src/features/journey/targetGeometry.ts +176 -0
  84. package/src/features/journey/touchTarget.ts +237 -26
  85. package/src/features/journey/uiState.ts +80 -1
  86. package/src/features/navigation/AutoScreenDetector.ts +65 -5
  87. package/src/features/network/NetworkFeature.ts +41 -4
  88. package/src/features/network/index.ts +18 -0
  89. package/src/features/network/thirdParty.ts +92 -0
  90. package/src/features/ota/OtaOrchestrator.ts +190 -90
  91. package/src/features/ota/OtaTypes.ts +23 -1
  92. package/src/features/session/SessionManager.ts +113 -0
  93. package/src/public/ScaleBunFacade.ts +23 -69
  94. package/src/public/types.ts +10 -0
  95. package/src/specs/NativeScaleBunOta.ts +35 -20
  96. package/lib/commonjs/features/ota/crypto/builtinVerifier.js +0 -248
  97. package/lib/commonjs/features/ota/crypto/loadEd25519.js +0 -40
  98. package/lib/commonjs/features/ota/crypto/loadSha512.js +0 -40
  99. package/lib/commonjs/features/ota/crypto/nativeVerifier.js +0 -121
  100. package/lib/commonjs/features/ota/signature.js +0 -175
  101. package/lib/module/features/ota/crypto/builtinVerifier.js +0 -240
  102. package/lib/module/features/ota/crypto/loadEd25519.js +0 -34
  103. package/lib/module/features/ota/crypto/loadSha512.js +0 -34
  104. package/lib/module/features/ota/crypto/nativeVerifier.js +0 -113
  105. package/lib/module/features/ota/signature.js +0 -169
  106. package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +0 -53
  107. package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +0 -30
  108. package/lib/typescript/features/ota/crypto/loadSha512.d.ts +0 -15
  109. package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +0 -35
  110. package/lib/typescript/features/ota/signature.d.ts +0 -81
  111. package/src/features/ota/crypto/builtinVerifier.ts +0 -257
  112. package/src/features/ota/crypto/loadEd25519.ts +0 -41
  113. package/src/features/ota/crypto/loadSha512.ts +0 -35
  114. package/src/features/ota/crypto/nativeVerifier.ts +0 -117
  115. package/src/features/ota/signature.ts +0 -206
@@ -12,7 +12,6 @@ var _OtaEventEmitter = require("./OtaEventEmitter");
12
12
  var _geoCountry = require("./geoCountry");
13
13
  var _deviceAttributes = require("./deviceAttributes");
14
14
  var _installationId = require("../../core/id/installationId");
15
- var _signature = require("./signature");
16
15
  var _environment = require("./environment");
17
16
  var _retry = require("./retry");
18
17
  var _StorageBackend = require("../../storage/StorageBackend");
@@ -120,12 +119,12 @@ async function deliverOtaEvents(params) {
120
119
  })
121
120
  });
122
121
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
123
- __DEV__ && _internalLogger.logger.debug(`[OTA] Delivered ${items.length} telemetry event(s)`);
122
+ _internalLogger.logger.debug(`[OTA] Delivered ${items.length} telemetry event(s)`);
124
123
  } catch (err) {
125
124
  // Failed delivery must not fail the update — but the events must not be
126
125
  // lost either: requeue so the next sync retries them.
127
126
  for (const e of events) _OtaEventEmitter.otaEventEmitter.emit(e);
128
- __DEV__ && _internalLogger.logger.debug(`[OTA] Telemetry delivery failed (will retry next sync): ${err?.message}`);
127
+ _internalLogger.logger.debug(`[OTA] Telemetry delivery failed (will retry next sync): ${err?.message}`);
129
128
  }
130
129
  }
131
130
 
@@ -145,6 +144,8 @@ class OtaOrchestrator {
145
144
  return this.currentBundle;
146
145
  }
147
146
  isRestartRequiredState = false;
147
+ /** Root/jailbreak indicators observed at init; empty when none found. */
148
+ integrityIndicators = [];
148
149
  healthyTimer = null;
149
150
  bootGuardConfig = {};
150
151
  environment = null;
@@ -155,9 +156,10 @@ class OtaOrchestrator {
155
156
  /**
156
157
  * Initialize the orchestrator. Checks for boot-guard recovery state.
157
158
  *
158
- * `signature` is optional: an app that has not adopted signing keeps working
159
- * on SHA-256 integrity alone. Once a public key IS configured, a bundle that
160
- * cannot be verified is refused rather than installed (OTA-03).
159
+ * Signature enforcement is no longer a JS concern: signed releases are
160
+ * verified and staged in NATIVE code against RSA keys embedded in the app
161
+ * package (SB-OTA-RSA-SHA256-V1), and unsigned releases keep working on
162
+ * SHA-256 integrity alone. There is nothing for the host to configure here.
161
163
  */
162
164
  init(config) {
163
165
  (0, _crashSafe.noThrow)(() => {
@@ -170,7 +172,6 @@ class OtaOrchestrator {
170
172
  // them. Config is still refreshed so late signature setup works.
171
173
  if (this.initialized) {
172
174
  this.bootGuardConfig = config ?? this.bootGuardConfig;
173
- this.signatureConfig = config?.signature ?? this.signatureConfig;
174
175
  return;
175
176
  }
176
177
  if (!_NativeScaleBunOta.default) {
@@ -200,13 +201,30 @@ class OtaOrchestrator {
200
201
  // We cannot see which form the host used — the identity check after
201
202
  // restart is what proves it — but naming the right fix up front turns
202
203
  // a confusing silent failure into a one-line correction.
203
- __DEV__ && _internalLogger.logger.debug('[OTA] React Native >= 0.82 detected. The only valid Android integration is ' + 'getDefaultReactHost(..., jsBundleFilePath = ScaleBunOtaModule.getJSBundleFile(ctx)) — ' + 'ReactNativeHost and its getJSBundleFile() override no longer exist.');
204
+ _internalLogger.logger.debug('[OTA] React Native >= 0.82 detected. The only valid Android integration is ' + 'getDefaultReactHost(..., jsBundleFilePath = ScaleBunOtaModule.getJSBundleFile(ctx)) — ' + 'ReactNativeHost and its getJSBundleFile() override no longer exist.');
204
205
  }
205
206
  this.initialized = true;
206
207
  this.enabled = true;
208
+
209
+ // Device integrity signal.
210
+ //
211
+ // Reported, not enforced. Root defeats any check made from inside the app,
212
+ // so this is not a boundary — it is context. It tells an operator that a
213
+ // device's OTA slot is writable by other processes, which is what makes the
214
+ // launch-time hash re-check in SlotManager meaningful rather than paranoid.
215
+ // A host app that wants to withhold updates on such devices can read
216
+ // `deviceIntegrityIndicators()` and decide for itself.
217
+ try {
218
+ const raw = _NativeScaleBunOta.default?.getDeviceIntegrity?.();
219
+ this.integrityIndicators = raw ? JSON.parse(raw) : [];
220
+ if (this.integrityIndicators.length > 0) {
221
+ _internalLogger.logger.warn(`[OTA] Device integrity indicators present: ${this.integrityIndicators.join(', ')}. ` + 'Updates still install and are still signature-verified, but an installed ' + 'bundle on this device can be modified by other privileged processes.');
222
+ }
223
+ } catch {
224
+ this.integrityIndicators = [];
225
+ }
207
226
  this.bootGuardConfig = config ?? {};
208
- this.signatureConfig = config?.signature;
209
- __DEV__ && _internalLogger.logger.debug(`[OTA] Orchestrator initialized (RN ${this.environment.rnVersionString ?? 'unknown'}` + `${this.environment.bridgeless ? ', bridgeless' : ''}` + `${this.environment.hermes ? `, Hermes HBC v${this.environment.hermesBytecodeVersion ?? '?'}` : ''})`);
227
+ _internalLogger.logger.debug(`[OTA] Orchestrator initialized (RN ${this.environment.rnVersionString ?? 'unknown'}` + `${this.environment.bridgeless ? ', bridgeless' : ''}` + `${this.environment.hermes ? `, Hermes HBC v${this.environment.hermesBytecodeVersion ?? '?'}` : ''})`);
210
228
 
211
229
  // ONE read of the slot state, shared by both boot-time consumers.
212
230
  //
@@ -237,6 +255,22 @@ class OtaOrchestrator {
237
255
 
238
256
  // Prove the bundle we installed is the bundle that loaded.
239
257
  this.verifyRunningBundleIdentity();
258
+
259
+ // Boot-guard health is a LOCAL fact: this process booted from the staged
260
+ // bundle and did not crash. The heartbeat used to start only on the
261
+ // `action === 'NONE'` branch of sync(), so confirming a good bundle needed
262
+ // a successful network round-trip that returned exactly that answer — a
263
+ // device offline, rate-limited, or still being offered an update could
264
+ // never clear its marker, and the guard reverted a bundle that was working.
265
+ //
266
+ // It runs HERE, last, and is handed the slot state that was already read.
267
+ // Starting it earlier made it call getSlotState() itself, and that read is
268
+ // consume-on-read for the revert record — it swallowed the revert before
269
+ // checkBootGuardRecovery() could name the bundle that failed, so a
270
+ // crash-rollback went unreported. It also read bootGuardConfig before
271
+ // init() had assigned it, so healthyAfterMs silently fell back to the
272
+ // default.
273
+ this.startBootGuardHeartbeat(slotState);
240
274
  });
241
275
  }
242
276
 
@@ -275,7 +309,7 @@ class OtaOrchestrator {
275
309
  releaseId: record.releaseId ?? undefined,
276
310
  sha256: record.sha256
277
311
  };
278
- __DEV__ && _internalLogger.logger.debug(`[OTA] Running bundle v${record.version} (${record.bundleId})`);
312
+ _internalLogger.logger.debug(`[OTA] Running bundle v${record.version} (${record.bundleId})`);
279
313
  return;
280
314
  }
281
315
 
@@ -285,7 +319,7 @@ class OtaOrchestrator {
285
319
  this.currentBundle = {
286
320
  sha256: current.sha256
287
321
  };
288
- __DEV__ && _internalLogger.logger.debug(`[OTA] Running an OTA bundle (sha ${String(current.sha256).slice(0, 12)}…) with no local install record.`);
322
+ _internalLogger.logger.debug(`[OTA] Running an OTA bundle (sha ${String(current.sha256).slice(0, 12)}…) with no local install record.`);
289
323
  } catch {
290
324
  // Slot state unreadable — treat as factory bundle.
291
325
  }
@@ -333,7 +367,7 @@ class OtaOrchestrator {
333
367
  // this bundle" and answers by serving the same bundle again, forever.
334
368
  if (!record.identityToken) return;
335
369
  if (running === record.identityToken) {
336
- __DEV__ && _internalLogger.logger.debug('[OTA] Install verified — running bundle matches what was installed.');
370
+ _internalLogger.logger.debug('[OTA] Install verified — running bundle matches what was installed.');
337
371
  this.retireIdentityToken(record);
338
372
  return;
339
373
  }
@@ -466,6 +500,14 @@ class OtaOrchestrator {
466
500
  isEnabled() {
467
501
  return this.enabled;
468
502
  }
503
+
504
+ /**
505
+ * Root/jailbreak indicators detected on this device, as stable reason strings.
506
+ * Empty means nothing was detected — not proof the device is clean.
507
+ */
508
+ deviceIntegrityIndicators() {
509
+ return [...this.integrityIndicators];
510
+ }
469
511
  isRestartRequired() {
470
512
  return this.isRestartRequiredState;
471
513
  }
@@ -485,12 +527,27 @@ class OtaOrchestrator {
485
527
  // OTA has no bundle for a non-mobile target; claiming Android there would install the wrong one.
486
528
  const mobileOS = (0, _device.resolveMobileOS)();
487
529
  if (!mobileOS) throw new Error('OTA is not supported on this platform');
530
+ // SDK trust coverage (CICDPAGE §12): report which trusted key IDs are
531
+ // embedded in this binary so the dashboard can measure key-rotation
532
+ // adoption. Telemetry only — the server never trusts a key a device claims.
533
+ // Defensive: optional native method, never let it break a check.
534
+ let trustedKeyIds;
535
+ try {
536
+ const raw = _NativeScaleBunOta.default?.getTrustedKeyIds?.();
537
+ if (raw) {
538
+ const arr = JSON.parse(raw);
539
+ if (Array.isArray(arr) && arr.length) trustedKeyIds = arr.slice(0, 16);
540
+ }
541
+ } catch {
542
+ /* trust reporting is best-effort — never break the update check */
543
+ }
488
544
  const payload = {
489
545
  appVersion: requestParams.appVersion,
490
546
  platform: mobileOS,
491
547
  installationId: requestParams.installationId,
492
548
  currentBundleId: this.currentBundle?.id,
493
549
  currentBundleHash: this.currentBundle?.sha256,
550
+ trustedKeyIds,
494
551
  channelName: requestParams.channelName,
495
552
  // Host-supplied country wins; otherwise the cached edge-Worker lookup
496
553
  // fills in (see geoCountry.ts). Undefined when neither knows — the
@@ -558,7 +615,7 @@ class OtaOrchestrator {
558
615
  error: 'OTA native module disabled or missing'
559
616
  };
560
617
  }
561
- __DEV__ && _internalLogger.logger.debug('[OTA] Sync started…');
618
+ _internalLogger.logger.debug('[OTA] Sync started…');
562
619
 
563
620
  // The funnel's denominator, for devices already on an OTA bundle. Emitted
564
621
  // before the request, so a check that fails outright still counts as a
@@ -577,7 +634,7 @@ class OtaOrchestrator {
577
634
  }
578
635
  const checkRes = await this.checkForUpdate(params);
579
636
  if (checkRes.action === 'NONE') {
580
- __DEV__ && _internalLogger.logger.debug('[OTA] App is up to date');
637
+ _internalLogger.logger.debug('[OTA] App is up to date');
581
638
  // If we booted from an OTA bundle and it's healthy, clear the boot guard
582
639
  this.startBootGuardHeartbeat();
583
640
  return {
@@ -624,31 +681,18 @@ class OtaOrchestrator {
624
681
  version: bundle.version
625
682
  });
626
683
 
627
- // ── VERIFY AUTHENTICITY (OTA-03) ─────────────────────────────────────
628
- // Before anything touches the disk. SHA-256 proves the bytes arrived
629
- // intact; only the signature proves they came from you. Checking after
630
- // staging would mean writing unverified code to the slot directory first.
631
- const signatureOutcome = await (0, _signature.verifyBundleSignature)(bundle.sha256, bundle.signature, this.signatureConfig);
632
- if (!signatureOutcome.ok) {
633
- _OtaEventEmitter.otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
634
- releaseId: bundle.releaseId,
635
- error: `Signature check failed: ${signatureOutcome.reason}`,
636
- version: bundle.version
637
- });
638
- return {
639
- status: 'ERROR',
640
- error: `Bundle signature check failed (${signatureOutcome.reason})`,
641
- bundle
642
- };
643
- }
644
-
645
684
  // ── DOWNLOAD ─────────────────────────────────────────────────────────
685
+ // Authenticity is enforced NATIVELY now (see the envelope branch below):
686
+ // SHA-256 proves the bytes arrived intact, and for signed releases the
687
+ // RSA signature — checked in native code against keys embedded in the app
688
+ // package — proves they came from you. JS neither pins keys nor verifies
689
+ // signatures.
646
690
  _OtaEventEmitter.otaEventEmitter.emitSimple('DOWNLOAD_STARTED', bundle.id, {
647
691
  releaseId: bundle.releaseId,
648
692
  version: bundle.version
649
693
  });
650
694
  const downloadStart = Date.now();
651
- __DEV__ && _internalLogger.logger.debug(`[OTA] Downloading update v${bundle.version}…`);
695
+ _internalLogger.logger.debug(`[OTA] Downloading update v${bundle.version}…`);
652
696
 
653
697
  // Live progress: native tick → DOWNLOAD_PROGRESS (feeds useOtaUpdate) →
654
698
  // throttled POST to the live tracker. `bundle.size` is the authoritative
@@ -696,46 +740,94 @@ class OtaOrchestrator {
696
740
  }
697
741
  });
698
742
  let staged = false;
699
-
700
- // Sprint 7: Try patch first if offered, fall back to full bundle
701
- if (bundle.patch && _NativeScaleBunOta.default.stagePatch) {
702
- __DEV__ && _internalLogger.logger.debug(`[OTA] Patch available (${(bundle.patch.size / 1024).toFixed(0)} KB vs ` + `${(bundle.size / 1024).toFixed(0)} KB full) — trying patch…`);
743
+ if (bundle.envelope) {
744
+ // ── NATIVE-ENFORCED VERIFY + STAGE (SB-OTA-RSA-SHA256-V1) ───────────
745
+ // Hand the whole envelope to native code, which verifies the RSA
746
+ // signature against the keys EMBEDDED in the app package, then downloads
747
+ // the artifact, enforces the SIGNED size + SHA-256, and stages it into
748
+ // the `next` slot — all outside JS. JavaScript never stages bytes it has
749
+ // not proven authentic. No patch path here: a signed offer is always a
750
+ // full verified download (native patch is a documented follow-up).
751
+ const envelopeJson = JSON.stringify({
752
+ ...bundle.envelope,
753
+ downloadUrl: bundle.url
754
+ });
755
+ let verdict;
703
756
  try {
704
- staged = await _NativeScaleBunOta.default.stagePatch(bundle.patch.url, bundle.patch.sha256, bundle.sha256 // expected full-bundle hash after patching
705
- );
706
- if (staged) {
707
- patchUsed = true;
708
- __DEV__ && _internalLogger.logger.debug('[OTA] Patch applied successfully');
709
- } else {
710
- _internalLogger.logger.warn('[OTA] Patch staging returned false — falling back to full bundle');
711
- }
712
- } catch (patchErr) {
713
- _internalLogger.logger.warn(`[OTA] Patch staging failed: ${patchErr.message} — falling back to full bundle`);
757
+ const verdictJson = await _NativeScaleBunOta.default.verifyAndStageRelease(envelopeJson);
758
+ verdict = JSON.parse(verdictJson);
759
+ } catch (err) {
760
+ // A rejected promise (or an unparseable verdict) is machinery failure,
761
+ // never a valid result — treat it as a hard failure.
762
+ verdict = {
763
+ ok: false,
764
+ code: 'verify_stage_threw',
765
+ message: err?.message ?? String(err)
766
+ };
714
767
  }
715
- }
768
+ if (!verdict.ok) {
769
+ unsubscribeProgress();
770
+ postProgress(0, 'FAILED');
771
+ _internalLogger.logger.error(`[OTA] Native verify/stage refused the release: ${verdict.code ?? 'unknown'}` + `${verdict.message ? ` — ${verdict.message}` : ''}`);
772
+ _OtaEventEmitter.otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
773
+ releaseId: bundle.releaseId,
774
+ // `error` is mapped onto the wire `errorCode` by deliverOtaEvents —
775
+ // carry the stable native verdict code there.
776
+ error: verdict.code ?? 'verify_and_stage_release_failed',
777
+ version: bundle.version
778
+ });
779
+ return {
780
+ status: 'ERROR',
781
+ error: `Release verification failed (${verdict.code ?? 'unknown'})`,
782
+ bundle
783
+ };
784
+ }
785
+ staged = true;
786
+ } else {
787
+ // ── LEGACY / UNSIGNED (SHA-256 integrity only) ─────────────────────
788
+ // No envelope means the app embeds no trusted keys and the release is
789
+ // unsigned: keep the pre-RSA behaviour so those apps still update.
716
790
 
717
- // Full-bundle fallback (or primary path when no patch is available).
718
- // Retried with backoff: on a mobile network a transient drop mid-download
719
- // is the common case, and previously one failure meant waiting for the
720
- // next cold start — often hours away, sometimes never.
721
- if (!staged) {
722
- try {
723
- staged = await (0, _retry.retryWithBackoff)(async () => {
724
- const ok = await _NativeScaleBunOta.default.stageBundle(bundle.url, bundle.sha256);
725
- if (!ok) {
726
- // Native returns a bare boolean, so a hash mismatch and a
727
- // dropped connection are indistinguishable here. Retrying a
728
- // genuine mismatch a couple of times is cheap; not retrying a
729
- // dropped connection costs the whole update.
730
- throw new Error('stageBundle returned false');
791
+ // Sprint 7: Try patch first if offered, fall back to full bundle
792
+ if (bundle.patch && _NativeScaleBunOta.default.stagePatch) {
793
+ _internalLogger.logger.debug(`[OTA] Patch available (${(bundle.patch.size / 1024).toFixed(0)} KB vs ` + `${(bundle.size / 1024).toFixed(0)} KB full) — trying patch…`);
794
+ try {
795
+ staged = await _NativeScaleBunOta.default.stagePatch(bundle.patch.url, bundle.patch.sha256, bundle.sha256 // expected full-bundle hash after patching
796
+ );
797
+ if (staged) {
798
+ patchUsed = true;
799
+ _internalLogger.logger.debug('[OTA] Patch applied successfully');
800
+ } else {
801
+ _internalLogger.logger.warn('[OTA] Patch staging returned false falling back to full bundle');
731
802
  }
732
- return ok;
733
- }, {
734
- label: `download v${bundle.version}`,
735
- attempts: 3
736
- });
737
- } catch {
738
- staged = false;
803
+ } catch (patchErr) {
804
+ _internalLogger.logger.warn(`[OTA] Patch staging failed: ${patchErr.message} — falling back to full bundle`);
805
+ }
806
+ }
807
+
808
+ // Full-bundle fallback (or primary path when no patch is available).
809
+ // Retried with backoff: on a mobile network a transient drop mid-download
810
+ // is the common case, and previously one failure meant waiting for the
811
+ // next cold start — often hours away, sometimes never.
812
+ if (!staged) {
813
+ try {
814
+ staged = await (0, _retry.retryWithBackoff)(async () => {
815
+ const ok = await _NativeScaleBunOta.default.stageBundle(bundle.url, bundle.sha256);
816
+ if (!ok) {
817
+ // Native returns a bare boolean, so a hash mismatch and a
818
+ // dropped connection are indistinguishable here. Retrying a
819
+ // genuine mismatch a couple of times is cheap; not retrying a
820
+ // dropped connection costs the whole update.
821
+ throw new Error('stageBundle returned false');
822
+ }
823
+ return ok;
824
+ }, {
825
+ label: `download v${bundle.version}`,
826
+ attempts: 3
827
+ });
828
+ } catch {
829
+ staged = false;
830
+ }
739
831
  }
740
832
  }
741
833
  unsubscribeProgress();
@@ -795,7 +887,7 @@ class OtaOrchestrator {
795
887
  releaseId: bundle.releaseId,
796
888
  version: bundle.version
797
889
  });
798
- __DEV__ && _internalLogger.logger.debug(`[OTA] Update v${bundle.version} installed successfully!`);
890
+ _internalLogger.logger.debug(`[OTA] Update v${bundle.version} installed successfully!`);
799
891
 
800
892
  // Deliver telemetry BEFORE a restart tears the JS runtime down —
801
893
  // an INSTALLED event queued but not sent would vanish with the process.
@@ -841,7 +933,7 @@ class OtaOrchestrator {
841
933
  * native boot-guard marker. If JS crashes before the timer fires, the
842
934
  * marker stays set and native code reverts on next cold start.
843
935
  */
844
- startBootGuardHeartbeat() {
936
+ startBootGuardHeartbeat(preRead) {
845
937
  if (!_NativeScaleBunOta.default) return;
846
938
 
847
939
  // Cancel any existing timer
@@ -850,20 +942,23 @@ class OtaOrchestrator {
850
942
  this.healthyTimer = null;
851
943
  }
852
944
  try {
853
- const stateJson = _NativeScaleBunOta.default.getSlotState();
854
- const state = JSON.parse(stateJson);
945
+ // getSlotState() is CONSUME-ON-READ for the revert record, so a caller that
946
+ // has already read it passes the parsed result down rather than letting
947
+ // this read again and swallow a rollback. `null` is a real answer (module
948
+ // absent or payload unreadable) — only `undefined` means "nobody read it".
949
+ const state = preRead !== undefined ? preRead : JSON.parse(_NativeScaleBunOta.default.getSlotState());
855
950
 
856
951
  // Only run the heartbeat if boot-guard marker is present
857
952
  // (meaning we're running from a freshly-applied OTA bundle)
858
- if (!state.bootMarkerPresent) {
953
+ if (!state?.bootMarkerPresent) {
859
954
  return;
860
955
  }
861
956
  const healthyMs = this.bootGuardConfig.healthyAfterMs ?? DEFAULT_HEALTHY_AFTER_MS;
862
- __DEV__ && _internalLogger.logger.debug(`[OTA] Boot guard active — marking healthy after ${healthyMs}ms`);
957
+ _internalLogger.logger.debug(`[OTA] Boot guard active — marking healthy after ${healthyMs}ms`);
863
958
  this.healthyTimer = setTimeout(() => {
864
959
  (0, _crashSafe.noThrow)(() => {
865
960
  _NativeScaleBunOta.default?.markHealthy();
866
- __DEV__ && _internalLogger.logger.info('[OTA] Boot guard cleared — bundle marked healthy ✓');
961
+ _internalLogger.logger.info('[OTA] Boot guard cleared — bundle marked healthy ✓');
867
962
  this.healthyTimer = null;
868
963
 
869
964
  // The honest activation signal. INSTALLED is emitted optimistically,
@@ -918,11 +1013,11 @@ class OtaOrchestrator {
918
1013
  /* the restart below makes cleanup moot */
919
1014
  }
920
1015
  this.resumeRestartArmed = false;
921
- __DEV__ && _internalLogger.logger.info('[OTA] App resumed — activating the staged update now');
1016
+ _internalLogger.logger.info('[OTA] App resumed — activating the staged update now');
922
1017
  this.restart();
923
1018
  }
924
1019
  });
925
- __DEV__ && _internalLogger.logger.debug('[OTA] Update staged — will activate on next resume (ON_NEXT_RESUME)');
1020
+ _internalLogger.logger.debug('[OTA] Update staged — will activate on next resume (ON_NEXT_RESUME)');
926
1021
  } catch {
927
1022
  // AppState unavailable (tests, exotic hosts): the update still activates
928
1023
  // on the next cold start, which is the documented fallback behaviour.
@@ -942,7 +1037,7 @@ class OtaOrchestrator {
942
1037
  */
943
1038
  restart() {
944
1039
  (0, _crashSafe.noThrow)(() => {
945
- __DEV__ && _internalLogger.logger.info('[OTA] Triggering app restart…');
1040
+ _internalLogger.logger.info('[OTA] Triggering app restart…');
946
1041
  if (_NativeScaleBunOta.default && typeof _NativeScaleBunOta.default.restartApp === 'function') {
947
1042
  _NativeScaleBunOta.default.restartApp();
948
1043
  } else if (_reactNative.DevSettings && typeof _reactNative.DevSettings.reload === 'function') {
@@ -959,6 +959,69 @@ class SessionManager {
959
959
  state_status: stateStatus,
960
960
  ui: details?.ui,
961
961
  target_id: details?.targetId,
962
+ /**
963
+ * RN-6 — shipped with the row so the join is auditable instead of assumed.
964
+ *
965
+ * Without it, an interaction with no `target_id` is ambiguous in the worst way:
966
+ * it could mean the control genuinely had no identity, or that the correlation
967
+ * refused and the identity exists but could not be attributed. Those are
968
+ * different defects with different owners, and the accounting lane cannot give
969
+ * an honest unplaceable REASON without knowing which.
970
+ *
971
+ * Undefined on the JS-fallback path (no native event, so nothing was joined),
972
+ * which is itself the correct answer there rather than a fabricated 'exact'.
973
+ */
974
+ correlation: details?.correlation,
975
+ correlation_delta_ms: details?.correlationDeltaMs,
976
+ correlation_runner_up_delta_ms: details?.correlationRunnerUpDeltaMs,
977
+ /**
978
+ * RN-1/RN-2 — stored alongside `target_id` so a reader can tell an authored
979
+ * identifier from a structural inference. 'path' means the app authored no testID
980
+ * and the identity was derived from component names, which is useful but will
981
+ * change under refactoring; a dashboard that presents the two identically invites
982
+ * someone to trust a key that is about to move.
983
+ */
984
+ target_source: details?.targetSource,
985
+ /**
986
+ * RN-8 — stored so "13.8% of interactions have a screen" becomes answerable.
987
+ * 'none' means the app instrumented nothing the SDK could see; 'manual' means a
988
+ * <ScaleBunScreen> beacon; 'navigation' means the React Navigation hook. Three
989
+ * different owners behind one number that today has none.
990
+ */
991
+ screen_source: details?.screenSource,
992
+ /**
993
+ * RN-13 — what makes "the heat is on this control" checkable instead of assumed.
994
+ * Absent when the control could not be measured synchronously, which is a fact worth
995
+ * storing: an unmeasurable control and a mis-placed tap are different findings.
996
+ */
997
+ target_rect: details?.targetRect,
998
+ /**
999
+ * RN-3 — the backend already reads scrollX/scrollY off the payload into the fact
1000
+ * table's own columns (it has done since the web SDK, which sends `sx`/`sy`), so
1001
+ * these need no migration. Mobile simply never sent them.
1002
+ *
1003
+ * `scroll_source` says whether an offset was available at all: 'none' means the app
1004
+ * uses a plain <ScrollView> rather than the SDK's wrapper, which is a coverage gap in
1005
+ * the integration and not a tap at the top of a list.
1006
+ */
1007
+ scrollX: details?.scrollX,
1008
+ scrollY: details?.scrollY,
1009
+ scroll_source: details?.scrollSource,
1010
+ /**
1011
+ * RN-4 — what stops a recycled native view merging two different rows into one
1012
+ * hotspot. The key is the app's own keyExtractor output, so it is stable across
1013
+ * scrolling and across sessions; the index is carried so a recycled slot (same index,
1014
+ * different key) can be proven rather than inferred.
1015
+ */
1016
+ item_key: details?.itemKey,
1017
+ item_index: details?.itemIndex,
1018
+ /**
1019
+ * RN-15 — where in the control, not just which control. Turns "they tapped Add to
1020
+ * Cart" into "they tapped its left edge", which is what tells you whether a hit area
1021
+ * is right. Outside 0..1 is a hitSlop tap and is kept as such.
1022
+ */
1023
+ local_u: details?.localU,
1024
+ local_v: details?.localV,
962
1025
  gestureType,
963
1026
  x: details?.x,
964
1027
  y: details?.y,