@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,15 @@
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
+ export type Sha512Fn = (msg: Uint8Array) => Uint8Array;
14
+ export declare function loadSha512(): Sha512Fn | null;
15
+ //# sourceMappingURL=loadSha512.d.ts.map
@@ -0,0 +1,35 @@
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
+ import type { SignatureVerifier } from '../signature';
26
+ /**
27
+ * The native-first verifier, or null when the native module (or its
28
+ * verifyEd25519 method — an app running new JS against an old binary) is
29
+ * absent. Cached like the builtin: module availability does not change at
30
+ * runtime, and this is consulted on every update check.
31
+ */
32
+ export declare function getNativeVerifier(): SignatureVerifier | null;
33
+ /** Test seam — clears the cached resolution. */
34
+ export declare function _resetNativeVerifier(): void;
35
+ //# sourceMappingURL=nativeVerifier.d.ts.map
@@ -11,10 +11,17 @@
11
11
  * download and nothing else. On a platform whose entire purpose is remote code
12
12
  * delivery, that is the control that matters most.
13
13
  *
14
- * WHY IT IS SHAPED LIKE THIS. The SDK ships zero third-party runtime
15
- * dependencies, and React Native has no built-in ed25519. So verification is
16
- * delegated to a host-provided verifier when one is installed, and the SDK's job
17
- * is to decide unambiguouslywhat happens when there is not one.
14
+ * WHY IT IS SHAPED LIKE THIS. React Native has no built-in ed25519, so the
15
+ * verification primitive has to come from somewhere. It is resolved in order:
16
+ * a host-supplied verifier, else the built-in one assembled from the OPTIONAL
17
+ * `@noble/ed25519` + `@noble/hashes` peers, else nothing and the SDK's job is
18
+ * to decide, unambiguously, what happens in that last case.
19
+ *
20
+ * The built-in path exists because requiring every adopter to hand-write a
21
+ * verifier put the most security-critical operation in the product in code the
22
+ * SDK could neither test nor audit, and made a routine dependency bump able to
23
+ * silently stop all updates. See `crypto/builtinVerifier.ts`. Signing stays
24
+ * opt-in, and apps that never adopt it carry no curve arithmetic.
18
25
  *
19
26
  * THE POLICY, which is the important part:
20
27
  *
@@ -40,8 +47,16 @@ export type SignatureVerifier = (input: {
40
47
  publicKey: string;
41
48
  }) => boolean | Promise<boolean>;
42
49
  export interface SignatureConfig {
43
- /** ed25519 public key shipped in the app binary. Absent = signing not adopted. */
44
- publicKey?: string;
50
+ /**
51
+ * ed25519 public key(s) shipped in the app binary. Absent = signing not
52
+ * adopted. An ARRAY pins several keys at once and a bundle is accepted when
53
+ * ANY of them verifies — this is what makes key rotation possible without an
54
+ * app-store release: ship a build pinning [old, new], start signing with new,
55
+ * drop old from the next build. With a single pinnable key, losing the
56
+ * private key meant no OTA capability until a new binary cleared review —
57
+ * the exact emergency OTA exists to solve.
58
+ */
59
+ publicKey?: string | string[];
45
60
  /** Host-provided ed25519 verification function. */
46
61
  verifier?: SignatureVerifier;
47
62
  }
@@ -50,7 +65,7 @@ export type SignatureOutcome = {
50
65
  reason: 'verified' | 'not_configured';
51
66
  } | {
52
67
  ok: false;
53
- reason: 'no_verifier' | 'invalid_signature' | 'missing_signature' | 'verifier_threw';
68
+ reason: 'no_verifier' | 'invalid_signature' | 'missing_signature' | 'verifier_threw' | 'no_keys';
54
69
  };
