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.
@@ -12,13 +12,24 @@
12
12
  * v1 (legacy): {source}/{platform}/{name}/{version}/wyvrn.{json,zip}
13
13
  * {source}/{platform}/{name}/latest.json
14
14
  *
15
- * v2: {source}/v2/{name}/{version}/{profileHash}/wyvrn.{json,zip}
15
+ * v2: {source}/v2/{name}/{version}/{profileHash}/wyvrn.json
16
+ * {source}/v2/{name}/{version}/{profileHash}/wyvrn.zip (fat-zip publish, default)
17
+ * {source}/v2/{name}/{version}/{profileHash}/wyvrn-<Config>.zip (per-config slice publish — recipe build.publishPerConfig: true)
18
+ * {source}/v2/{name}/{version}/{profileHash}/wyvrn.att.json (S1)
19
+ * {source}/v2/{name}/{version}/{profileHash}/wyvrn.sig (S1)
16
20
  * {source}/v2/{name}/{version}/source.json
17
21
  * {source}/v2/{name}/versions.json
18
22
  * {source}/v2/{name}/latest.json
23
+ * {source}/v2/.trust/keys.json (S1)
24
+ * {source}/v2/.trust/mode.json (S1)
19
25
  *
20
26
  * Publishing with v2 tooling writes both v2 AND v1 paths by default so that
21
27
  * v1 clients can still consume the packages.
28
+ *
29
+ * The signing files (`wyvrn.att.json` + `wyvrn.sig`) and the per-registry
30
+ * trust anchor (`.trust/`) are additive — pre-S1 clients ignore them and
31
+ * S1 clients treat their absence as `mode: off` (CLAUDE.md §8.S1 +
32
+ * claude/PLAN-SIGNING.md / echo-off-setlocal-velvet-lagoon plan).
22
33
  */
