@scalebun/react-native 1.10.6 → 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.
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +75 -0
- package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +12 -0
- package/dist/scalebun.full.js +432 -51
- package/dist/scalebun.slim.js +432 -51
- package/ios/Ota/ScaleBunOtaBridge.mm +6 -0
- package/ios/Ota/ScaleBunOtaModule.swift +67 -0
- package/lib/commonjs/core/config/schema.js +34 -1
- package/lib/commonjs/core/constants/version.js +1 -1
- package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +24 -0
- package/lib/commonjs/features/journey/uiState.js +87 -0
- package/lib/commonjs/features/ota/crypto/builtinVerifier.js +248 -0
- package/lib/commonjs/features/ota/crypto/loadEd25519.js +40 -0
- package/lib/commonjs/features/ota/crypto/loadSha512.js +40 -0
- package/lib/commonjs/features/ota/crypto/nativeVerifier.js +121 -0
- package/lib/commonjs/features/ota/signature.js +87 -27
- package/lib/commonjs/metro/serializerCompose.js +32 -0
- package/lib/commonjs/public/ScaleBunFacade.js +74 -11
- package/lib/module/core/config/schema.js +34 -1
- package/lib/module/core/constants/version.js +1 -1
- package/lib/module/features/journey/ScaleBunDebugRoot.js +24 -0
- package/lib/module/features/journey/uiState.js +79 -0
- package/lib/module/features/ota/crypto/builtinVerifier.js +240 -0
- package/lib/module/features/ota/crypto/loadEd25519.js +34 -0
- package/lib/module/features/ota/crypto/loadSha512.js +34 -0
- package/lib/module/features/ota/crypto/nativeVerifier.js +113 -0
- package/lib/module/features/ota/signature.js +87 -27
- package/lib/module/metro/serializerCompose.js +32 -0
- package/lib/module/public/ScaleBunFacade.js +74 -11
- package/lib/typescript/core/config/schema.d.ts +2 -0
- package/lib/typescript/core/constants/version.d.ts +1 -1
- package/lib/typescript/features/journey/uiState.d.ts +53 -0
- package/lib/typescript/features/ota/OtaTypes.d.ts +50 -0
- package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +53 -0
- package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +30 -0
- package/lib/typescript/features/ota/crypto/loadSha512.d.ts +15 -0
- package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +35 -0
- package/lib/typescript/features/ota/signature.d.ts +22 -7
- package/lib/typescript/public/ScaleBunFacade.d.ts +35 -6
- package/lib/typescript/specs/NativeScaleBunOta.d.ts +23 -0
- package/package.json +18 -3
- package/src/core/config/schema.ts +30 -3
- package/src/core/constants/version.ts +1 -1
- package/src/features/journey/ScaleBunDebugRoot.tsx +22 -0
- package/src/features/journey/uiState.ts +84 -0
- package/src/features/ota/OtaTypes.ts +51 -0
- package/src/features/ota/crypto/builtinVerifier.ts +257 -0
- package/src/features/ota/crypto/loadEd25519.ts +41 -0
- package/src/features/ota/crypto/loadSha512.ts +35 -0
- package/src/features/ota/crypto/nativeVerifier.ts +117 -0
- package/src/features/ota/signature.ts +108 -25
- package/src/metro/serializerCompose.ts +38 -2
- package/src/public/ScaleBunFacade.ts +87 -13
- package/src/specs/NativeScaleBunOta.ts +24 -0
|
@@ -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.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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 {
|
|
@@ -215,12 +215,17 @@ class ScaleBunFacade {
|
|
|
215
215
|
/**
|
|
216
216
|
* Boot the OTA orchestrator when the init config asks for it.
|
|
217
217
|
*
|
|
218
|
-
* `publicSigningKey`
|
|
219
|
-
* from configuration alone. The
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
218
|
+
* `publicSigningKey` / `publicSigningKeys` are honoured here so signature
|
|
219
|
+
* enforcement is reachable from configuration alone. The list form exists
|
|
220
|
+
* for key ROTATION: a build pinning [old, new] keeps verifying while the
|
|
221
|
+
* server moves to the new key, so replacing a key never needs an emergency
|
|
222
|
+
* store release. Both fields merge (deduplicated) into one pinned set.
|
|
223
|
+
*
|
|
224
|
+
* Verification comes from `ota.verifySignature` when supplied, else the
|
|
225
|
+
* built-in @noble-based verifier (optional peers). Pinning keys with
|
|
226
|
+
* NEITHER available is fail-CLOSED by design (signature.ts) — an update
|
|
227
|
+
* that cannot be verified is not installed. We say that out loud rather
|
|
228
|
+
* than letting the app discover it as a silent no-update condition.
|
|
224
229
|
*/
|
|
225
230
|
_maybeStartOta(rawConfig) {
|
|
226
231
|
const ota = rawConfig?.ota;
|
|
@@ -232,15 +237,34 @@ class ScaleBunFacade {
|
|
|
232
237
|
const {
|
|
233
238
|
otaOrchestrator
|
|
234
239
|
} = require('../features/ota/OtaOrchestrator');
|
|
235
|
-
const
|
|
240
|
+
const rawSingle = ota.publicSigningKey;
|
|
241
|
+
const rawList = ota.publicSigningKeys;
|
|
242
|
+
const publicKeys = Array.from(new Set([...(typeof rawSingle === 'string' && rawSingle ? [rawSingle] : []), ...(Array.isArray(rawList) ? rawList : [])].filter(k => typeof k === 'string' && k.trim().length > 0)));
|
|
243
|
+
// Signing was REQUESTED if a usable key exists, or the host passed a
|
|
244
|
+
// keys array at all — an empty one included. `publicSigningKeys: []`
|
|
245
|
+
// is a broken attempt to enable signing, and it must flow through to
|
|
246
|
+
// signature.ts's fail-closed handling, not silently disable signing.
|
|
247
|
+
const signingRequested = publicKeys.length > 0 || Array.isArray(rawList);
|
|
236
248
|
const verifier = ota.verifySignature;
|
|
237
|
-
if (
|
|
238
|
-
|
|
249
|
+
if (signingRequested && typeof verifier !== 'function') {
|
|
250
|
+
// A host verifier is optional since the built-in one landed — the
|
|
251
|
+
// old unconditional warning here told every correctly-configured
|
|
252
|
+
// app that its updates would be rejected. Warn only when neither
|
|
253
|
+
// verifier can actually be resolved.
|
|
254
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
255
|
+
const {
|
|
256
|
+
getBuiltinVerifier
|
|
257
|
+
} = require('../features/ota/crypto/builtinVerifier');
|
|
258
|
+
if (!getBuiltinVerifier()) {
|
|
259
|
+
_internalLogger.logger.warn('[ScaleBun] A signing key is pinned but no signature verifier is available. ' + 'Signature checking is fail-closed: updates will be REJECTED until one exists. ' + 'Install the optional peers `@noble/ed25519` + `@noble/hashes` (no further ' + 'code needed), or supply `ota.verifySignature`.');
|
|
260
|
+
}
|
|
239
261
|
}
|
|
240
262
|
otaOrchestrator.init({
|
|
241
|
-
...(
|
|
263
|
+
...(signingRequested ? {
|
|
242
264
|
signature: {
|
|
243
|
-
|
|
265
|
+
// Single key stays a plain string — the shape every
|
|
266
|
+
// existing consumer and test already handles.
|
|
267
|
+
publicKey: publicKeys.length === 1 ? publicKeys[0] : publicKeys,
|
|
244
268
|
verifier
|
|
245
269
|
}
|
|
246
270
|
} : {}),
|
|
@@ -477,6 +501,45 @@ class ScaleBunFacade {
|
|
|
477
501
|
}
|
|
478
502
|
|
|
479
503
|
/** Convenience: emit a Phase 1 purchase event (revenue + currency + transaction_id). */
|
|
504
|
+
/**
|
|
505
|
+
* Declare which UI state the user is looking at, so taps are attributed to the surface they
|
|
506
|
+
* happened on rather than averaged across every variant of the screen.
|
|
507
|
+
*
|
|
508
|
+
* ScaleBun.setUiState('filter-sheet', 'open');
|
|
509
|
+
* ScaleBun.setUiState('checkout-step', 'payment');
|
|
510
|
+
*
|
|
511
|
+
* Call it when the state CHANGES — the value is read at tap time, so it only has to be right by
|
|
512
|
+
* the time the next tap lands. On React Native this is the ONLY source of UI state: unlike the web,
|
|
513
|
+
* there is no queryable accessibility tree that says a sheet is up, and guessing would produce a
|
|
514
|
+
* state key that is right on some apps and silently wrong on others.
|
|
515
|
+
*
|
|
516
|
+
* Names and values are identifiers, not content: they become query keys in the dashboard, so a
|
|
517
|
+
* person's name or a cart total does not belong in one. `;`, `:` and `|` are stripped and both
|
|
518
|
+
* halves are capped at 32 characters.
|
|
519
|
+
*/
|
|
520
|
+
setUiState(name, value) {
|
|
521
|
+
try {
|
|
522
|
+
const {
|
|
523
|
+
setUiState
|
|
524
|
+
} = require('../features/journey/uiState');
|
|
525
|
+
setUiState(name, value);
|
|
526
|
+
} catch {/* no-throw: a state declaration must never break the host */}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Stop reporting a UI state dimension — or, with no argument, all of them.
|
|
531
|
+
*
|
|
532
|
+
* Clear on screen unmount. A declaration left behind follows the user onto a surface where it means
|
|
533
|
+
* nothing, and every tap there is filed under a state that was not on screen.
|
|
534
|
+
*/
|
|
535
|
+
clearUiState(name) {
|
|
536
|
+
try {
|
|
537
|
+
const {
|
|
538
|
+
clearUiState
|
|
539
|
+
} = require('../features/journey/uiState');
|
|
540
|
+
clearUiState(name);
|
|
541
|
+
} catch {/* no-throw */}
|
|
542
|
+
}
|
|
480
543
|
trackPurchase(input) {
|
|
481
544
|
(0, _crashSafe.noThrow)(() => this._eventTracker?.trackPurchase(input));
|
|
482
545
|
}
|
|
@@ -289,6 +289,14 @@ const SHAPE = {
|
|
|
289
289
|
kind: 'str',
|
|
290
290
|
opt: true
|
|
291
291
|
},
|
|
292
|
+
// Rotation support: several pinned keys, any of which may verify a
|
|
293
|
+
// bundle. Ship a build pinning [old, new], re-sign server-side with
|
|
294
|
+
// new, drop old next release — no emergency store submission when a
|
|
295
|
+
// key must be replaced. Merged with publicSigningKey by the facade.
|
|
296
|
+
publicSigningKeys: {
|
|
297
|
+
kind: 'strArr',
|
|
298
|
+
opt: true
|
|
299
|
+
},
|
|
292
300
|
mandatoryBlocksUi: bool(false)
|
|
293
301
|
}
|
|
294
302
|
}
|
|
@@ -328,7 +336,7 @@ function parseField(field, input, path, issues) {
|
|
|
328
336
|
present: true
|
|
329
337
|
};
|
|
330
338
|
// Required with no default (matches the base schemas' type errors on undefined).
|
|
331
|
-
return fail(field.kind === 'bool' ? 'Expected boolean, received undefined' : field.kind === 'num' ? 'Expected number, received undefined' : field.kind === 'str' ? 'Expected string, received undefined' : field.kind === 'enum' ? "Invalid enum value. Expected " + field.values.map(v => `'${v}'`).join(' | ') : 'Expected object, received undefined');
|
|
339
|
+
return fail(field.kind === 'bool' ? 'Expected boolean, received undefined' : field.kind === 'num' ? 'Expected number, received undefined' : field.kind === 'str' ? 'Expected string, received undefined' : field.kind === 'strArr' ? 'Expected array, received undefined' : field.kind === 'enum' ? "Invalid enum value. Expected " + field.values.map(v => `'${v}'`).join(' | ') : 'Expected object, received undefined');
|
|
332
340
|
}
|
|
333
341
|
switch (field.kind) {
|
|
334
342
|
case 'any':
|
|
@@ -354,6 +362,31 @@ function parseField(field, input, path, issues) {
|
|
|
354
362
|
value: input,
|
|
355
363
|
present: true
|
|
356
364
|
};
|
|
365
|
+
case 'strArr':
|
|
366
|
+
{
|
|
367
|
+
// Mirrors the vendored ArraySchema exactly: element failures are
|
|
368
|
+
// reported at their index and fail the whole field (no partial
|
|
369
|
+
// arrays reach the output).
|
|
370
|
+
if (!Array.isArray(input)) return fail(`Expected array, received ${typeofName(input)}`);
|
|
371
|
+
let allOk = true;
|
|
372
|
+
for (let i = 0; i < input.length; i++) {
|
|
373
|
+
if (typeof input[i] !== 'string') {
|
|
374
|
+
issues.push({
|
|
375
|
+
path: [...path, i],
|
|
376
|
+
message: `Expected string, received ${typeofName(input[i])}`
|
|
377
|
+
});
|
|
378
|
+
allOk = false;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return allOk ? {
|
|
382
|
+
ok: true,
|
|
383
|
+
value: [...input],
|
|
384
|
+
present: true
|
|
385
|
+
} : {
|
|
386
|
+
ok: false,
|
|
387
|
+
present: true
|
|
388
|
+
};
|
|
389
|
+
}
|
|
357
390
|
case 'num':
|
|
358
391
|
if (typeof input !== 'number' || Number.isNaN(input)) {
|
|
359
392
|
return fail(`Expected number, received ${typeofName(input)}`);
|
|
@@ -496,9 +496,33 @@ export function ScaleBunDebugRoot({
|
|
|
496
496
|
} = require('../navigation/AutoScreenDetector');
|
|
497
497
|
screen = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
|
|
498
498
|
} catch {/* no-throw */}
|
|
499
|
+
/**
|
|
500
|
+
* UI STATE, read HERE and not later.
|
|
501
|
+
*
|
|
502
|
+
* A tap belongs to the surface that was on screen when the finger landed: tapping the
|
|
503
|
+
* filter button while the sheet is DOWN belongs to `closed`, because that is what the
|
|
504
|
+
* user was looking at when they reached for it. Reading it after the handler has run
|
|
505
|
+
* moves every "open the thing" tap into the state it created — the one state it
|
|
506
|
+
* certainly does not belong to.
|
|
507
|
+
*
|
|
508
|
+
* Declared-only on this platform (see uiState.ts): absent means not captured, which
|
|
509
|
+
* the dashboard keeps distinct from "nothing was open".
|
|
510
|
+
*/
|
|
511
|
+
let ui;
|
|
512
|
+
try {
|
|
513
|
+
const {
|
|
514
|
+
uiStateSignature
|
|
515
|
+
} = require('./uiState');
|
|
516
|
+
ui = uiStateSignature();
|
|
517
|
+
} catch {/* no-throw: a tap must never be lost to state capture */}
|
|
499
518
|
emitAutomaticEvent('element_interacted', {
|
|
500
519
|
gesture_type: gestureType,
|
|
501
520
|
screen_name: screen,
|
|
521
|
+
/* THIS MAP ENUMERATES. A field added to the payload and forgotten here reaches the
|
|
522
|
+
backend as undefined with no error anywhere — the recurring defect class in this
|
|
523
|
+
codebase. `ui` is the key the grid aggregate reads for state, byte-identical to
|
|
524
|
+
the web SDK's, so one dashboard control queries both platforms. */
|
|
525
|
+
ui,
|
|
502
526
|
// testID/nativeID is an author-controlled stable identifier.
|
|
503
527
|
// Accessibility labels and rendered text are deliberately omitted.
|
|
504
528
|
target_id: targetInfo?.testId,
|
|
@@ -0,0 +1,79 @@
|
|
|
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();
|
|
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 => typeof s === 'string' ? s.replace(/[;:|]/g, '').trim().slice(0, 32) : '';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Declare the state of one UI dimension.
|
|
45
|
+
*
|
|
46
|
+
* Call it when the state CHANGES, not on every render: the value is read at interaction time, so it
|
|
47
|
+
* only has to be correct by the time the next tap lands.
|
|
48
|
+
*/
|
|
49
|
+
export function setUiState(name, value) {
|
|
50
|
+
const n = clean(name);
|
|
51
|
+
const v = clean(value);
|
|
52
|
+
if (n && v) declared.set(n, v);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Stop reporting a dimension — or, with no name, all of them.
|
|
57
|
+
*
|
|
58
|
+
* Later taps carry no value for it, which is not the same as carrying a value. Clearing all is what a
|
|
59
|
+
* screen unmount wants: declarations from the previous screen would otherwise follow the user onto a
|
|
60
|
+
* surface where they mean nothing, and every tap there would be filed under a state that was not on
|
|
61
|
+
* screen.
|
|
62
|
+
*/
|
|
63
|
+
export function clearUiState(name) {
|
|
64
|
+
if (name === undefined) declared.clear();else declared.delete(clean(name));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The signature for the interaction happening RIGHT NOW, or undefined when nothing is declared.
|
|
69
|
+
*
|
|
70
|
+
* undefined rather than '' deliberately — see the header. '' is the web's "we looked and nothing was
|
|
71
|
+
* open"; on RN there is nothing to look at, so the honest answer is silence.
|
|
72
|
+
*/
|
|
73
|
+
export function uiStateSignature() {
|
|
74
|
+
if (!declared.size) return undefined;
|
|
75
|
+
/* SORTED, or the same surface produces different signatures depending on the order the host happened
|
|
76
|
+
to declare things in, and every count fragments into several that mean nothing. */
|
|
77
|
+
return [...declared.keys()].sort().map(k => `${k}:${declared.get(k)}`).join(';').slice(0, 96);
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=uiState.js.map
|