wyvrnpm 2.12.2 → 2.13.11

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/src/download.js CHANGED
@@ -13,6 +13,9 @@ const { evaluateCompat, pickBestCandidate, summarizeReasons } = require('./compa
13
13
  const { buildFromSource } = require('./build');
14
14
  const { uploadSourceBuiltArtefact } = require('./upload-built');
15
15
  const { assertAllSafeZipEntryNames } = require('./zip-safe');
16
+ const { sha256OfCanonical } = require('./signing/canonical');
17
+ const { verifyAttestation, parseSigEnvelope } = require('./signing');
18
+ const { assertBoundHashes } = require('./signing/attestation');
16
19
  const log = require('./logger');
17
20
 
18
21
  // ---------------------------------------------------------------------------
@@ -104,6 +107,117 @@ async function tryDownloadFromSources(name, version, packageSources, platform, h
104
107
  * @param {string[]} packageSources
105
108
  * @param {object} authOptions { awsProfile?, token? }
106
109
  */
110
+ /**
111
+ * Run S1 signature verification on a successfully-downloaded v2 artefact.
112
+ *
113
+ * Honours per-registry `mode.json`:
114
+ * - `off` → skip entirely (returns null).
115
+ * - `warn` → att+sig present and valid, log identity; missing → warn + null;
116
+ * invalid → throw (bad sig is stricter than missing).
117
+ * - `require` → att+sig must be present AND valid; otherwise throw.
118
+ *
119
+ * `requireSignatures` (from CLI `--require-signatures`) escalates whatever
120
+ * the registry says to `require` for this run.
121
+ *
122
+ * Throws on any failure that should block the install. Returns
123
+ * `{ keyId, identityHint, alg }` on a successful verify, `null` when mode
124
+ * is off or warn-and-missing.
125
+ *
126
+ * @param {object} args
127
+ * @param {string} args.source registry source URL the zip came from
128
+ * @param {string} args.name
129
+ * @param {string} args.version
130
+ * @param {string} args.profileHash
131
+ * @param {string} [args.contentSha256] SHA256 of the local zip — fat-zip bound check (v1)
132
+ * @param {Object<string, string>} [args.sliceShaMap]
133
+ * `{ config → sha256 }` of locally-computed per-slice hashes — slice
134
+ * bound check (v2). Pass exactly one of `contentSha256` or `sliceShaMap`.
135
+ * @param {object} [args.authOptions] { awsProfile?, token? }
136
+ * @param {boolean} [args.requireSignatures]
137
+ * @returns {Promise<{keyId: string, identityHint: string|null, alg: string}|null>}
138
+ */
139
+ async function verifyDownloadIfRequired({
140
+ source, name, version, profileHash, contentSha256, sliceShaMap,
141
+ authOptions = {}, requireSignatures = false,
142
+ }) {
143
+ let provider;
144
+ try { provider = getProvider(source); }
145
+ catch { return null; }
146
+
147
+ const trust = await provider.v2GetTrustAnchor({ source, ...authOptions });
148
+ let mode = trust?.mode?.mode ?? 'off';
149
+ if (requireSignatures) mode = 'require';
150
+ if (mode === 'off') return null;
151
+
152
+ const [attBytes, sigBytes] = await Promise.all([
153
+ provider.v2GetAttestation({ source, name, version, profileHash, ...authOptions }),
154
+ provider.v2GetSignature ({ source, name, version, profileHash, ...authOptions }),
155
+ ]);
156
+
157
+ if (!attBytes || !sigBytes) {
158
+ if (mode === 'require') {
159
+ throw new Error(`registry mode=require but signature files missing on ${source}`);
160
+ }
161
+ log.warn(`unsigned artefact (mode=warn): ${name}@${version} [${profileHash}]`);
162
+ return null;
163
+ }
164
+ if (!trust?.keys) {
165
+ throw new Error(`registry has signature files but no .trust/keys.json — mode=${mode}`);
166
+ }
167
+
168
+ // Re-fetch the manifest as the bytes the publisher canonicalised.
169
+ // Providers no longer mutate the manifest, so the v2GetMeta result is
170
+ // exactly what was uploaded → the same canonical SHA the attestation
171
+ // signed over.
172
+ const meta = await provider.v2GetMeta({ source, name, version, profileHash, ...authOptions });
173
+ if (!meta) throw new Error(`manifest not available for verification: ${name}@${version}`);
174
+ const manifestSha256 = sha256OfCanonical(meta);
175
+
176
+ const sigEnvelope = parseSigEnvelope(sigBytes.toString('utf8'));
177
+ let result;
178
+ if (sliceShaMap && Object.keys(sliceShaMap).length > 0) {
179
+ // v2 / slice path: cryptographic verify happens against the parsed
180
+ // canonical bytes once. The bound-hash check runs once per downloaded
181
+ // slice — the canonical body covers every slice's hash, so a tampered
182
+ // or omitted slice changes the signed bytes and the sig fails before
183
+ // we even reach assertBoundHashes.
184
+ const sliceConfigs = Object.keys(sliceShaMap);
185
+ const firstConfig = sliceConfigs[0];
186
+ result = verifyAttestation({
187
+ attBytes,
188
+ sigEnvelope,
189
+ trustAnchors: trust.keys,
190
+ expected: {
191
+ name, version, profileHash, manifestSha256,
192
+ sliceConfig: firstConfig,
193
+ sliceSha256: sliceShaMap[firstConfig],
194
+ },
195
+ });
196
+ for (const config of sliceConfigs.slice(1)) {
197
+ // Cheap shape + value check against the already-verified attestation.
198
+ assertBoundHashes(result.attestation, {
199
+ manifestSha256,
200
+ sliceConfig: config,
201
+ sliceSha256: sliceShaMap[config],
202
+ });
203
+ }
204
+ } else {
205
+ // v1 / fat-zip path (today).
206
+ result = verifyAttestation({
207
+ attBytes,
208
+ sigEnvelope,
209
+ trustAnchors: trust.keys,
210
+ expected: { name, version, profileHash, contentSha256, manifestSha256 },
211
+ });
212
+ }
213
+ log.info(
214
+ `signed by ${result.keyId}` +
215
+ (result.identityHint ? ` (${result.identityHint})` : '') +
216
+ ` [${result.kind}]`,
217
+ );
218
+ return { keyId: result.keyId, identityHint: result.identityHint, alg: result.kind };
219
+ }
220
+
107
221
  async function findV2Index(dep, packageSources, authOptions = {}) {
108
222
  const { name, version } = dep;
109
223
  const { awsProfile, token } = authOptions;
@@ -126,9 +240,106 @@ async function findV2Index(dep, packageSources, authOptions = {}) {
126
240
  return { found: false, indexFoundInSource: null, versionsIndex: null, availableProfiles: [] };
127
241
  }
128
242
 
243
+ /**
244
+ * Per-config slice download. Called by tryV2Exact when the exact-match
245
+ * manifest carries an `artefactConfigs` block. Resolves the consumer's
246
+ * requested configs against the publisher's available configs, downloads
247
+ * each slice into a per-package staging path, and SHA-verifies on the fly.
248
+ *
249
+ * Empty intersection → fail-fast (CLAUDE.md §2.8 silent fallback is a
250
+ * bug). Partial intersection → warn about missing slices, proceed with
251
+ * what's available.
252
+ *
253
+ * `requestConfigs === null` is the "no consumer override" signal — fetch
254
+ * every slice the publisher published. Equivalent to today's behaviour
255
+ * for fat-zip publishes (consumer pulls everything).
256
+ *
257
+ * @param {object} args
258
+ * @returns {Promise<{
259
+ * ok: boolean,
260
+ * reason?: string,
261
+ * slices?: Array<{ config: string, zipPath: string, sha256: string }>,
262
+ * }>}
263
+ */
264
+ async function downloadV2Slices({
265
+ provider, source, name, version, profileHash,
266
+ exactMeta, requestConfigs, destDir, timeoutMs, authOptions = {},
267
+ }) {
268
+ const available = Object.keys(exactMeta.artefactConfigs || {});
269
+ if (available.length === 0) {
270
+ return { ok: false, reason: 'manifest declared artefactConfigs but the map was empty' };
271
+ }
272
+ // Default: when consumer didn't override, fetch every config the
273
+ // publisher has — equivalent to today's "download everything" behaviour
274
+ // for pre-feature consumers.
275
+ const requested = (Array.isArray(requestConfigs) && requestConfigs.length > 0)
276
+ ? requestConfigs
277
+ : available;
278
+ const downloadConfigs = requested.filter((c) => available.includes(c));
279
+ if (downloadConfigs.length === 0) {
280
+ return {
281
+ ok: false,
282
+ reason:
283
+ `none of the requested configs [${requested.join(', ')}] are published — ` +
284
+ `available: [${available.join(', ')}]`,
285
+ };
286
+ }
287
+ if (downloadConfigs.length < requested.length) {
288
+ const missing = requested.filter((c) => !available.includes(c));
289
+ log.warn(
290
+ `requested config(s) [${missing.join(', ')}] not published for ${name}@${version} — ` +
291
+ `skipping; available: [${available.join(', ')}]`,
292
+ );
293
+ }
294
+
295
+ const { awsProfile, token } = authOptions;
296
+ const slices = [];
297
+ for (const config of downloadConfigs.slice().sort()) {
298
+ const slicePath = path.join(destDir, `${name}-${config}.zip`);
299
+ const ok = await provider.v2DownloadZip(
300
+ { source, name, version, profileHash, sliceConfig: config, awsProfile, token },
301
+ slicePath,
302
+ timeoutMs,
303
+ );
304
+ if (!ok) {
305
+ return { ok: false, reason: `failed to download slice "${config}" from ${source}` };
306
+ }
307
+ const localSha = sha256Of(slicePath);
308
+ const expectedSha = exactMeta.artefactConfigs[config]?.sha256;
309
+ if (typeof expectedSha !== 'string' || localSha !== expectedSha) {
310
+ return {
311
+ ok: false,
312
+ reason:
313
+ `SHA256 mismatch on slice "${config}":\n` +
314
+ ` expected: ${expectedSha}\n` +
315
+ ` actual: ${localSha}`,
316
+ };
317
+ }
318
+ slices.push({ config, zipPath: slicePath, sha256: localSha });
319
+ log.info(` ${config.padEnd(15)} ${formatSizeShort(slicePath).padStart(10)} sha=${localSha.slice(0, 16)}...`);
320
+ }
321
+ return { ok: true, slices };
322
+ }
323
+
324
+ /**
325
+ * Best-effort byte-size formatter for slice download log lines. Returns
326
+ * a short rendering ("3.7 MB") or a fallback when stat fails.
327
+ */
328
+ function formatSizeShort(filePath) {
329
+ try {
330
+ const n = fs.statSync(filePath).size;
331
+ if (n < 1024) return `${n} B`;
332
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
333
+ if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(2)} MB`;
334
+ return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
335
+ } catch {
336
+ return '?';
337
+ }
338
+ }
339
+
129
340
  async function tryV2Exact(dep, packageSources, destZipPath, timeoutMs, authOptions = {}) {
130
341
  const { name, version, profileHash } = dep;
131
- const { awsProfile, token } = authOptions;
342
+ const { awsProfile, token, requestConfigs } = authOptions;
132
343
 
133
344
  let indexFoundInSource = null;
134
345
  let versionsIndex = null;
@@ -143,11 +354,50 @@ async function tryV2Exact(dep, packageSources, destZipPath, timeoutMs, authOptio
143
354
  log.info(`Trying v2: ${metaUrl}`);
144
355
  const exactMeta = await provider.v2GetMeta({ source, name, version, profileHash, awsProfile, token });
145
356
  if (exactMeta) {
357
+ // ── Per-config slice publish ───────────────────────────────────────
358
+ if (exactMeta.artefactConfigs && Object.keys(exactMeta.artefactConfigs).length > 0) {
359
+ log.info(` per-config publish: configs=${Object.keys(exactMeta.artefactConfigs).join(', ')}`);
360
+ const sliceResult = await downloadV2Slices({
361
+ provider, source, name, version, profileHash,
362
+ exactMeta, requestConfigs,
363
+ destDir: path.dirname(destZipPath),
364
+ timeoutMs,
365
+ authOptions: { awsProfile, token },
366
+ });
367
+ if (sliceResult.ok) {
368
+ return {
369
+ found: true,
370
+ result: {
371
+ kind: 'slice',
372
+ slices: sliceResult.slices,
373
+ configs: sliceResult.slices.map((s) => s.config).sort(),
374
+ perConfigSha256: Object.fromEntries(sliceResult.slices.map((s) => [s.config, s.sha256])),
375
+ gitSha: exactMeta.gitSha ?? null,
376
+ gitRepo: exactMeta.gitRepo ?? null,
377
+ profileHash,
378
+ source,
379
+ },
380
+ indexFoundInSource: source,
381
+ availableProfiles: [],
382
+ };
383
+ }
384
+ // Slice download failed — bubble the reason for caller diagnostics.
385
+ log.error(`per-config download failed for ${name}@${version}: ${sliceResult.reason}`);
386
+ return {
387
+ found: false,
388
+ sliceFailureReason: sliceResult.reason,
389
+ indexFoundInSource: source,
390
+ availableProfiles: [],
391
+ versionsIndex: null,
392
+ };
393
+ }
394
+ // ── Fat-zip path (today) ───────────────────────────────────────────
146
395
  const ok = await provider.v2DownloadZip({ source, name, version, profileHash, awsProfile, token }, destZipPath, timeoutMs);
147
396
  if (ok) {
148
397
  return {
149
398
  found: true,
150
399
  result: {
400
+ kind: 'fat-zip',
151
401
  contentSha256: exactMeta.contentSha256 ?? null,
152
402
  gitSha: exactMeta.gitSha ?? null,
153
403
  gitRepo: exactMeta.gitRepo ?? null,
@@ -345,6 +595,24 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
345
595
  // so the caller can print a single aggregated summary at the end of an
346
596
  // install (see src/commands/install.js). Mutated in place.
347
597
  uploadStats,
598
+ // S1: when true, every download must verify against the registry's
599
+ // trust anchor regardless of what `mode.json` says. Set by
600
+ // `wyvrnpm install --require-signatures`.
601
+ requireSignatures = false,
602
+ // S1 upload-built: when set, the consumer-rebuilt artefact is signed
603
+ // with this context before upload. Resolved once at the install entry
604
+ // point (src/commands/install.js) from the consumer's defaultSigning
605
+ // / per-source signing config.
606
+ uploadSigningContext = null,
607
+ // Per-config slice selection (PLAN-PER-CONFIG). null means "no
608
+ // override; fetch every slice the publisher published" — equivalent
609
+ // to today's behaviour for fat-zip publishes.
610
+ requestConfigs = null,
611
+ // Per-dep override of `requestConfigs`. Map of `{ depName: [...] }`.
612
+ // When a dep is named in this map, those configs apply to it
613
+ // exclusively; otherwise the global `requestConfigs` takes over.
614
+ // Resolved by install.js from wyvrn.local.json: depRequestConfigs.
615
+ depRequestConfigs = null,
348
616
  } = options;
349
617
 
350
618
  if (!fs.existsSync(razerDir)) {
@@ -367,6 +635,16 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
367
635
  // configured sources.
368
636
  const depSources = pinnedSource ? [pinnedSource.url] : packageSources;
369
637
 
638
+ // Per-dep override of the global slice selection. Resolved once
639
+ // here so the cache check, slice download, and post-extract meta
640
+ // write all see the same effective config list. The override comes
641
+ // from wyvrn.local.json:depRequestConfigs[<name>] when set,
642
+ // otherwise the dep inherits the global `requestConfigs`.
643
+ const depEffectiveRequestConfigs =
644
+ (depRequestConfigs && Object.prototype.hasOwnProperty.call(depRequestConfigs, name))
645
+ ? depRequestConfigs[name]
646
+ : requestConfigs;
647
+
370
648
  const extractDir = path.join(razerDir, name);
371
649
  const versionFile = path.join(extractDir, '.wyvrn-version');
372
650
  const metaFile = path.join(extractDir, '.wyvrn-meta.json');
@@ -396,12 +674,36 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
396
674
  const sameVersion = installedVersion === version;
397
675
  const sameProfile = !profileHash || installedMeta?.profileHash === profileHash;
398
676
 
399
- if (sameVersion && sameProfile) {
677
+ // Slice publishes additionally key the cache on the consumer's
678
+ // configs request — going from [Release] to [Release, Debug] must
679
+ // re-fetch even when version + profileHash match, otherwise the
680
+ // Debug binaries are missing and find_package(Debug) fails.
681
+ const sameConfigs = (() => {
682
+ // Pre-feature dep (no `configs` in installedMeta) → version + profileHash is enough.
683
+ if (!installedMeta?.configs) return true;
684
+ // Slice dep: compare requestConfigs (or "everything" if null) vs installed.
685
+ // Uses the per-dep effective list so a `depRequestConfigs.grpc`
686
+ // override correctly invalidates the cache when the consumer
687
+ // adds or removes configs from grpc's slice selection.
688
+ if (!depEffectiveRequestConfigs) return true;
689
+ const requestedSet = new Set(depEffectiveRequestConfigs);
690
+ const installedSet = new Set(installedMeta.configs);
691
+ if (requestedSet.size !== installedSet.size) return false;
692
+ for (const c of requestedSet) if (!installedSet.has(c)) return false;
693
+ return true;
694
+ })();
695
+
696
+ if (sameVersion && sameProfile && sameConfigs) {
400
697
  log.info(`Already present: ${name}@${version}${profileHash ? ` [${installedMeta?.profileHash}]` : ''} — skipping`);
401
698
  lockEntries.set(name, {
402
699
  version,
403
700
  profileHash: installedMeta?.profileHash ?? null,
404
- contentSha256: installedMeta?.contentSha256 ?? null,
701
+ ...(installedMeta?.configs
702
+ ? {
703
+ configs: installedMeta.configs,
704
+ perConfigSha256: installedMeta.perConfigSha256 ?? {},
705
+ }
706
+ : { contentSha256: installedMeta?.contentSha256 ?? null }),
405
707
  gitSha: installedMeta?.gitSha ?? null,
406
708
  resolvedFrom: installedMeta?.resolvedFrom ?? 'cached',
407
709
  ...(options ? { options } : {}),
@@ -411,8 +713,10 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
411
713
  }
412
714
  if (!sameVersion) {
413
715
  log.info(`Version changed: ${name} ${installedVersion} → ${version}, re-downloading`);
414
- } else {
716
+ } else if (!sameProfile) {
415
717
  log.info(`Profile changed for ${name}@${version}, re-downloading`);
718
+ } else {
719
+ log.info(`Requested configs changed for ${name}@${version} — re-downloading`);
416
720
  }
417
721
  fs.rmSync(extractDir, { recursive: true, force: true });
418
722
  }
@@ -441,12 +745,26 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
441
745
  depSources,
442
746
  destZipPath,
443
747
  timeoutMs,
444
- { awsProfile, token },
748
+ { awsProfile, token, requestConfigs: depEffectiveRequestConfigs },
445
749
  );
446
750
 
447
751
  if (v2.found) {
448
752
  downloadResult = v2.result;
449
- resolvedFrom = 'v2';
753
+ resolvedFrom = (v2.result?.kind === 'slice') ? 'v2-slice' : 'v2';
754
+ } else if (v2.sliceFailureReason) {
755
+ // We DID find an exact match for this profileHash, but the per-config
756
+ // slice download failed — empty consumer-publisher intersection,
757
+ // SHA mismatch, network error. Fail-fast: don't fall back to compat
758
+ // / source-build / v1, which would silently substitute a different
759
+ // build (CLAUDE.md §2.8 — silent fallback is a bug).
760
+ log.error(`per-config slice failed for ${name}@${version}: ${v2.sliceFailureReason}`);
761
+ lockEntries.set(name, {
762
+ version,
763
+ profileHash,
764
+ resolvedFrom: 'slice-failed',
765
+ sliceFailureReason: v2.sliceFailureReason,
766
+ });
767
+ continue;
450
768
  } else if (v2.indexFoundInSource) {
451
769
  // v2 knows this package but no exact profile match (or --build=always
452
770
  // forced the path here). Do NOT fall back to v1 — that would silently
@@ -559,8 +877,14 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
559
877
  }
560
878
  }
561
879
 
562
- // ── SHA256 integrity check (v2 only) ────────────────────────────────────
563
- if (downloadResult?.contentSha256) {
880
+ // Slice publishes are recognised by `kind: 'slice'` on the download
881
+ // result. SHA-verify already happened in downloadV2Slices (per slice,
882
+ // before this point); the rest of the pipeline just needs to know
883
+ // whether we have one zip or N to extract.
884
+ const sliceMode = downloadResult?.kind === 'slice';
885
+
886
+ // ── SHA256 integrity check (fat-zip only; slice already verified) ──────
887
+ if (!sliceMode && downloadResult?.contentSha256) {
564
888
  const actual = sha256Of(destZipPath);
565
889
  if (actual !== downloadResult.contentSha256) {
566
890
  log.error(
@@ -575,10 +899,55 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
575
899
  log.info(`SHA256 verified: ${actual.slice(0, 16)}...`);
576
900
  }
577
901
 
902
+ // ── S1 signature verification ──────────────────────────────────────────
903
+ // Only meaningful for v2 downloads with a known source (skip cached,
904
+ // link, and v1-fallback paths). Aborts on bad sig under any non-off
905
+ // mode; warn-mode missing falls through with a logged warning. Slice
906
+ // mode passes the per-config sha map; fat-zip mode passes contentSha256.
907
+ let signerInfo = null;
908
+ const haveBoundHashes = sliceMode
909
+ ? Boolean(downloadResult?.perConfigSha256 && Object.keys(downloadResult.perConfigSha256).length > 0)
910
+ : Boolean(downloadResult?.contentSha256);
911
+ if (downloadResult?.source && downloadResult?.profileHash && haveBoundHashes) {
912
+ try {
913
+ signerInfo = await verifyDownloadIfRequired({
914
+ source: downloadResult.source,
915
+ name, version,
916
+ profileHash: downloadResult.profileHash,
917
+ ...(sliceMode
918
+ ? { sliceShaMap: downloadResult.perConfigSha256 }
919
+ : { contentSha256: downloadResult.contentSha256 }),
920
+ authOptions: { awsProfile, token },
921
+ requireSignatures,
922
+ });
923
+ } catch (err) {
924
+ log.error(`signature verification failed for ${name}@${version}: ${err.message}`);
925
+ if (sliceMode) {
926
+ for (const slice of downloadResult.slices ?? []) {
927
+ try { fs.unlinkSync(slice.zipPath); } catch { /* ignore */ }
928
+ }
929
+ } else {
930
+ try { fs.unlinkSync(destZipPath); } catch { /* ignore */ }
931
+ }
932
+ lockEntries.set(name, { version, profileHash: downloadResult.profileHash, resolvedFrom: 'sig-verify-failed' });
933
+ continue;
934
+ }
935
+ }
936
+
578
937
  // ── Extract ─────────────────────────────────────────────────────────────
579
938
  try {
580
939
  fs.mkdirSync(extractDir, { recursive: true });
581
- await extractZip(destZipPath, extractDir);
940
+ if (sliceMode) {
941
+ // Multiple slices into one tree — shared files re-extract identically
942
+ // (multi-config-merge invariant locked in by the slice byte-identity
943
+ // audit on the publish side). Per-config files land in their distinct
944
+ // lib/<Config>/ etc. paths, no overwrites.
945
+ for (const slice of downloadResult.slices) {
946
+ await extractZip(slice.zipPath, extractDir);
947
+ }
948
+ } else {
949
+ await extractZip(destZipPath, extractDir);
950
+ }
582
951
 
583
952
  // Write .wyvrn-version (v1 compat)
584
953
  fs.writeFileSync(versionFile, version, 'utf8');
@@ -588,16 +957,25 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
588
957
  version,
589
958
  resolvedFrom,
590
959
  profileHash: downloadResult?.profileHash ?? null,
591
- contentSha256: downloadResult?.contentSha256 ?? null,
960
+ ...(sliceMode
961
+ ? {
962
+ configs: downloadResult.configs,
963
+ perConfigSha256: downloadResult.perConfigSha256,
964
+ }
965
+ : { contentSha256: downloadResult?.contentSha256 ?? null }),
592
966
  gitSha: downloadResult?.gitSha ?? null,
593
967
  gitRepo: downloadResult?.gitRepo ?? null,
594
968
  installedAt: new Date().toISOString(),
969
+ // S1: signer identity per dep when verification succeeded
970
+ // (null otherwise — surfaces in `wyvrnpm show` and audit tools).
971
+ signer: signerInfo,
595
972
  };
596
973
  fs.writeFileSync(metaFile, JSON.stringify(meta, null, 2) + '\n', 'utf8');
597
974
 
598
975
  log.info(
599
976
  `Extracted: ${name}@${version}` +
600
977
  (downloadResult?.profileHash ? ` [${downloadResult.profileHash}]` : '') +
978
+ (sliceMode ? ` configs=[${downloadResult.configs.join(', ')}]` : '') +
601
979
  ` (${resolvedFrom}) → ${extractDir}`,
602
980
  );
603
981
 
@@ -612,7 +990,12 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
612
990
  version,
613
991
  resolvedFrom,
614
992
  profileHash: downloadResult?.profileHash ?? null,
615
- contentSha256: downloadResult?.contentSha256 ?? null,
993
+ ...(sliceMode
994
+ ? {
995
+ configs: downloadResult.configs,
996
+ perConfigSha256: downloadResult.perConfigSha256,
997
+ }
998
+ : { contentSha256: downloadResult?.contentSha256 ?? null }),
616
999
  gitSha: downloadResult?.gitSha ?? null,
617
1000
  ...(options ? { options } : {}),
618
1001
  // S4: record the pinned source name on the lock entry so a
@@ -639,6 +1022,12 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
639
1022
  options,
640
1023
  uploadSource,
641
1024
  uploadAuth: uploadAuth ?? { awsProfile, token },
1025
+ // S1 — sign the consumer-rebuilt artefact identically to an
1026
+ // author publish. Resolved once at install entry and threaded
1027
+ // down here. When null, the upload is unsigned and the
1028
+ // destination registry's mode.json governs whether downstream
1029
+ // consumers warn or abort.
1030
+ signingContext: uploadSigningContext,
642
1031
  });
643
1032
  if (uploadStats) {
644
1033
  const bucket = outcome.uploaded
@@ -657,6 +1046,14 @@ async function downloadDependencies(deps, packageSources, platform, razerDir, ht
657
1046
  log.warn(`Failed to extract ${name}@${version}: ${err.message}`);
658
1047
  lockEntries.set(name, { version, resolvedFrom: 'extract-failed' });
659
1048
  } finally {
1049
+ // Clean up downloaded zip(s). Slice mode tracks N paths; fat-zip is
1050
+ // the single destZipPath. Both ignore unlink errors — the file may
1051
+ // not exist if an earlier step bailed.
1052
+ if (downloadResult?.kind === 'slice') {
1053
+ for (const slice of downloadResult.slices ?? []) {
1054
+ try { fs.unlinkSync(slice.zipPath); } catch { /* ignore */ }
1055
+ }
1056
+ }
660
1057
  try { fs.unlinkSync(destZipPath); } catch { /* ignore */ }
661
1058
  }
662
1059
  }