@scalebun/react-native 1.10.6 → 1.11.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 (75) hide show
  1. package/android/src/main/java/com/scalebun/replaysdk/tracking/InteractionTracker.kt +25 -24
  2. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +75 -0
  3. package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +12 -0
  4. package/dist/scalebun.full.js +653 -223
  5. package/dist/scalebun.slim.js +652 -222
  6. package/ios/Capture/InteractionTracker.swift +8 -4
  7. package/ios/Ota/ScaleBunOtaBridge.mm +6 -0
  8. package/ios/Ota/ScaleBunOtaModule.swift +67 -0
  9. package/lib/commonjs/analytics/EventTracker.js +5 -5
  10. package/lib/commonjs/analytics/automaticEvents.js +3 -2
  11. package/lib/commonjs/core/config/schema.js +34 -1
  12. package/lib/commonjs/core/constants/version.js +7 -2
  13. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +80 -79
  14. package/lib/commonjs/features/journey/interactionProtocol.js +47 -0
  15. package/lib/commonjs/features/journey/uiState.js +94 -0
  16. package/lib/commonjs/features/ota/crypto/builtinVerifier.js +248 -0
  17. package/lib/commonjs/features/ota/crypto/loadEd25519.js +40 -0
  18. package/lib/commonjs/features/ota/crypto/loadSha512.js +40 -0
  19. package/lib/commonjs/features/ota/crypto/nativeVerifier.js +121 -0
  20. package/lib/commonjs/features/ota/signature.js +87 -27
  21. package/lib/commonjs/features/session/JourneyEventPipeline.js +6 -5
  22. package/lib/commonjs/features/session/SessionManager.js +37 -38
  23. package/lib/commonjs/metro/serializerCompose.js +32 -0
  24. package/lib/commonjs/public/ScaleBunFacade.js +74 -11
  25. package/lib/module/analytics/EventTracker.js +5 -5
  26. package/lib/module/analytics/automaticEvents.js +3 -2
  27. package/lib/module/core/config/schema.js +34 -1
  28. package/lib/module/core/constants/version.js +7 -2
  29. package/lib/module/features/journey/ScaleBunDebugRoot.js +80 -79
  30. package/lib/module/features/journey/interactionProtocol.js +38 -0
  31. package/lib/module/features/journey/uiState.js +86 -0
  32. package/lib/module/features/ota/crypto/builtinVerifier.js +240 -0
  33. package/lib/module/features/ota/crypto/loadEd25519.js +34 -0
  34. package/lib/module/features/ota/crypto/loadSha512.js +34 -0
  35. package/lib/module/features/ota/crypto/nativeVerifier.js +113 -0
  36. package/lib/module/features/ota/signature.js +87 -27
  37. package/lib/module/features/session/JourneyEventPipeline.js +6 -5
  38. package/lib/module/features/session/SessionManager.js +37 -38
  39. package/lib/module/metro/serializerCompose.js +32 -0
  40. package/lib/module/public/ScaleBunFacade.js +74 -11
  41. package/lib/typescript/analytics/EventTracker.d.ts +1 -1
  42. package/lib/typescript/analytics/automaticEvents.d.ts +3 -1
  43. package/lib/typescript/core/config/schema.d.ts +2 -0
  44. package/lib/typescript/core/constants/version.d.ts +7 -2
  45. package/lib/typescript/features/journey/interactionProtocol.d.ts +21 -0
  46. package/lib/typescript/features/journey/uiState.d.ts +53 -0
  47. package/lib/typescript/features/ota/OtaTypes.d.ts +50 -0
  48. package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +53 -0
  49. package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +30 -0
  50. package/lib/typescript/features/ota/crypto/loadSha512.d.ts +15 -0
  51. package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +35 -0
  52. package/lib/typescript/features/ota/signature.d.ts +22 -7
  53. package/lib/typescript/features/session/JourneyEventPipeline.d.ts +1 -0
  54. package/lib/typescript/features/session/SessionManager.d.ts +15 -10
  55. package/lib/typescript/public/ScaleBunFacade.d.ts +35 -6
  56. package/lib/typescript/specs/NativeScaleBunOta.d.ts +23 -0
  57. package/package.json +19 -3
  58. package/src/analytics/EventTracker.ts +5 -5
  59. package/src/analytics/automaticEvents.ts +4 -0
  60. package/src/core/config/schema.ts +30 -3
  61. package/src/core/constants/version.ts +7 -2
  62. package/src/features/journey/ScaleBunDebugRoot.tsx +96 -75
  63. package/src/features/journey/interactionProtocol.ts +65 -0
  64. package/src/features/journey/uiState.ts +89 -0
  65. package/src/features/ota/OtaTypes.ts +51 -0
  66. package/src/features/ota/crypto/builtinVerifier.ts +257 -0
  67. package/src/features/ota/crypto/loadEd25519.ts +41 -0
  68. package/src/features/ota/crypto/loadSha512.ts +35 -0
  69. package/src/features/ota/crypto/nativeVerifier.ts +117 -0
  70. package/src/features/ota/signature.ts +108 -25
  71. package/src/features/session/JourneyEventPipeline.ts +7 -5
  72. package/src/features/session/SessionManager.ts +75 -38
  73. package/src/metro/serializerCompose.ts +38 -2
  74. package/src/public/ScaleBunFacade.ts +87 -13
  75. package/src/specs/NativeScaleBunOta.ts +24 -0
