@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
@@ -6,7 +6,6 @@ import { otaEventEmitter } from './OtaEventEmitter';
6
6
  import { getDeviceCountry, prefetchDeviceCountry } from './geoCountry';
7
7
  import { deviceTargetingAttributes } from './deviceAttributes';
8
8
  import { resolveInstallationId } from '../../core/id/installationId';
9
- import { verifyBundleSignature } from './signature';
10
9
  import { detectOtaEnvironment } from './environment';
11
10
  import { retryWithBackoff, isRetryableNetworkError, HttpStatusError } from './retry';
12
11
  import { createStorageBackend } from '../../storage/StorageBackend';
@@ -114,12 +113,12 @@ async function deliverOtaEvents(params) {
114
113
  })
115
114
  });
116
115
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
117
- __DEV__ && logger.debug(`[OTA] Delivered ${items.length} telemetry event(s)`);
116
+ logger.debug(`[OTA] Delivered ${items.length} telemetry event(s)`);
118
117
  } catch (err) {
119
118
  // Failed delivery must not fail the update — but the events must not be
120
119
  // lost either: requeue so the next sync retries them.
121
120
  for (const e of events) otaEventEmitter.emit(e);
122
- __DEV__ && logger.debug(`[OTA] Telemetry delivery failed (will retry next sync): ${err?.message}`);
121
+ logger.debug(`[OTA] Telemetry delivery failed (will retry next sync): ${err?.message}`);
123
122
  }
124
123
  }
125
124
 
@@ -139,6 +138,8 @@ export class OtaOrchestrator {
139
138
  return this.currentBundle;
140
139
  }
141
140
  isRestartRequiredState = false;
141
+ /** Root/jailbreak indicators observed at init; empty when none found. */
142
+ integrityIndicators = [];
142
143
  healthyTimer = null;
143
144
  bootGuardConfig = {};
144
145
  environment = null;
