@scalebun/react-native 1.11.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) 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/OtaProtocol.kt +244 -0
  4. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaKeyRegistry.kt +100 -0
  5. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +115 -60
  6. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaReleaseVerifier.kt +129 -0
  7. package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +18 -0
  8. package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +4 -8
  9. package/dist/scalebun.full.js +127 -422
  10. package/dist/scalebun.slim.js +127 -422
  11. package/ios/Ota/OtaProtocol.swift +242 -0
  12. package/ios/Ota/OtaSlotManager.swift +26 -0
  13. package/ios/Ota/ScaleBunOtaBridge.mm +6 -3
  14. package/ios/Ota/ScaleBunOtaKeyRegistry.swift +147 -0
  15. package/ios/Ota/ScaleBunOtaModule.swift +125 -53
  16. package/ios/Ota/ScaleBunOtaReleaseVerifier.swift +115 -0
  17. package/ios/Ota/ScaleBunOtaVerifierTests.swift +92 -0
  18. package/lib/commonjs/core/config/schema.js +3 -12
  19. package/lib/commonjs/core/constants/version.js +1 -1
  20. package/lib/commonjs/features/network/NetworkFeature.js +39 -5
  21. package/lib/commonjs/features/network/thirdParty.js +90 -0
  22. package/lib/commonjs/features/ota/OtaOrchestrator.js +108 -60
  23. package/lib/commonjs/public/ScaleBunFacade.js +5 -41
  24. package/lib/module/core/config/schema.js +3 -12
  25. package/lib/module/core/constants/version.js +1 -1
  26. package/lib/module/features/network/NetworkFeature.js +38 -4
  27. package/lib/module/features/network/thirdParty.js +83 -0
  28. package/lib/module/features/ota/OtaOrchestrator.js +108 -60
  29. package/lib/module/public/ScaleBunFacade.js +5 -41
  30. package/lib/typescript/core/config/schema.d.ts +0 -3
  31. package/lib/typescript/core/constants/version.d.ts +1 -1
  32. package/lib/typescript/features/network/index.d.ts +18 -0
  33. package/lib/typescript/features/network/thirdParty.d.ts +65 -0
  34. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +5 -8
  35. package/lib/typescript/features/ota/OtaTypes.d.ts +22 -0
  36. package/lib/typescript/public/ScaleBunFacade.d.ts +5 -11
  37. package/lib/typescript/specs/NativeScaleBunOta.d.ts +30 -20
  38. package/package.json +3 -13
  39. package/scalebun-react-native.podspec +4 -0
  40. package/src/core/config/schema.ts +3 -9
  41. package/src/core/constants/version.ts +1 -1
  42. package/src/features/network/NetworkFeature.ts +41 -4
  43. package/src/features/network/index.ts +18 -0
  44. package/src/features/network/thirdParty.ts +92 -0
  45. package/src/features/ota/OtaOrchestrator.ts +116 -70
  46. package/src/features/ota/OtaTypes.ts +23 -1
  47. package/src/public/ScaleBunFacade.ts +5 -53
  48. package/src/specs/NativeScaleBunOta.ts +32 -20
  49. package/lib/commonjs/features/ota/crypto/builtinVerifier.js +0 -248
  50. package/lib/commonjs/features/ota/crypto/loadEd25519.js +0 -40
  51. package/lib/commonjs/features/ota/crypto/loadSha512.js +0 -40
  52. package/lib/commonjs/features/ota/crypto/nativeVerifier.js +0 -121
  53. package/lib/commonjs/features/ota/signature.js +0 -175
  54. package/lib/module/features/ota/crypto/builtinVerifier.js +0 -240
  55. package/lib/module/features/ota/crypto/loadEd25519.js +0 -34
  56. package/lib/module/features/ota/crypto/loadSha512.js +0 -34
  57. package/lib/module/features/ota/crypto/nativeVerifier.js +0 -113
  58. package/lib/module/features/ota/signature.js +0 -169
  59. package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +0 -53
  60. package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +0 -30
  61. package/lib/typescript/features/ota/crypto/loadSha512.d.ts +0 -15
  62. package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +0 -35
  63. package/lib/typescript/features/ota/signature.d.ts +0 -81
  64. package/src/features/ota/crypto/builtinVerifier.ts +0 -257
  65. package/src/features/ota/crypto/loadEd25519.ts +0 -41
  66. package/src/features/ota/crypto/loadSha512.ts +0 -35
  67. package/src/features/ota/crypto/nativeVerifier.ts +0 -117
  68. 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");
