@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
@@ -14,7 +14,6 @@ import { otaEventEmitter } from './OtaEventEmitter';
14
14
  import { getDeviceCountry, prefetchDeviceCountry } from './geoCountry';
15
15
  import { deviceTargetingAttributes } from './deviceAttributes';
16
16
  import { resolveInstallationId } from '../../core/id/installationId';
17
- import { verifyBundleSignature, type SignatureConfig } from './signature';
18
17
  import { detectOtaEnvironment, type OtaEnvironment } from './environment';
19
18
  import { retryWithBackoff, isRetryableNetworkError, HttpStatusError } from './retry';
20
19
  import { createStorageBackend, type StorageBackend } from '../../storage/StorageBackend';
@@ -138,12 +137,12 @@ async function deliverOtaEvents(params: {
138
137
  },
139
138
  );
140
139
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
141
- __DEV__ && logger.debug(`[OTA] Delivered ${items.length} telemetry event(s)`);
140
+ logger.debug(`[OTA] Delivered ${items.length} telemetry event(s)`);
142
141
  } catch (err: any) {
143
142
  // Failed delivery must not fail the update — but the events must not be
144
143
  // lost either: requeue so the next sync retries them.
145
144
  for (const e of events) otaEventEmitter.emit(e);
146
- __DEV__ && logger.debug(`[OTA] Telemetry delivery failed (will retry next sync): ${err?.message}`);
145
+ logger.debug(`[OTA] Telemetry delivery failed (will retry next sync): ${err?.message}`);
147
146
  }
148
147
  }
149
148
 
@@ -189,9 +188,10 @@ export class OtaOrchestrator {
189
188
  return this.currentBundle;
190
189
  }
191
190
  private isRestartRequiredState = false;
191
+ /** Root/jailbreak indicators observed at init; empty when none found. */
192
+ private integrityIndicators: string[] = [];
192
193
  private healthyTimer: ReturnType<typeof setTimeout> | null = null;
193
194
  private bootGuardConfig: BootGuardConfig = {};
194
- private signatureConfig: SignatureConfig | undefined;
195
195
  private environment: OtaEnvironment | null = null;
196
196
  /** One-time boot checks have run in this process (init is idempotent). */
197
197
  private initialized = false;
