@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
@@ -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. */
@@ -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 {
@@ -18,13 +18,14 @@ import { BugReportFeature } from '../features/bugreport/BugReportFeature';
18
18
  import { PersistentQueue } from '../pipeline/queue/persistentQueue';
19
19
  import { MemoryBackend, createStorageBackend } from '../storage/StorageBackend';
20
20
  import { getDeviceId } from '../core/id/deviceId';
21
- import { mergeDeviceContext, resolveEventPlatform } from '../core/context/device';
21
+ import { mergeDeviceContext, nativeDeviceContext, resolveEventPlatform } from '../core/context/device';
22
22
  import { coreContainer } from '../core/di/container';
23
23
  import { flushQueue } from '../pipeline/queue/flushQueue';
24
24
  import { enableDebug as _enableDebug, disableDebug as _disableDebug, isDebugEnabled, addBreadcrumb as _addBreadcrumb, ping as _ping, _getStream, _setFlushFn, getPerformanceFeature as _getPerformanceFeature, requestExport as _requestExport, buildDebugConfigFromInitConfig, applyFeatureGatesToDebugConfig, sendDebugMetric } from '../debug';
25
25
  import { ReplaySdk } from '../features/replay/public/api';
26
26
  import { SDKBootstrapper } from '../bootstrap/SDKBootstrapper';
27
27
  import { EventTracker } from '../analytics/EventTracker';
28
+ import { bridgeAdapter } from '../features/replay/bridge/adapters/bridgeAdapter';
28
29
  import { emitAutomaticEvent } from '../analytics/automaticEvents';
29
30
  import { resolveEventLane, isUploadedSessionEvent } from '../analytics/eventLane';
30
31
  import { ConfigManager } from '../config/ConfigManager';
@@ -208,12 +209,17 @@ class ScaleBunFacade {
208
209
  /**
209
210
  * Boot the OTA orchestrator when the init config asks for it.
210
211
  *
211
- * `publicSigningKey` is honoured here so signature enforcement is reachable
212
- * from configuration alone. The SDK ships no ed25519 implementation, so a
213
- * host that pins a key must also supply `ota.verifySignature`; pinning a key
214
- * without a verifier is fail-CLOSED by design (signature.ts) an update
215
- * that cannot be verified is not installed. We say that out loud rather than
216
- * 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.
217
223
  */
218
224
  _maybeStartOta(rawConfig) {
219
225
  const ota = rawConfig?.ota;
@@ -225,15 +231,34 @@ class ScaleBunFacade {
225
231
  const {
226
232
  otaOrchestrator
227
233
  } = require('../features/ota/OtaOrchestrator');
228
- 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);
229
242
  const verifier = ota.verifySignature;
230
- if (publicKey && typeof verifier !== 'function') {
231
- 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
+ }
232
255
  }
233
256
  otaOrchestrator.init({
234
- ...(publicKey ? {
257
+ ...(signingRequested ? {
235
258
  signature: {
236
- 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,
237
262
  verifier
238
263
  }
239
264
  } : {}),
@@ -307,6 +332,8 @@ class ScaleBunFacade {
307
332
  * the whole time. The SDK knows the device; the integrator is guessing at it.
308
333
  * Everything outside the detected keys is still theirs.
309
334
  */
335
+ // Everything Platform.constants knows. On iOS that is neither the model nor the
336
+ // manufacturer, so the bridge completes it below once it answers.
310
337
  context: mergeDeviceContext(rawConfig?.context),
311
338
  // Use the same foreground id as replay/journey capture. The
312
339
  // callback is resolved per event because the session subsystem
@@ -321,6 +348,19 @@ class ScaleBunFacade {
321
348
  this._eventTracker.start();
322
349
  __DEV__ && logger.info('[ScaleBun] Envelope event tracking started → /v1/batch.');
323
350
 
351
+ // Complete the device context from the native bridge.
352
+ //
353
+ // `Platform.constants` answers Android synchronously — model and manufacturer ride the
354
+ // first event. It answers NEITHER on iOS, so without this every iOS installation
355
+ // reported no device model at all: `nativeDeviceContext` existed for exactly this and
356
+ // had no caller, and the bridge's own answer was `UIDevice.current.model` — the string
357
+ // "iPhone", identical on every iPhone ever made.
358
+ //
359
+ // Fire-and-forget on purpose. The bridge is async and absent entirely on Expo Go or an
360
+ // older native binary; waiting on it would delay the first event, and failing on it
361
+ // would take analytics down over a nice-to-have field.
362
+ this._applyNativeDeviceContext(this._eventTracker);
363
+
324
364
  // Android: capture the Play Install Referrer once per install → feeds the
325
365
  // deterministic click_id path + a one-off install_referrer event (UTM/gclid).
326
366
  // Fire-and-forget; no-op off Android.
@@ -345,6 +385,24 @@ class ScaleBunFacade {
345
385
  }
346
386
  }
347
387
 
388
+ /**
389
+ * Fold the native bridge's device facts into the analytics context once it answers.
390
+ *
391
+ * Detected values never overwrite a populated one with an empty one — `nativeDeviceContext`
392
+ * drops absent keys and `applyDetectedContext` ignores an empty patch — so a bridge that
393
+ * cannot answer leaves whatever `Platform.constants` already found.
394
+ */
395
+ _applyNativeDeviceContext(tracker) {
396
+ Promise.resolve().then(() => bridgeAdapter.getDeviceInfo()).then(info => {
397
+ if (!info) return;
398
+ tracker.applyDetectedContext(nativeDeviceContext({
399
+ osVersion: info.osVersion,
400
+ deviceModel: info.deviceModel
401
+ // iOS ships no manufacturer over the bridge; it is a constant there and the
402
+ // static path already set it. Android's arrives via Platform.constants.
403
+ }));
404
+ }).catch(() => {/* bridge unavailable — the static context stands */});
405
+ }
348
406
  /**
349
407
  * Android-only: read the Play Install Referrer once per install and route it into
350
408
  * attribution. `scalebun_click_id` → the deterministic click path; the full
@@ -437,6 +495,45 @@ class ScaleBunFacade {
437
495
  }
438
496
 
439
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
+ }
440
537
  trackPurchase(input) {
441
538
  noThrow(() => this._eventTracker?.trackPurchase(input));
442
539
  }
@@ -134,6 +134,21 @@ export declare class EventTracker {
134
134
  }): void;
135
135
  flush(): Promise<void>;
136
136
  stop(): void;
137
+ /**
138
+ * Fold in device facts that were not knowable at init.
139
+ *
140
+ * `Platform.constants` answers Android synchronously, so its model and manufacturer ride the
141
+ * very first event. iOS exposes neither there — only the native bridge knows, and the bridge is
142
+ * async and may not be installed at all. Without this the iOS half of every install reported no
143
+ * model whatsoever.
144
+ *
145
+ * `buildEnvelope` reads `cfg.context` per event, so applying it here reaches every event from
146
+ * the next one onward. Detected keys WIN over whatever the integrator configured, for the same
147
+ * reason `mergeDeviceContext` inverts the usual precedence: a measurement must not be
148
+ * overridable by a guess. Empty patches are ignored so a bridge that answered with nothing
149
+ * cannot blank a value the static path already found.
150
+ */
151
+ applyDetectedContext(patch: Record<string, string>): void;
137
152
  private buildEnvelope;
138
153
  private enqueue;
139
154
  private scheduleFlush;
@@ -93,6 +93,8 @@ export interface ScaleBunConfig {
93
93
  checkOnForeground: boolean;
94
94
  channelOverride?: string;
95
95
  publicSigningKey?: string;
96
+ /** Additional pinned keys for rotation; unioned with publicSigningKey. */
97
+ publicSigningKeys?: string[];
96
98
  mandatoryBlocksUi: boolean;
97
99
  };
98
100
  }
@@ -3,5 +3,5 @@
3
3
  * can attribute telemetry to the SDK build that produced it.
4
4
  * Keep in sync with package.json "version".
5
5
  */
6
- export declare const SDK_VERSION = "1.10.5";
6
+ export declare const SDK_VERSION = "1.10.7";
7
7
  //# sourceMappingURL=version.d.ts.map
@@ -34,6 +34,7 @@ export declare function nativeDeviceContext(info: {
34
34
  platform?: string;
35
35
  osVersion?: string;
36
36
  deviceModel?: string;
37
+ manufacturer?: string;
37
38
  } | null): Record<string, string>;
38
39
  /**
39
40
  * Merge the integrator's context with what we detected — DETECTED WINS on the keys above.
@@ -0,0 +1,53 @@
1
+ /**
2
+ * UI STATE AT INTERACTION TIME — React Native.
3
+ *
4
+ * A screen is not just `Cart`. It is `Cart` with the filter sheet down, or up; with the address form
5
+ * expanded, or collapsed. Those are different interaction surfaces and a tap on one must never be
6
+ * counted on another — heat from the open sheet painted over the closed layout is a picture of
7
+ * something that never existed.
8
+ *
9
+ * HOW THIS DIFFERS FROM THE WEB, and the difference is not an omission:
10
+ *
11
+ * The web SDK ALSO detects state automatically, by reading `aria-expanded` and open `<dialog>`
12
+ * elements out of the live DOM. Those are platform-defined, so reading them is a measurement.
13
+ *
14
+ * React Native has no equivalent. There is no queryable tree of accessibility state that says a
15
+ * bottom sheet is up; a sheet is a component with a boolean in someone's store, and the only place
16
+ * that boolean exists is the host's own code. Guessing at it — from a modal's presence in the tree, or
17
+ * from a component name — would produce a state key that is right on some apps and silently wrong on
18
+ * others, and a wrong state key partitions taps into buckets corresponding to nothing.
19
+ *
20
+ * So on RN, state is DECLARED and never inferred. `ScaleBun.setUiState('filter-sheet', 'open')` is the
21
+ * whole mechanism, and its absence means "not captured" rather than "nothing was open". That
22
+ * distinction is carried all the way to the dashboard: a tap with no declared state is NULL, not '',
23
+ * and cannot be selected as a state or counted as one. Reporting it as "nothing was open" would be
24
+ * asserting a measurement nobody took.
25
+ *
26
+ * PAIRED WITH: whatzbug-web-sdk/packages/web/src/features/journey/uiState.ts — same wire format
27
+ * (`name:value` pairs, sorted, `;`-joined, <= 96 chars), same separator stripping, same explicit-wins
28
+ * precedence, so one dashboard control queries both platforms.
29
+ */
30
+ /**
31
+ * Declare the state of one UI dimension.
32
+ *
33
+ * Call it when the state CHANGES, not on every render: the value is read at interaction time, so it
34
+ * only has to be correct by the time the next tap lands.
35
+ */
36
+ export declare function setUiState(name: string, value: string): void;
37
+ /**
38
+ * Stop reporting a dimension — or, with no name, all of them.
39
+ *
40
+ * Later taps carry no value for it, which is not the same as carrying a value. Clearing all is what a
41
+ * screen unmount wants: declarations from the previous screen would otherwise follow the user onto a
42
+ * surface where they mean nothing, and every tap there would be filed under a state that was not on
43
+ * screen.
44
+ */
45
+ export declare function clearUiState(name?: string): void;
46
+ /**
47
+ * The signature for the interaction happening RIGHT NOW, or undefined when nothing is declared.
48
+ *
49
+ * undefined rather than '' deliberately — see the header. '' is the web's "we looked and nothing was
50
+ * open"; on RN there is nothing to look at, so the honest answer is silence.
51
+ */
52
+ export declare function uiStateSignature(): string | undefined;
53
+ //# sourceMappingURL=uiState.d.ts.map
@@ -5,6 +5,49 @@
5
5
  * Golden JSON fixtures in packages/protocol/fixtures/ota/ are the SINGLE source
6
6
  * of truth; conformance tests in both repos assert against them to prevent drift.
7
7
  */
8
+ /**
9
+ * ═══════════════════════════════════════════════════════════════════════════
10
+ * OTA-TELEMETRY-SPEC — remaining telemetry fixes (from the 2026-08 audit)
11
+ * ═══════════════════════════════════════════════════════════════════════════
12
+ * These close the SDK half of spec §84-86. All are ADDITIVE + backward-compatible
13
+ * (new optional fields, new enum members, new no-throw emit calls); old installed
14
+ * clients omit them and the backend ingests nullable/unknown fields. Each ALTERS
15
+ * DEVICE RUNTIME BEHAVIOR, so land them behind a real RN build + device/kill-test
16
+ * (Metro cannot exercise the boot-guard) — this file only carries the contract
17
+ * types + this spec, not the runtime wiring.
18
+ *
19
+ * 1. releaseId end-to-end (CRITICAL — root of the dashboard funnel mismatch)
20
+ * Backend already serves it: OtaBundlePayload.releaseId (done, this file +
21
+ * ota-check.service.ts). SDK TODO:
22
+ * - store payload.releaseId on the downloaded/installed bundle state, and
23
+ * - set `releaseId: bundle.releaseId` in the delivery mapper
24
+ * OtaOrchestrator.ts deliverOtaEvents (~:105) — currently only bundleId.
25
+ * Then ota_events carry releaseId and the funnel keys by release.
26
+ *
27
+ * 2. Emit CHECK + OFFERED (funnel top is currently unmeasurable)
28
+ * - CHECK: emit at the start of checkForUpdate (OtaOrchestrator.ts ~:480),
29
+ * before the fetch. ('CHECK' type already exists — no emit site today.)
30
+ * - OFFERED: add 'OFFERED' to OtaEventType, emit when checkRes.action ===
31
+ * 'DOWNLOAD' (~:619) before download begins.
32
+ *
33
+ * 3. Emit BOOT_SUCCESS (honest activation signal)
34
+ * Add 'BOOT_SUCCESS' to OtaEventType and emit it from the boot-guard
35
+ * markHealthy path (OtaOrchestrator.ts ~:863). INSTALLED (~:794) is emitted
36
+ * optimistically BEFORE the bundle boots; keep it (it means "staged+swapped")
37
+ * but let the dashboard measure real activation on BOOT_SUCCESS. Optionally
38
+ * add 'VERIFIED' after signature check (~:635).
39
+ *
40
+ * 4. Stamp the running OTA bundle onto session/crash telemetry
41
+ * SessionMetadata.bundleId is the NATIVE app package id, not the OTA bundle.
42
+ * Add optional otaBundleId?/otaBundleVersion? (distinct fields — do NOT
43
+ * overload bundleId) sourced from otaOrchestrator.getCurrentBundle() at
44
+ * session start (SessionManager.ts ~:451), so crashes attribute to the
45
+ * running bundle/version (release-health crash impact).
46
+ *
47
+ * Do NOT repurpose errorCode (it already collapses failure-error vs rollback-
48
+ * reason); add a new optional field if the two must be distinguished.
49
+ * ═══════════════════════════════════════════════════════════════════════════
50
+ */
8
51
  export interface OtaCheckRequest {
9
52
  appVersion: string;
10
53
  platform: 'android' | 'ios';
@@ -43,6 +86,13 @@ export interface OtaCheckResponse {
43
86
  export interface OtaBundlePayload {
44
87
  id: string;
45
88
  version: number;
89
+ /**
90
+ * The release this bundle is served AS (the backend now includes it — see
91
+ * ota.contracts.ts / ota-check.service.ts). Optional, for backward-compat with
92
+ * older backends. Carry it onto the emitted OtaEventItem.releaseId so telemetry
93
+ * is attributable by RELEASE, not just bundle. See OTA-TELEMETRY-SPEC below.
94
+ */
95
+ releaseId?: string;
46
96
  url: string;
47
97
  size: number;
48
98
  sha256: string;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Built-in ed25519 verifier, assembled from OPTIONAL peer dependencies.
3
+ *
4
+ * WHAT THIS REPLACES. Pinning `ota.publicSigningKey` used to require the host
5
+ * app to write its own `verifySignature` — roughly a hundred lines of hex and
6
+ * base64 decoding wrapped around a crypto library. That put the single most
7
+ * security-critical operation in the product, the one deciding whether remote
8
+ * code is authentic before it executes, in code the SDK could neither test nor
9
+ * audit. Three failure modes all landed on the app author:
10
+ *
11
+ * - A verifier that always returns `true` silently disables the feature, and
12
+ * nothing can detect it.
13
+ * - @noble's hash-provider property moved between major versions, so a
14
+ * routine dependency bump made every update fail closed with no signal
15
+ * pointing at the cause.
16
+ * - Hand-written base64 decoders tend to omit base64url, so a signature
17
+ * containing `-` or `_` is rejected as a forgery.
18
+ *
19
+ * All three are now the SDK's problem, which is where they belong. A host that
20
+ * installs `@noble/ed25519` and `@noble/hashes` gets verification by pinning a
21
+ * key and writing no code at all.
22
+ *
23
+ * WHY OPTIONAL AND NOT A HARD DEPENDENCY. Most apps never adopt bundle
24
+ * signing, and they should not carry curve arithmetic they will not run. The
25
+ * `verifySignature` hook remains supported and still WINS over this, for teams
26
+ * with their own crypto policy or a native implementation to delegate to.
27
+ *
28
+ * WHAT THIS DOES NOT FIX. Verification still happens in JS, inside the very
29
+ * bundle it protects. An attacker who lands one malicious bundle by other means
30
+ * can neuter the check for every update after it. Closing that needs native
31
+ * verification — CryptoKit on iOS (13.4+, already the deployment target) and
32
+ * `Signature.getInstance("Ed25519")` on Android API 33+, with a fallback below.
33
+ */
34
+ import type { SignatureVerifier } from '../signature';
35
+ /**
36
+ * Decode a detached signature that may arrive hex- or base64-encoded.
37
+ *
38
+ * Tries the unambiguous case first: 128 hex characters is exactly 64 bytes and
39
+ * cannot be anything else. Otherwise base64, then hex as a last resort. Only a
40
+ * result of exactly 64 bytes is accepted, so a string that decodes under the
41
+ * wrong scheme is rejected rather than fed to the curve code as garbage.
42
+ */
43
+ export declare function decodeSignature(sig: string): Uint8Array | null;
44
+ /**
45
+ * The built-in verifier, or null when the optional deps are not installed.
46
+ * Resolution is cached, including the negative result — a missing dependency
47
+ * does not become present at runtime, and retrying the require on every update
48
+ * check would be pure overhead.
49
+ */
50
+ export declare function getBuiltinVerifier(): SignatureVerifier | null;
51
+ /** Test seam — clears the cached resolution. */
52
+ export declare function _resetBuiltinVerifier(): void;
53
+ //# sourceMappingURL=builtinVerifier.d.ts.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Isolated lazy loader for the OPTIONAL `@noble/ed25519` dep.
3
+ *
4
+ * WHY ITS OWN FILE: Metro's dependency collector emits a dependency map one
5
+ * entry short for a module containing TWO OR MORE different-string inline
6
+ * `require()` calls, so the require indices desync and `_dependencyMap[N]`
7
+ * reads `undefined` ("Requiring unknown module 'undefined'"). Exactly ONE
8
+ * inline require per module avoids the miscount. `loadSha512` is a sibling file
9
+ * for the same reason — see `push/adapters/loadNotifee.ts`, which hit this first.
10
+ *
11
+ * The string must still be STATICALLY resolvable at bundle time; hosts that do
12
+ * not install it stub it via `withScaleBun`.
13
+ */
14
+ /** The subset of the @noble/ed25519 surface this SDK uses. */
15
+ export interface NobleEd25519 {
16
+ verifyAsync?: (sig: Uint8Array, msg: Uint8Array, pub: Uint8Array) => Promise<boolean>;
17
+ verify?: (sig: Uint8Array, msg: Uint8Array, pub: Uint8Array) => boolean;
18
+ /** v3 shape: `ed.hashes.sha512` */
19
+ hashes?: {
20
+ sha512?: unknown;
21
+ sha512Async?: unknown;
22
+ };
23
+ /** v2 shape: `ed.etc.sha512Sync` */
24
+ etc?: {
25
+ sha512Sync?: unknown;
26
+ sha512Async?: unknown;
27
+ };
28
+ }
29
+ export declare function loadEd25519(): NobleEd25519 | null;
30
+ //# sourceMappingURL=loadEd25519.d.ts.map