55
70
  /**
56
71
  * Decide whether a bundle may be staged.
@@ -69,17 +69,30 @@ declare class ScaleBunFacade {
69
69
  /**
70
70
  * Boot the OTA orchestrator when the init config asks for it.
71
71
  *
72
- * `publicSigningKey` is honoured here so signature enforcement is reachable
73
- * from configuration alone. The SDK ships no ed25519 implementation, so a
74
- * host that pins a key must also supply `ota.verifySignature`; pinning a key
75
- * without a verifier is fail-CLOSED by design (signature.ts) an update
76
- * that cannot be verified is not installed. We say that out loud rather than
77
- * letting the app discover it as a silent no-update condition.
72
+ * `publicSigningKey` / `publicSigningKeys` are honoured here so signature
73
+ * enforcement is reachable from configuration alone. The list form exists
74
+ * for key ROTATION: a build pinning [old, new] keeps verifying while the
75
+ * server moves to the new key, so replacing a key never needs an emergency
76
+ * store release. Both fields merge (deduplicated) into one pinned set.
77
+ *
78
+ * Verification comes from `ota.verifySignature` when supplied, else the
79
+ * built-in @noble-based verifier (optional peers). Pinning keys with
80
+ * NEITHER available is fail-CLOSED by design (signature.ts) — an update
81
+ * that cannot be verified is not installed. We say that out loud rather
82
+ * than letting the app discover it as a silent no-update condition.
78
83
  */
79
84
  private _maybeStartOta;
80
85
  private _autoEnableDebug;
81
86
  /** Boot the Phase 1 envelope tracking lane if an appId is configured. */
82
87
  private _maybeStartEventTracker;
88
+ /**
89
+ * Fold the native bridge's device facts into the analytics context once it answers.
90
+ *
91
+ * Detected values never overwrite a populated one with an empty one — `nativeDeviceContext`
92
+ * drops absent keys and `applyDetectedContext` ignores an empty patch — so a bridge that
93
+ * cannot answer leaves whatever `Platform.constants` already found.
94
+ */
95
+ private _applyNativeDeviceContext;
83
96
  /**
84
97
  * Android-only: read the Play Install Referrer once per install and route it into
85
98
  * attribution. `scalebun_click_id` → the deterministic click path; the full
@@ -140,6 +153,30 @@ declare class ScaleBunFacade {
140
153
  variant: (key: string) => string | null;
141
154
  };
142
155
  /** Convenience: emit a Phase 1 purchase event (revenue + currency + transaction_id). */
