pulse-updates 1.3.8 → 1.3.9

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.
@@ -119,6 +119,122 @@ async function hashFileSHA256(filePath) {
119
119
  });
120
120
  }
121
121
 
122
+ const NATIVE_MODULE_EVIDENCE_VERSION = 1;
123
+ const NATIVE_MODULE_NAME = /^[\w.$-]{1,128}$/;
124
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
125
+
126
+ function validateNativeModules(value, source) {
127
+ if (!Array.isArray(value) || value.length > 4096) {
128
+ throw new Error(`Invalid native-module evidence in ${source}: nativeModules must be an array`);
129
+ }
130
+ if (!value.every((name) => typeof name === 'string' && NATIVE_MODULE_NAME.test(name))) {
131
+ throw new Error(`Invalid native-module evidence in ${source}: nativeModules contains an invalid name`);
132
+ }
133
+ const canonical = [...new Set(value)].sort();
134
+ if (canonical.length !== value.length || canonical.some((name, index) => name !== value[index])) {
135
+ throw new Error(`Invalid native-module evidence in ${source}: nativeModules must be sorted and unique`);
136
+ }
137
+ return [...value];
138
+ }
139
+
140
+ function validateEvidenceContext(value, config, source) {
141
+ if (value.schemaVersion !== NATIVE_MODULE_EVIDENCE_VERSION
142
+ || value.platform !== config.platform
143
+ || value.runtimeVersion !== config.runtimeVersion
144
+ || value.channel !== config.channel) {
145
+ throw new Error(
146
+ `Invalid native-module evidence in ${source}: release context does not match ` +
147
+ `${config.runtimeVersion}/${config.platform}/${config.channel}`
148
+ );
149
+ }
150
+ if (!SHA256_HEX.test(value.scannedBundleSha256 ?? '')
151
+ || !SHA256_HEX.test(value.uploadBundleSha256 ?? '')) {
152
+ throw new Error(`Invalid native-module evidence in ${source}: bundle SHA-256 is missing or invalid`);
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Persist the capability scan beside a hash of the exact post-Hermes artifact. A wrapper that
158
+ * deliberately transforms that artifact (for example Bastion) must first verify
159
+ * scannedBundleSha256, then replace uploadBundleSha256 with the transformed artifact hash. The
160
+ * --skip-bundle pass verifies the latter before it creates anything server-side.
161
+ */
162
+ export async function writeNativeModulesEvidence(evidencePath, config, bundlePath, nativeModules) {
163
+ const resolved = path.resolve(evidencePath);
164
+ const canonicalModules = validateNativeModules(nativeModules, resolved);
165
+ const bundleSha256 = await hashFileSHA256(bundlePath);
166
+ const evidence = {
167
+ schemaVersion: NATIVE_MODULE_EVIDENCE_VERSION,
168
+ platform: config.platform,
169
+ runtimeVersion: config.runtimeVersion,
170
+ channel: config.channel,
171
+ scannedBundleSha256: bundleSha256,
172
+ uploadBundleSha256: bundleSha256,
173
+ nativeModules: canonicalModules,
174
+ };
175
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
176
+ const temporary = `${resolved}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
177
+ try {
178
+ fs.writeFileSync(temporary, `${JSON.stringify(evidence)}\n`, { mode: 0o600 });
179
+ fs.renameSync(temporary, resolved);
180
+ } finally {
181
+ // renameSync removes the source on success. On every failure path, including a destination
182
+ // that becomes invalid concurrently, do not strand capability evidence in a readable temp file.
183
+ try { fs.unlinkSync(temporary); } catch (error) {
184
+ if (error?.code !== 'ENOENT') throw error;
185
+ }
186
+ }
187
+ return evidence;
188
+ }
189
+
190
+ /** Read and bind capability evidence to the exact artifact about to be uploaded. */
191
+ export async function readNativeModulesEvidence(evidencePath, config, bundlePath) {
192
+ const resolved = path.resolve(evidencePath);
193
+ let evidence;
194
+ try {
195
+ evidence = JSON.parse(fs.readFileSync(resolved, 'utf8'));
196
+ } catch (error) {
197
+ throw new Error(`Native-module evidence is missing or unreadable at ${resolved}: ${error.message}`);
198
+ }
199
+ if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) {
200
+ throw new Error(`Invalid native-module evidence in ${resolved}: expected an object`);
201
+ }
202
+ validateEvidenceContext(evidence, config, resolved);
203
+ const nativeModules = validateNativeModules(evidence.nativeModules, resolved);
204
+ const uploadBundleSha256 = await hashFileSHA256(bundlePath);
205
+ if (uploadBundleSha256 !== evidence.uploadBundleSha256) {
206
+ throw new Error(
207
+ `Native-module evidence does not describe the upload artifact: expected ` +
208
+ `${evidence.uploadBundleSha256}, got ${uploadBundleSha256}`
209
+ );
210
+ }
211
+ return { ...evidence, nativeModules };
212
+ }
213
+
214
+ export function createReleaseRequest(config, metadata, nativeModules, nativeModulesVerified) {
215
+ return {
216
+ runtimeVersion: config.runtimeVersion,
217
+ platform: config.platform,
218
+ channel: config.channel,
219
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
220
+ // A verified empty array is evidence too. Legacy --skip-bundle calls without the handoff file
221
+ // retain their old omitted-field behaviour, while guarded wrappers cannot silently bypass ABI
222
+ // preflight by turning a real scan into an absent fingerprint.
223
+ nativeModules: nativeModulesVerified
224
+ ? nativeModules
225
+ : (nativeModules.length ? nativeModules : undefined),
226
+ // Which binary this bundle is for, when it is only for one. A runtime version is not always a
227
+ // single build — on iOS it is MARKETING_VERSION, which stays put across re-submissions — and a
228
+ // bundle wrapped with one build's key cannot be opened by another, which stops that app from
229
+ // starting rather than merely leaving it un-updated. Naming the build keeps the manifest from
230
+ // handing it to the wrong one. Omitted for unwrapped bundles, which every build can load.
231
+ // Spread rather than assign: an explicit `nativeBuild: undefined` is invisible over the wire
232
+ // but not to a caller comparing the request it built, and unwrapped bundles have no build to
233
+ // name.
234
+ ...(config.nativeBuild ? { nativeBuild: config.nativeBuild } : {}),
235
+ };
236
+ }
237
+
122
238
  /**
123
239
  * Calculate MD5 hash of a file (for asset key - matches Metro/expo format)
124
240
  */
@@ -826,6 +942,10 @@ function loadConfig(options) {
826
942
  apiKey: options['api-key'] || process.env.PULSE_API_KEY || fileConfig.apiKey,
827
943
  runtimeVersion,
828
944
  platform,
945
+ // Only ever what the operator states. It could be read from the pbxproj or build.gradle, but a
946
+ // guess here is the failure it exists to prevent: the checkout is not reliably the build the
947
+ // update is for, which is the whole reason a runtime version is not enough on its own.
948
+ nativeBuild: options['native-build'] || process.env.PULSE_NATIVE_BUILD || fileConfig.nativeBuild,
829
949
  channel: options.channel || fileConfig.channel || 'production',
830
950
  bundleDir: options['bundle-dir'] || fileConfig.bundleDir || './dist',
831
951
  entryFile: options['entry-file'] || fileConfig.entryFile || 'index.ts',
@@ -844,7 +964,7 @@ function loadConfig(options) {
844
964
  /**
845
965
  * Main publish command
846
966
  */
847
- async function publish(options) {
967
+ export async function publish(options) {
848
968
  const config = loadConfig(options);
849
969
 
850
970
  // Validate required config
@@ -873,25 +993,60 @@ async function publish(options) {
873
993
  log(`Bundle Dir: ${bundleDir}\n`);
874
994
 
875
995
  let nativeModules = [];
996
+ let nativeModulesVerified = false;
997
+ const nativeModulesFileOption = options['native-modules-file'];
998
+ if (nativeModulesFileOption !== undefined
999
+ && (typeof nativeModulesFileOption !== 'string' || !nativeModulesFileOption.trim())) {
1000
+ throw new Error('--native-modules-file requires a path');
1001
+ }
1002
+ const nativeModulesFile = nativeModulesFileOption
1003
+ ? path.resolve(nativeModulesFileOption)
1004
+ : null;
1005
+ const bundlePath = path.join(bundleDir, config.platform, 'index.bundle');
876
1006
 
877
1007
  // Step 1: Create bundle (unless skipped)
878
1008
  if (!config.skipBundle) {
879
1009
  logStep('1/6', 'Creating bundle...');
880
- const bundlePath = createBundle(config.platform, bundleDir, config.entryFile);
881
- logSuccess(`Bundle created: ${bundlePath}`);
1010
+ const createdBundlePath = createBundle(config.platform, bundleDir, config.entryFile);
1011
+ logSuccess(`Bundle created: ${createdBundlePath}`);
882
1012
 
883
1013
  // Crash-prediction fingerprint: extract native module references from the plain JS bundle
884
1014
  // (must happen BEFORE Hermes bytecode compilation, while the bundle is still readable JS).
885
- nativeModules = extractNativeModules(bundlePath);
1015
+ nativeModules = extractNativeModules(createdBundlePath);
1016
+ nativeModulesVerified = true;
886
1017
  log(`Native modules referenced: ${nativeModules.length}${nativeModules.length ? ` (${nativeModules.join(', ')})` : ''}`);
887
1018
 
888
1019
  // Step 2: Compile with Hermes
889
1020
  logStep('2/6', 'Compiling with Hermes...');
890
- compileWithHermes(bundlePath, config.platform, { requireHermes: config.requireHermes });
1021
+ compileWithHermes(createdBundlePath, config.platform, { requireHermes: config.requireHermes });
891
1022
  logSuccess('Hermes compilation complete');
1023
+ if (nativeModulesFile) {
1024
+ await writeNativeModulesEvidence(
1025
+ nativeModulesFile,
1026
+ config,
1027
+ createdBundlePath,
1028
+ nativeModules,
1029
+ );
1030
+ log(`Native-module evidence written: ${nativeModulesFile}`);
1031
+ }
892
1032
  } else {
893
1033
  logStep('1/6', 'Skipping bundle creation (--skip-bundle)');
894
1034
  logStep('2/6', 'Skipping Hermes compilation');
1035
+ if (nativeModulesFile) {
1036
+ const evidence = await readNativeModulesEvidence(
1037
+ nativeModulesFile,
1038
+ config,
1039
+ bundlePath,
1040
+ );
1041
+ nativeModules = evidence.nativeModules;
1042
+ nativeModulesVerified = true;
1043
+ log(`Native-module evidence verified: ${nativeModules.length}${nativeModules.length ? ` (${nativeModules.join(', ')})` : ''}`);
1044
+ } else {
1045
+ logWarning(
1046
+ '--skip-bundle has no --native-modules-file; preserving legacy behaviour without a ' +
1047
+ 'capability fingerprint. Guarded release wrappers should fail before reaching this path.'
1048
+ );
1049
+ }
895
1050
  }
896
1051
 
897
1052
  // Step 3: Collect assets and calculate hashes
@@ -929,13 +1084,12 @@ async function publish(options) {
929
1084
  'Content-Type': 'application/json',
930
1085
  'X-API-Key': config.apiKey,
931
1086
  },
932
- body: JSON.stringify({
933
- runtimeVersion: config.runtimeVersion,
934
- platform: config.platform,
935
- channel: config.channel,
936
- metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
937
- nativeModules: nativeModules.length ? nativeModules : undefined,
938
- }),
1087
+ body: JSON.stringify(createReleaseRequest(
1088
+ config,
1089
+ metadata,
1090
+ nativeModules,
1091
+ nativeModulesVerified,
1092
+ )),
939
1093
  });
940
1094
 
941
1095
  if (!createResponse.ok) {
@@ -963,13 +1117,63 @@ async function publish(options) {
963
1117
  // modules that weren't in the previous good release for this runtime version — native code can't ship
964
1118
  // over the air, so it would crash the installed cohort. Override with --allow-native-change.
965
1119
  let preflight = null;
1120
+ // Any verified capability scan (a direct build or a two-pass evidence handoff) requires a valid
1121
+ // server answer: an unavailable or malformed preflight is not evidence that the OTA is safe, so
1122
+ // it must stop before any asset upload/finalize. Only legacy --skip-bundle without evidence has
1123
+ // no capability set to enforce; --no-native-check remains the one explicit escape hatch.
1124
+ const requiresVerifiedPreflight = nativeModulesVerified && !options['no-native-check'];
1125
+ let pfRes = null;
966
1126
  try {
967
- const pfRes = await fetch(`${config.apiUrl}/api/releases/${release.id}/preflight`, {
1127
+ pfRes = await fetch(`${config.apiUrl}/api/releases/${release.id}/preflight`, {
968
1128
  headers: { 'X-API-Key': config.apiKey },
969
1129
  });
970
- if (pfRes.ok) preflight = await pfRes.json();
971
- } catch {
972
- log('⚠ Preflight check could not run (server unreachable); continuing.');
1130
+ } catch (error) {
1131
+ if (requiresVerifiedPreflight) {
1132
+ throw new Error(
1133
+ `Native ABI preflight could not reach the server; refusing capability-verified publish: ${
1134
+ error instanceof Error ? error.message : String(error)
1135
+ }`,
1136
+ );
1137
+ }
1138
+ logWarning('Preflight check could not run (server unreachable); continuing legacy publish.');
1139
+ }
1140
+ if (pfRes && !pfRes.ok) {
1141
+ if (requiresVerifiedPreflight) {
1142
+ throw new Error(`Native ABI preflight failed with HTTP ${pfRes.status}; refusing capability-verified publish.`);
1143
+ }
1144
+ logWarning(`Preflight check returned HTTP ${pfRes.status}; continuing legacy publish.`);
1145
+ }
1146
+ if (pfRes?.ok) {
1147
+ let decodedPreflight = false;
1148
+ try {
1149
+ preflight = await pfRes.json();
1150
+ decodedPreflight = true;
1151
+ } catch (error) {
1152
+ if (requiresVerifiedPreflight) {
1153
+ throw new Error(
1154
+ `Native ABI preflight returned invalid JSON; refusing capability-verified publish: ${
1155
+ error instanceof Error ? error.message : String(error)
1156
+ }`,
1157
+ );
1158
+ }
1159
+ logWarning('Preflight check returned invalid JSON; continuing legacy publish.');
1160
+ }
1161
+ const validPreflight = decodedPreflight
1162
+ && preflight !== null
1163
+ && typeof preflight === 'object'
1164
+ && !Array.isArray(preflight)
1165
+ && typeof preflight.ok === 'boolean'
1166
+ && Array.isArray(preflight.warnings)
1167
+ && preflight.warnings.every((warning) => typeof warning === 'string')
1168
+ && Array.isArray(preflight.newNativeModules)
1169
+ && preflight.newNativeModules.every((moduleName) => typeof moduleName === 'string');
1170
+ if (decodedPreflight && !validPreflight) {
1171
+ preflight = null;
1172
+ if (requiresVerifiedPreflight) {
1173
+ throw new Error('Native ABI preflight returned an invalid response schema; refusing capability-verified publish.');
1174
+ }
1175
+ logWarning('Preflight check returned an invalid response schema; continuing legacy publish.');
1176
+ }
973
1177
  }
974
1178
  if (preflight) {
975
1179
  for (const w of preflight.warnings || []) log(`⚠ ${w}`);
@@ -1456,8 +1660,15 @@ ${colors.cyan}Options:${colors.reset}
1456
1660
  --channel <channel> Release channel (default: production)
1457
1661
  --bundle-dir <dir> Output directory (default: ./dist)
1458
1662
  --bundle <file> Embedded bundle for register-capabilities (otherwise built automatically)
1663
+ --native-build <n> The build this bundle is for (iOS CFBundleVersion, Android versionCode).
1664
+ Required when the bundle is wrapped with a per-build key: without it the
1665
+ manifest serves the bundle to every build sharing the runtime version,
1666
+ and the ones holding a different key cannot start.
1459
1667
  --entry-file <file> Entry file (default: index.ts)
1460
1668
  --skip-bundle Skip bundle creation (use existing)
1669
+ --native-modules-file <file>
1670
+ Capability evidence handoff for a two-pass/--skip-bundle publish. The
1671
+ build pass writes it; the upload pass validates its context and SHA-256.
1461
1672
  --dry-run Build + validate locally without creating a release on the server
1462
1673
  --allow-native-change Publish even though new native modules were detected (you certify they're
1463
1674
  already in the installed binary). Default is FAIL-CLOSED: a new native module