@@ -6,6 +6,8 @@ Object.defineProperty(exports, "__esModule", {
6
6
  exports._resetSignatureWarnings = _resetSignatureWarnings;
7
7
  exports.verifyBundleSignature = verifyBundleSignature;
8
8
  var _internalLogger = require("../../core/logger/internalLogger");
9
+ var _builtinVerifier = require("./crypto/builtinVerifier");
10
+ var _nativeVerifier = require("./crypto/nativeVerifier");
9
11
  /**
10
12
  * Bundle signature verification (OTA-03).
11
13
  *
@@ -19,10 +21,17 @@ var _internalLogger = require("../../core/logger/internalLogger");
19
21
  * download and nothing else. On a platform whose entire purpose is remote code
20
22
  * delivery, that is the control that matters most.
21
23
  *
22
- * WHY IT IS SHAPED LIKE THIS. The SDK ships zero third-party runtime
23
- * dependencies, and React Native has no built-in ed25519. So verification is
24
- * delegated to a host-provided verifier when one is installed, and the SDK's job
25
- * is to decide unambiguouslywhat happens when there is not one.
24
+ * WHY IT IS SHAPED LIKE THIS. React Native has no built-in ed25519, so the
25
+ * verification primitive has to come from somewhere. It is resolved in order:
26
+ * a host-supplied verifier, else the built-in one assembled from the OPTIONAL
27
+ * `@noble/ed25519` + `@noble/hashes` peers, else nothing and the SDK's job is
28
+ * to decide, unambiguously, what happens in that last case.
29
+ *
30
+ * The built-in path exists because requiring every adopter to hand-write a
31
+ * verifier put the most security-critical operation in the product in code the
32
+ * SDK could neither test nor audit, and made a routine dependency bump able to
33
+ * silently stop all updates. See `crypto/builtinVerifier.ts`. Signing stays
34
+ * opt-in, and apps that never adopt it carry no curve arithmetic.
26
35
  *
27
36
  * THE POLICY, which is the important part:
28
37
  *
@@ -52,10 +61,16 @@ let warnedNotConfigured = false;
52
61
  * different operational events and must not collapse into one.
53
62
  */
54
63
  async function verifyBundleSignature(bundleSha256, signature, config) {
55
- const publicKey = config?.publicKey;
64
+ const rawKey = config?.publicKey;
65
+
66
+ // Normalize to a list. A single non-empty string is the pre-rotation config
67
+ // shape and behaves exactly as before; an array pins several keys at once.
68
+ const keys = (Array.isArray(rawKey) ? rawKey : rawKey ? [rawKey] : []).filter(k => typeof k === 'string' && k.trim().length > 0);
56
69
 
57
- // Signing not adopted by this app — nothing to enforce.
58
- if (!publicKey) {
70
+ // Signing not adopted by this app — nothing to enforce. Parity note: a
71
+ // falsy single value (undefined, '') has always meant "not configured" and
72
+ // still does.
73
+ if (rawKey === undefined || rawKey === '' || rawKey === null) {
59
74
  if (!warnedNotConfigured) {
60
75
  warnedNotConfigured = true;
61
76
  _internalLogger.logger.warn('[OTA] No signing public key configured — bundles are accepted on SHA-256 ' + 'integrity alone. Configure `ota.publicSigningKey` to enforce authenticity.');
@@ -66,6 +81,19 @@ async function verifyBundleSignature(bundleSha256, signature, config) {
66
81
  };
67
82
  }
68
83
 
84
+ // The host PROVIDED a key config that resolves to zero usable keys — an
85
+ // empty array, or an array of blank strings. That is a broken attempt to
86
+ // enable signing, not an absence of one, and the two must not collapse:
87
+ // treating `publicSigningKeys: []` as "not configured" would let a config
88
+ // mistake silently downgrade an app to unverified installs. Fail closed.
89
+ if (keys.length === 0) {
90
+ _internalLogger.logger.error('[OTA] Signing key config resolves to zero usable keys (empty array or blank ' + 'strings) — refusing to stage. Remove the config to disable signing, or pin ' + 'at least one real key.');
91
+ return {
92
+ ok: false,
93
+ reason: 'no_keys'
94
+ };
95
+ }
96
+
69
97
  // The app opted in, so a bundle without a signature is a refusal, not a pass.
70
98
  if (!signature) {
71
99
  _internalLogger.logger.error('[OTA] Bundle has no signature but a signing key is configured — refusing to stage.');
@@ -74,38 +102,70 @@ async function verifyBundleSignature(bundleSha256, signature, config) {
74
102
  reason: 'missing_signature'
75
103
  };
76
104
  }
77
- if (typeof config?.verifier !== 'function') {
78
- _internalLogger.logger.error('[OTA] A signing key is configured but no signature verifier is available — ' + 'refusing to stage. Provide `ota.verifySignature` so signatures can be checked.');
105
+
106
+ // Resolution order, and the order matters:
107
+ // 1. A host-supplied verifier ALWAYS wins. A team with its own crypto
108
+ // policy must be able to override whatever the SDK would otherwise pick.
109
+ // 2. Otherwise the NATIVE verifier (CryptoKit / Android 13+ platform
110
+ // ed25519). Preferred over the JS one because it runs outside the
111
+ // bundle it protects and needs no optional peers; on OS levels without
112
+ // ed25519 it delegates to the builtin internally.
113
+ // 3. Otherwise the built-in @noble verifier, if the optional peers are
114
+ // installed. This is the path that removes ~100 lines of hand-written
115
+ // crypto plumbing from every app that adopts signing.
116
+ // 4. Otherwise reject, because a configured key is a request for
117
+ // enforcement and quietly installing unverified code would turn a
118
+ // security feature into a placebo.
119
+ const verifier = typeof config?.verifier === 'function' ? config.verifier : (0, _nativeVerifier.getNativeVerifier)() ?? (0, _builtinVerifier.getBuiltinVerifier)();
120
+ if (!verifier) {
121
+ _internalLogger.logger.error('[OTA] A signing key is configured but no signature verifier is available — ' + 'refusing to stage. Either install the optional peers ' + '`@noble/ed25519` and `@noble/hashes` (the SDK then verifies with no ' + 'further code), or provide your own `ota.verifySignature`.');
79
122
  return {
80
123
  ok: false,
81
124
  reason: 'no_verifier'
82
125
  };
83
126
  }
84
- try {
85
- const valid = await config.verifier({
86
- messageHex: bundleSha256,
87
- signature,
88
- publicKey
89
- });
90
- if (!valid) {
91
- _internalLogger.logger.error('[OTA] Bundle signature is INVALID refusing to stage.');
92
- return {
93
- ok: false,
94
- reason: 'invalid_signature'
127
+
128
+ // Try every pinned key; any single match accepts. The verifier contract is
129
+ // unchanged (one key per call) so host-supplied verifiers keep working.
130
+ //
131
+ // Outcome labelling when nothing matched, and why it matters: if at least
132
+ // one verifier call RAN TO COMPLETION and said no, the bundle failed
133
+ // verification — 'invalid_signature'. Only when EVERY call threw is the
134
+ // machinery itself suspect'verifier_threw'. Collapsing those (e.g. by
135
+ // letting the last key's throw win) would point an investigation at the
136
+ // host's verifier when the actual event was a forged bundle, or vice versa.
137
+ let sawThrow = false;
138
+ let sawCompletion = false;
139
+ for (const key of keys) {
140
+ try {
141
+ const valid = await verifier({
142
+ messageHex: bundleSha256,
143
+ signature,
144
+ publicKey: key
145
+ });
146
+ sawCompletion = true;
147
+ if (valid) return {
148
+ ok: true,
149
+ reason: 'verified'
95
150
  };
151
+ } catch (err) {
152
+ // A throwing verifier is treated as a failed verification for this key,
153
+ // never as a pass — but the remaining keys still get their chance.
154
+ sawThrow = true;
155
+ _internalLogger.logger.error(`[OTA] Signature verifier threw: ${err?.message ?? err}`);
96
156
  }
97
- return {
98
- ok: true,
99
- reason: 'verified'
100
- };
101
- } catch (err) {
102
- // A throwing verifier is treated as a failed verification, never as a pass.
103
- _internalLogger.logger.error(`[OTA] Signature verifier threw: ${err?.message ?? err}`);
157
+ }
158
+ if (sawThrow && !sawCompletion) {
104
159
  return {
105
160
  ok: false,
106
161
  reason: 'verifier_threw'
107
162
  };
108
163
  }
164
+ _internalLogger.logger.error(keys.length > 1 ? `[OTA] Bundle signature is INVALID under all ${keys.length} pinned keys — refusing to stage.` : '[OTA] Bundle signature is INVALID — refusing to stage.');
165
+ return {
166
+ ok: false,
167
+ reason: 'invalid_signature'
168
+ };
109
169
  }
110
170
 
111
171
  /** Test seam — resets the once-per-process "not configured" warning. */
@@ -70,26 +70,27 @@ class JourneyEventPipeline {
70
70
  emit(type, opts) {
71
71
  try {
72
72
  const key = `${type}:${opts?.subtype ?? ''}`;
73
- const now = Date.now();
74
- if (key === this.lastEventKey && now - this.lastEventTs < this.config.dedupeWindowMs) {
73
+ const receivedAt = Date.now();
74
+ const occurredAt = opts?.timestamp ?? receivedAt;
75
+ if (key === this.lastEventKey && receivedAt - this.lastEventTs < this.config.dedupeWindowMs) {
75
76
  // Within dedup window — allow high-confidence native events to
76
77
  // REPLACE a prior low-confidence JS event for the same gesture.
77
78
  // This prevents the race where JS fires first and the pipeline
78
79
  // drops the native event that has more accurate coordinates.
79
80
  const incomingConfidence = opts?.payload?.confidence;
80
81
  if (incomingConfidence === 'high' && this.lastEventConfidence !== 'high') {
81
- this._replaceLastEvent(key, now, opts);
82
+ this._replaceLastEvent(key, receivedAt, opts);
82
83
  }
83
84
  return null;
84
85
  }
85
86
  this.lastEventKey = key;
86
- this.lastEventTs = now;
87
+ this.lastEventTs = receivedAt;
87
88
  this.lastEventConfidence = opts?.payload?.confidence ?? null;
88
89
  const event = {
89
90
  eventId: generateEventId(),
90
91
  sessionId: this.sessionId,
91
92
  journeyId: opts?.journeyId,
92
- ts: now,
93
+ ts: occurredAt,
93
94
  type,
94
95
  subtype: opts?.subtype,
95
96
  severity: opts?.severity ?? inferSeverity(type),
@@ -17,6 +17,8 @@ var _bridgeAdapter = require("../replay/bridge/adapters/bridgeAdapter");
17
17
  var _redaction = require("../../debug/redaction");
18
18
  var _calibrationContext = require("../journey/calibrationContext");
19
19
  var _device = require("../../core/context/device");
20
+ var _automaticEvents = require("../../analytics/automaticEvents");
21
+ var _interactionProtocol = require("../journey/interactionProtocol");
20
22
  /**
21
23
  * ScaleBun SDK — Session Manager
22
24
  *
@@ -35,12 +37,6 @@ var _device = require("../../core/context/device");
35
37
 
36
38
  // ─── Types ──────────────────────────────────────────────────────────────────
37
39
 
38
- /**
39
- * Max analytics-lane heatmap interactions emitted per foreground (analytics
40
- * session) window. Bounds ingest volume now that capture defaults ON; enough to
41
- * resolve hotspot density, the long tail is dropped (drop-newest beyond cap).
42
- */
43
- const HEATMAP_MAX_INTERACTIONS_PER_WINDOW = 200;
44
40
  // ─── Singleton ──────────────────────────────────────────────────────────────
45
41
 
46
42
  let _instance = null;
@@ -56,15 +52,6 @@ class SessionManager {
56
52
  * lane even when no replay recording is active. Additive, opt-in (default off).
57
53
  */
58
54
  _captureInteractionHeatmap = false;
59
- /**
60
- * Sampling cap for the analytics-lane heatmap emission. Now that capture is
61
- * ON by default, an unbounded one-event-per-gesture stream could materially
62
- * inflate ingest volume. We cap emitted interactions per analytics-session
63
- * window (finalize-scoped per foreground): the first N gestures define the
64
- * hotspot shape; the long tail is dropped. Resets when the window changes.
65
- */
66
- _heatmapWindowSessionId = null;
67
- _heatmapWindowCount = 0;
68
55
  session = null;
69
56
  active = false;
70
57
  timeoutTimer = null;
@@ -666,21 +653,11 @@ class SessionManager {
666
653
  * injected sensitive keys (e.g. a label) are stripped before transport.
667
654
  * - Never throws.
668
655
  */
669
- _emitInteractionToAnalytics(gestureType, payload) {
670
- if (!this._captureInteractionHeatmap) return;
671
- if (this.active) return; // recording lane already carries this tap
656
+ _emitInteractionToAnalytics(gestureType, payload, occurredAt, screenName) {
657
+ if (!this._captureInteractionHeatmap) return false;
658
+ if (this.active) return false; // recording lane already carries this tap
672
659
  const adapter = this._backendTransport;
673
- if (!adapter) return;
674
-
675
- // Per-foreground sampling cap. Reset the counter when the analytics
676
- // session window rolls over, then drop anything past the cap.
677
- const windowId = adapter.analyticsSessionId ?? '';
678
- if (windowId !== this._heatmapWindowSessionId) {
679
- this._heatmapWindowSessionId = windowId;
680
- this._heatmapWindowCount = 0;
681
- }
682
- if (this._heatmapWindowCount >= HEATMAP_MAX_INTERACTIONS_PER_WINDOW) return;
683
- this._heatmapWindowCount++;
660
+ if (!adapter) return false;
684
661
  try {
685
662
  // Normalize coords in place (same logic as the recording lane).
686
663
  this._normalizeInteractionPayload(payload);
@@ -707,16 +684,18 @@ class SessionManager {
707
684
  eventId: (0, _sessionId.generateEventId)(),
708
685
  // sessionId is (re)stamped by the analytics lane at flush time.
709
686
  sessionId: adapter.analyticsSessionId ?? '',
710
- ts: Date.now(),
687
+ ts: occurredAt,
711
688
  type: 'USER_ACTION',
712
689
  subtype: `gesture:${gestureType}`,
713
- screen: this._lastKnownScreen ?? undefined,
690
+ screen: screenName ?? this._lastKnownScreen ?? undefined,
714
691
  payload: safePayload,
715
692
  source: 'user'
716
693
  };
717
694
  adapter.trackEvent(event);
695
+ return true;
718
696
  } catch (err) {
719
697
  _internalLogger.logger.error('[SessionManager] heatmap analytics emit failed:', err);
698
+ return false;
720
699
  }
721
700
  }
722
701
 
@@ -936,13 +915,14 @@ class SessionManager {
936
915
  * Notify of a user interaction. Called by ScaleBunDebugRoot touch handlers.
937
916
  * Also triggers frame capture for desktop-initiated recordings.
938
917
  */
939
- onUserAction(subtype, payload) {
918
+ onUserAction(subtype, payload, context) {
940
919
  if (!this.active) return; // Defense-in-depth: no emission when recording is OFF
941
920
  this.emitEvent('USER_ACTION', {
942
921
  subtype,
943
- screen: this._lastKnownScreen ?? undefined,
922
+ screen: context?.screen ?? this._lastKnownScreen ?? undefined,
944
923
  payload,
945
- source: 'user'
924
+ source: 'user',
925
+ timestamp: context?.timestamp
946
926
  });
947
927
  // NOTE: Do NOT call captureManager.onInteraction() here.
948
928
  // emitEvent() already triggers onInteraction() for USER_ACTION events (line ~405).
@@ -957,7 +937,8 @@ class SessionManager {
957
937
  // Exact-duplicate suppression — see _lastGestureSig. Signature is the gesture type plus
958
938
  // raw coordinates verbatim; String() keeps undefined coords distinct from 0 (a payload
959
939
  // with no coords never collides with a real origin tap).
960
- const dedupSig = `${gestureType}|${String(details?.x)}|${String(details?.y)}|${String(details?.endX)}|${String(details?.endY)}`;
940
+ const suppliedInteractionId = details?.interactionId;
941
+ const dedupSig = suppliedInteractionId ? `id:${suppliedInteractionId}` : `${gestureType}|${String(details?.x)}|${String(details?.y)}|${String(details?.endX)}|${String(details?.endY)}`;
961
942
  const nowTs = Date.now();
962
943
  if (dedupSig === this._lastGestureSig && nowTs - this._lastGestureTs <= SessionManager.GESTURE_DEDUP_WINDOW_MS) {
963
944
  this._lastGestureTs = nowTs; // a burst of 3 stays suppressed even if gaps chain past the window
@@ -969,7 +950,15 @@ class SessionManager {
969
950
  // Build the canonical gesture payload ONCE so the recording lane and the
970
951
  // (additive) analytics lane carry byte-identical keys (normalizedX/Y,
971
952
  // gestureType, etc.). Same object shape that was previously inlined.
953
+ const interactionId = suppliedInteractionId ?? (0, _interactionProtocol.generateInteractionId)();
954
+ const occurredAt = details?.occurredAt ?? Date.now();
955
+ const stateStatus = details?.stateStatus ?? (details?.ui ? 'captured_nonempty' : 'not_captured');
972
956
  const payload = {
957
+ interaction_id: interactionId,
958
+ interaction_protocol: details?.interactionProtocol ?? _interactionProtocol.INTERACTION_PROTOCOL_VERSION,
959
+ state_status: stateStatus,
960
+ ui: details?.ui,
961
+ target_id: details?.targetId,
973
962
  gestureType,
974
963
  x: details?.x,
975
964
  y: details?.y,
@@ -1014,9 +1003,16 @@ class SessionManager {
1014
1003
  // when the flag is OFF, no backend transport is attached, or recording is
1015
1004
  // active (the recording path below already carries this tap). Uses a fresh
1016
1005
  // payload clone so analytics normalization never mutates the recording one.
1017
- this._emitInteractionToAnalytics(gestureType, {
1006
+ const analyticsReplayCarrier = this._emitInteractionToAnalytics(gestureType, {
1018
1007
  ...payload
1019
- });
1008
+ }, occurredAt, details?.screenName);
1009
+ const replayCarrier = !!this._backendTransport && (this.active || analyticsReplayCarrier);
1010
+ if (details?.emitAutomaticAnalytics) {
1011
+ try {
1012
+ const safePayload = (0, _redaction.redactBody)(payload);
1013
+ (0, _automaticEvents.emitAutomaticEvent)('element_interacted', (0, _interactionProtocol.automaticInteractionProperties)(safePayload, details?.screenName ?? this._lastKnownScreen ?? undefined, replayCarrier), occurredAt);
1014
+ } catch {/* automatic projection must never affect interaction capture */}
1015
+ }
1020
1016
  if (!this.active) return; // Defense-in-depth: no emission when recording is OFF
1021
1017
  // The subtype becomes `ReplayEvent.label`, which is what the Events explorer GROUPS BY. Naming
1022
1018
  // the tapped control here is what splits taps per control instead of collapsing every tap in the
@@ -1026,7 +1022,10 @@ class SessionManager {
1026
1022
  // ⚠️ `gestureType` also travels in the payload, and the backend's `resolveGesture` reads THAT
1027
1023
  // first — so enriching the label cannot change heatmap gesture classification.
1028
1024
  const target = typeof details?.target === 'string' ? details.target.trim() : '';
1029
- this.onUserAction(target ? `gesture:${gestureType} · ${target}` : `gesture:${gestureType}`, payload);
1025
+ this.onUserAction(target ? `gesture:${gestureType} · ${target}` : `gesture:${gestureType}`, payload, {
1026
+ screen: details?.screenName,
1027
+ timestamp: occurredAt
1028
+ });
1030
1029
  }
1031
1030
 
1032
1031
  /**
@@ -83,6 +83,9 @@ function discoverArtifacts(packageRoot) {
83
83
  return out;
84
84
  }
85
85
 
86
+ /** One-shot guard so an Expo build does not print the pass-through notice per bundle. */
87
+ let warnedUnknownShape = false;
88
+
86
89
  /**
87
90
  * Build the composing serializer. `hostSerializer` is the app's own customSerializer, if any.
88
91
  */
@@ -94,6 +97,35 @@ function createComposingSerializer(artifacts, internals, hostSerializer) {
94
97
  const hostResult = await hostSerializer(entryPoint, preModules, graph, options);
95
98
  if (typeof hostResult === 'string') {
96
99
  code = hostResult;
100
+ } else if (hostResult && typeof hostResult.code !== 'string') {
101
+ /**
102
+ * A shape this wrapper does not understand — pass it back UNTOUCHED.
103
+ *
104
+ * Expo is the case that matters. `expo/metro-config` installs a
105
+ * serializer returning `{ artifacts: [...] }`, a multi-artifact
106
+ * shape with no top-level `code`. Reading `.code` off it yielded
107
+ * undefined, and returning `{ code: undefined, map }` made every
108
+ * `expo export:embed` die with:
109
+ *
110
+ * Serializer did not return expected format. The project copy
111
+ * of `expo/metro-config` may be out of date.
112
+ *
113
+ * — a message that sends you to upgrade Expo, which is not the
114
+ * problem. The result: `withScaleBun` broke PRODUCTION BUNDLING
115
+ * FOR EVERY EXPO APP, and the SDK's own marketing test app could
116
+ * not build. Found by bundling it.
117
+ *
118
+ * Passing it through costs ScaleBun's source-map composition on
119
+ * Expo (OTA stack traces stay mapped to the bundle rather than to
120
+ * original sources). That is a real loss and strictly better than
121
+ * a build that cannot run at all. Composing INTO an artifact
122
+ * array is the follow-up; it must not be guessed at here.
123
+ */
124
+ if (!warnedUnknownShape) {
125
+ warnedUnknownShape = true;
126
+ console.warn('[ScaleBun/metro] The host serializer returned a shape this SDK does not ' + 'compose (Expo returns { artifacts }). Passing it through unchanged — ' + 'the build is correct, but ScaleBun source-map composition is skipped.');
127
+ }
128
+ return hostResult;
97
129
  } else {
98
130
  code = hostResult.code;
99
131
  try {
@@ -215,12 +215,17 @@ class ScaleBunFacade {
215
215
  /**
216
216
  * Boot the OTA orchestrator when the init config asks for it.
217
217
  *
218
- * `publicSigningKey` is honoured here so signature enforcement is reachable
219
- * from configuration alone. The SDK ships no ed25519 implementation, so a
220
- * host that pins a key must also supply `ota.verifySignature`; pinning a key
221
- * without a verifier is fail-CLOSED by design (signature.ts) an update
222
- * that cannot be verified is not installed. We say that out loud rather than
223
- * letting the app discover it as a silent no-update condition.
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.
224
229
  */
225
230
  _maybeStartOta(rawConfig) {
226
231
  const ota = rawConfig?.ota;
@@ -232,15 +237,34 @@ class ScaleBunFacade {
232
237
  const {
233
238
  otaOrchestrator
234
239
  } = require('../features/ota/OtaOrchestrator');
235
- const publicKey = ota.publicSigningKey;
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);
236
248
  const verifier = ota.verifySignature;
237
- if (publicKey && typeof verifier !== 'function') {
238
- _internalLogger.logger.warn('[ScaleBun] ota.publicSigningKey is set but ota.verifySignature is not a function. ' + 'Signature checking is fail-closed: updates will be REJECTED until a verifier is supplied.');
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
+ }
239
261
  }
240
262
  otaOrchestrator.init({
241
- ...(publicKey ? {
263
+ ...(signingRequested ? {
242
264
  signature: {
243
- publicKey,
265
+ // Single key stays a plain string — the shape every
266
+ // existing consumer and test already handles.
267
+ publicKey: publicKeys.length === 1 ? publicKeys[0] : publicKeys,
244
268
  verifier
245
269
  }
246
270
  } : {}),
@@ -477,6 +501,45 @@ class ScaleBunFacade {
477
501
  }
478
502
 
479
503
  /** Convenience: emit a Phase 1 purchase event (revenue + currency + transaction_id). */
504
+ /**
505
+ * Declare which UI state the user is looking at, so taps are attributed to the surface they
506
+ * happened on rather than averaged across every variant of the screen.
507
+ *
508
+ * ScaleBun.setUiState('filter-sheet', 'open');
509
+ * ScaleBun.setUiState('checkout-step', 'payment');
510
+ *
511
+ * Call it when the state CHANGES — the value is read at tap time, so it only has to be right by
512
+ * the time the next tap lands. On React Native this is the ONLY source of UI state: unlike the web,
513
+ * there is no queryable accessibility tree that says a sheet is up, and guessing would produce a
514
+ * state key that is right on some apps and silently wrong on others.
515
+ *
516
+ * Names and values are identifiers, not content: they become query keys in the dashboard, so a
517
+ * person's name or a cart total does not belong in one. `;`, `:` and `|` are stripped and both
518
+ * halves are capped at 32 characters.
519
+ */
520
+ setUiState(name, value) {
521
+ try {
522
+ const {
523
+ setUiState
524
+ } = require('../features/journey/uiState');
525
+ setUiState(name, value);
526
+ } catch {/* no-throw: a state declaration must never break the host */}
527
+ }
528
+
529
+ /**
530
+ * Stop reporting a UI state dimension — or, with no argument, all of them.
531
+ *
532
+ * Clear on screen unmount. A declaration left behind follows the user onto a surface where it means
533
+ * nothing, and every tap there is filed under a state that was not on screen.
534
+ */
535
+ clearUiState(name) {
536
+ try {
537
+ const {
538
+ clearUiState
539
+ } = require('../features/journey/uiState');
540
+ clearUiState(name);
541
+ } catch {/* no-throw */}
542
+ }
480
543
  trackPurchase(input) {
481
544
  (0, _crashSafe.noThrow)(() => this._eventTracker?.trackPurchase(input));
482
545
  }
@@ -98,7 +98,7 @@ export class EventTracker {
98
98
  this.started = true;
99
99
  if (this.cfg.automaticEventTracking) {
100
100
  this.automaticEventsUnsubscribe = subscribeAutomaticEvents(event => {
101
- this.track(event.name, event.properties);
101
+ this.track(event.name, event.properties, event.timestamp);
102
102
  });
103
103
  }
104
104
  if (this.cfg.autoLifecycleEvents) {
@@ -221,9 +221,9 @@ export class EventTracker {
221
221
 
222
222
  // ─── tracking ────────────────────────────────────────────────────────────
223
223
 
224
- track(eventName, properties) {
224
+ track(eventName, properties, timestamp) {
225
225
  try {
226
- this.enqueue(this.buildEnvelope(eventName, properties));
226
+ this.enqueue(this.buildEnvelope(eventName, properties, timestamp));
227
227
  try {
228
228
  this.cfg.onEvent?.(eventName);
229
229
  } catch {/* no-throw */}
@@ -360,7 +360,7 @@ export class EventTracker {
360
360
 
361
361
  // ─── internals ─────────────────────────────────────────────────────────────
362
362
 
363
- buildEnvelope(eventName, properties) {
363
+ buildEnvelope(eventName, properties, timestamp) {
364
364
  const ctx = this.cfg.context ?? {};
365
365
  let canonicalSessionId;
366
366
  try {
@@ -371,7 +371,7 @@ export class EventTracker {
371
371
  const env = {
372
372
  event_id: uuid(),
373
373
  event_name: eventName,
374
- event_time: Date.now(),
374
+ event_time: timestamp ?? Date.now(),
375
375
  app_id: this.cfg.appId,
376
376
  platform: this.cfg.platform ?? resolveEventPlatform(),
377
377
  installation_id: this.installationId,
@@ -19,10 +19,11 @@ function compactProperties(properties) {
19
19
  }
20
20
  return out;
21
21
  }
22
- export function emitAutomaticEvent(name, properties) {
22
+ export function emitAutomaticEvent(name, properties, timestamp) {
23
23
  const event = {
24
24
  name,
25
- properties: compactProperties(properties)
25
+ properties: compactProperties(properties),
26
+ timestamp
26
27
  };
27
28
  if (listeners.size === 0) {
28
29
  pending.push(event);
@@ -289,6 +289,14 @@ const SHAPE = {
289
289
  kind: 'str',
290
290
  opt: true
291
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
+ },
292
300
  mandatoryBlocksUi: bool(false)
293
301
  }
294
302
  }
@@ -328,7 +336,7 @@ function parseField(field, input, path, issues) {
328
336
  present: true
329
337
  };
330
338
  // Required with no default (matches the base schemas' type errors on undefined).
331
- return fail(field.kind === 'bool' ? 'Expected boolean, received undefined' : field.kind === 'num' ? 'Expected number, received undefined' : field.kind === 'str' ? 'Expected string, received undefined' : field.kind === 'enum' ? "Invalid enum value. Expected " + field.values.map(v => `'${v}'`).join(' | ') : 'Expected object, received undefined');
339
+ return fail(field.kind === 'bool' ? 'Expected boolean, received undefined' : field.kind === 'num' ? 'Expected number, received undefined' : field.kind === 'str' ? 'Expected string, received undefined' : field.kind === 'strArr' ? 'Expected array, received undefined' : field.kind === 'enum' ? "Invalid enum value. Expected " + field.values.map(v => `'${v}'`).join(' | ') : 'Expected object, received undefined');
332
340
  }
333
341
  switch (field.kind) {
334
342
  case 'any':
@@ -354,6 +362,31 @@ function parseField(field, input, path, issues) {
354
362
  value: input,
355
363
  present: true
356
364
  };
365
+ case 'strArr':
366
+ {
367
+ // Mirrors the vendored ArraySchema exactly: element failures are
368
+ // reported at their index and fail the whole field (no partial
369
+ // arrays reach the output).
370
+ if (!Array.isArray(input)) return fail(`Expected array, received ${typeofName(input)}`);
371
+ let allOk = true;
372
+ for (let i = 0; i < input.length; i++) {
373
+ if (typeof input[i] !== 'string') {
374
+ issues.push({
375
+ path: [...path, i],
376
+ message: `Expected string, received ${typeofName(input[i])}`
377
+ });
378
+ allOk = false;
379
+ }
380
+ }
381
+ return allOk ? {
382
+ ok: true,
383
+ value: [...input],
384
+ present: true
385
+ } : {
386
+ ok: false,
387
+ present: true
388
+ };
389
+ }
357
390
  case 'num':
358
391
  if (typeof input !== 'number' || Number.isNaN(input)) {
359
392
  return fail(`Expected number, received ${typeofName(input)}`);
@@ -1,7 +1,12 @@
1
1
  /**
2
2
  * ScaleBun SDK version. Sent with the session-start envelope so the dashboard
3
3
  * can attribute telemetry to the SDK build that produced it.
4
- * Keep in sync with package.json "version".
4
+ * Keep in sync with package.json "version" — `version.test.ts` fails when they drift.
5
+ *
6
+ * Why the test matters: the 1.11.0 bump missed this line, so the build would have reported itself as
7
+ * 1.10.6. Every "is the release live, and on what share of traffic" question is answered from this
8
+ * value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
9
+ * version was introduced to solve.
5
10
  */
6
- export const SDK_VERSION = '1.10.6';
11
+ export const SDK_VERSION = '1.11.0';
7
12
  //# sourceMappingURL=version.js.map