@@ -155,9 +154,10 @@ class OtaOrchestrator {
155
154
  /**
156
155
  * Initialize the orchestrator. Checks for boot-guard recovery state.
157
156
  *
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).
157
+ * Signature enforcement is no longer a JS concern: signed releases are
158
+ * verified and staged in NATIVE code against RSA keys embedded in the app
159
+ * package (SB-OTA-RSA-SHA256-V1), and unsigned releases keep working on
160
+ * SHA-256 integrity alone. There is nothing for the host to configure here.
161
161
  */
162
162
  init(config) {
163
163
  (0, _crashSafe.noThrow)(() => {
@@ -170,7 +170,6 @@ class OtaOrchestrator {
170
170
  // them. Config is still refreshed so late signature setup works.
171
171
  if (this.initialized) {
172
172
  this.bootGuardConfig = config ?? this.bootGuardConfig;
173
- this.signatureConfig = config?.signature ?? this.signatureConfig;
174
173
  return;
175
174
  }
176
175
  if (!_NativeScaleBunOta.default) {
@@ -205,7 +204,6 @@ class OtaOrchestrator {
205
204
  this.initialized = true;
206
205
  this.enabled = true;
207
206
  this.bootGuardConfig = config ?? {};
208
- this.signatureConfig = config?.signature;
209
207
  __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 ?? '?'}` : ''})`);
210
208
 
211
209
  // ONE read of the slot state, shared by both boot-time consumers.
@@ -485,12 +483,27 @@ class OtaOrchestrator {
485
483
  // OTA has no bundle for a non-mobile target; claiming Android there would install the wrong one.
486
484
  const mobileOS = (0, _device.resolveMobileOS)();
487
485
  if (!mobileOS) throw new Error('OTA is not supported on this platform');
486
+ // SDK trust coverage (CICDPAGE §12): report which trusted key IDs are
487
+ // embedded in this binary so the dashboard can measure key-rotation
488
+ // adoption. Telemetry only — the server never trusts a key a device claims.
489
+ // Defensive: optional native method, never let it break a check.
490
+ let trustedKeyIds;
491
+ try {
492
+ const raw = _NativeScaleBunOta.default?.getTrustedKeyIds?.();
493
+ if (raw) {
494
+ const arr = JSON.parse(raw);
495
+ if (Array.isArray(arr) && arr.length) trustedKeyIds = arr.slice(0, 16);
496
+ }
497
+ } catch {
498
+ /* trust reporting is best-effort — never break the update check */
499
+ }
488
500
  const payload = {
489
501
  appVersion: requestParams.appVersion,
490
502
  platform: mobileOS,
491
503
  installationId: requestParams.installationId,
492
504
  currentBundleId: this.currentBundle?.id,
493
505
  currentBundleHash: this.currentBundle?.sha256,
506
+ trustedKeyIds,
494
507
  channelName: requestParams.channelName,
495
508
  // Host-supplied country wins; otherwise the cached edge-Worker lookup
496
509
  // fills in (see geoCountry.ts). Undefined when neither knows — the
@@ -624,25 +637,12 @@ class OtaOrchestrator {
624
637
  version: bundle.version
625
638
  });
626
639
 
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
640
  // ── DOWNLOAD ─────────────────────────────────────────────────────────
641
+ // Authenticity is enforced NATIVELY now (see the envelope branch below):
642
+ // SHA-256 proves the bytes arrived intact, and for signed releases the
643
+ // RSA signature — checked in native code against keys embedded in the app
644
+ // package — proves they came from you. JS neither pins keys nor verifies
645
+ // signatures.
646
646
  _OtaEventEmitter.otaEventEmitter.emitSimple('DOWNLOAD_STARTED', bundle.id, {
647
647
  releaseId: bundle.releaseId,
648
648
  version: bundle.version
@@ -696,46 +696,94 @@ class OtaOrchestrator {
696
696
  }
697
697
  });
698
698
  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…`);
699
+ if (bundle.envelope) {
700
+ // ── NATIVE-ENFORCED VERIFY + STAGE (SB-OTA-RSA-SHA256-V1) ───────────
701
+ // Hand the whole envelope to native code, which verifies the RSA
702
+ // signature against the keys EMBEDDED in the app package, then downloads
703
+ // the artifact, enforces the SIGNED size + SHA-256, and stages it into
704
+ // the `next` slot — all outside JS. JavaScript never stages bytes it has
705
+ // not proven authentic. No patch path here: a signed offer is always a
706
+ // full verified download (native patch is a documented follow-up).
707
+ const envelopeJson = JSON.stringify({
708
+ ...bundle.envelope,
709
+ downloadUrl: bundle.url
710
+ });
711
+ let verdict;
703
712
  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');
713
+ const verdictJson = await _NativeScaleBunOta.default.verifyAndStageRelease(envelopeJson);
714
+ verdict = JSON.parse(verdictJson);
715
+ } catch (err) {
716
+ // A rejected promise (or an unparseable verdict) is machinery failure,
717
+ // never a valid result — treat it as a hard failure.
718
+ verdict = {
719
+ ok: false,
720
+ code: 'verify_stage_threw',
721
+ message: err?.message ?? String(err)
722
+ };
723
+ }
724
+ if (!verdict.ok) {
725
+ unsubscribeProgress();
726
+ postProgress(0, 'FAILED');
727
+ _internalLogger.logger.error(`[OTA] Native verify/stage refused the release: ${verdict.code ?? 'unknown'}` + `${verdict.message ? ` — ${verdict.message}` : ''}`);
728
+ _OtaEventEmitter.otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
729
+ releaseId: bundle.releaseId,
730
+ // `error` is mapped onto the wire `errorCode` by deliverOtaEvents —
731
+ // carry the stable native verdict code there.
732
+ error: verdict.code ?? 'verify_and_stage_release_failed',
733
+ version: bundle.version
734
+ });
735
+ return {
736
+ status: 'ERROR',
737
+ error: `Release verification failed (${verdict.code ?? 'unknown'})`,
738
+ bundle
739
+ };
740
+ }
741
+ staged = true;
742
+ } else {
743
+ // ── LEGACY / UNSIGNED (SHA-256 integrity only) ─────────────────────
744
+ // No envelope means the app embeds no trusted keys and the release is
745
+ // unsigned: keep the pre-RSA behaviour so those apps still update.
746
+
747
+ // Sprint 7: Try patch first if offered, fall back to full bundle
748
+ if (bundle.patch && _NativeScaleBunOta.default.stagePatch) {
749
+ __DEV__ && _internalLogger.logger.debug(`[OTA] Patch available (${(bundle.patch.size / 1024).toFixed(0)} KB vs ` + `${(bundle.size / 1024).toFixed(0)} KB full) — trying patch…`);
750
+ try {
751
+ staged = await _NativeScaleBunOta.default.stagePatch(bundle.patch.url, bundle.patch.sha256, bundle.sha256 // expected full-bundle hash after patching
752
+ );
753
+ if (staged) {
754
+ patchUsed = true;
755
+ __DEV__ && _internalLogger.logger.debug('[OTA] Patch applied successfully');
756
+ } else {
757
+ _internalLogger.logger.warn('[OTA] Patch staging returned false — falling back to full bundle');
758
+ }
759
+ } catch (patchErr) {
760
+ _internalLogger.logger.warn(`[OTA] Patch staging failed: ${patchErr.message} — falling back to full bundle`);
711
761
  }
712
- } catch (patchErr) {
713
- _internalLogger.logger.warn(`[OTA] Patch staging failed: ${patchErr.message} — falling back to full bundle`);
714
762
  }
715
- }
716
763
 
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');
731
- }
732
- return ok;
733
- }, {
734
- label: `download v${bundle.version}`,
735
- attempts: 3
736
- });
737
- } catch {
738
- staged = false;
764
+ // Full-bundle fallback (or primary path when no patch is available).
765
+ // Retried with backoff: on a mobile network a transient drop mid-download
766
+ // is the common case, and previously one failure meant waiting for the
767
+ // next cold start — often hours away, sometimes never.
768
+ if (!staged) {
769
+ try {
770
+ staged = await (0, _retry.retryWithBackoff)(async () => {
771
+ const ok = await _NativeScaleBunOta.default.stageBundle(bundle.url, bundle.sha256);
772
+ if (!ok) {
773
+ // Native returns a bare boolean, so a hash mismatch and a
774
+ // dropped connection are indistinguishable here. Retrying a
775
+ // genuine mismatch a couple of times is cheap; not retrying a
776
+ // dropped connection costs the whole update.
777
+ throw new Error('stageBundle returned false');
778
+ }
779
+ return ok;
780
+ }, {
781
+ label: `download v${bundle.version}`,
782
+ attempts: 3
783
+ });
784
+ } catch {
785
+ staged = false;
786
+ }
739
787
  }
740
788
  }
741
789
  unsubscribeProgress();
@@ -215,17 +215,11 @@ class ScaleBunFacade {
215
215
  /**
216
216
  * Boot the OTA orchestrator when the init config asks for it.
217
217
  *
218
- * `publicSigningKey` / `publicSigningKeys` are honoured here so signature
219
- * enforcement is reachable from configuration alone. The list form exists
220
- * for key ROTATION: a build pinning [old, new] keeps verifying while the
221
- * server moves to the new key, so replacing a key never needs an emergency
222
- * store release. Both fields merge (deduplicated) into one pinned set.
223
- *
224
- * Verification comes from `ota.verifySignature` when supplied, else the
225
- * built-in @noble-based verifier (optional peers). Pinning keys with
226
- * NEITHER available is fail-CLOSED by design (signature.ts) — an update
227
- * that cannot be verified is not installed. We say that out loud rather
228
- * than letting the app discover it as a silent no-update condition.
218
+ * Signature enforcement is no longer configured from JS. Signed releases are
219
+ * verified and staged in NATIVE code against RSA keys embedded in the app
220
+ * package (SB-OTA-RSA-SHA256-V1); unsigned releases keep working on SHA-256
221
+ * integrity. There are no signing-key or `verifySignature` options to pass
222
+ * through the host embeds its trusted keys natively.
229
223
  */
230
224
  _maybeStartOta(rawConfig) {
231
225
  const ota = rawConfig?.ota;
@@ -237,42 +231,12 @@ class ScaleBunFacade {
237
231
  const {
238
232
  otaOrchestrator
239
233
  } = require('../features/ota/OtaOrchestrator');
240
- const rawSingle = ota.publicSigningKey;
241
- const rawList = ota.publicSigningKeys;
242
- const publicKeys = Array.from(new Set([...(typeof rawSingle === 'string' && rawSingle ? [rawSingle] : []), ...(Array.isArray(rawList) ? rawList : [])].filter(k => typeof k === 'string' && k.trim().length > 0)));
243
- // Signing was REQUESTED if a usable key exists, or the host passed a
244
- // keys array at all — an empty one included. `publicSigningKeys: []`
245
- // is a broken attempt to enable signing, and it must flow through to
246
- // signature.ts's fail-closed handling, not silently disable signing.
247
- const signingRequested = publicKeys.length > 0 || Array.isArray(rawList);
248
- const verifier = ota.verifySignature;
249
- if (signingRequested && typeof verifier !== 'function') {
250
- // A host verifier is optional since the built-in one landed — the
251
- // old unconditional warning here told every correctly-configured
252
- // app that its updates would be rejected. Warn only when neither
253
- // verifier can actually be resolved.
254
- // eslint-disable-next-line @typescript-eslint/no-var-requires
255
- const {
256
- getBuiltinVerifier
257
- } = require('../features/ota/crypto/builtinVerifier');
258
- if (!getBuiltinVerifier()) {
259
- _internalLogger.logger.warn('[ScaleBun] A signing key is pinned but no signature verifier is available. ' + 'Signature checking is fail-closed: updates will be REJECTED until one exists. ' + 'Install the optional peers `@noble/ed25519` + `@noble/hashes` (no further ' + 'code needed), or supply `ota.verifySignature`.');
260
- }
261
- }
262
234
  // `healthyAfterMs` is the field BootGuardConfig actually declares.
263
235
  // This passed `healthyTimeoutMs`, which nothing reads, so a host that
264
236
  // tuned the boot-guard window silently got the 10s default instead.
265
237
  // Both spellings are accepted so the older one keeps working.
266
238
  const healthyAfterMs = typeof ota.healthyAfterMs === 'number' ? ota.healthyAfterMs : typeof ota.healthyTimeoutMs === 'number' ? ota.healthyTimeoutMs : undefined;
267
239
  otaOrchestrator.init({
268
- ...(signingRequested ? {
269
- signature: {
270
- // Single key stays a plain string — the shape every
271
- // existing consumer and test already handles.
272
- publicKey: publicKeys.length === 1 ? publicKeys[0] : publicKeys,
273
- verifier
274
- }
275
- } : {}),
276
240
  ...(healthyAfterMs !== undefined ? {
277
241
  healthyAfterMs
278
242
  } : {})
@@ -285,18 +285,9 @@ const SHAPE = {
285
285
  kind: 'str',
286
286
  opt: true
287
287
  },
288
- publicSigningKey: {
289
- kind: 'str',
290
- opt: true
291
- },
292
- // Rotation support: several pinned keys, any of which may verify a
293
- // bundle. Ship a build pinning [old, new], re-sign server-side with
294
- // new, drop old next release — no emergency store submission when a
295
- // key must be replaced. Merged with publicSigningKey by the facade.
296
- publicSigningKeys: {
297
- kind: 'strArr',
298
- opt: true
299
- },
288
+ // No signing-key fields: signed releases are verified natively against
289
+ // RSA keys embedded in the app package (SB-OTA-RSA-SHA256-V1). JS must
290
+ // not supply a trusted key.
300
291
  mandatoryBlocksUi: bool(false)
301
292
  }
302
293
  }
@@ -8,5 +8,5 @@
8
8
  * value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
9
9
  * version was introduced to solve.
10
10
  */
11
- export const SDK_VERSION = '1.11.1';
11
+ export const SDK_VERSION = '2.0.0';
12
12
  //# sourceMappingURL=version.js.map
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import { installNetworkMonitoring } from './index';
27
+ import { isThirdPartyBeacon } from './thirdParty';
27
28
  import { reportNetworkRequest } from '../replay/integrations/network/networkAdapter';
28
29
  import { SessionManager } from '../session/SessionManager';
29
30
  import { getTrackedScreen } from '../../debug/screenTracking';
@@ -44,12 +45,28 @@ export class NetworkFeature {
44
45
  // self-capture loop. Apply the SAME isSdkInternalUrl filter the replay
45
46
  // branch below already uses so genuine app requests still flow through.
46
47
  const isInternal = isSdkInternalUrl(data.url, this.options.sdkBaseUrl);
47
- if (!isInternal) {
48
+ /**
49
+ * Beacons and denied URLs leave NO trace, in any lane.
50
+ *
51
+ * A network request reaches three places from here: the analytics echo
52
+ * (`$network_request`), the local replay pipeline, and the backend network endpoint
53
+ * that becomes the `network_requests` row the session panel renders. Filtering one
54
+ * and not the others is how a request half-disappears and two counts of the same
55
+ * call stop agreeing -- so this is applied to all three.
56
+ *
57
+ * Kept separate from `isInternal` rather than folded into it: the SDK's own uploads
58
+ * are already excluded from two of the three lanes and DELIBERATELY still reach the
59
+ * local replay pipeline. Widening that here would be an unrelated behaviour change
60
+ * riding along on a filter.
61
+ */
62
+ const isBeaconOrDenied = this.options.captureThirdParty !== true && isThirdPartyBeacon(data.url, this.options.thirdPartyHosts) || matchesDenyUrl(data.url, this.options.denyUrls);
63
+ const isExcluded = isInternal || isBeaconOrDenied;
64
+ if (!isExcluded) {
48
65
  this.trackFn('$network_request', data);
49
66
  }
50
67
 
51
68
  // Bridge the raw network event into the Session Replay pipeline
52
- if (data.type === 'network') {
69
+ if (data.type === 'network' && !isBeaconOrDenied) {
53
70
  reportNetworkRequest({
54
71
  method: data.method,
55
72
  url: data.url,
@@ -72,8 +89,10 @@ export class NetworkFeature {
72
89
  // (correlated by sessionId+timestamp), so we no longer emit
73
90
  // NETWORK_REQUEST_* replay events here (that produced duplicate rows
74
91
  // and count divergence between replay_events and network_requests).
75
- // Skip SDK's own upload calls to prevent recursive event spam.
76
- if (!isInternal) {
92
+ // Skip SDK's own upload calls to prevent recursive event spam, and anything the
93
+ // beacon/denyUrls filter excluded -- this is the lane that becomes the
94
+ // network_requests row a person sees on the session panel.
95
+ if (!isExcluded) {
77
96
  const sessionManager = SessionManager.getExistingInstance();
78
97
  if (sessionManager) {
79
98
  const backendTransport = sessionManager.getBackendTransport();
@@ -107,6 +126,21 @@ export class NetworkFeature {
107
126
  }
108
127
  }
109
128
 
129
+ /**
130
+ * Substring or RegExp, the same rule as the web SDK's `matchUrl`.
131
+ *
132
+ * Spelled out rather than shared because the two SDKs have no common runtime package, and the
133
+ * rule is two lines. What must NOT differ is the beacon list, which is why that one is a
134
+ * byte-identical vendored copy with a hash gate rather than a retyped constant.
135
+ */
136
+ function matchesDenyUrl(url, patterns) {
137
+ if (!patterns || !patterns.length || !url) return false;
138
+ for (const p of patterns) {
139
+ if (typeof p === 'string' ? url.includes(p) : p.test(url)) return true;
140
+ }
141
+ return false;
142
+ }
143
+
110
144
  // Returns true if the URL is a ScaleBun SDK internal endpoint that must not be
111
145
  // captured — doing so would cause recursive network event spam.
112
146
  function isSdkInternalUrl(url, sdkBaseUrl) {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * THIRD-PARTY BEACON HOSTS — the one list, and the one matcher, for every layer that drops them.
3
+ *
4
+ * WHAT THIS IS FOR
5
+ * A session on an instrumented site spends most of its network tab on traffic the app did not
6
+ * meaningfully make: analytics collectors, ad pixels, IP-enrichment calls, tag managers. It is
7
+ * captured, uploaded, stored and rendered, and none of it answers a question anyone asks a session
8
+ * replay. One real sign-in screen was 27 requests, most of them beacons.
9
+ *
10
+ * It is not only clutter. Session health scores `failedRequests` and `slowRequests`, so a tracker
11
+ * killed by an ad blocker (a transport error) or one that is simply slow moves a number that is
12
+ * supposed to describe the APP. That is the part that makes this a correctness fix rather than a
13
+ * tidy-up.
14
+ *
15
+ * WHY HOSTS ONLY, NEVER PATHS
16
+ * The obvious rules from looking at a network tab are `/collect`, `/p`, `/api/v2/pixel`. Every one
17
+ * of them is a path that a customer's own API is allowed to have, and dropping a real API call is
18
+ * far worse than keeping a beacon: the noise is visible and annoying, the missing request is
19
+ * invisible and misleading. So matching is on the HOSTNAME, with a dot boundary, and a request to
20
+ * `/collect` on the app's own origin is kept — correctly.
21
+ *
22
+ * WHY IT IS A SUFFIX LIST AND NOT A REGEX
23
+ * Two reasons, both practical. It is applied inside an SDK whose eager bundle is measured in bytes,
24
+ * and a comma-joined string of suffixes compresses to almost nothing next to an equivalent pattern
25
+ * source. And it has to be COPIED, byte for byte, into the React Native SDK and into the backend's
26
+ * ingest path — a list with no syntax is a list that cannot drift in meaning between three
27
+ * languages' worth of tooling. `third-party:parity` hashes all three copies.
28
+ *
29
+ * ZERO IMPORTS, DELIBERATELY. That is what makes the byte-identical copy possible. If an edit here
30
+ * ever needs an import, the parity gate is the thing that will say so — keep the module pure rather
31
+ * than weakening the gate.
32
+ *
33
+ * WHAT IS DELIBERATELY NOT ON THE LIST
34
+ * The rule is: a host whose ONLY purpose is to receive telemetry. Everything else stays, because a
35
+ * dropped request is invisible and a kept one is merely noisy.
36
+ *
37
+ * - Feature-flag and experiment services (LaunchDarkly, Optimizely, Split). Their RESPONSES
38
+ * change what the app does, so a failed flag fetch is an application incident and the single
39
+ * most useful request in the session.
40
+ * - Chat and support widgets (Intercom, Zendesk, Drift). A broken widget is a broken feature the
41
+ * user can see.
42
+ * - `facebook.com` as a whole. `graph.facebook.com` is an API real apps call; only the pixel
43
+ * delivery hosts are listed.
44
+ * - CDNs of any kind, even ones that mostly serve tags: they also serve application assets, and
45
+ * dropping them would hide a real asset failure.
46
+ *
47
+ * When in doubt, leave it out. `denyUrls` stays the per-app escape hatch in both SDKs, and it can
48
+ * do what this list will not.
49
+ */
50
+
51
+ /**
52
+ * Known telemetry, advertising and enrichment endpoints, as hostname suffixes.
53
+ *
54
+ * One comma-joined string rather than an array: identical meaning, materially smaller once gzipped,
55
+ * and it keeps the copy in three repos a single line to compare by eye.
56
+ */
57
+ export const THIRD_PARTY_BEACON_HOSTS = 'google-analytics.com,googletagmanager.com,analytics.google.com,doubleclick.net,googlesyndication.com,' + 'googleadservices.com,app-measurement.com,facebook.net,analytics.tiktok.com,' + 'ads-twitter.com,analytics.twitter.com,ct.pinterest.com,bat.bing.com,px.ads.linkedin.com,tr.snapchat.com,' + 'criteo.com,criteo.net,taboola.com,outbrain.com,adroll.com,adsrvr.org,quantserve.com,scorecardresearch.com,' + 'hotjar.com,hotjar.io,clarity.ms,fullstory.com,mouseflow.com,crazyegg.com,luckyorange.com,inspectlet.com,' + 'segment.io,mixpanel.com,amplitude.com,heap.io,heapanalytics.com,rudderstack.com,posthog.com,' + 'matomo.cloud,plausible.io,simpleanalytics.com,statcounter.com,' + 'appsflyer.com,adjust.com,branch.io,' + 'ipify.org,ipinfo.io,ipapi.co,ip-api.com,geolocation-db.com,cloudflareinsights.com,' + 'nr-data.net,browser-intake-datadoghq.com,sentry.io,bugsnag.com,rollbar.com';
58
+
59
+ /**
60
+ * True when `url` is addressed to a known beacon host.
61
+ *
62
+ * Deliberately total: an unparseable or relative URL is NOT third party. A relative URL is
63
+ * same-origin by definition — the app's own call — and guessing on garbage input would drop real
64
+ * requests for no benefit.
65
+ */
66
+ export function isThirdPartyBeacon(url, hosts) {
67
+ if (!url) return false;
68
+ let host = '';
69
+ try {
70
+ // A relative URL throws here, which is the answer we want: it is first-party.
71
+ host = new URL(url).hostname.toLowerCase();
72
+ } catch {
73
+ return false;
74
+ }
75
+ if (!host) return false;
76
+ for (const suffix of (hosts ?? THIRD_PARTY_BEACON_HOSTS).split(',')) {
77
+ // Dot boundary, so `notevil-facebook.com` does not match `facebook.com` while
78
+ // `connect.facebook.com` does. The bare-equality arm covers the apex domain itself.
79
+ if (host === suffix || host.endsWith('.' + suffix)) return true;
80
+ }
81
+ return false;
82
+ }
83
+ //# sourceMappingURL=thirdParty.js.map