run402 4.50.0 → 4.52.0

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.
@@ -37,6 +37,7 @@ import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
37
37
  import { tmpdir } from "node:os";
38
38
  import { join } from "node:path";
39
39
  import { LocalError, isRun402Error } from "../errors.js";
40
+ import { fetchGitvaultObjectBytes } from "./gitvault-edge-fetch.js";
40
41
  import { GITVAULT_FORMAT, GITVAULT_GENESIS_EPOCH, GITVAULT_GENESIS_GENERATION, GITVAULT_HEX16_RE, GITVAULT_OID40_RE, GITVAULT_SUITE, attemptKeyCommitment, bytesToHex, checkFreshEpochKeyAgainstPriorKeys, checkHPartition, computeRotationId, computeTargetPartitionDigest, deriveDigestKey, deriveObjectKey, ekFingerprint, epochRotationKeyCommitment, formatGitvaultTimestamp, fromBase64url, hexToBytes, jcs, keyEnvelopeLedgerId, keyedCommitment, newGitvaultId, newHex32, nextEpoch, objectsetContent, openBindingPreimage, openEpochRotationForRecipient, openFrame, parseGitvaultStrict, parseRotateEpochPayload, pinManifestLedgerId, randomBytes, sealFrame, sealKeyEnvelope, sha256Hex, signGitvaultObject, storedBytes, storedBytesSha256, toBase64url, verifyGitvaultObject, } from "../namespaces/gitvault.crypto.js";
41
42
  import { GITVAULT_ZERO_SHA256_SENTINEL } from "../namespaces/gitvault.types.js";
42
43
  import { crossProfileGitvaultHint } from "./gitvault-profile-scan.js";
@@ -571,6 +572,37 @@ export function gitvaultManifestEntry(object) {
571
572
  }
572
573
  return entry;
573
574
  }