156
+ /**
157
+ * Declare which UI state the user is looking at, so taps are attributed to the surface they
158
+ * happened on rather than averaged across every variant of the screen.
159
+ *
160
+ * ScaleBun.setUiState('filter-sheet', 'open');
161
+ * ScaleBun.setUiState('checkout-step', 'payment');
162
+ *
163
+ * Call it when the state CHANGES — the value is read at tap time, so it only has to be right by
164
+ * the time the next tap lands. On React Native this is the ONLY source of UI state: unlike the web,
165
+ * there is no queryable accessibility tree that says a sheet is up, and guessing would produce a
166
+ * state key that is right on some apps and silently wrong on others.
167
+ *
168
+ * Names and values are identifiers, not content: they become query keys in the dashboard, so a
169
+ * person's name or a cart total does not belong in one. `;`, `:` and `|` are stripped and both
170
+ * halves are capped at 32 characters.
171
+ */
172
+ setUiState(name: string, value: string): void;
173
+ /**
174
+ * Stop reporting a UI state dimension — or, with no argument, all of them.
175
+ *
176
+ * Clear on screen unmount. A declaration left behind follows the user onto a surface where it means
177
+ * nothing, and every tap there is filed under a state that was not on screen.
178
+ */
179
+ clearUiState(name?: string): void;
143
180
  trackPurchase(input: {
144
181
  revenue: number;
145
182
  currency: string;
@@ -76,6 +76,29 @@ export interface Spec extends TurboModule {
76
76
  * `recreateReactContextInBackground()` (bridge). iOS: RCTReloadCommand.
77
77
  */
78
78
  restartApp(): void;
79
+ /**
80
+ * Verify a detached ed25519 signature in NATIVE code.
81
+ *
82
+ * Exists because the JS verifier runs inside the very bundle it protects —
83
+ * an attacker who lands one malicious bundle can neuter a JS check for every
84
+ * update after it. Platform crypto is outside the bundle's reach, and it also
85
+ * removes the runtime dependency on the optional @noble peers wherever the OS
86
+ * provides ed25519 (iOS 13+ CryptoKit everywhere; Android API 33+).
87
+ *
88
+ * All three arguments are lowercase hex, pre-validated by the JS caller:
89
+ * the 32-byte bundle SHA-256 (the signature is over its RAW BYTES, matching
90
+ * the CLI's signing contract), the 64-byte signature, the 32-byte public key.
91
+ *
92
+ * Resolves a verdict STRING, not a boolean, because "the signature is wrong"
93
+ * and "this OS cannot check" must never collapse into one value:
94
+ * 'valid' — signature verifies under the key
95
+ * 'invalid' — it does not; the caller must refuse the bundle
96
+ * 'unavailable' — this OS level has no ed25519 (Android < 33); the caller
97
+ * falls back to the JS verifier
98
+ * A rejected promise is machinery failure and the caller treats it as
99
+ * 'unavailable' — never as 'valid'.
100
+ */
101
+ verifyEd25519(messageHex: string, signatureHex: string, publicKeyHex: string): Promise<string>;
79
102
  }
80
103
  /**
81
104
  * Resolved via `get` (not `getEnforcing`) so the SDK degrades gracefully when
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalebun/react-native",
3
- "version": "1.10.5",
3
+ "version": "1.10.7",
4
4
  "description": "React Native SDK for ScaleBun",
5
5
  "main": "lib/commonjs/index",
6
6
  "module": "lib/module/index",
@@ -69,11 +69,15 @@
69
69
  "registry": "https://registry.npmjs.org/"
70
70
  },
71
71
  "devDependencies": {
72
+ "@babel/preset-env": "^8.0.2",
73
+ "@noble/ed25519": "^3.1.0",
74
+ "@noble/hashes": "^2.3.0",
72
75
  "@react-native-community/eslint-config": "^3.2.0",
73
76
  "@types/jest": "^29.5.12",
74
77
  "@types/node": "^25.3.3",
75
78
  "@types/react": "^18.2.0",
76
79
  "@types/react-native": "^0.73.0",
80
+ "babel-jest": "^30.4.1",
77
81
  "del-cli": "^5.1.0",
78
82
  "esbuild": "0.25.0",
79
83
  "eslint": "^8.57.0",
@@ -92,7 +96,9 @@
92
96
  "expo-router": ">=1.0.0",
93
97
  "react": ">=18.0.0",
94
98
  "react-native": ">=0.74.0",
95
- "react-native-view-shot": ">=3.0.0"
99
+ "react-native-view-shot": ">=3.0.0",
100
+ "@noble/ed25519": ">=2.0.0",
101
+ "@noble/hashes": ">=2.0.0"
96
102
  },
97
103
  "peerDependenciesMeta": {
98
104
  "react-native-view-shot": {
@@ -103,6 +109,12 @@
103
109
  },
104
110
  "expo-router": {
105
111
  "optional": true
112
+ },
113
+ "@noble/ed25519": {
114
+ "optional": true
115
+ },
116
+ "@noble/hashes": {
117
+ "optional": true
106
118
  }
107
119
  },
108
120
  "dependencies": {
@@ -139,6 +151,9 @@
139
151
  "lint": "eslint \"**/*.{js,ts,tsx}\"",
140
152
  "clean": "del-cli lib dist",
141
153
  "build": "bob build && node scripts/build-dist.mjs",
142
- "watch": "bob build --watch"
154
+ "watch": "bob build --watch",
155
+ "test:screens": "node --experimental-transform-types --import ./scripts/rn-globals.mjs --import ./scripts/register-ts-ext.mjs --test scripts/screen-detection.test.ts",
156
+ "test:uistate": "node --experimental-transform-types --import ./scripts/rn-globals.mjs --import ./scripts/register-ts-ext.mjs --test scripts/ui-state.test.ts",
157
+ "typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json"
143
158
  }
144
159
  }
