@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
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Isolated lazy loader for the OPTIONAL `@noble/hashes` SHA-512.
3
+ *
4
+ * Separate file from `loadEd25519` because Metro miscounts a module holding two
5
+ * different-string inline `require()` calls — see the comment there.
6
+ *
7
+ * ed25519 is defined in terms of SHA-512, and React Native has no native
8
+ * SHA-512, so @noble/ed25519 cannot verify anything until a hash provider is
9
+ * wired into it. Which property it expects differs by major version, so the
10
+ * wiring lives in `builtinVerifier`, not here; this module only obtains the
11
+ * function.
12
+ */
13
+
14
+ export function loadSha512() {
15
+ try {
16
+ // The `.js` SUFFIX IS REQUIRED, not stylistic. @noble/hashes v2 declares
17
+ // its exports map with extensions — `"./sha2.js"`, not `"./sha2"` — so
18
+ // the extensionless form misses the map entirely. Node still resolves it
19
+ // by falling back to file-based lookup, which is why it passes here,
20
+ // but Metro prints
21
+ // "Attempted to import … not listed in the exports … Falling back to
22
+ // file-based resolution"
23
+ // on every bundle, and that fallback is exactly what a stricter resolver
24
+ // (or a future Metro) drops. Caught while bundling a real Expo app.
25
+ const mod = require('@noble/hashes/sha2.js');
26
+ const sha512 = mod?.sha512 ?? mod?.default?.sha512;
27
+ // A stubbed module yields undefined here, which correctly reads as
28
+ // "unavailable" rather than throwing deep inside the curve code later.
29
+ return typeof sha512 === 'function' ? sha512 : null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+ //# sourceMappingURL=loadSha512.js.map
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Native ed25519 verifier — platform crypto wrapped as a SignatureVerifier.
3
+ *
4
+ * WHY IT EXISTS. The built-in @noble verifier runs in JS, inside the very
5
+ * bundle it protects: an attacker who lands one malicious bundle can neuter a
6
+ * JS check for every update after it. Platform crypto (CryptoKit on iOS,
7
+ * Android's conscrypt on API 33+) sits outside the bundle's reach — and using
8
+ * it also drops the runtime dependency on the optional @noble peers wherever
9
+ * the OS provides ed25519.
10
+ *
11
+ * WHAT IT DOES NOT FIX, stated plainly: the ORCHESTRATION still lives in JS.
12
+ * A hostile bundle can skip calling any verifier and drive the native staging
13
+ * methods directly. Moving the crypto native shrinks the attack surface (no
14
+ * more tampering with a bundled crypto lib to flip a verdict) but the full
15
+ * close needs native-ENFORCED staging with a natively-pinned key — an
16
+ * architectural change tracked separately, not smuggled into this one.
17
+ *
18
+ * VERDICT CONTRACT (mirrors the spec): the native side resolves
19
+ * 'valid' | 'invalid' | 'unavailable'. 'invalid' is a definitive NO.
20
+ * 'unavailable' (Android < 33, malformed input, machinery failure) means this
21
+ * source cannot answer — the composed verifier below then delegates to the
22
+ * @noble builtin, and when that is absent too it THROWS, which the policy
23
+ * layer converts to a rejection. Every path that cannot verify refuses.
24
+ */
25
+
26
+ import NativeScaleBunOta from '../../../specs/NativeScaleBunOta';
27
+ import { logger } from '../../../core/logger/internalLogger';
28
+ import { decodeSignature, getBuiltinVerifier } from './builtinVerifier';
29
+ const ED25519_PUBLIC_KEY_HEX_CHARS = 64;
30
+
31
+ /** bytes → lowercase hex (the native side takes clean hex only). */
32
+ function bytesToHex(bytes) {
33
+ let out = '';
34
+ for (const b of bytes) out += b.toString(16).padStart(2, '0');
35
+ return out;
36
+ }
37
+
38
+ /** Strict lowercase-hex normalization; null when the value is not hex. */
39
+ function normalizeHex(value, expectedChars) {
40
+ const clean = value.trim().toLowerCase().replace(/^0x/, '');
41
+ if (clean.length !== expectedChars || /[^0-9a-f]/.test(clean)) return null;
42
+ return clean;
43
+ }
44
+ let resolved;
45
+
46
+ /**
47
+ * The native-first verifier, or null when the native module (or its
48
+ * verifyEd25519 method — an app running new JS against an old binary) is
49
+ * absent. Cached like the builtin: module availability does not change at
50
+ * runtime, and this is consulted on every update check.
51
+ */
52
+ export function getNativeVerifier() {
53
+ if (resolved !== undefined) return resolved;
54
+ const native = NativeScaleBunOta;
55
+ if (!native || typeof native.verifyEd25519 !== 'function') {
56
+ // Old binary, Jest, web/SSR — not an error, just not this source.
57
+ resolved = null;
58
+ return null;
59
+ }
60
+ resolved = async ({
61
+ messageHex,
62
+ signature,
63
+ publicKey
64
+ }) => {
65
+ // All decoding and validation happens HERE, in one place, so both
66
+ // platforms' native code only ever sees clean fixed-length hex. The
67
+ // signature may arrive hex or base64 (the wire allows both) — reuse
68
+ // the builtin's decoder rather than growing a second, subtly
69
+ // different one.
70
+ const message = normalizeHex(messageHex, 64);
71
+ const key = normalizeHex(publicKey, ED25519_PUBLIC_KEY_HEX_CHARS);
72
+ const sigBytes = decodeSignature(signature);
73
+ if (!message || !key || !sigBytes) {
74
+ // Same messages-by-symptom philosophy as the builtin: a mistyped
75
+ // key must not present as "every update is a forgery".
76
+ logger.error('[OTA] Signature inputs malformed (bundle hash, signature, or public key) — refusing to stage.');
77
+ return false;
78
+ }
79
+ let verdict;
80
+ try {
81
+ verdict = await native.verifyEd25519(message, bytesToHex(sigBytes), key);
82
+ } catch (err) {
83
+ // A rejected promise is machinery failure, not a forgery verdict.
84
+ logger.warn(`[OTA] Native ed25519 verify failed to run: ${err?.message ?? err}`);
85
+ verdict = 'unavailable';
86
+ }
87
+ if (verdict === 'valid') return true;
88
+ if (verdict === 'invalid') return false;
89
+
90
+ // 'unavailable' (Android < 33) — fall back to the JS verifier so those
91
+ // devices keep verifying rather than losing enforcement.
92
+ const builtin = getBuiltinVerifier();
93
+ if (builtin) {
94
+ return builtin({
95
+ messageHex,
96
+ signature,
97
+ publicKey
98
+ });
99
+ }
100
+ // No source can verify. Throwing (rather than returning false) keeps
101
+ // the policy layer's outcome labels honest: this surfaces as machinery
102
+ // failure with an actionable message, not as "the bundle is forged".
103
+ throw new Error('ed25519 unavailable: this OS has no native implementation (Android < 13) and the ' + 'optional peers @noble/ed25519 + @noble/hashes are not installed.');
104
+ };
105
+ __DEV__ && logger.debug('[OTA] Using native ed25519 verifier (platform crypto).');
106
+ return resolved;
107
+ }
108
+
109
+ /** Test seam — clears the cached resolution. */
110
+ export function _resetNativeVerifier() {
111
+ resolved = undefined;
112
+ }
113
+ //# sourceMappingURL=nativeVerifier.js.map
@@ -1,4 +1,6 @@
1
1
  import { logger } from '../../core/logger/internalLogger';
2
+ import { getBuiltinVerifier } from './crypto/builtinVerifier';
3
+ import { getNativeVerifier } from './crypto/nativeVerifier';
2
4
 
3
5
  /**
4
6
  * Bundle signature verification (OTA-03).
@@ -13,10 +15,17 @@ import { logger } from '../../core/logger/internalLogger';
13
15
  * download and nothing else. On a platform whose entire purpose is remote code
14
16
  * delivery, that is the control that matters most.
15
17
  *
16
- * WHY IT IS SHAPED LIKE THIS. The SDK ships zero third-party runtime
17
- * dependencies, and React Native has no built-in ed25519. So verification is
18
- * delegated to a host-provided verifier when one is installed, and the SDK's job
19
- * is to decide unambiguouslywhat happens when there is not one.
18
+ * WHY IT IS SHAPED LIKE THIS. React Native has no built-in ed25519, so the
19
+ * verification primitive has to come from somewhere. It is resolved in order:
20
+ * a host-supplied verifier, else the built-in one assembled from the OPTIONAL
21
+ * `@noble/ed25519` + `@noble/hashes` peers, else nothing and the SDK's job is
22
+ * to decide, unambiguously, what happens in that last case.
23
+ *
24
+ * The built-in path exists because requiring every adopter to hand-write a
25
+ * verifier put the most security-critical operation in the product in code the
26
+ * SDK could neither test nor audit, and made a routine dependency bump able to
27
+ * silently stop all updates. See `crypto/builtinVerifier.ts`. Signing stays
28
+ * opt-in, and apps that never adopt it carry no curve arithmetic.
20
29
  *
21
30
  * THE POLICY, which is the important part:
22
31
  *
@@ -46,10 +55,16 @@ let warnedNotConfigured = false;
46
55
  * different operational events and must not collapse into one.
47
56
  */
48
57
  export async function verifyBundleSignature(bundleSha256, signature, config) {
49
- const publicKey = config?.publicKey;
58
+ const rawKey = config?.publicKey;
59
+
60
+ // Normalize to a list. A single non-empty string is the pre-rotation config
61
+ // shape and behaves exactly as before; an array pins several keys at once.
62
+ const keys = (Array.isArray(rawKey) ? rawKey : rawKey ? [rawKey] : []).filter(k => typeof k === 'string' && k.trim().length > 0);
50
63
 
51
- // Signing not adopted by this app — nothing to enforce.
52
- if (!publicKey) {
64
+ // Signing not adopted by this app — nothing to enforce. Parity note: a
65
+ // falsy single value (undefined, '') has always meant "not configured" and
66
+ // still does.
67
+ if (rawKey === undefined || rawKey === '' || rawKey === null) {
53
68
  if (!warnedNotConfigured) {
54
69
  warnedNotConfigured = true;
55
70
  logger.warn('[OTA] No signing public key configured — bundles are accepted on SHA-256 ' + 'integrity alone. Configure `ota.publicSigningKey` to enforce authenticity.');
@@ -60,6 +75,19 @@ export async function verifyBundleSignature(bundleSha256, signature, config) {
60
75
  };
61
76
  }
62
77
 
78
+ // The host PROVIDED a key config that resolves to zero usable keys — an
79
+ // empty array, or an array of blank strings. That is a broken attempt to
80
+ // enable signing, not an absence of one, and the two must not collapse:
81
+ // treating `publicSigningKeys: []` as "not configured" would let a config
82
+ // mistake silently downgrade an app to unverified installs. Fail closed.
83
+ if (keys.length === 0) {
84
+ 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.');
85
+ return {
86
+ ok: false,
87
+ reason: 'no_keys'
88
+ };
89
+ }
90
+
63
91
  // The app opted in, so a bundle without a signature is a refusal, not a pass.
64
92
  if (!signature) {
65
93
  logger.error('[OTA] Bundle has no signature but a signing key is configured — refusing to stage.');
@@ -68,38 +96,70 @@ export async function verifyBundleSignature(bundleSha256, signature, config) {
68
96
  reason: 'missing_signature'
69
97
  };
70
98
  }
71
- if (typeof config?.verifier !== 'function') {
72
- 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.');
99
+
100
+ // Resolution order, and the order matters:
101
+ // 1. A host-supplied verifier ALWAYS wins. A team with its own crypto
102
+ // policy must be able to override whatever the SDK would otherwise pick.
103
+ // 2. Otherwise the NATIVE verifier (CryptoKit / Android 13+ platform
104
+ // ed25519). Preferred over the JS one because it runs outside the
105
+ // bundle it protects and needs no optional peers; on OS levels without
106
+ // ed25519 it delegates to the builtin internally.
107
+ // 3. Otherwise the built-in @noble verifier, if the optional peers are
108
+ // installed. This is the path that removes ~100 lines of hand-written
109
+ // crypto plumbing from every app that adopts signing.
110
+ // 4. Otherwise reject, because a configured key is a request for
111
+ // enforcement and quietly installing unverified code would turn a
112
+ // security feature into a placebo.
113
+ const verifier = typeof config?.verifier === 'function' ? config.verifier : getNativeVerifier() ?? getBuiltinVerifier();
114
+ if (!verifier) {
115
+ 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`.');
73
116
  return {
74
117
  ok: false,
75
118
  reason: 'no_verifier'
76
119
  };
77
120
  }
78
- try {
79
- const valid = await config.verifier({
80
- messageHex: bundleSha256,
81
- signature,
82
- publicKey
83
- });
84
- if (!valid) {
85
- logger.error('[OTA] Bundle signature is INVALID refusing to stage.');
86
- return {
87
- ok: false,
88
- reason: 'invalid_signature'
121
+
122
+ // Try every pinned key; any single match accepts. The verifier contract is
123
+ // unchanged (one key per call) so host-supplied verifiers keep working.
124
+ //
125
+ // Outcome labelling when nothing matched, and why it matters: if at least
126
+ // one verifier call RAN TO COMPLETION and said no, the bundle failed
127
+ // verification — 'invalid_signature'. Only when EVERY call threw is the
128
+ // machinery itself suspect'verifier_threw'. Collapsing those (e.g. by
129
+ // letting the last key's throw win) would point an investigation at the
130
+ // host's verifier when the actual event was a forged bundle, or vice versa.
131
+ let sawThrow = false;
132
+ let sawCompletion = false;
133
+ for (const key of keys) {
134
+ try {
135
+ const valid = await verifier({
136
+ messageHex: bundleSha256,
137
+ signature,
138
+ publicKey: key
139
+ });
140
+ sawCompletion = true;
141
+ if (valid) return {
142
+ ok: true,
143
+ reason: 'verified'
89
144
  };
145
+ } catch (err) {
146
+ // A throwing verifier is treated as a failed verification for this key,
147
+ // never as a pass — but the remaining keys still get their chance.
148
+ sawThrow = true;
149
+ logger.error(`[OTA] Signature verifier threw: ${err?.message ?? err}`);
90
150
  }
91
- return {
92
- ok: true,
93
- reason: 'verified'
94
- };
95
- } catch (err) {
96
- // A throwing verifier is treated as a failed verification, never as a pass.
97
- logger.error(`[OTA] Signature verifier threw: ${err?.message ?? err}`);
151
+ }
152
+ if (sawThrow && !sawCompletion) {
98
153
  return {
99
154
  ok: false,
100
155
  reason: 'verifier_threw'
101
156
  };
102
157
  }
158
+ 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.');
159
+ return {
160
+ ok: false,
161
+ reason: 'invalid_signature'
162
+ };
103
163
  }
104
164
 
105
165
  /** Test seam — resets the once-per-process "not configured" warning. */
@@ -65,26 +65,27 @@ export class JourneyEventPipeline {
65
65
  emit(type, opts) {
66
66
  try {
67
67
  const key = `${type}:${opts?.subtype ?? ''}`;
68
- const now = Date.now();
69
- if (key === this.lastEventKey && now - this.lastEventTs < this.config.dedupeWindowMs) {
68
+ const receivedAt = Date.now();
69
+ const occurredAt = opts?.timestamp ?? receivedAt;
70
+ if (key === this.lastEventKey && receivedAt - this.lastEventTs < this.config.dedupeWindowMs) {
70
71
  // Within dedup window — allow high-confidence native events to
71
72
  // REPLACE a prior low-confidence JS event for the same gesture.
72
73
  // This prevents the race where JS fires first and the pipeline
73
74
  // drops the native event that has more accurate coordinates.
74
75
  const incomingConfidence = opts?.payload?.confidence;
75
76
  if (incomingConfidence === 'high' && this.lastEventConfidence !== 'high') {
76
- this._replaceLastEvent(key, now, opts);
77
+ this._replaceLastEvent(key, receivedAt, opts);
77
78
  }
78
79
  return null;
79
80
  }
80
81
  this.lastEventKey = key;
81
- this.lastEventTs = now;
82
+ this.lastEventTs = receivedAt;
82
83
  this.lastEventConfidence = opts?.payload?.confidence ?? null;
83
84
  const event = {
84
85
  eventId: generateEventId(),
85
86
  sessionId: this.sessionId,
86
87
  journeyId: opts?.journeyId,
87
- ts: now,
88
+ ts: occurredAt,
88
89
  type,
89
90
  subtype: opts?.subtype,
90
91
  severity: opts?.severity ?? inferSeverity(type),
@@ -27,15 +27,11 @@ import { bridgeAdapter } from '../replay/bridge/adapters/bridgeAdapter';
27
27
  import { redactBody } from '../../debug/redaction';
28
28
  import { consumeCalibrationTarget } from '../journey/calibrationContext';
29
29
  import { resolvePlatformOS } from '../../core/context/device';
30
+ import { emitAutomaticEvent } from '../../analytics/automaticEvents';
31
+ import { INTERACTION_PROTOCOL_VERSION, automaticInteractionProperties, generateInteractionId } from '../journey/interactionProtocol';
30
32
 
31
33
  // ─── Types ──────────────────────────────────────────────────────────────────
32
34
 
33
- /**
34
- * Max analytics-lane heatmap interactions emitted per foreground (analytics
35
- * session) window. Bounds ingest volume now that capture defaults ON; enough to
36
- * resolve hotspot density, the long tail is dropped (drop-newest beyond cap).
37
- */
38
- const HEATMAP_MAX_INTERACTIONS_PER_WINDOW = 200;
39
35
  // ─── Singleton ──────────────────────────────────────────────────────────────
40
36
 
41
37
  let _instance = null;
@@ -51,15 +47,6 @@ export class SessionManager {
51
47
  * lane even when no replay recording is active. Additive, opt-in (default off).
52
48
  */
53
49
  _captureInteractionHeatmap = false;
54
- /**
55
- * Sampling cap for the analytics-lane heatmap emission. Now that capture is
56
- * ON by default, an unbounded one-event-per-gesture stream could materially
57
- * inflate ingest volume. We cap emitted interactions per analytics-session
58
- * window (finalize-scoped per foreground): the first N gestures define the
59
- * hotspot shape; the long tail is dropped. Resets when the window changes.
60
- */
61
- _heatmapWindowSessionId = null;
62
- _heatmapWindowCount = 0;
63
50
  session = null;
64
51
  active = false;
65
52
  timeoutTimer = null;
@@ -661,21 +648,11 @@ export class SessionManager {
661
648
  * injected sensitive keys (e.g. a label) are stripped before transport.
662
649
  * - Never throws.
663
650
  */
664
- _emitInteractionToAnalytics(gestureType, payload) {
665
- if (!this._captureInteractionHeatmap) return;
666
- if (this.active) return; // recording lane already carries this tap
651
+ _emitInteractionToAnalytics(gestureType, payload, occurredAt, screenName) {
652
+ if (!this._captureInteractionHeatmap) return false;
653
+ if (this.active) return false; // recording lane already carries this tap
667
654
  const adapter = this._backendTransport;
668
- if (!adapter) return;
669
-
670
- // Per-foreground sampling cap. Reset the counter when the analytics
671
- // session window rolls over, then drop anything past the cap.
672
- const windowId = adapter.analyticsSessionId ?? '';
673
- if (windowId !== this._heatmapWindowSessionId) {
674
- this._heatmapWindowSessionId = windowId;
675
- this._heatmapWindowCount = 0;
676
- }
677
- if (this._heatmapWindowCount >= HEATMAP_MAX_INTERACTIONS_PER_WINDOW) return;
678
- this._heatmapWindowCount++;
655
+ if (!adapter) return false;
679
656
  try {
680
657
  // Normalize coords in place (same logic as the recording lane).
681
658
  this._normalizeInteractionPayload(payload);
@@ -702,16 +679,18 @@ export class SessionManager {
702
679
  eventId: generateEventId(),
703
680
  // sessionId is (re)stamped by the analytics lane at flush time.
704
681
  sessionId: adapter.analyticsSessionId ?? '',
705
- ts: Date.now(),
682
+ ts: occurredAt,
706
683
  type: 'USER_ACTION',
707
684
  subtype: `gesture:${gestureType}`,
708
- screen: this._lastKnownScreen ?? undefined,
685
+ screen: screenName ?? this._lastKnownScreen ?? undefined,
709
686
  payload: safePayload,
710
687
  source: 'user'
711
688
  };
712
689
  adapter.trackEvent(event);
690
+ return true;
713
691
  } catch (err) {
714
692
  logger.error('[SessionManager] heatmap analytics emit failed:', err);
693
+ return false;
715
694
  }
716
695
  }
717
696
 
@@ -931,13 +910,14 @@ export class SessionManager {
931
910
  * Notify of a user interaction. Called by ScaleBunDebugRoot touch handlers.
932
911
  * Also triggers frame capture for desktop-initiated recordings.
933
912
  */
934
- onUserAction(subtype, payload) {
913
+ onUserAction(subtype, payload, context) {
935
914
  if (!this.active) return; // Defense-in-depth: no emission when recording is OFF
936
915
  this.emitEvent('USER_ACTION', {
937
916
  subtype,
938
- screen: this._lastKnownScreen ?? undefined,
917
+ screen: context?.screen ?? this._lastKnownScreen ?? undefined,
939
918
  payload,
940
- source: 'user'
919
+ source: 'user',
920
+ timestamp: context?.timestamp
941
921
  });
942
922
  // NOTE: Do NOT call captureManager.onInteraction() here.
943
923
  // emitEvent() already triggers onInteraction() for USER_ACTION events (line ~405).
@@ -952,7 +932,8 @@ export class SessionManager {
952
932
  // Exact-duplicate suppression — see _lastGestureSig. Signature is the gesture type plus
953
933
  // raw coordinates verbatim; String() keeps undefined coords distinct from 0 (a payload
954
934
  // with no coords never collides with a real origin tap).
955
- const dedupSig = `${gestureType}|${String(details?.x)}|${String(details?.y)}|${String(details?.endX)}|${String(details?.endY)}`;
935
+ const suppliedInteractionId = details?.interactionId;
936
+ const dedupSig = suppliedInteractionId ? `id:${suppliedInteractionId}` : `${gestureType}|${String(details?.x)}|${String(details?.y)}|${String(details?.endX)}|${String(details?.endY)}`;
956
937
  const nowTs = Date.now();
957
938
  if (dedupSig === this._lastGestureSig && nowTs - this._lastGestureTs <= SessionManager.GESTURE_DEDUP_WINDOW_MS) {
958
939
  this._lastGestureTs = nowTs; // a burst of 3 stays suppressed even if gaps chain past the window
@@ -964,7 +945,15 @@ export class SessionManager {
964
945
  // Build the canonical gesture payload ONCE so the recording lane and the
965
946
  // (additive) analytics lane carry byte-identical keys (normalizedX/Y,
966
947
  // gestureType, etc.). Same object shape that was previously inlined.
948
+ const interactionId = suppliedInteractionId ?? generateInteractionId();
949
+ const occurredAt = details?.occurredAt ?? Date.now();
950
+ const stateStatus = details?.stateStatus ?? (details?.ui ? 'captured_nonempty' : 'not_captured');
967
951
  const payload = {
952
+ interaction_id: interactionId,
953
+ interaction_protocol: details?.interactionProtocol ?? INTERACTION_PROTOCOL_VERSION,
954
+ state_status: stateStatus,
955
+ ui: details?.ui,
956
+ target_id: details?.targetId,
968
957
  gestureType,
969
958
  x: details?.x,
970
959
  y: details?.y,
@@ -1009,9 +998,16 @@ export class SessionManager {
1009
998
  // when the flag is OFF, no backend transport is attached, or recording is
1010
999
  // active (the recording path below already carries this tap). Uses a fresh
1011
1000
  // payload clone so analytics normalization never mutates the recording one.
1012
- this._emitInteractionToAnalytics(gestureType, {
1001
+ const analyticsReplayCarrier = this._emitInteractionToAnalytics(gestureType, {
1013
1002
  ...payload
1014
- });
1003
+ }, occurredAt, details?.screenName);
1004
+ const replayCarrier = !!this._backendTransport && (this.active || analyticsReplayCarrier);
1005
+ if (details?.emitAutomaticAnalytics) {
1006
+ try {
1007
+ const safePayload = redactBody(payload);
1008
+ emitAutomaticEvent('element_interacted', automaticInteractionProperties(safePayload, details?.screenName ?? this._lastKnownScreen ?? undefined, replayCarrier), occurredAt);
1009
+ } catch {/* automatic projection must never affect interaction capture */}
1010
+ }
1015
1011
  if (!this.active) return; // Defense-in-depth: no emission when recording is OFF
1016
1012
  // The subtype becomes `ReplayEvent.label`, which is what the Events explorer GROUPS BY. Naming
1017
1013
  // the tapped control here is what splits taps per control instead of collapsing every tap in the
@@ -1021,7 +1017,10 @@ export class SessionManager {
1021
1017
  // ⚠️ `gestureType` also travels in the payload, and the backend's `resolveGesture` reads THAT
1022
1018
  // first — so enriching the label cannot change heatmap gesture classification.
1023
1019
  const target = typeof details?.target === 'string' ? details.target.trim() : '';
1024
- this.onUserAction(target ? `gesture:${gestureType} · ${target}` : `gesture:${gestureType}`, payload);
1020
+ this.onUserAction(target ? `gesture:${gestureType} · ${target}` : `gesture:${gestureType}`, payload, {
1021
+ screen: details?.screenName,
1022
+ timestamp: occurredAt
1023
+ });
1025
1024
  }
1026
1025
 
1027
1026
  /**
@@ -74,6 +74,9 @@ export function discoverArtifacts(packageRoot) {
74
74
  return out;
75
75
  }
76
76
 
77
+ /** One-shot guard so an Expo build does not print the pass-through notice per bundle. */
78
+ let warnedUnknownShape = false;
79
+
77
80
  /**
78
81
  * Build the composing serializer. `hostSerializer` is the app's own customSerializer, if any.
79
82
  */
@@ -85,6 +88,35 @@ export function createComposingSerializer(artifacts, internals, hostSerializer)
85
88
  const hostResult = await hostSerializer(entryPoint, preModules, graph, options);
86
89
  if (typeof hostResult === 'string') {
87
90
  code = hostResult;
91
+ } else if (hostResult && typeof hostResult.code !== 'string') {
92
+ /**
93
+ * A shape this wrapper does not understand — pass it back UNTOUCHED.
94
+ *
95
+ * Expo is the case that matters. `expo/metro-config` installs a
96
+ * serializer returning `{ artifacts: [...] }`, a multi-artifact
97
+ * shape with no top-level `code`. Reading `.code` off it yielded
98
+ * undefined, and returning `{ code: undefined, map }` made every
99
+ * `expo export:embed` die with:
100
+ *
101
+ * Serializer did not return expected format. The project copy
102
+ * of `expo/metro-config` may be out of date.
103
+ *
104
+ * — a message that sends you to upgrade Expo, which is not the
105
+ * problem. The result: `withScaleBun` broke PRODUCTION BUNDLING
106
+ * FOR EVERY EXPO APP, and the SDK's own marketing test app could
107
+ * not build. Found by bundling it.
108
+ *
109
+ * Passing it through costs ScaleBun's source-map composition on
110
+ * Expo (OTA stack traces stay mapped to the bundle rather than to
111
+ * original sources). That is a real loss and strictly better than
112
+ * a build that cannot run at all. Composing INTO an artifact
113
+ * array is the follow-up; it must not be guessed at here.
114
+ */
115
+ if (!warnedUnknownShape) {
116
+ warnedUnknownShape = true;
117
+ 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.');
118
+ }
119
+ return hostResult;
88
120
  } else {
89
121
  code = hostResult.code;
90
122
  try {
@@ -209,12 +209,17 @@ class ScaleBunFacade {
209
209
  /**
210
210
  * Boot the OTA orchestrator when the init config asks for it.
211
211
  *
212
- * `publicSigningKey` is honoured here so signature enforcement is reachable
213
- * from configuration alone. The SDK ships no ed25519 implementation, so a
214
- * host that pins a key must also supply `ota.verifySignature`; pinning a key
215
- * without a verifier is fail-CLOSED by design (signature.ts) an update
216
- * that cannot be verified is not installed. We say that out loud rather than
217
- * letting the app discover it as a silent no-update condition.
212
+ * `publicSigningKey` / `publicSigningKeys` are honoured here so signature
213
+ * enforcement is reachable from configuration alone. The list form exists
214
+ * for key ROTATION: a build pinning [old, new] keeps verifying while the
215
+ * server moves to the new key, so replacing a key never needs an emergency
216
+ * store release. Both fields merge (deduplicated) into one pinned set.
217
+ *
218
+ * Verification comes from `ota.verifySignature` when supplied, else the
219
+ * built-in @noble-based verifier (optional peers). Pinning keys with
220
+ * NEITHER available is fail-CLOSED by design (signature.ts) — an update
221
+ * that cannot be verified is not installed. We say that out loud rather
222
+ * than letting the app discover it as a silent no-update condition.
218
223
  */
219
224
  _maybeStartOta(rawConfig) {
220
225
  const ota = rawConfig?.ota;
@@ -226,15 +231,34 @@ class ScaleBunFacade {
226
231
  const {
227
232
  otaOrchestrator
228
233
  } = require('../features/ota/OtaOrchestrator');
229
- const publicKey = ota.publicSigningKey;
234
+ const rawSingle = ota.publicSigningKey;
235
+ const rawList = ota.publicSigningKeys;
236
+ const publicKeys = Array.from(new Set([...(typeof rawSingle === 'string' && rawSingle ? [rawSingle] : []), ...(Array.isArray(rawList) ? rawList : [])].filter(k => typeof k === 'string' && k.trim().length > 0)));
237
+ // Signing was REQUESTED if a usable key exists, or the host passed a
238
+ // keys array at all — an empty one included. `publicSigningKeys: []`
239
+ // is a broken attempt to enable signing, and it must flow through to
240
+ // signature.ts's fail-closed handling, not silently disable signing.
241
+ const signingRequested = publicKeys.length > 0 || Array.isArray(rawList);
230
242
  const verifier = ota.verifySignature;
231
- if (publicKey && typeof verifier !== 'function') {
232
- 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.');
243
+ if (signingRequested && typeof verifier !== 'function') {
244
+ // A host verifier is optional since the built-in one landed the
245
+ // old unconditional warning here told every correctly-configured
246
+ // app that its updates would be rejected. Warn only when neither
247
+ // verifier can actually be resolved.
248
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
249
+ const {
250
+ getBuiltinVerifier
251
+ } = require('../features/ota/crypto/builtinVerifier');
252
+ if (!getBuiltinVerifier()) {
253
+ 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`.');
254
+ }
233
255
  }
234
256
  otaOrchestrator.init({
235
- ...(publicKey ? {
257
+ ...(signingRequested ? {
236
258
  signature: {
237
- publicKey,
259
+ // Single key stays a plain string — the shape every
260
+ // existing consumer and test already handles.
261
+ publicKey: publicKeys.length === 1 ? publicKeys[0] : publicKeys,
238
262
  verifier
239
263
  }
240
264
  } : {}),
@@ -471,6 +495,45 @@ class ScaleBunFacade {
471
495
  }
472
496
 
473
497
  /** Convenience: emit a Phase 1 purchase event (revenue + currency + transaction_id). */
498
+ /**
499
+ * Declare which UI state the user is looking at, so taps are attributed to the surface they
500
+ * happened on rather than averaged across every variant of the screen.
501
+ *
502
+ * ScaleBun.setUiState('filter-sheet', 'open');
503
+ * ScaleBun.setUiState('checkout-step', 'payment');
504
+ *
505
+ * Call it when the state CHANGES — the value is read at tap time, so it only has to be right by
506
+ * the time the next tap lands. On React Native this is the ONLY source of UI state: unlike the web,
507
+ * there is no queryable accessibility tree that says a sheet is up, and guessing would produce a
508
+ * state key that is right on some apps and silently wrong on others.
509
+ *
510
+ * Names and values are identifiers, not content: they become query keys in the dashboard, so a
511
+ * person's name or a cart total does not belong in one. `;`, `:` and `|` are stripped and both
512
+ * halves are capped at 32 characters.
513
+ */
514
+ setUiState(name, value) {
515
+ try {
516
+ const {
517
+ setUiState
518
+ } = require('../features/journey/uiState');
519
+ setUiState(name, value);
520
+ } catch {/* no-throw: a state declaration must never break the host */}
521
+ }
522
+
523
+ /**
524
+ * Stop reporting a UI state dimension — or, with no argument, all of them.
525
+ *
526
+ * Clear on screen unmount. A declaration left behind follows the user onto a surface where it means
527
+ * nothing, and every tap there is filed under a state that was not on screen.
528
+ */
529
+ clearUiState(name) {
530
+ try {
531
+ const {
532
+ clearUiState
533
+ } = require('../features/journey/uiState');
534
+ clearUiState(name);
535
+ } catch {/* no-throw */}
536
+ }
474
537
  trackPurchase(input) {
475
538
  noThrow(() => this._eventTracker?.trackPurchase(input));
476
539
  }