575
+ // ─── Inline upload (gitvault-composite-state-read design D2) ─────────────────
576
+ /** Mirrors the gateway's `GITVAULT_INLINE_UPLOAD_MAX_OBJECT_BYTES` (`services/gitvault/upload-sessions.ts`). */
577
+ export const GITVAULT_INLINE_UPLOAD_MAX_OBJECT_BYTES = 262_144;
578
+ /** Mirrors the gateway's `GITVAULT_INLINE_UPLOAD_MAX_REQUEST_BYTES`. */
579
+ export const GITVAULT_INLINE_UPLOAD_MAX_REQUEST_BYTES = 1_048_576;
580
+ /**
581
+ * The client-side mirror of the gateway's `isInlineUploadRequest` + per-object/
582
+ * per-request cap check: every object must fit under the PER-OBJECT cap AND
583
+ * the batch's total under the PER-REQUEST cap, or the whole batch takes the
584
+ * presigned session+PUT+finalize shape — no per-object mixing, matching the
585
+ * server's `VALIDATION_FAILED` refusal on a mixed request. An empty batch is
586
+ * never "inline" (nothing to send either way; `upload()`'s own early return
587
+ * already short-circuits before this is consulted, and `putObject` always
588
+ * wraps exactly one object so it never hits this branch).
589
+ *
590
+ * Takes the narrowest shape that satisfies every call site (`GitvaultUploadObject[]`
591
+ * for `uploadObjects`, a single `{bytes}`-shaped array for `putObject`) so
592
+ * neither caller needs to fabricate unrelated fields just to ask the
593
+ * question.
594
+ */
595
+ export function gitvaultInlineUploadEligible(objects) {
596
+ if (objects.length === 0)
597
+ return false;
598
+ let total = 0;
599
+ for (const o of objects) {
600
+ if (o.bytes.length > GITVAULT_INLINE_UPLOAD_MAX_OBJECT_BYTES)
601
+ return false;
602
+ total += o.bytes.length;
603
+ }
604
+ return total <= GITVAULT_INLINE_UPLOAD_MAX_REQUEST_BYTES;
605
+ }
574
606
  /**
575
607
  * The stable key both sides agree on, used to pair receipts back to
576
608
  * requests — MIRRORS the gateway's `keyEnvelopeLedgerId`/`pinManifestLedgerId`
@@ -693,7 +725,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
693
725
  const target = presigned.reads[0];
694
726
  if (!target)
695
727
  return null;
696
- const r = await client.fetch(target.url, { method: "GET" });
728
+ const r = await fetchGitvaultObjectBytes(client, target);
697
729
  if (r.status === 404)
698
730
  return null;
699
731
  if (!r.ok)
@@ -735,7 +767,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
735
767
  return mapBounded(targets, GITVAULT_TRANSPORT_CONCURRENCY, async (target, i) => {
736
768
  if (!target)
737
769
  return null;
738
- const r = await client.fetch(target.url, { method: "GET" });
770
+ const r = await fetchGitvaultObjectBytes(client, target);
739
771
  if (r.status === 404)
740
772
  return null;
741
773
  if (!r.ok)
@@ -743,6 +775,42 @@ export function createGitvaultHttpTransport(client, options = {}) {
743
775
  return new Uint8Array(await r.arrayBuffer());
744
776
  });
745
777
  }
778
+ /** Resolve ONE `GET …/state` carrier arm to raw bytes — inline decode, or a plain GET on the presigned URL (preferring its `edge_url` companion, gitvault-read-edge-cache design D5), `null` on a 404 (mirrors {@link getObjectBytes}'s absent reading; both arms indistinguishable after this). */
779
+ async function resolveVaultStateCarrier(carrier) {
780
+ if ("inline" in carrier)
781
+ return fromBase64url(carrier.inline, "carriers.inline");
782
+ const r = await fetchGitvaultObjectBytes(client, { url: carrier.presigned_url, edge_url: carrier.edge_url });
783
+ if (r.status === 404)
784
+ return null;
785
+ if (!r.ok)
786
+ fail("GITVAULT_OBJECT_READ_FAILED", `vault-state carrier GET failed (HTTP ${r.status})`, "reading the gitvault vault state", { status: r.status });
787
+ return new Uint8Array(await r.arrayBuffer());
788
+ }
789
+ /**
790
+ * `GET …/state` (design D1): one JSON body carrying the vault record, the
791
+ * newest generation, its head's exact stored bytes, and both carriers —
792
+ * resolved to raw bytes here, verified nowhere here (see the interface's
793
+ * own doc comment on {@link GitvaultTransport.getState}).
794
+ */
795
+ async function getVaultStateOut(repoId) {
796
+ const raw = await client.request(`${base(repoId)}/state`, { context: "reading the gitvault vault state" });
797
+ const head = raw.head ? { stored_bytes: fromBase64url(raw.head.stored_bytes, "head.stored_bytes"), stored_bytes_sha256: raw.head.stored_bytes_sha256 } : null;
798
+ const carriers = raw.carriers
799
+ ? { ref_state: await resolveVaultStateCarrier(raw.carriers.ref_state), retention_roots: await resolveVaultStateCarrier(raw.carriers.retention_roots) }
800
+ : null;
801
+ return { vault: raw.vault, newest_generation: raw.newest_generation, head, carriers };
802
+ }
803
+ /** Pair a `finalize`-shaped response's receipts back onto `objects` by ledger id — shared by the inline and presigned upload paths so neither forks the other's receipt-compare logic. */
804
+ function receiptsFromFinalize(objects, entries, fin) {
805
+ const receipts = new Map(fin.receipts.map((r) => [gitvaultLedgerId(r), r]));
806
+ return objects.map((o, i) => {
807
+ const id = gitvaultLedgerId(entries[i]);
808
+ const r = receipts.get(id);
809
+ if (!r)
810
+ fail("GITVAULT_RECEIPT_MISSING", `finalize returned no receipt for ${id}`, "finalizing gitvault upload session", { object_id: id, path: o.path });
811
+ return { path: o.path, object_id: o.object_id, sha256: r.ciphertext_sha256 ?? r.stored_bytes_sha256 ?? "", size_bytes: r.size_bytes };
812
+ });
813
+ }
746
814
  async function upload(repoId, objects, resourceBinding) {
747
815
  if (objects.length === 0)
748
816
  return [];
@@ -750,6 +818,19 @@ export function createGitvaultHttpTransport(client, options = {}) {
750
818
  // wire — the control plane derives the bucket key itself and refuses an
751
819
  // entry carrying an unexpected member.
752
820
  const entries = objects.map((o) => gitvaultManifestEntry(o));
821
+ if (gitvaultInlineUploadEligible(objects)) {
822
+ // gitvault-composite-state-read design D2: every object fits under the
823
+ // caps — one POST, bytes verified + written server-side, and the
824
+ // response IS the finalize response (no session, no PUTs, no separate
825
+ // finalize call). Same closed-key manifest as the presigned path, plus
826
+ // each entry's own bytes.
827
+ const fin = await client.request(`${base(repoId)}/upload-sessions`, {
828
+ method: "POST",
829
+ body: { objects: entries.map((entry, i) => ({ ...entry, bytes: b64u(objects[i].bytes) })), ...(resourceBinding ? { resource_binding: resourceBinding } : {}) },
830
+ context: "uploading gitvault objects inline",
831
+ });
832
+ return receiptsFromFinalize(objects, entries, fin);
833
+ }
753
834
  const session = await client.request(`${base(repoId)}/upload-sessions`, {
754
835
  method: "POST",
755
836
  body: { objects: entries, ...(resourceBinding ? { resource_binding: resourceBinding } : {}) },
@@ -785,14 +866,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
785
866
  fail("GITVAULT_UPLOAD_FAILED", `presigned PUT failed (HTTP ${r.status}) for ${o.path}`, "uploading gitvault objects", { path: o.path, status: r.status });
786
867
  });
787
868
  const fin = await client.request(`${base(repoId)}/upload-sessions/${encodeURIComponent(session.upload_session_id)}/finalize`, { method: "POST", body: {}, context: "finalizing gitvault upload session" });
788
- const receipts = new Map(fin.receipts.map((r) => [gitvaultLedgerId(r), r]));
789
- return objects.map((o, i) => {
790
- const id = gitvaultLedgerId(entries[i]);
791
- const r = receipts.get(id);
792
- if (!r)
793
- fail("GITVAULT_RECEIPT_MISSING", `finalize returned no receipt for ${id}`, "finalizing gitvault upload session", { object_id: id, path: o.path });
794
- return { path: o.path, object_id: o.object_id, sha256: r.ciphertext_sha256 ?? r.stored_bytes_sha256 ?? "", size_bytes: r.size_bytes };
795
- });
869
+ return receiptsFromFinalize(objects, entries, fin);
796
870
  }
797
871
  async function admit(repoId, generation, bytes, hash, extra = {}) {
798
872
  try {
@@ -874,6 +948,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
874
948
  return { cleared: r.advisory_cleared ?? r.cleared ?? false };
875
949
  },
876
950
  getVaultRecord: ({ repo_id }) => client.request(base(repo_id), { context: "reading the gitvault record" }),
951
+ getState: ({ repo_id }) => getVaultStateOut(repo_id),
877
952
  findVaultByProject: ({ project_id }) => client.request(`/gitvault/v1/vaults?project_id=${encodeURIComponent(project_id)}`, { context: "resolving the project's gitvault" }),
878
953
  findVaultByRepo: ({ org_slug, repo_name }) => client.request(`/gitvault/v1/vaults?repo=${encodeURIComponent(`${org_slug}/${repo_name}`)}`, { context: "resolving the gitvault by repo address" }),
879
954
  listOrgEncryptionKeys: ({ org_id }) => client.request(`/orgs/v1/${encodeURIComponent(org_id)}/encryption-keys`, { context: "reading the org encryption-key directory" }),
@@ -1168,6 +1243,130 @@ export class GitvaultVault {
1168
1243
  return this.genesisCache;
1169
1244
  }
1170
1245
  // ── §6.3/6.4 discovery + verification ──
1246
+ /**
1247
+ * gitvault-composite-state-read design D1 — the pin-current fast path
1248
+ * `verifyToNewest` tries FIRST: one `GET …/state` in place of BOTH the
1249
+ * live "server still holds the pin" read {@link readHead} would otherwise
1250
+ * perform AND, when eligible, the `listHeads` walk that would follow it.
1251
+ *
1252
+ * `null` means ineligible — the caller falls straight through to the
1253
+ * UNCHANGED `readHead` + `listHeads` flow, so a `null` here never weakens
1254
+ * verification, it only declines the shortcut:
1255
+ * - the vault is genuinely more than one generation ahead of `pin`
1256
+ * (the listing-walk shape this change does not touch, per design D1:
1257
+ * "a client whose pin is >1 behind newest_generation falls back to
1258
+ * the existing paginated listing + per-head walk");
1259
+ * - OR (one-generation-ahead only) the D194 epoch-continuity check the
1260
+ * ONE new head needs `pin`'s own `.epoch` for, and this call declines
1261
+ * to fetch `pin`'s own head bytes over the network — the entire point
1262
+ * of the shortcut — so it needs a LOCAL source for that epoch: either
1263
+ * `pin` is genesis (a fixed, known epoch), or `pin`'s own head bytes
1264
+ * are already cache-warm (from an earlier call, or from `admit()`'s
1265
+ * own post-push cache write). A cold cache here is not a correctness
1266
+ * problem, only a missed optimization.
1267
+ *
1268
+ * On a non-`null` return, `entries` is exactly what ONE real `listHeads`
1269
+ * page's `heads[]` would have been for this pin (0 items when the pin is
1270
+ * already current, 1 when it is exactly one generation behind — chain
1271
+ * link, gaplessness, and signature all still verified by the UNCHANGED
1272
+ * per-entry loop body {@link verifyToNewest} feeds them through), and
1273
+ * `pinnedHead` is `lastHead`'s INITIAL value: the real, verified head at
1274
+ * `pin.generation` when nothing new needs walking (so it is also the
1275
+ * FINAL value — the loop never runs), or a throwaway placeholder when one
1276
+ * new head is coming (the loop overwrites `lastHead` before anything else
1277
+ * ever reads it again — see `verifyToNewest`'s own `prevEpoch` line,
1278
+ * which is the ONLY thing that reads `lastHead`'s pre-loop value).
1279
+ *
1280
+ * Every byte this method reads from `getState` is cache-WARMED (head +
1281
+ * both carriers, keyed exactly as {@link readCachedHeadBytes}/
1282
+ * {@link openCarrier} already key their own writes) but NEVER trusted
1283
+ * here — every reader downstream re-verifies a cache hit against the hash
1284
+ * it would check network bytes against before using it (this file's own
1285
+ * established cache discipline; see `GitvaultKeystore`'s class doc
1286
+ * comment). A wrong or absent byte this method wrote is therefore just a
1287
+ * cache MISS on the next read, never a verification bypass.
1288
+ */
1289
+ async tryStateFastPath(pin) {
1290
+ const state = await this.transport.getState({ repo_id: this.repoId });
1291
+ // §6.4: the vault's newest generation may never fall below the
1292
+ // authenticated pin — checked here regardless of eligibility below, so
1293
+ // a regressed vault is caught exactly as loudly as it always was, even
1294
+ // when the walk that would otherwise discover it is about to be skipped.
1295
+ checkGenerationRegression(state.newest_generation ?? GITVAULT_GENESIS_GENERATION, pin.generation);
1296
+ // The gateway's own admission ledger advances `newest_generation` to the
1297
+ // GENESIS generation (0) the moment genesis itself is admitted — a vault
1298
+ // between "genesis admitted" and "first ordinary push" reports its own
1299
+ // generation as newest, `null` only for the narrower window before that
1300
+ // (kept here defensively; `state.head`/`state.carriers` are non-null
1301
+ // whenever `newest_generation` is non-null on the real route). EITHER
1302
+ // way, "no ORDINARY head yet" is the same case this file has always
1303
+ // treated specially: genesis is a DIFFERENT stored-object shape
1304
+ // (`vault_genesis` — no `ref_state`/`retention_roots`, {@link
1305
+ // GitvaultVault.genesis} owns verifying it via its OWN cache), so this
1306
+ // path must never parse `state.head` as a {@link GitvaultHead} when it
1307
+ // is actually genesis's bytes.
1308
+ const noOrdinaryHeadYet = state.newest_generation === null || state.newest_generation === GITVAULT_GENESIS_GENERATION;
1309
+ const pinBig = generationToBigInt(pin.generation);
1310
+ const newestBig = noOrdinaryHeadYet ? 0n : generationToBigInt(state.newest_generation);
1311
+ const diff = newestBig - pinBig; // ≥ 0n, guaranteed by the regression check above (which treats `null`/genesis identically to this)
1312
+ if (diff === 0n) {
1313
+ if (noOrdinaryHeadYet)
1314
+ return { entries: [], pinnedHead: null, prevEpoch: GITVAULT_GENESIS_EPOCH }; // matches today's `lastHead = null` for a genesis-only vault
1315
+ if (!state.head)
1316
+ fail("CHAIN_BROKEN", "the vault state reports an admitted generation but carries no head bytes", "verifying gitvault chain", { generation: state.newest_generation });
1317
+ const sha = sha256Hex(state.head.stored_bytes);
1318
+ if (sha !== pin.head_sha256)
1319
+ fail("CHAIN_BROKEN", `pinned head ${pin.generation} no longer hashes to the pin`, "reading pinned gitvault head", { generation: pin.generation });
1320
+ this.keystore.writeCachedHead(this.repoId, pin.generation, sha, state.head.stored_bytes);
1321
+ const head = parseGitvaultStrict(new TextDecoder().decode(state.head.stored_bytes));
1322
+ if (state.carriers)
1323
+ this.warmStateCarrierCache(pin.generation, head, state.carriers);
1324
+ return { entries: [], pinnedHead: head, prevEpoch: head.epoch };
1325
+ }
1326
+ if (diff === 1n) {
1327
+ let prevEpoch;
1328
+ if (pin.generation === GITVAULT_GENESIS_GENERATION) {
1329
+ prevEpoch = GITVAULT_GENESIS_EPOCH;
1330
+ }
1331
+ else {
1332
+ const cached = this.keystore.readCachedHead(this.repoId, pin.generation);
1333
+ if (!cached || sha256Hex(cached.bytes) !== pin.head_sha256)
1334
+ return null; // cold cache — decline the shortcut, never weaken it
1335
+ prevEpoch = parseGitvaultStrict(new TextDecoder().decode(cached.bytes)).epoch;
1336
+ }
1337
+ if (!state.head)
1338
+ fail("CHAIN_BROKEN", "the vault state reports a newer generation but carries no head bytes", "verifying gitvault chain", { generation: state.newest_generation });
1339
+ const newestGeneration = state.newest_generation;
1340
+ const shaOfBytes = sha256Hex(state.head.stored_bytes);
1341
+ if (shaOfBytes !== state.head.stored_bytes_sha256)
1342
+ fail("CHAIN_BROKEN", `head ${newestGeneration}: stored bytes hash does not match its own declared hash`, "verifying head chain", { generation: newestGeneration });
1343
+ this.keystore.writeCachedHead(this.repoId, newestGeneration, shaOfBytes, state.head.stored_bytes);
1344
+ const head = parseGitvaultStrict(new TextDecoder().decode(state.head.stored_bytes));
1345
+ if (state.carriers)
1346
+ this.warmStateCarrierCache(newestGeneration, head, state.carriers);
1347
+ // `pinnedHead` is a throwaway — see this method's own doc comment: the
1348
+ // ONE loop iteration below overwrites `lastHead` before anything but
1349
+ // `prevEpoch` (already resolved above) ever reads it again.
1350
+ return { entries: [{ generation: newestGeneration, stored_bytes_sha256: shaOfBytes }], pinnedHead: null, prevEpoch };
1351
+ }
1352
+ return null; // more than one generation behind — the existing listHeads walk owns this
1353
+ }
1354
+ /**
1355
+ * Warm the D3 carrier cache from a `GET …/state` response's two carriers,
1356
+ * keyed by the SAME `(object_id, ciphertext_sha256)` the carrying head's
1357
+ * own receipts name — a BLIND write (see {@link tryStateFastPath}'s doc
1358
+ * comment: every reader re-verifies a cache hit before trusting it, so
1359
+ * this is safe by construction). Skips a `null` carrier (absent stored
1360
+ * bytes) entirely rather than caching an absence — the existing
1361
+ * `openCarrier`/`decodeCarrierFrame` machinery already has its own
1362
+ * "frame absent" handling (`CHAIN_UNUSABLE`) via a genuine cache miss.
1363
+ */
1364
+ warmStateCarrierCache(generation, head, carriers) {
1365
+ if (carriers.ref_state)
1366
+ this.keystore.writeCachedCarrier(this.repoId, head.ref_state.object_id, generation, head.ref_state.ciphertext_sha256, carriers.ref_state);
1367
+ if (carriers.retention_roots)
1368
+ this.keystore.writeCachedCarrier(this.repoId, head.retention_roots.object_id, generation, head.retention_roots.ciphertext_sha256, carriers.retention_roots);
1369
+ }
1171
1370
  /**
1172
1371
  * List from the authenticated pin and verify every link upward. Persists
1173
1372
  * the verified prefix after each page, so a `VERIFICATION_BUDGET_EXCEEDED`
@@ -1213,13 +1412,26 @@ export class GitvaultVault {
1213
1412
  const writerKeyId = genesis.writer_key_id;
1214
1413
  const repo = this.repoFile();
1215
1414
  let pin = repo.verified_prefix ?? repo.head_pin ?? { generation: GITVAULT_GENESIS_GENERATION, head_sha256: genesisSha, pinned_at: formatGitvaultTimestamp(this.now()) };
1216
- let lastHead = pin.generation === GITVAULT_GENESIS_GENERATION ? null : await this.readHead(pin.generation, pin.head_sha256);
1415
+ // gitvault-composite-state-read design D1: try the pin-current fast path
1416
+ // FIRST — see {@link tryStateFastPath}'s own doc comment. `null` means
1417
+ // ineligible (genuinely more than one generation behind, or the pin's
1418
+ // own epoch could not be resolved without the network read this path
1419
+ // exists to avoid) — the caller falls straight through to the UNCHANGED
1420
+ // readHead + listHeads flow below, byte-identical to before this change.
1421
+ const fastPath = await this.tryStateFastPath(pin);
1422
+ let lastHead = fastPath ? fastPath.pinnedHead : pin.generation === GITVAULT_GENESIS_GENERATION ? null : await this.readHead(pin.generation, pin.head_sha256);
1217
1423
  const anchor = pin.generation;
1218
- let prevEpoch = lastHead?.epoch ?? GITVAULT_GENESIS_EPOCH;
1424
+ let prevEpoch = fastPath ? fastPath.prevEpoch : (lastHead?.epoch ?? GITVAULT_GENESIS_EPOCH);
1219
1425
  let progress = { after_generation: anchor, last_generation: anchor, delivered: 0 };
1220
1426
  let request = { after_generation: anchor, limit: String(GITVAULT_MAX_HEADS_PER_LISTING_PAGE) };
1221
1427
  let verified = 0;
1222
1428
  const rotations = [];
1429
+ // Consumed by (at most) the FIRST for(;;) iteration below — a listHeads
1430
+ // page this call never had to ask the network for, because the state
1431
+ // read above already proved it (0 entries: pin already current; 1 entry:
1432
+ // exactly the ONE new head, chain-linked from `pin` the SAME way a real
1433
+ // listHeads page's entry would be).
1434
+ let syntheticEntries = fastPath ? fastPath.entries : null;
1223
1435
  // ── decrypt-validation state (opt-in) ──
1224
1436
  const identity = decryptValidate ? this.keystore.readIdentity() : null;
1225
1437
  const ownKeypair = identity ? this.keystore.encryptionKeypair(identity) : null;
@@ -1307,7 +1519,14 @@ export class GitvaultVault {
1307
1519
  }
1308
1520
  };
1309
1521
  for (;;) {
1310
- const page = await this.transport.listHeads({ repo_id: this.repoId, ...request });
1522
+ // The synthetic page (0 or 1 entries) is only ever valid for the FIRST
1523
+ // iteration — `has_more: false` guarantees `nextListingRequest` ends
1524
+ // the loop right after it is consumed, so clearing it here is purely
1525
+ // defensive (a real second iteration can never see it non-null).
1526
+ const page = syntheticEntries !== null
1527
+ ? { format: GITVAULT_FORMAT, repo_id: this.repoId, after_generation: request.after_generation, heads: syntheticEntries, has_more: false, next_cursor: null, total: null }
1528
+ : await this.transport.listHeads({ repo_id: this.repoId, ...request });
1529
+ syntheticEntries = null;
1311
1530
  progress = verifyHeadsListingPage(page, request, progress, this.repoId);
1312
1531
  for (const entry of page.heads) {
1313
1532
  if (verified >= this.budget) {