23
34
  class BaseProvider {
24
35
  /**
@@ -84,20 +95,47 @@ class BaseProvider {
84
95
  * (author publish); populated by the upload-built flow. Readers ignore
85
96
  * the field — it is informational only.
86
97
  *
87
- * @param {{ manifest: string, zip: string }} files - local file paths
98
+ * `files` may also carry `attPath` and `sigPath` paths to the
99
+ * `wyvrn.att.json` (signed attestation body) and `wyvrn.sig` (detached
100
+ * signature envelope) produced by the publish flow's signing tail. Both
101
+ * optional; when present the provider MUST upload them in the order
102
+ * `zip(s) → manifest → att → sig → versions.json` so a partial-failure
103
+ * crash never leaves a sig-less entry visible in versions.json
104
+ * (claude/echo-off-setlocal-velvet-lagoon §3 + Risks §write-order).
105
+ *
106
+ * Per-config slicing (recipe `build.publishPerConfig: true`):
107
+ * `options.artefactConfigs: { config → { sha256, sizeBytes } }` and
108
+ * `files.artefactZips: { config → path }` together replace the single
109
+ * `files.zip` upload. The provider uploads each slice as
110
+ * `wyvrn-<Config>.zip` and emits a `configs` array (instead of
111
+ * `contentSha256`) into the versions.json profile entry + latest.json.
112
+ * `options.contentSha256` is null/absent in this branch — the
113
+ * per-slice SHAs live in the published manifest's `artefactConfigs`
114
+ * block (canonical source of truth) and in the v2 attestation's
115
+ * `artefactSha256` field. Pre-feature publishes (no
116
+ * `artefactConfigs`) take the byte-identical legacy path.
117
+ *
118
+ * @param {{
119
+ * manifest: string,
120
+ * zip?: string,
121
+ * artefactZips?: Record<string, string>,
122
+ * attPath?: string,
123
+ * sigPath?: string,
124
+ * }} files
88
125
  * @param {{
89
- * source: string,
90
- * name: string,
91
- * version: string,
92
- * profileHash: string,
93
- * buildSettings: import('../profile').WyvrnProfile, // stored in metadata for debugging
94
- * contentSha256: string,
95
- * gitSha: string|null,
96
- * gitRepo: string|null,
97
- * updateLatest?: boolean, // default true; false for upload-built
98
- * uploadedBy?: object, // optional provenance block, written into wyvrn.json
99
- * awsProfile?: string,
100
- * token?: string,
126
+ * source: string,
127
+ * name: string,
128
+ * version: string,
129
+ * profileHash: string,
130
+ * buildSettings: import('../profile').WyvrnProfile, // stored in metadata for debugging
131
+ * contentSha256?: string, // fat-zip publishes only
132
+ * artefactConfigs?: Record<string, { sha256: string, sizeBytes: number }>, // slice publishes only
133
+ * gitSha: string|null,
134
+ * gitRepo: string|null,
135
+ * updateLatest?: boolean, // default true; false for upload-built
136
+ * uploadedBy?: object, // optional provenance block, written into wyvrn.json
137
+ * awsProfile?: string,
138
+ * token?: string,
101
139
  * }} options
102
140
  * @returns {Promise<void>}
103
141
  */
@@ -105,6 +143,49 @@ class BaseProvider {
105
143
  throw new Error(`v2Publish() is not implemented in ${this.constructor.name}`);
106
144
  }
107
145
 
146
+ /**
147
+ * Fetch the signed attestation body (`wyvrn.att.json`) for a v2 build.
148
+ * Returns the raw bytes (as a Buffer) so the verifier sees exactly what
149
+ * the publisher signed — never a JSON.parse → JSON.stringify round trip,
150
+ * which would re-canonicalise and could change byte content.
151
+ *
152
+ * Returns null when the file does not exist (registry hasn't adopted S1,
153
+ * or this build was published with `--no-sign`).
154
+ *
155
+ * @param {{ source: string, name: string, version: string, profileHash: string, awsProfile?: string, token?: string }} _options
156
+ * @returns {Promise<Buffer|null>}
157
+ */
158
+ async v2GetAttestation(_options) {
159
+ return null;
160
+ }
161
+
162
+ /**
163
+ * Fetch the signature envelope (`wyvrn.sig`) for a v2 build. Same
164
+ * Buffer-not-parsed contract as `v2GetAttestation`.
165
+ *
166
+ * @param {{ source: string, name: string, version: string, profileHash: string, awsProfile?: string, token?: string }} _options
167
+ * @returns {Promise<Buffer|null>}
168
+ */
169
+ async v2GetSignature(_options) {
170
+ return null;
171
+ }
172
+
173
+ /**
174
+ * Fetch the per-registry trust anchor: `.trust/keys.json` and
175
+ * `.trust/mode.json`. Returns `{ keys, mode }` as parsed objects, or
176
+ * `null` when the registry has no `.trust/` directory at all (treated
177
+ * as mode `off` by the install flow — current behaviour).
178
+ *
179
+ * Either inner field may itself be null if only one of the two files
180
+ * exists (mid-rollout state). Callers default missing `mode` to `off`.
181
+ *
182
+ * @param {{ source: string, awsProfile?: string, token?: string }} _options
183
+ * @returns {Promise<{ keys: object|null, mode: object|null }|null>}
184
+ */
185
+ async v2GetTrustAnchor(_options) {
186
+ return null;
187
+ }
188
+
108
189
  /**
109
190
  * Fetch the wyvrn.json metadata object for a specific v2 build.
110
191
  * Returns null if the build does not exist.
@@ -130,7 +211,21 @@ class BaseProvider {
130
211
  /**
131
212
  * Download the prebuilt binary zip for a specific v2 build to destPath.
132
213
  *
133
- * @param {{ source: string, name: string, version: string, profileHash: string, awsProfile?: string, token?: string }} options
214
+ * For per-config slice publishes, set `options.sliceConfig` to the
215
+ * canonical CMake config name (e.g. `"Release"`). The provider downloads
216
+ * `wyvrn-<sliceConfig>.zip` instead of the fat `wyvrn.zip`. When unset,
217
+ * downloads the fat `wyvrn.zip` (today's path, byte-identical for
218
+ * pre-feature publishes).
219
+ *
220
+ * @param {{
221
+ * source: string,
222
+ * name: string,
223
+ * version: string,
224
+ * profileHash: string,
225
+ * sliceConfig?: string,
226
+ * awsProfile?: string,
227
+ * token?: string,
228
+ * }} options
134
229
  * @param {string} destPath - absolute path to write the zip to
135
230
  * @param {number} timeoutMs
136
231
  * @returns {Promise<boolean>} true on success
@@ -91,35 +91,62 @@ class FileProvider extends BaseProvider {
91
91
  async v2Publish(files, options) {
92
92
  const {
93
93
  source, name, version, profileHash, buildSettings,
94
- contentSha256, gitSha, gitRepo, publishedLock,
95
- updateLatest = true, uploadedBy,
94
+ contentSha256, gitSha, gitRepo,
95
+ updateLatest = true,
96
+ artefactConfigs,
96
97
  } = options;
98
+ // `publishedLock` and `uploadedBy` are no longer consumed here —
99
+ // both are baked into `files.manifest` by the publisher (via
100
+ // src/v2-meta.js) before this call. They remain in the v2Publish
101
+ // contract on BaseProvider only for backward compatibility.
97
102
 
98
103
  const v2root = this._v2Root(source, name);
99
104
  const buildDir = path.join(v2root, version, profileHash);
100
105
  fs.mkdirSync(buildDir, { recursive: true });
101
106
 
102
- // 1) zip
103
- this._copy(files.zip, path.join(buildDir, 'wyvrn.zip'));
104
-
105
- // 2) enriched wyvrn.json (buildSettings stored for debugging only)
106
- // Dependencies are published verbatim from wyvrn.json ranges stay as
107
- // ranges so consumers can intersect them with their own. The author's
108
- // lockfile snapshot is preserved in `publishedLock` for audit.
109
- const rawManifest = JSON.parse(fs.readFileSync(files.manifest, 'utf8'));
110
- const v2Meta = {
111
- ...rawManifest,
112
- schemaVersion: 2,
113
- profileHash,
114
- buildSettings,
115
- contentSha256,
116
- gitSha: gitSha ?? null,
117
- gitRepo: gitRepo ?? null,
118
- publishedAt: new Date().toISOString(),
119
- ...(publishedLock && Object.keys(publishedLock).length > 0 ? { publishedLock } : {}),
120
- ...(uploadedBy ? { uploadedBy } : {}),
121
- };
122
- this._writeJson(path.join(buildDir, 'wyvrn.json'), v2Meta);
107
+ // Slice publish iff artefactConfigs is supplied non-empty. List the
108
+ // configs in alphabetical order so versions.json round-trips
109
+ // deterministically across publishers.
110
+ const sliceConfigs =
111
+ (artefactConfigs && Object.keys(artefactConfigs).length > 0)
112
+ ? Object.keys(artefactConfigs).sort()
113
+ : null;
114
+
115
+ // 1) zip(s) — fat or per-config slices
116
+ if (sliceConfigs) {
117
+ if (!files.artefactZips || typeof files.artefactZips !== 'object') {
118
+ throw new Error('v2Publish: artefactConfigs supplied but files.artefactZips missing');
119
+ }
120
+ for (const config of sliceConfigs) {
121
+ const slicePath = files.artefactZips[config];
122
+ if (!slicePath) {
123
+ throw new Error(
124
+ `v2Publish: artefactConfigs declares "${config}" but files.artefactZips["${config}"] is missing`,
125
+ );
126
+ }
127
+ this._copy(slicePath, path.join(buildDir, `wyvrn-${config}.zip`));
128
+ }
129
+ } else {
130
+ if (!files.zip) {
131
+ throw new Error('v2Publish: files.zip required for fat-zip publish (or pass artefactConfigs + files.artefactZips for slice publish)');
132
+ }
133
+ this._copy(files.zip, path.join(buildDir, 'wyvrn.zip'));
134
+ }
135
+
136
+ // 2) wyvrn.json — uploaded VERBATIM from `files.manifest`. The
137
+ // publisher (commands/publish.js, upload-built.js) builds the v2Meta
138
+ // body via src/v2-meta.js so its signed manifestSha256 matches
139
+ // exactly what consumers compute from v2GetMeta. Providers MUST
140
+ // NOT mutate the manifest here — that would invalidate every
141
+ // signature.
142
+ this._copy(files.manifest, path.join(buildDir, 'wyvrn.json'));
143
+
144
+ // 2.5) signing files (S1) — written between manifest and versions.json
145
+ // so a partial-failure crash never leaves a sig-less entry visible
146
+ // in versions.json. Both files optional; when absent the registry's
147
+ // mode.json governs whether consumers warn or abort.
148
+ if (files.attPath) this._copy(files.attPath, path.join(buildDir, 'wyvrn.att.json'));
149
+ if (files.sigPath) this._copy(files.sigPath, path.join(buildDir, 'wyvrn.sig'));
123
150
 
124
151
  // 3) source.json
125
152
  if (gitSha || gitRepo) {
@@ -130,16 +157,23 @@ class FileProvider extends BaseProvider {
130
157
  });
131
158
  }
132
159
 
133
- // 4) versions.json index
160
+ // 4) versions.json index. Per-profile entries hold either
161
+ // `contentSha256` (fat zip) or `configs` (slice publish);
162
+ // both fields can coexist for hybrid publishes but phase 1
163
+ // emits one or the other.
134
164
  const versionsPath = path.join(v2root, 'versions.json');
135
165
  let versionsIdx = this._readJson(versionsPath) ?? { name, latest: version, versions: {} };
136
166
  if (!versionsIdx.versions) versionsIdx.versions = {};
137
167
  if (!versionsIdx.versions[version]) versionsIdx.versions[version] = { profiles: {} };
138
- versionsIdx.versions[version].profiles[profileHash] = {
139
- contentSha256,
168
+
169
+ const profileEntry = {
140
170
  publishedAt: new Date().toISOString(),
141
171
  buildSettings,
142
172
  };
173
+ if (sliceConfigs) profileEntry.configs = sliceConfigs;
174
+ else profileEntry.contentSha256 = contentSha256;
175
+ versionsIdx.versions[version].profiles[profileHash] = profileEntry;
176
+
143
177
  if (gitSha || gitRepo) {
144
178
  versionsIdx.versions[version].source = { gitRepo: gitRepo ?? null, gitSha: gitSha ?? null };
145
179
  }
@@ -149,7 +183,10 @@ class FileProvider extends BaseProvider {
149
183
  // 5) v2 latest.json — skipped for consumer uploads (upload-built) so a
150
184
  // historical-version rebuild cannot clobber the real "latest" pointer.
151
185
  if (updateLatest) {
152
- this._writeJson(path.join(v2root, 'latest.json'), { version, profileHash, contentSha256 });
186
+ const latestEntry = { version, profileHash };
187
+ if (sliceConfigs) latestEntry.configs = sliceConfigs;
188
+ else latestEntry.contentSha256 = contentSha256;
189
+ this._writeJson(path.join(v2root, 'latest.json'), latestEntry);
153
190
  }
154
191
  }
155
192
 
@@ -162,12 +199,36 @@ class FileProvider extends BaseProvider {
162
199
  return this._readJson(path.join(this._v2Root(source, name), 'versions.json'));
163
200
  }
164
201
 
165
- async v2DownloadZip({ source, name, version, profileHash }, destPath) {
166
- const src = path.join(this._v2Root(source, name), version, profileHash, 'wyvrn.zip');
202
+ async v2DownloadZip({ source, name, version, profileHash, sliceConfig }, destPath) {
203
+ const filename = sliceConfig ? `wyvrn-${sliceConfig}.zip` : 'wyvrn.zip';
204
+ const src = path.join(this._v2Root(source, name), version, profileHash, filename);
167
205
  if (!fs.existsSync(src)) return false;
168
206
  fs.copyFileSync(src, destPath);
169
207
  return true;
170
208
  }
209
+
210
+ // ── S1 signing surface ────────────────────────────────────────────────────
211
+
212
+ async v2GetAttestation({ source, name, version, profileHash }) {
213
+ const p = path.join(this._v2Root(source, name), version, profileHash, 'wyvrn.att.json');
214
+ if (!fs.existsSync(p)) return null;
215
+ return fs.readFileSync(p); // raw bytes — verifier needs them unmodified
216
+ }
217
+
218
+ async v2GetSignature({ source, name, version, profileHash }) {
219
+ const p = path.join(this._v2Root(source, name), version, profileHash, 'wyvrn.sig');
220
+ if (!fs.existsSync(p)) return null;
221
+ return fs.readFileSync(p);
222
+ }
223
+
224
+ async v2GetTrustAnchor({ source }) {
225
+ const trustDir = path.join(this._resolvePath(source), 'v2', '.trust');
226
+ if (!fs.existsSync(trustDir)) return null;
227
+ return {
228
+ keys: this._readJson(path.join(trustDir, 'keys.json')),
229
+ mode: this._readJson(path.join(trustDir, 'mode.json')),
230
+ };
231
+ }
171
232
  }
172
233
 
173
234
  module.exports = FileProvider;
@@ -123,6 +123,37 @@ class HttpProvider extends BaseProvider {
123
123
  });
124
124
  }
125
125
 
126
+ /**
127
+ * GET url → raw bytes (Buffer), or null on 404 / error. Used for the
128
+ * S1 attestation + signature fetches where the verifier must see the
129
+ * exact bytes the publisher signed (any JSON.parse → JSON.stringify
130
+ * round-trip would re-canonicalise and could change content).
131
+ */
132
+ _getBytes(url, token) {
133
+ return new Promise((resolve) => {
134
+ const parsed = new URL(url);
135
+ const headers = { 'User-Agent': USER_AGENT };
136
+ if (token) headers['Authorization'] = `Bearer ${token}`;
137
+ const req = this._lib(url).request(
138
+ { hostname: parsed.hostname, port: parsed.port || undefined, path: parsed.pathname, method: 'GET', headers },
139
+ (res) => {
140
+ if (res.statusCode === 404) { res.resume(); resolve(null); return; }
141
+ if (res.statusCode < 200 || res.statusCode >= 300) {
142
+ log.warn(`HTTP GET ${url} → ${res.statusCode}; treating as missing`);
143
+ res.resume();
144
+ resolve(null);
145
+ return;
146
+ }
147
+ const chunks = [];
148
+ res.on('data', (c) => chunks.push(c));
149
+ res.on('end', () => resolve(Buffer.concat(chunks)));
150
+ },
151
+ );
152
+ req.on('error', () => resolve(null));
153
+ req.end();
154
+ });
155
+ }
156
+
126
157
  /** GET url → stream download to destPath. Returns true on success. */
127
158
  async _download(url, destPath, token, timeoutMs) {
128
159
  const controller = new AbortController();
@@ -174,41 +205,57 @@ class HttpProvider extends BaseProvider {
174
205
  async v2Publish(files, options) {
175
206
  const {
176
207
  source, name, version, profileHash, buildSettings,
177
- contentSha256, gitSha, gitRepo, publishedLock, token,
178
- updateLatest = true, uploadedBy,
208
+ contentSha256, gitSha, gitRepo, token,
209
+ updateLatest = true,
210
+ artefactConfigs,
179
211
  } = options;
180
212
  const base = source.replace(/\/$/, '');
181
213
  const v2root = `${base}/v2/${name}`;
182
214
  const buildRoot = `${v2root}/${version}/${profileHash}`;
183
215
 
184
- // 1) Upload the binary zip
185
- await this._put(`${buildRoot}/wyvrn.zip`, files.zip, 'application/zip', token);
186
-
187
- // 2) Upload enriched wyvrn.json (original manifest + v2 metadata).
188
- // buildSettings is stored for debugging purposes only — not for configuration.
189
- // Dependencies are published verbatim from wyvrn.jsonranges stay as
190
- // ranges so consumers can intersect them with their own. The author's
191
- // lockfile snapshot is preserved in `publishedLock` for audit.
192
- const rawManifest = JSON.parse(fs.readFileSync(files.manifest, 'utf8'));
193
- const v2Meta = {
194
- ...rawManifest,
195
- schemaVersion: 2,
196
- profileHash,
197
- buildSettings, // copy of the profile used — for debugging
198
- contentSha256,
199
- gitSha: gitSha ?? null,
200
- gitRepo: gitRepo ?? null,
201
- publishedAt: new Date().toISOString(),
202
- ...(publishedLock && Object.keys(publishedLock).length > 0 ? { publishedLock } : {}),
203
- ...(uploadedBy ? { uploadedBy } : {}),
204
- };
205
- await this._putBuffer(
216
+ const sliceConfigs =
217
+ (artefactConfigs && Object.keys(artefactConfigs).length > 0)
218
+ ? Object.keys(artefactConfigs).sort()
219
+ : null;
220
+
221
+ // 1) Upload the binary zip(s)fat or per-config slices.
222
+ if (sliceConfigs) {
223
+ if (!files.artefactZips || typeof files.artefactZips !== 'object') {
224
+ throw new Error('v2Publish: artefactConfigs supplied but files.artefactZips missing');
225
+ }
226
+ for (const config of sliceConfigs) {
227
+ const slicePath = files.artefactZips[config];
228
+ if (!slicePath) {
229
+ throw new Error(
230
+ `v2Publish: artefactConfigs declares "${config}" but files.artefactZips["${config}"] is missing`,
231
+ );
232
+ }
233
+ await this._put(`${buildRoot}/wyvrn-${config}.zip`, slicePath, 'application/zip', token);
234
+ }
235
+ } else {
236
+ if (!files.zip) {
237
+ throw new Error('v2Publish: files.zip required for fat-zip publish');
238
+ }
239
+ await this._put(`${buildRoot}/wyvrn.zip`, files.zip, 'application/zip', token);
240
+ }
241
+
242
+ // 2) Upload wyvrn.json VERBATIM from `files.manifest`. The publisher
243
+ // (commands/publish.js, upload-built.js) builds the v2Meta body
244
+ // via src/v2-meta.js so the signed manifestSha256 matches what
245
+ // consumers compute from v2GetMeta. Providers MUST NOT mutate.
246
+ await this._put(
206
247
  `${buildRoot}/wyvrn.json`,
207
- Buffer.from(JSON.stringify(v2Meta, null, 2)),
248
+ files.manifest,
208
249
  'application/json',
209
250
  token,
210
251
  );
211
252
 
253
+ // 2.5) signing files (S1) — written between manifest and versions.json
254
+ // so a partial-failure crash never leaves a sig-less entry visible
255
+ // in versions.json. Both files optional; mode.json governs.
256
+ if (files.attPath) await this._put(`${buildRoot}/wyvrn.att.json`, files.attPath, 'application/json', token);
257
+ if (files.sigPath) await this._put(`${buildRoot}/wyvrn.sig`, files.sigPath, 'application/json', token);
258
+
212
259
  // 3) Upload source.json (git metadata for build-from-source)
213
260
  if (gitSha || gitRepo) {
214
261
  const srcMeta = { gitRepo: gitRepo ?? null, gitSha: gitSha ?? null };
@@ -220,17 +267,23 @@ class HttpProvider extends BaseProvider {
220
267
  );
221
268
  }
222
269
 
223
- // 4) Read-modify-write versions.json index (non-atomic, best-effort)
270
+ // 4) Read-modify-write versions.json index (non-atomic, best-effort).
271
+ // Per-profile entries hold either `contentSha256` (fat zip) or
272
+ // `configs` (slice publish).
224
273
  let versionsIdx = await this._getJson(`${v2root}/versions.json`, token) ?? {
225
274
  name, latest: version, versions: {},
226
275
  };
227
276
  if (!versionsIdx.versions) versionsIdx.versions = {};
228
277
  if (!versionsIdx.versions[version]) versionsIdx.versions[version] = { profiles: {} };
229
- versionsIdx.versions[version].profiles[profileHash] = {
230
- contentSha256,
278
+
279
+ const profileEntry = {
231
280
  publishedAt: new Date().toISOString(),
232
281
  buildSettings,
233
282
  };
283
+ if (sliceConfigs) profileEntry.configs = sliceConfigs;
284
+ else profileEntry.contentSha256 = contentSha256;
285
+ versionsIdx.versions[version].profiles[profileHash] = profileEntry;
286
+
234
287
  if (gitSha || gitRepo) {
235
288
  versionsIdx.versions[version].source = { gitRepo: gitRepo ?? null, gitSha: gitSha ?? null };
236
289
  }
@@ -244,9 +297,12 @@ class HttpProvider extends BaseProvider {
244
297
 
245
298
  // 5) v2 latest.json — skipped for consumer uploads (see FileProvider note).
246
299
  if (updateLatest) {
300
+ const latestEntry = { version, profileHash };
301
+ if (sliceConfigs) latestEntry.configs = sliceConfigs;
302
+ else latestEntry.contentSha256 = contentSha256;
247
303
  await this._putBuffer(
248
304
  `${v2root}/latest.json`,
249
- Buffer.from(JSON.stringify({ version, profileHash, contentSha256 })),
305
+ Buffer.from(JSON.stringify(latestEntry)),
250
306
  'application/json',
251
307
  token,
252
308
  );
@@ -263,11 +319,32 @@ class HttpProvider extends BaseProvider {
263
319
  return this._getJson(`${base}/v2/${name}/versions.json`, token);
264
320
  }
265
321
 
266
- async v2DownloadZip({ source, name, version, profileHash, token }, destPath, timeoutMs) {
267
- const base = source.replace(/\/$/, '');
268
- const url = `${base}/v2/${name}/${version}/${profileHash}/wyvrn.zip`;
322
+ async v2DownloadZip({ source, name, version, profileHash, sliceConfig, token }, destPath, timeoutMs) {
323
+ const base = source.replace(/\/$/, '');
324
+ const filename = sliceConfig ? `wyvrn-${sliceConfig}.zip` : 'wyvrn.zip';
325
+ const url = `${base}/v2/${name}/${version}/${profileHash}/${filename}`;
269
326
  return this._download(url, destPath, token, timeoutMs);
270
327
  }
328
+
329
+ // ── S1 signing surface ────────────────────────────────────────────────────
330
+
331
+ async v2GetAttestation({ source, name, version, profileHash, token }) {
332
+ const base = source.replace(/\/$/, '');
333
+ return this._getBytes(`${base}/v2/${name}/${version}/${profileHash}/wyvrn.att.json`, token);
334
+ }
335
+
336
+ async v2GetSignature({ source, name, version, profileHash, token }) {
337
+ const base = source.replace(/\/$/, '');
338
+ return this._getBytes(`${base}/v2/${name}/${version}/${profileHash}/wyvrn.sig`, token);
339
+ }
340
+
341
+ async v2GetTrustAnchor({ source, token }) {
342
+ const base = source.replace(/\/$/, '');
343
+ const keys = await this._getJson(`${base}/v2/.trust/keys.json`, token);
344
+ const mode = await this._getJson(`${base}/v2/.trust/mode.json`, token);
345
+ if (keys === null && mode === null) return null; // no .trust dir at all → mode off
346
+ return { keys, mode };
347
+ }
271
348
  }
272
349
 
273
350
  module.exports = HttpProvider;