@@ -387,6 +387,25 @@ export class EventTracker {
387
387
  this.persistQueue();
388
388
  }
389
389
 
390
+ /**
391
+ * Fold in device facts that were not knowable at init.
392
+ *
393
+ * `Platform.constants` answers Android synchronously, so its model and manufacturer ride the
394
+ * very first event. iOS exposes neither there — only the native bridge knows, and the bridge is
395
+ * async and may not be installed at all. Without this the iOS half of every install reported no
396
+ * model whatsoever.
397
+ *
398
+ * `buildEnvelope` reads `cfg.context` per event, so applying it here reaches every event from
399
+ * the next one onward. Detected keys WIN over whatever the integrator configured, for the same
400
+ * reason `mergeDeviceContext` inverts the usual precedence: a measurement must not be
401
+ * overridable by a guess. Empty patches are ignored so a bridge that answered with nothing
402
+ * cannot blank a value the static path already found.
403
+ */
404
+ applyDetectedContext(patch: Record<string, string>): void {
405
+ if (!patch || Object.keys(patch).length === 0) return;
406
+ this.cfg.context = { ...(this.cfg.context ?? {}), ...patch };
407
+ }
408
+
390
409
  // ─── internals ─────────────────────────────────────────────────────────────
391
410
 
392
411
  private buildEnvelope(eventName: string, properties?: Record<string, any>): Envelope {
@@ -85,6 +85,10 @@ type Field =
85
85
  | { kind: 'bool'; def?: boolean; opt?: true }
86
86
  | { kind: 'num'; min?: number; max?: number; int?: true; def?: number; opt?: true }
87
87
  | { kind: 'str'; min?: number; opt?: true }
88
+ // Array of strings. Message parity target is the vendored z's
89
+ // `z.array(z.string())`: "Expected array, received X" at the field path,
90
+ // "Expected string, received X" at [...path, index] per bad element.
91
+ | { kind: 'strArr'; opt?: true }
88
92
  | { kind: 'enum'; values: readonly string[]; opt?: true }
89
93
  | { kind: 'any'; def: () => unknown }
90
94
  | { kind: 'obj'; shape: Record<string, Field>; def?: true; opt?: true };
@@ -198,6 +202,11 @@ const SHAPE: Record<string, Field> = {
198
202
  checkOnForeground: bool(true),
199
203
  channelOverride: { kind: 'str', opt: true },
200
204
  publicSigningKey: { kind: 'str', opt: true },
205
+ // Rotation support: several pinned keys, any of which may verify a
206
+ // bundle. Ship a build pinning [old, new], re-sign server-side with
207
+ // new, drop old next release — no emergency store submission when a
208
+ // key must be replaced. Merged with publicSigningKey by the facade.
209
+ publicSigningKeys: { kind: 'strArr', opt: true },
201
210
  mandatoryBlocksUi: bool(false),
202
211
  },
203
212
  },
@@ -232,9 +241,11 @@ function parseField(
232
241
  ? 'Expected number, received undefined'
233
242
  : field.kind === 'str'
234
243
  ? 'Expected string, received undefined'
235
- : field.kind === 'enum'
236
- ? "Invalid enum value. Expected " + field.values.map((v) => `'${v}'`).join(' | ')
237
- : 'Expected object, received undefined',
244
+ : field.kind === 'strArr'
245
+ ? 'Expected array, received undefined'
246
+ : field.kind === 'enum'
247
+ ? "Invalid enum value. Expected " + field.values.map((v) => `'${v}'`).join(' | ')
248
+ : 'Expected object, received undefined',
238
249
  );
