@scalebun/react-native 1.10.5 → 1.10.7

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 (64) hide show
  1. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +75 -0
  2. package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +12 -0
  3. package/dist/scalebun.full.js +485 -51
  4. package/dist/scalebun.slim.js +485 -51
  5. package/ios/Core/DeviceIdentity.swift +50 -0
  6. package/ios/Ota/ScaleBunOtaBridge.mm +6 -0
  7. package/ios/Ota/ScaleBunOtaModule.swift +67 -0
  8. package/ios/Profiler/ScaleBunProfilerModule.swift +2 -8
  9. package/ios/ReplaySdk.swift +3 -1
  10. package/lib/commonjs/analytics/EventTracker.js +22 -0
  11. package/lib/commonjs/core/config/schema.js +34 -1
  12. package/lib/commonjs/core/constants/version.js +1 -1
  13. package/lib/commonjs/core/context/device.js +5 -0
  14. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +24 -0
  15. package/lib/commonjs/features/journey/uiState.js +87 -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/metro/serializerCompose.js +32 -0
  22. package/lib/commonjs/public/ScaleBunFacade.js +108 -11
  23. package/lib/module/analytics/EventTracker.js +22 -0
  24. package/lib/module/core/config/schema.js +34 -1
  25. package/lib/module/core/constants/version.js +1 -1
  26. package/lib/module/core/context/device.js +5 -0
  27. package/lib/module/features/journey/ScaleBunDebugRoot.js +24 -0
  28. package/lib/module/features/journey/uiState.js +79 -0
  29. package/lib/module/features/ota/crypto/builtinVerifier.js +240 -0
  30. package/lib/module/features/ota/crypto/loadEd25519.js +34 -0
  31. package/lib/module/features/ota/crypto/loadSha512.js +34 -0
  32. package/lib/module/features/ota/crypto/nativeVerifier.js +113 -0
  33. package/lib/module/features/ota/signature.js +87 -27
  34. package/lib/module/metro/serializerCompose.js +32 -0
  35. package/lib/module/public/ScaleBunFacade.js +109 -12
  36. package/lib/typescript/analytics/EventTracker.d.ts +15 -0
  37. package/lib/typescript/core/config/schema.d.ts +2 -0
  38. package/lib/typescript/core/constants/version.d.ts +1 -1
  39. package/lib/typescript/core/context/device.d.ts +1 -0
  40. package/lib/typescript/features/journey/uiState.d.ts +53 -0
  41. package/lib/typescript/features/ota/OtaTypes.d.ts +50 -0
  42. package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +53 -0
  43. package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +30 -0
  44. package/lib/typescript/features/ota/crypto/loadSha512.d.ts +15 -0
  45. package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +35 -0
  46. package/lib/typescript/features/ota/signature.d.ts +22 -7
  47. package/lib/typescript/public/ScaleBunFacade.d.ts +43 -6
  48. package/lib/typescript/specs/NativeScaleBunOta.d.ts +23 -0
  49. package/package.json +18 -3
  50. package/src/analytics/EventTracker.ts +19 -0
  51. package/src/core/config/schema.ts +30 -3
  52. package/src/core/constants/version.ts +1 -1
  53. package/src/core/context/device.ts +6 -0
  54. package/src/features/journey/ScaleBunDebugRoot.tsx +22 -0
  55. package/src/features/journey/uiState.ts +84 -0
  56. package/src/features/ota/OtaTypes.ts +51 -0
  57. package/src/features/ota/crypto/builtinVerifier.ts +257 -0
  58. package/src/features/ota/crypto/loadEd25519.ts +41 -0
  59. package/src/features/ota/crypto/loadSha512.ts +35 -0
  60. package/src/features/ota/crypto/nativeVerifier.ts +117 -0
  61. package/src/features/ota/signature.ts +108 -25
  62. package/src/metro/serializerCompose.ts +38 -2
  63. package/src/public/ScaleBunFacade.ts +128 -14
  64. package/src/specs/NativeScaleBunOta.ts +24 -0
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.loadEd25519 = loadEd25519;
7
+ /**
8
+ * Isolated lazy loader for the OPTIONAL `@noble/ed25519` dep.
9
+ *
10
+ * WHY ITS OWN FILE: Metro's dependency collector emits a dependency map one
11
+ * entry short for a module containing TWO OR MORE different-string inline
12
+ * `require()` calls, so the require indices desync and `_dependencyMap[N]`
13
+ * reads `undefined` ("Requiring unknown module 'undefined'"). Exactly ONE
14
+ * inline require per module avoids the miscount. `loadSha512` is a sibling file
15
+ * for the same reason — see `push/adapters/loadNotifee.ts`, which hit this first.
16
+ *
17
+ * The string must still be STATICALLY resolvable at bundle time; hosts that do
18
+ * not install it stub it via `withScaleBun`.
19
+ */
20
+
21
+ /** The subset of the @noble/ed25519 surface this SDK uses. */
22
+
23
+ function loadEd25519() {
24
+ try {
25
+ const mod = require('@noble/ed25519');
26
+ const ed = mod?.default ?? mod;
27
+ // Validate the API actually used, not mere truthiness: when the package
28
+ // is absent `withScaleBun` resolves it to an EMPTY STUB MODULE, which is
29
+ // a perfectly truthy object. A stub must read as "unavailable", because
30
+ // treating it as available would make every signed update fail with a
31
+ // crypto error instead of the actionable "no verifier" message.
32
+ if (typeof ed?.verifyAsync === 'function' || typeof ed?.verify === 'function') {
33
+ return ed;
34
+ }
35
+ return null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+ //# sourceMappingURL=loadEd25519.js.map
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.loadSha512 = loadSha512;
7
+ /**
8
+ * Isolated lazy loader for the OPTIONAL `@noble/hashes` SHA-512.
9
+ *
10
+ * Separate file from `loadEd25519` because Metro miscounts a module holding two
11
+ * different-string inline `require()` calls — see the comment there.
12
+ *
13
+ * ed25519 is defined in terms of SHA-512, and React Native has no native
14
+ * SHA-512, so @noble/ed25519 cannot verify anything until a hash provider is
15
+ * wired into it. Which property it expects differs by major version, so the
16
+ * wiring lives in `builtinVerifier`, not here; this module only obtains the
17
+ * function.
18
+ */
19
+
20
+ function loadSha512() {
21
+ try {
22
+ // The `.js` SUFFIX IS REQUIRED, not stylistic. @noble/hashes v2 declares
23
+ // its exports map with extensions — `"./sha2.js"`, not `"./sha2"` — so
24
+ // the extensionless form misses the map entirely. Node still resolves it
25
+ // by falling back to file-based lookup, which is why it passes here,
26
+ // but Metro prints
27
+ // "Attempted to import … not listed in the exports … Falling back to
28
+ // file-based resolution"
29
+ // on every bundle, and that fallback is exactly what a stricter resolver
30
+ // (or a future Metro) drops. Caught while bundling a real Expo app.
31
+ const mod = require('@noble/hashes/sha2.js');
32
+ const sha512 = mod?.sha512 ?? mod?.default?.sha512;
33
+ // A stubbed module yields undefined here, which correctly reads as
34
+ // "unavailable" rather than throwing deep inside the curve code later.
35
+ return typeof sha512 === 'function' ? sha512 : null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+ //# sourceMappingURL=loadSha512.js.map
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports._resetNativeVerifier = _resetNativeVerifier;
7
+ exports.getNativeVerifier = getNativeVerifier;
8
+ var _NativeScaleBunOta = _interopRequireDefault(require("../../../specs/NativeScaleBunOta"));
9
+ var _internalLogger = require("../../../core/logger/internalLogger");
10
+ var _builtinVerifier = require("./builtinVerifier");
11
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
12
+ /**
13
+ * Native ed25519 verifier — platform crypto wrapped as a SignatureVerifier.
14
+ *
15
+ * WHY IT EXISTS. The built-in @noble verifier runs in JS, inside the very
16
+ * bundle it protects: an attacker who lands one malicious bundle can neuter a
17
+ * JS check for every update after it. Platform crypto (CryptoKit on iOS,
18
+ * Android's conscrypt on API 33+) sits outside the bundle's reach — and using
19
+ * it also drops the runtime dependency on the optional @noble peers wherever
20
+ * the OS provides ed25519.
21
+ *
22
+ * WHAT IT DOES NOT FIX, stated plainly: the ORCHESTRATION still lives in JS.
23
+ * A hostile bundle can skip calling any verifier and drive the native staging
24
+ * methods directly. Moving the crypto native shrinks the attack surface (no
25
+ * more tampering with a bundled crypto lib to flip a verdict) but the full
26
+ * close needs native-ENFORCED staging with a natively-pinned key — an
27
+ * architectural change tracked separately, not smuggled into this one.
28
+ *
29
+ * VERDICT CONTRACT (mirrors the spec): the native side resolves
30
+ * 'valid' | 'invalid' | 'unavailable'. 'invalid' is a definitive NO.
31
+ * 'unavailable' (Android < 33, malformed input, machinery failure) means this
32
+ * source cannot answer — the composed verifier below then delegates to the
33
+ * @noble builtin, and when that is absent too it THROWS, which the policy
34
+ * layer converts to a rejection. Every path that cannot verify refuses.
35
+ */
36
+
37
+ const ED25519_PUBLIC_KEY_HEX_CHARS = 64;
38
+
39
+ /** bytes → lowercase hex (the native side takes clean hex only). */
40
+ function bytesToHex(bytes) {
41
+ let out = '';
42
+ for (const b of bytes) out += b.toString(16).padStart(2, '0');
43
+ return out;
44
+ }
45
+
46
+ /** Strict lowercase-hex normalization; null when the value is not hex. */
47
+ function normalizeHex(value, expectedChars) {
48
+ const clean = value.trim().toLowerCase().replace(/^0x/, '');
49
+ if (clean.length !== expectedChars || /[^0-9a-f]/.test(clean)) return null;
50
+ return clean;
51
+ }
52
+ let resolved;
53
+
54
+ /**
55
+ * The native-first verifier, or null when the native module (or its
56
+ * verifyEd25519 method — an app running new JS against an old binary) is
57
+ * absent. Cached like the builtin: module availability does not change at
58
+ * runtime, and this is consulted on every update check.
59
+ */
60
+ function getNativeVerifier() {
61
+ if (resolved !== undefined) return resolved;
62
+ const native = _NativeScaleBunOta.default;
63
+ if (!native || typeof native.verifyEd25519 !== 'function') {
64
+ // Old binary, Jest, web/SSR — not an error, just not this source.
65
+ resolved = null;
66
+ return null;
67
+ }
68
+ resolved = async ({
69
+ messageHex,
70
+ signature,
71
+ publicKey
72
+ }) => {
73
+ // All decoding and validation happens HERE, in one place, so both
74
+ // platforms' native code only ever sees clean fixed-length hex. The
75
+ // signature may arrive hex or base64 (the wire allows both) — reuse
76
+ // the builtin's decoder rather than growing a second, subtly
77
+ // different one.
78
+ const message = normalizeHex(messageHex, 64);
79
+ const key = normalizeHex(publicKey, ED25519_PUBLIC_KEY_HEX_CHARS);
80
+ const sigBytes = (0, _builtinVerifier.decodeSignature)(signature);
81
+ if (!message || !key || !sigBytes) {
82
+ // Same messages-by-symptom philosophy as the builtin: a mistyped
83
+ // key must not present as "every update is a forgery".
84
+ _internalLogger.logger.error('[OTA] Signature inputs malformed (bundle hash, signature, or public key) — refusing to stage.');
85
+ return false;
86
+ }
87
+ let verdict;
88
+ try {
89
+ verdict = await native.verifyEd25519(message, bytesToHex(sigBytes), key);
90
+ } catch (err) {
91
+ // A rejected promise is machinery failure, not a forgery verdict.
92
+ _internalLogger.logger.warn(`[OTA] Native ed25519 verify failed to run: ${err?.message ?? err}`);
93
+ verdict = 'unavailable';
94
+ }
95
+ if (verdict === 'valid') return true;
96
+ if (verdict === 'invalid') return false;
97
+
98
+ // 'unavailable' (Android < 33) — fall back to the JS verifier so those
99
+ // devices keep verifying rather than losing enforcement.
100
+ const builtin = (0, _builtinVerifier.getBuiltinVerifier)();
101
+ if (builtin) {
102
+ return builtin({
103
+ messageHex,
104
+ signature,
105
+ publicKey
106
+ });
107
+ }
108
+ // No source can verify. Throwing (rather than returning false) keeps
109
+ // the policy layer's outcome labels honest: this surfaces as machinery
110
+ // failure with an actionable message, not as "the bundle is forged".
111
+ throw new Error('ed25519 unavailable: this OS has no native implementation (Android < 13) and the ' + 'optional peers @noble/ed25519 + @noble/hashes are not installed.');
112
+ };
113
+ __DEV__ && _internalLogger.logger.debug('[OTA] Using native ed25519 verifier (platform crypto).');
114
+ return resolved;
115
+ }
116
+
117
+ /** Test seam — clears the cached resolution. */
118
+ function _resetNativeVerifier() {
119
+ resolved = undefined;
120
+ }
121
+ //# sourceMappingURL=nativeVerifier.js.map
@@ -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. */
@@ -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 {
@@ -20,6 +20,7 @@ var _debug = require("../debug");
20
20
  var _api = require("../features/replay/public/api");
21
21
  var _SDKBootstrapper = require("../bootstrap/SDKBootstrapper");
22
22
  var _EventTracker = require("../analytics/EventTracker");
23
+ var _bridgeAdapter = require("../features/replay/bridge/adapters/bridgeAdapter");
23
24
  var _automaticEvents = require("../analytics/automaticEvents");
24
25
  var _eventLane = require("../analytics/eventLane");
25
26
  var _ConfigManager = require("../config/ConfigManager");
@@ -214,12 +215,17 @@ class ScaleBunFacade {
214
215
  /**
215
216
  * Boot the OTA orchestrator when the init config asks for it.
216
217
  *
217
- * `publicSigningKey` is honoured here so signature enforcement is reachable
218
- * from configuration alone. The SDK ships no ed25519 implementation, so a
219
- * host that pins a key must also supply `ota.verifySignature`; pinning a key
220
- * without a verifier is fail-CLOSED by design (signature.ts) an update
221
- * that cannot be verified is not installed. We say that out loud rather than
222
- * 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.
223
229
  */
224
230
  _maybeStartOta(rawConfig) {
225
231
  const ota = rawConfig?.ota;
@@ -231,15 +237,34 @@ class ScaleBunFacade {
231
237
  const {
232
238
  otaOrchestrator
233
239
  } = require('../features/ota/OtaOrchestrator');
234
- 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);
235
248
  const verifier = ota.verifySignature;
236
- if (publicKey && typeof verifier !== 'function') {
237
- _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
+ }
238
261
  }
239
262
  otaOrchestrator.init({
240
- ...(publicKey ? {
263
+ ...(signingRequested ? {
241
264
  signature: {
242
- 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,
243
268
  verifier
244
269
  }
245
270
  } : {}),
@@ -313,6 +338,8 @@ class ScaleBunFacade {
313
338
  * the whole time. The SDK knows the device; the integrator is guessing at it.
314
339
  * Everything outside the detected keys is still theirs.
315
340
  */
341
+ // Everything Platform.constants knows. On iOS that is neither the model nor the
342
+ // manufacturer, so the bridge completes it below once it answers.
316
343
  context: (0, _device.mergeDeviceContext)(rawConfig?.context),
317
344
  // Use the same foreground id as replay/journey capture. The
318
345
  // callback is resolved per event because the session subsystem
@@ -327,6 +354,19 @@ class ScaleBunFacade {
327
354
  this._eventTracker.start();
328
355
  __DEV__ && _internalLogger.logger.info('[ScaleBun] Envelope event tracking started → /v1/batch.');
329
356
 
357
+ // Complete the device context from the native bridge.
358
+ //
359
+ // `Platform.constants` answers Android synchronously — model and manufacturer ride the
360
+ // first event. It answers NEITHER on iOS, so without this every iOS installation
361
+ // reported no device model at all: `nativeDeviceContext` existed for exactly this and
362
+ // had no caller, and the bridge's own answer was `UIDevice.current.model` — the string
363
+ // "iPhone", identical on every iPhone ever made.
364
+ //
365
+ // Fire-and-forget on purpose. The bridge is async and absent entirely on Expo Go or an
366
+ // older native binary; waiting on it would delay the first event, and failing on it
367
+ // would take analytics down over a nice-to-have field.
368
+ this._applyNativeDeviceContext(this._eventTracker);
369
+
330
370
  // Android: capture the Play Install Referrer once per install → feeds the
331
371
  // deterministic click_id path + a one-off install_referrer event (UTM/gclid).
332
372
  // Fire-and-forget; no-op off Android.
@@ -351,6 +391,24 @@ class ScaleBunFacade {
351
391
  }
352
392
  }
353
393
 
394
+ /**
395
+ * Fold the native bridge's device facts into the analytics context once it answers.
396
+ *
397
+ * Detected values never overwrite a populated one with an empty one — `nativeDeviceContext`
398
+ * drops absent keys and `applyDetectedContext` ignores an empty patch — so a bridge that
399
+ * cannot answer leaves whatever `Platform.constants` already found.
400
+ */
401
+ _applyNativeDeviceContext(tracker) {
402
+ Promise.resolve().then(() => _bridgeAdapter.bridgeAdapter.getDeviceInfo()).then(info => {
403
+ if (!info) return;
404
+ tracker.applyDetectedContext((0, _device.nativeDeviceContext)({
405
+ osVersion: info.osVersion,
406
+ deviceModel: info.deviceModel
407
+ // iOS ships no manufacturer over the bridge; it is a constant there and the
408
+ // static path already set it. Android's arrives via Platform.constants.
409
+ }));
410
+ }).catch(() => {/* bridge unavailable — the static context stands */});
411
+ }
354
412
  /**
355
413
  * Android-only: read the Play Install Referrer once per install and route it into
356
414
  * attribution. `scalebun_click_id` → the deterministic click path; the full
@@ -443,6 +501,45 @@ class ScaleBunFacade {
443
501
  }
444
502
 
445
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
+ }
446
543
  trackPurchase(input) {
447
544
  (0, _crashSafe.noThrow)(() => this._eventTracker?.trackPurchase(input));
448
545
  }
@@ -336,6 +336,28 @@ export class EventTracker {
336
336
  this.persistQueue();
337
337
  }
338
338
 
339
+ /**
340
+ * Fold in device facts that were not knowable at init.
341
+ *
342
+ * `Platform.constants` answers Android synchronously, so its model and manufacturer ride the
343
+ * very first event. iOS exposes neither there — only the native bridge knows, and the bridge is
344
+ * async and may not be installed at all. Without this the iOS half of every install reported no
345
+ * model whatsoever.
346
+ *
347
+ * `buildEnvelope` reads `cfg.context` per event, so applying it here reaches every event from
348
+ * the next one onward. Detected keys WIN over whatever the integrator configured, for the same
349
+ * reason `mergeDeviceContext` inverts the usual precedence: a measurement must not be
350
+ * overridable by a guess. Empty patches are ignored so a bridge that answered with nothing
351
+ * cannot blank a value the static path already found.
352
+ */
353
+ applyDetectedContext(patch) {
354
+ if (!patch || Object.keys(patch).length === 0) return;
355
+ this.cfg.context = {
356
+ ...(this.cfg.context ?? {}),
357
+ ...patch
358
+ };
359
+ }
360
+
339
361
  // ─── internals ─────────────────────────────────────────────────────────────
340
362
 
341
363
  buildEnvelope(eventName, properties) {