@@ -149,9 +150,10 @@ export class OtaOrchestrator {
149
150
  /**
150
151
  * Initialize the orchestrator. Checks for boot-guard recovery state.
151
152
  *
152
- * `signature` is optional: an app that has not adopted signing keeps working
153
- * on SHA-256 integrity alone. Once a public key IS configured, a bundle that
154
- * cannot be verified is refused rather than installed (OTA-03).
153
+ * Signature enforcement is no longer a JS concern: signed releases are
154
+ * verified and staged in NATIVE code against RSA keys embedded in the app
155
+ * package (SB-OTA-RSA-SHA256-V1), and unsigned releases keep working on
156
+ * SHA-256 integrity alone. There is nothing for the host to configure here.
155
157
  */
156
158
  init(config) {
157
159
  noThrow(() => {
@@ -164,7 +166,6 @@ export class OtaOrchestrator {
164
166
  // them. Config is still refreshed so late signature setup works.
165
167
  if (this.initialized) {
166
168
  this.bootGuardConfig = config ?? this.bootGuardConfig;
167
- this.signatureConfig = config?.signature ?? this.signatureConfig;
168
169
  return;
169
170
  }
170
171
  if (!NativeScaleBunOta) {
@@ -194,13 +195,30 @@ export class OtaOrchestrator {
194
195
  // We cannot see which form the host used — the identity check after
195
196
  // restart is what proves it — but naming the right fix up front turns
196
197
  // a confusing silent failure into a one-line correction.
197
- __DEV__ && 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.');
198
+ 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.');
198
199
  }
199
200
  this.initialized = true;
200
201
  this.enabled = true;
202
+
203
+ // Device integrity signal.
204
+ //
205
+ // Reported, not enforced. Root defeats any check made from inside the app,
206
+ // so this is not a boundary — it is context. It tells an operator that a
207
+ // device's OTA slot is writable by other processes, which is what makes the
208
+ // launch-time hash re-check in SlotManager meaningful rather than paranoid.
209
+ // A host app that wants to withhold updates on such devices can read
210
+ // `deviceIntegrityIndicators()` and decide for itself.
211
+ try {
212
+ const raw = NativeScaleBunOta?.getDeviceIntegrity?.();
213
+ this.integrityIndicators = raw ? JSON.parse(raw) : [];
214
+ if (this.integrityIndicators.length > 0) {
215
+ 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.');
216
+ }
217
+ } catch {
218
+ this.integrityIndicators = [];
219
+ }
201
220
  this.bootGuardConfig = config ?? {};
202
- this.signatureConfig = config?.signature;
203
- __DEV__ && logger.debug(`[OTA] Orchestrator initialized (RN ${this.environment.rnVersionString ?? 'unknown'}` + `${this.environment.bridgeless ? ', bridgeless' : ''}` + `${this.environment.hermes ? `, Hermes HBC v${this.environment.hermesBytecodeVersion ?? '?'}` : ''})`);
221
+ logger.debug(`[OTA] Orchestrator initialized (RN ${this.environment.rnVersionString ?? 'unknown'}` + `${this.environment.bridgeless ? ', bridgeless' : ''}` + `${this.environment.hermes ? `, Hermes HBC v${this.environment.hermesBytecodeVersion ?? '?'}` : ''})`);
204
222
 
205
223
  // ONE read of the slot state, shared by both boot-time consumers.
206
224
  //
@@ -231,6 +249,22 @@ export class OtaOrchestrator {
231
249
 
232
250
  // Prove the bundle we installed is the bundle that loaded.
233
251
  this.verifyRunningBundleIdentity();
252
+
253
+ // Boot-guard health is a LOCAL fact: this process booted from the staged
254
+ // bundle and did not crash. The heartbeat used to start only on the
255
+ // `action === 'NONE'` branch of sync(), so confirming a good bundle needed
256
+ // a successful network round-trip that returned exactly that answer — a
257
+ // device offline, rate-limited, or still being offered an update could
258
+ // never clear its marker, and the guard reverted a bundle that was working.
259
+ //
260
+ // It runs HERE, last, and is handed the slot state that was already read.
261
+ // Starting it earlier made it call getSlotState() itself, and that read is
262
+ // consume-on-read for the revert record — it swallowed the revert before
263
+ // checkBootGuardRecovery() could name the bundle that failed, so a
264
+ // crash-rollback went unreported. It also read bootGuardConfig before
265
+ // init() had assigned it, so healthyAfterMs silently fell back to the
266
+ // default.
267
+ this.startBootGuardHeartbeat(slotState);
234
268
  });
235
269
  }
236
270
 
@@ -269,7 +303,7 @@ export class OtaOrchestrator {
269
303
  releaseId: record.releaseId ?? undefined,
270
304
  sha256: record.sha256
271
305
  };
272
- __DEV__ && logger.debug(`[OTA] Running bundle v${record.version} (${record.bundleId})`);
306
+ logger.debug(`[OTA] Running bundle v${record.version} (${record.bundleId})`);
273
307
  return;
274
308
  }
275
309
 
@@ -279,7 +313,7 @@ export class OtaOrchestrator {
279
313
  this.currentBundle = {
280
314
  sha256: current.sha256
281
315
  };
282
- __DEV__ && logger.debug(`[OTA] Running an OTA bundle (sha ${String(current.sha256).slice(0, 12)}…) with no local install record.`);
316
+ logger.debug(`[OTA] Running an OTA bundle (sha ${String(current.sha256).slice(0, 12)}…) with no local install record.`);
283
317
  } catch {
284
318
  // Slot state unreadable — treat as factory bundle.
285
319
  }
@@ -327,7 +361,7 @@ export class OtaOrchestrator {
327
361
  // this bundle" and answers by serving the same bundle again, forever.
328
362
  if (!record.identityToken) return;
329
363
  if (running === record.identityToken) {
330
- __DEV__ && logger.debug('[OTA] Install verified — running bundle matches what was installed.');
364
+ logger.debug('[OTA] Install verified — running bundle matches what was installed.');
331
365
  this.retireIdentityToken(record);
332
366
  return;
333
367
  }
@@ -460,6 +494,14 @@ export class OtaOrchestrator {
460
494
  isEnabled() {
461
495
  return this.enabled;
462
496
  }
497
+
498
+ /**
499
+ * Root/jailbreak indicators detected on this device, as stable reason strings.
500
+ * Empty means nothing was detected — not proof the device is clean.
501
+ */
502
+ deviceIntegrityIndicators() {
503
+ return [...this.integrityIndicators];
504
+ }
463
505
  isRestartRequired() {
464
506
  return this.isRestartRequiredState;
465
507
  }
@@ -479,12 +521,27 @@ export class OtaOrchestrator {
479
521
  // OTA has no bundle for a non-mobile target; claiming Android there would install the wrong one.
480
522
  const mobileOS = resolveMobileOS();
481
523
  if (!mobileOS) throw new Error('OTA is not supported on this platform');
524
+ // SDK trust coverage (CICDPAGE §12): report which trusted key IDs are
525
+ // embedded in this binary so the dashboard can measure key-rotation
526
+ // adoption. Telemetry only — the server never trusts a key a device claims.
527
+ // Defensive: optional native method, never let it break a check.
528
+ let trustedKeyIds;
529
+ try {
530
+ const raw = NativeScaleBunOta?.getTrustedKeyIds?.();
531
+ if (raw) {
532
+ const arr = JSON.parse(raw);
533
+ if (Array.isArray(arr) && arr.length) trustedKeyIds = arr.slice(0, 16);
534
+ }
535
+ } catch {
536
+ /* trust reporting is best-effort — never break the update check */
537
+ }
482
538
  const payload = {
483
539
  appVersion: requestParams.appVersion,
484
540
  platform: mobileOS,
485
541
  installationId: requestParams.installationId,
486
542
  currentBundleId: this.currentBundle?.id,
487
543
  currentBundleHash: this.currentBundle?.sha256,
544
+ trustedKeyIds,
488
545
  channelName: requestParams.channelName,
489
546
  // Host-supplied country wins; otherwise the cached edge-Worker lookup
490
547
  // fills in (see geoCountry.ts). Undefined when neither knows — the
@@ -552,7 +609,7 @@ export class OtaOrchestrator {
552
609
  error: 'OTA native module disabled or missing'
553
610
  };
554
611
  }
555
- __DEV__ && logger.debug('[OTA] Sync started…');
612
+ logger.debug('[OTA] Sync started…');
556
613
 
557
614
  // The funnel's denominator, for devices already on an OTA bundle. Emitted
558
615
  // before the request, so a check that fails outright still counts as a
@@ -571,7 +628,7 @@ export class OtaOrchestrator {
571
628
  }
572
629
  const checkRes = await this.checkForUpdate(params);
573
630
  if (checkRes.action === 'NONE') {
574
- __DEV__ && logger.debug('[OTA] App is up to date');
631
+ logger.debug('[OTA] App is up to date');
575
632
  // If we booted from an OTA bundle and it's healthy, clear the boot guard
576
633
  this.startBootGuardHeartbeat();
577
634
  return {
@@ -618,31 +675,18 @@ export class OtaOrchestrator {
618
675
  version: bundle.version
619
676
  });
620
677
 
621
- // ── VERIFY AUTHENTICITY (OTA-03) ─────────────────────────────────────
622
- // Before anything touches the disk. SHA-256 proves the bytes arrived
623
- // intact; only the signature proves they came from you. Checking after
624
- // staging would mean writing unverified code to the slot directory first.
625
- const signatureOutcome = await verifyBundleSignature(bundle.sha256, bundle.signature, this.signatureConfig);
626
- if (!signatureOutcome.ok) {
627
- otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
628
- releaseId: bundle.releaseId,
629
- error: `Signature check failed: ${signatureOutcome.reason}`,
630
- version: bundle.version
631
- });
632
- return {
633
- status: 'ERROR',
634
- error: `Bundle signature check failed (${signatureOutcome.reason})`,
635
- bundle
636
- };
637
- }
638
-
639
678
  // ── DOWNLOAD ─────────────────────────────────────────────────────────
679
+ // Authenticity is enforced NATIVELY now (see the envelope branch below):
680
+ // SHA-256 proves the bytes arrived intact, and for signed releases the
681
+ // RSA signature — checked in native code against keys embedded in the app
682
+ // package — proves they came from you. JS neither pins keys nor verifies
683
+ // signatures.
640
684
  otaEventEmitter.emitSimple('DOWNLOAD_STARTED', bundle.id, {
641
685
  releaseId: bundle.releaseId,
642
686
  version: bundle.version
643
687
  });
644
688
  const downloadStart = Date.now();
645
- __DEV__ && logger.debug(`[OTA] Downloading update v${bundle.version}…`);
689
+ logger.debug(`[OTA] Downloading update v${bundle.version}…`);
646
690
 
647
691
  // Live progress: native tick → DOWNLOAD_PROGRESS (feeds useOtaUpdate) →
648
692
  // throttled POST to the live tracker. `bundle.size` is the authoritative
@@ -690,46 +734,94 @@ export class OtaOrchestrator {
690
734
  }
691
735
  });
692
736
  let staged = false;
693
-
694
- // Sprint 7: Try patch first if offered, fall back to full bundle
695
- if (bundle.patch && NativeScaleBunOta.stagePatch) {
696
- __DEV__ && logger.debug(`[OTA] Patch available (${(bundle.patch.size / 1024).toFixed(0)} KB vs ` + `${(bundle.size / 1024).toFixed(0)} KB full) — trying patch…`);
737
+ if (bundle.envelope) {
738
+ // ── NATIVE-ENFORCED VERIFY + STAGE (SB-OTA-RSA-SHA256-V1) ───────────
739
+ // Hand the whole envelope to native code, which verifies the RSA
740
+ // signature against the keys EMBEDDED in the app package, then downloads
741
+ // the artifact, enforces the SIGNED size + SHA-256, and stages it into
742
+ // the `next` slot — all outside JS. JavaScript never stages bytes it has
743
+ // not proven authentic. No patch path here: a signed offer is always a
744
+ // full verified download (native patch is a documented follow-up).
745
+ const envelopeJson = JSON.stringify({
746
+ ...bundle.envelope,
747
+ downloadUrl: bundle.url
748
+ });
749
+ let verdict;
697
750
  try {
698
- staged = await NativeScaleBunOta.stagePatch(bundle.patch.url, bundle.patch.sha256, bundle.sha256 // expected full-bundle hash after patching
699
- );
700
- if (staged) {
701
- patchUsed = true;
702
- __DEV__ && logger.debug('[OTA] Patch applied successfully');
703
- } else {
704
- logger.warn('[OTA] Patch staging returned false — falling back to full bundle');
705
- }
706
- } catch (patchErr) {
707
- logger.warn(`[OTA] Patch staging failed: ${patchErr.message} — falling back to full bundle`);
751
+ const verdictJson = await NativeScaleBunOta.verifyAndStageRelease(envelopeJson);
752
+ verdict = JSON.parse(verdictJson);
753
+ } catch (err) {
754
+ // A rejected promise (or an unparseable verdict) is machinery failure,
755
+ // never a valid result — treat it as a hard failure.
756
+ verdict = {
757
+ ok: false,
758
+ code: 'verify_stage_threw',
759
+ message: err?.message ?? String(err)
760
+ };
708
761
  }
709
- }
762
+ if (!verdict.ok) {
763
+ unsubscribeProgress();
764
+ postProgress(0, 'FAILED');
765
+ logger.error(`[OTA] Native verify/stage refused the release: ${verdict.code ?? 'unknown'}` + `${verdict.message ? ` — ${verdict.message}` : ''}`);
766
+ otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
767
+ releaseId: bundle.releaseId,
768
+ // `error` is mapped onto the wire `errorCode` by deliverOtaEvents —
769
+ // carry the stable native verdict code there.
770
+ error: verdict.code ?? 'verify_and_stage_release_failed',
771
+ version: bundle.version
772
+ });
773
+ return {
774
+ status: 'ERROR',
775
+ error: `Release verification failed (${verdict.code ?? 'unknown'})`,
776
+ bundle
777
+ };
778
+ }
779
+ staged = true;
780
+ } else {
781
+ // ── LEGACY / UNSIGNED (SHA-256 integrity only) ─────────────────────
782
+ // No envelope means the app embeds no trusted keys and the release is
783
+ // unsigned: keep the pre-RSA behaviour so those apps still update.
710
784
 
711
- // Full-bundle fallback (or primary path when no patch is available).
712
- // Retried with backoff: on a mobile network a transient drop mid-download
713
- // is the common case, and previously one failure meant waiting for the
714
- // next cold start — often hours away, sometimes never.
715
- if (!staged) {
716
- try {
717
- staged = await retryWithBackoff(async () => {
718
- const ok = await NativeScaleBunOta.stageBundle(bundle.url, bundle.sha256);
719
- if (!ok) {
720
- // Native returns a bare boolean, so a hash mismatch and a
721
- // dropped connection are indistinguishable here. Retrying a
722
- // genuine mismatch a couple of times is cheap; not retrying a
723
- // dropped connection costs the whole update.
724
- throw new Error('stageBundle returned false');
785
+ // Sprint 7: Try patch first if offered, fall back to full bundle
786
+ if (bundle.patch && NativeScaleBunOta.stagePatch) {
787
+ logger.debug(`[OTA] Patch available (${(bundle.patch.size / 1024).toFixed(0)} KB vs ` + `${(bundle.size / 1024).toFixed(0)} KB full) — trying patch…`);
788
+ try {
789
+ staged = await NativeScaleBunOta.stagePatch(bundle.patch.url, bundle.patch.sha256, bundle.sha256 // expected full-bundle hash after patching
790
+ );
791
+ if (staged) {
792
+ patchUsed = true;
793
+ logger.debug('[OTA] Patch applied successfully');
794
+ } else {
795
+ logger.warn('[OTA] Patch staging returned false falling back to full bundle');
725
796
  }
726
- return ok;
727
- }, {
728
- label: `download v${bundle.version}`,
729
- attempts: 3
730
- });
731
- } catch {
732
- staged = false;
797
+ } catch (patchErr) {
798
+ logger.warn(`[OTA] Patch staging failed: ${patchErr.message} — falling back to full bundle`);
799
+ }
800
+ }
801
+
802
+ // Full-bundle fallback (or primary path when no patch is available).
803
+ // Retried with backoff: on a mobile network a transient drop mid-download
804
+ // is the common case, and previously one failure meant waiting for the
805
+ // next cold start — often hours away, sometimes never.
806
+ if (!staged) {
807
+ try {
808
+ staged = await retryWithBackoff(async () => {
809
+ const ok = await NativeScaleBunOta.stageBundle(bundle.url, bundle.sha256);
810
+ if (!ok) {
811
+ // Native returns a bare boolean, so a hash mismatch and a
812
+ // dropped connection are indistinguishable here. Retrying a
813
+ // genuine mismatch a couple of times is cheap; not retrying a
814
+ // dropped connection costs the whole update.
815
+ throw new Error('stageBundle returned false');
816
+ }
817
+ return ok;
818
+ }, {
819
+ label: `download v${bundle.version}`,
820
+ attempts: 3
821
+ });
822
+ } catch {
823
+ staged = false;
824
+ }
733
825
  }
734
826
  }
735
827
  unsubscribeProgress();
@@ -789,7 +881,7 @@ export class OtaOrchestrator {
789
881
  releaseId: bundle.releaseId,
790
882
  version: bundle.version
791
883
  });
792
- __DEV__ && logger.debug(`[OTA] Update v${bundle.version} installed successfully!`);
884
+ logger.debug(`[OTA] Update v${bundle.version} installed successfully!`);
793
885
 
794
886
  // Deliver telemetry BEFORE a restart tears the JS runtime down —
795
887
  // an INSTALLED event queued but not sent would vanish with the process.
@@ -835,7 +927,7 @@ export class OtaOrchestrator {
835
927
  * native boot-guard marker. If JS crashes before the timer fires, the
836
928
  * marker stays set and native code reverts on next cold start.
837
929
  */
838
- startBootGuardHeartbeat() {
930
+ startBootGuardHeartbeat(preRead) {
839
931
  if (!NativeScaleBunOta) return;
840
932
 
841
933
  // Cancel any existing timer
@@ -844,20 +936,23 @@ export class OtaOrchestrator {
844
936
  this.healthyTimer = null;
845
937
  }
846
938
  try {
847
- const stateJson = NativeScaleBunOta.getSlotState();
848
- const state = JSON.parse(stateJson);
939
+ // getSlotState() is CONSUME-ON-READ for the revert record, so a caller that
940
+ // has already read it passes the parsed result down rather than letting
941
+ // this read again and swallow a rollback. `null` is a real answer (module
942
+ // absent or payload unreadable) — only `undefined` means "nobody read it".
943
+ const state = preRead !== undefined ? preRead : JSON.parse(NativeScaleBunOta.getSlotState());
849
944
 
850
945
  // Only run the heartbeat if boot-guard marker is present
851
946
  // (meaning we're running from a freshly-applied OTA bundle)
852
- if (!state.bootMarkerPresent) {
947
+ if (!state?.bootMarkerPresent) {
853
948
  return;
854
949
  }
855
950
  const healthyMs = this.bootGuardConfig.healthyAfterMs ?? DEFAULT_HEALTHY_AFTER_MS;
856
- __DEV__ && logger.debug(`[OTA] Boot guard active — marking healthy after ${healthyMs}ms`);
951
+ logger.debug(`[OTA] Boot guard active — marking healthy after ${healthyMs}ms`);
857
952
  this.healthyTimer = setTimeout(() => {
858
953
  noThrow(() => {
859
954
  NativeScaleBunOta?.markHealthy();
860
- __DEV__ && logger.info('[OTA] Boot guard cleared — bundle marked healthy ✓');
955
+ logger.info('[OTA] Boot guard cleared — bundle marked healthy ✓');
861
956
  this.healthyTimer = null;
862
957
 
863
958
  // The honest activation signal. INSTALLED is emitted optimistically,
@@ -912,11 +1007,11 @@ export class OtaOrchestrator {
912
1007
  /* the restart below makes cleanup moot */
913
1008
  }
914
1009
  this.resumeRestartArmed = false;
915
- __DEV__ && logger.info('[OTA] App resumed — activating the staged update now');
1010
+ logger.info('[OTA] App resumed — activating the staged update now');
916
1011
  this.restart();
917
1012
  }
918
1013
  });
919
- __DEV__ && logger.debug('[OTA] Update staged — will activate on next resume (ON_NEXT_RESUME)');
1014
+ logger.debug('[OTA] Update staged — will activate on next resume (ON_NEXT_RESUME)');
920
1015
  } catch {
921
1016
  // AppState unavailable (tests, exotic hosts): the update still activates
922
1017
  // on the next cold start, which is the documented fallback behaviour.
@@ -936,7 +1031,7 @@ export class OtaOrchestrator {
936
1031
  */
937
1032
  restart() {
938
1033
  noThrow(() => {
939
- __DEV__ && logger.info('[OTA] Triggering app restart…');
1034
+ logger.info('[OTA] Triggering app restart…');
940
1035
  if (NativeScaleBunOta && typeof NativeScaleBunOta.restartApp === 'function') {
941
1036
  NativeScaleBunOta.restartApp();
942
1037
  } else if (DevSettings && typeof DevSettings.reload === 'function') {
@@ -954,6 +954,69 @@ export class SessionManager {
954
954
  state_status: stateStatus,
955
955
  ui: details?.ui,
956
956
  target_id: details?.targetId,
957
+ /**
958
+ * RN-6 — shipped with the row so the join is auditable instead of assumed.
959
+ *
960
+ * Without it, an interaction with no `target_id` is ambiguous in the worst way:
961
+ * it could mean the control genuinely had no identity, or that the correlation
962
+ * refused and the identity exists but could not be attributed. Those are
963
+ * different defects with different owners, and the accounting lane cannot give
964
+ * an honest unplaceable REASON without knowing which.
965
+ *
966
+ * Undefined on the JS-fallback path (no native event, so nothing was joined),
967
+ * which is itself the correct answer there rather than a fabricated 'exact'.
968
+ */
969
+ correlation: details?.correlation,
970
+ correlation_delta_ms: details?.correlationDeltaMs,
971
+ correlation_runner_up_delta_ms: details?.correlationRunnerUpDeltaMs,
972
+ /**
973
+ * RN-1/RN-2 — stored alongside `target_id` so a reader can tell an authored
974
+ * identifier from a structural inference. 'path' means the app authored no testID
975
+ * and the identity was derived from component names, which is useful but will
976
+ * change under refactoring; a dashboard that presents the two identically invites
977
+ * someone to trust a key that is about to move.
978
+ */
979
+ target_source: details?.targetSource,
980
+ /**
981
+ * RN-8 — stored so "13.8% of interactions have a screen" becomes answerable.
982
+ * 'none' means the app instrumented nothing the SDK could see; 'manual' means a
983
+ * <ScaleBunScreen> beacon; 'navigation' means the React Navigation hook. Three
984
+ * different owners behind one number that today has none.
985
+ */
986
+ screen_source: details?.screenSource,
987
+ /**
988
+ * RN-13 — what makes "the heat is on this control" checkable instead of assumed.
989
+ * Absent when the control could not be measured synchronously, which is a fact worth
990
+ * storing: an unmeasurable control and a mis-placed tap are different findings.
991
+ */
992
+ target_rect: details?.targetRect,
993
+ /**
994
+ * RN-3 — the backend already reads scrollX/scrollY off the payload into the fact
995
+ * table's own columns (it has done since the web SDK, which sends `sx`/`sy`), so
996
+ * these need no migration. Mobile simply never sent them.
997
+ *
998
+ * `scroll_source` says whether an offset was available at all: 'none' means the app
999
+ * uses a plain <ScrollView> rather than the SDK's wrapper, which is a coverage gap in
1000
+ * the integration and not a tap at the top of a list.
1001
+ */
1002
+ scrollX: details?.scrollX,
1003
+ scrollY: details?.scrollY,
1004
+ scroll_source: details?.scrollSource,
1005
+ /**
1006
+ * RN-4 — what stops a recycled native view merging two different rows into one
1007
+ * hotspot. The key is the app's own keyExtractor output, so it is stable across
1008
+ * scrolling and across sessions; the index is carried so a recycled slot (same index,
1009
+ * different key) can be proven rather than inferred.
1010
+ */
1011
+ item_key: details?.itemKey,
1012
+ item_index: details?.itemIndex,
1013
+ /**
1014
+ * RN-15 — where in the control, not just which control. Turns "they tapped Add to
1015
+ * Cart" into "they tapped its left edge", which is what tells you whether a hit area
1016
+ * is right. Outside 0..1 is a hitSlop tap and is kept as such.
1017
+ */
1018
+ local_u: details?.localU,
1019
+ local_v: details?.localV,
957
1020
  gestureType,
958
1021
  x: details?.x,
959
1022
  y: details?.y,