239
250
  }
240
251
 
@@ -250,6 +261,20 @@ function parseField(
250
261
  return fail(`String must contain at least ${field.min} character(s)`);
251
262
  }
252
263
  return { ok: true, value: input, present: true };
264
+ case 'strArr': {
265
+ // Mirrors the vendored ArraySchema exactly: element failures are
266
+ // reported at their index and fail the whole field (no partial
267
+ // arrays reach the output).
268
+ if (!Array.isArray(input)) return fail(`Expected array, received ${typeofName(input)}`);
269
+ let allOk = true;
270
+ for (let i = 0; i < input.length; i++) {
271
+ if (typeof input[i] !== 'string') {
272
+ issues.push({ path: [...path, i], message: `Expected string, received ${typeofName(input[i])}` });
273
+ allOk = false;
274
+ }
275
+ }
276
+ return allOk ? { ok: true, value: [...input], present: true } : { ok: false, present: true };
277
+ }
253
278
  case 'num':
254
279
  if (typeof input !== 'number' || Number.isNaN(input)) {
255
280
  return fail(`Expected number, received ${typeofName(input)}`);
@@ -381,6 +406,8 @@ export interface ScaleBunConfig {
381
406
  checkOnForeground: boolean;
382
407
  channelOverride?: string;
383
408
  publicSigningKey?: string;
409
+ /** Additional pinned keys for rotation; unioned with publicSigningKey. */
410
+ publicSigningKeys?: string[];
384
411
  mandatoryBlocksUi: boolean;
385
412
  };
386
413
  }
@@ -3,4 +3,4 @@
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 const SDK_VERSION = '1.10.5';
6
+ export const SDK_VERSION = '1.10.7';
@@ -114,6 +114,10 @@ export function staticDeviceContext(): Record<string, string> {
114
114
  if (c.Model) out.device_model = String(c.Model);
115
115
  if (c.Manufacturer) out.manufacturer = String(c.Manufacturer);
116
116
  else if (c.Brand) out.manufacturer = String(c.Brand);
117
+ // iOS exposes no manufacturer because it does not need to: every device running iOS is Apple's.
118
+ // A tautology, not a guess — and it means the field is populated on event #1 rather than waiting
119
+ // for a bridge that may never answer.
120
+ else if (out.platform_os === 'ios') out.manufacturer = 'Apple';
117
121
  return out;
118
122
  }
119
123
 
@@ -122,11 +126,13 @@ export function nativeDeviceContext(info: {
122
126
  platform?: string;
123
127
  osVersion?: string;
124
128
  deviceModel?: string;
129
+ manufacturer?: string;
125
130
  } | null): Record<string, string> {
126
131
  const out: Record<string, string> = {};
127
132
  if (!info) return out;
128
133
  if (info.osVersion) out.os_version = info.osVersion;
129
134
  if (info.deviceModel) out.device_model = info.deviceModel;
135
+ if (info.manufacturer) out.manufacturer = info.manufacturer;
130
136
  return out;
131
137
  }
132
138
 