@@ -200,11 +200,12 @@ export class OtaOrchestrator {
200
200
  /**
201
201
  * Initialize the orchestrator. Checks for boot-guard recovery state.
202
202
  *
203
- * `signature` is optional: an app that has not adopted signing keeps working
204
- * on SHA-256 integrity alone. Once a public key IS configured, a bundle that
205
- * cannot be verified is refused rather than installed (OTA-03).
203
+ * Signature enforcement is no longer a JS concern: signed releases are
204
+ * verified and staged in NATIVE code against RSA keys embedded in the app
205
+ * package (SB-OTA-RSA-SHA256-V1), and unsigned releases keep working on
206
+ * SHA-256 integrity alone. There is nothing for the host to configure here.
206
207
  */
207
- init(config?: BootGuardConfig & { signature?: SignatureConfig }): void {
208
+ init(config?: BootGuardConfig): void {
208
209
  noThrow(() => {
209
210
  // Idempotent: init() runs its one-time checks ONCE per process. Found
210
211
  // live — a host that calls init() again after installing an update (any
@@ -215,7 +216,6 @@ export class OtaOrchestrator {
215
216
  // them. Config is still refreshed so late signature setup works.
216
217
  if (this.initialized) {
217
218
  this.bootGuardConfig = config ?? this.bootGuardConfig;
218
- this.signatureConfig = config?.signature ?? this.signatureConfig;
219
219
  return;
220
220
  }
221
221
 
@@ -249,7 +249,7 @@ export class OtaOrchestrator {
249
249
  // We cannot see which form the host used — the identity check after
250
250
  // restart is what proves it — but naming the right fix up front turns
251
251
  // a confusing silent failure into a one-line correction.
252
- __DEV__ && logger.debug(
252
+ logger.debug(
253
253
  '[OTA] React Native >= 0.82 detected. The only valid Android integration is ' +
254
254
  'getDefaultReactHost(..., jsBundleFilePath = ScaleBunOtaModule.getJSBundleFile(ctx)) — ' +
255
255
  'ReactNativeHost and its getJSBundleFile() override no longer exist.',
@@ -258,9 +258,30 @@ export class OtaOrchestrator {
258
258
 
259
259
  this.initialized = true;
260
260
  this.enabled = true;
261
+
262
+ // Device integrity signal.
263
+ //
264
+ // Reported, not enforced. Root defeats any check made from inside the app,
265
+ // so this is not a boundary — it is context. It tells an operator that a
266
+ // device's OTA slot is writable by other processes, which is what makes the
267
+ // launch-time hash re-check in SlotManager meaningful rather than paranoid.
268
+ // A host app that wants to withhold updates on such devices can read
269
+ // `deviceIntegrityIndicators()` and decide for itself.
270
+ try {
271
+ const raw = NativeScaleBunOta?.getDeviceIntegrity?.();
272
+ this.integrityIndicators = raw ? (JSON.parse(raw) as string[]) : [];
273
+ if (this.integrityIndicators.length > 0) {
274
+ logger.warn(
275
+ `[OTA] Device integrity indicators present: ${this.integrityIndicators.join(', ')}. ` +
276
+ 'Updates still install and are still signature-verified, but an installed ' +
277
+ 'bundle on this device can be modified by other privileged processes.',
278
+ );
279
+ }
280
+ } catch {
281
+ this.integrityIndicators = [];
282
+ }
261
283
  this.bootGuardConfig = config ?? {};
262
- this.signatureConfig = config?.signature;
263
- __DEV__ && logger.debug(
284
+ logger.debug(
264
285
  `[OTA] Orchestrator initialized (RN ${this.environment.rnVersionString ?? 'unknown'}` +
265
286
  `${this.environment.bridgeless ? ', bridgeless' : ''}` +
266
287
  `${this.environment.hermes ? `, Hermes HBC v${this.environment.hermesBytecodeVersion ?? '?'}` : ''})`,
@@ -295,6 +316,22 @@ export class OtaOrchestrator {
295
316
 
296
317
  // Prove the bundle we installed is the bundle that loaded.
297
318
  this.verifyRunningBundleIdentity();
319
+
320
+ // Boot-guard health is a LOCAL fact: this process booted from the staged
321
+ // bundle and did not crash. The heartbeat used to start only on the
322
+ // `action === 'NONE'` branch of sync(), so confirming a good bundle needed
323
+ // a successful network round-trip that returned exactly that answer — a
324
+ // device offline, rate-limited, or still being offered an update could
325
+ // never clear its marker, and the guard reverted a bundle that was working.
326
+ //
327
+ // It runs HERE, last, and is handed the slot state that was already read.
328
+ // Starting it earlier made it call getSlotState() itself, and that read is
329
+ // consume-on-read for the revert record — it swallowed the revert before
330
+ // checkBootGuardRecovery() could name the bundle that failed, so a
331
+ // crash-rollback went unreported. It also read bootGuardConfig before
332
+ // init() had assigned it, so healthyAfterMs silently fell back to the
333
+ // default.
334
+ this.startBootGuardHeartbeat(slotState);
298
335
  });
299
336
  }
300
337
 
@@ -334,7 +371,7 @@ export class OtaOrchestrator {
334
371
  releaseId: record.releaseId ?? undefined,
335
372
  sha256: record.sha256,
336
373
  } as OtaBundlePayload;
337
- __DEV__ && logger.debug(`[OTA] Running bundle v${record.version} (${record.bundleId})`);
374
+ logger.debug(`[OTA] Running bundle v${record.version} (${record.bundleId})`);
338
375
  return;
339
376
  }
340
377
 
@@ -342,7 +379,7 @@ export class OtaOrchestrator {
342
379
  // a reinstall of the app, cleared storage, or a bundle staged by an
343
380
  // older SDK. Report the hash so the server can still recognise it.
344
381
  this.currentBundle = { sha256: current.sha256 } as OtaBundlePayload;
345
- __DEV__ && logger.debug(
382
+ logger.debug(
346
383
  `[OTA] Running an OTA bundle (sha ${String(current.sha256).slice(0, 12)}…) with no local install record.`,
347
384
  );
348
385
  } catch {
@@ -394,7 +431,7 @@ export class OtaOrchestrator {
394
431
  if (!record.identityToken) return;
395
432
 
396
433
  if (running === record.identityToken) {
397
- __DEV__ && logger.debug('[OTA] Install verified — running bundle matches what was installed.');
434
+ logger.debug('[OTA] Install verified — running bundle matches what was installed.');
398
435
  this.retireIdentityToken(record);
399
436
  return;
400
437
  }
@@ -566,6 +603,14 @@ export class OtaOrchestrator {
566
603
  return this.enabled;
567
604
  }
568
605
 
606
+ /**
607
+ * Root/jailbreak indicators detected on this device, as stable reason strings.
608
+ * Empty means nothing was detected — not proof the device is clean.
609
+ */
610
+ deviceIntegrityIndicators(): string[] {
611
+ return [...this.integrityIndicators];
612
+ }
613
+
569
614
  isRestartRequired(): boolean {
570
615
  return this.isRestartRequiredState;
571
616
  }
@@ -595,12 +640,27 @@ export class OtaOrchestrator {
595
640
  // OTA has no bundle for a non-mobile target; claiming Android there would install the wrong one.
596
641
  const mobileOS = resolveMobileOS();
597
642
  if (!mobileOS) throw new Error('OTA is not supported on this platform');
643
+ // SDK trust coverage (CICDPAGE §12): report which trusted key IDs are
644
+ // embedded in this binary so the dashboard can measure key-rotation
645
+ // adoption. Telemetry only — the server never trusts a key a device claims.
646
+ // Defensive: optional native method, never let it break a check.
647
+ let trustedKeyIds: string[] | undefined;
648
+ try {
649
+ const raw = NativeScaleBunOta?.getTrustedKeyIds?.();
650
+ if (raw) {
651
+ const arr = JSON.parse(raw);
652
+ if (Array.isArray(arr) && arr.length) trustedKeyIds = arr.slice(0, 16);
653
+ }
654
+ } catch {
655
+ /* trust reporting is best-effort — never break the update check */
656
+ }
598
657
  const payload: OtaCheckRequest = {
599
658
  appVersion: requestParams.appVersion,
600
659
  platform: mobileOS,
601
660
  installationId: requestParams.installationId,
602
661
  currentBundleId: this.currentBundle?.id,
603
662
  currentBundleHash: this.currentBundle?.sha256,
663
+ trustedKeyIds,
604
664
  channelName: requestParams.channelName,
605
665
  // Host-supplied country wins; otherwise the cached edge-Worker lookup
606
666
  // fills in (see geoCountry.ts). Undefined when neither knows — the
@@ -686,7 +746,7 @@ export class OtaOrchestrator {
686
746
  return { status: 'ERROR', error: 'OTA native module disabled or missing' };
687
747
  }
688
748
 
689
- __DEV__ && logger.debug('[OTA] Sync started…');
749
+ logger.debug('[OTA] Sync started…');
690
750
 
691
751
  // The funnel's denominator, for devices already on an OTA bundle. Emitted
692
752
  // before the request, so a check that fails outright still counts as a
@@ -707,7 +767,7 @@ export class OtaOrchestrator {
707
767
  const checkRes = await this.checkForUpdate(params);
708
768
 
709
769
  if (checkRes.action === 'NONE') {
710
- __DEV__ && logger.debug('[OTA] App is up to date');
770
+ logger.debug('[OTA] App is up to date');
711
771
  // If we booted from an OTA bundle and it's healthy, clear the boot guard
712
772
  this.startBootGuardHeartbeat();
713
773
  return { status: 'UP_TO_DATE' };
@@ -747,35 +807,18 @@ export class OtaOrchestrator {
747
807
  version: bundle.version,
748
808
  });
749
809
 
750
- // ── VERIFY AUTHENTICITY (OTA-03) ─────────────────────────────────────
751
- // Before anything touches the disk. SHA-256 proves the bytes arrived
752
- // intact; only the signature proves they came from you. Checking after
753
- // staging would mean writing unverified code to the slot directory first.
754
- const signatureOutcome = await verifyBundleSignature(
755
- bundle.sha256,
756
- bundle.signature,
757
- this.signatureConfig,
758
- );
759
- if (!signatureOutcome.ok) {
760
- otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
761
- releaseId: bundle.releaseId,
762
- error: `Signature check failed: ${signatureOutcome.reason}`,
763
- version: bundle.version,
764
- });
765
- return {
766
- status: 'ERROR',
767
- error: `Bundle signature check failed (${signatureOutcome.reason})`,
768
- bundle,
769
- };
770
- }
771
-
772
810
  // ── DOWNLOAD ─────────────────────────────────────────────────────────
811
+ // Authenticity is enforced NATIVELY now (see the envelope branch below):
812
+ // SHA-256 proves the bytes arrived intact, and for signed releases the
813
+ // RSA signature — checked in native code against keys embedded in the app
814
+ // package — proves they came from you. JS neither pins keys nor verifies
815
+ // signatures.
773
816
  otaEventEmitter.emitSimple('DOWNLOAD_STARTED', bundle.id, {
774
817
  releaseId: bundle.releaseId,
775
818
  version: bundle.version,
776
819
  });
777
820
  const downloadStart = Date.now();
778
- __DEV__ && logger.debug(`[OTA] Downloading update v${bundle.version}…`);
821
+ logger.debug(`[OTA] Downloading update v${bundle.version}…`);
779
822
 
780
823
  // Live progress: native tick → DOWNLOAD_PROGRESS (feeds useOtaUpdate) →
781
824
  // throttled POST to the live tracker. `bundle.size` is the authoritative
@@ -824,51 +867,102 @@ export class OtaOrchestrator {
824
867
 
825
868
  let staged = false;
826
869
 
827
- // Sprint 7: Try patch first if offered, fall back to full bundle
828
- if (bundle.patch && NativeScaleBunOta.stagePatch) {
829
- __DEV__ && logger.debug(
830
- `[OTA] Patch available (${(bundle.patch.size / 1024).toFixed(0)} KB vs ` +
831
- `${(bundle.size / 1024).toFixed(0)} KB full) trying patch…`,
832
- );
870
+ if (bundle.envelope) {
871
+ // ── NATIVE-ENFORCED VERIFY + STAGE (SB-OTA-RSA-SHA256-V1) ───────────
872
+ // Hand the whole envelope to native code, which verifies the RSA
873
+ // signature against the keys EMBEDDED in the app package, then downloads
874
+ // the artifact, enforces the SIGNED size + SHA-256, and stages it into
875
+ // the `next` slot — all outside JS. JavaScript never stages bytes it has
876
+ // not proven authentic. No patch path here: a signed offer is always a
877
+ // full verified download (native patch is a documented follow-up).
878
+ const envelopeJson = JSON.stringify({ ...bundle.envelope, downloadUrl: bundle.url });
879
+ let verdict: {
880
+ ok?: boolean;
881
+ code?: string;
882
+ message?: string;
883
+ };
833
884
  try {
834
- staged = await NativeScaleBunOta.stagePatch(
835
- bundle.patch.url,
836
- bundle.patch.sha256,
837
- bundle.sha256, // expected full-bundle hash after patching
885
+ const verdictJson = await NativeScaleBunOta.verifyAndStageRelease(envelopeJson);
886
+ verdict = JSON.parse(verdictJson);
887
+ } catch (err: any) {
888
+ // A rejected promise (or an unparseable verdict) is machinery failure,
889
+ // never a valid result — treat it as a hard failure.
890
+ verdict = { ok: false, code: 'verify_stage_threw', message: err?.message ?? String(err) };
891
+ }
892
+
893
+ if (!verdict.ok) {
894
+ unsubscribeProgress();
895
+ postProgress(0, 'FAILED');
896
+ logger.error(
897
+ `[OTA] Native verify/stage refused the release: ${verdict.code ?? 'unknown'}` +
898
+ `${verdict.message ? ` — ${verdict.message}` : ''}`,
838
899
  );
839
- if (staged) {
840
- patchUsed = true;
841
- __DEV__ && logger.debug('[OTA] Patch applied successfully');
842
- } else {
843
- logger.warn('[OTA] Patch staging returned false — falling back to full bundle');
844
- }
845
- } catch (patchErr: any) {
846
- logger.warn(`[OTA] Patch staging failed: ${patchErr.message} — falling back to full bundle`);
900
+ otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
901
+ releaseId: bundle.releaseId,
902
+ // `error` is mapped onto the wire `errorCode` by deliverOtaEvents —
903
+ // carry the stable native verdict code there.
904
+ error: verdict.code ?? 'verify_and_stage_release_failed',
905
+ version: bundle.version,
906
+ });
907
+ return {
908
+ status: 'ERROR',
909
+ error: `Release verification failed (${verdict.code ?? 'unknown'})`,
910
+ bundle,
911
+ };
847
912
  }
848
- }
849
913
 
850
- // Full-bundle fallback (or primary path when no patch is available).
851
- // Retried with backoff: on a mobile network a transient drop mid-download
852
- // is the common case, and previously one failure meant waiting for the
853
- // next cold start often hours away, sometimes never.
854
- if (!staged) {
855
- try {
856
- staged = await retryWithBackoff(
857
- async () => {
858
- const ok = await NativeScaleBunOta!.stageBundle(bundle.url, bundle.sha256);
859
- if (!ok) {
860
- // Native returns a bare boolean, so a hash mismatch and a
861
- // dropped connection are indistinguishable here. Retrying a
862
- // genuine mismatch a couple of times is cheap; not retrying a
863
- // dropped connection costs the whole update.
864
- throw new Error('stageBundle returned false');
865
- }
866
- return ok;
867
- },
868
- { label: `download v${bundle.version}`, attempts: 3 },
914
+ staged = true;
915
+ } else {
916
+ // ── LEGACY / UNSIGNED (SHA-256 integrity only) ─────────────────────
917
+ // No envelope means the app embeds no trusted keys and the release is
918
+ // unsigned: keep the pre-RSA behaviour so those apps still update.
919
+
920
+ // Sprint 7: Try patch first if offered, fall back to full bundle
921
+ if (bundle.patch && NativeScaleBunOta.stagePatch) {
922
+ logger.debug(
923
+ `[OTA] Patch available (${(bundle.patch.size / 1024).toFixed(0)} KB vs ` +
924
+ `${(bundle.size / 1024).toFixed(0)} KB full) trying patch…`,
869
925
  );
870
- } catch {
871
- staged = false;
926
+ try {
927
+ staged = await NativeScaleBunOta.stagePatch(
928
+ bundle.patch.url,
929
+ bundle.patch.sha256,
930
+ bundle.sha256, // expected full-bundle hash after patching
931
+ );
932
+ if (staged) {
933
+ patchUsed = true;
934
+ logger.debug('[OTA] Patch applied successfully');
935
+ } else {
936
+ logger.warn('[OTA] Patch staging returned false — falling back to full bundle');
937
+ }
938
+ } catch (patchErr: any) {
939
+ logger.warn(`[OTA] Patch staging failed: ${patchErr.message} — falling back to full bundle`);
940
+ }
941
+ }
942
+
943
+ // Full-bundle fallback (or primary path when no patch is available).
944
+ // Retried with backoff: on a mobile network a transient drop mid-download
945
+ // is the common case, and previously one failure meant waiting for the
946
+ // next cold start — often hours away, sometimes never.
947
+ if (!staged) {
948
+ try {
949
+ staged = await retryWithBackoff(
950
+ async () => {
951
+ const ok = await NativeScaleBunOta!.stageBundle(bundle.url, bundle.sha256);
952
+ if (!ok) {
953
+ // Native returns a bare boolean, so a hash mismatch and a
954
+ // dropped connection are indistinguishable here. Retrying a
955
+ // genuine mismatch a couple of times is cheap; not retrying a
956
+ // dropped connection costs the whole update.
957
+ throw new Error('stageBundle returned false');
958
+ }
959
+ return ok;
960
+ },
961
+ { label: `download v${bundle.version}`, attempts: 3 },
962
+ );
963
+ } catch {
964
+ staged = false;
965
+ }
872
966
  }
873
967
  }
874
968
 
@@ -926,7 +1020,7 @@ export class OtaOrchestrator {
926
1020
  releaseId: bundle.releaseId,
927
1021
  version: bundle.version,
928
1022
  });
929
- __DEV__ && logger.debug(`[OTA] Update v${bundle.version} installed successfully!`);
1023
+ logger.debug(`[OTA] Update v${bundle.version} installed successfully!`);
930
1024
 
931
1025
  // Deliver telemetry BEFORE a restart tears the JS runtime down —
932
1026
  // an INSTALLED event queued but not sent would vanish with the process.
@@ -970,7 +1064,7 @@ export class OtaOrchestrator {
970
1064
  * native boot-guard marker. If JS crashes before the timer fires, the
971
1065
  * marker stays set and native code reverts on next cold start.
972
1066
  */
973
- private startBootGuardHeartbeat(): void {
1067
+ private startBootGuardHeartbeat(preRead?: Record<string, any> | null): void {
974
1068
  if (!NativeScaleBunOta) return;
975
1069
 
976
1070
  // Cancel any existing timer
@@ -980,22 +1074,28 @@ export class OtaOrchestrator {
980
1074
  }
981
1075
 
982
1076
  try {
983
- const stateJson = NativeScaleBunOta.getSlotState();
984
- const state = JSON.parse(stateJson);
1077
+ // getSlotState() is CONSUME-ON-READ for the revert record, so a caller that
1078
+ // has already read it passes the parsed result down rather than letting
1079
+ // this read again and swallow a rollback. `null` is a real answer (module
1080
+ // absent or payload unreadable) — only `undefined` means "nobody read it".
1081
+ const state =
1082
+ preRead !== undefined
1083
+ ? preRead
1084
+ : (JSON.parse(NativeScaleBunOta.getSlotState()) as Record<string, any>);
985
1085
 
986
1086
  // Only run the heartbeat if boot-guard marker is present
987
1087
  // (meaning we're running from a freshly-applied OTA bundle)
988
- if (!state.bootMarkerPresent) {
1088
+ if (!state?.bootMarkerPresent) {
989
1089
  return;
990
1090
  }
991
1091
 
992
1092
  const healthyMs = this.bootGuardConfig.healthyAfterMs ?? DEFAULT_HEALTHY_AFTER_MS;
993
- __DEV__ && logger.debug(`[OTA] Boot guard active — marking healthy after ${healthyMs}ms`);
1093
+ logger.debug(`[OTA] Boot guard active — marking healthy after ${healthyMs}ms`);
994
1094
 
995
1095
  this.healthyTimer = setTimeout(() => {
996
1096
  noThrow(() => {
997
1097
  NativeScaleBunOta?.markHealthy();
998
- __DEV__ && logger.info('[OTA] Boot guard cleared — bundle marked healthy ✓');
1098
+ logger.info('[OTA] Boot guard cleared — bundle marked healthy ✓');
999
1099
  this.healthyTimer = null;
1000
1100
 
1001
1101
  // The honest activation signal. INSTALLED is emitted optimistically,
@@ -1051,11 +1151,11 @@ export class OtaOrchestrator {
1051
1151
  /* the restart below makes cleanup moot */
1052
1152
  }
1053
1153
  this.resumeRestartArmed = false;
1054
- __DEV__ && logger.info('[OTA] App resumed — activating the staged update now');
1154
+ logger.info('[OTA] App resumed — activating the staged update now');
1055
1155
  this.restart();
1056
1156
  }
1057
1157
  });
1058
- __DEV__ && logger.debug('[OTA] Update staged — will activate on next resume (ON_NEXT_RESUME)');
1158
+ logger.debug('[OTA] Update staged — will activate on next resume (ON_NEXT_RESUME)');
1059
1159
  } catch {
1060
1160
  // AppState unavailable (tests, exotic hosts): the update still activates
1061
1161
  // on the next cold start, which is the documented fallback behaviour.
@@ -1075,7 +1175,7 @@ export class OtaOrchestrator {
1075
1175
  */
1076
1176
  restart(): void {
1077
1177
  noThrow(() => {
1078
- __DEV__ && logger.info('[OTA] Triggering app restart…');
1178
+ logger.info('[OTA] Triggering app restart…');
1079
1179
  if (NativeScaleBunOta && typeof NativeScaleBunOta.restartApp === 'function') {
1080
1180
  NativeScaleBunOta.restartApp();
1081
1181
  } else if (DevSettings && typeof DevSettings.reload === 'function') {
@@ -54,6 +54,11 @@ export interface OtaCheckRequest {
54
54
  installationId: string;
55
55
  currentBundleId?: string;
56
56
  currentBundleHash?: string;
57
+ /**
58
+ * Trusted key IDs embedded in this app binary, for SDK trust coverage
59
+ * (CICDPAGE §12). Telemetry only — never a source of server-side trust.
60
+ */
61
+ trustedKeyIds?: string[];
57
62
 
58
63
  // ── C1: channel + targeting context ────────────────────────────────────────
59
64
  // The backend DTO has accepted all of these since Sprint 9. The SDK declared
@@ -112,7 +117,24 @@ export interface OtaBundlePayload {
112
117
  url: string; // presigned GET — short-TTL
113
118
  size: number;
114
119
  sha256: string;
115
- signature?: string; // ed25519 detached signature (hex)
120
+ signature?: string; // ed25519 detached signature (hex) — legacy, unused by native RSA path
121
+ /**
122
+ * SB-OTA-RSA-SHA256-V1 release envelope. Present on signed releases; carries
123
+ * the protocol id, the id of the trusted RSA key the release was signed with,
124
+ * the canonical signed payload, and its signature. When present, the SDK hands
125
+ * the whole envelope to the native module (`verifyAndStageRelease`), which
126
+ * verifies the signature against the RSA public keys EMBEDDED in the app
127
+ * package and then downloads + integrity-checks + stages the artifact — JS
128
+ * never stages bytes it has not proven authentic, and never pins or verifies a
129
+ * key itself. Absent on legacy/unsigned releases, which fall back to
130
+ * `stageBundle` on SHA-256 integrity alone.
131
+ */
132
+ envelope?: {
133
+ protocol: string;
134
+ keyId: string;
135
+ signedPayload: string;
136
+ signature: string;
137
+ };
116
138
  isMandatory: boolean;
117
139
  /**
118
140
  * How to activate the bundle after install:
@@ -1105,6 +1105,56 @@ export class SessionManager {
1105
1105
  targetId?: string;
1106
1106
  screenName?: string;
1107
1107
  emitAutomaticAnalytics?: boolean;
1108
+ /**
1109
+ * RN-6 — the native↔JS join's own verdict, and it MUST be declared here.
1110
+ *
1111
+ * This signature ENUMERATES its fields rather than spreading, and the only caller
1112
+ * (ScaleBunDebugRoot's native listener) invokes it through `(sm as any)`. So a field
1113
+ * added at the call site and not added here is dropped in silence, with no type
1114
+ * error anywhere — which is precisely how these three nearly shipped as a no-op.
1115
+ *
1116
+ * 'exact' = one JS start was clearly closest. 'ambiguous' = two were within
1117
+ * CORRELATION_MARGIN_MS of each other, so target/ui/screenName were deliberately
1118
+ * withheld rather than guessed. 'none' = no start within tolerance, meaning the JS
1119
+ * handler never fired. The last two are kept distinct because they have different
1120
+ * fixes and collapsing them would hide which is happening in production.
1121
+ */
1122
+ correlation?: 'exact' | 'ambiguous' | 'none';
1123
+ correlationDeltaMs?: number | null;
1124
+ correlationRunnerUpDeltaMs?: number | null;
1125
+ /**
1126
+ * RN-1/RN-2 — which kind of identity `targetId` is. Declared here for the same reason as
1127
+ * the correlation fields above: this signature enumerates, and its only caller goes
1128
+ * through `(sm as any)`, so an undeclared field is dropped in silence.
1129
+ */
1130
+ targetSource?: 'testID' | 'path' | 'component' | 'none';
1131
+ /** RN-8: where the screen name came from. Declared here because this signature enumerates. */
1132
+ screenSource?: 'manual' | 'navigation' | 'none';
1133
+ /**
1134
+ * RN-13: the target control's rectangle in capture-base fractions. Declared here because
1135
+ * this signature ENUMERATES — the sixth field to need it, after three correlation fields,
1136
+ * targetSource and screenSource.
1137
+ */
1138
+ targetRect?: { x: number; y: number; width: number; height: number };
1139
+ /**
1140
+ * RN-3: content offset of the nearest ancestor scroller. Declared here because this
1141
+ * signature ENUMERATES — the seventh field to need it.
1142
+ */
1143
+ scrollX?: number;
1144
+ scrollY?: number;
1145
+ scrollSource?: 'wrapper' | 'none';
1146
+ /**
1147
+ * RN-4: list row identity. Declared here because this signature ENUMERATES — the eighth
1148
+ * and ninth fields to need it.
1149
+ */
1150
+ itemKey?: string;
1151
+ itemIndex?: number;
1152
+ /**
1153
+ * RN-15: tap position within the target control. Declared here because this signature
1154
+ * ENUMERATES — the tenth and eleventh fields to need it.
1155
+ */
1156
+ localU?: number;
1157
+ localV?: number;
1108
1158
  }): void {
1109
1159
  // Exact-duplicate suppression — see _lastGestureSig. Signature is the gesture type plus
1110
1160
  // raw coordinates verbatim; String() keeps undefined coords distinct from 0 (a payload
@@ -1136,6 +1186,69 @@ export class SessionManager {
1136
1186
  state_status: stateStatus,
1137
1187
  ui: details?.ui,
1138
1188
  target_id: details?.targetId,
1189
+ /**
1190
+ * RN-6 — shipped with the row so the join is auditable instead of assumed.
1191
+ *
1192
+ * Without it, an interaction with no `target_id` is ambiguous in the worst way:
1193
+ * it could mean the control genuinely had no identity, or that the correlation
1194
+ * refused and the identity exists but could not be attributed. Those are
1195
+ * different defects with different owners, and the accounting lane cannot give
1196
+ * an honest unplaceable REASON without knowing which.
1197
+ *
1198
+ * Undefined on the JS-fallback path (no native event, so nothing was joined),
1199
+ * which is itself the correct answer there rather than a fabricated 'exact'.
1200
+ */
1201
+ correlation: details?.correlation,
1202
+ correlation_delta_ms: details?.correlationDeltaMs,
1203
+ correlation_runner_up_delta_ms: details?.correlationRunnerUpDeltaMs,
1204
+ /**
1205
+ * RN-1/RN-2 — stored alongside `target_id` so a reader can tell an authored
1206
+ * identifier from a structural inference. 'path' means the app authored no testID
1207
+ * and the identity was derived from component names, which is useful but will
1208
+ * change under refactoring; a dashboard that presents the two identically invites
1209
+ * someone to trust a key that is about to move.
1210
+ */
1211
+ target_source: details?.targetSource,
1212
+ /**
1213
+ * RN-8 — stored so "13.8% of interactions have a screen" becomes answerable.
1214
+ * 'none' means the app instrumented nothing the SDK could see; 'manual' means a
1215
+ * <ScaleBunScreen> beacon; 'navigation' means the React Navigation hook. Three
1216
+ * different owners behind one number that today has none.
1217
+ */
1218
+ screen_source: details?.screenSource,
1219
+ /**
1220
+ * RN-13 — what makes "the heat is on this control" checkable instead of assumed.
1221
+ * Absent when the control could not be measured synchronously, which is a fact worth
1222
+ * storing: an unmeasurable control and a mis-placed tap are different findings.
1223
+ */
1224
+ target_rect: details?.targetRect,
1225
+ /**
1226
+ * RN-3 — the backend already reads scrollX/scrollY off the payload into the fact
1227
+ * table's own columns (it has done since the web SDK, which sends `sx`/`sy`), so
1228
+ * these need no migration. Mobile simply never sent them.
1229
+ *
1230
+ * `scroll_source` says whether an offset was available at all: 'none' means the app
1231
+ * uses a plain <ScrollView> rather than the SDK's wrapper, which is a coverage gap in
1232
+ * the integration and not a tap at the top of a list.
1233
+ */
1234
+ scrollX: details?.scrollX,
1235
+ scrollY: details?.scrollY,
1236
+ scroll_source: details?.scrollSource,
1237
+ /**
1238
+ * RN-4 — what stops a recycled native view merging two different rows into one
1239
+ * hotspot. The key is the app's own keyExtractor output, so it is stable across
1240
+ * scrolling and across sessions; the index is carried so a recycled slot (same index,
1241
+ * different key) can be proven rather than inferred.
1242
+ */
1243
+ item_key: details?.itemKey,
1244
+ item_index: details?.itemIndex,
1245
+ /**
1246
+ * RN-15 — where in the control, not just which control. Turns "they tapped Add to
1247
+ * Cart" into "they tapped its left edge", which is what tells you whether a hit area
1248
+ * is right. Outside 0..1 is a hitSlop tap and is kept as such.
1249
+ */
1250
+ local_u: details?.localU,
1251
+ local_v: details?.localV,
1139
1252
  gestureType,
1140
1253
  x: details?.x,
1141
1254
  y: details?.y,