@run402/sdk 4.95.0 → 4.97.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.
- package/README.md +27 -27
- package/core-dist/keystore.d.ts +1 -1
- package/core-dist/keystore.js +1 -1
- package/dist/errors.d.ts +5 -5
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -2
- package/dist/errors.js.map +1 -1
- package/dist/grant-key-credentials.d.ts +38 -0
- package/dist/grant-key-credentials.d.ts.map +1 -0
- package/dist/grant-key-credentials.js +42 -0
- package/dist/grant-key-credentials.js.map +1 -0
- package/dist/index.d.ts +16 -23
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -21
- package/dist/index.js.map +1 -1
- package/dist/namespaces/billing.d.ts +1 -1
- package/dist/namespaces/billing.js +1 -1
- package/dist/namespaces/buzz.js +1 -1
- package/dist/namespaces/buzz.js.map +1 -1
- package/dist/namespaces/credentials.d.ts +4 -4
- package/dist/namespaces/credentials.js +4 -4
- package/dist/namespaces/deploy.d.ts +5 -5
- package/dist/namespaces/deploy.d.ts.map +1 -1
- package/dist/namespaces/deploy.js +25 -25
- package/dist/namespaces/deploy.js.map +1 -1
- package/dist/namespaces/deploy.types.d.ts +17 -17
- package/dist/namespaces/deploy.types.d.ts.map +1 -1
- package/dist/namespaces/domains.d.ts +1 -1
- package/dist/namespaces/domains.js +1 -1
- package/dist/namespaces/errors.d.ts +1 -1
- package/dist/namespaces/errors.js +2 -2
- package/dist/namespaces/escalations.d.ts +2 -2
- package/dist/namespaces/escalations.js +2 -2
- package/dist/namespaces/escalations.types.d.ts +3 -3
- package/dist/namespaces/escalations.types.d.ts.map +1 -1
- package/dist/namespaces/events.d.ts +1 -1
- package/dist/namespaces/events.js +1 -1
- package/dist/namespaces/functions.js +1 -1
- package/dist/namespaces/functions.js.map +1 -1
- package/dist/namespaces/grants.d.ts +43 -9
- package/dist/namespaces/grants.d.ts.map +1 -1
- package/dist/namespaces/grants.js +99 -10
- package/dist/namespaces/grants.js.map +1 -1
- package/dist/namespaces/grants.types.d.ts +120 -15
- package/dist/namespaces/grants.types.d.ts.map +1 -1
- package/dist/namespaces/grants.types.js +12 -3
- package/dist/namespaces/grants.types.js.map +1 -1
- package/dist/namespaces/identity-links.types.d.ts +2 -2
- package/dist/namespaces/identity-links.types.d.ts.map +1 -1
- package/dist/namespaces/org.d.ts +3 -3
- package/dist/namespaces/org.js +3 -3
- package/dist/namespaces/org.types.d.ts +3 -3
- package/dist/namespaces/pay.js +1 -1
- package/dist/namespaces/projects.d.ts +1 -1
- package/dist/namespaces/projects.js +1 -1
- package/dist/namespaces/{gitvault.d.ts → repos.d.ts} +436 -449
- package/dist/namespaces/repos.d.ts.map +1 -0
- package/dist/namespaces/{gitvault.js → repos.js} +452 -460
- package/dist/namespaces/repos.js.map +1 -0
- package/dist/namespaces/rooms.d.ts +3 -3
- package/dist/namespaces/rooms.js +5 -5
- package/dist/namespaces/rooms.js.map +1 -1
- package/dist/namespaces/session.d.ts +2 -2
- package/dist/namespaces/session.d.ts.map +1 -1
- package/dist/namespaces/session.js +2 -2
- package/dist/namespaces/session.js.map +1 -1
- package/dist/namespaces/tier.d.ts +3 -3
- package/dist/namespaces/tier.d.ts.map +1 -1
- package/dist/namespaces/{gitvault.crypto.d.ts → vault.crypto.d.ts} +141 -141
- package/dist/namespaces/vault.crypto.d.ts.map +1 -0
- package/dist/namespaces/{gitvault.crypto.js → vault.crypto.js} +173 -173
- package/dist/namespaces/vault.crypto.js.map +1 -0
- package/dist/namespaces/{gitvault.types.d.ts → vault.types.d.ts} +136 -136
- package/dist/namespaces/vault.types.d.ts.map +1 -0
- package/dist/namespaces/{gitvault.types.js → vault.types.js} +4 -4
- package/dist/namespaces/vault.types.js.map +1 -0
- package/dist/node/bearer-redeem-key.d.ts +1 -1
- package/dist/node/bearer-redeem-key.js +5 -5
- package/dist/node/bearer-redeem-key.js.map +1 -1
- package/dist/node/credentials.d.ts +9 -9
- package/dist/node/credentials.d.ts.map +1 -1
- package/dist/node/credentials.js +12 -12
- package/dist/node/credentials.js.map +1 -1
- package/dist/node/http-dispatcher.js +5 -5
- package/dist/node/http-dispatcher.js.map +1 -1
- package/dist/node/index.d.ts +34 -34
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +41 -41
- package/dist/node/index.js.map +1 -1
- package/dist/node/{gitvault-address.d.ts → vault-address.d.ts} +42 -42
- package/dist/node/vault-address.d.ts.map +1 -0
- package/dist/node/{gitvault-address.js → vault-address.js} +43 -43
- package/dist/node/vault-address.js.map +1 -0
- package/dist/node/{gitvault-apply.d.ts → vault-apply.d.ts} +27 -27
- package/dist/node/vault-apply.d.ts.map +1 -0
- package/dist/node/{gitvault-apply.js → vault-apply.js} +38 -38
- package/dist/node/vault-apply.js.map +1 -0
- package/dist/node/vault-byo-config.d.ts +29 -0
- package/dist/node/vault-byo-config.d.ts.map +1 -0
- package/dist/node/{gitvault-byo-config.js → vault-byo-config.js} +18 -18
- package/dist/node/vault-byo-config.js.map +1 -0
- package/dist/node/vault-byo-probe.d.ts +20 -0
- package/dist/node/vault-byo-probe.d.ts.map +1 -0
- package/dist/node/vault-byo-probe.js +57 -0
- package/dist/node/vault-byo-probe.js.map +1 -0
- package/dist/node/{gitvault-creation-journal.d.ts → vault-creation-journal.d.ts} +69 -69
- package/dist/node/vault-creation-journal.d.ts.map +1 -0
- package/dist/node/{gitvault-creation-journal.js → vault-creation-journal.js} +54 -54
- package/dist/node/vault-creation-journal.js.map +1 -0
- package/dist/node/{gitvault-degraded-read.d.ts → vault-degraded-read.d.ts} +34 -34
- package/dist/node/vault-degraded-read.d.ts.map +1 -0
- package/dist/node/{gitvault-degraded-read.js → vault-degraded-read.js} +24 -24
- package/dist/node/vault-degraded-read.js.map +1 -0
- package/dist/node/{gitvault-deploy.d.ts → vault-deploy.d.ts} +67 -67
- package/dist/node/vault-deploy.d.ts.map +1 -0
- package/dist/node/{gitvault-deploy.js → vault-deploy.js} +27 -27
- package/dist/node/vault-deploy.js.map +1 -0
- package/dist/node/{gitvault-edge-fetch.d.ts → vault-edge-fetch.d.ts} +9 -9
- package/dist/node/vault-edge-fetch.d.ts.map +1 -0
- package/dist/node/{gitvault-edge-fetch.js → vault-edge-fetch.js} +11 -11
- package/dist/node/vault-edge-fetch.js.map +1 -0
- package/dist/node/{gitvault-handoff.d.ts → vault-handoff.d.ts} +9 -9
- package/dist/node/vault-handoff.d.ts.map +1 -0
- package/dist/node/{gitvault-handoff.js → vault-handoff.js} +24 -24
- package/dist/node/vault-handoff.js.map +1 -0
- package/dist/node/{gitvault-keystore.d.ts → vault-keystore.d.ts} +87 -77
- package/dist/node/vault-keystore.d.ts.map +1 -0
- package/dist/node/{gitvault-keystore.js → vault-keystore.js} +105 -68
- package/dist/node/vault-keystore.js.map +1 -0
- package/dist/node/{gitvault-member-bundle.d.ts → vault-member-bundle.d.ts} +17 -17
- package/dist/node/vault-member-bundle.d.ts.map +1 -0
- package/dist/node/{gitvault-member-bundle.js → vault-member-bundle.js} +11 -11
- package/dist/node/vault-member-bundle.js.map +1 -0
- package/dist/node/{gitvault-mirror-backend.d.ts → vault-mirror-backend.d.ts} +26 -26
- package/dist/node/vault-mirror-backend.d.ts.map +1 -0
- package/dist/node/{gitvault-mirror-backend.js → vault-mirror-backend.js} +25 -25
- package/dist/node/vault-mirror-backend.js.map +1 -0
- package/dist/node/{gitvault-mirror-config.d.ts → vault-mirror-config.d.ts} +23 -23
- package/dist/node/vault-mirror-config.d.ts.map +1 -0
- package/dist/node/{gitvault-mirror-config.js → vault-mirror-config.js} +16 -16
- package/dist/node/vault-mirror-config.js.map +1 -0
- package/dist/node/{gitvault-mirror.d.ts → vault-mirror.d.ts} +36 -36
- package/dist/node/vault-mirror.d.ts.map +1 -0
- package/dist/node/{gitvault-mirror.js → vault-mirror.js} +75 -75
- package/dist/node/vault-mirror.js.map +1 -0
- package/dist/node/{gitvault-native-crypto.d.ts → vault-native-crypto.d.ts} +10 -10
- package/dist/node/vault-native-crypto.d.ts.map +1 -0
- package/dist/node/{gitvault-native-crypto.js → vault-native-crypto.js} +16 -16
- package/dist/node/vault-native-crypto.js.map +1 -0
- package/dist/node/{gitvault-open-or-create.d.ts → vault-open-or-create.d.ts} +16 -16
- package/dist/node/vault-open-or-create.d.ts.map +1 -0
- package/dist/node/{gitvault-open-or-create.js → vault-open-or-create.js} +10 -10
- package/dist/node/vault-open-or-create.js.map +1 -0
- package/dist/node/{gitvault-prewarm.d.ts → vault-prewarm.d.ts} +13 -13
- package/dist/node/vault-prewarm.d.ts.map +1 -0
- package/dist/node/{gitvault-prewarm.js → vault-prewarm.js} +25 -25
- package/dist/node/vault-prewarm.js.map +1 -0
- package/dist/node/{gitvault-profile-scan.d.ts → vault-profile-scan.d.ts} +3 -3
- package/dist/node/vault-profile-scan.d.ts.map +1 -0
- package/dist/node/{gitvault-profile-scan.js → vault-profile-scan.js} +12 -12
- package/dist/node/vault-profile-scan.js.map +1 -0
- package/dist/node/{gitvault-prune.d.ts → vault-prune.d.ts} +61 -61
- package/dist/node/vault-prune.d.ts.map +1 -0
- package/dist/node/{gitvault-prune.js → vault-prune.js} +34 -34
- package/dist/node/vault-prune.js.map +1 -0
- package/dist/node/{gitvault-publication.d.ts → vault-publication.d.ts} +399 -399
- package/dist/node/vault-publication.d.ts.map +1 -0
- package/dist/node/{gitvault-publication.js → vault-publication.js} +584 -584
- package/dist/node/vault-publication.js.map +1 -0
- package/dist/node/{gitvault-push-to-create.d.ts → vault-push-to-create.d.ts} +16 -16
- package/dist/node/vault-push-to-create.d.ts.map +1 -0
- package/dist/node/{gitvault-push-to-create.js → vault-push-to-create.js} +9 -9
- package/dist/node/vault-push-to-create.js.map +1 -0
- package/dist/node/{gitvault-recover.d.ts → vault-recover.d.ts} +50 -50
- package/dist/node/vault-recover.d.ts.map +1 -0
- package/dist/node/{gitvault-recover.js → vault-recover.js} +84 -84
- package/dist/node/vault-recover.js.map +1 -0
- package/dist/node/{gitvault-restore.d.ts → vault-restore.d.ts} +8 -8
- package/dist/node/vault-restore.d.ts.map +1 -0
- package/dist/node/{gitvault-restore.js → vault-restore.js} +11 -11
- package/dist/node/vault-restore.js.map +1 -0
- package/dist/node/{gitvault-snapshot.d.ts → vault-snapshot.d.ts} +48 -48
- package/dist/node/vault-snapshot.d.ts.map +1 -0
- package/dist/node/{gitvault-snapshot.js → vault-snapshot.js} +61 -61
- package/dist/node/vault-snapshot.js.map +1 -0
- package/dist/node/{gitvault-writer-state.d.ts → vault-writer-state.d.ts} +8 -8
- package/dist/node/vault-writer-state.d.ts.map +1 -0
- package/dist/node/{gitvault-writer-state.js → vault-writer-state.js} +15 -15
- package/dist/node/vault-writer-state.js.map +1 -0
- package/dist/node/workflow-output.js +4 -4
- package/dist/node/workflow-output.js.map +1 -1
- package/dist/project-auth-classification.d.ts +8 -8
- package/dist/project-auth-classification.d.ts.map +1 -1
- package/dist/project-auth-classification.js +7 -7
- package/dist/project-auth-classification.js.map +1 -1
- package/dist/scoped.d.ts +6 -14
- package/dist/scoped.d.ts.map +1 -1
- package/dist/scoped.js +10 -21
- package/dist/scoped.js.map +1 -1
- package/package.json +1 -1
- package/dist/delegate-credentials.d.ts +0 -37
- package/dist/delegate-credentials.d.ts.map +0 -1
- package/dist/delegate-credentials.js +0 -41
- package/dist/delegate-credentials.js.map +0 -1
- package/dist/namespaces/delegates.d.ts +0 -50
- package/dist/namespaces/delegates.d.ts.map +0 -1
- package/dist/namespaces/delegates.js +0 -99
- package/dist/namespaces/delegates.js.map +0 -1
- package/dist/namespaces/delegates.types.d.ts +0 -88
- package/dist/namespaces/delegates.types.d.ts.map +0 -1
- package/dist/namespaces/delegates.types.js +0 -10
- package/dist/namespaces/delegates.types.js.map +0 -1
- package/dist/namespaces/gitvault.crypto.d.ts.map +0 -1
- package/dist/namespaces/gitvault.crypto.js.map +0 -1
- package/dist/namespaces/gitvault.d.ts.map +0 -1
- package/dist/namespaces/gitvault.js.map +0 -1
- package/dist/namespaces/gitvault.types.d.ts.map +0 -1
- package/dist/namespaces/gitvault.types.js.map +0 -1
- package/dist/node/gitvault-address.d.ts.map +0 -1
- package/dist/node/gitvault-address.js.map +0 -1
- package/dist/node/gitvault-apply.d.ts.map +0 -1
- package/dist/node/gitvault-apply.js.map +0 -1
- package/dist/node/gitvault-byo-config.d.ts +0 -29
- package/dist/node/gitvault-byo-config.d.ts.map +0 -1
- package/dist/node/gitvault-byo-config.js.map +0 -1
- package/dist/node/gitvault-byo-probe.d.ts +0 -20
- package/dist/node/gitvault-byo-probe.d.ts.map +0 -1
- package/dist/node/gitvault-byo-probe.js +0 -57
- package/dist/node/gitvault-byo-probe.js.map +0 -1
- package/dist/node/gitvault-creation-journal.d.ts.map +0 -1
- package/dist/node/gitvault-creation-journal.js.map +0 -1
- package/dist/node/gitvault-degraded-read.d.ts.map +0 -1
- package/dist/node/gitvault-degraded-read.js.map +0 -1
- package/dist/node/gitvault-deploy.d.ts.map +0 -1
- package/dist/node/gitvault-deploy.js.map +0 -1
- package/dist/node/gitvault-edge-fetch.d.ts.map +0 -1
- package/dist/node/gitvault-edge-fetch.js.map +0 -1
- package/dist/node/gitvault-handoff.d.ts.map +0 -1
- package/dist/node/gitvault-handoff.js.map +0 -1
- package/dist/node/gitvault-keystore.d.ts.map +0 -1
- package/dist/node/gitvault-keystore.js.map +0 -1
- package/dist/node/gitvault-member-bundle.d.ts.map +0 -1
- package/dist/node/gitvault-member-bundle.js.map +0 -1
- package/dist/node/gitvault-mirror-backend.d.ts.map +0 -1
- package/dist/node/gitvault-mirror-backend.js.map +0 -1
- package/dist/node/gitvault-mirror-config.d.ts.map +0 -1
- package/dist/node/gitvault-mirror-config.js.map +0 -1
- package/dist/node/gitvault-mirror.d.ts.map +0 -1
- package/dist/node/gitvault-mirror.js.map +0 -1
- package/dist/node/gitvault-native-crypto.d.ts.map +0 -1
- package/dist/node/gitvault-native-crypto.js.map +0 -1
- package/dist/node/gitvault-open-or-create.d.ts.map +0 -1
- package/dist/node/gitvault-open-or-create.js.map +0 -1
- package/dist/node/gitvault-prewarm.d.ts.map +0 -1
- package/dist/node/gitvault-prewarm.js.map +0 -1
- package/dist/node/gitvault-profile-scan.d.ts.map +0 -1
- package/dist/node/gitvault-profile-scan.js.map +0 -1
- package/dist/node/gitvault-prune.d.ts.map +0 -1
- package/dist/node/gitvault-prune.js.map +0 -1
- package/dist/node/gitvault-publication.d.ts.map +0 -1
- package/dist/node/gitvault-publication.js.map +0 -1
- package/dist/node/gitvault-push-to-create.d.ts.map +0 -1
- package/dist/node/gitvault-push-to-create.js.map +0 -1
- package/dist/node/gitvault-recover.d.ts.map +0 -1
- package/dist/node/gitvault-recover.js.map +0 -1
- package/dist/node/gitvault-restore.d.ts.map +0 -1
- package/dist/node/gitvault-restore.js.map +0 -1
- package/dist/node/gitvault-snapshot.d.ts.map +0 -1
- package/dist/node/gitvault-snapshot.js.map +0 -1
- package/dist/node/gitvault-writer-state.d.ts.map +0 -1
- package/dist/node/gitvault-writer-state.js.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* vault — publication (protocol rev 41 §6 + §4.4–4.7 + §5A client side;
|
|
3
3
|
* task 5.4).
|
|
4
4
|
*
|
|
5
5
|
* What lives here:
|
|
@@ -13,12 +13,12 @@
|
|
|
13
13
|
* next_cursor coupling, strictly-above-anchor, gapless across pages,
|
|
14
14
|
* truthful total), chain linkage (`CHAIN_BROKEN`), and the transition
|
|
15
15
|
* fail-closed rule (`UPGRADE_REQUIRED`);
|
|
16
|
-
* - {@link
|
|
16
|
+
* - {@link VaultTransport} — the control-plane + bucket operations the
|
|
17
17
|
* vault needs (extends task 5.3's creation transport) — and
|
|
18
|
-
* {@link
|
|
18
|
+
* {@link createVaultHttpTransport}, the `fetch`-backed implementation
|
|
19
19
|
* over the SDK kernel (upload sessions → presigned PUT with
|
|
20
20
|
* `If-None-Match: *` → finalize);
|
|
21
|
-
* - {@link
|
|
21
|
+
* - {@link Vault} — discover/verify to newest with the RESUMABLE
|
|
22
22
|
* verification budget, materialize (decrypt ref_state + retention_roots →
|
|
23
23
|
* the materialized pin), push (pack_set or a checkpoint-bearing head when
|
|
24
24
|
* the delta exceeds the 64-receipt budget, upload with receipt-compare,
|
|
@@ -37,29 +37,29 @@ 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 {
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
46
|
-
import { applyAddWriterKey, applyWriterSetUpdate, buildAddWriterKeyActivationPayload, buildWriterDoorAddWriterKeyPayload, initialWriterState, resolveActiveWriter, resolveKnownWriter, validateAddWriterKeyPayload, validateWriterSetUpdate, writerKeyIdOf, } from "./
|
|
47
|
-
import {
|
|
40
|
+
import { fetchVaultObjectBytes } from "./vault-edge-fetch.js";
|
|
41
|
+
import { openVaultDestinationBackend } from "./vault-mirror-backend.js";
|
|
42
|
+
import { VAULT_FORMAT, VAULT_GENESIS_EPOCH, VAULT_GENESIS_GENERATION, VAULT_HEX16_RE, VAULT_OID40_RE, VAULT_SUITE, attemptKeyCommitment, bytesToHex, checkFreshEpochKeyAgainstPriorKeys, checkHPartition, computeRotationId, computeTargetPartitionDigest, deriveDigestKey, deriveObjectKey, ekFingerprint, vkFingerprint, VAULT_TERMINAL_LOSS_DOCTOR_TEXT, VAULT_TERMINAL_LOSS_STATEMENT, epochRotationKeyCommitment, formatVaultTimestamp, fromBase64url, hexToBytes, jcs, keyEnvelopeLedgerId, keyedCommitment, newVaultId, newHex32, nextEpoch, objectsetContent, openBindingPreimage, openEpochRotationForRecipient, openFrame, parseAddWriterKeyPayload, parseVaultStrict, parseRotateEpochPayload, pinManifestLedgerId, randomBytes, sealFrame, sealKeyEnvelope, sha256Hex, signVaultObject, storedBytes, storedBytesSha256, toBase64url, verifyVaultObject, } from "../namespaces/vault.crypto.js";
|
|
43
|
+
import { VAULT_ZERO_SHA256_SENTINEL } from "../namespaces/vault.types.js";
|
|
44
|
+
import { vaultCheckpointStaleness } from "../namespaces/repos.js";
|
|
45
|
+
import { crossProfileVaultHint } from "./vault-profile-scan.js";
|
|
46
|
+
import { applyAddWriterKey, applyWriterSetUpdate, buildAddWriterKeyActivationPayload, buildWriterDoorAddWriterKeyPayload, initialWriterState, resolveActiveWriter, resolveKnownWriter, validateAddWriterKeyPayload, validateWriterSetUpdate, writerKeyIdOf, } from "./vault-writer-state.js";
|
|
47
|
+
import { VAULT_DEPLOY_REF, hardenedGit, hasObject, hasObjects, isAncestor } from "./vault-snapshot.js";
|
|
48
48
|
// ─── Constants (constants.json) ──────────────────────────────────────────────
|
|
49
|
-
export const
|
|
50
|
-
export const
|
|
51
|
-
export const
|
|
52
|
-
export const
|
|
53
|
-
export const
|
|
54
|
-
export const
|
|
55
|
-
export const
|
|
56
|
-
export const
|
|
57
|
-
export const
|
|
58
|
-
export const
|
|
59
|
-
export const
|
|
60
|
-
export const
|
|
49
|
+
export const VAULT_MAX_CANONICAL_REFS = 10_000;
|
|
50
|
+
export const VAULT_MAX_REF_UPDATES_PER_TRANSACTION = 1_000;
|
|
51
|
+
export const VAULT_MAX_RETENTION_ROOT_ENTRIES = 50_000;
|
|
52
|
+
export const VAULT_MAX_REPAIR_ADDED_ROOTS = 10_000;
|
|
53
|
+
export const VAULT_MAX_WAL_RECEIPTS_PER_HEAD = 64;
|
|
54
|
+
export const VAULT_MAX_CHECKPOINT_PACKS = 4_096;
|
|
55
|
+
export const VAULT_MAX_CHECKPOINT_TOTAL_STORED_BYTES = 858993459200n;
|
|
56
|
+
export const VAULT_MULTI_OBJECT_PACK_TARGET_BYTES = 201_326_592;
|
|
57
|
+
export const VAULT_MAX_REF_STATE_OBJECT_BYTES = 33_554_432;
|
|
58
|
+
export const VAULT_MAX_HEADS_PER_LISTING_PAGE = 1_000;
|
|
59
|
+
export const VAULT_VERIFICATION_BUDGET_HEADS = 100_000;
|
|
60
|
+
export const VAULT_RETENTION_MIN_DAYS = 90;
|
|
61
61
|
/** Default admission-conflict retries before the push gives up (each retry re-verifies + re-applies to the winner). */
|
|
62
|
-
export const
|
|
62
|
+
export const VAULT_PUSH_CONFLICT_RETRIES = 5;
|
|
63
63
|
const CANONICAL_REF_RE = /^(?!.*\.\.)(?!.*\/\/)(?!.*@\{)refs\/(heads|tags|run402)\/[^\u0000-\u0020\u007f~^:?*[\\]+$/;
|
|
64
64
|
const BRANCH_REF_RE = /^(?!.*\.\.)(?!.*\/\/)(?!.*@\{)refs\/heads\/[^\u0000-\u0020\u007f~^:?*[\\]+$/;
|
|
65
65
|
const CURSOR_RE = /^[A-Za-z0-9_-]{1,256}$/;
|
|
@@ -72,10 +72,10 @@ function fail(code, message, context, details, nextActions) {
|
|
|
72
72
|
* Resolved once from this package's own `package.json` — mirrors
|
|
73
73
|
* `node/index.ts`'s `readSdkPackageVersion`, duplicated locally rather than
|
|
74
74
|
* imported to avoid a dependency edge from this file (imported by the
|
|
75
|
-
* cross-runtime `namespaces/
|
|
75
|
+
* cross-runtime `namespaces/repos.ts` via a dynamic `import()`, task
|
|
76
76
|
* 5.0's public crypto surface) back onto the Node CLI/SDK entry module.
|
|
77
77
|
*/
|
|
78
|
-
const
|
|
78
|
+
const VAULT_SDK_PACKAGE_VERSION = (() => {
|
|
79
79
|
try {
|
|
80
80
|
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
81
81
|
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
@@ -92,12 +92,12 @@ const GITVAULT_SDK_PACKAGE_VERSION = (() => {
|
|
|
92
92
|
* authorization input; no wire grammar is promised for it (protocol-v0.md
|
|
93
93
|
* §4.14, `rotate_epoch_payload.json`'s own field `$comment`).
|
|
94
94
|
*/
|
|
95
|
-
export function
|
|
96
|
-
return `run402@${
|
|
95
|
+
export function vaultReaderEntrypoint(entrypoint) {
|
|
96
|
+
return `run402@${VAULT_SDK_PACKAGE_VERSION}/${entrypoint}`;
|
|
97
97
|
}
|
|
98
98
|
// ─── Generations ─────────────────────────────────────────────────────────────
|
|
99
99
|
export function generationToBigInt(generation) {
|
|
100
|
-
if (!
|
|
100
|
+
if (!VAULT_HEX16_RE.test(generation))
|
|
101
101
|
fail("CHAIN_BROKEN", `malformed generation: ${generation}`, "parsing generation");
|
|
102
102
|
return BigInt(`0x${generation}`);
|
|
103
103
|
}
|
|
@@ -119,8 +119,8 @@ export async function evaluateRefTransaction(current, transaction, options) {
|
|
|
119
119
|
const updates = transaction.updates;
|
|
120
120
|
if (!Array.isArray(updates) || updates.length === 0)
|
|
121
121
|
fail("REF_TRANSACTION_INVALID", "a ref transaction needs at least one update", "evaluating ref transaction");
|
|
122
|
-
if (updates.length >
|
|
123
|
-
fail("REF_STATE_LIMIT_EXCEEDED", `${updates.length} updates exceed the ${
|
|
122
|
+
if (updates.length > VAULT_MAX_REF_UPDATES_PER_TRANSACTION) {
|
|
123
|
+
fail("REF_STATE_LIMIT_EXCEEDED", `${updates.length} updates exceed the ${VAULT_MAX_REF_UPDATES_PER_TRANSACTION}-update transaction bound`, "evaluating ref transaction", { updates: updates.length, bound: VAULT_MAX_REF_UPDATES_PER_TRANSACTION });
|
|
124
124
|
}
|
|
125
125
|
const seen = new Set();
|
|
126
126
|
for (const u of updates) {
|
|
@@ -134,9 +134,9 @@ export async function evaluateRefTransaction(current, transaction, options) {
|
|
|
134
134
|
if (u.ref.startsWith("refs/run402/") && (options.protocol_refs ?? "refuse") === "refuse") {
|
|
135
135
|
fail("REFNAME_UNSUPPORTED", `${u.ref} is protocol-owned (refs/run402/*); user pushes to it are refused`, "evaluating ref transaction", { ref: u.ref });
|
|
136
136
|
}
|
|
137
|
-
if (u.expected_old_oid !== null && !
|
|
137
|
+
if (u.expected_old_oid !== null && !VAULT_OID40_RE.test(u.expected_old_oid))
|
|
138
138
|
fail("REF_TRANSACTION_INVALID", `expected_old_oid for ${u.ref} is not a 40-hex oid`, "evaluating ref transaction", { ref: u.ref });
|
|
139
|
-
if (u.new_oid !== null && !
|
|
139
|
+
if (u.new_oid !== null && !VAULT_OID40_RE.test(u.new_oid))
|
|
140
140
|
fail("REF_TRANSACTION_INVALID", `new_oid for ${u.ref} is not a 40-hex oid`, "evaluating ref transaction", { ref: u.ref });
|
|
141
141
|
if (u.new_oid === null && u.expected_old_oid === null)
|
|
142
142
|
fail("REF_TRANSACTION_INVALID", `${u.ref}: a delete requires expected_old_oid`, "evaluating ref transaction", { ref: u.ref });
|
|
@@ -178,7 +178,7 @@ export async function evaluateRefTransaction(current, transaction, options) {
|
|
|
178
178
|
}
|
|
179
179
|
if (failures.length > 0) {
|
|
180
180
|
const nextActions = [{ action: "refetch, reapply the transaction to the winner's map, retry" }];
|
|
181
|
-
// The force-with-lease inversion (
|
|
181
|
+
// The force-with-lease inversion (vault-force-spelling-and-pin-fold):
|
|
182
182
|
// git's remote-helper protocol carries force only as a `+` refspec
|
|
183
183
|
// prefix, which `--force-with-lease` never sets — the lease cannot cross
|
|
184
184
|
// the helper boundary, so a rebased tip arrives as a NON-force update
|
|
@@ -200,15 +200,15 @@ export async function evaluateRefTransaction(current, transaction, options) {
|
|
|
200
200
|
/** §6.5 bound: ≤ 10 000 canonical refs and the serialized map ≤ 32 MiB. */
|
|
201
201
|
export function assertRefMapCardinality(refs) {
|
|
202
202
|
const n = Object.keys(refs).length;
|
|
203
|
-
if (n >
|
|
204
|
-
fail("REF_STATE_LIMIT_EXCEEDED", `${n} canonical refs exceed the ${
|
|
205
|
-
if (jcs(refs).length >
|
|
203
|
+
if (n > VAULT_MAX_CANONICAL_REFS)
|
|
204
|
+
fail("REF_STATE_LIMIT_EXCEEDED", `${n} canonical refs exceed the ${VAULT_MAX_CANONICAL_REFS} bound`, "checking ref-state cardinality", { refs: n });
|
|
205
|
+
if (jcs(refs).length > VAULT_MAX_REF_STATE_OBJECT_BYTES)
|
|
206
206
|
fail("REF_STATE_LIMIT_EXCEEDED", "the serialized ref map exceeds 32 MiB", "checking ref-state cardinality");
|
|
207
207
|
}
|
|
208
208
|
/** The §4.4 deploy-ref move: force-with-lease `refs/run402/deploys/latest` → `oid` (creation when absent). */
|
|
209
209
|
export function deployRefTransaction(current, oid) {
|
|
210
|
-
const old = current[
|
|
211
|
-
return { updates: [{ ref:
|
|
210
|
+
const old = current[VAULT_DEPLOY_REF] ?? null;
|
|
211
|
+
return { updates: [{ ref: VAULT_DEPLOY_REF, expected_old_oid: old, new_oid: oid, force: old !== null }] };
|
|
212
212
|
}
|
|
213
213
|
// ─── §4.5 Retention roots (pure) ─────────────────────────────────────────────
|
|
214
214
|
/** `effective_admitted_at = max(prepared_at, storage creation time of the winning admission record)` (§4.10). */
|
|
@@ -216,7 +216,7 @@ export function effectiveAdmittedAt(preparedAt, recordStorageCreatedAt) {
|
|
|
216
216
|
return Date.parse(preparedAt) >= Date.parse(recordStorageCreatedAt) ? preparedAt : recordStorageCreatedAt;
|
|
217
217
|
}
|
|
218
218
|
/** A root may be removed iff `effective_admitted_at + 90 days < cutoff_at` (STRICT; §4.5a). */
|
|
219
|
-
export function isRootEligibleForRemoval(effectiveAdmittedAtIso, cutoffAtIso, retentionDays =
|
|
219
|
+
export function isRootEligibleForRemoval(effectiveAdmittedAtIso, cutoffAtIso, retentionDays = VAULT_RETENTION_MIN_DAYS) {
|
|
220
220
|
const expiry = Date.parse(effectiveAdmittedAtIso) + retentionDays * 24 * 60 * 60 * 1000;
|
|
221
221
|
return expiry < Date.parse(cutoffAtIso);
|
|
222
222
|
}
|
|
@@ -243,14 +243,14 @@ export function evolveRetentionRoots(previous, options) {
|
|
|
243
243
|
});
|
|
244
244
|
}
|
|
245
245
|
roots.sort(compareRoots);
|
|
246
|
-
if (roots.length >
|
|
247
|
-
fail("REF_STATE_LIMIT_EXCEEDED", `${roots.length} retention roots exceed the ${
|
|
246
|
+
if (roots.length > VAULT_MAX_RETENTION_ROOT_ENTRIES)
|
|
247
|
+
fail("REF_STATE_LIMIT_EXCEEDED", `${roots.length} retention roots exceed the ${VAULT_MAX_RETENTION_ROOT_ENTRIES} bound`, "evolving retention roots", { roots: roots.length });
|
|
248
248
|
return roots;
|
|
249
249
|
}
|
|
250
250
|
// ─── clone-installs-retained-refs (D1-D5): local refs/r402/retain/* bookkeeping ──
|
|
251
251
|
/**
|
|
252
252
|
* The client-local, protocol-owned namespace (design D4). Distinct from the
|
|
253
|
-
* VAULT-side `refs/run402/*` (e.g. `
|
|
253
|
+
* VAULT-side `refs/run402/*` (e.g. `VAULT_DEPLOY_REF`) that rides the wire
|
|
254
254
|
* as part of `ref_state` — `refs/r402/*` never rides the wire at all; it is
|
|
255
255
|
* written directly into the local `.git` by the materializer and reconciled
|
|
256
256
|
* on every later fetch/fsck. A push naming any ref under it is refused by the
|
|
@@ -258,14 +258,14 @@ export function evolveRetentionRoots(previous, options) {
|
|
|
258
258
|
* `git-remote-run402.mjs`'s `partitionProtectedRefPushes`) — this constant is
|
|
259
259
|
* the ONE place the namespace string is spelled, shared by both sides.
|
|
260
260
|
*/
|
|
261
|
-
export const
|
|
261
|
+
export const VAULT_R402_REF_NAMESPACE = "refs/r402/";
|
|
262
262
|
/** Where a retained (branch-unreachable) deploy-capture tip gets its local ref (design D1/D2). */
|
|
263
|
-
export const
|
|
263
|
+
export const VAULT_RETAIN_REF_PREFIX = `${VAULT_R402_REF_NAMESPACE}retain/`;
|
|
264
264
|
/**
|
|
265
265
|
* D1's ref-identity choice, recorded here because the design doc's own
|
|
266
|
-
* assumption did not hold: `
|
|
266
|
+
* assumption did not hold: `VaultRetentionRoot` carries no per-capture
|
|
267
267
|
* stable id (only `{ref, oid, dropped_at_generation}`) — the capture id that
|
|
268
|
-
* DOES exist (`
|
|
268
|
+
* DOES exist (`VaultHead.capture_binding.capture_id`) lives on the head
|
|
269
269
|
* that INTRODUCED a tip onto a canonical ref, not on the retention-root entry
|
|
270
270
|
* recording its later displacement, and correlating the two would require
|
|
271
271
|
* walking the chain further back than materialization already reads (D2
|
|
@@ -274,8 +274,8 @@ export const GITVAULT_RETAIN_REF_PREFIX = `${GITVAULT_R402_REF_NAMESPACE}retain/
|
|
|
274
274
|
* ever changes for the same history), and needs no correlation at all — the
|
|
275
275
|
* ref name IS the tip's own identity.
|
|
276
276
|
*/
|
|
277
|
-
export function
|
|
278
|
-
return `${
|
|
277
|
+
export function vaultRetainedRefName(oid) {
|
|
278
|
+
return `${VAULT_RETAIN_REF_PREFIX}${oid}`;
|
|
279
279
|
}
|
|
280
280
|
/** `git for-each-ref --format='%(objectname) %(refname)' <prefix>` → `Map<refname, oid>`. Namespace-scoped by construction (the prefix argv). */
|
|
281
281
|
async function listRefsUnderPrefix(repoDir, prefix) {
|
|
@@ -316,14 +316,14 @@ async function listRefsUnderPrefix(repoDir, prefix) {
|
|
|
316
316
|
* never a warning.
|
|
317
317
|
*/
|
|
318
318
|
/**
|
|
319
|
-
* Name prefix of the scratch directory {@link
|
|
319
|
+
* Name prefix of the scratch directory {@link Vault.buildPacks}
|
|
320
320
|
* allocates INSIDE the repository's git common dir. Dotted and clearly named
|
|
321
321
|
* so it can never be mistaken for `objects/`, `refs/`, or a pack directory.
|
|
322
322
|
*/
|
|
323
|
-
export const
|
|
323
|
+
export const VAULT_PACK_SCRATCH_PREFIX = ".run402-vault-packs-";
|
|
324
324
|
/**
|
|
325
325
|
* Allocate the pack-objects scratch directory on the OBJECT STORE's own
|
|
326
|
-
* filesystem: `<git-common-dir>/.run402-
|
|
326
|
+
* filesystem: `<git-common-dir>/.run402-vault-packs-XXXXXX`, never under
|
|
327
327
|
* `os.tmpdir()`.
|
|
328
328
|
*
|
|
329
329
|
* `git pack-objects <base>` writes its temporary pack under
|
|
@@ -338,7 +338,7 @@ export const GITVAULT_PACK_SCRATCH_PREFIX = ".run402-gitvault-packs-";
|
|
|
338
338
|
*/
|
|
339
339
|
export async function allocatePackScratchDir(repoDir) {
|
|
340
340
|
const commonDir = (await hardenedGit(repoDir, ["rev-parse", "--path-format=absolute", "--git-common-dir"])).text().trim();
|
|
341
|
-
return mkdtempSync(join(commonDir,
|
|
341
|
+
return mkdtempSync(join(commonDir, VAULT_PACK_SCRATCH_PREFIX));
|
|
342
342
|
}
|
|
343
343
|
export async function reconcileRetainedTipRefs(repoDir, state) {
|
|
344
344
|
const empty = (warning = null) => ({ written: [], deleted: [], retained_count: 0, warning });
|
|
@@ -386,8 +386,8 @@ export async function reconcileRetainedTipRefs(repoDir, state) {
|
|
|
386
386
|
if (!reachable)
|
|
387
387
|
retainedOids.push(oid);
|
|
388
388
|
}
|
|
389
|
-
const existing = await listRefsUnderPrefix(repoDir,
|
|
390
|
-
const desired = new Map(retainedOids.map((oid) => [
|
|
389
|
+
const existing = await listRefsUnderPrefix(repoDir, VAULT_RETAIN_REF_PREFIX);
|
|
390
|
+
const desired = new Map(retainedOids.map((oid) => [vaultRetainedRefName(oid), oid]));
|
|
391
391
|
const toWrite = [];
|
|
392
392
|
for (const [ref, oid] of desired)
|
|
393
393
|
if (existing.get(ref) !== oid)
|
|
@@ -414,40 +414,40 @@ export async function reconcileRetainedTipRefs(repoDir, state) {
|
|
|
414
414
|
}
|
|
415
415
|
/** Validate a listing request before it is sent (the request schema, D186). */
|
|
416
416
|
export function validateHeadsListingRequest(request) {
|
|
417
|
-
if (!
|
|
418
|
-
fail("
|
|
417
|
+
if (!VAULT_HEX16_RE.test(request.after_generation))
|
|
418
|
+
fail("VAULT_LISTING_REQUEST_INVALID", "after_generation is REQUIRED and must be 16 lowercase hex (the verification anchor)", "listing heads", { after_generation: request.after_generation });
|
|
419
419
|
if (!LIMIT_RE.test(request.limit))
|
|
420
|
-
fail("
|
|
420
|
+
fail("VAULT_LISTING_REQUEST_INVALID", "limit is REQUIRED and must be 1..1000", "listing heads", { limit: request.limit });
|
|
421
421
|
if (request.cursor !== undefined && !CURSOR_RE.test(request.cursor))
|
|
422
422
|
fail("INVALID_CURSOR", "cursor violates the opaque-token grammar; restart from after_generation without a cursor and echo each page's next_cursor unchanged", "listing heads", { cursor: request.cursor });
|
|
423
423
|
}
|
|
424
424
|
/**
|
|
425
425
|
* Validate one page against the request and the sequence so far. Returns the
|
|
426
426
|
* advanced progress. Refusals: anchor not echoed / wrong vault / coupling
|
|
427
|
-
* violation / retired member → `
|
|
427
|
+
* violation / retired member → `VAULT_LISTING_PAGE_INVALID`; an entry at or
|
|
428
428
|
* below the anchor → `GENERATION_REGRESSION`; a gap within or across pages →
|
|
429
429
|
* `CHAIN_BROKEN`; an untruthful final `total` → `CHAIN_BROKEN`.
|
|
430
430
|
*/
|
|
431
431
|
export function verifyHeadsListingPage(page, request, progress, expectedRepoId) {
|
|
432
432
|
const p = page;
|
|
433
|
-
if (p.format !==
|
|
434
|
-
fail("
|
|
433
|
+
if (p.format !== VAULT_FORMAT)
|
|
434
|
+
fail("VAULT_LISTING_PAGE_INVALID", "listing page has the wrong format", "listing heads");
|
|
435
435
|
if ("next_after_generation" in p)
|
|
436
|
-
fail("
|
|
436
|
+
fail("VAULT_LISTING_PAGE_INVALID", "listing page carries the retired next_after_generation member (D186 forbids it)", "listing heads");
|
|
437
437
|
if (expectedRepoId && p.repo_id !== expectedRepoId)
|
|
438
|
-
fail("
|
|
438
|
+
fail("VAULT_LISTING_PAGE_INVALID", "listing page is for a different vault", "listing heads", { repo_id: p.repo_id });
|
|
439
439
|
if (p.after_generation !== request.after_generation || p.after_generation !== progress.after_generation)
|
|
440
|
-
fail("
|
|
440
|
+
fail("VAULT_LISTING_PAGE_INVALID", "the page did not echo the request's after_generation anchor", "listing heads", { echoed: p.after_generation, anchor: request.after_generation });
|
|
441
441
|
if (typeof p.has_more !== "boolean")
|
|
442
|
-
fail("
|
|
442
|
+
fail("VAULT_LISTING_PAGE_INVALID", "has_more must be a boolean", "listing heads");
|
|
443
443
|
if (p.has_more === false && p.next_cursor !== null)
|
|
444
|
-
fail("
|
|
444
|
+
fail("VAULT_LISTING_PAGE_INVALID", "has_more=false with a non-null next_cursor is contradictory", "listing heads");
|
|
445
445
|
if (p.has_more === true && (typeof p.next_cursor !== "string" || !CURSOR_RE.test(p.next_cursor)))
|
|
446
|
-
fail("
|
|
447
|
-
if (!Array.isArray(p.heads) || p.heads.length >
|
|
448
|
-
fail("
|
|
446
|
+
fail("VAULT_LISTING_PAGE_INVALID", "has_more=true requires a non-null opaque next_cursor", "listing heads");
|
|
447
|
+
if (!Array.isArray(p.heads) || p.heads.length > VAULT_MAX_HEADS_PER_LISTING_PAGE || p.heads.length > Number(request.limit))
|
|
448
|
+
fail("VAULT_LISTING_PAGE_INVALID", "heads[] exceeds the page limit", "listing heads");
|
|
449
449
|
if (p.total !== null && !/^(0|[1-9][0-9]{0,14})$/.test(p.total))
|
|
450
|
-
fail("
|
|
450
|
+
fail("VAULT_LISTING_PAGE_INVALID", "total must be an exact decimal string or null", "listing heads");
|
|
451
451
|
let last = generationToBigInt(progress.last_generation);
|
|
452
452
|
const anchor = generationToBigInt(progress.after_generation);
|
|
453
453
|
let delivered = progress.delivered;
|
|
@@ -477,7 +477,7 @@ export function nextListingRequest(request, page) {
|
|
|
477
477
|
if (!page.has_more)
|
|
478
478
|
return null;
|
|
479
479
|
if (page.next_cursor === null)
|
|
480
|
-
fail("
|
|
480
|
+
fail("VAULT_LISTING_PAGE_INVALID", "has_more=true requires a non-null opaque next_cursor", "listing heads");
|
|
481
481
|
return { after_generation: request.after_generation, cursor: page.next_cursor, limit: request.limit };
|
|
482
482
|
}
|
|
483
483
|
/**
|
|
@@ -487,7 +487,7 @@ export function nextListingRequest(request, page) {
|
|
|
487
487
|
*/
|
|
488
488
|
export function checkGenerationRegression(listedNewestGeneration, pinnedGeneration) {
|
|
489
489
|
if (generationToBigInt(listedNewestGeneration) < generationToBigInt(pinnedGeneration)) {
|
|
490
|
-
fail("GENERATION_REGRESSION", `the vault's newest generation ${listedNewestGeneration} is below the authenticated pin ${pinnedGeneration}; refusing to publish over a rollback`, "verifying
|
|
490
|
+
fail("GENERATION_REGRESSION", `the vault's newest generation ${listedNewestGeneration} is below the authenticated pin ${pinnedGeneration}; refusing to publish over a rollback`, "verifying vault chain", { listed_newest_generation: listedNewestGeneration, pinned_generation: pinnedGeneration }, [{ action: "do not publish; escalate — the vault regressed below a generation this client authenticated" }]);
|
|
491
491
|
}
|
|
492
492
|
}
|
|
493
493
|
/** `SHA-256("r402s/v0/open-binding" ‖ lp(client_open_id) ‖ lp(base_head) ‖ lp_opt(prior) ‖ lp(cap))`. */
|
|
@@ -498,7 +498,7 @@ export function openBindingDigest(clientOpenId, record) {
|
|
|
498
498
|
* Recompute the binding from the record's OWN fields and compare it bytewise
|
|
499
499
|
* with the signed issuance digest. The error registry has no dedicated code for
|
|
500
500
|
* the fence inequality (D145), so the client surfaces
|
|
501
|
-
* `
|
|
501
|
+
* `VAULT_OPEN_BINDING_MISMATCH`; a same-`client_open_id` retry that carries a
|
|
502
502
|
* DIFFERENT binding is the registry's `CLIENT_OPEN_ID_CONFLICT`.
|
|
503
503
|
*/
|
|
504
504
|
export function checkOpenBinding(clientOpenId, record, issuanceOpenBindingSha256, options = {}) {
|
|
@@ -508,7 +508,7 @@ export function checkOpenBinding(clientOpenId, record, issuanceOpenBindingSha256
|
|
|
508
508
|
if (options.retry) {
|
|
509
509
|
fail("CLIENT_OPEN_ID_CONFLICT", `client_open_id ${clientOpenId} was already opened with a different request binding`, "checking maintenance open binding", { recomputed, issued: issuanceOpenBindingSha256 });
|
|
510
510
|
}
|
|
511
|
-
fail("
|
|
511
|
+
fail("VAULT_OPEN_BINDING_MISMATCH", `the C1 record does not recompute to the signed issuance binding for ${clientOpenId}`, "checking maintenance open binding", { recomputed, issued: issuanceOpenBindingSha256 });
|
|
512
512
|
}
|
|
513
513
|
/** Verify one link: bytes hash to the listing, strict parse, generation, prev linkage, epoch pin, repo, writer signature. */
|
|
514
514
|
export function checkChainLink(input) {
|
|
@@ -516,7 +516,7 @@ export function checkChainLink(input) {
|
|
|
516
516
|
const hash = sha256Hex(input.stored_bytes);
|
|
517
517
|
if (hash !== input.listed_sha256)
|
|
518
518
|
fail("CHAIN_BROKEN", `head ${input.expected_generation}: stored bytes hash ${hash} ≠ the listing's ${input.listed_sha256}`, "verifying head chain", { generation: input.expected_generation });
|
|
519
|
-
if (h.format !==
|
|
519
|
+
if (h.format !== VAULT_FORMAT || h.object_kind !== "head" || h.suite !== VAULT_SUITE)
|
|
520
520
|
fail("CHAIN_BROKEN", `head ${input.expected_generation}: not an r402s/v0 head`, "verifying head chain");
|
|
521
521
|
if (h.repo_id !== input.repo_id)
|
|
522
522
|
fail("CHAIN_BROKEN", `head ${input.expected_generation}: repo_id ${h.repo_id} ≠ ${input.repo_id}`, "verifying head chain");
|
|
@@ -530,7 +530,7 @@ export function checkChainLink(input) {
|
|
|
530
530
|
// exactly (increment-by-one, no skip). Pure and keyless: this checks only
|
|
531
531
|
// the head's own signed `epoch` FIELD against the predecessor's, never
|
|
532
532
|
// opens any envelope — decrypting under the resulting epoch is a separate,
|
|
533
|
-
// keyed step (`
|
|
533
|
+
// keyed step (`Vault`'s rotation-envelope open, `openEpochRotationForRecipient`).
|
|
534
534
|
const isRotation = h.transition !== null && h.transition.kind === "rotate_epoch";
|
|
535
535
|
const permittedEpoch = isRotation ? nextEpoch(input.prev_epoch) : input.prev_epoch;
|
|
536
536
|
if (h.epoch !== permittedEpoch) {
|
|
@@ -538,7 +538,7 @@ export function checkChainLink(input) {
|
|
|
538
538
|
}
|
|
539
539
|
if (h.writer_key_id !== input.writer_key_id)
|
|
540
540
|
fail("CHAIN_BROKEN", `head ${h.generation}: writer_key_id ${h.writer_key_id} is not the registered writer`, "verifying head chain");
|
|
541
|
-
if (!
|
|
541
|
+
if (!verifyVaultObject(h, input.writer_public_key))
|
|
542
542
|
fail("CHAIN_BROKEN", `head ${h.generation}: signature does not verify under the registered writer key`, "verifying head chain");
|
|
543
543
|
if ((h.checkpoint === null) !== (h.checkpoint_purpose === null))
|
|
544
544
|
fail("CHAIN_BROKEN", `head ${h.generation}: checkpoint/checkpoint_purpose must be null together`, "verifying head chain");
|
|
@@ -546,7 +546,7 @@ export function checkChainLink(input) {
|
|
|
546
546
|
fail("CHAIN_BROKEN", `head ${h.generation}: a checkpoint-bearing head must carry an empty WAL set`, "verifying head chain");
|
|
547
547
|
if ((h.repair !== null) !== (h.checkpoint_purpose === "repair"))
|
|
548
548
|
fail("CHAIN_BROKEN", `head ${h.generation}: repair ⇔ checkpoint_purpose "repair"`, "verifying head chain");
|
|
549
|
-
if (h.wal_entries.length >
|
|
549
|
+
if (h.wal_entries.length > VAULT_MAX_WAL_RECEIPTS_PER_HEAD)
|
|
550
550
|
fail("CHAIN_BROKEN", `head ${h.generation}: ${h.wal_entries.length} WAL receipts exceed the 64 budget`, "verifying head chain");
|
|
551
551
|
}
|
|
552
552
|
/**
|
|
@@ -559,10 +559,10 @@ export function checkChainLink(input) {
|
|
|
559
559
|
* rotation is ACTIVATED") — `checkChainLink`'s own D194 epoch-continuity
|
|
560
560
|
* check already validates its structural admissibility, and
|
|
561
561
|
* {@link parseRotateEpochPayload} / the caller's own envelope-open step
|
|
562
|
-
* (`
|
|
563
|
-
* EXEMPT as of rev 47 (
|
|
562
|
+
* (`Vault.verifyToNewest`) handle it fully. `add_writer_key` is
|
|
563
|
+
* EXEMPT as of rev 47 (vault-multi-writer) the same way — {@link
|
|
564
564
|
* parseAddWriterKeyPayload} + `validateAddWriterKeyPayload`
|
|
565
|
-
* (`
|
|
565
|
+
* (`vault-writer-state.js`) handle it fully, INSIDE `verifyToNewest`'s
|
|
566
566
|
* own loop, BEFORE this function ever runs on that head (so a transition
|
|
567
567
|
* that fails writer validation never reaches here at all — this function
|
|
568
568
|
* only ever sees ones that already passed). The other two kinds
|
|
@@ -599,7 +599,7 @@ const PATH_PATTERNS = [
|
|
|
599
599
|
[/^verifier-receipts\/(vr_[0-9a-f]{32})\.json$/, (m) => ({ kind: "object", read: { object_kind: "verifier_receipt", object_id: m[1] } })],
|
|
600
600
|
];
|
|
601
601
|
/** `null` for a path with no wire identity (e.g. a locally-held cutoff ticket). */
|
|
602
|
-
export function
|
|
602
|
+
export function vaultWireRefForPath(path) {
|
|
603
603
|
for (const [re, build] of PATH_PATTERNS) {
|
|
604
604
|
const m = re.exec(path);
|
|
605
605
|
if (m)
|
|
@@ -608,24 +608,24 @@ export function gitvaultWireRefForPath(path) {
|
|
|
608
608
|
return null;
|
|
609
609
|
}
|
|
610
610
|
/** The manifest entry for one upload — closed-key, exactly what the control plane validates. */
|
|
611
|
-
export function
|
|
612
|
-
const ref =
|
|
611
|
+
export function vaultManifestEntry(object) {
|
|
612
|
+
const ref = vaultWireRefForPath(object.path);
|
|
613
613
|
if (!ref || ref.kind !== "object") {
|
|
614
|
-
fail("
|
|
614
|
+
fail("VAULT_UPLOAD_SESSION_INVALID", `${object.path} is not an uploadable object path; the control plane addresses uploads by object_kind + ledger identity`, "building the vault upload manifest", { path: object.path });
|
|
615
615
|
}
|
|
616
616
|
const entry = { ...ref.read, sha256: object.sha256, size_bytes: object.size_bytes };
|
|
617
617
|
if (entry.object_kind === "wal_pack") {
|
|
618
618
|
if (object.base_generation === undefined)
|
|
619
|
-
fail("
|
|
619
|
+
fail("VAULT_UPLOAD_SESSION_INVALID", "a wal_pack upload must declare base_generation (§4.1)", "building the vault upload manifest", { path: object.path });
|
|
620
620
|
entry.base_generation = object.base_generation;
|
|
621
621
|
}
|
|
622
622
|
return entry;
|
|
623
623
|
}
|
|
624
|
-
// ─── Inline upload (
|
|
625
|
-
/** Mirrors the gateway's `
|
|
626
|
-
export const
|
|
627
|
-
/** Mirrors the gateway's `
|
|
628
|
-
export const
|
|
624
|
+
// ─── Inline upload (vault-composite-state-read design D2) ─────────────────
|
|
625
|
+
/** Mirrors the gateway's `VAULT_INLINE_UPLOAD_MAX_OBJECT_BYTES` (`services/vault/upload-sessions.ts`). */
|
|
626
|
+
export const VAULT_INLINE_UPLOAD_MAX_OBJECT_BYTES = 262_144;
|
|
627
|
+
/** Mirrors the gateway's `VAULT_INLINE_UPLOAD_MAX_REQUEST_BYTES`. */
|
|
628
|
+
export const VAULT_INLINE_UPLOAD_MAX_REQUEST_BYTES = 1_048_576;
|
|
629
629
|
/**
|
|
630
630
|
* The client-side mirror of the gateway's `isInlineUploadRequest` + per-object/
|
|
631
631
|
* per-request cap check: every object must fit under the PER-OBJECT cap AND
|
|
@@ -636,30 +636,30 @@ export const GITVAULT_INLINE_UPLOAD_MAX_REQUEST_BYTES = 1_048_576;
|
|
|
636
636
|
* already short-circuits before this is consulted, and `putObject` always
|
|
637
637
|
* wraps exactly one object so it never hits this branch).
|
|
638
638
|
*
|
|
639
|
-
* Takes the narrowest shape that satisfies every call site (`
|
|
639
|
+
* Takes the narrowest shape that satisfies every call site (`VaultUploadObject[]`
|
|
640
640
|
* for `uploadObjects`, a single `{bytes}`-shaped array for `putObject`) so
|
|
641
641
|
* neither caller needs to fabricate unrelated fields just to ask the
|
|
642
642
|
* question.
|
|
643
643
|
*/
|
|
644
|
-
export function
|
|
644
|
+
export function vaultInlineUploadEligible(objects) {
|
|
645
645
|
if (objects.length === 0)
|
|
646
646
|
return false;
|
|
647
647
|
let total = 0;
|
|
648
648
|
for (const o of objects) {
|
|
649
|
-
if (o.bytes.length >
|
|
649
|
+
if (o.bytes.length > VAULT_INLINE_UPLOAD_MAX_OBJECT_BYTES)
|
|
650
650
|
return false;
|
|
651
651
|
total += o.bytes.length;
|
|
652
652
|
}
|
|
653
|
-
return total <=
|
|
653
|
+
return total <= VAULT_INLINE_UPLOAD_MAX_REQUEST_BYTES;
|
|
654
654
|
}
|
|
655
655
|
/**
|
|
656
656
|
* The stable key both sides agree on, used to pair receipts back to
|
|
657
657
|
* requests — MIRRORS the gateway's `keyEnvelopeLedgerId`/`pinManifestLedgerId`
|
|
658
|
-
* (services/
|
|
658
|
+
* (services/vault/epoch-rotation.ts) exactly; drift here breaks receipt
|
|
659
659
|
* pairing at upload finalize for a rotation-attempt envelope or a pin
|
|
660
660
|
* manifest.
|
|
661
661
|
*/
|
|
662
|
-
export function
|
|
662
|
+
export function vaultLedgerId(read) {
|
|
663
663
|
if (read.object_kind === "key_envelope")
|
|
664
664
|
return keyEnvelopeLedgerId(read.epoch, read.recipient_fingerprint, read.rotation_id ?? null);
|
|
665
665
|
if (read.object_kind === "recipient_pin_manifest")
|
|
@@ -670,12 +670,12 @@ export function gitvaultLedgerId(read) {
|
|
|
670
670
|
* `scheme://host` for a URL string, or `null` for anything that fails to
|
|
671
671
|
* parse (never thrown) — the sole normalizer between a full presigned/edge
|
|
672
672
|
* URL (path, query, signature, everything) and the bare origin
|
|
673
|
-
* {@link
|
|
673
|
+
* {@link VaultHttpTransportOptions.onObjectStoreOriginObserved} hands a
|
|
674
674
|
* caller. Deliberately NOT exported: this module is the only place that
|
|
675
675
|
* observes raw object-store URLs, so the normalizer has exactly one call
|
|
676
676
|
* site and needs no wider audience.
|
|
677
677
|
*/
|
|
678
|
-
function
|
|
678
|
+
function vaultObjectStoreOrigin(url) {
|
|
679
679
|
try {
|
|
680
680
|
return new URL(url).origin;
|
|
681
681
|
}
|
|
@@ -684,12 +684,12 @@ function gitvaultObjectStoreOrigin(url) {
|
|
|
684
684
|
}
|
|
685
685
|
}
|
|
686
686
|
/** Dedup, in order, dropping anything that failed to parse. */
|
|
687
|
-
function
|
|
687
|
+
function vaultObjectStoreOrigins(urls) {
|
|
688
688
|
const out = [];
|
|
689
689
|
for (const u of urls) {
|
|
690
690
|
if (!u)
|
|
691
691
|
continue;
|
|
692
|
-
const origin =
|
|
692
|
+
const origin = vaultObjectStoreOrigin(u);
|
|
693
693
|
if (origin && !out.includes(origin))
|
|
694
694
|
out.push(origin);
|
|
695
695
|
}
|
|
@@ -698,13 +698,13 @@ function gitvaultObjectStoreOrigins(urls) {
|
|
|
698
698
|
function b64(bytes) { return Buffer.from(bytes).toString("base64"); }
|
|
699
699
|
function b64u(bytes) { return Buffer.from(bytes).toString("base64url"); }
|
|
700
700
|
/**
|
|
701
|
-
* Independent reads/PUTs within one
|
|
701
|
+
* Independent reads/PUTs within one vault step run at this concurrency
|
|
702
702
|
* (design D2) — "browser-era origin etiquette", well within what S3/the
|
|
703
703
|
* gateway tolerate, and it keeps in-flight memory bounded by ~6×frame size.
|
|
704
704
|
* Chain-ordered steps (the head-chain walk, admission, readback) never call
|
|
705
705
|
* this — they stay strictly sequential.
|
|
706
706
|
*/
|
|
707
|
-
export const
|
|
707
|
+
export const VAULT_TRANSPORT_CONCURRENCY = 6;
|
|
708
708
|
/**
|
|
709
709
|
* Run `fn` over `items` with at most `limit` in flight at once, preserving
|
|
710
710
|
* result order regardless of completion order. A plain worker-pool: `limit`
|
|
@@ -729,9 +729,9 @@ export async function mapBounded(items, limit, fn) {
|
|
|
729
729
|
* `If-None-Match: *` (create-only — the bucket policy demands it) and the
|
|
730
730
|
* FULL_OBJECT SHA-256 checksum header.
|
|
731
731
|
*/
|
|
732
|
-
export function
|
|
732
|
+
export function createVaultHttpTransport(client, options = {}) {
|
|
733
733
|
const vid = (repoId) => encodeURIComponent((options.vaultIdFor ?? ((r) => r))(repoId));
|
|
734
|
-
const base = (repoId) => `/
|
|
734
|
+
const base = (repoId) => `/vaults/v1/${vid(repoId)}`;
|
|
735
735
|
/**
|
|
736
736
|
* Read raw bytes from a generation-addressed route (heads, admission records).
|
|
737
737
|
*
|
|
@@ -743,15 +743,15 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
743
743
|
*/
|
|
744
744
|
async function getGenerationBytes(repoId, route, generation) {
|
|
745
745
|
const path = `${base(repoId)}/${route}/${encodeURIComponent(generation)}`;
|
|
746
|
-
const auth = (await client.credentials.getAuth(path, { method: `
|
|
746
|
+
const auth = (await client.credentials.getAuth(path, { method: `vault.read_${route}` })) ?? {};
|
|
747
747
|
const r = await client.fetch(`${client.apiBase}${path}`, { method: "GET", headers: { ...auth, accept: "application/json" } });
|
|
748
748
|
if (r.status === 404)
|
|
749
749
|
return null;
|
|
750
750
|
if (!r.ok) {
|
|
751
751
|
// Surface the registry code the control plane actually sent (e.g.
|
|
752
|
-
// `
|
|
752
|
+
// `VAULT_ACCESS_DENIED`) rather than flattening every refusal into a
|
|
753
753
|
// generic read failure — the codes carry the caller's next action.
|
|
754
|
-
let code = "
|
|
754
|
+
let code = "VAULT_OBJECT_READ_FAILED";
|
|
755
755
|
let message = `${route}/${generation} read failed (HTTP ${r.status})`;
|
|
756
756
|
try {
|
|
757
757
|
const envelope = (await r.json());
|
|
@@ -763,12 +763,12 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
763
763
|
catch {
|
|
764
764
|
// a non-JSON body — keep the generic code
|
|
765
765
|
}
|
|
766
|
-
fail(code, message, `reading
|
|
766
|
+
fail(code, message, `reading vault ${route}`, { generation, status: r.status });
|
|
767
767
|
}
|
|
768
768
|
return new Uint8Array(await r.arrayBuffer());
|
|
769
769
|
}
|
|
770
770
|
/**
|
|
771
|
-
* Resolve ONE `object-reads` presign target to bytes (
|
|
771
|
+
* Resolve ONE `object-reads` presign target to bytes (vault-small-
|
|
772
772
|
* object-inline design D3): consume `inline` when present and EITHER no
|
|
773
773
|
* `expectedSha256` was supplied OR the decoded bytes hash to it, else
|
|
774
774
|
* fall through to the ordinary `url`/`edge_url` fetch exactly as before
|
|
@@ -780,23 +780,23 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
780
780
|
* authorized caller, the entry's own `url` would have served — nothing
|
|
781
781
|
* here becomes a new source of truth (design D5).
|
|
782
782
|
*
|
|
783
|
-
*
|
|
783
|
+
* vault-object-host-predial (task 1.2): once the fetch below
|
|
784
784
|
* COMPLETES (any status — a thrown fetch error is NOT this), reports the
|
|
785
785
|
* target's origin(s) via `options.onObjectStoreOriginObserved`. Never
|
|
786
786
|
* fired for the `inline` short-circuit above — no URL was dialed.
|
|
787
787
|
*/
|
|
788
788
|
/**
|
|
789
|
-
*
|
|
789
|
+
* vault-byo-primary-bucket: a payload object of a BYO vault, named by
|
|
790
790
|
* the key the gateway handed back. Read from the vault's locally configured
|
|
791
791
|
* destination — never from run402, which holds no copy. `null` when the
|
|
792
792
|
* key is absent there (the caller's absence adjudication takes over,
|
|
793
|
-
* `
|
|
793
|
+
* `VAULT_BYO_OBJECT_MISSING`); a machine with no local BYO config for
|
|
794
794
|
* the vault refuses by name instead of reporting a false absence.
|
|
795
795
|
*/
|
|
796
796
|
async function readByoObject(repoId, key, what) {
|
|
797
797
|
const backend = options.byoBackend?.(repoId) ?? null;
|
|
798
798
|
if (!backend) {
|
|
799
|
-
fail("
|
|
799
|
+
fail("VAULT_BYO_NOT_CONFIGURED", `vault ${repoId} keeps its payload in its own bucket and this machine has no local BYO config for it — ${what} cannot be read`, "reading vault object", { repo_id: repoId, key }, [{ action: "this vault was allocated with `run402 repos create --byo <destination> --profile <name>|--ambient`; this machine needs the same destination and a credential NAME for it in the keystore's byo/<repo_id>.json (copy that file from the allocating machine — it carries no secret material)" }]);
|
|
800
800
|
}
|
|
801
801
|
return backend.get(key);
|
|
802
802
|
}
|
|
@@ -809,10 +809,10 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
809
809
|
return bytes;
|
|
810
810
|
}
|
|
811
811
|
if (target.url === undefined)
|
|
812
|
-
fail("
|
|
813
|
-
const r = await
|
|
812
|
+
fail("VAULT_OBJECT_READ_FAILED", `object-reads entry for ${path} carries neither a presign nor a byo_key`, "reading vault object", { path });
|
|
813
|
+
const r = await fetchVaultObjectBytes(client, { url: target.url, ...(target.edge_url ? { edge_url: target.edge_url } : {}) });
|
|
814
814
|
try {
|
|
815
|
-
options.onObjectStoreOriginObserved?.(repoId,
|
|
815
|
+
options.onObjectStoreOriginObserved?.(repoId, vaultObjectStoreOrigins([target.url, target.edge_url]));
|
|
816
816
|
}
|
|
817
817
|
catch {
|
|
818
818
|
/* a caller's own persistence failure must never surface into this read */
|
|
@@ -820,21 +820,21 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
820
820
|
if (r.status === 404)
|
|
821
821
|
return null;
|
|
822
822
|
if (!r.ok)
|
|
823
|
-
fail("
|
|
823
|
+
fail("VAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${path}`, "reading vault object", { path, status: r.status });
|
|
824
824
|
return new Uint8Array(await r.arrayBuffer());
|
|
825
825
|
}
|
|
826
826
|
/** Presign + fetch one object by its ledger identity (`POST …/object-reads`). */
|
|
827
827
|
async function getObjectBytes(repoId, path, expectedSha256) {
|
|
828
|
-
const ref =
|
|
828
|
+
const ref = vaultWireRefForPath(path);
|
|
829
829
|
if (!ref)
|
|
830
|
-
fail("
|
|
830
|
+
fail("VAULT_OBJECT_READ_FAILED", `${path} has no control-plane wire identity; it is not a readable vault object`, "reading vault object", { path });
|
|
831
831
|
if (ref.kind === "head")
|
|
832
832
|
return getGenerationBytes(repoId, "heads", ref.generation);
|
|
833
833
|
if (ref.kind === "admission")
|
|
834
834
|
return getGenerationBytes(repoId, "admissions", ref.generation);
|
|
835
835
|
let presigned;
|
|
836
836
|
try {
|
|
837
|
-
presigned = await client.request(`${base(repoId)}/object-reads`, { method: "POST", body: { objects: [ref.read] }, context: "resolving
|
|
837
|
+
presigned = await client.request(`${base(repoId)}/object-reads`, { method: "POST", body: { objects: [ref.read] }, context: "resolving vault object" });
|
|
838
838
|
}
|
|
839
839
|
catch (e) {
|
|
840
840
|
if (isRun402Error(e) && e.status === 404)
|
|
@@ -849,7 +849,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
849
849
|
// (older/staging) gateway behind RUN402_API_BASE, so name it rather
|
|
850
850
|
// than letting it read as a client validation bug.
|
|
851
851
|
if (ref.read.object_kind === "recipient_pin_manifest" && isRun402Error(e) && e.code === "VALIDATION_FAILED" && /epoch must be 16 hex/.test(e.message ?? "")) {
|
|
852
|
-
throw new LocalError(`this gateway predates the 2026-08-28 fix that made object-reads accept recipient_pin_manifest reads by pin_manifest_version (its null-idScalar branch rejected them with key_envelope's epoch-shape complaint) — this vault's pin manifest at ${path} cannot be read back over the network from it by a keystore that did not itself just publish the manifest`, "reading
|
|
852
|
+
throw new LocalError(`this gateway predates the 2026-08-28 fix that made object-reads accept recipient_pin_manifest reads by pin_manifest_version (its null-idScalar branch rejected them with key_envelope's epoch-shape complaint) — this vault's pin manifest at ${path} cannot be read back over the network from it by a keystore that did not itself just publish the manifest`, "reading vault object", { code: "VAULT_PIN_MANIFEST_READ_UNSUPPORTED", details: { path, pin_manifest_version: ref.read.pin_manifest_version }, cause: e });
|
|
853
853
|
}
|
|
854
854
|
throw e;
|
|
855
855
|
}
|
|
@@ -859,9 +859,9 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
859
859
|
return resolveObjectReadTarget(repoId, target, expectedSha256, path);
|
|
860
860
|
}
|
|
861
861
|
/**
|
|
862
|
-
* Presign + fetch N independent objects (
|
|
862
|
+
* Presign + fetch N independent objects (vault-client-round-trips
|
|
863
863
|
* design D2): ONE `object-reads` POST naming every path, then the GETs
|
|
864
|
-
* with bounded concurrency ({@link
|
|
864
|
+
* with bounded concurrency ({@link VAULT_TRANSPORT_CONCURRENCY}).
|
|
865
865
|
* Every path must be `object-reads`-addressed (a "carrier": ref_state,
|
|
866
866
|
* retention_roots, a WAL/checkpoint pack, …) — a generation-addressed
|
|
867
867
|
* head/admission path has no place in a batch built for materialize/
|
|
@@ -874,7 +874,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
874
874
|
const targets = await presignObjectBatch(repoId, paths);
|
|
875
875
|
if (!targets)
|
|
876
876
|
return paths.map(() => null);
|
|
877
|
-
return mapBounded(targets,
|
|
877
|
+
return mapBounded(targets, VAULT_TRANSPORT_CONCURRENCY, async (target, i) => {
|
|
878
878
|
if (!target)
|
|
879
879
|
return null;
|
|
880
880
|
return resolveObjectReadTarget(repoId, target, expected?.[i], paths[i]);
|
|
@@ -883,14 +883,14 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
883
883
|
/** The shared presign step of the batched read: ONE `object-reads` POST; `null` means the not-found shapes `getObjects` maps to an all-absent result. */
|
|
884
884
|
async function presignObjectBatch(repoId, paths) {
|
|
885
885
|
const refs = paths.map((path) => {
|
|
886
|
-
const ref =
|
|
886
|
+
const ref = vaultWireRefForPath(path);
|
|
887
887
|
if (!ref || ref.kind !== "object")
|
|
888
|
-
fail("
|
|
888
|
+
fail("VAULT_OBJECT_READ_FAILED", `${path} is not a batch-readable carrier object`, "reading vault objects", { path });
|
|
889
889
|
return ref.read;
|
|
890
890
|
});
|
|
891
891
|
let presigned;
|
|
892
892
|
try {
|
|
893
|
-
presigned = await client.request(`${base(repoId)}/object-reads`, { method: "POST", body: { objects: refs }, context: "resolving
|
|
893
|
+
presigned = await client.request(`${base(repoId)}/object-reads`, { method: "POST", body: { objects: refs }, context: "resolving vault objects" });
|
|
894
894
|
}
|
|
895
895
|
catch (e) {
|
|
896
896
|
if (isRun402Error(e) && e.status === 404)
|
|
@@ -899,16 +899,16 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
899
899
|
return null;
|
|
900
900
|
throw e;
|
|
901
901
|
}
|
|
902
|
-
const byLedgerId = new Map(presigned.reads.map((r) => [
|
|
903
|
-
return refs.map((r) => byLedgerId.get(
|
|
902
|
+
const byLedgerId = new Map(presigned.reads.map((r) => [vaultLedgerId(r), r]));
|
|
903
|
+
return refs.map((r) => byLedgerId.get(vaultLedgerId(r)) ?? null);
|
|
904
904
|
}
|
|
905
905
|
/**
|
|
906
|
-
* Per-object settlement over the same batch (
|
|
906
|
+
* Per-object settlement over the same batch (vault-pipelined-restore
|
|
907
907
|
* D2): identical presign + bounded GETs, but each index's promise settles
|
|
908
908
|
* when ITS bytes land. Failure semantics per index match `getObjects`'s
|
|
909
909
|
* per-element behavior (absent → null, a failed GET → the same
|
|
910
|
-
*
|
|
911
|
-
* object-inline design D3 — see {@link
|
|
910
|
+
* VAULT_OBJECT_READ_FAILED), including `expected` (vault-small-
|
|
911
|
+
* object-inline design D3 — see {@link VaultTransport.getObjects}'s
|
|
912
912
|
* doc comment); every promise is pre-marked handled so an abandoned tail
|
|
913
913
|
* never becomes an unhandled rejection.
|
|
914
914
|
*/
|
|
@@ -948,12 +948,12 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
948
948
|
}
|
|
949
949
|
}
|
|
950
950
|
};
|
|
951
|
-
void Promise.all(Array.from({ length: Math.min(
|
|
951
|
+
void Promise.all(Array.from({ length: Math.min(VAULT_TRANSPORT_CONCURRENCY, targets.length) }, () => worker()));
|
|
952
952
|
return deferreds.map((d) => d.promise);
|
|
953
953
|
}
|
|
954
954
|
/**
|
|
955
955
|
* `POST …/head-reads` — many generation-addressed heads' EXACT stored bytes
|
|
956
|
-
* in ONE round trip (
|
|
956
|
+
* in ONE round trip (vault-batched-head-reads task 4.1).
|
|
957
957
|
*
|
|
958
958
|
* `null` means UNSUPPORTED, and it is deliberately the answer to EVERY
|
|
959
959
|
* failure shape: a 404 from a gateway that predates the route, a refusal, a
|
|
@@ -979,7 +979,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
979
979
|
const res = await client.request(`${base(repoId)}/head-reads`, {
|
|
980
980
|
method: "POST",
|
|
981
981
|
body: { generations },
|
|
982
|
-
context: "reading
|
|
982
|
+
context: "reading vault heads in a batch",
|
|
983
983
|
});
|
|
984
984
|
const byGeneration = new Map();
|
|
985
985
|
for (const h of res.heads ?? []) {
|
|
@@ -1007,15 +1007,15 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1007
1007
|
return null;
|
|
1008
1008
|
}
|
|
1009
1009
|
}
|
|
1010
|
-
/** Resolve ONE `GET …/state` carrier arm to raw bytes — inline decode, or a plain GET on the presigned URL (preferring its `edge_url` companion,
|
|
1010
|
+
/** Resolve ONE `GET …/state` carrier arm to raw bytes — inline decode, or a plain GET on the presigned URL (preferring its `edge_url` companion, vault-read-edge-cache design D5), `null` on a 404 (mirrors {@link getObjectBytes}'s absent reading; both arms indistinguishable after this). Origin observation (vault-object-host-predial task 1.2) mirrors `resolveObjectReadTarget`'s. */
|
|
1011
1011
|
async function resolveVaultStateCarrier(repoId, carrier) {
|
|
1012
1012
|
if ("byo_key" in carrier)
|
|
1013
1013
|
return readByoObject(repoId, carrier.byo_key, "vault-state carrier");
|
|
1014
1014
|
if ("inline" in carrier)
|
|
1015
1015
|
return fromBase64url(carrier.inline, "carriers.inline");
|
|
1016
|
-
const r = await
|
|
1016
|
+
const r = await fetchVaultObjectBytes(client, { url: carrier.presigned_url, edge_url: carrier.edge_url });
|
|
1017
1017
|
try {
|
|
1018
|
-
options.onObjectStoreOriginObserved?.(repoId,
|
|
1018
|
+
options.onObjectStoreOriginObserved?.(repoId, vaultObjectStoreOrigins([carrier.presigned_url, carrier.edge_url]));
|
|
1019
1019
|
}
|
|
1020
1020
|
catch {
|
|
1021
1021
|
/* a caller's own persistence failure must never surface into this read */
|
|
@@ -1023,14 +1023,14 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1023
1023
|
if (r.status === 404)
|
|
1024
1024
|
return null;
|
|
1025
1025
|
if (!r.ok)
|
|
1026
|
-
fail("
|
|
1026
|
+
fail("VAULT_OBJECT_READ_FAILED", `vault-state carrier GET failed (HTTP ${r.status})`, "reading the vault state", { status: r.status });
|
|
1027
1027
|
return new Uint8Array(await r.arrayBuffer());
|
|
1028
1028
|
}
|
|
1029
1029
|
/**
|
|
1030
1030
|
* `GET …/state` (design D1): one JSON body carrying the vault record, the
|
|
1031
1031
|
* newest generation, its head's exact stored bytes, and both carriers —
|
|
1032
1032
|
* resolved to raw bytes here, verified nowhere here (see the interface's
|
|
1033
|
-
* own doc comment on {@link
|
|
1033
|
+
* own doc comment on {@link VaultTransport.getState}).
|
|
1034
1034
|
*/
|
|
1035
1035
|
async function getVaultStateOut(repoId, since, restore) {
|
|
1036
1036
|
const qs = [];
|
|
@@ -1038,14 +1038,14 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1038
1038
|
qs.push(`since=${encodeURIComponent(since)}`);
|
|
1039
1039
|
if (restore)
|
|
1040
1040
|
qs.push("restore=1");
|
|
1041
|
-
const raw = await client.request(`${base(repoId)}/state${qs.length > 0 ? `?${qs.join("&")}` : ""}`, { context: "reading the
|
|
1041
|
+
const raw = await client.request(`${base(repoId)}/state${qs.length > 0 ? `?${qs.join("&")}` : ""}`, { context: "reading the vault state" });
|
|
1042
1042
|
const head = raw.head ? { stored_bytes: fromBase64url(raw.head.stored_bytes, "head.stored_bytes"), stored_bytes_sha256: raw.head.stored_bytes_sha256 } : null;
|
|
1043
1043
|
const carriers = raw.carriers
|
|
1044
1044
|
? { ref_state: await resolveVaultStateCarrier(repoId, raw.carriers.ref_state), retention_roots: await resolveVaultStateCarrier(repoId, raw.carriers.retention_roots) }
|
|
1045
1045
|
: null;
|
|
1046
1046
|
// Delta decode is deliberately forgiving: a malformed entry is dropped,
|
|
1047
1047
|
// never thrown — the delta is an accelerator and the ordinary reads own
|
|
1048
|
-
// every failure mode (
|
|
1048
|
+
// every failure mode (vault-delta-fetch D2/D4).
|
|
1049
1049
|
let delta = null;
|
|
1050
1050
|
if (raw.delta && Array.isArray(raw.delta.heads) && Array.isArray(raw.delta.packs)) {
|
|
1051
1051
|
try {
|
|
@@ -1061,7 +1061,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1061
1061
|
delta = null;
|
|
1062
1062
|
}
|
|
1063
1063
|
}
|
|
1064
|
-
// Restore-plan decode (
|
|
1064
|
+
// Restore-plan decode (vault-restore-recipe design D1-D5): the SAME
|
|
1065
1065
|
// forgiving posture as `delta` above — ANY structural or resolution
|
|
1066
1066
|
// anomaly drops the whole plan, never throws, so a hiccup fetching one
|
|
1067
1067
|
// above-cap pack never fails the state read itself (head/carriers/delta
|
|
@@ -1097,11 +1097,11 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1097
1097
|
const packEntries = rp.packs.filter((pk) => typeof pk?.object_kind === "string" && typeof pk.object_id === "string");
|
|
1098
1098
|
if (packEntries.length !== rp.packs.length)
|
|
1099
1099
|
return null; // a malformed pack entry disqualifies the whole plan
|
|
1100
|
-
const resolved = await mapBounded(packEntries,
|
|
1100
|
+
const resolved = await mapBounded(packEntries, VAULT_TRANSPORT_CONCURRENCY, async (pk) => {
|
|
1101
1101
|
const target = { object_kind: pk.object_kind, object_id: pk.object_id, url: pk.url ?? "", edge_url: pk.edge_url, inline: pk.inline, stored_bytes_sha256: pk.stored_bytes_sha256 ?? "", size_bytes: pk.size_bytes ?? "0", expires_at: pk.expires_at ?? "" };
|
|
1102
1102
|
// Self-consistency only (the same gate `inline` gets everywhere
|
|
1103
1103
|
// else) — real verification against the head/manifest's OWN
|
|
1104
|
-
// receipt happens later, in `
|
|
1104
|
+
// receipt happens later, in `Vault`, before any byte is
|
|
1105
1105
|
// trusted. A pack the gateway declined to presign (`url` absent,
|
|
1106
1106
|
// `inline` absent) is `null` here and falls back to the ordinary
|
|
1107
1107
|
// object-reads fetch by (object_kind, object_id) in the caller.
|
|
@@ -1121,12 +1121,12 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1121
1121
|
}
|
|
1122
1122
|
/** 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. */
|
|
1123
1123
|
function receiptsFromFinalize(objects, entries, fin) {
|
|
1124
|
-
const receipts = new Map(fin.receipts.map((r) => [
|
|
1124
|
+
const receipts = new Map(fin.receipts.map((r) => [vaultLedgerId(r), r]));
|
|
1125
1125
|
return objects.map((o, i) => {
|
|
1126
|
-
const id =
|
|
1126
|
+
const id = vaultLedgerId(entries[i]);
|
|
1127
1127
|
const r = receipts.get(id);
|
|
1128
1128
|
if (!r)
|
|
1129
|
-
fail("
|
|
1129
|
+
fail("VAULT_RECEIPT_MISSING", `finalize returned no receipt for ${id}`, "finalizing vault upload session", { object_id: id, path: o.path });
|
|
1130
1130
|
return { path: o.path, object_id: o.object_id, sha256: r.ciphertext_sha256 ?? r.stored_bytes_sha256 ?? "", size_bytes: r.size_bytes };
|
|
1131
1131
|
});
|
|
1132
1132
|
}
|
|
@@ -1136,15 +1136,15 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1136
1136
|
// The manifest is closed-key: `path` is client-local and MUST NOT ride the
|
|
1137
1137
|
// wire — the control plane derives the bucket key itself and refuses an
|
|
1138
1138
|
// entry carrying an unexpected member.
|
|
1139
|
-
const entries = objects.map((o) =>
|
|
1140
|
-
//
|
|
1139
|
+
const entries = objects.map((o) => vaultManifestEntry(o));
|
|
1140
|
+
// vault-byo-primary-bucket task 3.2: the inline (bytes-in-body) fast
|
|
1141
1141
|
// path is NEVER used for a BYO vault, even when the payload is small
|
|
1142
1142
|
// enough to be eligible — that transport writes bytes into run402's own
|
|
1143
1143
|
// bucket, exactly what the credential model forbids (the gateway
|
|
1144
1144
|
// refuses it too, VALIDATION_FAILED, so skipping it here is purely a
|
|
1145
1145
|
// wasted-round-trip avoidance, not a correctness dependency).
|
|
1146
|
-
if (!byo &&
|
|
1147
|
-
//
|
|
1146
|
+
if (!byo && vaultInlineUploadEligible(objects)) {
|
|
1147
|
+
// vault-composite-state-read design D2: every object fits under the
|
|
1148
1148
|
// caps — one POST, bytes verified + written server-side, and the
|
|
1149
1149
|
// response IS the finalize response (no session, no PUTs, no separate
|
|
1150
1150
|
// finalize call). Same closed-key manifest as the presigned path, plus
|
|
@@ -1152,46 +1152,46 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1152
1152
|
const fin = await client.request(`${base(repoId)}/upload-sessions`, {
|
|
1153
1153
|
method: "POST",
|
|
1154
1154
|
body: { objects: entries.map((entry, i) => ({ ...entry, bytes: b64u(objects[i].bytes) })), ...(resourceBinding ? { resource_binding: resourceBinding } : {}) },
|
|
1155
|
-
context: "uploading
|
|
1155
|
+
context: "uploading vault objects inline",
|
|
1156
1156
|
});
|
|
1157
1157
|
return receiptsFromFinalize(objects, entries, fin);
|
|
1158
1158
|
}
|
|
1159
1159
|
const session = await client.request(`${base(repoId)}/upload-sessions`, {
|
|
1160
1160
|
method: "POST",
|
|
1161
1161
|
body: { objects: entries, ...(resourceBinding ? { resource_binding: resourceBinding } : {}) },
|
|
1162
|
-
context: "opening
|
|
1162
|
+
context: "opening vault upload session",
|
|
1163
1163
|
});
|
|
1164
|
-
const issued = new Map(session.objects.map((u) => [
|
|
1165
|
-
//
|
|
1164
|
+
const issued = new Map(session.objects.map((u) => [vaultLedgerId(u), u]));
|
|
1165
|
+
// vault-byo-primary-bucket task 3.2 — one backend, opened ONCE, for
|
|
1166
1166
|
// every BYO payload object this session names; `null` `put` never
|
|
1167
1167
|
// appears in the session response unless `byo` was supplied (the
|
|
1168
1168
|
// gateway only mints `put: null` for a vault whose storage_profile is
|
|
1169
1169
|
// "byo", which is exactly when THIS caller must have supplied `byo`
|
|
1170
|
-
// too — see the resolution in `
|
|
1171
|
-
const byoBackend = byo ?
|
|
1170
|
+
// too — see the resolution in `Vault.uploadAll`).
|
|
1171
|
+
const byoBackend = byo ? openVaultDestinationBackend(byo.destination, byo.credential) : null;
|
|
1172
1172
|
const byoAttestations = [];
|
|
1173
1173
|
// Independent create-only PUTs within one session (design D2) — bounded
|
|
1174
1174
|
// concurrency, same limit as the batched object reads below.
|
|
1175
|
-
await mapBounded(objects,
|
|
1176
|
-
const id =
|
|
1175
|
+
await mapBounded(objects, VAULT_TRANSPORT_CONCURRENCY, async (o, i) => {
|
|
1176
|
+
const id = vaultLedgerId(entries[i]);
|
|
1177
1177
|
const target = issued.get(id);
|
|
1178
1178
|
if (!target)
|
|
1179
|
-
fail("
|
|
1179
|
+
fail("VAULT_UPLOAD_SESSION_INVALID", `the session issued no upload for ${id}`, "uploading vault objects", { object_id: id, path: o.path });
|
|
1180
1180
|
if (target.put === null) {
|
|
1181
|
-
// BYO payload object (task 2.2's
|
|
1181
|
+
// BYO payload object (task 2.2's VAULT_BYO_PAYLOAD_KINDS
|
|
1182
1182
|
// partition, mirrored client-side by the server's own `put: null` +
|
|
1183
1183
|
// `key` shape) — the client writes DIRECTLY to its own bucket. No
|
|
1184
1184
|
// request to run402 ever carries a customer-bucket credential; no
|
|
1185
1185
|
// request to run402 ever carries these bytes either.
|
|
1186
1186
|
if (!byoBackend || !target.key) {
|
|
1187
|
-
fail("
|
|
1187
|
+
fail("VAULT_BYO_BUCKET_WRITE_REFUSED", `the session named ${o.path} as a BYO-written object but no BYO write target was resolved`, "uploading vault objects", { path: o.path, object_id: id });
|
|
1188
1188
|
}
|
|
1189
1189
|
let put;
|
|
1190
1190
|
try {
|
|
1191
1191
|
put = await byoBackend.putCreateOnly(target.key, o.bytes);
|
|
1192
1192
|
}
|
|
1193
1193
|
catch (e) {
|
|
1194
|
-
fail("
|
|
1194
|
+
fail("VAULT_BYO_BUCKET_WRITE_REFUSED", `writing ${o.path} to the BYO destination failed: ${e instanceof Error ? e.message : String(e)}`, "uploading vault objects", { path: o.path, key: target.key });
|
|
1195
1195
|
}
|
|
1196
1196
|
if (!put.created) {
|
|
1197
1197
|
// create-only: the key already exists — legal ONLY if it is
|
|
@@ -1201,7 +1201,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1201
1201
|
// compare branch below, and the gateway's own attestation rule).
|
|
1202
1202
|
const existing = await byoBackend.get(target.key);
|
|
1203
1203
|
if (!existing || sha256Hex(existing) !== o.sha256) {
|
|
1204
|
-
fail("
|
|
1204
|
+
fail("VAULT_BYO_BUCKET_WRITE_REFUSED", `${o.path} already exists in the BYO destination with different bytes`, "uploading vault objects", { path: o.path, key: target.key });
|
|
1205
1205
|
}
|
|
1206
1206
|
}
|
|
1207
1207
|
byoAttestations.push({ key: target.key, sha256: o.sha256, size_bytes: o.size_bytes, create_only_result: "created" });
|
|
@@ -1222,18 +1222,18 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1222
1222
|
// create-only: the key exists — legal only if it is byte-identical (read-and-compare)
|
|
1223
1223
|
const existing = await getObjectBytes(repoId, o.path);
|
|
1224
1224
|
if (!existing || sha256Hex(existing) !== o.sha256)
|
|
1225
|
-
fail("
|
|
1225
|
+
fail("VAULT_OBJECT_EXISTS_DIFFERENT", `${o.path} already exists with different bytes`, "uploading vault objects", { path: o.path });
|
|
1226
1226
|
return;
|
|
1227
1227
|
}
|
|
1228
1228
|
if (!r.ok)
|
|
1229
|
-
fail("
|
|
1229
|
+
fail("VAULT_UPLOAD_FAILED", `presigned PUT failed (HTTP ${r.status}) for ${o.path}`, "uploading vault objects", { path: o.path, status: r.status });
|
|
1230
1230
|
});
|
|
1231
1231
|
const fin = await client.request(`${base(repoId)}/upload-sessions/${encodeURIComponent(session.upload_session_id)}/finalize`, {
|
|
1232
1232
|
method: "POST",
|
|
1233
1233
|
// Only a BYO session ever carries attestations — an ordinary managed
|
|
1234
1234
|
// session's finalize body stays `{}`, byte-identical to today.
|
|
1235
1235
|
body: byo ? { attestations: byoAttestations } : {},
|
|
1236
|
-
context: "finalizing
|
|
1236
|
+
context: "finalizing vault upload session",
|
|
1237
1237
|
});
|
|
1238
1238
|
return receiptsFromFinalize(objects, entries, fin);
|
|
1239
1239
|
}
|
|
@@ -1242,7 +1242,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1242
1242
|
const r = await client.request(`${base(repoId)}/admissions`, {
|
|
1243
1243
|
method: "POST",
|
|
1244
1244
|
body: { generation, stored_bytes: b64u(bytes), stored_bytes_sha256: hash, ...extra },
|
|
1245
|
-
context: "admitting
|
|
1245
|
+
context: "admitting vault head",
|
|
1246
1246
|
});
|
|
1247
1247
|
return { outcome: "admitted", admission_record_sha256: r.admission_record_sha256, capture_receipt: r.capture_receipt ?? null };
|
|
1248
1248
|
}
|
|
@@ -1261,7 +1261,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1261
1261
|
// The route wraps the signed allocation object under `allocation` and
|
|
1262
1262
|
// adds routing sugar (`allocation_sha256`, `deduplicated`, next_actions).
|
|
1263
1263
|
// The vault verifies the SIGNED object, so unwrap it here.
|
|
1264
|
-
const res = await client.request("/
|
|
1264
|
+
const res = await client.request("/vaults/v1", { method: "POST", body: request, context: "allocating vault" });
|
|
1265
1265
|
return res.allocation ?? res;
|
|
1266
1266
|
},
|
|
1267
1267
|
async putObject(request) {
|
|
@@ -1274,7 +1274,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1274
1274
|
getHeads: ({ repo_id, generations }) => getHeadsBytes(repo_id, generations),
|
|
1275
1275
|
async admitGenesis(request) {
|
|
1276
1276
|
try {
|
|
1277
|
-
const r = await admit(request.repo_id,
|
|
1277
|
+
const r = await admit(request.repo_id, VAULT_GENESIS_GENERATION, request.stored_bytes, request.stored_bytes_sha256, { allocation_generation: request.allocation_generation });
|
|
1278
1278
|
if (r.outcome === "admitted")
|
|
1279
1279
|
return { outcome: "admitted", admitted_sha256: request.stored_bytes_sha256 };
|
|
1280
1280
|
return { outcome: "already_admitted", admitted_sha256: r.winner.stored_bytes_sha256 };
|
|
@@ -1285,20 +1285,20 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1285
1285
|
throw e;
|
|
1286
1286
|
}
|
|
1287
1287
|
},
|
|
1288
|
-
getGenesis: ({ repo_id }) => getObjectBytes(repo_id, `head/${
|
|
1288
|
+
getGenesis: ({ repo_id }) => getObjectBytes(repo_id, `head/${VAULT_GENESIS_GENERATION}`),
|
|
1289
1289
|
// ── publication (5.4) ──
|
|
1290
1290
|
async listHeads(request) {
|
|
1291
1291
|
validateHeadsListingRequest(request);
|
|
1292
1292
|
const qs = new URLSearchParams({ after_generation: request.after_generation, limit: request.limit });
|
|
1293
1293
|
if (request.cursor !== undefined)
|
|
1294
1294
|
qs.set("cursor", request.cursor);
|
|
1295
|
-
return client.request(`${base(request.repo_id)}/heads?${qs.toString()}`, { context: "listing
|
|
1295
|
+
return client.request(`${base(request.repo_id)}/heads?${qs.toString()}`, { context: "listing vault heads" });
|
|
1296
1296
|
},
|
|
1297
1297
|
uploadObjects: ({ repo_id, objects, resource_binding, byo }) => upload(repo_id, objects, resource_binding, byo),
|
|
1298
1298
|
admitHead: (r) => admit(r.repo_id, r.generation, r.stored_bytes, r.stored_bytes_sha256),
|
|
1299
|
-
// Both routes SHIPPED (`routes/
|
|
1299
|
+
// Both routes SHIPPED (`routes/vault-admission.ts`). `retention-cutoffs`
|
|
1300
1300
|
// answers `{ticket, receipt, next_actions}`, which IS
|
|
1301
|
-
// `
|
|
1301
|
+
// `VaultRetentionCutoffIssued` plus routing sugar — no unwrap needed.
|
|
1302
1302
|
requestRetentionCutoff: ({ repo_id, base_head_sha256 }) => client.request(`${base(repo_id)}/retention-cutoffs`, { method: "POST", body: { base_head_sha256 }, context: "requesting retention cutoff ticket" }),
|
|
1303
1303
|
async exchangeActivationToken({ repo_id, operation_id, capture_receipt }) {
|
|
1304
1304
|
// The route wraps the SIGNED token under `activation_token` and adds
|
|
@@ -1307,10 +1307,10 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1307
1307
|
// caller verifies the SIGNED object, and `checkActivationTokenBinding`
|
|
1308
1308
|
// compares nine fields that all live INSIDE it. Passing the envelope on
|
|
1309
1309
|
// as the token mismatches every one of them
|
|
1310
|
-
// (`
|
|
1310
|
+
// (`VAULT_TOKEN_BINDING_MISMATCH`) and the deploy never commits — even
|
|
1311
1311
|
// though the envelope's sibling `object_id` makes the shape look close
|
|
1312
1312
|
// enough to be a plausible token at a glance. Pinned by
|
|
1313
|
-
// `
|
|
1313
|
+
// `vault-wire-shapes.test.ts`.
|
|
1314
1314
|
const res = await client.request(`${base(repo_id)}/activation-tokens`, { method: "POST", body: { operation_id, capture_receipt }, context: "exchanging capture receipt for activation token" });
|
|
1315
1315
|
return res.activation_token ?? res;
|
|
1316
1316
|
},
|
|
@@ -1318,38 +1318,38 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1318
1318
|
const r = await client.request(`${base(repo_id)}/override-completions`, { method: "POST", body: { operation_id, capture_receipt }, context: "submitting override completion" });
|
|
1319
1319
|
return { cleared: r.advisory_cleared ?? r.cleared ?? false };
|
|
1320
1320
|
},
|
|
1321
|
-
getVaultRecord: ({ repo_id }) => client.request(base(repo_id), { context: "reading the
|
|
1321
|
+
getVaultRecord: ({ repo_id }) => client.request(base(repo_id), { context: "reading the vault record" }),
|
|
1322
1322
|
getState: ({ repo_id, since, restore }) => getVaultStateOut(repo_id, since, restore),
|
|
1323
|
-
openCompactionGrant: ({ repo_id }) => client.request(`${base(repo_id)}/compaction-grant`, { method: "POST", context: "opening the
|
|
1323
|
+
openCompactionGrant: ({ repo_id }) => client.request(`${base(repo_id)}/compaction-grant`, { method: "POST", context: "opening the vault compaction headroom grant" }),
|
|
1324
1324
|
closeCompactionGrant: async ({ repo_id }) => {
|
|
1325
|
-
const r = await client.request(`${base(repo_id)}/compaction-grant`, { method: "DELETE", context: "closing the
|
|
1325
|
+
const r = await client.request(`${base(repo_id)}/compaction-grant`, { method: "DELETE", context: "closing the vault compaction headroom grant" });
|
|
1326
1326
|
return { closed: r.closed === true };
|
|
1327
1327
|
},
|
|
1328
|
-
findVaultByProject: ({ project_id }) => client.request(`/
|
|
1329
|
-
findVaultByRepo: ({ org_slug, repo_name }) => client.request(`/
|
|
1328
|
+
findVaultByProject: ({ project_id }) => client.request(`/vaults/v1?project_id=${encodeURIComponent(project_id)}`, { context: "resolving the project's vault" }),
|
|
1329
|
+
findVaultByRepo: ({ org_slug, repo_name }) => client.request(`/vaults/v1?repo=${encodeURIComponent(`${org_slug}/${repo_name}`)}`, { context: "resolving the vault by repo address" }),
|
|
1330
1330
|
listOrgEncryptionKeys: ({ org_id }) => client.request(`/orgs/v1/${encodeURIComponent(org_id)}/encryption-keys`, { context: "reading the org encryption-key directory" }),
|
|
1331
|
-
listEnvelopeRecipients: ({ repo_id }) => client.request(`${base(repo_id)}/envelope-recipients`, { context: "reading the
|
|
1331
|
+
listEnvelopeRecipients: ({ repo_id }) => client.request(`${base(repo_id)}/envelope-recipients`, { context: "reading the vault envelope recipients" }),
|
|
1332
1332
|
// ── epoch rotation (D193-D203, rev 42, §9.2) ──
|
|
1333
1333
|
async createRotationAttempt({ repo_id, descriptor }) {
|
|
1334
1334
|
const res = await client.request(`${base(repo_id)}/rotation-attempts`, {
|
|
1335
1335
|
method: "POST",
|
|
1336
1336
|
body: descriptor,
|
|
1337
|
-
context: "creating a
|
|
1337
|
+
context: "creating a vault rotation attempt",
|
|
1338
1338
|
});
|
|
1339
1339
|
return { rotation_id: res.rotation_id, descriptor: res.descriptor, deduplicated: res.deduplicated ?? false };
|
|
1340
1340
|
},
|
|
1341
|
-
confirmRecipient: ({ repo_id, principal_id, new_fingerprint }) => client.request(`${base(repo_id)}/recipients/${encodeURIComponent(principal_id)}/confirm`, { method: "POST", body: { new_fingerprint }, context: "confirming a
|
|
1342
|
-
repinRecipient: ({ repo_id, principal_id, old_ek_fingerprint, new_fingerprint }) => client.request(`${base(repo_id)}/recipients/${encodeURIComponent(principal_id)}/repin`, { method: "POST", body: { old_ek_fingerprint, new_fingerprint }, context: "re-pinning a
|
|
1343
|
-
declareRecipientKeyRevoked: ({ repo_id, principal_id }) => client.request(`${base(repo_id)}/recipients/${encodeURIComponent(principal_id)}/key-revocation`, { method: "POST", body: {}, context: "declaring a
|
|
1344
|
-
declareEpochSecretExposed: ({ repo_id }) => client.request(`${base(repo_id)}/epoch-secret-exposure`, { method: "POST", body: {}, context: "declaring a
|
|
1345
|
-
declareWriterAuthorityUnavailable: ({ repo_id }) => client.request(`${base(repo_id)}/writer-authority/declare-unavailable`, { method: "POST", body: {}, context: "declaring
|
|
1341
|
+
confirmRecipient: ({ repo_id, principal_id, new_fingerprint }) => client.request(`${base(repo_id)}/recipients/${encodeURIComponent(principal_id)}/confirm`, { method: "POST", body: { new_fingerprint }, context: "confirming a vault recipient's first pin" }),
|
|
1342
|
+
repinRecipient: ({ repo_id, principal_id, old_ek_fingerprint, new_fingerprint }) => client.request(`${base(repo_id)}/recipients/${encodeURIComponent(principal_id)}/repin`, { method: "POST", body: { old_ek_fingerprint, new_fingerprint }, context: "re-pinning a vault recipient" }),
|
|
1343
|
+
declareRecipientKeyRevoked: ({ repo_id, principal_id }) => client.request(`${base(repo_id)}/recipients/${encodeURIComponent(principal_id)}/key-revocation`, { method: "POST", body: {}, context: "declaring a vault recipient key revoked" }),
|
|
1344
|
+
declareEpochSecretExposed: ({ repo_id }) => client.request(`${base(repo_id)}/epoch-secret-exposure`, { method: "POST", body: {}, context: "declaring a vault epoch secret exposed" }),
|
|
1345
|
+
declareWriterAuthorityUnavailable: ({ repo_id }) => client.request(`${base(repo_id)}/writer-authority/declare-unavailable`, { method: "POST", body: {}, context: "declaring vault writer authority unavailable" }),
|
|
1346
1346
|
async submitOpenProof({ repo_id, principal_id, ek_fingerprint, chain_verified_to_generation, decryptable_to_generation, reader_entrypoint }) {
|
|
1347
1347
|
// requestWithResponse (not request) — the gateway's own status code is
|
|
1348
1348
|
// the ONLY signal distinguishing a fresh mint (201) from an idempotent
|
|
1349
1349
|
// replay of the tuple's already-committed winner (200); the receipt
|
|
1350
|
-
// BODY is identical either way (routes/
|
|
1350
|
+
// BODY is identical either way (routes/repos.ts:
|
|
1351
1351
|
// `res.status(inserted ? 201 : 200).json(receipt)`).
|
|
1352
|
-
const res = await client.requestWithResponse(`${base(repo_id)}/recipients/${encodeURIComponent(principal_id)}/proof-of-open`, { method: "POST", body: { ek_fingerprint, chain_verified_to_generation, decryptable_to_generation, reader_entrypoint }, context: "submitting a
|
|
1352
|
+
const res = await client.requestWithResponse(`${base(repo_id)}/recipients/${encodeURIComponent(principal_id)}/proof-of-open`, { method: "POST", body: { ek_fingerprint, chain_verified_to_generation, decryptable_to_generation, reader_entrypoint }, context: "submitting a vault proof-of-open receipt" });
|
|
1353
1353
|
return { receipt: res.body, deduplicated: res.status === 200 };
|
|
1354
1354
|
},
|
|
1355
1355
|
acquireMaintenanceLease: ({ repo_id, base_head_sha256, current_checkpoint_hash, r1_size_bytes, r2_cap_size_bytes, p_before_c1_size_bytes, p_before_c2_size_bytes }) => client.request(`${base(repo_id)}/maintenance-leases`, {
|
|
@@ -1362,10 +1362,10 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1362
1362
|
p_before_c1_size_bytes: p_before_c1_size_bytes ?? "0",
|
|
1363
1363
|
p_before_c2_size_bytes: p_before_c2_size_bytes ?? "0",
|
|
1364
1364
|
},
|
|
1365
|
-
context: "acquiring the
|
|
1365
|
+
context: "acquiring the vault maintenance lease",
|
|
1366
1366
|
}),
|
|
1367
|
-
heartbeatMaintenanceLease: ({ repo_id, maintenance_lease_id, holder_token }) => client.request(`${base(repo_id)}/maintenance-leases/${encodeURIComponent(maintenance_lease_id)}/heartbeat`, { method: "POST", body: { holder_token }, context: "renewing the
|
|
1368
|
-
releaseMaintenanceLease: ({ repo_id, maintenance_lease_id, holder_token }) => client.request(`${base(repo_id)}/maintenance-leases/${encodeURIComponent(maintenance_lease_id)}`, { method: "DELETE", body: { holder_token }, context: "releasing the
|
|
1367
|
+
heartbeatMaintenanceLease: ({ repo_id, maintenance_lease_id, holder_token }) => client.request(`${base(repo_id)}/maintenance-leases/${encodeURIComponent(maintenance_lease_id)}/heartbeat`, { method: "POST", body: { holder_token }, context: "renewing the vault maintenance lease" }),
|
|
1368
|
+
releaseMaintenanceLease: ({ repo_id, maintenance_lease_id, holder_token }) => client.request(`${base(repo_id)}/maintenance-leases/${encodeURIComponent(maintenance_lease_id)}`, { method: "DELETE", body: { holder_token }, context: "releasing the vault maintenance lease" }),
|
|
1369
1369
|
// ── prune (§7.3) ──
|
|
1370
1370
|
async submitPruneIntent({ repo_id, intent_bytes }) {
|
|
1371
1371
|
// Deliberately NOT `client.request`: that serializes a body object, and
|
|
@@ -1374,7 +1374,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1374
1374
|
// object would change what was signed. Same raw-fetch shape the
|
|
1375
1375
|
// generation-addressed reads use, so it inherits the same credentials.
|
|
1376
1376
|
const path = `${base(repo_id)}/prune-intents`;
|
|
1377
|
-
const auth = (await client.credentials.getAuth(path, { method: "
|
|
1377
|
+
const auth = (await client.credentials.getAuth(path, { method: "repos.prune" })) ?? {};
|
|
1378
1378
|
const r = await client.fetch(`${client.apiBase}${path}`, {
|
|
1379
1379
|
method: "POST",
|
|
1380
1380
|
headers: { ...auth, "content-type": "application/json", accept: "application/json" },
|
|
@@ -1398,7 +1398,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1398
1398
|
// envelope were `{error: {code, message, details}}` — always missed,
|
|
1399
1399
|
// so EVERY refusal (receipt-id reuse, the retention floor,
|
|
1400
1400
|
// GC_EPOCH_STALE, …) collapsed to the same opaque
|
|
1401
|
-
// `
|
|
1401
|
+
// `VAULT_PRUNE_SUBMIT_FAILED {details: null}`. Read the real
|
|
1402
1402
|
// shape so the gateway's own refusal rides through verbatim; this
|
|
1403
1403
|
// wrapper only adds the HTTP status, never replaces content.
|
|
1404
1404
|
const envelope = parsed;
|
|
@@ -1415,14 +1415,14 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1415
1415
|
details.http_status = r.status;
|
|
1416
1416
|
if (envelope?.trace_id)
|
|
1417
1417
|
details.trace_id = envelope.trace_id;
|
|
1418
|
-
fail(envelope?.code ?? "
|
|
1418
|
+
fail(envelope?.code ?? "VAULT_PRUNE_SUBMIT_FAILED", envelope?.message ?? (typeof envelope?.error === "string" ? envelope.error : undefined) ?? `prune intent submission failed (HTTP ${r.status})`, "submitting the vault prune intent", details, Array.isArray(envelope?.next_actions) ? envelope.next_actions : undefined);
|
|
1419
1419
|
}
|
|
1420
1420
|
const body = (parsed ?? {});
|
|
1421
1421
|
return { ...body, stored: body.stored === true };
|
|
1422
1422
|
},
|
|
1423
1423
|
async getPruneIntent({ repo_id, prune_intent_object_id }) {
|
|
1424
1424
|
try {
|
|
1425
|
-
return await client.request(`${base(repo_id)}/prune-intents/${encodeURIComponent(prune_intent_object_id)}`, { context: "reading the
|
|
1425
|
+
return await client.request(`${base(repo_id)}/prune-intents/${encodeURIComponent(prune_intent_object_id)}`, { context: "reading the vault prune intent" });
|
|
1426
1426
|
}
|
|
1427
1427
|
catch (e) {
|
|
1428
1428
|
// Authorize-before-reveal: a malformed, absent, or foreign id all
|
|
@@ -1433,10 +1433,10 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1433
1433
|
}
|
|
1434
1434
|
},
|
|
1435
1435
|
};
|
|
1436
|
-
return process.env.
|
|
1436
|
+
return process.env.RUN402_VAULT_TRACE === "1" ? traceVaultTransport(transport) : transport;
|
|
1437
1437
|
}
|
|
1438
1438
|
/**
|
|
1439
|
-
* `
|
|
1439
|
+
* `RUN402_VAULT_TRACE=1` (design D7) — one stderr line per real
|
|
1440
1440
|
* transport operation (op kind, a path/object-count shape when the request
|
|
1441
1441
|
* carries one, byte count when the result carries one, duration) plus a
|
|
1442
1442
|
* session summary at process exit (total ops, total time spent in this
|
|
@@ -1445,17 +1445,17 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1445
1445
|
* `git-remote-run402` protocol stream, the same discipline the helper's
|
|
1446
1446
|
* own `note()` follows — and not a canonical surface: it is NOT what the
|
|
1447
1447
|
* client-surface spec's counted budgets measure (that is
|
|
1448
|
-
* `
|
|
1448
|
+
* `VaultOpCounter`, a test-only instrument over the SAME operation
|
|
1449
1449
|
* shapes). The client-side env var carries no gateway env-registry policy.
|
|
1450
1450
|
*/
|
|
1451
|
-
function
|
|
1451
|
+
function traceVaultTransport(inner) {
|
|
1452
1452
|
let opCount = 0;
|
|
1453
1453
|
let totalMs = 0;
|
|
1454
1454
|
const sessionStart = Date.now();
|
|
1455
1455
|
process.on("exit", () => {
|
|
1456
1456
|
if (opCount === 0)
|
|
1457
1457
|
return;
|
|
1458
|
-
process.stderr.write(`
|
|
1458
|
+
process.stderr.write(`vault-trace: session summary — ${opCount} op(s), ${totalMs.toFixed(1)}ms in transport, ${Date.now() - sessionStart}ms wall-clock\n`);
|
|
1459
1459
|
});
|
|
1460
1460
|
const describeRequest = (arg) => {
|
|
1461
1461
|
if (!arg || typeof arg !== "object")
|
|
@@ -1494,14 +1494,14 @@ function traceGitvaultTransport(inner) {
|
|
|
1494
1494
|
const elapsed = Date.now() - start;
|
|
1495
1495
|
opCount += 1;
|
|
1496
1496
|
totalMs += elapsed;
|
|
1497
|
-
process.stderr.write(`
|
|
1497
|
+
process.stderr.write(`vault-trace: ${prop}${describeRequest(args[0])}${describeResult(result)} ${elapsed}ms\n`);
|
|
1498
1498
|
return result;
|
|
1499
1499
|
}
|
|
1500
1500
|
catch (e) {
|
|
1501
1501
|
const elapsed = Date.now() - start;
|
|
1502
1502
|
opCount += 1;
|
|
1503
1503
|
totalMs += elapsed;
|
|
1504
|
-
process.stderr.write(`
|
|
1504
|
+
process.stderr.write(`vault-trace: ${prop}${describeRequest(args[0])} FAILED ${elapsed}ms\n`);
|
|
1505
1505
|
throw e;
|
|
1506
1506
|
}
|
|
1507
1507
|
};
|
|
@@ -1509,7 +1509,7 @@ function traceGitvaultTransport(inner) {
|
|
|
1509
1509
|
});
|
|
1510
1510
|
}
|
|
1511
1511
|
// ─── Storage paths (§3) ──────────────────────────────────────────────────────
|
|
1512
|
-
export const
|
|
1512
|
+
export const vaultPaths = {
|
|
1513
1513
|
head: (generation) => `head/${generation}`,
|
|
1514
1514
|
admission: (generation) => `admissions/${generation}`,
|
|
1515
1515
|
wal: (id) => `wal/${id}.pack.enc`,
|
|
@@ -1523,7 +1523,7 @@ export const gitvaultPaths = {
|
|
|
1523
1523
|
verifierReceipt: (id) => `verifier-receipts/${id}.json`,
|
|
1524
1524
|
/**
|
|
1525
1525
|
* `envelopes/<epoch>/<recipient_fingerprint>` — mirrors
|
|
1526
|
-
* `
|
|
1526
|
+
* `vault-creation-journal.ts`'s private `envelopePath` (the genesis
|
|
1527
1527
|
* creator's envelope); this is the same addressing for every OTHER
|
|
1528
1528
|
* recipient's `key_envelope`. `rotationId` present (D195, rev 42) widens
|
|
1529
1529
|
* this to `envelopes/<epoch>/<rotation_id>/<recipient_fingerprint>` — a
|
|
@@ -1534,7 +1534,7 @@ export const gitvaultPaths = {
|
|
|
1534
1534
|
/** `recipient-pins/<pin_manifest_version>.json` (D197, rev 42) — version-addressed, plaintext-structured, writer-signed. */
|
|
1535
1535
|
pinManifest: (pinManifestVersion) => `recipient-pins/${pinManifestVersion}.json`,
|
|
1536
1536
|
};
|
|
1537
|
-
// ─── Incremental restore marker (
|
|
1537
|
+
// ─── Incremental restore marker (vault-client-round-trips design D5) ──────
|
|
1538
1538
|
//
|
|
1539
1539
|
// `restored_through` — the newest generation whose WAL packs this TARGET
|
|
1540
1540
|
// DIRECTORY has fully applied — is local git state, same store as the
|
|
@@ -1542,8 +1542,8 @@ export const gitvaultPaths = {
|
|
|
1542
1542
|
// actually wrote into (never `process.cwd()`, and never scoped to `repo_id`:
|
|
1543
1543
|
// a re-pointed remote's marker simply fails its `head_sha256` comparison and
|
|
1544
1544
|
// falls back to wholesale, safely, since that comparison is a content hash).
|
|
1545
|
-
const
|
|
1546
|
-
const
|
|
1545
|
+
const VAULT_RESTORE_MARKER_GENERATION_KEY = "r402.restoredThrough";
|
|
1546
|
+
const VAULT_RESTORE_MARKER_SHA256_KEY = "r402.restoredThroughSha256";
|
|
1547
1547
|
async function readLocalGitConfigValue(dir, key) {
|
|
1548
1548
|
try {
|
|
1549
1549
|
const out = (await hardenedGit(dir, ["config", "--local", "--get", key])).text().trim();
|
|
@@ -1555,49 +1555,49 @@ async function readLocalGitConfigValue(dir, key) {
|
|
|
1555
1555
|
}
|
|
1556
1556
|
}
|
|
1557
1557
|
/** Read this target directory's `restored_through` marker, or `null` when nothing was ever restored into it. */
|
|
1558
|
-
export async function
|
|
1559
|
-
const generation = await readLocalGitConfigValue(targetRepoDir,
|
|
1560
|
-
const head_sha256 = await readLocalGitConfigValue(targetRepoDir,
|
|
1558
|
+
export async function readVaultRestoreMarker(targetRepoDir) {
|
|
1559
|
+
const generation = await readLocalGitConfigValue(targetRepoDir, VAULT_RESTORE_MARKER_GENERATION_KEY);
|
|
1560
|
+
const head_sha256 = await readLocalGitConfigValue(targetRepoDir, VAULT_RESTORE_MARKER_SHA256_KEY);
|
|
1561
1561
|
if (!generation || !head_sha256)
|
|
1562
1562
|
return null;
|
|
1563
1563
|
return { generation, head_sha256 };
|
|
1564
1564
|
}
|
|
1565
1565
|
/** Advance the marker — called only after a restore's coverage verification succeeds. */
|
|
1566
|
-
async function
|
|
1567
|
-
await hardenedGit(targetRepoDir, ["config", "--local",
|
|
1568
|
-
await hardenedGit(targetRepoDir, ["config", "--local",
|
|
1566
|
+
async function writeVaultRestoreMarker(targetRepoDir, generation, headSha256) {
|
|
1567
|
+
await hardenedGit(targetRepoDir, ["config", "--local", VAULT_RESTORE_MARKER_GENERATION_KEY, generation]);
|
|
1568
|
+
await hardenedGit(targetRepoDir, ["config", "--local", VAULT_RESTORE_MARKER_SHA256_KEY, headSha256]);
|
|
1569
1569
|
}
|
|
1570
|
-
// ─── auto-gc cadence threshold (
|
|
1570
|
+
// ─── auto-gc cadence threshold (vault-checkpoint-cadence design D1) ───────
|
|
1571
1571
|
//
|
|
1572
1572
|
// `auto_gc_generations` rides the SAME local-git-config mechanism as the
|
|
1573
1573
|
// restore marker above — a per-CHECKOUT knob, exactly like git's own
|
|
1574
1574
|
// `gc.auto` (`git config gc.auto`), not a server-side vault policy. Read
|
|
1575
1575
|
// fresh every push (a cheap local read, never network); `0` disables;
|
|
1576
1576
|
// absent reads as the default.
|
|
1577
|
-
const
|
|
1577
|
+
const VAULT_AUTO_GC_GENERATIONS_KEY = "r402.autoGcGenerations";
|
|
1578
1578
|
/** Default `auto_gc_generations` — see the change proposal's rationale (≈2-3s over the fresh-checkpoint floor at this backlog, a busy repo compacts roughly once per few dozen pushes). */
|
|
1579
|
-
export const
|
|
1579
|
+
export const VAULT_AUTO_GC_GENERATIONS_DEFAULT = 32;
|
|
1580
1580
|
/**
|
|
1581
1581
|
* Read this checkout's auto-gc threshold, or the default when unset or
|
|
1582
1582
|
* unparseable. Never throws — a corrupt local config value degrades to the
|
|
1583
1583
|
* default rather than blocking a push's own auto-gc check.
|
|
1584
1584
|
*/
|
|
1585
|
-
export async function
|
|
1586
|
-
const raw = await readLocalGitConfigValue(targetRepoDir,
|
|
1585
|
+
export async function readVaultAutoGcThreshold(targetRepoDir) {
|
|
1586
|
+
const raw = await readLocalGitConfigValue(targetRepoDir, VAULT_AUTO_GC_GENERATIONS_KEY);
|
|
1587
1587
|
if (raw === null)
|
|
1588
|
-
return
|
|
1588
|
+
return VAULT_AUTO_GC_GENERATIONS_DEFAULT;
|
|
1589
1589
|
const trimmed = raw.trim();
|
|
1590
1590
|
if (!/^\d+$/.test(trimmed))
|
|
1591
|
-
return
|
|
1591
|
+
return VAULT_AUTO_GC_GENERATIONS_DEFAULT;
|
|
1592
1592
|
const n = Number.parseInt(trimmed, 10);
|
|
1593
|
-
return Number.isSafeInteger(n) && n >= 0 ? n :
|
|
1593
|
+
return Number.isSafeInteger(n) && n >= 0 ? n : VAULT_AUTO_GC_GENERATIONS_DEFAULT;
|
|
1594
1594
|
}
|
|
1595
1595
|
/** Set this checkout's auto-gc threshold. `0` disables auto-gc entirely. */
|
|
1596
|
-
export async function
|
|
1597
|
-
await hardenedGit(targetRepoDir, ["config", "--local",
|
|
1596
|
+
export async function writeVaultAutoGcThreshold(targetRepoDir, generations) {
|
|
1597
|
+
await hardenedGit(targetRepoDir, ["config", "--local", VAULT_AUTO_GC_GENERATIONS_KEY, String(generations)]);
|
|
1598
1598
|
}
|
|
1599
1599
|
/** A transport-agnostic view of git ops the publication needs (the local repository). */
|
|
1600
|
-
export class
|
|
1600
|
+
export class Vault {
|
|
1601
1601
|
keystore;
|
|
1602
1602
|
transport;
|
|
1603
1603
|
repoId;
|
|
@@ -1605,7 +1605,7 @@ export class GitvaultVault {
|
|
|
1605
1605
|
now;
|
|
1606
1606
|
budget;
|
|
1607
1607
|
/**
|
|
1608
|
-
*
|
|
1608
|
+
* vault-clone-scaling (bench P2): the CURRENT walk window's prefetched
|
|
1609
1609
|
* bytes — head bytes (bounded-concurrent direct reads) and carrier frames
|
|
1610
1610
|
* (one batched getObjects), keyed by storage path — filled by
|
|
1611
1611
|
* `verifyToNewest`'s per-page prefetch and `restoreObjectsInto`'s
|
|
@@ -1622,7 +1622,7 @@ export class GitvaultVault {
|
|
|
1622
1622
|
*/
|
|
1623
1623
|
walkPrefetch = null;
|
|
1624
1624
|
/**
|
|
1625
|
-
* WAL pack bytes carried by the state read's delta (
|
|
1625
|
+
* WAL pack bytes carried by the state read's delta (vault-delta-fetch),
|
|
1626
1626
|
* keyed by object_id — the walkPrefetch discipline exactly: UNTRUSTED
|
|
1627
1627
|
* until a consumer's own hash check passes, a mismatch is a plain miss,
|
|
1628
1628
|
* and the buffer is transient (stashed by {@link tryStateFastPath},
|
|
@@ -1630,7 +1630,7 @@ export class GitvaultVault {
|
|
|
1630
1630
|
*/
|
|
1631
1631
|
stateDeltaPacks = null;
|
|
1632
1632
|
/**
|
|
1633
|
-
* The state read's restore plan (
|
|
1633
|
+
* The state read's restore plan (vault-restore-recipe design D1-D5),
|
|
1634
1634
|
* stashed RAW — never verified at stash time, unlike `stateDeltaPacks`
|
|
1635
1635
|
* (whose heads self-check before entering the shared keystore cache): a
|
|
1636
1636
|
* plan's heads/checkpoint/packs are only ever verified by {@link
|
|
@@ -1646,7 +1646,7 @@ export class GitvaultVault {
|
|
|
1646
1646
|
allowUnauthenticatedSalvage;
|
|
1647
1647
|
genesisCache = null;
|
|
1648
1648
|
/**
|
|
1649
|
-
*
|
|
1649
|
+
* vault-byo-primary-bucket task 3.2 — this vault's resolved BYO write
|
|
1650
1650
|
* target, cached after the first resolution (storage_profile/
|
|
1651
1651
|
* byo_destination are immutable-at-allocation in v1, so a single
|
|
1652
1652
|
* read-once-per-instance is safe). `undefined` = not yet resolved;
|
|
@@ -1661,14 +1661,14 @@ export class GitvaultVault {
|
|
|
1661
1661
|
this.repoId = options.repo_id;
|
|
1662
1662
|
this.repoDir = options.repo_dir ?? null;
|
|
1663
1663
|
this.now = options.now ?? (() => new Date());
|
|
1664
|
-
this.budget = options.verification_budget ??
|
|
1665
|
-
this.retries = options.conflict_retries ??
|
|
1664
|
+
this.budget = options.verification_budget ?? VAULT_VERIFICATION_BUDGET_HEADS;
|
|
1665
|
+
this.retries = options.conflict_retries ?? VAULT_PUSH_CONFLICT_RETRIES;
|
|
1666
1666
|
this.servicePublicKey = options.service_public_key ?? null;
|
|
1667
1667
|
this.allowUnauthenticatedSalvage = options.allow_unauthenticated_salvage ?? false;
|
|
1668
1668
|
}
|
|
1669
1669
|
static open(options) {
|
|
1670
|
-
const v = new
|
|
1671
|
-
v.repoFile(); // KEYSTORE_MISSING /
|
|
1670
|
+
const v = new Vault(options);
|
|
1671
|
+
v.repoFile(); // KEYSTORE_MISSING / VAULT_REPO_STATE_MISSING surface here
|
|
1672
1672
|
return v;
|
|
1673
1673
|
}
|
|
1674
1674
|
repoFile() {
|
|
@@ -1678,23 +1678,23 @@ export class GitvaultVault {
|
|
|
1678
1678
|
// directory/filename read, appended after the existing remedy rather
|
|
1679
1679
|
// than replacing it.
|
|
1680
1680
|
if (!this.keystore.readIdentity()) {
|
|
1681
|
-
fail("KEYSTORE_MISSING", "no
|
|
1682
|
-
{ action: "restore ~/.config/run402/
|
|
1683
|
-
...
|
|
1681
|
+
fail("KEYSTORE_MISSING", "no vault identity in the keystore", "opening vault", undefined, [
|
|
1682
|
+
{ action: "restore ~/.config/run402/vault from backup or accept vault loss" },
|
|
1683
|
+
...crossProfileVaultHint(this.repoId),
|
|
1684
1684
|
]);
|
|
1685
1685
|
}
|
|
1686
1686
|
const repo = this.keystore.readRepo(this.repoId);
|
|
1687
1687
|
if (!repo) {
|
|
1688
|
-
const hint =
|
|
1689
|
-
fail("
|
|
1688
|
+
const hint = crossProfileVaultHint(this.repoId);
|
|
1689
|
+
fail("VAULT_REPO_STATE_MISSING", `no keystore repo file for ${this.repoId}; restore it from the principal's own envelope (keystore.restoreRepoFromEnvelope)`, "opening vault", { repo_id: this.repoId }, hint.length > 0 ? hint : undefined);
|
|
1690
1690
|
}
|
|
1691
1691
|
return repo;
|
|
1692
1692
|
}
|
|
1693
1693
|
/**
|
|
1694
|
-
*
|
|
1694
|
+
* vault-agent-envelopes D4 — the COLD OPEN. A keystore that holds an
|
|
1695
1695
|
* identity but no repo file for this vault (a member joining from a fresh
|
|
1696
1696
|
* machine, or a creator whose repo file was lost) restores `K_repo` from
|
|
1697
|
-
* its OWN `key_envelope` instead of dying `
|
|
1697
|
+
* its OWN `key_envelope` instead of dying `VAULT_REPO_STATE_MISSING`:
|
|
1698
1698
|
*
|
|
1699
1699
|
* 1. genesis (the writer-key source) is fetched and signature-verified;
|
|
1700
1700
|
* 2. its creator fingerprints are compared against the control plane's
|
|
@@ -1703,10 +1703,10 @@ export class GitvaultVault {
|
|
|
1703
1703
|
* genesis needs a substituted allocation, which the org's owners can
|
|
1704
1704
|
* see — TOFU + audit, human-envelopes D4's tier);
|
|
1705
1705
|
* 3. the envelope-recipients read says whether THIS fingerprint is
|
|
1706
|
-
* covered — if not, `
|
|
1706
|
+
* covered — if not, `VAULT_ENVELOPE_PENDING` names the key-holders
|
|
1707
1707
|
* who can fulfil and the exact next actions (never a terminal error:
|
|
1708
1708
|
* the desired state already records this member; any key-holder's
|
|
1709
|
-
* next
|
|
1709
|
+
* next vault operation wraps);
|
|
1710
1710
|
* 4. the base envelope is fetched, opened, and the repo file written
|
|
1711
1711
|
* `restored_from_envelope` with the genesis hash PINNED (a later open
|
|
1712
1712
|
* seeing a different genesis for this repo_id refuses
|
|
@@ -1719,7 +1719,7 @@ export class GitvaultVault {
|
|
|
1719
1719
|
* Returns `null` when the repo file already existed (nothing restored).
|
|
1720
1720
|
*/
|
|
1721
1721
|
/**
|
|
1722
|
-
*
|
|
1722
|
+
* vault-multi-writer (rev 47) — the vault's CURRENT admitted writer set
|
|
1723
1723
|
* for a cold open, computed from the chain itself: every admitted head is
|
|
1724
1724
|
* hash-checked against the listing, signature-verified under the writer
|
|
1725
1725
|
* the chain admits at that point, and its `add_writer_key` /
|
|
@@ -1732,9 +1732,9 @@ export class GitvaultVault {
|
|
|
1732
1732
|
let writerState = initialWriterState(this.repoId, genesis);
|
|
1733
1733
|
let prevSha256 = sha256Hex(genesisBytes);
|
|
1734
1734
|
let prevEpoch = genesis.epoch;
|
|
1735
|
-
let expectedGeneration = nextGeneration(
|
|
1736
|
-
let progress = { after_generation:
|
|
1737
|
-
let request = { after_generation:
|
|
1735
|
+
let expectedGeneration = nextGeneration(VAULT_GENESIS_GENERATION);
|
|
1736
|
+
let progress = { after_generation: VAULT_GENESIS_GENERATION, last_generation: VAULT_GENESIS_GENERATION, delivered: 0 };
|
|
1737
|
+
let request = { after_generation: VAULT_GENESIS_GENERATION, limit: String(VAULT_MAX_HEADS_PER_LISTING_PAGE) };
|
|
1738
1738
|
for (;;) {
|
|
1739
1739
|
const page = await this.transport.listHeads({ repo_id: this.repoId, ...request });
|
|
1740
1740
|
progress = verifyHeadsListingPage(page, request, progress, this.repoId);
|
|
@@ -1742,7 +1742,7 @@ export class GitvaultVault {
|
|
|
1742
1742
|
const bytes = await this.readCachedHeadBytes(entry.generation, entry.stored_bytes_sha256);
|
|
1743
1743
|
if (!bytes)
|
|
1744
1744
|
fail("CHAIN_BROKEN", `listed head ${entry.generation} is absent from storage`, "resolving the vault's writer set for a cold open", { generation: entry.generation });
|
|
1745
|
-
const head =
|
|
1745
|
+
const head = parseVaultStrict(new TextDecoder().decode(bytes));
|
|
1746
1746
|
const isWriterAdd = head.transition !== null && head.transition.kind === "add_writer_key";
|
|
1747
1747
|
let addWriterKeyPayload = null;
|
|
1748
1748
|
let signerPubkey;
|
|
@@ -1765,7 +1765,7 @@ export class GitvaultVault {
|
|
|
1765
1765
|
else {
|
|
1766
1766
|
const signer = resolveActiveWriter(writerState, head.writer_key_id);
|
|
1767
1767
|
if (!signer)
|
|
1768
|
-
fail("
|
|
1768
|
+
fail("VAULT_WRITER_NOT_ADMITTED", `head ${head.generation}: writer_key_id ${head.writer_key_id} is not a currently admitted writer`, "resolving the vault's writer set for a cold open", { generation: head.generation });
|
|
1769
1769
|
signerPubkey = signer.signing_pubkey;
|
|
1770
1770
|
signerKeyId = signer.writer_key_id;
|
|
1771
1771
|
}
|
|
@@ -1800,25 +1800,25 @@ export class GitvaultVault {
|
|
|
1800
1800
|
return null;
|
|
1801
1801
|
const identity = this.keystore.readIdentity();
|
|
1802
1802
|
if (!identity) {
|
|
1803
|
-
fail("KEYSTORE_MISSING", "no
|
|
1804
|
-
{ action: "restore ~/.config/run402/
|
|
1805
|
-
...
|
|
1803
|
+
fail("KEYSTORE_MISSING", "no vault identity in the keystore", "opening vault", undefined, [
|
|
1804
|
+
{ action: "restore ~/.config/run402/vault from backup or accept vault loss" },
|
|
1805
|
+
...crossProfileVaultHint(this.repoId),
|
|
1806
1806
|
]);
|
|
1807
1807
|
}
|
|
1808
1808
|
const ownKeypair = this.keystore.encryptionKeypair(identity);
|
|
1809
1809
|
if (!ownKeypair) {
|
|
1810
|
-
fail("VAULT_UNRECOVERABLE",
|
|
1810
|
+
fail("VAULT_UNRECOVERABLE", VAULT_TERMINAL_LOSS_DOCTOR_TEXT, "opening vault", { statement: VAULT_TERMINAL_LOSS_STATEMENT, repo_id: this.repoId });
|
|
1811
1811
|
}
|
|
1812
1812
|
const ownFingerprint = identity.encryption_fingerprint;
|
|
1813
1813
|
const genesisBytes = await this.transport.getGenesis({ repo_id: this.repoId });
|
|
1814
1814
|
if (!genesisBytes)
|
|
1815
|
-
fail("CHAIN_BROKEN", "the vault has no admitted genesis", "opening
|
|
1816
|
-
const genesis =
|
|
1817
|
-
if (genesis.epoch !==
|
|
1818
|
-
fail("VAULT_CREATION_CONFLICT", `the served genesis declares epoch ${genesis.epoch}, not the genesis epoch`, "opening
|
|
1815
|
+
fail("CHAIN_BROKEN", "the vault has no admitted genesis", "opening vault", { repo_id: this.repoId });
|
|
1816
|
+
const genesis = parseVaultStrict(new TextDecoder().decode(genesisBytes));
|
|
1817
|
+
if (genesis.epoch !== VAULT_GENESIS_EPOCH) {
|
|
1818
|
+
fail("VAULT_CREATION_CONFLICT", `the served genesis declares epoch ${genesis.epoch}, not the genesis epoch`, "opening vault", { repo_id: this.repoId, epoch: genesis.epoch });
|
|
1819
1819
|
}
|
|
1820
1820
|
if (genesis.repo_id !== this.repoId) {
|
|
1821
|
-
fail("VAULT_CREATION_CONFLICT", `the served genesis names repo ${genesis.repo_id}, not ${this.repoId}`, "opening
|
|
1821
|
+
fail("VAULT_CREATION_CONFLICT", `the served genesis names repo ${genesis.repo_id}, not ${this.repoId}`, "opening vault", { repo_id: this.repoId, served: genesis.repo_id });
|
|
1822
1822
|
}
|
|
1823
1823
|
// Platform-attested creator anchor. The allocation is optional on the
|
|
1824
1824
|
// wire (older gateways) — absent means the restore is unauthenticated
|
|
@@ -1831,13 +1831,13 @@ export class GitvaultVault {
|
|
|
1831
1831
|
// downgrade to salvage — it is a refusal. A malicious or broken serving
|
|
1832
1832
|
// path that cannot forge the SIGNED allocation must not get a vault
|
|
1833
1833
|
// opened by simply omitting it.
|
|
1834
|
-
fail("
|
|
1834
|
+
fail("VAULT_ALLOCATION_UNATTESTED", "the control plane returned no signed allocation record for this vault — refusing an unattested cold restore (recovery flows may opt into explicit unauthenticated salvage)", "opening vault", { repo_id: this.repoId });
|
|
1835
1835
|
}
|
|
1836
1836
|
if (allocation) {
|
|
1837
1837
|
const genesisEk = ekFingerprint(fromBase64url(genesis.creator_encryption_pubkey, "genesis.creator_encryption_pubkey"));
|
|
1838
1838
|
const genesisVk = vkFingerprint(fromBase64url(genesis.creator_signing_pubkey, "genesis.creator_signing_pubkey"));
|
|
1839
1839
|
if (genesisEk !== allocation.creator_encryption_fingerprint || genesisVk !== allocation.creator_signing_fingerprint) {
|
|
1840
|
-
fail("VAULT_CREATION_CONFLICT", "the served genesis's creator keys do not match the control plane's signed allocation record for this vault — refusing a substituted genesis", "opening
|
|
1840
|
+
fail("VAULT_CREATION_CONFLICT", "the served genesis's creator keys do not match the control plane's signed allocation record for this vault — refusing a substituted genesis", "opening vault", { repo_id: this.repoId, genesis_creator_encryption_fingerprint: genesisEk, allocation_creator_encryption_fingerprint: allocation.creator_encryption_fingerprint, genesis_creator_signing_fingerprint: genesisVk, allocation_creator_signing_fingerprint: allocation.creator_signing_fingerprint });
|
|
1841
1841
|
}
|
|
1842
1842
|
allocationAttested = true;
|
|
1843
1843
|
}
|
|
@@ -1857,7 +1857,7 @@ export class GitvaultVault {
|
|
|
1857
1857
|
// best-effort — the refusal is complete without the roster
|
|
1858
1858
|
}
|
|
1859
1859
|
const desired = coverage.desired?.find((d) => d.ek_fingerprint === ownFingerprint) ?? null;
|
|
1860
|
-
fail("
|
|
1860
|
+
fail("VAULT_ENVELOPE_PENDING", `this keystore (fingerprint ${ownFingerprint}) is not yet a recipient on vault ${this.repoId} — no key_envelope has been wrapped for it; a key-holder's next vault operation fulfils the org's desired state`, "opening vault", {
|
|
1861
1861
|
repo_id: this.repoId,
|
|
1862
1862
|
org_id: genesis.org_id,
|
|
1863
1863
|
own_fingerprint: ownFingerprint,
|
|
@@ -1866,16 +1866,16 @@ export class GitvaultVault {
|
|
|
1866
1866
|
covering_recipient_count: coverage.recipient_fingerprints.length,
|
|
1867
1867
|
}, [
|
|
1868
1868
|
{ action: "run402 repos access", why: "poll — this_keystore.covered_on_this_vault flips true once a key-holder has wrapped this vault to your key" },
|
|
1869
|
-
{ action: "ask a key-holder to run any
|
|
1869
|
+
{ action: "ask a key-holder to run any vault operation (run402 repos view / git push)", why: "a key-holding client wraps every pending desired recipient on its next operation; nothing else is required of them" },
|
|
1870
1870
|
]);
|
|
1871
1871
|
}
|
|
1872
|
-
const envelopeBytes = await this.transport.getObject({ repo_id: this.repoId, path:
|
|
1872
|
+
const envelopeBytes = await this.transport.getObject({ repo_id: this.repoId, path: vaultPaths.envelope(genesis.epoch, ownFingerprint) });
|
|
1873
1873
|
if (!envelopeBytes) {
|
|
1874
|
-
fail("
|
|
1874
|
+
fail("VAULT_EPOCH_NOT_OPENABLE", `the envelope-recipients read lists this keystore (fingerprint ${ownFingerprint}) as covered, but its base key_envelope could not be retrieved — a server-side or network problem, not a problem with your key; retry`, "opening vault", { repo_id: this.repoId, epoch: genesis.epoch, recipient_fingerprint: ownFingerprint, reason: "envelope_fetch_failed" });
|
|
1875
1875
|
}
|
|
1876
|
-
const envelope =
|
|
1876
|
+
const envelope = parseVaultStrict(new TextDecoder().decode(envelopeBytes));
|
|
1877
1877
|
if (envelope.repo_id !== this.repoId || envelope.epoch !== genesis.epoch || envelope.recipient_fingerprint !== ownFingerprint) {
|
|
1878
|
-
fail("
|
|
1878
|
+
fail("VAULT_ENVELOPE_ALTERED", "the retrieved base key_envelope does not address this vault, epoch, and recipient — refusing to open it", "opening vault", { repo_id: this.repoId, epoch: genesis.epoch, recipient_fingerprint: ownFingerprint, envelope: { repo_id: envelope.repo_id, epoch: envelope.epoch, recipient_fingerprint: envelope.recipient_fingerprint } });
|
|
1879
1879
|
}
|
|
1880
1880
|
// rev 47 (writers plural): an envelope wrapped by a non-genesis writer is
|
|
1881
1881
|
// verified under THAT writer's key, which only the chain can vouch for —
|
|
@@ -1898,7 +1898,7 @@ export class GitvaultVault {
|
|
|
1898
1898
|
}
|
|
1899
1899
|
/** {@link open}, but a missing repo file triggers {@link ensureRepoState} first. */
|
|
1900
1900
|
static async openOrRestore(options) {
|
|
1901
|
-
const v = new
|
|
1901
|
+
const v = new Vault(options);
|
|
1902
1902
|
const restored = await v.ensureRepoState();
|
|
1903
1903
|
v.repoFile();
|
|
1904
1904
|
return { vault: v, restored };
|
|
@@ -1906,7 +1906,7 @@ export class GitvaultVault {
|
|
|
1906
1906
|
kRepo() { return hexToBytes(this.repoFile().k_repo_hex); }
|
|
1907
1907
|
epoch() { return this.repoFile().epoch; }
|
|
1908
1908
|
/**
|
|
1909
|
-
*
|
|
1909
|
+
* vault-clone-scaling (P3): staleness of the newest checkpoint coverage
|
|
1910
1910
|
* this checkout has locally learned, measured at `newestGeneration`. Reads
|
|
1911
1911
|
* the keystore AFTER the caller's own persist (a checkpoint-form push has
|
|
1912
1912
|
* already recorded its fresh coverage by the time its result is built), so
|
|
@@ -1914,13 +1914,13 @@ export class GitvaultVault {
|
|
|
1914
1914
|
* the helper; unknown coverage reads as `{0, advised: false}` — silent.
|
|
1915
1915
|
*/
|
|
1916
1916
|
checkpointStalenessNow(newestGeneration) {
|
|
1917
|
-
return
|
|
1917
|
+
return vaultCheckpointStaleness({ newest_generation: newestGeneration, covers_through_generation: this.repoFile().checkpoint_covers_through ?? null });
|
|
1918
1918
|
}
|
|
1919
1919
|
/**
|
|
1920
1920
|
* Fetch many generation-addressed heads' bytes ahead of the ordered walk,
|
|
1921
1921
|
* keyed by head path.
|
|
1922
1922
|
*
|
|
1923
|
-
* BATCH-FIRST (
|
|
1923
|
+
* BATCH-FIRST (vault-batched-head-reads task 4.2): one
|
|
1924
1924
|
* `POST …/head-reads` carries a whole page's bytes. Heads deliberately do
|
|
1925
1925
|
* NOT ride `getObjects` — the `object-reads` presign batch is CARRIER-ONLY
|
|
1926
1926
|
* by wire design (see `getObjectsBytes`'s fail-closed doc comment; the live
|
|
@@ -1928,10 +1928,10 @@ export class GitvaultVault {
|
|
|
1928
1928
|
* by 25 serial singles), which is exactly why the batch route had to be its
|
|
1929
1929
|
* own thing.
|
|
1930
1930
|
*
|
|
1931
|
-
* FALLBACK (the shipped
|
|
1931
|
+
* FALLBACK (the shipped vault-clone-scaling P2 shape): on ANY
|
|
1932
1932
|
* unsupported answer — an older gateway, a refusal, a fault — head bytes
|
|
1933
1933
|
* parallelize as the SAME direct GETs the ordered walk itself would issue,
|
|
1934
|
-
* just early and overlapped at {@link
|
|
1934
|
+
* just early and overlapped at {@link VAULT_TRANSPORT_CONCURRENCY}. The
|
|
1935
1935
|
* transport remembers a route-absent verdict, so the probe is paid once per
|
|
1936
1936
|
* client, not once per window.
|
|
1937
1937
|
*
|
|
@@ -1958,12 +1958,12 @@ export class GitvaultVault {
|
|
|
1958
1958
|
for (let i = 0; i < ascending.length; i++) {
|
|
1959
1959
|
const b = batched[i];
|
|
1960
1960
|
if (b)
|
|
1961
|
-
map.set(
|
|
1961
|
+
map.set(vaultPaths.head(ascending[i]), b);
|
|
1962
1962
|
}
|
|
1963
1963
|
return map;
|
|
1964
1964
|
}
|
|
1965
|
-
const paths = generations.map((g) =>
|
|
1966
|
-
const fetched = await mapBounded(paths,
|
|
1965
|
+
const paths = generations.map((g) => vaultPaths.head(g));
|
|
1966
|
+
const fetched = await mapBounded(paths, VAULT_TRANSPORT_CONCURRENCY, (path) => this.transport.getObject({ repo_id: this.repoId, path }).catch(() => null));
|
|
1967
1967
|
for (let i = 0; i < paths.length; i++) {
|
|
1968
1968
|
const b = fetched[i];
|
|
1969
1969
|
if (b)
|
|
@@ -1973,7 +1973,7 @@ export class GitvaultVault {
|
|
|
1973
1973
|
}
|
|
1974
1974
|
git() {
|
|
1975
1975
|
if (!this.repoDir)
|
|
1976
|
-
fail("
|
|
1976
|
+
fail("VAULT_REPO_DIR_REQUIRED", "this operation needs the local git repository (repo_dir)", "vault publication");
|
|
1977
1977
|
return this.repoDir;
|
|
1978
1978
|
}
|
|
1979
1979
|
/** Fetch + pin-check the genesis (the writer key source). */
|
|
@@ -1992,17 +1992,17 @@ export class GitvaultVault {
|
|
|
1992
1992
|
this.keystore.writeCachedGenesis(this.repoId, repo.genesis_sha256, bytes);
|
|
1993
1993
|
}
|
|
1994
1994
|
if (!bytes)
|
|
1995
|
-
fail("CHAIN_BROKEN", "the vault has no admitted genesis", "reading
|
|
1995
|
+
fail("CHAIN_BROKEN", "the vault has no admitted genesis", "reading vault genesis", { repo_id: this.repoId });
|
|
1996
1996
|
const sha256 = sha256Hex(bytes);
|
|
1997
1997
|
if (sha256 !== repo.genesis_sha256)
|
|
1998
|
-
fail("VAULT_CREATION_CONFLICT", `the admitted genesis (${sha256}) is not the pinned one (${repo.genesis_sha256}); refusing a substituted vault`, "reading
|
|
1999
|
-
const genesis =
|
|
1998
|
+
fail("VAULT_CREATION_CONFLICT", `the admitted genesis (${sha256}) is not the pinned one (${repo.genesis_sha256}); refusing a substituted vault`, "reading vault genesis", { admitted: sha256, pinned: repo.genesis_sha256 });
|
|
1999
|
+
const genesis = parseVaultStrict(new TextDecoder().decode(bytes));
|
|
2000
2000
|
this.genesisCache = { genesis, sha256 };
|
|
2001
2001
|
return this.genesisCache;
|
|
2002
2002
|
}
|
|
2003
2003
|
// ── §6.3/6.4 discovery + verification ──
|
|
2004
2004
|
/**
|
|
2005
|
-
*
|
|
2005
|
+
* vault-composite-state-read design D1 — the pin-current fast path
|
|
2006
2006
|
* `verifyToNewest` tries FIRST: one `GET …/state` in place of BOTH the
|
|
2007
2007
|
* live "server still holds the pin" read {@link readHead} would otherwise
|
|
2008
2008
|
* perform AND, when eligible, the `listHeads` walk that would follow it.
|
|
@@ -2040,12 +2040,12 @@ export class GitvaultVault {
|
|
|
2040
2040
|
* {@link openCarrier} already key their own writes) but NEVER trusted
|
|
2041
2041
|
* here — every reader downstream re-verifies a cache hit against the hash
|
|
2042
2042
|
* it would check network bytes against before using it (this file's own
|
|
2043
|
-
* established cache discipline; see `
|
|
2043
|
+
* established cache discipline; see `VaultKeystore`'s class doc
|
|
2044
2044
|
* comment). A wrong or absent byte this method wrote is therefore just a
|
|
2045
2045
|
* cache MISS on the next read, never a verification bypass.
|
|
2046
2046
|
*/
|
|
2047
2047
|
async tryStateFastPath(pin, deltaSince, restore) {
|
|
2048
|
-
//
|
|
2048
|
+
// vault-delta-fetch: carry the caller's MATERIALIZED position as
|
|
2049
2049
|
// `since` — a capable gateway answers small spans with the heads +
|
|
2050
2050
|
// inline WAL packs in this same response; every other gateway/span
|
|
2051
2051
|
// simply omits `delta`. The RESTORE marker (what this git dir has
|
|
@@ -2055,7 +2055,7 @@ export class GitvaultVault {
|
|
|
2055
2055
|
// that is generations behind — exactly the multi-checkout shape the
|
|
2056
2056
|
// bench runs.
|
|
2057
2057
|
//
|
|
2058
|
-
// `restore` (
|
|
2058
|
+
// `restore` (vault-restore-recipe design D2/D6) rides the SAME state
|
|
2059
2059
|
// read — never a second round trip — so the caller (`restoreObjectsInto`,
|
|
2060
2060
|
// via `materialize`/`verifyToNewest`) declares it only on the wholesale
|
|
2061
2061
|
// shape (see that method's own doc comment). This call declining the
|
|
@@ -2067,7 +2067,7 @@ export class GitvaultVault {
|
|
|
2067
2067
|
// authenticated pin — checked here regardless of eligibility below, so
|
|
2068
2068
|
// a regressed vault is caught exactly as loudly as it always was, even
|
|
2069
2069
|
// when the walk that would otherwise discover it is about to be skipped.
|
|
2070
|
-
checkGenerationRegression(state.newest_generation ??
|
|
2070
|
+
checkGenerationRegression(state.newest_generation ?? VAULT_GENESIS_GENERATION, pin.generation);
|
|
2071
2071
|
// The gateway's own admission ledger advances `newest_generation` to the
|
|
2072
2072
|
// GENESIS generation (0) the moment genesis itself is admitted — a vault
|
|
2073
2073
|
// between "genesis admitted" and "first ordinary push" reports its own
|
|
@@ -2077,55 +2077,55 @@ export class GitvaultVault {
|
|
|
2077
2077
|
// way, "no ORDINARY head yet" is the same case this file has always
|
|
2078
2078
|
// treated specially: genesis is a DIFFERENT stored-object shape
|
|
2079
2079
|
// (`vault_genesis` — no `ref_state`/`retention_roots`, {@link
|
|
2080
|
-
//
|
|
2081
|
-
// path must never parse `state.head` as a {@link
|
|
2080
|
+
// Vault.genesis} owns verifying it via its OWN cache), so this
|
|
2081
|
+
// path must never parse `state.head` as a {@link VaultHead} when it
|
|
2082
2082
|
// is actually genesis's bytes.
|
|
2083
2083
|
if (state.delta)
|
|
2084
2084
|
this.consumeStateDelta(state.delta);
|
|
2085
|
-
//
|
|
2085
|
+
// vault-restore-recipe: stashed RAW (never verified here — see the
|
|
2086
2086
|
// field's own doc comment); a genesis-only response (below) still runs
|
|
2087
2087
|
// this line first, but `assembleVaultRestorePlan` never fires server-side
|
|
2088
2088
|
// before an ordinary head exists, so `state.restore_plan` is simply
|
|
2089
2089
|
// absent in that window — nothing to stash either way.
|
|
2090
2090
|
if (state.restore_plan)
|
|
2091
2091
|
this.stateRestorePlan = state.restore_plan;
|
|
2092
|
-
const noOrdinaryHeadYet = state.newest_generation === null || state.newest_generation ===
|
|
2092
|
+
const noOrdinaryHeadYet = state.newest_generation === null || state.newest_generation === VAULT_GENESIS_GENERATION;
|
|
2093
2093
|
const pinBig = generationToBigInt(pin.generation);
|
|
2094
2094
|
const newestBig = noOrdinaryHeadYet ? 0n : generationToBigInt(state.newest_generation);
|
|
2095
2095
|
const diff = newestBig - pinBig; // ≥ 0n, guaranteed by the regression check above (which treats `null`/genesis identically to this)
|
|
2096
2096
|
if (diff === 0n) {
|
|
2097
2097
|
if (noOrdinaryHeadYet)
|
|
2098
|
-
return { entries: [], pinnedHead: null, prevEpoch:
|
|
2098
|
+
return { entries: [], pinnedHead: null, prevEpoch: VAULT_GENESIS_EPOCH }; // matches today's `lastHead = null` for a genesis-only vault
|
|
2099
2099
|
if (!state.head)
|
|
2100
|
-
fail("CHAIN_BROKEN", "the vault state reports an admitted generation but carries no head bytes", "verifying
|
|
2100
|
+
fail("CHAIN_BROKEN", "the vault state reports an admitted generation but carries no head bytes", "verifying vault chain", { generation: state.newest_generation });
|
|
2101
2101
|
const sha = sha256Hex(state.head.stored_bytes);
|
|
2102
2102
|
if (sha !== pin.head_sha256)
|
|
2103
|
-
fail("CHAIN_BROKEN", `pinned head ${pin.generation} no longer hashes to the pin`, "reading pinned
|
|
2103
|
+
fail("CHAIN_BROKEN", `pinned head ${pin.generation} no longer hashes to the pin`, "reading pinned vault head", { generation: pin.generation });
|
|
2104
2104
|
this.keystore.writeCachedHead(this.repoId, pin.generation, sha, state.head.stored_bytes);
|
|
2105
|
-
const head =
|
|
2105
|
+
const head = parseVaultStrict(new TextDecoder().decode(state.head.stored_bytes));
|
|
2106
2106
|
if (state.carriers)
|
|
2107
2107
|
this.warmStateCarrierCache(pin.generation, head, state.carriers);
|
|
2108
2108
|
return { entries: [], pinnedHead: head, prevEpoch: head.epoch };
|
|
2109
2109
|
}
|
|
2110
2110
|
if (diff === 1n) {
|
|
2111
2111
|
let prevEpoch;
|
|
2112
|
-
if (pin.generation ===
|
|
2113
|
-
prevEpoch =
|
|
2112
|
+
if (pin.generation === VAULT_GENESIS_GENERATION) {
|
|
2113
|
+
prevEpoch = VAULT_GENESIS_EPOCH;
|
|
2114
2114
|
}
|
|
2115
2115
|
else {
|
|
2116
2116
|
const cached = this.keystore.readCachedHead(this.repoId, pin.generation);
|
|
2117
2117
|
if (!cached || sha256Hex(cached.bytes) !== pin.head_sha256)
|
|
2118
2118
|
return null; // cold cache — decline the shortcut, never weaken it
|
|
2119
|
-
prevEpoch =
|
|
2119
|
+
prevEpoch = parseVaultStrict(new TextDecoder().decode(cached.bytes)).epoch;
|
|
2120
2120
|
}
|
|
2121
2121
|
if (!state.head)
|
|
2122
|
-
fail("CHAIN_BROKEN", "the vault state reports a newer generation but carries no head bytes", "verifying
|
|
2122
|
+
fail("CHAIN_BROKEN", "the vault state reports a newer generation but carries no head bytes", "verifying vault chain", { generation: state.newest_generation });
|
|
2123
2123
|
const newestGeneration = state.newest_generation;
|
|
2124
2124
|
const shaOfBytes = sha256Hex(state.head.stored_bytes);
|
|
2125
2125
|
if (shaOfBytes !== state.head.stored_bytes_sha256)
|
|
2126
2126
|
fail("CHAIN_BROKEN", `head ${newestGeneration}: stored bytes hash does not match its own declared hash`, "verifying head chain", { generation: newestGeneration });
|
|
2127
2127
|
this.keystore.writeCachedHead(this.repoId, newestGeneration, shaOfBytes, state.head.stored_bytes);
|
|
2128
|
-
const head =
|
|
2128
|
+
const head = parseVaultStrict(new TextDecoder().decode(state.head.stored_bytes));
|
|
2129
2129
|
if (state.carriers)
|
|
2130
2130
|
this.warmStateCarrierCache(newestGeneration, head, state.carriers);
|
|
2131
2131
|
// `pinnedHead` is a throwaway — see this method's own doc comment: the
|
|
@@ -2136,7 +2136,7 @@ export class GitvaultVault {
|
|
|
2136
2136
|
return null; // more than one generation behind — the existing listHeads walk owns this
|
|
2137
2137
|
}
|
|
2138
2138
|
/**
|
|
2139
|
-
* Absorb a state read's delta (
|
|
2139
|
+
* Absorb a state read's delta (vault-delta-fetch): heads blind-warm the
|
|
2140
2140
|
* SAME keystore head cache every walk already re-verifies on read (the
|
|
2141
2141
|
* established cache discipline — a wrong byte is a miss, never a bypass),
|
|
2142
2142
|
* gated only on each entry's self-consistency; packs stash into the
|
|
@@ -2199,7 +2199,7 @@ export class GitvaultVault {
|
|
|
2199
2199
|
* producer/consumer would, and counting each decrypt attempt as an EXTRA
|
|
2200
2200
|
* unit against the same `this.budget` (decryption is the expensive step).
|
|
2201
2201
|
* `options.strict` (default `true`) throws immediately, fail-closed, on the
|
|
2202
|
-
* first `
|
|
2202
|
+
* first `VAULT_EPOCH_NOT_OPENABLE` / AEAD failure it hits — the ordinary
|
|
2203
2203
|
* `materialize()` read path's behavior. `strict: false` (fsck's tolerant
|
|
2204
2204
|
* mode) instead records `decrypt.failure` and stops attempting further
|
|
2205
2205
|
* decrypts while the pure chain walk keeps going — this is exactly what
|
|
@@ -2211,7 +2211,7 @@ export class GitvaultVault {
|
|
|
2211
2211
|
const decryptValidate = options.decryptValidate ?? false;
|
|
2212
2212
|
const strict = options.strict ?? true;
|
|
2213
2213
|
const { genesis, sha256: genesisSha } = await this.genesis();
|
|
2214
|
-
//
|
|
2214
|
+
// vault-multi-writer rev 47: `writerKey`/`writerKeyId` (kept for the
|
|
2215
2215
|
// rotation-envelope open below, which is signed by whoever DROVE that
|
|
2216
2216
|
// specific rotation — resolved per-rotation from `writerState`, not this
|
|
2217
2217
|
// fallback) name just the genesis creator, retained as the seed for
|
|
@@ -2220,12 +2220,12 @@ export class GitvaultVault {
|
|
|
2220
2220
|
const writerKey = genesis.creator_signing_pubkey;
|
|
2221
2221
|
const writerKeyId = genesis.writer_key_id;
|
|
2222
2222
|
const repo = this.repoFile();
|
|
2223
|
-
let pin = repo.verified_prefix ?? repo.head_pin ?? { generation:
|
|
2224
|
-
//
|
|
2223
|
+
let pin = repo.verified_prefix ?? repo.head_pin ?? { generation: VAULT_GENESIS_GENERATION, head_sha256: genesisSha, pinned_at: formatVaultTimestamp(this.now()) };
|
|
2224
|
+
// vault-multi-writer rev 47 (task 5.1): the writer set as chain state.
|
|
2225
2225
|
// Trusted as the starting point at `pin`'s generation exactly the same
|
|
2226
2226
|
// way `pin` itself is trusted as the starting point for the head chain —
|
|
2227
2227
|
// `writer_set_pin` advances in LOCKSTEP with `head_pin`/`verified_prefix`
|
|
2228
|
-
// (see `
|
|
2228
|
+
// (see `VaultRepoFile.writer_set_pin`'s own doc comment), so a repeat
|
|
2229
2229
|
// call never re-walks the writer set from genesis. `initialWriterState`
|
|
2230
2230
|
// covers the true first-ever call (no persisted pin yet).
|
|
2231
2231
|
//
|
|
@@ -2251,38 +2251,38 @@ export class GitvaultVault {
|
|
|
2251
2251
|
// the complete bookkeeping.
|
|
2252
2252
|
const legacyWriterPin = repo.writer_set_pin != null && repo.writer_set_pin.retired_writers === undefined;
|
|
2253
2253
|
if (legacyWriterPin)
|
|
2254
|
-
pin = { generation:
|
|
2254
|
+
pin = { generation: VAULT_GENESIS_GENERATION, head_sha256: genesisSha, pinned_at: formatVaultTimestamp(this.now()) };
|
|
2255
2255
|
let writerState = repo.writer_set_pin && !legacyWriterPin
|
|
2256
2256
|
? { version: repo.writer_set_pin.version, writers: repo.writer_set_pin.writers, sha256: repo.writer_set_pin.sha256, burnedWriterKeyIds: new Set(), consumedHandoffIds: new Set(), retiredWriters: repo.writer_set_pin.retired_writers ?? [] }
|
|
2257
2257
|
: initialWriterState(this.repoId, genesis);
|
|
2258
2258
|
const persistWriterSetPin = () => {
|
|
2259
2259
|
if (!persist)
|
|
2260
2260
|
return;
|
|
2261
|
-
this.keystore.updateRepo(this.repoId, { writer_set_pin: { version: writerState.version, sha256: writerState.sha256, writers: [...writerState.writers], retired_writers: [...(writerState.retiredWriters ?? [])], pinned_at:
|
|
2261
|
+
this.keystore.updateRepo(this.repoId, { writer_set_pin: { version: writerState.version, sha256: writerState.sha256, writers: [...writerState.writers], retired_writers: [...(writerState.retiredWriters ?? [])], pinned_at: formatVaultTimestamp(this.now()) } });
|
|
2262
2262
|
};
|
|
2263
|
-
//
|
|
2263
|
+
// vault-composite-state-read design D1: try the pin-current fast path
|
|
2264
2264
|
// FIRST — see {@link tryStateFastPath}'s own doc comment. `null` means
|
|
2265
2265
|
// ineligible (genuinely more than one generation behind, or the pin's
|
|
2266
2266
|
// own epoch could not be resolved without the network read this path
|
|
2267
2267
|
// exists to avoid) — the caller falls straight through to the UNCHANGED
|
|
2268
2268
|
// readHead + listHeads flow below, byte-identical to before this change.
|
|
2269
|
-
// `options.restore` (
|
|
2269
|
+
// `options.restore` (vault-restore-recipe) rides along unchanged —
|
|
2270
2270
|
// declined exactly when the fast path itself is declined, so a caller
|
|
2271
2271
|
// more than one generation behind never gets (or needs) a plan here.
|
|
2272
2272
|
const fastPath = await this.tryStateFastPath(pin, options.deltaSince, options.restore);
|
|
2273
|
-
let lastHead = fastPath ? fastPath.pinnedHead : pin.generation ===
|
|
2273
|
+
let lastHead = fastPath ? fastPath.pinnedHead : pin.generation === VAULT_GENESIS_GENERATION ? null : await this.readHead(pin.generation, pin.head_sha256);
|
|
2274
2274
|
const anchor = pin.generation;
|
|
2275
|
-
let prevEpoch = fastPath ? fastPath.prevEpoch : (lastHead?.epoch ??
|
|
2275
|
+
let prevEpoch = fastPath ? fastPath.prevEpoch : (lastHead?.epoch ?? VAULT_GENESIS_EPOCH);
|
|
2276
2276
|
let progress = { after_generation: anchor, last_generation: anchor, delivered: 0 };
|
|
2277
|
-
let request = { after_generation: anchor, limit: String(
|
|
2277
|
+
let request = { after_generation: anchor, limit: String(VAULT_MAX_HEADS_PER_LISTING_PAGE) };
|
|
2278
2278
|
let verified = 0;
|
|
2279
2279
|
const rotations = [];
|
|
2280
|
-
//
|
|
2280
|
+
// vault-clone-scaling (P3): coverage this walk LEARNS. A checkpoint
|
|
2281
2281
|
// head names it outright; a walk anchored at GENESIS that completes
|
|
2282
2282
|
// without seeing one proves coverage = genesis. A partial (non-genesis)
|
|
2283
2283
|
// walk that sees none proves nothing and persists nothing.
|
|
2284
2284
|
let walkCheckpointCoverage = null;
|
|
2285
|
-
const walkedFromGenesis = anchor ===
|
|
2285
|
+
const walkedFromGenesis = anchor === VAULT_GENESIS_GENERATION;
|
|
2286
2286
|
// Consumed by (at most) the FIRST for(;;) iteration below — a listHeads
|
|
2287
2287
|
// page this call never had to ask the network for, because the state
|
|
2288
2288
|
// read above already proved it (0 entries: pin already current; 1 entry:
|
|
@@ -2335,7 +2335,7 @@ export class GitvaultVault {
|
|
|
2335
2335
|
continue;
|
|
2336
2336
|
current = { generation: rot.generation, epoch: rot.epoch, rotation_id: rot.payload.rotation_id };
|
|
2337
2337
|
if (!identity || !ownKeypair) {
|
|
2338
|
-
fail("
|
|
2338
|
+
fail("VAULT_EPOCH_NOT_OPENABLE", `no local vault identity — cannot open epoch ${rot.epoch} (rotation ${rot.payload.rotation_id})`, "opening an epoch-rotation key envelope", { epoch: rot.epoch, rotation_id: rot.payload.rotation_id });
|
|
2339
2339
|
}
|
|
2340
2340
|
const ownFingerprint = ekFingerprint(ownKeypair.public_key);
|
|
2341
2341
|
const kE = await openEpochRotationForRecipient({
|
|
@@ -2343,13 +2343,13 @@ export class GitvaultVault {
|
|
|
2343
2343
|
payload: rot.payload,
|
|
2344
2344
|
own_fingerprint: ownFingerprint,
|
|
2345
2345
|
own_encryption_keypair: ownKeypair,
|
|
2346
|
-
//
|
|
2346
|
+
// vault-multi-writer rev 47: the writer who actually drove
|
|
2347
2347
|
// THIS rotation (resolved by the forward walk when it collected
|
|
2348
|
-
// `rot` — see `
|
|
2348
|
+
// `rot` — see `VaultEncounteredRotation.signing_pubkey`'s own
|
|
2349
2349
|
// doc comment for the one narrower gap this falls back for).
|
|
2350
2350
|
writer_signing_public_key: rot.signing_pubkey ?? writerKey,
|
|
2351
2351
|
get_envelope_bytes: (path) => this.transport.getObject({ repo_id: this.repoId, path }),
|
|
2352
|
-
envelope_path: (epoch, fp, rotationId) =>
|
|
2352
|
+
envelope_path: (epoch, fp, rotationId) => vaultPaths.envelope(epoch, fp, rotationId),
|
|
2353
2353
|
});
|
|
2354
2354
|
epochKeys[rot.epoch] = bytesToHex(kE);
|
|
2355
2355
|
if (persist)
|
|
@@ -2358,29 +2358,29 @@ export class GitvaultVault {
|
|
|
2358
2358
|
current = { generation: head.generation, epoch: head.epoch, rotation_id: null };
|
|
2359
2359
|
const kRepoHex = epochKeys[head.epoch];
|
|
2360
2360
|
if (!kRepoHex) {
|
|
2361
|
-
fail("
|
|
2361
|
+
fail("VAULT_EPOCH_NOT_OPENABLE", `no locally known key for epoch ${head.epoch} at generation ${head.generation}`, "materializing vault head", { epoch: head.epoch, generation: head.generation });
|
|
2362
2362
|
}
|
|
2363
2363
|
const kRepo = hexToBytes(kRepoHex);
|
|
2364
2364
|
// Design D2: ref_state + retention_roots both ride `head`, so one
|
|
2365
2365
|
// batched presign (or a cache hit) serves both instead of two
|
|
2366
2366
|
// independent presign-then-GET round trips.
|
|
2367
|
-
//
|
|
2367
|
+
// vault-multi-writer rev 47: `head`'s OWN signer, resolved from
|
|
2368
2368
|
// the writer state — by the time this runs, `writerState` already
|
|
2369
2369
|
// reflects `head`'s own transition (if any), so this correctly finds
|
|
2370
2370
|
// a just-self-admitted handoff writer too, not only a pre-existing one.
|
|
2371
2371
|
// Historical read: the head is already admitted, so a writer removed
|
|
2372
2372
|
// SINCE it was signed still verifies it (retired keys are kept).
|
|
2373
2373
|
const headSigner = resolveKnownWriter(writerState, head.writer_key_id)?.signing_pubkey ?? writerKey;
|
|
2374
|
-
const { refState, roots } = await this.openMaterializeCarriers(head.ref_state,
|
|
2374
|
+
const { refState, roots } = await this.openMaterializeCarriers(head.ref_state, vaultPaths.refState(head.ref_state.object_id), head.retention_roots, vaultPaths.retentionRoots(head.retention_roots.object_id), headSigner, { epoch: head.epoch, k_repo: kRepo });
|
|
2375
2375
|
if (refState.generation !== head.generation || roots.generation !== head.generation)
|
|
2376
|
-
fail("CHAIN_UNUSABLE", "carrier generation does not match the head", "materializing
|
|
2376
|
+
fail("CHAIN_UNUSABLE", "carrier generation does not match the head", "materializing vault head");
|
|
2377
2377
|
decryptedRefState = refState;
|
|
2378
2378
|
decryptedRoots = roots;
|
|
2379
2379
|
decryptPin = { ...headPin };
|
|
2380
2380
|
return true;
|
|
2381
2381
|
}
|
|
2382
2382
|
catch (e) {
|
|
2383
|
-
const code = isRun402Error(e) && e.code ? e.code : "
|
|
2383
|
+
const code = isRun402Error(e) && e.code ? e.code : "VAULT_EPOCH_NOT_OPENABLE";
|
|
2384
2384
|
decryptFailure = { generation: current.generation, epoch: current.epoch, rotation_id: current.rotation_id, code, message: e instanceof Error ? e.message : String(e) };
|
|
2385
2385
|
decryptFailureError = e;
|
|
2386
2386
|
return false;
|
|
@@ -2392,11 +2392,11 @@ export class GitvaultVault {
|
|
|
2392
2392
|
// the loop right after it is consumed, so clearing it here is purely
|
|
2393
2393
|
// defensive (a real second iteration can never see it non-null).
|
|
2394
2394
|
const page = syntheticEntries !== null
|
|
2395
|
-
? { format:
|
|
2395
|
+
? { format: VAULT_FORMAT, repo_id: this.repoId, after_generation: request.after_generation, heads: syntheticEntries, has_more: false, next_cursor: null, total: null }
|
|
2396
2396
|
: await this.transport.listHeads({ repo_id: this.repoId, ...request });
|
|
2397
2397
|
syntheticEntries = null;
|
|
2398
2398
|
progress = verifyHeadsListingPage(page, request, progress, this.repoId);
|
|
2399
|
-
//
|
|
2399
|
+
// vault-clone-scaling (bench P2): the page just verified names every
|
|
2400
2400
|
// entry's generation + stored_bytes_sha256, and the BYTES reads are
|
|
2401
2401
|
// independent — verification (and decryption) order is a LOCAL
|
|
2402
2402
|
// obligation. Bounded by the remaining verification budget, this
|
|
@@ -2428,7 +2428,7 @@ export class GitvaultVault {
|
|
|
2428
2428
|
if (missingHeads.length > 1) {
|
|
2429
2429
|
const fetched = await this.prefetchHeadsConcurrent(missingHeads.map((e) => e.generation));
|
|
2430
2430
|
for (const e of missingHeads) {
|
|
2431
|
-
const path =
|
|
2431
|
+
const path = vaultPaths.head(e.generation);
|
|
2432
2432
|
const bytes = fetched.get(path) ?? null;
|
|
2433
2433
|
if (bytes && sha256Hex(bytes) === e.stored_bytes_sha256)
|
|
2434
2434
|
prefetch.set(path, bytes);
|
|
@@ -2437,19 +2437,19 @@ export class GitvaultVault {
|
|
|
2437
2437
|
if (decryptValidate && wanted.length > 1) {
|
|
2438
2438
|
const carriers = [];
|
|
2439
2439
|
for (const e of wanted) {
|
|
2440
|
-
const headBytes = prefetch.get(
|
|
2440
|
+
const headBytes = prefetch.get(vaultPaths.head(e.generation)) ?? cachedHeadIfMatching(e);
|
|
2441
2441
|
if (!headBytes)
|
|
2442
2442
|
continue;
|
|
2443
2443
|
let parsed;
|
|
2444
2444
|
try {
|
|
2445
|
-
parsed =
|
|
2445
|
+
parsed = parseVaultStrict(new TextDecoder().decode(headBytes));
|
|
2446
2446
|
}
|
|
2447
2447
|
catch {
|
|
2448
2448
|
continue; // the ordered loop's checkChainLink owns rejecting it
|
|
2449
2449
|
}
|
|
2450
2450
|
for (const w of [
|
|
2451
|
-
{ receipt: parsed.ref_state, path:
|
|
2452
|
-
{ receipt: parsed.retention_roots, path:
|
|
2451
|
+
{ receipt: parsed.ref_state, path: vaultPaths.refState(parsed.ref_state.object_id) },
|
|
2452
|
+
{ receipt: parsed.retention_roots, path: vaultPaths.retentionRoots(parsed.retention_roots.object_id) },
|
|
2453
2453
|
]) {
|
|
2454
2454
|
const cached = this.keystore.readCachedCarrier(this.repoId, w.receipt.object_id);
|
|
2455
2455
|
if (cached && sha256Hex(cached.bytes) === w.receipt.ciphertext_sha256)
|
|
@@ -2482,13 +2482,13 @@ export class GitvaultVault {
|
|
|
2482
2482
|
persistMaterializedIfAny();
|
|
2483
2483
|
fail("VERIFICATION_BUDGET_EXCEEDED", persist
|
|
2484
2484
|
? `${verified} heads verified this call; the verified prefix (generation ${pin.generation}) is persisted — call again to continue`
|
|
2485
|
-
: `${verified} heads verified this call in no-write mode; nothing was persisted — a retry restarts from the original pin, not generation ${pin.generation}`, "verifying
|
|
2485
|
+
: `${verified} heads verified this call in no-write mode; nothing was persisted — a retry restarts from the original pin, not generation ${pin.generation}`, "verifying vault chain", { verified_through: pin.generation, persisted: persist }, [{ action: persist ? "resume verification from the persisted verified prefix" : "re-run without --no-write to persist and resume incrementally, or re-run this same audit call again from the start" }]);
|
|
2486
2486
|
}
|
|
2487
2487
|
const bytes = await this.readCachedHeadBytes(entry.generation, entry.stored_bytes_sha256);
|
|
2488
2488
|
if (!bytes)
|
|
2489
|
-
fail("CHAIN_BROKEN", `listed head ${entry.generation} is absent from storage`, "verifying
|
|
2490
|
-
const head =
|
|
2491
|
-
//
|
|
2489
|
+
fail("CHAIN_BROKEN", `listed head ${entry.generation} is absent from storage`, "verifying vault chain", { generation: entry.generation });
|
|
2490
|
+
const head = parseVaultStrict(new TextDecoder().decode(bytes));
|
|
2491
|
+
// vault-multi-writer rev 47 (task 5.1): resolve WHO must have
|
|
2492
2492
|
// signed this specific head from the writer state as of the
|
|
2493
2493
|
// PREDECESSOR generation (`writerState`, not yet advanced past this
|
|
2494
2494
|
// head) — never the single fixed genesis key. A head carrying an
|
|
@@ -2504,7 +2504,7 @@ export class GitvaultVault {
|
|
|
2504
2504
|
this.keystore.updateRepo(this.repoId, { head_pin: pin, verified_prefix: null });
|
|
2505
2505
|
persistWriterSetPin();
|
|
2506
2506
|
persistMaterializedIfAny();
|
|
2507
|
-
fail(code, `head ${head.generation}: ${detail}`, "verifying
|
|
2507
|
+
fail(code, `head ${head.generation}: ${detail}`, "verifying vault chain", { generation: head.generation });
|
|
2508
2508
|
};
|
|
2509
2509
|
const isWriterAdd = head.transition !== null && head.transition.kind === "add_writer_key";
|
|
2510
2510
|
let addWriterKeyPayload = null;
|
|
@@ -2530,7 +2530,7 @@ export class GitvaultVault {
|
|
|
2530
2530
|
else {
|
|
2531
2531
|
const signer = resolveActiveWriter(writerState, head.writer_key_id);
|
|
2532
2532
|
if (!signer)
|
|
2533
|
-
failClosedTransition("
|
|
2533
|
+
failClosedTransition("VAULT_WRITER_NOT_ADMITTED", `writer_key_id ${head.writer_key_id} is not a currently admitted writer`);
|
|
2534
2534
|
resolvedWriterPubkey = signer.signing_pubkey;
|
|
2535
2535
|
resolvedWriterKeyId = signer.writer_key_id;
|
|
2536
2536
|
}
|
|
@@ -2556,7 +2556,7 @@ export class GitvaultVault {
|
|
|
2556
2556
|
// can still resolve the epoch keys it needs.
|
|
2557
2557
|
rotationPayload = parseRotateEpochPayload(head);
|
|
2558
2558
|
rotations.push({ generation: head.generation, epoch: head.epoch, payload: rotationPayload, signing_pubkey: resolvedWriterPubkey });
|
|
2559
|
-
//
|
|
2559
|
+
// vault-multi-writer rev 47 (D227): writer REMOVAL rides
|
|
2560
2560
|
// rotate_epoch as an additive `writer_set_update` field. The
|
|
2561
2561
|
// "blocked set" gate inside `validateWriterSetUpdate` is a
|
|
2562
2562
|
// gateway-only, live-state fact this reader cannot independently
|
|
@@ -2584,7 +2584,7 @@ export class GitvaultVault {
|
|
|
2584
2584
|
writerState = applyAddWriterKey(this.repoId, writerState, { writer_key_id: addWriterKeyPayload.added_writer.writer_key_id, signing_pubkey: addWriterKeyPayload.added_writer.signing_pubkey }, consumedHandoffId);
|
|
2585
2585
|
}
|
|
2586
2586
|
prevEpoch = head.epoch;
|
|
2587
|
-
pin = { generation: head.generation, head_sha256: entry.stored_bytes_sha256, pinned_at:
|
|
2587
|
+
pin = { generation: head.generation, head_sha256: entry.stored_bytes_sha256, pinned_at: formatVaultTimestamp(this.now()) };
|
|
2588
2588
|
lastHead = head;
|
|
2589
2589
|
if (head.checkpoint)
|
|
2590
2590
|
walkCheckpointCoverage = head.checkpoint.covers_through_generation;
|
|
@@ -2615,7 +2615,7 @@ export class GitvaultVault {
|
|
|
2615
2615
|
// is authoritative for its whole history, so no checkpoint seen means
|
|
2616
2616
|
// coverage = genesis, honestly.
|
|
2617
2617
|
if (persist) {
|
|
2618
|
-
const learned = walkCheckpointCoverage ?? (walkedFromGenesis ?
|
|
2618
|
+
const learned = walkCheckpointCoverage ?? (walkedFromGenesis ? VAULT_GENESIS_GENERATION : null);
|
|
2619
2619
|
if (learned !== null)
|
|
2620
2620
|
this.keystore.updateRepo(this.repoId, { checkpoint_covers_through: learned });
|
|
2621
2621
|
}
|
|
@@ -2639,7 +2639,7 @@ export class GitvaultVault {
|
|
|
2639
2639
|
while (cur && !epochKeys[cur.epoch]) {
|
|
2640
2640
|
if (cur.transition !== null && cur.transition.kind === "rotate_epoch") {
|
|
2641
2641
|
const payload = parseRotateEpochPayload(cur);
|
|
2642
|
-
//
|
|
2642
|
+
// vault-multi-writer rev 47: best-effort resolution against the
|
|
2643
2643
|
// FINAL (post-forward-walk) writer state — correct unless `cur`'s
|
|
2644
2644
|
// own signer was removed by a LATER rotation between `cur` and
|
|
2645
2645
|
// `lastHead`, the one documented residual gap (falls back to the
|
|
@@ -2661,9 +2661,9 @@ export class GitvaultVault {
|
|
|
2661
2661
|
// {@link readCachedHeadBytes} documents, so a cache hit is safe here too.
|
|
2662
2662
|
const bytes = await this.readCachedHeadBytes(prevGen, cur.prev_sha256);
|
|
2663
2663
|
if (!bytes || sha256Hex(bytes) !== cur.prev_sha256)
|
|
2664
|
-
fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during epoch-key catch-up`, "materializing
|
|
2665
|
-
cur =
|
|
2666
|
-
curPin = { generation: prevGen, head_sha256: bytes ? sha256Hex(bytes) : curPin.head_sha256, pinned_at:
|
|
2664
|
+
fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during epoch-key catch-up`, "materializing vault head", { generation: prevGen });
|
|
2665
|
+
cur = parseVaultStrict(new TextDecoder().decode(bytes));
|
|
2666
|
+
curPin = { generation: prevGen, head_sha256: bytes ? sha256Hex(bytes) : curPin.head_sha256, pinned_at: formatVaultTimestamp(this.now()) };
|
|
2667
2667
|
}
|
|
2668
2668
|
rotations.sort((a, b) => (a.generation < b.generation ? -1 : a.generation > b.generation ? 1 : 0));
|
|
2669
2669
|
// `cur` (non-null, non-lastHead) is the BOUNDARY generation whose own
|
|
@@ -2698,7 +2698,7 @@ export class GitvaultVault {
|
|
|
2698
2698
|
rotations,
|
|
2699
2699
|
decrypt: decryptValidate
|
|
2700
2700
|
? {
|
|
2701
|
-
decryptable_to_generation: decryptPin?.generation ??
|
|
2701
|
+
decryptable_to_generation: decryptPin?.generation ?? VAULT_GENESIS_GENERATION,
|
|
2702
2702
|
ref_state: decryptedRefState,
|
|
2703
2703
|
retention_roots: decryptedRoots,
|
|
2704
2704
|
epoch_keys_hex: epochKeys,
|
|
@@ -2728,10 +2728,10 @@ export class GitvaultVault {
|
|
|
2728
2728
|
const cached = this.keystore.readCachedHead(this.repoId, generation);
|
|
2729
2729
|
if (cached && sha256Hex(cached.bytes) === expectedSha256)
|
|
2730
2730
|
return cached.bytes;
|
|
2731
|
-
//
|
|
2731
|
+
// vault-clone-scaling (P2): a page-prefetched head serves exactly as
|
|
2732
2732
|
// a network fetch would — sha-checked here against the SAME expected
|
|
2733
2733
|
// value, then cache-warmed. A miss/mismatch falls through to the read.
|
|
2734
|
-
const path =
|
|
2734
|
+
const path = vaultPaths.head(generation);
|
|
2735
2735
|
const prefetched = this.walkPrefetch?.get(path);
|
|
2736
2736
|
if (prefetched && sha256Hex(prefetched) === expectedSha256) {
|
|
2737
2737
|
this.keystore.writeCachedHead(this.repoId, generation, expectedSha256, prefetched);
|
|
@@ -2752,18 +2752,18 @@ export class GitvaultVault {
|
|
|
2752
2752
|
* THIS call's own read is exempt.
|
|
2753
2753
|
*/
|
|
2754
2754
|
async readHead(generation, expectedSha256) {
|
|
2755
|
-
const bytes = await this.transport.getObject({ repo_id: this.repoId, path:
|
|
2755
|
+
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: vaultPaths.head(generation) });
|
|
2756
2756
|
// Absent ⇒ the vault no longer holds a generation this client authenticated: a ROLLBACK, not a
|
|
2757
2757
|
// broken link (chain-005). Present-but-different bytes IS a broken link (substituted object).
|
|
2758
2758
|
if (!bytes) {
|
|
2759
|
-
fail("GENERATION_REGRESSION", `the vault no longer holds the authenticated generation ${generation}; it regressed below the pin`, "reading pinned
|
|
2759
|
+
fail("GENERATION_REGRESSION", `the vault no longer holds the authenticated generation ${generation}; it regressed below the pin`, "reading pinned vault head", { generation }, [
|
|
2760
2760
|
{ action: "do not publish; escalate — the vault regressed below a generation this client authenticated" },
|
|
2761
2761
|
]);
|
|
2762
2762
|
}
|
|
2763
2763
|
if (sha256Hex(bytes) !== expectedSha256)
|
|
2764
|
-
fail("CHAIN_BROKEN", `pinned head ${generation} no longer hashes to the pin`, "reading pinned
|
|
2764
|
+
fail("CHAIN_BROKEN", `pinned head ${generation} no longer hashes to the pin`, "reading pinned vault head", { generation });
|
|
2765
2765
|
this.keystore.writeCachedHead(this.repoId, generation, expectedSha256, bytes);
|
|
2766
|
-
return
|
|
2766
|
+
return parseVaultStrict(new TextDecoder().decode(bytes));
|
|
2767
2767
|
}
|
|
2768
2768
|
/**
|
|
2769
2769
|
* Decrypt + verify one carrier's already-fetched ciphertext frame; any
|
|
@@ -2782,17 +2782,17 @@ export class GitvaultVault {
|
|
|
2782
2782
|
const epoch = keyOverride?.epoch ?? this.epoch();
|
|
2783
2783
|
const kRepo = keyOverride?.k_repo ?? this.kRepo();
|
|
2784
2784
|
if (!frame)
|
|
2785
|
-
fail("CHAIN_UNUSABLE", `${kind} ${receipt.object_id} is absent from storage`, "materializing
|
|
2785
|
+
fail("CHAIN_UNUSABLE", `${kind} ${receipt.object_id} is absent from storage`, "materializing vault head", { object_id: receipt.object_id }, [{ action: "stay read-only at the materialized pin; run the repair path" }]);
|
|
2786
2786
|
let plaintext;
|
|
2787
2787
|
try {
|
|
2788
2788
|
plaintext = openFrame({ k_obj: deriveObjectKey(kRepo, this.repoId, epoch, kind, receipt.object_id), repo_id: this.repoId, object_kind: kind, object_id: receipt.object_id, epoch, frame, expected_ciphertext_sha256: receipt.ciphertext_sha256 });
|
|
2789
2789
|
}
|
|
2790
2790
|
catch (e) {
|
|
2791
|
-
fail("CHAIN_UNUSABLE", `${kind} ${receipt.object_id} cannot be opened: ${e.message}`, "materializing
|
|
2791
|
+
fail("CHAIN_UNUSABLE", `${kind} ${receipt.object_id} cannot be opened: ${e.message}`, "materializing vault head", { object_id: receipt.object_id }, [{ action: "stay read-only at the materialized pin; run the repair path" }]);
|
|
2792
2792
|
}
|
|
2793
|
-
const object =
|
|
2794
|
-
if (object.object_kind !== kind || object.object_id !== receipt.object_id || object.repo_id !== this.repoId || !
|
|
2795
|
-
fail("CHAIN_UNUSABLE", `${kind} ${receipt.object_id} plaintext fails its identity/signature checks`, "materializing
|
|
2793
|
+
const object = parseVaultStrict(new TextDecoder().decode(plaintext));
|
|
2794
|
+
if (object.object_kind !== kind || object.object_id !== receipt.object_id || object.repo_id !== this.repoId || !verifyVaultObject(object, writerKey)) {
|
|
2795
|
+
fail("CHAIN_UNUSABLE", `${kind} ${receipt.object_id} plaintext fails its identity/signature checks`, "materializing vault head", { object_id: receipt.object_id });
|
|
2796
2796
|
}
|
|
2797
2797
|
return object;
|
|
2798
2798
|
}
|
|
@@ -2832,7 +2832,7 @@ export class GitvaultVault {
|
|
|
2832
2832
|
const cachedRoots = this.keystore.readCachedCarrier(this.repoId, rootsReceipt.object_id);
|
|
2833
2833
|
const refStateHit = Boolean(cachedRefState && sha256Hex(cachedRefState.bytes) === refStateReceipt.ciphertext_sha256);
|
|
2834
2834
|
const rootsHit = Boolean(cachedRoots && sha256Hex(cachedRoots.bytes) === rootsReceipt.ciphertext_sha256);
|
|
2835
|
-
//
|
|
2835
|
+
// vault-clone-scaling (P2): a page-prefetched frame serves exactly as
|
|
2836
2836
|
// a fetched one — sha-checked against the receipt's ciphertext hash; a
|
|
2837
2837
|
// miss/mismatch falls through to the batched network read below.
|
|
2838
2838
|
const preRefState = !refStateHit ? (this.walkPrefetch?.get(refStatePath) ?? null) : null;
|
|
@@ -2873,7 +2873,7 @@ export class GitvaultVault {
|
|
|
2873
2873
|
* transition now opens the rotation's own envelope and decrypts under the
|
|
2874
2874
|
* NEW epoch, chaining through multiple sequential rotations; a keystore
|
|
2875
2875
|
* with no envelope for a new epoch fails CLOSED with
|
|
2876
|
-
* `
|
|
2876
|
+
* `VAULT_EPOCH_NOT_OPENABLE`, never a bare `VAULT_AEAD_AUTH_FAILURE`)
|
|
2877
2877
|
* — `strict: true` means this call throws exactly where the OLD
|
|
2878
2878
|
* (pre-fix) `materialize()` silently produced a wrong `k_obj` instead.
|
|
2879
2879
|
*/
|
|
@@ -2882,7 +2882,7 @@ export class GitvaultVault {
|
|
|
2882
2882
|
const state = await this.verifyToNewest({ persist, decryptValidate: true, strict: true, deltaSince: options.deltaSince, restore: options.restore });
|
|
2883
2883
|
if (!state.head) {
|
|
2884
2884
|
if (persist)
|
|
2885
|
-
this.keystore.updateRepo(this.repoId, { materialized_pin: { generation: state.generation, head_sha256: state.head_sha256, pinned_at:
|
|
2885
|
+
this.keystore.updateRepo(this.repoId, { materialized_pin: { generation: state.generation, head_sha256: state.head_sha256, pinned_at: formatVaultTimestamp(this.now()) } });
|
|
2886
2886
|
return { ...state, ref_state: null, retention_roots: null, refs: {}, roots: [], head_target: { kind: "symref", ref: "refs/heads/main" }, epoch_keys_hex: state.decrypt?.epoch_keys_hex ?? {} };
|
|
2887
2887
|
}
|
|
2888
2888
|
// `strict: true` guarantees `state.decrypt` is non-null with no failure
|
|
@@ -2894,10 +2894,10 @@ export class GitvaultVault {
|
|
|
2894
2894
|
const refState = state.decrypt.ref_state;
|
|
2895
2895
|
const roots = state.decrypt.retention_roots;
|
|
2896
2896
|
if (refState.generation !== state.generation || roots.generation !== state.generation)
|
|
2897
|
-
fail("CHAIN_UNUSABLE", "carrier generation does not match the head", "materializing
|
|
2897
|
+
fail("CHAIN_UNUSABLE", "carrier generation does not match the head", "materializing vault head");
|
|
2898
2898
|
return { ...state, ref_state: refState, retention_roots: roots, refs: { ...refState.refs }, roots: roots.roots.map((r) => ({ ...r })), head_target: refState.head_target, epoch_keys_hex: state.decrypt.epoch_keys_hex };
|
|
2899
2899
|
}
|
|
2900
|
-
// ── envelope recipients (
|
|
2900
|
+
// ── envelope recipients (vault-human-envelopes task 4.1, the ADD-path workaround) ──
|
|
2901
2901
|
/**
|
|
2902
2902
|
* Wrap the vault's CURRENT epoch key to every org member the directory
|
|
2903
2903
|
* lists but the vault does not yet have a `key_envelope` for.
|
|
@@ -2906,7 +2906,7 @@ export class GitvaultVault {
|
|
|
2906
2906
|
* describes a recipient-set change as an epoch rotation — "history epochs
|
|
2907
2907
|
* stay wrapped as they were; a new member reads from their first covered
|
|
2908
2908
|
* epoch forward" — which needs a protocol revision: V0 pins `epoch` to
|
|
2909
|
-
* the single constant `
|
|
2909
|
+
* the single constant `VAULT_GENESIS_EPOCH` on every head, so there
|
|
2910
2910
|
* is no "forward" to speak of. What this method actually does, legally, without any
|
|
2911
2911
|
* protocol change: `key_envelope` objects are never head-referenced (not
|
|
2912
2912
|
* even genesis's own envelope is), so uploading an ADDITIONAL one at
|
|
@@ -2931,7 +2931,7 @@ export class GitvaultVault {
|
|
|
2931
2931
|
*
|
|
2932
2932
|
* **The gateway directory route carries `public_key` on every row**
|
|
2933
2933
|
* (`GET /orgs/v1/:org_id/encryption-keys` — see the
|
|
2934
|
-
* doc comment on {@link
|
|
2934
|
+
* doc comment on {@link VaultOrgEncryptionKeyEntry}), so against a
|
|
2935
2935
|
* current gateway entries actually wrap. A directory entry that arrives
|
|
2936
2936
|
* WITHOUT the field (an older/rolling-deploy gateway) is still tolerated
|
|
2937
2937
|
* per-entry — reported under `skipped` with reason `missing_public_key`,
|
|
@@ -2939,9 +2939,9 @@ export class GitvaultVault {
|
|
|
2939
2939
|
*
|
|
2940
2940
|
* Best-effort by design at the call site, not here: this method itself
|
|
2941
2941
|
* either completes (returning a full per-recipient breakdown) or throws
|
|
2942
|
-
* (e.g. `
|
|
2942
|
+
* (e.g. `VAULT_READ_ONLY` when this principal holds no signing key).
|
|
2943
2943
|
* Callers that want "never block on this" (the deploy hook) wrap the call
|
|
2944
|
-
* themselves — see `
|
|
2944
|
+
* themselves — see `Repos.capture`'s `#tryReconcileEnvelopeRecipients`.
|
|
2945
2945
|
*/
|
|
2946
2946
|
async reconcileEnvelopeRecipients() {
|
|
2947
2947
|
const repo = this.repoFile();
|
|
@@ -2996,13 +2996,13 @@ export class GitvaultVault {
|
|
|
2996
2996
|
epoch,
|
|
2997
2997
|
recipient_public_key: recipientPublicKey,
|
|
2998
2998
|
signer,
|
|
2999
|
-
created_at:
|
|
2999
|
+
created_at: formatVaultTimestamp(this.now()),
|
|
3000
3000
|
});
|
|
3001
3001
|
try {
|
|
3002
3002
|
const byo = await this.resolveByoWriteTarget();
|
|
3003
3003
|
await this.transport.putObject({
|
|
3004
3004
|
repo_id: this.repoId,
|
|
3005
|
-
path:
|
|
3005
|
+
path: vaultPaths.envelope(epoch, sealed.receipt.recipient_fingerprint),
|
|
3006
3006
|
bytes: sealed.stored_bytes,
|
|
3007
3007
|
expected_sha256: sealed.stored_bytes_sha256,
|
|
3008
3008
|
expected_size_bytes: sealed.size_bytes,
|
|
@@ -3015,25 +3015,25 @@ export class GitvaultVault {
|
|
|
3015
3015
|
// the SAME recipient first — HPKE seal is randomized, so two valid
|
|
3016
3016
|
// wraps of the same K_repo to the same recipient produce DIFFERENT
|
|
3017
3017
|
// ciphertext bytes, and the create-only path's read-and-compare
|
|
3018
|
-
// reports that as `
|
|
3018
|
+
// reports that as `VAULT_OBJECT_EXISTS_DIFFERENT` even though the
|
|
3019
3019
|
// recipient is now genuinely covered. Treat exactly that code as a
|
|
3020
3020
|
// benign race, not a failure; anything else propagates.
|
|
3021
|
-
if (isRun402Error(e) && e.code === "
|
|
3022
|
-
//
|
|
3021
|
+
if (isRun402Error(e) && e.code === "VAULT_OBJECT_EXISTS_DIFFERENT") {
|
|
3022
|
+
// vault-agent-envelopes (consult 7.6): a benign race is only
|
|
3023
3023
|
// benign once the WINNING envelope has been read back and verified
|
|
3024
3024
|
// — same repo, same epoch, this recipient, signed by the vault's
|
|
3025
3025
|
// registered writer. Anything else is recorded as skipped, never as
|
|
3026
3026
|
// coverage, and never pinned.
|
|
3027
|
-
const winnerPath =
|
|
3027
|
+
const winnerPath = vaultPaths.envelope(epoch, sealed.receipt.recipient_fingerprint);
|
|
3028
3028
|
const winnerBytes = await this.transport.getObject({ repo_id: this.repoId, path: winnerPath }).catch(() => null);
|
|
3029
3029
|
let winnerOk = false;
|
|
3030
3030
|
if (winnerBytes) {
|
|
3031
3031
|
try {
|
|
3032
|
-
const winner =
|
|
3032
|
+
const winner = parseVaultStrict(new TextDecoder().decode(winnerBytes));
|
|
3033
3033
|
const g = (await this.genesis()).genesis;
|
|
3034
3034
|
winnerOk = winner.repo_id === this.repoId && winner.epoch === epoch && winner.recipient_fingerprint === entry.ek_fingerprint
|
|
3035
3035
|
&& winner.created_by === g.writer_key_id
|
|
3036
|
-
&&
|
|
3036
|
+
&& verifyVaultObject(winner, g.creator_signing_pubkey);
|
|
3037
3037
|
}
|
|
3038
3038
|
catch {
|
|
3039
3039
|
winnerOk = false;
|
|
@@ -3044,7 +3044,7 @@ export class GitvaultVault {
|
|
|
3044
3044
|
// winner does not verify is evidence of tampering, equivocation,
|
|
3045
3045
|
// or broken object identity — categorically different from a
|
|
3046
3046
|
// stale recipient. FATAL, never an ordinary skip.
|
|
3047
|
-
fail("
|
|
3047
|
+
fail("VAULT_ENVELOPE_ALTERED", `a conflicting key_envelope already stored at ${winnerPath} does not verify as this vault's writer-signed envelope for this recipient — refusing to treat the conflict as a benign race`, "reconciling vault envelope recipients", { repo_id: this.repoId, path: winnerPath, epoch, recipient_fingerprint: entry.ek_fingerprint });
|
|
3048
3048
|
}
|
|
3049
3049
|
alreadyCovered.push(entry.ek_fingerprint);
|
|
3050
3050
|
}
|
|
@@ -3074,7 +3074,7 @@ export class GitvaultVault {
|
|
|
3074
3074
|
return { path, object_kind: kind, object_id: objectId, bytes: sealed.frame, sha256: sealed.ciphertext_sha256, size_bytes: sealed.size_bytes };
|
|
3075
3075
|
}
|
|
3076
3076
|
/**
|
|
3077
|
-
* The owner signing seed, or `
|
|
3077
|
+
* The owner signing seed, or `VAULT_READ_ONLY`.
|
|
3078
3078
|
*
|
|
3079
3079
|
* Public so the prune lane signs its intent core, wrapper, and verifier
|
|
3080
3080
|
* receipt through the SAME refusal path every other signed object uses — a
|
|
@@ -3086,12 +3086,12 @@ export class GitvaultVault {
|
|
|
3086
3086
|
const identity = this.keystore.ensureIdentity();
|
|
3087
3087
|
const kp = this.keystore.signingKeypair(identity);
|
|
3088
3088
|
if (!kp)
|
|
3089
|
-
fail("
|
|
3089
|
+
fail("VAULT_READ_ONLY", "the signing key is missing from identity.json; this principal is read-only", "signing vault object", undefined, [{ action: "stay read-only at the materialized pin" }]);
|
|
3090
3090
|
return kp.seed;
|
|
3091
3091
|
}
|
|
3092
3092
|
writerKeyId() { return this.keystore.ensureIdentity().signing_fingerprint; }
|
|
3093
3093
|
/**
|
|
3094
|
-
*
|
|
3094
|
+
* vault-multi-writer (task 5.8) — the push pre-check: before signing an
|
|
3095
3095
|
* ORDINARY head (any operation that expects this session to already be an
|
|
3096
3096
|
* active writer), refuse EARLY and LOCALLY when this session's own key is
|
|
3097
3097
|
* not (or is no longer) an active writer, rather than let real
|
|
@@ -3105,13 +3105,13 @@ export class GitvaultVault {
|
|
|
3105
3105
|
* `removedMidRace` (design D8/D10) distinguishes the two shapes this
|
|
3106
3106
|
* refusal takes: `false` (the default) is the ORDINARY pre-check — this
|
|
3107
3107
|
* session was never (or is not currently) an admitted writer, thrown as
|
|
3108
|
-
* `
|
|
3108
|
+
* `VAULT_WRITER_NOT_ADMITTED`. `true` is the CAS-LOSER path
|
|
3109
3109
|
* specifically: a retry, after re-materializing from the winner, that
|
|
3110
3110
|
* discovers THIS session's own key was removed by whatever won the race —
|
|
3111
3111
|
* "stop if removed" in D8's loser-rule sequence (fetch winner → verify →
|
|
3112
3112
|
* apply writer transition → stop if removed → rebase → rebuild → bounded
|
|
3113
3113
|
* backoff) — thrown as the more specific, client-local
|
|
3114
|
-
* `
|
|
3114
|
+
* `VAULT_WRITER_REMOVED` (D10) instead: a real prior attempt just lost
|
|
3115
3115
|
* to a removal, not a caller who was never eligible.
|
|
3116
3116
|
*
|
|
3117
3117
|
* Deliberately NOT called for a "handoff"-door `add_writer_key` head
|
|
@@ -3123,47 +3123,47 @@ export class GitvaultVault {
|
|
|
3123
3123
|
const myKey = this.writerKeyId();
|
|
3124
3124
|
if (pin && pin.writers.some((w) => w.writer_key_id === myKey))
|
|
3125
3125
|
return;
|
|
3126
|
-
const code = removedMidRace ? "
|
|
3126
|
+
const code = removedMidRace ? "VAULT_WRITER_REMOVED" : "VAULT_WRITER_NOT_ADMITTED";
|
|
3127
3127
|
fail(code, removedMidRace
|
|
3128
3128
|
? `this session's writer key (${myKey}) was removed while a conflicting head was admitted first`
|
|
3129
3129
|
: `this session's writer key (${myKey}) is not an active writer on this vault`, context, {}, [
|
|
3130
3130
|
{
|
|
3131
3131
|
type: "request_writer_sync",
|
|
3132
|
-
why: "This session's key is not (yet, or no longer) an admitted writer on this vault. If you were just added as an org member, any current writer's next
|
|
3132
|
+
why: "This session's key is not (yet, or no longer) an admitted writer on this vault. If you were just added as an org member, any current writer's next vault operation (push, deploy, or r.repos.reconcile()) admits pending writers automatically.",
|
|
3133
3133
|
},
|
|
3134
3134
|
]);
|
|
3135
3135
|
}
|
|
3136
|
-
/** The owner's full signing keypair, or `
|
|
3136
|
+
/** The owner's full signing keypair, or `VAULT_READ_ONLY` — same refusal path as {@link signer}, which returns only the seed; {@link sealKeyEnvelope} needs both halves. */
|
|
3137
3137
|
signingKeypair() {
|
|
3138
3138
|
const identity = this.keystore.ensureIdentity();
|
|
3139
3139
|
const kp = this.keystore.signingKeypair(identity);
|
|
3140
3140
|
if (!kp)
|
|
3141
|
-
fail("
|
|
3141
|
+
fail("VAULT_READ_ONLY", "the signing key is missing from identity.json; this principal is read-only", "signing vault object", undefined, [{ action: "stay read-only at the materialized pin" }]);
|
|
3142
3142
|
return kp;
|
|
3143
3143
|
}
|
|
3144
3144
|
buildRefState(generation, refs, headTarget, keyOverride) {
|
|
3145
3145
|
if (headTarget.kind === "symref" && !BRANCH_REF_RE.test(headTarget.ref))
|
|
3146
3146
|
fail("REFNAME_UNSUPPORTED", `head_target symref must name a refs/heads/* branch: ${headTarget.ref}`, "building ref_state");
|
|
3147
|
-
if (headTarget.kind === "detached" && !
|
|
3147
|
+
if (headTarget.kind === "detached" && !VAULT_OID40_RE.test(headTarget.oid))
|
|
3148
3148
|
fail("REF_TRANSACTION_INVALID", "detached head_target needs a 40-hex oid", "building ref_state");
|
|
3149
3149
|
assertRefMapCardinality(refs);
|
|
3150
3150
|
const sorted = {};
|
|
3151
3151
|
for (const k of Object.keys(refs).sort())
|
|
3152
3152
|
sorted[k] = refs[k];
|
|
3153
|
-
const id =
|
|
3154
|
-
const object =
|
|
3153
|
+
const id = newVaultId("refs");
|
|
3154
|
+
const object = signVaultObject({ format: VAULT_FORMAT, object_kind: "ref_state", suite: VAULT_SUITE, repo_id: this.repoId, object_id: id, generation, refs: sorted, head_target: headTarget }, this.signer());
|
|
3155
3155
|
const plaintext = storedBytes(object);
|
|
3156
|
-
if (plaintext.length >
|
|
3156
|
+
if (plaintext.length > VAULT_MAX_REF_STATE_OBJECT_BYTES)
|
|
3157
3157
|
fail("REF_STATE_LIMIT_EXCEEDED", "ref_state object exceeds 32 MiB", "building ref_state");
|
|
3158
|
-
return { object, upload: this.seal("ref_state", id, plaintext,
|
|
3158
|
+
return { object, upload: this.seal("ref_state", id, plaintext, vaultPaths.refState(id), keyOverride) };
|
|
3159
3159
|
}
|
|
3160
3160
|
buildRetentionRoots(generation, roots, cutoff, keyOverride) {
|
|
3161
|
-
const id =
|
|
3162
|
-
const object =
|
|
3161
|
+
const id = newVaultId("rr");
|
|
3162
|
+
const object = signVaultObject({ format: VAULT_FORMAT, object_kind: "retention_roots", suite: VAULT_SUITE, repo_id: this.repoId, object_id: id, generation, cutoff, roots: [...roots].sort(compareRoots) }, this.signer());
|
|
3163
3163
|
const plaintext = storedBytes(object);
|
|
3164
|
-
if (plaintext.length >
|
|
3164
|
+
if (plaintext.length > VAULT_MAX_REF_STATE_OBJECT_BYTES)
|
|
3165
3165
|
fail("REF_STATE_LIMIT_EXCEEDED", "retention_roots object exceeds 32 MiB", "building retention_roots");
|
|
3166
|
-
return { object, upload: this.seal("retention_roots", id, plaintext,
|
|
3166
|
+
return { object, upload: this.seal("retention_roots", id, plaintext, vaultPaths.retentionRoots(id), keyOverride) };
|
|
3167
3167
|
}
|
|
3168
3168
|
/** Plaintext, independently non-thin packs covering `reachable(tips) ∖ reachable(base)`, split at the multi-object target. */
|
|
3169
3169
|
async buildPacks(tips, base) {
|
|
@@ -3177,7 +3177,7 @@ export class GitvaultVault {
|
|
|
3177
3177
|
// genuine local gap and is named as such.
|
|
3178
3178
|
const uniqueTips = [];
|
|
3179
3179
|
for (const t of new Set(tips)) {
|
|
3180
|
-
if (!
|
|
3180
|
+
if (!VAULT_OID40_RE.test(t))
|
|
3181
3181
|
continue;
|
|
3182
3182
|
if (await hasObject(dir, t)) {
|
|
3183
3183
|
uniqueTips.push(t);
|
|
@@ -3185,13 +3185,13 @@ export class GitvaultVault {
|
|
|
3185
3185
|
}
|
|
3186
3186
|
if (baseSet.has(t))
|
|
3187
3187
|
continue;
|
|
3188
|
-
fail("GIT_COMMAND_FAILED", `tip ${t} is not in this checkout's object store and the vault does not cover it yet — fetch it before publishing`, "building
|
|
3188
|
+
fail("GIT_COMMAND_FAILED", `tip ${t} is not in this checkout's object store and the vault does not cover it yet — fetch it before publishing`, "building vault packs", { oid: t });
|
|
3189
3189
|
}
|
|
3190
3190
|
if (uniqueTips.length === 0)
|
|
3191
3191
|
return [];
|
|
3192
3192
|
const presentBase = [];
|
|
3193
3193
|
for (const b of baseSet)
|
|
3194
|
-
if (
|
|
3194
|
+
if (VAULT_OID40_RE.test(b) && (await hasObject(dir, b)))
|
|
3195
3195
|
presentBase.push(b);
|
|
3196
3196
|
// On the object store's own filesystem — `git pack-objects` rename(2)s
|
|
3197
3197
|
// its temp pack from `objects/pack/` onto this base (see
|
|
@@ -3199,7 +3199,7 @@ export class GitvaultVault {
|
|
|
3199
3199
|
const tmp = await allocatePackScratchDir(dir);
|
|
3200
3200
|
try {
|
|
3201
3201
|
const revs = [...uniqueTips, ...presentBase.map((b) => `^${b}`)].join("\n") + "\n";
|
|
3202
|
-
await hardenedGit(dir, ["pack-objects", "--revs", "--no-reuse-delta", "--delta-base-offset", `--max-pack-size=${
|
|
3202
|
+
await hardenedGit(dir, ["pack-objects", "--revs", "--no-reuse-delta", "--delta-base-offset", `--max-pack-size=${VAULT_MULTI_OBJECT_PACK_TARGET_BYTES}`, "-q", join(tmp, "p")], { input: revs });
|
|
3203
3203
|
const files = readdirSync(tmp).filter((f) => f.endsWith(".pack")).sort();
|
|
3204
3204
|
return files.map((f) => new Uint8Array(readFileSync(join(tmp, f))));
|
|
3205
3205
|
}
|
|
@@ -3220,7 +3220,7 @@ export class GitvaultVault {
|
|
|
3220
3220
|
* parameterized by directory, rather than a second rev-list at the call site.
|
|
3221
3221
|
*/
|
|
3222
3222
|
async objectSetIn(dir, tips) {
|
|
3223
|
-
const unique = [...new Set(tips)].filter((t) =>
|
|
3223
|
+
const unique = [...new Set(tips)].filter((t) => VAULT_OID40_RE.test(t));
|
|
3224
3224
|
if (unique.length === 0)
|
|
3225
3225
|
return [];
|
|
3226
3226
|
const out = await hardenedGit(dir, ["rev-list", "--objects", "--no-object-names", ...unique]);
|
|
@@ -3236,7 +3236,7 @@ export class GitvaultVault {
|
|
|
3236
3236
|
*/
|
|
3237
3237
|
async openRetentionRootsAt(receipt) {
|
|
3238
3238
|
const { genesis } = await this.genesis();
|
|
3239
|
-
return this.openCarrier("retention_roots", receipt,
|
|
3239
|
+
return this.openCarrier("retention_roots", receipt, vaultPaths.retentionRoots(receipt.object_id), genesis.creator_signing_pubkey);
|
|
3240
3240
|
}
|
|
3241
3241
|
digest(label, content) {
|
|
3242
3242
|
return keyedCommitment(deriveDigestKey(this.kRepo(), this.repoId, this.epoch(), label), content);
|
|
@@ -3278,7 +3278,7 @@ export class GitvaultVault {
|
|
|
3278
3278
|
* recomputed). Coverage above the V0 maximum → `CHECKPOINT_SET_LIMIT_EXCEEDED`.
|
|
3279
3279
|
*/
|
|
3280
3280
|
async buildCheckpoint(input) {
|
|
3281
|
-
const tips =
|
|
3281
|
+
const tips = Vault.coverageTips(input.ref_state.refs, input.retention_roots.roots, input.ref_state.head_target);
|
|
3282
3282
|
const absent = [];
|
|
3283
3283
|
for (const t of tips)
|
|
3284
3284
|
if (!(await hasObject(this.git(), t)))
|
|
@@ -3298,15 +3298,15 @@ export class GitvaultVault {
|
|
|
3298
3298
|
fail("CHECKPOINT_INCOMPLETE", `covered tip ${t} is not present locally and the vault did not supply it; the checkpoint cannot be built from this repository`, "building checkpoint set", { oid: t });
|
|
3299
3299
|
}
|
|
3300
3300
|
const packs = await this.buildPacks(tips, []);
|
|
3301
|
-
if (packs.length >
|
|
3302
|
-
fail("CHECKPOINT_SET_LIMIT_EXCEEDED", `${packs.length} packs exceed the ${
|
|
3301
|
+
if (packs.length > VAULT_MAX_CHECKPOINT_PACKS)
|
|
3302
|
+
fail("CHECKPOINT_SET_LIMIT_EXCEEDED", `${packs.length} packs exceed the ${VAULT_MAX_CHECKPOINT_PACKS}-pack V0 checkpoint maximum`, "building checkpoint set", undefined, [{ action: "compact or prune before adding coverage" }]);
|
|
3303
3303
|
const entries = [];
|
|
3304
3304
|
const objects = [];
|
|
3305
3305
|
let totalPlain = 0n;
|
|
3306
3306
|
let totalStored = 0n;
|
|
3307
3307
|
for (const pack of packs) {
|
|
3308
|
-
const id =
|
|
3309
|
-
const upload = this.seal("checkpoint_pack", id, pack,
|
|
3308
|
+
const id = newVaultId("ckp");
|
|
3309
|
+
const upload = this.seal("checkpoint_pack", id, pack, vaultPaths.checkpointPack(id));
|
|
3310
3310
|
objects.push(upload);
|
|
3311
3311
|
entries.push({ object_id: id, plaintext_sha256: sha256Hex(pack), plaintext_size_bytes: String(pack.length), ciphertext_sha256: upload.sha256, size_bytes: upload.size_bytes });
|
|
3312
3312
|
totalPlain += BigInt(pack.length);
|
|
@@ -3314,26 +3314,26 @@ export class GitvaultVault {
|
|
|
3314
3314
|
}
|
|
3315
3315
|
const objectIds = await this.objectSet(tips);
|
|
3316
3316
|
const manifestUnsigned = {
|
|
3317
|
-
format:
|
|
3317
|
+
format: VAULT_FORMAT, object_kind: "checkpoint_manifest", suite: VAULT_SUITE, repo_id: this.repoId, object_id: newVaultId("chk"),
|
|
3318
3318
|
covers_through_generation: input.generation, git_object_format: "sha1", packs: entries, total_plaintext_size_bytes: String(totalPlain),
|
|
3319
3319
|
ref_state_hmac: this.digest("refmap", this.withoutSignature(input.ref_state)),
|
|
3320
3320
|
retention_roots_hmac: this.digest("rootset", this.withoutSignature(input.retention_roots)),
|
|
3321
3321
|
object_set_hmac: this.digest("objectset", { oids: objectIds }),
|
|
3322
3322
|
};
|
|
3323
|
-
const manifest =
|
|
3324
|
-
const manifestUpload = this.seal("checkpoint_manifest", manifest.object_id, storedBytes(manifest),
|
|
3323
|
+
const manifest = signVaultObject(manifestUnsigned, this.signer());
|
|
3324
|
+
const manifestUpload = this.seal("checkpoint_manifest", manifest.object_id, storedBytes(manifest), vaultPaths.checkpointManifest(manifest.object_id));
|
|
3325
3325
|
objects.unshift(manifestUpload);
|
|
3326
3326
|
totalStored += BigInt(manifestUpload.size_bytes);
|
|
3327
|
-
if (totalStored >
|
|
3327
|
+
if (totalStored > VAULT_MAX_CHECKPOINT_TOTAL_STORED_BYTES)
|
|
3328
3328
|
fail("CHECKPOINT_SET_LIMIT_EXCEEDED", "the checkpoint's stored bytes exceed the 800 GiB V0 maximum", "building checkpoint set", { total_stored_size_bytes: String(totalStored) }, [{ action: "compact or prune before adding coverage" }]);
|
|
3329
|
-
const claimSet =
|
|
3330
|
-
format:
|
|
3329
|
+
const claimSet = signVaultObject({
|
|
3330
|
+
format: VAULT_FORMAT, object_kind: "checkpoint_claim_set", suite: VAULT_SUITE, repo_id: this.repoId, object_id: newVaultId("ccs"),
|
|
3331
3331
|
manifest_receipt: { object_id: manifest.object_id, object_kind: "checkpoint_manifest", ciphertext_sha256: manifestUpload.sha256, size_bytes: manifestUpload.size_bytes },
|
|
3332
3332
|
ordered_pack_receipts: entries.map((e) => ({ object_id: e.object_id, object_kind: "checkpoint_pack", ciphertext_sha256: e.ciphertext_sha256, size_bytes: e.size_bytes })),
|
|
3333
3333
|
total_stored_size_bytes: String(totalStored), covers_through_generation: input.generation, writer_key_id: this.writerKeyId(),
|
|
3334
3334
|
}, this.signer());
|
|
3335
3335
|
const claimBytes = storedBytes(claimSet);
|
|
3336
|
-
const claimUpload = { path:
|
|
3336
|
+
const claimUpload = { path: vaultPaths.claimSet(claimSet.object_id), object_kind: "checkpoint_claim_set", object_id: claimSet.object_id, bytes: claimBytes, sha256: sha256Hex(claimBytes), size_bytes: String(claimBytes.length) };
|
|
3337
3337
|
objects.push(claimUpload);
|
|
3338
3338
|
const built = { manifest, claim_set: claimSet, claim_set_receipt: { object_id: claimSet.object_id, object_kind: "checkpoint_claim_set", stored_bytes_sha256: claimUpload.sha256, size_bytes: claimUpload.size_bytes }, objects, packs, covered_tips: tips };
|
|
3339
3339
|
await this.acceptCheckpoint(built, input.ref_state, input.retention_roots);
|
|
@@ -3342,13 +3342,13 @@ export class GitvaultVault {
|
|
|
3342
3342
|
/** §4.7 acceptance: restore from the set ALONE into an empty scratch; every covered ref resolves; fsck connectivity; recompute the three commitments. */
|
|
3343
3343
|
async acceptCheckpoint(built, refState, roots) {
|
|
3344
3344
|
checkClaimSetEquality(built.claim_set, built.manifest, built.claim_set.covers_through_generation);
|
|
3345
|
-
const scratch = mkdtempSync(join(tmpdir(), "run402-
|
|
3345
|
+
const scratch = mkdtempSync(join(tmpdir(), "run402-vault-accept-"));
|
|
3346
3346
|
try {
|
|
3347
3347
|
await hardenedGit(scratch, ["init", "-q", "--bare", "--object-format=sha1", "."]);
|
|
3348
3348
|
for (const pack of built.packs) {
|
|
3349
3349
|
await hardenedGit(scratch, ["index-pack", "--stdin", "--strict"], { input: pack });
|
|
3350
3350
|
}
|
|
3351
|
-
const tips =
|
|
3351
|
+
const tips = Vault.coverageTips(refState.refs, roots.roots, refState.head_target);
|
|
3352
3352
|
for (const t of tips) {
|
|
3353
3353
|
if (!(await hasObject(scratch, t)))
|
|
3354
3354
|
fail("CHECKPOINT_INCOMPLETE", `covered tip ${t} does not resolve from the restored set`, "accepting checkpoint set", { oid: t }, [{ action: "rebuild the checkpoint set; restorers fall back to WAL replay" }]);
|
|
@@ -3384,7 +3384,7 @@ export class GitvaultVault {
|
|
|
3384
3384
|
if (!block)
|
|
3385
3385
|
fail("CHECKPOINT_INCOMPLETE", `head ${head.generation} carries no checkpoint to verify`, "verifying a stored checkpoint", { generation: head.generation });
|
|
3386
3386
|
const { genesis } = await this.genesis();
|
|
3387
|
-
//
|
|
3387
|
+
// vault-multi-writer rev 47: `head` may have been signed by any
|
|
3388
3388
|
// CURRENTLY-KNOWN writer, not just the genesis creator — resolved from
|
|
3389
3389
|
// the keystore's persisted `writer_set_pin` (the same substrate
|
|
3390
3390
|
// `verifyToNewest` maintains), falling back to the genesis-creator key
|
|
@@ -3394,23 +3394,23 @@ export class GitvaultVault {
|
|
|
3394
3394
|
// same documented residual as the backward-catch-up decrypt path above —
|
|
3395
3395
|
// `writer_set_pin` only remembers currently-active writers, not burned ones.
|
|
3396
3396
|
const writerKey = this.writerKeyForHead(head, genesis.creator_signing_pubkey);
|
|
3397
|
-
const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path:
|
|
3397
|
+
const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path: vaultPaths.claimSet(block.claim_set.object_id), expected_sha256: block.claim_set.stored_bytes_sha256 });
|
|
3398
3398
|
if (!claimBytes || sha256Hex(claimBytes) !== block.claim_set.stored_bytes_sha256) {
|
|
3399
3399
|
fail("CHECKPOINT_INCOMPLETE", `checkpoint claim set ${block.claim_set.object_id} is absent or does not match the head's receipt`, "verifying a stored checkpoint", { object_id: block.claim_set.object_id });
|
|
3400
3400
|
}
|
|
3401
|
-
const claimSet =
|
|
3402
|
-
if (!
|
|
3401
|
+
const claimSet = parseVaultStrict(new TextDecoder().decode(claimBytes));
|
|
3402
|
+
if (!verifyVaultObject(claimSet, writerKey))
|
|
3403
3403
|
fail("CHECKPOINT_INCOMPLETE", "checkpoint claim set signature fails", "verifying a stored checkpoint", { object_id: claimSet.object_id });
|
|
3404
|
-
const manifest = await this.openCarrier("checkpoint_manifest", claimSet.manifest_receipt,
|
|
3404
|
+
const manifest = await this.openCarrier("checkpoint_manifest", claimSet.manifest_receipt, vaultPaths.checkpointManifest(claimSet.manifest_receipt.object_id), writerKey);
|
|
3405
3405
|
checkClaimSetEquality(claimSet, manifest, block.covers_through_generation);
|
|
3406
|
-
const refState = await this.openCarrier("ref_state", head.ref_state,
|
|
3407
|
-
const roots = await this.openCarrier("retention_roots", head.retention_roots,
|
|
3408
|
-
const tips =
|
|
3409
|
-
const scratch = mkdtempSync(join(tmpdir(), "run402-
|
|
3406
|
+
const refState = await this.openCarrier("ref_state", head.ref_state, vaultPaths.refState(head.ref_state.object_id), writerKey);
|
|
3407
|
+
const roots = await this.openCarrier("retention_roots", head.retention_roots, vaultPaths.retentionRoots(head.retention_roots.object_id), writerKey);
|
|
3408
|
+
const tips = Vault.coverageTips(refState.refs, roots.roots, refState.head_target);
|
|
3409
|
+
const scratch = mkdtempSync(join(tmpdir(), "run402-vault-attest-"));
|
|
3410
3410
|
try {
|
|
3411
3411
|
await hardenedGit(scratch, ["init", "-q", "--bare", "--object-format=sha1", "."]);
|
|
3412
3412
|
for (const p of manifest.packs) {
|
|
3413
|
-
const frame = await this.transport.getObject({ repo_id: this.repoId, path:
|
|
3413
|
+
const frame = await this.transport.getObject({ repo_id: this.repoId, path: vaultPaths.checkpointPack(p.object_id), expected_sha256: p.ciphertext_sha256 });
|
|
3414
3414
|
if (!frame)
|
|
3415
3415
|
fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} is absent from storage`, "verifying a stored checkpoint", { object_id: p.object_id });
|
|
3416
3416
|
const plain = openFrame({ k_obj: deriveObjectKey(this.kRepo(), this.repoId, this.epoch(), "checkpoint_pack", p.object_id), repo_id: this.repoId, object_kind: "checkpoint_pack", object_id: p.object_id, epoch: this.epoch(), frame, expected_ciphertext_sha256: p.ciphertext_sha256 });
|
|
@@ -3466,7 +3466,7 @@ export class GitvaultVault {
|
|
|
3466
3466
|
const generations = [...chain.keys()].sort();
|
|
3467
3467
|
const out = [];
|
|
3468
3468
|
const { genesis } = await this.genesis();
|
|
3469
|
-
//
|
|
3469
|
+
// vault-multi-writer rev 47: `verifyToNewest()` above already
|
|
3470
3470
|
// persisted the current `writer_set_pin` (active AND retired writers) —
|
|
3471
3471
|
// same per-head resolution as `verifyStoredCheckpoint`.
|
|
3472
3472
|
for (const gen of generations) {
|
|
@@ -3478,13 +3478,13 @@ export class GitvaultVault {
|
|
|
3478
3478
|
// Plaintext-structured and stored-bytes-receipted: no decryption, but
|
|
3479
3479
|
// the hash and the owner signature are still checked before a single
|
|
3480
3480
|
// pack receipt inside it is believed.
|
|
3481
|
-
const bytes = await this.transport.getObject({ repo_id: this.repoId, path:
|
|
3481
|
+
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: vaultPaths.claimSet(block.claim_set.object_id), expected_sha256: block.claim_set.stored_bytes_sha256 });
|
|
3482
3482
|
if (!bytes || sha256Hex(bytes) !== block.claim_set.stored_bytes_sha256) {
|
|
3483
|
-
fail("CHECKPOINT_INCOMPLETE", `checkpoint claim set ${block.claim_set.object_id} (generation ${gen}) is absent or altered`, "walking the
|
|
3483
|
+
fail("CHECKPOINT_INCOMPLETE", `checkpoint claim set ${block.claim_set.object_id} (generation ${gen}) is absent or altered`, "walking the vault chain", { generation: gen, object_id: block.claim_set.object_id });
|
|
3484
3484
|
}
|
|
3485
|
-
claimSet =
|
|
3486
|
-
if (!
|
|
3487
|
-
fail("CHECKPOINT_INCOMPLETE", `checkpoint claim set ${claimSet.object_id} signature fails`, "walking the
|
|
3485
|
+
claimSet = parseVaultStrict(new TextDecoder().decode(bytes));
|
|
3486
|
+
if (!verifyVaultObject(claimSet, writerKey)) {
|
|
3487
|
+
fail("CHECKPOINT_INCOMPLETE", `checkpoint claim set ${claimSet.object_id} signature fails`, "walking the vault chain", { generation: gen });
|
|
3488
3488
|
}
|
|
3489
3489
|
}
|
|
3490
3490
|
out.push({ head: entry.head, head_sha256: entry.sha256, claim_set: claimSet });
|
|
@@ -3493,19 +3493,19 @@ export class GitvaultVault {
|
|
|
3493
3493
|
}
|
|
3494
3494
|
// ── upload with receipt-compare ──
|
|
3495
3495
|
/**
|
|
3496
|
-
*
|
|
3496
|
+
* vault-byo-primary-bucket task 3.2 — resolve (once, cached) this
|
|
3497
3497
|
* machine's local BYO write config for THIS vault. `null` when none is
|
|
3498
3498
|
* configured locally — either because this is an ordinary managed vault
|
|
3499
3499
|
* (the common case; deliberately never confirmed with a network call, so
|
|
3500
3500
|
* a managed vault's push pays zero extra round trips), or because this
|
|
3501
3501
|
* machine has not been configured to write a BYO vault it nonetheless
|
|
3502
|
-
* belongs to (surfaced downstream as `
|
|
3502
|
+
* belongs to (surfaced downstream as `VAULT_BYO_BUCKET_WRITE_REFUSED`
|
|
3503
3503
|
* when the session actually names a `put: null` object with no target).
|
|
3504
3504
|
*/
|
|
3505
3505
|
async resolveByoWriteTarget() {
|
|
3506
3506
|
if (this.byoResolution !== undefined)
|
|
3507
3507
|
return this.byoResolution;
|
|
3508
|
-
const { readByoConfig } = await import("./
|
|
3508
|
+
const { readByoConfig } = await import("./vault-byo-config.js");
|
|
3509
3509
|
const local = readByoConfig(this.keystore, this.repoId);
|
|
3510
3510
|
this.byoResolution = local ? { destination: local.destination, ...(local.credential ? { credential: local.credential } : {}) } : null;
|
|
3511
3511
|
return this.byoResolution;
|
|
@@ -3514,10 +3514,10 @@ export class GitvaultVault {
|
|
|
3514
3514
|
const byo = await this.resolveByoWriteTarget();
|
|
3515
3515
|
const receipts = await this.transport.uploadObjects({ repo_id: this.repoId, objects, ...(byo ? { byo } : {}) });
|
|
3516
3516
|
if (receipts.length !== objects.length)
|
|
3517
|
-
fail("
|
|
3517
|
+
fail("VAULT_RECEIPT_MISMATCH", `${receipts.length} receipts for ${objects.length} objects`, "comparing finalization receipts");
|
|
3518
3518
|
const mismatches = objects.filter((o, i) => receipts[i].path !== o.path || receipts[i].sha256 !== o.sha256 || receipts[i].size_bytes !== o.size_bytes);
|
|
3519
3519
|
if (mismatches.length > 0) {
|
|
3520
|
-
fail("
|
|
3520
|
+
fail("VAULT_RECEIPT_MISMATCH", `the server's finalization receipts do not match the expected manifest for ${mismatches.map((m) => m.path).join(", ")}; refusing to sign a head over them`, "comparing finalization receipts", { paths: mismatches.map((m) => m.path) });
|
|
3521
3521
|
}
|
|
3522
3522
|
}
|
|
3523
3523
|
// ── §6.2 push ──
|
|
@@ -3540,8 +3540,8 @@ export class GitvaultVault {
|
|
|
3540
3540
|
});
|
|
3541
3541
|
const refState = this.buildRefState(generation, input.refs, input.head_target);
|
|
3542
3542
|
const rootsObj = this.buildRetentionRoots(generation, roots, ticket ? { cutoff_ticket_sha256: ticket.receipt.stored_bytes_sha256, cutoff_at: ticket.ticket.cutoff_at } : null);
|
|
3543
|
-
const baseTips =
|
|
3544
|
-
const newTips =
|
|
3543
|
+
const baseTips = Vault.coverageTips(base.refs, base.roots, base.head_target);
|
|
3544
|
+
const newTips = Vault.coverageTips(input.refs, roots, input.head_target);
|
|
3545
3545
|
// Preflight the projected coverage against the V0 checkpoint maximum (client-side, plaintext projection).
|
|
3546
3546
|
const walPacks = input.force_checkpoint ? [] : await this.buildPacks(newTips, baseTips);
|
|
3547
3547
|
let form;
|
|
@@ -3549,7 +3549,7 @@ export class GitvaultVault {
|
|
|
3549
3549
|
let checkpoint = null;
|
|
3550
3550
|
const objects = [refState.upload, rootsObj.upload];
|
|
3551
3551
|
let rawPackBytes = 0;
|
|
3552
|
-
if (input.force_checkpoint || walPacks.length >
|
|
3552
|
+
if (input.force_checkpoint || walPacks.length > VAULT_MAX_WAL_RECEIPTS_PER_HEAD) {
|
|
3553
3553
|
form = "checkpoint";
|
|
3554
3554
|
const built = await this.buildCheckpoint({ generation, ref_state: refState.object, retention_roots: rootsObj.object });
|
|
3555
3555
|
objects.push(...built.objects);
|
|
@@ -3559,8 +3559,8 @@ export class GitvaultVault {
|
|
|
3559
3559
|
else {
|
|
3560
3560
|
form = "wal";
|
|
3561
3561
|
for (const pack of walPacks) {
|
|
3562
|
-
const id =
|
|
3563
|
-
const upload = { ...this.seal("wal_pack", id, pack,
|
|
3562
|
+
const id = newVaultId("wal");
|
|
3563
|
+
const upload = { ...this.seal("wal_pack", id, pack, vaultPaths.wal(id)), base_generation: base.generation };
|
|
3564
3564
|
objects.push(upload);
|
|
3565
3565
|
walEntries.push({ object_id: id, object_kind: "wal_pack", ciphertext_sha256: upload.sha256, size_bytes: upload.size_bytes, base_generation: base.generation });
|
|
3566
3566
|
rawPackBytes += pack.length;
|
|
@@ -3581,7 +3581,7 @@ export class GitvaultVault {
|
|
|
3581
3581
|
retention_roots: { object_id: rootsObj.object.object_id, object_kind: "retention_roots", ciphertext_sha256: rootsObj.upload.sha256, size_bytes: rootsObj.upload.size_bytes },
|
|
3582
3582
|
checkpoint, checkpoint_purpose: checkpoint ? "ordinary_push" : null, capture_binding: binding, repair: null,
|
|
3583
3583
|
},
|
|
3584
|
-
//
|
|
3584
|
+
// vault-multi-writer (task 5.6) — an `add_writer_key` activation
|
|
3585
3585
|
// head rides THIS same content-neutral machinery: `input.transition`
|
|
3586
3586
|
// is undefined for every pre-existing caller (push/publishCheckpoint),
|
|
3587
3587
|
// so their heads are byte-for-byte unchanged; `submitWriterActivationHead`
|
|
@@ -3605,14 +3605,14 @@ export class GitvaultVault {
|
|
|
3605
3605
|
let removalRotated = false;
|
|
3606
3606
|
let base = options.base ?? (await this.materialize());
|
|
3607
3607
|
for (;;) {
|
|
3608
|
-
//
|
|
3608
|
+
// vault-multi-writer (task 5.8) — checked fresh on EVERY attempt
|
|
3609
3609
|
// (including a CAS-loser retry, design D8's "stop if removed"): a
|
|
3610
3610
|
// concurrent rotation could remove this session's own writer key
|
|
3611
3611
|
// between one attempt and the next. `conflicts > 0` is exactly the
|
|
3612
3612
|
// CAS-loser path (this iteration only runs after losing a race and
|
|
3613
|
-
// re-materializing from the winner) —
|
|
3614
|
-
// the ordinary
|
|
3615
|
-
this.assertCallerIsWriter("publishing
|
|
3613
|
+
// re-materializing from the winner) — VAULT_WRITER_REMOVED there,
|
|
3614
|
+
// the ordinary VAULT_WRITER_NOT_ADMITTED on the first attempt.
|
|
3615
|
+
this.assertCallerIsWriter("publishing vault head", conflicts > 0);
|
|
3616
3616
|
const evaluation = await evaluateRefTransaction(base.refs, options.transaction, { isAncestor: (a, d) => isAncestor(this.git(), a, d), protocol_refs: options.protocol_refs });
|
|
3617
3617
|
let published;
|
|
3618
3618
|
try {
|
|
@@ -3622,8 +3622,8 @@ export class GitvaultVault {
|
|
|
3622
3622
|
});
|
|
3623
3623
|
}
|
|
3624
3624
|
catch (e) {
|
|
3625
|
-
//
|
|
3626
|
-
// epoch key to the remaining set") /
|
|
3625
|
+
// vault-human-envelopes D5 ("Remove: next capture rotates the
|
|
3626
|
+
// epoch key to the remaining set") / vault-multi-writer D6: an
|
|
3627
3627
|
// outstanding MEMBERSHIP REMOVAL is completed by the next push of any
|
|
3628
3628
|
// surviving writer — rotate under the writer-capable
|
|
3629
3629
|
// reason:"member_removed", re-materialize on the rotated chain, and
|
|
@@ -3645,15 +3645,15 @@ export class GitvaultVault {
|
|
|
3645
3645
|
if (published.outcome === "conflict") {
|
|
3646
3646
|
conflicts += 1;
|
|
3647
3647
|
if (conflicts > this.retries)
|
|
3648
|
-
fail("HEAD_CAS_CONFLICT", `admission lost ${conflicts} races at generation ${published.generation}; giving up`, "publishing
|
|
3648
|
+
fail("HEAD_CAS_CONFLICT", `admission lost ${conflicts} races at generation ${published.generation}; giving up`, "publishing vault head", { generation: published.generation, winner: published.winner }, [{ action: "verify the attached winner from storage, rebase, retry" }]);
|
|
3649
3649
|
base = await this.materialize(); // re-verify from storage (the winner) before re-applying the transaction
|
|
3650
3650
|
continue;
|
|
3651
3651
|
}
|
|
3652
3652
|
// `push()` never sets `dry_run`, so this outcome is unreachable here —
|
|
3653
3653
|
// narrows `published` to `"admitted"` for the return below.
|
|
3654
3654
|
if (published.outcome === "dry_run")
|
|
3655
|
-
fail("GIT_COMMAND_FAILED", "internal: push() received a dry-run result it never requested", "publishing
|
|
3656
|
-
this.keystore.updateRepo(this.repoId, { last_ref_transaction: { generation: published.generation, transaction: options.transaction, at:
|
|
3655
|
+
fail("GIT_COMMAND_FAILED", "internal: push() received a dry-run result it never requested", "publishing vault head");
|
|
3656
|
+
this.keystore.updateRepo(this.repoId, { last_ref_transaction: { generation: published.generation, transaction: options.transaction, at: formatVaultTimestamp(this.now()) } });
|
|
3657
3657
|
return { generation: published.generation, head_sha256: published.head_sha256, head: published.head, admission_record_sha256: published.admission_record_sha256, capture_receipt: published.capture_receipt, form: published.form, conflicts_retried: conflicts, refs: published.refs, checkpoint_staleness: this.checkpointStalenessNow(published.generation) };
|
|
3658
3658
|
}
|
|
3659
3659
|
}
|
|
@@ -3672,8 +3672,8 @@ export class GitvaultVault {
|
|
|
3672
3672
|
*
|
|
3673
3673
|
* Never retries and never allocates: an unallocated vault has no `repo_id`
|
|
3674
3674
|
* (hence no encryption key) to preview a push against at all — callers
|
|
3675
|
-
* resolve the vault READ-ONLY first (see `
|
|
3676
|
-
* `../namespaces/
|
|
3675
|
+
* resolve the vault READ-ONLY first (see `Repos.planCapture` in
|
|
3676
|
+
* `../namespaces/repos.js`, which reports `allocation_needed: true` in
|
|
3677
3677
|
* that case instead of calling this method).
|
|
3678
3678
|
*/
|
|
3679
3679
|
async planPush(options) {
|
|
@@ -3692,7 +3692,7 @@ export class GitvaultVault {
|
|
|
3692
3692
|
// `"dry_run"` outcome — `"conflict"`/`"admitted"` are reachable only via
|
|
3693
3693
|
// `admit()`, which a dry run never calls.
|
|
3694
3694
|
if (published.outcome !== "dry_run")
|
|
3695
|
-
fail("GIT_COMMAND_FAILED", "internal: planPush did not receive a dry-run result", "planning
|
|
3695
|
+
fail("GIT_COMMAND_FAILED", "internal: planPush did not receive a dry-run result", "planning vault push");
|
|
3696
3696
|
const encryptedBytes = published.objects.reduce((sum, o) => sum + BigInt(o.size_bytes), 0n);
|
|
3697
3697
|
return {
|
|
3698
3698
|
base_generation: base.generation,
|
|
@@ -3721,7 +3721,7 @@ export class GitvaultVault {
|
|
|
3721
3721
|
* `prepared_at` alone would shorten the lane, which the protocol's own
|
|
3722
3722
|
* delayed-PUT vector calls out as the naive implementation.
|
|
3723
3723
|
*
|
|
3724
|
-
* This is NOT `run402
|
|
3724
|
+
* This is NOT `run402 repos gc`: the §7.2 maintenance CYCLE (purpose
|
|
3725
3725
|
* `maintenance_cycle`, C1/C2 roles, stage claim sets, prune intents, `R2_cap`
|
|
3726
3726
|
* accounting) is a separate protocol under compact authority, and gets its own
|
|
3727
3727
|
* method when it ships.
|
|
@@ -3737,13 +3737,13 @@ export class GitvaultVault {
|
|
|
3737
3737
|
if (published.outcome === "conflict") {
|
|
3738
3738
|
conflicts += 1;
|
|
3739
3739
|
if (conflicts > this.retries)
|
|
3740
|
-
fail("HEAD_CAS_CONFLICT", `the checkpoint lost ${conflicts} races at generation ${published.generation}; giving up`, "publishing
|
|
3740
|
+
fail("HEAD_CAS_CONFLICT", `the checkpoint lost ${conflicts} races at generation ${published.generation}; giving up`, "publishing vault checkpoint", { generation: published.generation, winner: published.winner }, [{ action: "verify the attached winner from storage, retry" }]);
|
|
3741
3741
|
continue;
|
|
3742
3742
|
}
|
|
3743
3743
|
// `publishCheckpoint` never sets `dry_run`, so this outcome is
|
|
3744
3744
|
// unreachable here — narrows `published` to `"admitted"` below.
|
|
3745
3745
|
if (published.outcome === "dry_run")
|
|
3746
|
-
fail("GIT_COMMAND_FAILED", "internal: publishCheckpoint() received a dry-run result it never requested", "publishing
|
|
3746
|
+
fail("GIT_COMMAND_FAILED", "internal: publishCheckpoint() received a dry-run result it never requested", "publishing vault checkpoint");
|
|
3747
3747
|
return { generation: published.generation, head_sha256: published.head_sha256, head: published.head, admission_record_sha256: published.admission_record_sha256, capture_receipt: published.capture_receipt, form: published.form, conflicts_retried: conflicts, refs: published.refs, checkpoint_staleness: this.checkpointStalenessNow(published.generation) };
|
|
3748
3748
|
}
|
|
3749
3749
|
}
|
|
@@ -3752,12 +3752,12 @@ export class GitvaultVault {
|
|
|
3752
3752
|
const issued = await this.transport.requestRetentionCutoff({ repo_id: this.repoId, base_head_sha256: baseHeadSha256 });
|
|
3753
3753
|
const t = issued.ticket;
|
|
3754
3754
|
if (t.object_kind !== "retention_cutoff" || t.repo_id !== this.repoId || t.base_head_sha256 !== baseHeadSha256) {
|
|
3755
|
-
fail("
|
|
3755
|
+
fail("VAULT_CUTOFF_TICKET_INVALID", `the retention cutoff ticket does not bind this vault's base head ${baseHeadSha256}`, "requesting retention cutoff ticket", { object_id: t.object_id, base_head_sha256: t.base_head_sha256 });
|
|
3756
3756
|
}
|
|
3757
3757
|
if (Date.parse(t.expires_at) <= this.now().getTime())
|
|
3758
|
-
fail("
|
|
3759
|
-
if (this.servicePublicKey && !
|
|
3760
|
-
fail("
|
|
3758
|
+
fail("VAULT_CUTOFF_TICKET_INVALID", `the retention cutoff ticket expired at ${t.expires_at}`, "requesting retention cutoff ticket", { object_id: t.object_id });
|
|
3759
|
+
if (this.servicePublicKey && !verifyVaultObject(t, this.servicePublicKey)) {
|
|
3760
|
+
fail("VAULT_CUTOFF_TICKET_INVALID", "the retention cutoff ticket is not signed by the pinned service key", "requesting retention cutoff ticket", { object_id: t.object_id, service_key_id: t.service_key_id });
|
|
3761
3761
|
}
|
|
3762
3762
|
return issued;
|
|
3763
3763
|
}
|
|
@@ -3777,15 +3777,15 @@ export class GitvaultVault {
|
|
|
3777
3777
|
*/
|
|
3778
3778
|
signHead(fields, overrides) {
|
|
3779
3779
|
const unsigned = {
|
|
3780
|
-
format:
|
|
3780
|
+
format: VAULT_FORMAT, object_kind: "head", suite: VAULT_SUITE, repo_id: this.repoId,
|
|
3781
3781
|
generation: fields.generation, prev_sha256: fields.prev_sha256, epoch: overrides?.epoch ?? this.epoch(),
|
|
3782
3782
|
wal_entries: fields.wal_entries, ref_state: fields.ref_state, retention_roots: fields.retention_roots,
|
|
3783
3783
|
checkpoint: fields.checkpoint, checkpoint_purpose: fields.checkpoint_purpose, capture_binding: fields.capture_binding,
|
|
3784
3784
|
repair: fields.repair, transition: overrides?.transition ?? null,
|
|
3785
3785
|
...(overrides?.pin_manifest ? { pin_manifest: overrides.pin_manifest } : {}),
|
|
3786
|
-
writer_key_id: this.writerKeyId(), created_at:
|
|
3786
|
+
writer_key_id: this.writerKeyId(), created_at: formatVaultTimestamp(this.now()),
|
|
3787
3787
|
};
|
|
3788
|
-
return
|
|
3788
|
+
return signVaultObject(unsigned, this.signer());
|
|
3789
3789
|
}
|
|
3790
3790
|
/** Admit a signed head; on success read it back from storage and compare BEFORE any pin advances. */
|
|
3791
3791
|
async admit(head) {
|
|
@@ -3797,27 +3797,27 @@ export class GitvaultVault {
|
|
|
3797
3797
|
// Post-admit readback is a spec-mandated verification obligation, never
|
|
3798
3798
|
// a cache lookup — it exists to prove the SERVER stored what was sent,
|
|
3799
3799
|
// which only a genuine network read can answer.
|
|
3800
|
-
const back = await this.transport.getObject({ repo_id: this.repoId, path:
|
|
3800
|
+
const back = await this.transport.getObject({ repo_id: this.repoId, path: vaultPaths.head(head.generation) });
|
|
3801
3801
|
if (!back || sha256Hex(back) !== hash) {
|
|
3802
|
-
fail("
|
|
3802
|
+
fail("VAULT_HEAD_READBACK_MISMATCH", `the admitted head at generation ${head.generation} read back ${back ? sha256Hex(back) : "absent"} ≠ ${hash}; the push is NOT reported as landed and no pin advances`, "reading back admitted head", { generation: head.generation, expected: hash, observed: back ? sha256Hex(back) : null });
|
|
3803
3803
|
}
|
|
3804
3804
|
// Design D3: the readback just network-confirmed these exact bytes —
|
|
3805
3805
|
// warm the cache with them so the NEXT operation (this session's own
|
|
3806
3806
|
// `materialize` retry, or a later invocation) can skip re-fetching this
|
|
3807
3807
|
// generation's head entirely.
|
|
3808
3808
|
this.keystore.writeCachedHead(this.repoId, head.generation, hash, back);
|
|
3809
|
-
const pin = { generation: head.generation, head_sha256: hash, pinned_at:
|
|
3809
|
+
const pin = { generation: head.generation, head_sha256: hash, pinned_at: formatVaultTimestamp(this.now()) };
|
|
3810
3810
|
this.keystore.updateRepo(this.repoId, {
|
|
3811
3811
|
head_pin: pin,
|
|
3812
3812
|
materialized_pin: pin,
|
|
3813
3813
|
verified_prefix: null,
|
|
3814
|
-
//
|
|
3814
|
+
// vault-clone-scaling (P3): a checkpoint-form head IS fresh
|
|
3815
3815
|
// coverage this checkout just learned first-hand.
|
|
3816
3816
|
...(head.checkpoint ? { checkpoint_covers_through: head.checkpoint.covers_through_generation } : {}),
|
|
3817
3817
|
});
|
|
3818
3818
|
return { outcome: "admitted", head_sha256: hash, admission_record_sha256: result.admission_record_sha256, capture_receipt: result.capture_receipt };
|
|
3819
3819
|
}
|
|
3820
|
-
// ── epoch rotation (D193-D203, rev 42, change
|
|
3820
|
+
// ── epoch rotation (D193-D203, rev 42, change vault-human-envelopes) ──
|
|
3821
3821
|
/**
|
|
3822
3822
|
* Walk the chain BACKWARD from the current tip to find the nearest
|
|
3823
3823
|
* admitted `recipient_pin_manifest` receipt (D197: "a fresh client... reads
|
|
@@ -3840,20 +3840,20 @@ export class GitvaultVault {
|
|
|
3840
3840
|
let gen = generationToBigInt(newestGeneration);
|
|
3841
3841
|
while (gen >= 1n) {
|
|
3842
3842
|
const generation = gen.toString(16).padStart(16, "0");
|
|
3843
|
-
const bytes = await this.transport.getObject({ repo_id: this.repoId, path:
|
|
3843
|
+
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: vaultPaths.head(generation) });
|
|
3844
3844
|
if (!bytes)
|
|
3845
3845
|
fail("CHAIN_BROKEN", `head ${generation} is missing from storage while resolving the effective pin manifest`, "resolving the effective recipient pin manifest", { generation });
|
|
3846
|
-
const head =
|
|
3846
|
+
const head = parseVaultStrict(new TextDecoder().decode(bytes));
|
|
3847
3847
|
if (head.pin_manifest)
|
|
3848
3848
|
return this.readPinManifestObject(head.pin_manifest);
|
|
3849
3849
|
gen -= 1n;
|
|
3850
3850
|
}
|
|
3851
3851
|
if (genesis.pin_manifest)
|
|
3852
3852
|
return this.readPinManifestObject(genesis.pin_manifest);
|
|
3853
|
-
return { pinManifestVersion: "0".repeat(16), pinManifestSha256:
|
|
3853
|
+
return { pinManifestVersion: "0".repeat(16), pinManifestSha256: VAULT_ZERO_SHA256_SENTINEL, pinnedFingerprintOf: new Map() };
|
|
3854
3854
|
}
|
|
3855
3855
|
/**
|
|
3856
|
-
* Local-cache short-circuit (see {@link
|
|
3856
|
+
* Local-cache short-circuit (see {@link VaultRepoFile.known_pin_manifest}'s
|
|
3857
3857
|
* own doc comment for why this is safe): a manifest THIS keystore itself
|
|
3858
3858
|
* just built, signed, and admitted is resolved from the on-disk cache
|
|
3859
3859
|
* instead of a network `object-reads` round trip — same return shape,
|
|
@@ -3877,18 +3877,18 @@ export class GitvaultVault {
|
|
|
3877
3877
|
if (known && known.pin_manifest_version === receipt.pin_manifest_version && known.stored_bytes_sha256 === receipt.stored_bytes_sha256) {
|
|
3878
3878
|
return { pinManifestVersion: known.pin_manifest_version, pinManifestSha256: known.stored_bytes_sha256, pinnedFingerprintOf: new Map(known.pins.map((p) => [p.principal_id, p.ek_fingerprint])) };
|
|
3879
3879
|
}
|
|
3880
|
-
const bytes = await this.transport.getObject({ repo_id: this.repoId, path:
|
|
3880
|
+
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: vaultPaths.pinManifest(receipt.pin_manifest_version), expected_sha256: receipt.stored_bytes_sha256 });
|
|
3881
3881
|
if (!bytes || sha256Hex(bytes) !== receipt.stored_bytes_sha256) {
|
|
3882
|
-
fail("
|
|
3882
|
+
fail("VAULT_RECEIPT_MISMATCH", `recipient_pin_manifest ${receipt.pin_manifest_version} is absent or does not match its receipted hash`, "resolving the effective recipient pin manifest", { pin_manifest_version: receipt.pin_manifest_version });
|
|
3883
3883
|
}
|
|
3884
|
-
const manifest =
|
|
3884
|
+
const manifest = parseVaultStrict(new TextDecoder().decode(bytes));
|
|
3885
3885
|
const { genesis } = await this.genesis();
|
|
3886
|
-
//
|
|
3886
|
+
// vault-multi-writer rev 47: resolve the manifest's OWN declared
|
|
3887
3887
|
// signer against the persisted writer set (same pattern + same
|
|
3888
3888
|
// documented residual as `verifyStoredCheckpoint`/`chainEntries` above).
|
|
3889
3889
|
const manifestSigner = this.repoFile().writer_set_pin?.writers.find((w) => w.writer_key_id === manifest.writer_key_id)?.signing_pubkey ?? genesis.creator_signing_pubkey;
|
|
3890
|
-
if (!
|
|
3891
|
-
fail("
|
|
3890
|
+
if (!verifyVaultObject(manifest, manifestSigner)) {
|
|
3891
|
+
fail("VAULT_SIGNATURE_INVALID", `recipient_pin_manifest ${receipt.pin_manifest_version} signature does not verify`, "resolving the effective recipient pin manifest", { pin_manifest_version: receipt.pin_manifest_version });
|
|
3892
3892
|
}
|
|
3893
3893
|
const pinnedFingerprintOf = new Map(manifest.pins.map((p) => [p.principal_id, p.ek_fingerprint]));
|
|
3894
3894
|
return { pinManifestVersion: receipt.pin_manifest_version, pinManifestSha256: receipt.stored_bytes_sha256, pinnedFingerprintOf };
|
|
@@ -3924,17 +3924,17 @@ export class GitvaultVault {
|
|
|
3924
3924
|
for (const [principalId, ekFp] of prior.pinnedFingerprintOf) {
|
|
3925
3925
|
if (updateIds.has(principalId))
|
|
3926
3926
|
continue; // replaced below
|
|
3927
|
-
pins.push({ principal_id: principalId, ek_fingerprint: ekFp, pinned_at:
|
|
3927
|
+
pins.push({ principal_id: principalId, ek_fingerprint: ekFp, pinned_at: formatVaultTimestamp(this.now()), confirmed_by: "operator_confirmation", confirmation_receipt_sha256: VAULT_ZERO_SHA256_SENTINEL });
|
|
3928
3928
|
}
|
|
3929
3929
|
for (const u of updates) {
|
|
3930
|
-
pins.push({ principal_id: u.principal_id, ek_fingerprint: u.ek_fingerprint, pinned_at:
|
|
3930
|
+
pins.push({ principal_id: u.principal_id, ek_fingerprint: u.ek_fingerprint, pinned_at: formatVaultTimestamp(this.now()), confirmed_by: "operator_confirmation", confirmation_receipt_sha256: storedBytesSha256(u.receipt) });
|
|
3931
3931
|
}
|
|
3932
3932
|
pins.sort((a, b) => (a.principal_id < b.principal_id ? -1 : a.principal_id > b.principal_id ? 1 : 0));
|
|
3933
|
-
const unsignedManifest = { format:
|
|
3934
|
-
const manifest =
|
|
3933
|
+
const unsignedManifest = { format: VAULT_FORMAT, object_kind: "recipient_pin_manifest", suite: VAULT_SUITE, repo_id: this.repoId, pin_manifest_version: nextVersion, base_pin_manifest_sha256: prior.pinManifestSha256, pins, writer_key_id: this.writerKeyId() };
|
|
3934
|
+
const manifest = signVaultObject(unsignedManifest, this.signer());
|
|
3935
3935
|
const manifestBytes = storedBytes(manifest);
|
|
3936
3936
|
const manifestSha = sha256Hex(manifestBytes);
|
|
3937
|
-
const upload = { path:
|
|
3937
|
+
const upload = { path: vaultPaths.pinManifest(nextVersion), object_kind: "recipient_pin_manifest", object_id: null, bytes: manifestBytes, sha256: manifestSha, size_bytes: String(manifestBytes.length) };
|
|
3938
3938
|
return { nextVersion, manifestSha, upload, pins: pins.map((p) => ({ principal_id: p.principal_id, ek_fingerprint: p.ek_fingerprint })) };
|
|
3939
3939
|
}
|
|
3940
3940
|
/**
|
|
@@ -3949,7 +3949,7 @@ export class GitvaultVault {
|
|
|
3949
3949
|
* **This is an ORDINARY admission (`transition: null`) and is therefore
|
|
3950
3950
|
* itself refused `EPOCH_ROTATION_REQUIRED` while a migration/revocation/
|
|
3951
3951
|
* exposure condition is outstanding on this vault (D193) — the exact
|
|
3952
|
-
* deadlock the incident behind {@link
|
|
3952
|
+
* deadlock the incident behind {@link Vault.rotateEpoch}'s
|
|
3953
3953
|
* `pending_confirmations` parameter closes.** When this vault is in that
|
|
3954
3954
|
* state, fold the receipt into `rotateEpoch({..., pending_confirmations:
|
|
3955
3955
|
* [{principal_id, ek_fingerprint, receipt}]})` instead of calling this
|
|
@@ -3960,7 +3960,7 @@ export class GitvaultVault {
|
|
|
3960
3960
|
let conflicts = 0;
|
|
3961
3961
|
for (;;) {
|
|
3962
3962
|
const base = await this.materialize();
|
|
3963
|
-
this.assertCallerIsWriter("publishing a recipient_pin_manifest update", conflicts > 0); //
|
|
3963
|
+
this.assertCallerIsWriter("publishing a recipient_pin_manifest update", conflicts > 0); // vault-multi-writer task 5.8
|
|
3964
3964
|
const prior = await this.loadEffectivePinManifest(base.generation);
|
|
3965
3965
|
const { nextVersion, manifestSha, upload: manifestUpload, pins } = this.buildPinManifestUpdate(prior, [input]);
|
|
3966
3966
|
await this.uploadAll([manifestUpload]);
|
|
@@ -4008,17 +4008,17 @@ export class GitvaultVault {
|
|
|
4008
4008
|
*
|
|
4009
4009
|
* **`recipient_state_version`/`recipient_revocation_version` are REQUIRED
|
|
4010
4010
|
* inputs, not discovered here.** The gateway exposes NO general read route
|
|
4011
|
-
* for `internal.
|
|
4011
|
+
* for `internal.vault_recipient_state_counters` (D194) — the ONLY
|
|
4012
4012
|
* client-visible read of these two org-scoped counters today is the
|
|
4013
4013
|
* response of `POST …/recipients/:principal_id/key-revocation`
|
|
4014
|
-
* ({@link
|
|
4014
|
+
* ({@link VaultTransport.declareRecipientKeyRevoked}), which is why
|
|
4015
4015
|
* {@link rotateEpochForKeyRevocation} (below) is the one fully
|
|
4016
4016
|
* self-contained entry point. For `member_removed` / `elective_rekey` /
|
|
4017
4017
|
* `epoch_secret_exposed`, a caller that does not already know the current
|
|
4018
4018
|
* counter pair cannot discover it from any shipped gateway route — this
|
|
4019
4019
|
* is a confirmed gap in the live gateway (verified against
|
|
4020
|
-
* `packages/gateway/src/services/
|
|
4021
|
-
* every `routes/
|
|
4020
|
+
* `packages/gateway/src/services/vault/reads.ts:getVaultRecord` and
|
|
4021
|
+
* every `routes/vault*.ts` handler, not inferred), not a client
|
|
4022
4022
|
* limitation this SDK can work around. Passing a stale/guessed pair fails
|
|
4023
4023
|
* safely: the admission fence's D194 frozen-counter comparison refuses
|
|
4024
4024
|
* `RECIPIENT_SET_MISMATCH` rather than silently canonizing against wrong
|
|
@@ -4041,7 +4041,7 @@ export class GitvaultVault {
|
|
|
4041
4041
|
* PREDECESSOR manifest, unchanged. They land in
|
|
4042
4042
|
* `excluded_unconfirmed_principal_ids` here (same as without folding) and
|
|
4043
4043
|
* become eligible starting at the NEXT rotation, once this manifest is
|
|
4044
|
-
* the admitted predecessor. See {@link
|
|
4044
|
+
* the admitted predecessor. See {@link VaultRotationResult.pin_manifest_published}.
|
|
4045
4045
|
*
|
|
4046
4046
|
* **Honest residual — this does not rescue a vault with ZERO ever-
|
|
4047
4047
|
* confirmed principals.** If `included` would be empty even with the
|
|
@@ -4059,7 +4059,7 @@ export class GitvaultVault {
|
|
|
4059
4059
|
let conflicts = 0;
|
|
4060
4060
|
for (;;) {
|
|
4061
4061
|
const base = await this.materialize();
|
|
4062
|
-
this.assertCallerIsWriter("driving an epoch rotation", conflicts > 0); //
|
|
4062
|
+
this.assertCallerIsWriter("driving an epoch rotation", conflicts > 0); // vault-multi-writer task 5.8
|
|
4063
4063
|
const repo = this.repoFile();
|
|
4064
4064
|
const currentEpoch = base.head?.epoch ?? this.epoch();
|
|
4065
4065
|
const newEpoch = nextEpoch(currentEpoch);
|
|
@@ -4077,7 +4077,7 @@ export class GitvaultVault {
|
|
|
4077
4077
|
const envelopeRecipients = await this.transport.listEnvelopeRecipients({ repo_id: this.repoId });
|
|
4078
4078
|
const desired = envelopeRecipients.desired;
|
|
4079
4079
|
if (!desired) {
|
|
4080
|
-
fail("
|
|
4080
|
+
fail("VAULT_DESIRED_STATE_UNAVAILABLE", "the gateway did not report desired-recipient state (desired[]) for this vault — an epoch rotation cannot compute its H-partition without it", "computing the epoch-rotation H-partition");
|
|
4081
4081
|
}
|
|
4082
4082
|
const pinManifest = await this.loadEffectivePinManifest(base.generation);
|
|
4083
4083
|
// The vault record: `writer_set` (the chain's active writers, by
|
|
@@ -4155,7 +4155,7 @@ export class GitvaultVault {
|
|
|
4155
4155
|
included.push({ principal_id: d.principal_id, ek_fingerprint: d.ek_fingerprint, public_key: pub });
|
|
4156
4156
|
}
|
|
4157
4157
|
if (pinBlocked.length > 0) {
|
|
4158
|
-
fail("
|
|
4158
|
+
fail("VAULT_ROTATION_BLOCKED_PIN_MISMATCH", `${pinBlocked.length} principal(s) have a live pin that disagrees with their current directory key and cannot be safely included or excluded under the protocol's own H-partition rules — run the /repin ceremony for each, then retry: ${pinBlocked.map((p) => p.principal_id).join(", ")}`, "computing the epoch-rotation H-partition", { blocked: pinBlocked });
|
|
4159
4159
|
}
|
|
4160
4160
|
if (options.reason === "elective_rekey" && (excludedKeyless.length > 0 || excludedUnconfirmed.length > 0)) {
|
|
4161
4161
|
fail("EPOCH_ROTATION_INCOMPLETE_ENROLLMENT", `reason:"elective_rekey" refuses a target set that is a proper subset of the vault's current actual coverage — ${excludedKeyless.length} keyless, ${excludedUnconfirmed.length} unconfirmed`, "computing the epoch-rotation H-partition", { excluded_keyless_principal_ids: excludedKeyless, excluded_unconfirmed_principal_ids: excludedUnconfirmed });
|
|
@@ -4178,7 +4178,7 @@ export class GitvaultVault {
|
|
|
4178
4178
|
excluded_keyless_principal_ids: excludedKeyless,
|
|
4179
4179
|
excluded_unconfirmed_principal_ids: excludedUnconfirmed,
|
|
4180
4180
|
});
|
|
4181
|
-
//
|
|
4181
|
+
// vault-multi-writer (task 5.9, D6/D227) — fold in a writer_set_update
|
|
4182
4182
|
// whenever the gateway's own blocked set (`ineligible_members`, read
|
|
4183
4183
|
// fresh) is non-empty. This is what closes D6's "ordinary
|
|
4184
4184
|
// admissions... refuse EPOCH_ROTATION_REQUIRED... until a rotate_epoch
|
|
@@ -4209,7 +4209,7 @@ export class GitvaultVault {
|
|
|
4209
4209
|
let writerSetUpdate = null;
|
|
4210
4210
|
if (ineligible.length > 0) {
|
|
4211
4211
|
if (!writerPin) {
|
|
4212
|
-
fail("
|
|
4212
|
+
fail("VAULT_WRITER_STATE_UNAVAILABLE", "no locally verified writer_set_pin for this vault — the chain must be verified (materialize/verifyToNewest) before a writer_set_update can be built against a known base writer set", "computing the writer_set_update");
|
|
4213
4213
|
}
|
|
4214
4214
|
const predecessorState = { version: writerPin.version, writers: writerPin.writers, sha256: writerPin.sha256, burnedWriterKeyIds: new Set(), consumedHandoffIds: new Set(), retiredWriters: writerPin.retired_writers ?? [] };
|
|
4215
4215
|
// A rotation's own top-level `reason:"epoch_secret_exposed"` is the
|
|
@@ -4246,7 +4246,7 @@ export class GitvaultVault {
|
|
|
4246
4246
|
// `false`; omitting the field is refused RECIPIENT_SET_MISMATCH.
|
|
4247
4247
|
const migrationBootstrap = false;
|
|
4248
4248
|
const descriptorFields = {
|
|
4249
|
-
format:
|
|
4249
|
+
format: VAULT_FORMAT, object_kind: "rotation_attempt_descriptor", suite: VAULT_SUITE,
|
|
4250
4250
|
repo_id: this.repoId, base_head_sha256: base.head_sha256, new_epoch: newEpoch,
|
|
4251
4251
|
recipient_state_version: options.recipient_state_version, recipient_revocation_version: options.recipient_revocation_version,
|
|
4252
4252
|
migration_bootstrap: migrationBootstrap,
|
|
@@ -4256,7 +4256,7 @@ export class GitvaultVault {
|
|
|
4256
4256
|
...(writerSetUpdate ? { writer_revocation_version: vaultRecord.writer_revocation_version ?? "0", writer_set_base_sha256: writerSetUpdate.base_sha256, writer_set_next_sha256: writerSetUpdate.next_sha256 } : {}),
|
|
4257
4257
|
};
|
|
4258
4258
|
const attemptCommitment = attemptKeyCommitment(kE, this.repoId, newEpoch, descriptorFields);
|
|
4259
|
-
const signedDescriptor =
|
|
4259
|
+
const signedDescriptor = signVaultObject({ ...descriptorFields, attempt_key_commitment: attemptCommitment }, this.signer());
|
|
4260
4260
|
const rotationId = computeRotationId(signedDescriptor);
|
|
4261
4261
|
const attemptOut = await this.transport.createRotationAttempt({ repo_id: this.repoId, descriptor: signedDescriptor });
|
|
4262
4262
|
if (attemptOut.rotation_id !== rotationId) {
|
|
@@ -4265,11 +4265,11 @@ export class GitvaultVault {
|
|
|
4265
4265
|
// Seal one key_envelope per included recipient, all from the SAME kE (D200's global one-key property depends on this).
|
|
4266
4266
|
const sealedReceipts = [];
|
|
4267
4267
|
const envelopeUploads = [];
|
|
4268
|
-
const nowIso =
|
|
4268
|
+
const nowIso = formatVaultTimestamp(this.now());
|
|
4269
4269
|
for (const p of included) {
|
|
4270
4270
|
const sealed = await sealKeyEnvelope({ k_repo: kE, repo_id: this.repoId, epoch: newEpoch, recipient_public_key: p.public_key, signer: this.signingKeypair(), created_at: nowIso, rotation_id: rotationId });
|
|
4271
4271
|
sealedReceipts.push({ principal_id: p.principal_id, envelope: sealed.receipt });
|
|
4272
|
-
envelopeUploads.push({ path:
|
|
4272
|
+
envelopeUploads.push({ path: vaultPaths.envelope(newEpoch, p.ek_fingerprint, rotationId), object_kind: "key_envelope", object_id: null, bytes: sealed.stored_bytes, sha256: sealed.stored_bytes_sha256, size_bytes: sealed.size_bytes });
|
|
4273
4273
|
}
|
|
4274
4274
|
if (envelopeUploads.length > 0)
|
|
4275
4275
|
await this.uploadAll(envelopeUploads);
|
|
@@ -4282,7 +4282,7 @@ export class GitvaultVault {
|
|
|
4282
4282
|
pin_manifest_sha256: pinManifest.pinManifestSha256, target_partition_digest: targetPartitionDigest,
|
|
4283
4283
|
epoch_key_commitment: epochKeyCommitmentValue, excluded_keyless_principal_ids: excludedKeyless, excluded_unconfirmed_principal_ids: excludedUnconfirmed,
|
|
4284
4284
|
recipient_authority_attestation: null, envelopes: sealedReceipts,
|
|
4285
|
-
//
|
|
4285
|
+
// vault-multi-writer (task 5.9, D227): present IFF this rotation ALSO removes one or more writers.
|
|
4286
4286
|
...(writerSetUpdate ? { writer_set_update: writerSetUpdate } : {}),
|
|
4287
4287
|
};
|
|
4288
4288
|
// D209 (rev 44) — round-trip THIS principal's own new-epoch
|
|
@@ -4304,7 +4304,7 @@ export class GitvaultVault {
|
|
|
4304
4304
|
// rotation committed server-side that even its own writer cannot
|
|
4305
4305
|
// read back. A round-trip failure THROWS
|
|
4306
4306
|
// (openEpochRotationForRecipient's own error framing — e.g.
|
|
4307
|
-
//
|
|
4307
|
+
// VAULT_EPOCH_NOT_OPENABLE / EPOCH_KEY_COMMITMENT_MISMATCH — never
|
|
4308
4308
|
// a bare AEAD failure); this call never emits a false attestation.
|
|
4309
4309
|
const identity = this.keystore.readIdentity();
|
|
4310
4310
|
const ownKeypair = identity ? this.keystore.encryptionKeypair(identity) : null;
|
|
@@ -4320,7 +4320,7 @@ export class GitvaultVault {
|
|
|
4320
4320
|
own_encryption_keypair: ownKeypair,
|
|
4321
4321
|
writer_signing_public_key: this.signingKeypair().public_key,
|
|
4322
4322
|
get_envelope_bytes: (path) => this.transport.getObject({ repo_id: this.repoId, path }),
|
|
4323
|
-
envelope_path: (epoch, fp, rid) =>
|
|
4323
|
+
envelope_path: (epoch, fp, rid) => vaultPaths.envelope(epoch, fp, rid),
|
|
4324
4324
|
});
|
|
4325
4325
|
// openEpochRotationForRecipient already recomputes epoch_key_commitment
|
|
4326
4326
|
// from the opened plaintext and compares it to payloadBase's own
|
|
@@ -4334,7 +4334,7 @@ export class GitvaultVault {
|
|
|
4334
4334
|
chain_verified_to_generation: base.generation,
|
|
4335
4335
|
decryptable_to_generation: generation,
|
|
4336
4336
|
opened_fingerprint: ownFingerprint,
|
|
4337
|
-
reader_entrypoint:
|
|
4337
|
+
reader_entrypoint: vaultReaderEntrypoint("openEpochRotationForRecipient"),
|
|
4338
4338
|
};
|
|
4339
4339
|
selfCheck = "passed";
|
|
4340
4340
|
}
|
|
@@ -4406,12 +4406,12 @@ export class GitvaultVault {
|
|
|
4406
4406
|
if (pinManifestFold) {
|
|
4407
4407
|
this.keystore.updateRepo(this.repoId, { known_pin_manifest: { pin_manifest_version: pinManifestFold.nextVersion, stored_bytes_sha256: pinManifestFold.manifestSha, pins: pinManifestFold.pins } });
|
|
4408
4408
|
}
|
|
4409
|
-
//
|
|
4409
|
+
// vault-multi-writer (task 5.9) — the SAME local-pin advance
|
|
4410
4410
|
// #publishAddWriterKeyTransition performs on ITS success path: this
|
|
4411
4411
|
// head just admitted a writer_set_update, so the locally-pinned
|
|
4412
4412
|
// writer set is now stale until refreshed here.
|
|
4413
4413
|
if (writerSetUpdate) {
|
|
4414
|
-
this.keystore.updateRepo(this.repoId, { writer_set_pin: { version: writerSetUpdate.next_version, sha256: writerSetUpdate.next_sha256, writers: [...writerSetUpdate.writers], pinned_at:
|
|
4414
|
+
this.keystore.updateRepo(this.repoId, { writer_set_pin: { version: writerSetUpdate.next_version, sha256: writerSetUpdate.next_sha256, writers: [...writerSetUpdate.writers], pinned_at: formatVaultTimestamp(this.now()) } });
|
|
4415
4415
|
}
|
|
4416
4416
|
return {
|
|
4417
4417
|
outcome: "admitted", generation, head_sha256: admitted.head_sha256, new_epoch: newEpoch, rotation_id: rotationId, reason: options.reason,
|
|
@@ -4425,14 +4425,14 @@ export class GitvaultVault {
|
|
|
4425
4425
|
}
|
|
4426
4426
|
/**
|
|
4427
4427
|
* The writer-capable rotation that completes an org membership removal
|
|
4428
|
-
* (
|
|
4428
|
+
* (vault-multi-writer D6: "`member_removed` keeps its automatic
|
|
4429
4429
|
* writer-capable path"). The removal itself already advanced the org's
|
|
4430
4430
|
* D194 counters and flipped the member to `pending_removal`, so there is
|
|
4431
4431
|
* nothing to declare: read the counters off the envelope-recipients read
|
|
4432
4432
|
* (the same read the H-partition uses) and rotate under
|
|
4433
4433
|
* `reason:"member_removed"`, which needs `gitvault.writer` only — any
|
|
4434
4434
|
* surviving writer can run it, no owner step-up. Refuses
|
|
4435
|
-
* `
|
|
4435
|
+
* `VAULT_ROTATION_COUNTERS_UNAVAILABLE` on a gateway that does not yet
|
|
4436
4436
|
* carry the counters on that read.
|
|
4437
4437
|
*/
|
|
4438
4438
|
async rotateEpochForMemberRemoval(options = {}) {
|
|
@@ -4440,7 +4440,7 @@ export class GitvaultVault {
|
|
|
4440
4440
|
const state = recipients.recipient_state_version;
|
|
4441
4441
|
const revocation = recipients.recipient_revocation_version;
|
|
4442
4442
|
if (typeof state !== "string" || typeof revocation !== "string") {
|
|
4443
|
-
fail("
|
|
4443
|
+
fail("VAULT_ROTATION_COUNTERS_UNAVAILABLE", "the gateway did not report the org's rotation counters on the envelope-recipients read — a member-removal rotation cannot be fenced without them; an owner can run `run402 repos access revoke-key <principal_id>` instead", "reading the rotation counters");
|
|
4444
4444
|
}
|
|
4445
4445
|
return this.rotateEpoch({ reason: "member_removed", recipient_state_version: state, recipient_revocation_version: revocation, client_idempotency_key: options.client_idempotency_key });
|
|
4446
4446
|
}
|
|
@@ -4454,7 +4454,7 @@ export class GitvaultVault {
|
|
|
4454
4454
|
const counters = await this.transport.declareRecipientKeyRevoked({ repo_id: this.repoId, principal_id: principalId });
|
|
4455
4455
|
return this.rotateEpoch({ reason: "recipient_key_revoked", recipient_state_version: counters.recipient_state_version, recipient_revocation_version: counters.recipient_revocation_version, client_idempotency_key: options.client_idempotency_key });
|
|
4456
4456
|
}
|
|
4457
|
-
// ── writer activation (
|
|
4457
|
+
// ── writer activation (vault-multi-writer task 5.6, design D4/D5) ──────
|
|
4458
4458
|
/**
|
|
4459
4459
|
* Submit a REF-NEUTRAL `add_writer_key{authorization.kind:"handoff"}`
|
|
4460
4460
|
* activation head — no ref/checkpoint/repair changes ride it (protocol
|
|
@@ -4484,13 +4484,13 @@ export class GitvaultVault {
|
|
|
4484
4484
|
*/
|
|
4485
4485
|
async submitWriterActivationHead(input) {
|
|
4486
4486
|
return this.#publishAddWriterKeyTransition(input.addedWriterKeyId, (pin) => buildAddWriterKeyActivationPayload(this.repoId, pin, { writer_key_id: input.addedWriterKeyId, signing_pubkey: input.addedSigningPubkeyB64u, principal_id: input.addedPrincipalId }, input.handoffId, input.grant, input.acceptance), "submitting a writer activation head",
|
|
4487
|
-
//
|
|
4487
|
+
// vault-multi-writer task 5.8: the handoff door is signed by the
|
|
4488
4488
|
// ADDED key itself, which is by design NOT YET a writer — the pre-
|
|
4489
4489
|
// check must be SKIPPED here, or this call could never succeed.
|
|
4490
4490
|
false);
|
|
4491
4491
|
}
|
|
4492
4492
|
/**
|
|
4493
|
-
*
|
|
4493
|
+
* vault-multi-writer (task 5.7) — the "writer"-door twin of {@link
|
|
4494
4494
|
* submitWriterActivationHead}: an ALREADY-ACTIVE writer (this vault
|
|
4495
4495
|
* session's own key) admits `input`, an eligible org member with a
|
|
4496
4496
|
* published signing key but no writer standing yet, via a fresh
|
|
@@ -4500,8 +4500,8 @@ export class GitvaultVault {
|
|
|
4500
4500
|
* branch), re-verified admission-side by `checkTransitionAdmissible`.
|
|
4501
4501
|
* Task 5.8 adds the SAME local pre-check every other head-signing path
|
|
4502
4502
|
* gets: if THIS session's own key is not (or is no longer) an active
|
|
4503
|
-
* writer, refuse EARLY and LOCALLY (`
|
|
4504
|
-
* `
|
|
4503
|
+
* writer, refuse EARLY and LOCALLY (`VAULT_WRITER_NOT_ADMITTED` /
|
|
4504
|
+
* `VAULT_WRITER_REMOVED`) rather than let the head reach the gateway
|
|
4505
4505
|
* only to be refused there — redundant with `reconcileWriterAdmissions`'s
|
|
4506
4506
|
* own upfront gate for that caller, but this method is public and a
|
|
4507
4507
|
* direct caller should get the same fast, specific refusal.
|
|
@@ -4533,7 +4533,7 @@ export class GitvaultVault {
|
|
|
4533
4533
|
this.assertCallerIsWriter(errorContext, conflicts > 0);
|
|
4534
4534
|
const pin = this.repoFile().writer_set_pin;
|
|
4535
4535
|
if (!pin) {
|
|
4536
|
-
fail("
|
|
4536
|
+
fail("VAULT_WRITER_STATE_UNAVAILABLE", "no locally verified writer_set_pin for this vault — the chain must be verified (materialize/verifyToNewest) before an activation head can be built against a known base writer set", errorContext);
|
|
4537
4537
|
}
|
|
4538
4538
|
if (pin.writers.some((w) => w.writer_key_id === addedWriterKeyId)) {
|
|
4539
4539
|
return { outcome: "already_admitted", generation: base.generation };
|
|
@@ -4554,7 +4554,7 @@ export class GitvaultVault {
|
|
|
4554
4554
|
}
|
|
4555
4555
|
if (published.outcome === "dry_run")
|
|
4556
4556
|
fail("GIT_COMMAND_FAILED", "internal: #publishAddWriterKeyTransition() received a dry-run result it never requested", errorContext);
|
|
4557
|
-
this.keystore.updateRepo(this.repoId, { writer_set_pin: { version: nextState.version, sha256: nextState.sha256, writers: [...nextState.writers], pinned_at:
|
|
4557
|
+
this.keystore.updateRepo(this.repoId, { writer_set_pin: { version: nextState.version, sha256: nextState.sha256, writers: [...nextState.writers], pinned_at: formatVaultTimestamp(this.now()) } });
|
|
4558
4558
|
return {
|
|
4559
4559
|
outcome: "activated",
|
|
4560
4560
|
result: { generation: published.generation, head_sha256: published.head_sha256, head: published.head, admission_record_sha256: published.admission_record_sha256, capture_receipt: published.capture_receipt, form: published.form, conflicts_retried: conflicts, refs: published.refs, checkpoint_staleness: this.checkpointStalenessNow(published.generation) },
|
|
@@ -4562,7 +4562,7 @@ export class GitvaultVault {
|
|
|
4562
4562
|
}
|
|
4563
4563
|
}
|
|
4564
4564
|
/**
|
|
4565
|
-
*
|
|
4565
|
+
* vault-multi-writer (task 5.7) — the vault's OWN writer-admission
|
|
4566
4566
|
* reconcile: reads `pending_writers[]` off the vault record (eligible org
|
|
4567
4567
|
* members with no writer standing yet), resolves each candidate's
|
|
4568
4568
|
* published signing key off the org's encryption-key directory (the SAME
|
|
@@ -4580,7 +4580,7 @@ export class GitvaultVault {
|
|
|
4580
4580
|
* a session that reached this call already pushed successfully as an
|
|
4581
4581
|
* active writer in every wired call site (push/snapshot/deploy) OR is a
|
|
4582
4582
|
* member that simply is not one yet (session-start/read) — the SAME
|
|
4583
|
-
* "not yet admitted" case {@link
|
|
4583
|
+
* "not yet admitted" case {@link Repos.capture}'s own pre-push check
|
|
4584
4584
|
* (task 5.8) names, not a new refusal shape. Returns `{eligible: false}`
|
|
4585
4585
|
* with every other field empty (never throws) for that case: every OTHER
|
|
4586
4586
|
* `pending_writers[]` entry would fail identically, so there is nothing
|
|
@@ -4641,7 +4641,7 @@ export class GitvaultVault {
|
|
|
4641
4641
|
*/
|
|
4642
4642
|
async repair(input) {
|
|
4643
4643
|
const newest = await this.verifyToNewest();
|
|
4644
|
-
//
|
|
4644
|
+
// vault-multi-writer task 5.8 — repair requires OWNER+step-up
|
|
4645
4645
|
// server-side (`authority.repair`), but that is layered ON TOP of the
|
|
4646
4646
|
// baseline writer requirement every head needs (`authority.writer`,
|
|
4647
4647
|
// confirmed against the gateway's own admission fixture: an owner who
|
|
@@ -4660,8 +4660,8 @@ export class GitvaultVault {
|
|
|
4660
4660
|
// The base head's carriers were sealed by the writer that carried THAT
|
|
4661
4661
|
// head (rev 47: any admitted writer, not only the creator).
|
|
4662
4662
|
const writerKey = this.writerKeyForHead(baseEntry.head, newest.genesis.creator_signing_pubkey);
|
|
4663
|
-
const baseRefState = await this.openCarrier("ref_state", baseEntry.head.ref_state,
|
|
4664
|
-
const baseRoots = await this.openCarrier("retention_roots", baseEntry.head.retention_roots,
|
|
4663
|
+
const baseRefState = await this.openCarrier("ref_state", baseEntry.head.ref_state, vaultPaths.refState(baseEntry.head.ref_state.object_id), writerKey);
|
|
4664
|
+
const baseRoots = await this.openCarrier("retention_roots", baseEntry.head.retention_roots, vaultPaths.retentionRoots(baseEntry.head.retention_roots.object_id), writerKey);
|
|
4665
4665
|
const repairGen = nextGeneration(newest.generation);
|
|
4666
4666
|
const repairedRefs = baseRefState.refs;
|
|
4667
4667
|
const repairedTips = Object.values(repairedRefs);
|
|
@@ -4670,7 +4670,7 @@ export class GitvaultVault {
|
|
|
4670
4670
|
const entry = chain.get(bigIntToGeneration(g));
|
|
4671
4671
|
let state;
|
|
4672
4672
|
try {
|
|
4673
|
-
state = await this.openCarrier("ref_state", entry.head.ref_state,
|
|
4673
|
+
state = await this.openCarrier("ref_state", entry.head.ref_state, vaultPaths.refState(entry.head.ref_state.object_id), writerKey);
|
|
4674
4674
|
}
|
|
4675
4675
|
catch {
|
|
4676
4676
|
continue; // an unusable superseded ref_state preserves nothing it cannot name (reason unusable_ref_state)
|
|
@@ -4689,8 +4689,8 @@ export class GitvaultVault {
|
|
|
4689
4689
|
}
|
|
4690
4690
|
}
|
|
4691
4691
|
}
|
|
4692
|
-
if (added.length >
|
|
4693
|
-
fail("REF_STATE_LIMIT_EXCEEDED", `${added.length} repair-added roots exceed the ${
|
|
4692
|
+
if (added.length > VAULT_MAX_REPAIR_ADDED_ROOTS)
|
|
4693
|
+
fail("REF_STATE_LIMIT_EXCEEDED", `${added.length} repair-added roots exceed the ${VAULT_MAX_REPAIR_ADDED_ROOTS} bound`, "preparing repair");
|
|
4694
4694
|
const roots = evolveRetentionRoots(baseRoots.roots, { generation: repairGen, dropped: added });
|
|
4695
4695
|
const refState = this.buildRefState(repairGen, repairedRefs, baseRefState.head_target);
|
|
4696
4696
|
const rootsObj = this.buildRetentionRoots(repairGen, roots, null);
|
|
@@ -4735,13 +4735,13 @@ export class GitvaultVault {
|
|
|
4735
4735
|
const stop = generationToBigInt(baseGeneration);
|
|
4736
4736
|
while (g >= stop) {
|
|
4737
4737
|
const gen = bigIntToGeneration(g);
|
|
4738
|
-
const bytes = await this.transport.getObject({ repo_id: this.repoId, path:
|
|
4738
|
+
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: vaultPaths.head(gen) });
|
|
4739
4739
|
if (!bytes || sha256Hex(bytes) !== expected)
|
|
4740
|
-
fail("CHAIN_BROKEN", `head ${gen} no longer matches the verified chain`, "re-reading
|
|
4741
|
-
const head =
|
|
4740
|
+
fail("CHAIN_BROKEN", `head ${gen} no longer matches the verified chain`, "re-reading vault chain", { generation: gen });
|
|
4741
|
+
const head = parseVaultStrict(new TextDecoder().decode(bytes));
|
|
4742
4742
|
if (transitions === "refuse") {
|
|
4743
4743
|
if (head.transition !== null)
|
|
4744
|
-
fail("UPGRADE_REQUIRED", "a repair must not cross an admitted transition", "re-reading
|
|
4744
|
+
fail("UPGRADE_REQUIRED", "a repair must not cross an admitted transition", "re-reading vault chain", { generation: gen });
|
|
4745
4745
|
}
|
|
4746
4746
|
else {
|
|
4747
4747
|
assertNoTransition(head);
|
|
@@ -4758,10 +4758,10 @@ export class GitvaultVault {
|
|
|
4758
4758
|
* `targetRepoDir` (an initialized repository), then verify every canonical
|
|
4759
4759
|
* ref + the HEAD target resolves. Returns the materialized refs.
|
|
4760
4760
|
*
|
|
4761
|
-
* Design D5 (
|
|
4761
|
+
* Design D5 (vault-client-round-trips): incremental above the local
|
|
4762
4762
|
* `restored_through` marker (this target directory's own local git
|
|
4763
|
-
* config, read/written by {@link
|
|
4764
|
-
*
|
|
4763
|
+
* config, read/written by {@link readVaultRestoreMarker}/{@link
|
|
4764
|
+
* writeVaultRestoreMarker}) — replaying only the WAL packs of
|
|
4765
4765
|
* generations above it — WHEN the walk from newest back to the marker is
|
|
4766
4766
|
* plain WAL the entire way. The moment that walk crosses a
|
|
4767
4767
|
* checkpoint-bearing, repair, or transition head (checked BEFORE
|
|
@@ -4773,12 +4773,12 @@ export class GitvaultVault {
|
|
|
4773
4773
|
* paths; the marker only advances after they both succeed.
|
|
4774
4774
|
*/
|
|
4775
4775
|
async restoreObjectsInto(targetRepoDir, reuse) {
|
|
4776
|
-
//
|
|
4776
|
+
// vault-delta-fetch: the marker is read BEFORE materialize so the
|
|
4777
4777
|
// state read can carry THIS git dir's applied position as `since` —
|
|
4778
4778
|
// see tryStateFastPath's own comment on why the pin is the wrong
|
|
4779
4779
|
// position for a standing clone.
|
|
4780
4780
|
//
|
|
4781
|
-
//
|
|
4781
|
+
// vault-session-state-reuse: `reuse` lets a caller that ALREADY
|
|
4782
4782
|
// materialized state for this same target directory — using the SAME
|
|
4783
4783
|
// marker `since` this method would itself read — hand that response
|
|
4784
4784
|
// in directly, skipping this method's own state read entirely (the
|
|
@@ -4791,9 +4791,9 @@ export class GitvaultVault {
|
|
|
4791
4791
|
// session, a stale handoff) falls through to this method's own
|
|
4792
4792
|
// `materialize()` exactly as if `reuse` had never been passed. A wrong
|
|
4793
4793
|
// reuse therefore costs one extra read, never a wrong result.
|
|
4794
|
-
const marker = await
|
|
4794
|
+
const marker = await readVaultRestoreMarker(targetRepoDir);
|
|
4795
4795
|
const markerMatches = (a, b) => a === null ? b === null : b !== null && a.generation === b.generation && a.head_sha256 === b.head_sha256;
|
|
4796
|
-
//
|
|
4796
|
+
// vault-restore-recipe design D2/D6: a marker-absent materialize is,
|
|
4797
4797
|
// by construction, the wholesale shape (a fresh target has nothing to
|
|
4798
4798
|
// replay incrementally) — declare restore intent here so a capable
|
|
4799
4799
|
// gateway's plan rides THIS read rather than a second one. Mirrors the
|
|
@@ -4811,16 +4811,16 @@ export class GitvaultVault {
|
|
|
4811
4811
|
// Already up to date locally: no pack fetch, not even a walk — only the
|
|
4812
4812
|
// (entirely local) coverage + retained-refs bookkeeping below runs.
|
|
4813
4813
|
if (marker && marker.generation === newest.generation && marker.head_sha256 === newest.head_sha256) {
|
|
4814
|
-
const tips =
|
|
4814
|
+
const tips = Vault.coverageTips(newest.refs, newest.roots, newest.head_target);
|
|
4815
4815
|
const present = await hasObjects(targetRepoDir, tips);
|
|
4816
4816
|
for (const t of tips) {
|
|
4817
4817
|
if (!present.has(t))
|
|
4818
|
-
fail("CHAIN_UNUSABLE", `covered tip ${t} does not resolve after restore`, "restoring
|
|
4818
|
+
fail("CHAIN_UNUSABLE", `covered tip ${t} does not resolve after restore`, "restoring vault objects", { oid: t });
|
|
4819
4819
|
}
|
|
4820
4820
|
const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: newest.refs, roots: newest.roots, head_target: newest.head_target });
|
|
4821
4821
|
return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
|
|
4822
4822
|
}
|
|
4823
|
-
//
|
|
4823
|
+
// vault-restore-recipe (design D1-D6): consume a plan that already
|
|
4824
4824
|
// rode this method's OWN materialize() (the `restore: true` request
|
|
4825
4825
|
// above, or the reused list-phase read) BEFORE paying for the backward
|
|
4826
4826
|
// walk below at all. `tryConsumeRestorePlan` verifies from scratch and
|
|
@@ -4841,7 +4841,7 @@ export class GitvaultVault {
|
|
|
4841
4841
|
const heads = [];
|
|
4842
4842
|
let cur = newest.head;
|
|
4843
4843
|
let incremental = marker !== null;
|
|
4844
|
-
//
|
|
4844
|
+
// vault-clone-scaling (bench P2): the backward walk's head PATHS are
|
|
4845
4845
|
// all derivable up front (generation N−1, N−2, …) — only each head's
|
|
4846
4846
|
// expected hash arrives chain-sequentially. Same fetch-concurrent /
|
|
4847
4847
|
// verify-ordered split as the forward walk: page-sized windows of
|
|
@@ -4880,7 +4880,7 @@ export class GitvaultVault {
|
|
|
4880
4880
|
if (lo < 1n)
|
|
4881
4881
|
lo = 1n;
|
|
4882
4882
|
nextWindow = nextWindow * 4n;
|
|
4883
|
-
const pageCap = BigInt(
|
|
4883
|
+
const pageCap = BigInt(VAULT_MAX_HEADS_PER_LISTING_PAGE);
|
|
4884
4884
|
if (nextWindow > pageCap)
|
|
4885
4885
|
nextWindow = pageCap;
|
|
4886
4886
|
prefetchFloor = lo;
|
|
@@ -4905,7 +4905,7 @@ export class GitvaultVault {
|
|
|
4905
4905
|
incremental = false;
|
|
4906
4906
|
heads.length = 0;
|
|
4907
4907
|
cur = newest.head;
|
|
4908
|
-
//
|
|
4908
|
+
// vault-restore-recipe (design D1/D2, task 2.1's named fallback
|
|
4909
4909
|
// shape): the marker WAS present when this method's own materialize()
|
|
4910
4910
|
// ran, so `restore` was never declared and no plan rode that read —
|
|
4911
4911
|
// fetch one now, dedicated, before falling through to the ordinary
|
|
@@ -4946,13 +4946,13 @@ export class GitvaultVault {
|
|
|
4946
4946
|
await prefetchBackwardWindow(prevBig);
|
|
4947
4947
|
const bytes = await this.readCachedHeadBytes(prevGen, cur.prev_sha256);
|
|
4948
4948
|
if (!bytes || sha256Hex(bytes) !== cur.prev_sha256)
|
|
4949
|
-
fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during restore`, "restoring
|
|
4950
|
-
cur =
|
|
4949
|
+
fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during restore`, "restoring vault objects");
|
|
4950
|
+
cur = parseVaultStrict(new TextDecoder().decode(bytes));
|
|
4951
4951
|
}
|
|
4952
4952
|
return this.applyRestoreHeads(targetRepoDir, heads, incremental, newest, writerKey, null, null);
|
|
4953
4953
|
}
|
|
4954
4954
|
/**
|
|
4955
|
-
* The shared restore tail (
|
|
4955
|
+
* The shared restore tail (vault-restore-recipe): apply `heads`'
|
|
4956
4956
|
* checkpoint (if any) + WAL packs into `targetRepoDir`, verify §4.7
|
|
4957
4957
|
* coverage, reconcile retained refs, and advance the marker — IDENTICALLY
|
|
4958
4958
|
* whether `heads` came from {@link restoreObjectsInto}'s own backward walk
|
|
@@ -4972,10 +4972,10 @@ export class GitvaultVault {
|
|
|
4972
4972
|
* packs (`stateDeltaPacks` never carries checkpoint packs — a delta span
|
|
4973
4973
|
* never crosses a checkpoint boundary). Both are `null` on the ordinary
|
|
4974
4974
|
* walk path, in which case every line below is byte-identical to the
|
|
4975
|
-
* pre-
|
|
4975
|
+
* pre-vault-restore-recipe shape.
|
|
4976
4976
|
*/
|
|
4977
4977
|
/**
|
|
4978
|
-
*
|
|
4978
|
+
* vault-multi-writer (rev 47): the key a head's own checkpoint claim set
|
|
4979
4979
|
* and carriers were signed under — the head's writer, resolved against the
|
|
4980
4980
|
* keystore's persisted `writer_set_pin` (maintained by the chain walk that
|
|
4981
4981
|
* always precedes a restore), falling back to `fallback` (the genesis
|
|
@@ -4990,7 +4990,7 @@ export class GitvaultVault {
|
|
|
4990
4990
|
return known.find((w) => w.writer_key_id === head.writer_key_id)?.signing_pubkey ?? fallback;
|
|
4991
4991
|
}
|
|
4992
4992
|
async applyRestoreHeads(targetRepoDir, heads, incremental, newest, writerKey, precheckedCheckpoint, planPacks) {
|
|
4993
|
-
//
|
|
4993
|
+
// vault-clone-scaling (P3): a WHOLESALE walk just stopped at the
|
|
4994
4994
|
// newest checkpoint-bearing head, or proved there is none back to
|
|
4995
4995
|
// genesis — that is first-hand checkpoint coverage, and recording it is
|
|
4996
4996
|
// what makes the NEXT walk's backward window exact instead of a
|
|
@@ -4999,7 +4999,7 @@ export class GitvaultVault {
|
|
|
4999
4999
|
// at the marker, learns nothing, and skips — `learned` stays null.)
|
|
5000
5000
|
{
|
|
5001
5001
|
const stop = heads[0];
|
|
5002
|
-
const learned = stop.checkpoint ? stop.checkpoint.covers_through_generation : !incremental && stop.generation === "0000000000000001" ?
|
|
5002
|
+
const learned = stop.checkpoint ? stop.checkpoint.covers_through_generation : !incremental && stop.generation === "0000000000000001" ? VAULT_GENESIS_GENERATION : null;
|
|
5003
5003
|
if (learned !== null) {
|
|
5004
5004
|
const known = this.repoFile().checkpoint_covers_through ?? null;
|
|
5005
5005
|
if (known === null || !/^[0-9a-f]{16}$/.test(known) || generationToBigInt(learned) > generationToBigInt(known)) {
|
|
@@ -5012,18 +5012,18 @@ export class GitvaultVault {
|
|
|
5012
5012
|
// NEVER falls back to `this.kRepo()`/`this.epoch()` (this principal's
|
|
5013
5013
|
// CURRENT pointer, which is the NEWEST epoch, not necessarily every
|
|
5014
5014
|
// historical one a restore spans). `newest.epoch_keys_hex` is the full
|
|
5015
|
-
// map `materialize()` just resolved (throwing `
|
|
5015
|
+
// map `materialize()` just resolved (throwing `VAULT_EPOCH_NOT_OPENABLE`
|
|
5016
5016
|
// fail-closed if any needed epoch could not be opened), so every lookup
|
|
5017
5017
|
// below is guaranteed present.
|
|
5018
5018
|
const kRepoForEpoch = (epoch) => this.epochKeyFor(newest, epoch);
|
|
5019
5019
|
const first = heads[0];
|
|
5020
|
-
//
|
|
5020
|
+
// vault-pipelined-restore: per-object settlement when the transport
|
|
5021
5021
|
// offers it, the `getObjects` barrier otherwise — pipelining is a
|
|
5022
5022
|
// wall-clock property, never a correctness dependency, so a transport
|
|
5023
5023
|
// without the method reproduces today's serial-after-barrier behavior
|
|
5024
5024
|
// exactly (each per-index promise settles when the whole batch does).
|
|
5025
5025
|
//
|
|
5026
|
-
// `expected` (
|
|
5026
|
+
// `expected` (vault-small-object-inline design D3) is index-aligned
|
|
5027
5027
|
// with `paths` — every caller below already knows each object's
|
|
5028
5028
|
// receipted ciphertext hash BEFORE fetching it, so it rides along here
|
|
5029
5029
|
// to let an `object-reads`-backed transport verify (and, on a lying
|
|
@@ -5040,7 +5040,7 @@ export class GitvaultVault {
|
|
|
5040
5040
|
void p.catch(() => { });
|
|
5041
5041
|
return perIndex;
|
|
5042
5042
|
};
|
|
5043
|
-
//
|
|
5043
|
+
// vault-pipelined-restore D3: the WAL entry list derives from the
|
|
5044
5044
|
// already-verified head walk, so its batched download is INITIATED here —
|
|
5045
5045
|
// before the checkpoint branch — and its (small, many) objects land while
|
|
5046
5046
|
// the checkpoint downloads, decrypts, and indexes. APPLICATION of WAL
|
|
@@ -5050,11 +5050,11 @@ export class GitvaultVault {
|
|
|
5050
5050
|
// rotation-spanning restore never reuses one head's epoch for another's
|
|
5051
5051
|
// pack.
|
|
5052
5052
|
const walEntries = heads.flatMap((h) => h.wal_entries.map((w) => ({ w, epoch: h.epoch })));
|
|
5053
|
-
//
|
|
5053
|
+
// vault-delta-fetch: packs the state read already delivered inline
|
|
5054
5054
|
// skip the network ENTIRELY — but only after hashing to their carrying
|
|
5055
5055
|
// head's receipt (a lying delta is a plain miss; the ordinary fetch
|
|
5056
5056
|
// below reproduces the unbatched behavior for that slot). The buffer is
|
|
5057
|
-
// consumed exactly once.
|
|
5057
|
+
// consumed exactly once. vault-restore-recipe extends the SAME
|
|
5058
5058
|
// short-circuit to `planPacks` (a verified plan's own pack bytes) —
|
|
5059
5059
|
// checked SECOND so a delta hit never gets shadowed by a plan hit for
|
|
5060
5060
|
// the same slot (the two never coexist in practice, per `getState`'s
|
|
@@ -5081,7 +5081,7 @@ export class GitvaultVault {
|
|
|
5081
5081
|
}
|
|
5082
5082
|
const uncovered = walEntries.map((_, i) => i).filter((i) => !deltaCovered.has(i));
|
|
5083
5083
|
const walFramesP = (async () => {
|
|
5084
|
-
const fetched = uncovered.length > 0 ? await settled(uncovered.map((i) =>
|
|
5084
|
+
const fetched = uncovered.length > 0 ? await settled(uncovered.map((i) => vaultPaths.wal(walEntries[i].w.object_id)), uncovered.map((i) => walEntries[i].w.ciphertext_sha256)) : [];
|
|
5085
5085
|
return walEntries.map((_, i) => (deltaCovered.has(i) ? Promise.resolve(deltaCovered.get(i)) : fetched[uncovered.indexOf(i)]));
|
|
5086
5086
|
})();
|
|
5087
5087
|
void walFramesP.catch(() => { });
|
|
@@ -5089,27 +5089,27 @@ export class GitvaultVault {
|
|
|
5089
5089
|
let claimSet;
|
|
5090
5090
|
let manifest;
|
|
5091
5091
|
if (precheckedCheckpoint) {
|
|
5092
|
-
//
|
|
5092
|
+
// vault-restore-recipe: already fetched + verified (signature,
|
|
5093
5093
|
// cross-equality) by `verifyRestorePlan` against the plan's own
|
|
5094
5094
|
// inline bytes — no network read, no re-verify.
|
|
5095
5095
|
({ claimSet, manifest } = precheckedCheckpoint);
|
|
5096
5096
|
}
|
|
5097
5097
|
else {
|
|
5098
|
-
const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path:
|
|
5098
|
+
const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path: vaultPaths.claimSet(first.checkpoint.claim_set.object_id), expected_sha256: first.checkpoint.claim_set.stored_bytes_sha256 });
|
|
5099
5099
|
if (!claimBytes || sha256Hex(claimBytes) !== first.checkpoint.claim_set.stored_bytes_sha256)
|
|
5100
|
-
fail("CHECKPOINT_INCOMPLETE", "claim set absent or altered", "restoring
|
|
5101
|
-
claimSet =
|
|
5102
|
-
if (!
|
|
5103
|
-
fail("CHECKPOINT_INCOMPLETE", "claim set signature fails", "restoring
|
|
5104
|
-
manifest = await this.openCarrier("checkpoint_manifest", claimSet.manifest_receipt,
|
|
5100
|
+
fail("CHECKPOINT_INCOMPLETE", "claim set absent or altered", "restoring vault objects");
|
|
5101
|
+
claimSet = parseVaultStrict(new TextDecoder().decode(claimBytes));
|
|
5102
|
+
if (!verifyVaultObject(claimSet, this.writerKeyForHead(first, writerKey)))
|
|
5103
|
+
fail("CHECKPOINT_INCOMPLETE", "claim set signature fails", "restoring vault objects");
|
|
5104
|
+
manifest = await this.openCarrier("checkpoint_manifest", claimSet.manifest_receipt, vaultPaths.checkpointManifest(claimSet.manifest_receipt.object_id), this.writerKeyForHead(first, writerKey), { epoch: first.epoch, k_repo: kRepoForEpoch(first.epoch) });
|
|
5105
5105
|
checkClaimSetEquality(claimSet, manifest, first.checkpoint.covers_through_generation);
|
|
5106
5106
|
}
|
|
5107
|
-
// Design D2 +
|
|
5107
|
+
// Design D2 + vault-pipelined-restore D1: every checkpoint pack is
|
|
5108
5108
|
// independent — one batched presign for all of them, applied via
|
|
5109
5109
|
// index-pack strictly in manifest order, PIPELINED: apply(i) awaits
|
|
5110
5110
|
// bytes(i), so decrypt/verify/index of an early pack overlaps the
|
|
5111
5111
|
// later packs' downloads. Per-pack verification (AEAD open + plaintext
|
|
5112
|
-
// hash) still completes before any byte reaches git.
|
|
5112
|
+
// hash) still completes before any byte reaches git. vault-restore-
|
|
5113
5113
|
// recipe: a `planPacks` hit skips the fetch for that index entirely —
|
|
5114
5114
|
// the SAME short-circuit the WAL loop above runs, extended to the
|
|
5115
5115
|
// checkpoint class (never carried by `stateDeltaPacks`).
|
|
@@ -5122,15 +5122,15 @@ export class GitvaultVault {
|
|
|
5122
5122
|
});
|
|
5123
5123
|
}
|
|
5124
5124
|
const uncoveredCk = manifest.packs.map((_, i) => i).filter((i) => !checkpointCovered.has(i));
|
|
5125
|
-
const ckFrames = uncoveredCk.length > 0 ? await settled(uncoveredCk.map((i) =>
|
|
5125
|
+
const ckFrames = uncoveredCk.length > 0 ? await settled(uncoveredCk.map((i) => vaultPaths.checkpointPack(manifest.packs[i].object_id)), uncoveredCk.map((i) => manifest.packs[i].ciphertext_sha256)) : [];
|
|
5126
5126
|
for (let i = 0; i < manifest.packs.length; i++) {
|
|
5127
5127
|
const p = manifest.packs[i];
|
|
5128
5128
|
const frame = checkpointCovered.has(i) ? checkpointCovered.get(i) : ((await ckFrames[uncoveredCk.indexOf(i)]) ?? null);
|
|
5129
5129
|
if (!frame)
|
|
5130
|
-
fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} absent`, "restoring
|
|
5130
|
+
fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} absent`, "restoring vault objects");
|
|
5131
5131
|
const plain = openFrame({ k_obj: deriveObjectKey(kRepoForEpoch(first.epoch), this.repoId, first.epoch, "checkpoint_pack", p.object_id), repo_id: this.repoId, object_kind: "checkpoint_pack", object_id: p.object_id, epoch: first.epoch, frame, expected_ciphertext_sha256: p.ciphertext_sha256 });
|
|
5132
5132
|
if (sha256Hex(plain) !== p.plaintext_sha256 || String(plain.length) !== p.plaintext_size_bytes)
|
|
5133
|
-
fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} plaintext mismatch`, "restoring
|
|
5133
|
+
fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} plaintext mismatch`, "restoring vault objects");
|
|
5134
5134
|
await hardenedGit(targetRepoDir, ["index-pack", "--stdin", "--strict"], { input: plain });
|
|
5135
5135
|
}
|
|
5136
5136
|
}
|
|
@@ -5144,20 +5144,20 @@ export class GitvaultVault {
|
|
|
5144
5144
|
const { w, epoch } = walEntries[i];
|
|
5145
5145
|
const frame = (await walFrames[i]) ?? null;
|
|
5146
5146
|
if (!frame)
|
|
5147
|
-
fail("CHAIN_UNUSABLE", `WAL pack ${w.object_id} absent`, "restoring
|
|
5147
|
+
fail("CHAIN_UNUSABLE", `WAL pack ${w.object_id} absent`, "restoring vault objects");
|
|
5148
5148
|
const plain = openFrame({ k_obj: deriveObjectKey(kRepoForEpoch(epoch), this.repoId, epoch, "wal_pack", w.object_id), repo_id: this.repoId, object_kind: "wal_pack", object_id: w.object_id, epoch, frame, expected_ciphertext_sha256: w.ciphertext_sha256 });
|
|
5149
5149
|
await hardenedGit(targetRepoDir, ["index-pack", "--stdin", "--strict"], { input: plain });
|
|
5150
5150
|
}
|
|
5151
5151
|
// the §4.7 coverage set — canonical refs ∪ unexpired roots ∪ the HEAD target — must all resolve.
|
|
5152
|
-
// ONE batched git invocation for the whole set (
|
|
5152
|
+
// ONE batched git invocation for the whole set (vault-delta-fetch
|
|
5153
5153
|
// task 3.1) — identical every-tip-must-resolve semantics, one process
|
|
5154
5154
|
// instead of one per tip.
|
|
5155
5155
|
{
|
|
5156
|
-
const tips =
|
|
5156
|
+
const tips = Vault.coverageTips(newest.refs, newest.roots, newest.head_target);
|
|
5157
5157
|
const present = await hasObjects(targetRepoDir, tips);
|
|
5158
5158
|
for (const t of tips) {
|
|
5159
5159
|
if (!present.has(t))
|
|
5160
|
-
fail("CHAIN_UNUSABLE", `covered tip ${t} does not resolve after restore`, "restoring
|
|
5160
|
+
fail("CHAIN_UNUSABLE", `covered tip ${t} does not resolve after restore`, "restoring vault objects", { oid: t });
|
|
5161
5161
|
}
|
|
5162
5162
|
}
|
|
5163
5163
|
// clone-installs-retained-refs (D2): every retained tip just restored
|
|
@@ -5170,21 +5170,21 @@ export class GitvaultVault {
|
|
|
5170
5170
|
// (a crash or throw anywhere above) leaves the marker unadvanced, so the
|
|
5171
5171
|
// next fetch simply repeats this same range; applying an already-applied
|
|
5172
5172
|
// WAL pack again is the existing, already-safe wholesale behavior.
|
|
5173
|
-
await
|
|
5173
|
+
await writeVaultRestoreMarker(targetRepoDir, newest.generation, newest.head_sha256);
|
|
5174
5174
|
return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
|
|
5175
5175
|
}
|
|
5176
5176
|
/**
|
|
5177
5177
|
* `newest.epoch_keys_hex[epoch]`, decoded — the D194 per-carrying-head key
|
|
5178
5178
|
* lookup shared by {@link applyRestoreHeads} and {@link verifyRestorePlan}.
|
|
5179
5179
|
* `newest.epoch_keys_hex` is the FULL map `materialize()` resolved
|
|
5180
|
-
* (throwing `
|
|
5180
|
+
* (throwing `VAULT_EPOCH_NOT_OPENABLE` fail-closed if any needed epoch
|
|
5181
5181
|
* could not be opened), so every lookup through this helper is guaranteed
|
|
5182
5182
|
* present.
|
|
5183
5183
|
*/
|
|
5184
5184
|
epochKeyFor(newest, epoch) {
|
|
5185
5185
|
const hex = newest.epoch_keys_hex[epoch];
|
|
5186
5186
|
if (!hex)
|
|
5187
|
-
fail("
|
|
5187
|
+
fail("VAULT_EPOCH_NOT_OPENABLE", `no locally known key for epoch ${epoch} while restoring objects`, "restoring vault objects", { epoch });
|
|
5188
5188
|
return hexToBytes(hex);
|
|
5189
5189
|
}
|
|
5190
5190
|
/**
|
|
@@ -5214,8 +5214,8 @@ export class GitvaultVault {
|
|
|
5214
5214
|
// fail its OWN element, never abort the whole computation, and this
|
|
5215
5215
|
// function's contract is "any bad element disqualifies the WHOLE plan."
|
|
5216
5216
|
// The try/catch below still stands, to catch the genuine exceptions
|
|
5217
|
-
// `
|
|
5218
|
-
// `
|
|
5217
|
+
// `parseVaultStrict`/`decodeCarrierFrame`/`checkClaimSetEquality`/
|
|
5218
|
+
// `verifyVaultObject` can raise (a `fail()`-thrown `Run402Error`, or a
|
|
5219
5219
|
// strict-parse rejection) — those are real errors, just ones this method
|
|
5220
5220
|
// treats as "the plan didn't verify" rather than propagating.
|
|
5221
5221
|
try {
|
|
@@ -5225,7 +5225,7 @@ export class GitvaultVault {
|
|
|
5225
5225
|
for (const h of plan.heads) {
|
|
5226
5226
|
if (sha256Hex(h.stored_bytes) !== h.stored_bytes_sha256)
|
|
5227
5227
|
return null; // self-hash mismatch
|
|
5228
|
-
const head =
|
|
5228
|
+
const head = parseVaultStrict(new TextDecoder().decode(h.stored_bytes));
|
|
5229
5229
|
if (head.generation !== h.generation)
|
|
5230
5230
|
return null; // generation mismatch
|
|
5231
5231
|
parsed.push({ generation: h.generation, bytes: h.stored_bytes, head });
|
|
@@ -5248,7 +5248,7 @@ export class GitvaultVault {
|
|
|
5248
5248
|
return null; // chain link broken
|
|
5249
5249
|
}
|
|
5250
5250
|
const first = parsed[0];
|
|
5251
|
-
if (plan.boundary_generation ===
|
|
5251
|
+
if (plan.boundary_generation === VAULT_GENESIS_GENERATION) {
|
|
5252
5252
|
if (first.generation !== "0000000000000001")
|
|
5253
5253
|
return null; // genesis boundary mismatch
|
|
5254
5254
|
const { sha256: genesisSha } = await this.genesis();
|
|
@@ -5265,7 +5265,7 @@ export class GitvaultVault {
|
|
|
5265
5265
|
if (!this.walkPrefetch)
|
|
5266
5266
|
this.walkPrefetch = new Map();
|
|
5267
5267
|
for (const p of parsed)
|
|
5268
|
-
this.walkPrefetch.set(
|
|
5268
|
+
this.walkPrefetch.set(vaultPaths.head(p.generation), p.bytes);
|
|
5269
5269
|
let checkpoint = null;
|
|
5270
5270
|
if (first.head.checkpoint) {
|
|
5271
5271
|
if (!plan.checkpoint)
|
|
@@ -5275,8 +5275,8 @@ export class GitvaultVault {
|
|
|
5275
5275
|
return null; // claim set id mismatch
|
|
5276
5276
|
if (sha256Hex(plan.checkpoint.claim_set.stored_bytes) !== claimReceipt.stored_bytes_sha256)
|
|
5277
5277
|
return null; // claim set hash mismatch
|
|
5278
|
-
const claimSet =
|
|
5279
|
-
if (!
|
|
5278
|
+
const claimSet = parseVaultStrict(new TextDecoder().decode(plan.checkpoint.claim_set.stored_bytes));
|
|
5279
|
+
if (!verifyVaultObject(claimSet, this.writerKeyForHead(first.head, writerKey)))
|
|
5280
5280
|
return null; // claim set signature fails
|
|
5281
5281
|
if (claimSet.manifest_receipt.object_id !== plan.checkpoint.manifest.object_id)
|
|
5282
5282
|
return null; // manifest id mismatch
|
|
@@ -5295,7 +5295,7 @@ export class GitvaultVault {
|
|
|
5295
5295
|
}
|
|
5296
5296
|
}
|
|
5297
5297
|
/**
|
|
5298
|
-
* Consume a stashed restore plan (
|
|
5298
|
+
* Consume a stashed restore plan (vault-restore-recipe design D1-D6):
|
|
5299
5299
|
* verify it ({@link verifyRestorePlan}) and, on success, apply it via the
|
|
5300
5300
|
* SAME shared tail the backward walk uses ({@link applyRestoreHeads}) —
|
|
5301
5301
|
* `null` on absence or ANY verification failure, the caller's cue to run
|
|
@@ -5319,12 +5319,12 @@ export class GitvaultVault {
|
|
|
5319
5319
|
}
|
|
5320
5320
|
return this.applyRestoreHeads(targetRepoDir, verified.heads, false, newest, writerKey, verified.checkpoint, planPacks);
|
|
5321
5321
|
}
|
|
5322
|
-
// ── compaction headroom grant (
|
|
5323
|
-
/** Thin passthrough to the transport — see {@link
|
|
5322
|
+
// ── compaction headroom grant (vault-checkpoint-cadence design D3) ──
|
|
5323
|
+
/** Thin passthrough to the transport — see {@link VaultTransport.openCompactionGrant}. */
|
|
5324
5324
|
async openCompactionGrant() {
|
|
5325
5325
|
return this.transport.openCompactionGrant({ repo_id: this.repoId });
|
|
5326
5326
|
}
|
|
5327
|
-
/** Thin passthrough to the transport — see {@link
|
|
5327
|
+
/** Thin passthrough to the transport — see {@link VaultTransport.closeCompactionGrant}. Always safe best-effort; never throws by construction of the route (idempotent). */
|
|
5328
5328
|
async closeCompactionGrant() {
|
|
5329
5329
|
return this.transport.closeCompactionGrant({ repo_id: this.repoId });
|
|
5330
5330
|
}
|
|
@@ -5351,7 +5351,7 @@ export function checkClaimSetEquality(claimSet, manifest, headCoversThrough) {
|
|
|
5351
5351
|
/** Convenience for the deploy lane: the §6.5 capture binding. */
|
|
5352
5352
|
export function captureBinding(captureId, applyPlanSha256, snapshotOidHmac) {
|
|
5353
5353
|
if (!/^[0-9a-f]{32}$/.test(captureId))
|
|
5354
|
-
fail("
|
|
5354
|
+
fail("VAULT_BAD_ID", "capture_id must be 32 lowercase hex", "building capture binding");
|
|
5355
5355
|
return { capture_id: captureId, apply_plan_sha256: applyPlanSha256, snapshot_oid_hmac: snapshotOidHmac };
|
|
5356
5356
|
}
|
|
5357
|
-
//# sourceMappingURL=
|
|
5357
|
+
//# sourceMappingURL=vault-publication.js.map
|