@@ -503,9 +503,31 @@ export function ScaleBunDebugRoot({
503
503
  const { AutoScreenDetector } = require('../navigation/AutoScreenDetector');
504
504
  screen = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
505
505
  } catch { /* no-throw */ }
506
+ /**
507
+ * UI STATE, read HERE and not later.
508
+ *
509
+ * A tap belongs to the surface that was on screen when the finger landed: tapping the
510
+ * filter button while the sheet is DOWN belongs to `closed`, because that is what the
511
+ * user was looking at when they reached for it. Reading it after the handler has run
512
+ * moves every "open the thing" tap into the state it created — the one state it
513
+ * certainly does not belong to.
514
+ *
515
+ * Declared-only on this platform (see uiState.ts): absent means not captured, which
516
+ * the dashboard keeps distinct from "nothing was open".
517
+ */
518
+ let ui: string | undefined;
519
+ try {
520
+ const { uiStateSignature } = require('./uiState');
521
+ ui = uiStateSignature();
522
+ } catch { /* no-throw: a tap must never be lost to state capture */ }
506
523
  emitAutomaticEvent('element_interacted', {
507
524
  gesture_type: gestureType,
508
525
  screen_name: screen,
526
+ /* THIS MAP ENUMERATES. A field added to the payload and forgotten here reaches the
527
+ backend as undefined with no error anywhere — the recurring defect class in this
528
+ codebase. `ui` is the key the grid aggregate reads for state, byte-identical to
529
+ the web SDK's, so one dashboard control queries both platforms. */
530
+ ui,
509
531
  // testID/nativeID is an author-controlled stable identifier.
510
532
  // Accessibility labels and rendered text are deliberately omitted.
511
533
  target_id: targetInfo?.testId,
@@ -0,0 +1,84 @@
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
+ /** Host-declared state dimensions. The only source on this platform. */
32
+ const declared = new Map<string, string>();
33
+
34
+ /**
35
+ * `;` and `:` are the wire separators and cannot appear inside a name or value; the length is bounded.
36
+ *
37
+ * Names and values are IDENTIFIERS, not content. They become aggregation keys in the dashboard, so a
38
+ * value carrying a person's name or a cart total would put user data into a query key — and into every
39
+ * chart legend built from it.
40
+ */
41
+ const clean = (s: string): string =>
42
+ typeof s === 'string' ? s.replace(/[;:|]/g, '').trim().slice(0, 32) : '';
43
+
44
+ /**
45
+ * Declare the state of one UI dimension.
46
+ *
47
+ * Call it when the state CHANGES, not on every render: the value is read at interaction time, so it
48
+ * only has to be correct by the time the next tap lands.
49
+ */
50
+ export function setUiState(name: string, value: string): void {
51
+ const n = clean(name);
52
+ const v = clean(value);
53
+ if (n && v) declared.set(n, v);
54
+ }
55
+
56
+ /**
57
+ * Stop reporting a dimension — or, with no name, all of them.
58
+ *
59
+ * Later taps carry no value for it, which is not the same as carrying a value. Clearing all is what a
60
+ * screen unmount wants: declarations from the previous screen would otherwise follow the user onto a
61
+ * surface where they mean nothing, and every tap there would be filed under a state that was not on
62
+ * screen.
63
+ */
64
+ export function clearUiState(name?: string): void {
65
+ if (name === undefined) declared.clear();
66
+ else declared.delete(clean(name));
67
+ }
68
+
69
+ /**
70
+ * The signature for the interaction happening RIGHT NOW, or undefined when nothing is declared.
71
+ *
72
+ * undefined rather than '' deliberately — see the header. '' is the web's "we looked and nothing was
73
+ * open"; on RN there is nothing to look at, so the honest answer is silence.
74
+ */
75
+ export function uiStateSignature(): string | undefined {
76
+ if (!declared.size) return undefined;
77
+ /* SORTED, or the same surface produces different signatures depending on the order the host happened
78
+ to declare things in, and every count fragments into several that mean nothing. */
79
+ return [...declared.keys()]
80
+ .sort()
81
+ .map((k) => `${k}:${declared.get(k)}`)
82
+ .join(';')
83
+ .slice(0, 96);
84
+ }
@@ -6,6 +6,50 @@
6
6
  * of truth; conformance tests in both repos assert against them to prevent drift.
7
7
  */
8
8
 
9
+ /**
10
+ * ═══════════════════════════════════════════════════════════════════════════
11
+ * OTA-TELEMETRY-SPEC — remaining telemetry fixes (from the 2026-08 audit)
12
+ * ═══════════════════════════════════════════════════════════════════════════
13
+ * These close the SDK half of spec §84-86. All are ADDITIVE + backward-compatible
14
+ * (new optional fields, new enum members, new no-throw emit calls); old installed
15
+ * clients omit them and the backend ingests nullable/unknown fields. Each ALTERS
16
+ * DEVICE RUNTIME BEHAVIOR, so land them behind a real RN build + device/kill-test
17
+ * (Metro cannot exercise the boot-guard) — this file only carries the contract
18
+ * types + this spec, not the runtime wiring.
19
+ *
20
+ * 1. releaseId end-to-end (CRITICAL — root of the dashboard funnel mismatch)
21
+ * Backend already serves it: OtaBundlePayload.releaseId (done, this file +
22
+ * ota-check.service.ts). SDK TODO:
23
+ * - store payload.releaseId on the downloaded/installed bundle state, and
24
+ * - set `releaseId: bundle.releaseId` in the delivery mapper
25
+ * OtaOrchestrator.ts deliverOtaEvents (~:105) — currently only bundleId.
26
+ * Then ota_events carry releaseId and the funnel keys by release.
27
+ *
28
+ * 2. Emit CHECK + OFFERED (funnel top is currently unmeasurable)
29
+ * - CHECK: emit at the start of checkForUpdate (OtaOrchestrator.ts ~:480),
30
+ * before the fetch. ('CHECK' type already exists — no emit site today.)
31
+ * - OFFERED: add 'OFFERED' to OtaEventType, emit when checkRes.action ===
32
+ * 'DOWNLOAD' (~:619) before download begins.
33
+ *
34
+ * 3. Emit BOOT_SUCCESS (honest activation signal)
35
+ * Add 'BOOT_SUCCESS' to OtaEventType and emit it from the boot-guard
36
+ * markHealthy path (OtaOrchestrator.ts ~:863). INSTALLED (~:794) is emitted
37
+ * optimistically BEFORE the bundle boots; keep it (it means "staged+swapped")
38
+ * but let the dashboard measure real activation on BOOT_SUCCESS. Optionally
39
+ * add 'VERIFIED' after signature check (~:635).
40
+ *
41
+ * 4. Stamp the running OTA bundle onto session/crash telemetry
42
+ * SessionMetadata.bundleId is the NATIVE app package id, not the OTA bundle.
43
+ * Add optional otaBundleId?/otaBundleVersion? (distinct fields — do NOT
44
+ * overload bundleId) sourced from otaOrchestrator.getCurrentBundle() at
45
+ * session start (SessionManager.ts ~:451), so crashes attribute to the
46
+ * running bundle/version (release-health crash impact).
47
+ *
48
+ * Do NOT repurpose errorCode (it already collapses failure-error vs rollback-
49
+ * reason); add a new optional field if the two must be distinguished.
50
+ * ═══════════════════════════════════════════════════════════════════════════
51
+ */
52
+
9
53
  // ── SDK → backend (POST /sdk/ota/check) ──────────────────────────────────────
10
54
  export interface OtaCheckRequest {
11
55
  appVersion: string;
@@ -62,6 +106,13 @@ export interface OtaCheckResponse {
62
106
  export interface OtaBundlePayload {
63
107
  id: string;
64
108
  version: number;
109
+ /**
110
+ * The release this bundle is served AS (the backend now includes it — see
111
+ * ota.contracts.ts / ota-check.service.ts). Optional, for backward-compat with
112
+ * older backends. Carry it onto the emitted OtaEventItem.releaseId so telemetry
113
+ * is attributable by RELEASE, not just bundle. See OTA-TELEMETRY-SPEC below.
114
+ */
115
+ releaseId?: string;
65
116
  url: string; // presigned GET — short-TTL
66
117
  size: number;
67
118
  sha256: string;