run402 4.68.2 → 4.69.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/cli.mjs +2 -2
- package/core-dist/allowance-auth.js +1 -1
- package/core-dist/allowance.js +1 -1
- package/core-dist/config.js +4 -4
- package/core-dist/keystore.js +7 -7
- package/core-dist/profiles.js +1 -1
- package/core-dist/redact.js +6 -7
- package/git-remote-run402.mjs +2 -2
- package/gitvault-surface.json +2 -1
- package/lib/agent.mjs +1 -1
- package/lib/allowance.mjs +3 -4
- package/lib/argparse.mjs +7 -9
- package/lib/billing.mjs +33 -35
- package/lib/buzz-notifications.mjs +3 -2
- package/lib/buzz.mjs +14 -8
- package/lib/buzz.test.mjs +1 -1
- package/lib/command-manifest.mjs +38 -20
- package/lib/config.mjs +1 -1
- package/lib/deploy-v2.mjs +3 -3
- package/lib/doctor-source-scan.mjs +1 -1
- package/lib/doctor.mjs +42 -25
- package/lib/email.mjs +2 -3
- package/lib/harness-context.mjs +1 -2
- package/lib/init.mjs +1 -1
- package/lib/next-actions.mjs +1 -1
- package/lib/org-context.mjs +83 -2
- package/lib/org.mjs +231 -114
- package/lib/projects.mjs +2 -2
- package/lib/remote-helper-session.mjs +26 -32
- package/lib/repos.mjs +117 -15
- package/lib/rooms-context.mjs +5 -5
- package/lib/sdk-errors.mjs +1 -2
- package/lib/status.mjs +1 -1
- package/lib/update-check.test.mjs +1 -1
- package/lib/wallet-context.mjs +10 -10
- package/lib/wallet-context.test.mjs +6 -7
- package/lib/wallets.mjs +2 -2
- package/lib/webhooks.mjs +2 -2
- package/package.json +1 -1
- package/sdk/core-dist/allowance-auth.js +1 -1
- package/sdk/core-dist/allowance.js +1 -1
- package/sdk/core-dist/config.js +4 -4
- package/sdk/core-dist/keystore.js +7 -7
- package/sdk/core-dist/profiles.js +1 -1
- package/sdk/core-dist/redact.js +6 -7
- package/sdk/dist/delegate-credentials.d.ts +1 -1
- package/sdk/dist/delegate-credentials.js +1 -1
- package/sdk/dist/errors.d.ts +10 -4
- package/sdk/dist/errors.d.ts.map +1 -1
- package/sdk/dist/errors.js +2 -3
- package/sdk/dist/errors.js.map +1 -1
- package/sdk/dist/kernel.js +1 -1
- package/sdk/dist/kernel.js.map +1 -1
- package/sdk/dist/namespaces/allowance.d.ts +1 -1
- package/sdk/dist/namespaces/allowance.js +1 -1
- package/sdk/dist/namespaces/allowance.js.map +1 -1
- package/sdk/dist/namespaces/assets.js +1 -1
- package/sdk/dist/namespaces/assets.js.map +1 -1
- package/sdk/dist/namespaces/assets.types.d.ts +1 -1
- package/sdk/dist/namespaces/ci.d.ts.map +1 -1
- package/sdk/dist/namespaces/ci.js +4 -6
- package/sdk/dist/namespaces/ci.js.map +1 -1
- package/sdk/dist/namespaces/deploy.js +7 -8
- package/sdk/dist/namespaces/deploy.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.crypto.d.ts +86 -3
- package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.crypto.js +102 -3
- package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.d.ts +197 -17
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +698 -58
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.types.d.ts +91 -3
- package/sdk/dist/namespaces/gitvault.types.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.types.js.map +1 -1
- package/sdk/dist/namespaces/operator.d.ts +1 -1
- package/sdk/dist/namespaces/operator.js +1 -1
- package/sdk/dist/namespaces/org.d.ts +14 -1
- package/sdk/dist/namespaces/org.d.ts.map +1 -1
- package/sdk/dist/namespaces/org.js +15 -0
- package/sdk/dist/namespaces/org.js.map +1 -1
- package/sdk/dist/namespaces/org.types.d.ts +30 -0
- package/sdk/dist/namespaces/org.types.d.ts.map +1 -1
- package/sdk/dist/namespaces/projects.types.d.ts +1 -1
- package/sdk/dist/namespaces/subdomains.js +1 -1
- package/sdk/dist/namespaces/subdomains.js.map +1 -1
- package/sdk/dist/namespaces/transfers.d.ts +3 -4
- package/sdk/dist/namespaces/transfers.d.ts.map +1 -1
- package/sdk/dist/namespaces/transfers.js +1 -2
- package/sdk/dist/namespaces/transfers.js.map +1 -1
- package/sdk/dist/node/actions-node.d.ts.map +1 -1
- package/sdk/dist/node/actions-node.js +3 -4
- package/sdk/dist/node/actions-node.js.map +1 -1
- package/sdk/dist/node/deploy-manifest.js +2 -2
- package/sdk/dist/node/deploy-manifest.js.map +1 -1
- package/sdk/dist/node/gitvault-address.js +2 -2
- package/sdk/dist/node/gitvault-address.js.map +1 -1
- package/sdk/dist/node/gitvault-creation-journal.d.ts +1 -1
- package/sdk/dist/node/gitvault-creation-journal.js +2 -2
- package/sdk/dist/node/gitvault-creation-journal.js.map +1 -1
- package/sdk/dist/node/gitvault-deploy.d.ts +2 -2
- package/sdk/dist/node/gitvault-deploy.js +2 -2
- package/sdk/dist/node/gitvault-handoff.d.ts +142 -3
- package/sdk/dist/node/gitvault-handoff.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-handoff.js +251 -21
- package/sdk/dist/node/gitvault-handoff.js.map +1 -1
- package/sdk/dist/node/gitvault-keystore.d.ts +78 -3
- package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-keystore.js +46 -5
- package/sdk/dist/node/gitvault-keystore.js.map +1 -1
- package/sdk/dist/node/gitvault-mirror.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-mirror.js +3 -5
- package/sdk/dist/node/gitvault-mirror.js.map +1 -1
- package/sdk/dist/node/gitvault-profile-scan.js +1 -1
- package/sdk/dist/node/gitvault-publication.d.ts +379 -21
- package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-publication.js +664 -41
- package/sdk/dist/node/gitvault-publication.js.map +1 -1
- package/sdk/dist/node/gitvault-recover.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-recover.js +7 -10
- package/sdk/dist/node/gitvault-recover.js.map +1 -1
- package/sdk/dist/node/gitvault-snapshot.d.ts +1 -2
- package/sdk/dist/node/gitvault-snapshot.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-snapshot.js +1 -2
- package/sdk/dist/node/gitvault-snapshot.js.map +1 -1
- package/sdk/dist/node/gitvault-writer-state.d.ts +201 -0
- package/sdk/dist/node/gitvault-writer-state.d.ts.map +1 -0
- package/sdk/dist/node/gitvault-writer-state.js +357 -0
- package/sdk/dist/node/gitvault-writer-state.js.map +1 -0
- package/sdk/dist/node/index.d.ts +4 -0
- package/sdk/dist/node/index.d.ts.map +1 -1
- package/sdk/dist/node/index.js +20 -1
- package/sdk/dist/node/index.js.map +1 -1
- package/sdk/dist/node/paid-fetch.d.ts.map +1 -1
- package/sdk/dist/node/paid-fetch.js +3 -4
- package/sdk/dist/node/paid-fetch.js.map +1 -1
- package/sdk/dist/node/sites-node.js +1 -1
- package/sdk/dist/node/sites-node.js.map +1 -1
|
@@ -39,10 +39,11 @@ import { join } from "node:path";
|
|
|
39
39
|
import { LocalError, isRun402Error } from "../errors.js";
|
|
40
40
|
import { fetchGitvaultObjectBytes } from "./gitvault-edge-fetch.js";
|
|
41
41
|
import { openGitvaultDestinationBackend } from "./gitvault-mirror-backend.js";
|
|
42
|
-
import { GITVAULT_FORMAT, GITVAULT_GENESIS_EPOCH, GITVAULT_GENESIS_GENERATION, GITVAULT_HEX16_RE, GITVAULT_OID40_RE, GITVAULT_SUITE, attemptKeyCommitment, bytesToHex, checkFreshEpochKeyAgainstPriorKeys, checkHPartition, computeRotationId, computeTargetPartitionDigest, deriveDigestKey, deriveObjectKey, ekFingerprint, epochRotationKeyCommitment, formatGitvaultTimestamp, fromBase64url, hexToBytes, jcs, keyEnvelopeLedgerId, keyedCommitment, newGitvaultId, newHex32, nextEpoch, objectsetContent, openBindingPreimage, openEpochRotationForRecipient, openFrame, parseGitvaultStrict, parseRotateEpochPayload, pinManifestLedgerId, randomBytes, sealFrame, sealKeyEnvelope, sha256Hex, signGitvaultObject, storedBytes, storedBytesSha256, toBase64url, verifyGitvaultObject, } from "../namespaces/gitvault.crypto.js";
|
|
42
|
+
import { GITVAULT_FORMAT, GITVAULT_GENESIS_EPOCH, GITVAULT_GENESIS_GENERATION, GITVAULT_HEX16_RE, GITVAULT_OID40_RE, GITVAULT_SUITE, attemptKeyCommitment, bytesToHex, checkFreshEpochKeyAgainstPriorKeys, checkHPartition, computeRotationId, computeTargetPartitionDigest, deriveDigestKey, deriveObjectKey, ekFingerprint, vkFingerprint, GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT, GITVAULT_TERMINAL_LOSS_STATEMENT, epochRotationKeyCommitment, formatGitvaultTimestamp, fromBase64url, hexToBytes, jcs, keyEnvelopeLedgerId, keyedCommitment, newGitvaultId, newHex32, nextEpoch, objectsetContent, openBindingPreimage, openEpochRotationForRecipient, openFrame, parseAddWriterKeyPayload, parseGitvaultStrict, parseRotateEpochPayload, pinManifestLedgerId, randomBytes, sealFrame, sealKeyEnvelope, sha256Hex, signGitvaultObject, storedBytes, storedBytesSha256, toBase64url, verifyGitvaultObject, } from "../namespaces/gitvault.crypto.js";
|
|
43
43
|
import { GITVAULT_ZERO_SHA256_SENTINEL } from "../namespaces/gitvault.types.js";
|
|
44
44
|
import { gitvaultCheckpointStaleness } from "../namespaces/gitvault.js";
|
|
45
45
|
import { crossProfileGitvaultHint } from "./gitvault-profile-scan.js";
|
|
46
|
+
import { applyAddWriterKey, applyWriterSetUpdate, buildAddWriterKeyActivationPayload, buildWriterDoorAddWriterKeyPayload, initialWriterState, resolveActiveWriter, validateAddWriterKeyPayload, validateWriterSetUpdate, writerKeyIdOf, } from "./gitvault-writer-state.js";
|
|
46
47
|
import { GITVAULT_DEPLOY_REF, hardenedGit, hasObject, hasObjects, isAncestor } from "./gitvault-snapshot.js";
|
|
47
48
|
// ─── Constants (constants.json) ──────────────────────────────────────────────
|
|
48
49
|
export const GITVAULT_MAX_CANONICAL_REFS = 10_000;
|
|
@@ -533,14 +534,20 @@ export function checkChainLink(input) {
|
|
|
533
534
|
* rotation is ACTIVATED") — `checkChainLink`'s own D194 epoch-continuity
|
|
534
535
|
* check already validates its structural admissibility, and
|
|
535
536
|
* {@link parseRotateEpochPayload} / the caller's own envelope-open step
|
|
536
|
-
* (`GitvaultVault.verifyToNewest`) handle it fully.
|
|
537
|
-
*
|
|
538
|
-
*
|
|
537
|
+
* (`GitvaultVault.verifyToNewest`) handle it fully. `add_writer_key` is
|
|
538
|
+
* EXEMPT as of rev 47 (gitvault-multi-writer) the same way — {@link
|
|
539
|
+
* parseAddWriterKeyPayload} + `validateAddWriterKeyPayload`
|
|
540
|
+
* (`gitvault-writer-state.js`) handle it fully, INSIDE `verifyToNewest`'s
|
|
541
|
+
* own loop, BEFORE this function ever runs on that head (so a transition
|
|
542
|
+
* that fails writer validation never reaches here at all — this function
|
|
543
|
+
* only ever sees ones that already passed). The other two kinds
|
|
544
|
+
* (`add_envelope`, `transfer_binding`) remain genuinely unactivated and stay
|
|
545
|
+
* fail-closed exactly as before.
|
|
539
546
|
*/
|
|
540
547
|
export function assertNoTransition(head) {
|
|
541
548
|
if (head.transition === null)
|
|
542
549
|
return;
|
|
543
|
-
if (head.transition.kind === "rotate_epoch")
|
|
550
|
+
if (head.transition.kind === "rotate_epoch" || head.transition.kind === "add_writer_key")
|
|
544
551
|
return;
|
|
545
552
|
const kinds = ["add_envelope", "rotate_epoch", "add_writer_key", "transfer_binding"];
|
|
546
553
|
if (!kinds.includes(head.transition.kind))
|
|
@@ -791,12 +798,9 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
791
798
|
if (isRun402Error(e) && e.code === "RESOURCE_NOT_FOUND")
|
|
792
799
|
return null;
|
|
793
800
|
// The gateway accepts `{object_kind: "recipient_pin_manifest",
|
|
794
|
-
// pin_manifest_version}` reads
|
|
795
|
-
// null-idScalar validation was hardcoded to key_envelope's
|
|
796
|
-
// `{epoch, recipient_fingerprint}` shape and 400'd every such read
|
|
797
|
-
// with "epoch must be 16 hex"). A VALIDATION_FAILED here therefore
|
|
801
|
+
// pin_manifest_version}` reads. A VALIDATION_FAILED here therefore
|
|
798
802
|
// indicates a genuinely malformed request and propagates as-is —
|
|
799
|
-
// EXCEPT the
|
|
803
|
+
// EXCEPT the epoch-shape complaint, which a current gateway can
|
|
800
804
|
// never emit for this kind: that signature can only mean an unfixed
|
|
801
805
|
// (older/staging) gateway behind RUN402_API_BASE, so name it rather
|
|
802
806
|
// than letting it read as a client validation bug.
|
|
@@ -1339,7 +1343,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
1339
1343
|
parsed = null;
|
|
1340
1344
|
}
|
|
1341
1345
|
if (!r.ok) {
|
|
1342
|
-
//
|
|
1346
|
+
// The gateway's error envelope is FLAT
|
|
1343
1347
|
// (docs/style.md §Errors — `buildErrorEnvelope` in the gateway) —
|
|
1344
1348
|
// `code`, `message`, `details`, `trace_id`, and `next_actions` all
|
|
1345
1349
|
// ride at the TOP level, never nested under an `error` object (that
|
|
@@ -1593,6 +1597,7 @@ export class GitvaultVault {
|
|
|
1593
1597
|
stateRestorePlan = null;
|
|
1594
1598
|
retries;
|
|
1595
1599
|
servicePublicKey;
|
|
1600
|
+
allowUnauthenticatedSalvage;
|
|
1596
1601
|
genesisCache = null;
|
|
1597
1602
|
/**
|
|
1598
1603
|
* gitvault-byo-primary-bucket task 3.2 — this vault's resolved BYO write
|
|
@@ -1613,6 +1618,7 @@ export class GitvaultVault {
|
|
|
1613
1618
|
this.budget = options.verification_budget ?? GITVAULT_VERIFICATION_BUDGET_HEADS;
|
|
1614
1619
|
this.retries = options.conflict_retries ?? GITVAULT_PUSH_CONFLICT_RETRIES;
|
|
1615
1620
|
this.servicePublicKey = options.service_public_key ?? null;
|
|
1621
|
+
this.allowUnauthenticatedSalvage = options.allow_unauthenticated_salvage ?? false;
|
|
1616
1622
|
}
|
|
1617
1623
|
static open(options) {
|
|
1618
1624
|
const v = new GitvaultVault(options);
|
|
@@ -1620,7 +1626,7 @@ export class GitvaultVault {
|
|
|
1620
1626
|
return v;
|
|
1621
1627
|
}
|
|
1622
1628
|
repoFile() {
|
|
1623
|
-
// Cross-profile hint
|
|
1629
|
+
// Cross-profile hint: a keystore-miss on the
|
|
1624
1630
|
// ACTIVE profile is enriched, when the scan finds one, with which OTHER
|
|
1625
1631
|
// local wallet profile holds this repo's key — a purely local
|
|
1626
1632
|
// directory/filename read, appended after the existing remedy rather
|
|
@@ -1638,6 +1644,137 @@ export class GitvaultVault {
|
|
|
1638
1644
|
}
|
|
1639
1645
|
return repo;
|
|
1640
1646
|
}
|
|
1647
|
+
/**
|
|
1648
|
+
* gitvault-agent-envelopes D4 — the COLD OPEN. A keystore that holds an
|
|
1649
|
+
* identity but no repo file for this vault (a member joining from a fresh
|
|
1650
|
+
* machine, or a creator whose repo file was lost) restores `K_repo` from
|
|
1651
|
+
* its OWN `key_envelope` instead of dying `GITVAULT_REPO_STATE_MISSING`:
|
|
1652
|
+
*
|
|
1653
|
+
* 1. genesis (the writer-key source) is fetched and signature-verified;
|
|
1654
|
+
* 2. its creator fingerprints are compared against the control plane's
|
|
1655
|
+
* SIGNED allocation record — `platform_attested`, never `receipt`
|
|
1656
|
+
* (the platform serves both sides of that comparison; a substituted
|
|
1657
|
+
* genesis needs a substituted allocation, which the org's owners can
|
|
1658
|
+
* see — TOFU + audit, human-envelopes D4's tier);
|
|
1659
|
+
* 3. the envelope-recipients read says whether THIS fingerprint is
|
|
1660
|
+
* covered — if not, `GITVAULT_ENVELOPE_PENDING` names the key-holders
|
|
1661
|
+
* who can fulfil and the exact next actions (never a terminal error:
|
|
1662
|
+
* the desired state already records this member; any key-holder's
|
|
1663
|
+
* next gitvault operation wraps);
|
|
1664
|
+
* 4. the base envelope is fetched, opened, and the repo file written
|
|
1665
|
+
* `restored_from_envelope` with the genesis hash PINNED (a later open
|
|
1666
|
+
* seeing a different genesis for this repo_id refuses
|
|
1667
|
+
* `VAULT_CREATION_CONFLICT`).
|
|
1668
|
+
*
|
|
1669
|
+
* Rotation epochs are NOT opened here — `verifyToNewest` walks them and
|
|
1670
|
+
* opens each rotation-scoped envelope this identity is included in, exactly
|
|
1671
|
+
* as it does for every other reader (`openEpochRotationForRecipient`).
|
|
1672
|
+
*
|
|
1673
|
+
* Returns `null` when the repo file already existed (nothing restored).
|
|
1674
|
+
*/
|
|
1675
|
+
async ensureRepoState() {
|
|
1676
|
+
if (this.keystore.readRepo(this.repoId))
|
|
1677
|
+
return null;
|
|
1678
|
+
const identity = this.keystore.readIdentity();
|
|
1679
|
+
if (!identity) {
|
|
1680
|
+
fail("KEYSTORE_MISSING", "no gitvault identity in the keystore", "opening gitvault vault", undefined, [
|
|
1681
|
+
{ action: "restore ~/.config/run402/gitvault from backup or accept vault loss" },
|
|
1682
|
+
...crossProfileGitvaultHint(this.repoId),
|
|
1683
|
+
]);
|
|
1684
|
+
}
|
|
1685
|
+
const ownKeypair = this.keystore.encryptionKeypair(identity);
|
|
1686
|
+
if (!ownKeypair) {
|
|
1687
|
+
fail("VAULT_UNRECOVERABLE", GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT, "opening gitvault vault", { statement: GITVAULT_TERMINAL_LOSS_STATEMENT, repo_id: this.repoId });
|
|
1688
|
+
}
|
|
1689
|
+
const ownFingerprint = identity.encryption_fingerprint;
|
|
1690
|
+
const genesisBytes = await this.transport.getGenesis({ repo_id: this.repoId });
|
|
1691
|
+
if (!genesisBytes)
|
|
1692
|
+
fail("CHAIN_BROKEN", "the vault has no admitted genesis", "opening gitvault vault", { repo_id: this.repoId });
|
|
1693
|
+
const genesis = parseGitvaultStrict(new TextDecoder().decode(genesisBytes));
|
|
1694
|
+
if (genesis.epoch !== GITVAULT_GENESIS_EPOCH) {
|
|
1695
|
+
fail("VAULT_CREATION_CONFLICT", `the served genesis declares epoch ${genesis.epoch}, not the genesis epoch`, "opening gitvault vault", { repo_id: this.repoId, epoch: genesis.epoch });
|
|
1696
|
+
}
|
|
1697
|
+
if (genesis.repo_id !== this.repoId) {
|
|
1698
|
+
fail("VAULT_CREATION_CONFLICT", `the served genesis names repo ${genesis.repo_id}, not ${this.repoId}`, "opening gitvault vault", { repo_id: this.repoId, served: genesis.repo_id });
|
|
1699
|
+
}
|
|
1700
|
+
// Platform-attested creator anchor. The allocation is optional on the
|
|
1701
|
+
// wire (older gateways) — absent means the restore is unauthenticated
|
|
1702
|
+
// salvage, and the result says so.
|
|
1703
|
+
let allocationAttested = false;
|
|
1704
|
+
const record = await this.transport.getVaultRecord({ repo_id: this.repoId });
|
|
1705
|
+
const allocation = record.allocation ?? null;
|
|
1706
|
+
if (!allocation && !this.allowUnauthenticatedSalvage) {
|
|
1707
|
+
// Consult round 2 §5: an absent allocation on an ordinary open is not a
|
|
1708
|
+
// downgrade to salvage — it is a refusal. A malicious or broken serving
|
|
1709
|
+
// path that cannot forge the SIGNED allocation must not get a vault
|
|
1710
|
+
// opened by simply omitting it.
|
|
1711
|
+
fail("GITVAULT_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 gitvault vault", { repo_id: this.repoId });
|
|
1712
|
+
}
|
|
1713
|
+
if (allocation) {
|
|
1714
|
+
const genesisEk = ekFingerprint(fromBase64url(genesis.creator_encryption_pubkey, "genesis.creator_encryption_pubkey"));
|
|
1715
|
+
const genesisVk = vkFingerprint(fromBase64url(genesis.creator_signing_pubkey, "genesis.creator_signing_pubkey"));
|
|
1716
|
+
if (genesisEk !== allocation.creator_encryption_fingerprint || genesisVk !== allocation.creator_signing_fingerprint) {
|
|
1717
|
+
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 gitvault 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 });
|
|
1718
|
+
}
|
|
1719
|
+
allocationAttested = true;
|
|
1720
|
+
}
|
|
1721
|
+
const coverage = await this.transport.listEnvelopeRecipients({ repo_id: this.repoId });
|
|
1722
|
+
if (!coverage.recipient_fingerprints.includes(ownFingerprint)) {
|
|
1723
|
+
// Who can fulfil: every current directory recipient that IS covered
|
|
1724
|
+
// (they hold K_repo), named by principal id — best-effort, the read may
|
|
1725
|
+
// 403 for a caller whose membership is still propagating.
|
|
1726
|
+
let keyHolders = [];
|
|
1727
|
+
try {
|
|
1728
|
+
const directory = await this.transport.listOrgEncryptionKeys({ org_id: genesis.org_id });
|
|
1729
|
+
keyHolders = directory.keys
|
|
1730
|
+
.filter((k) => coverage.recipient_fingerprints.includes(k.ek_fingerprint))
|
|
1731
|
+
.map((k) => ({ principal_id: k.principal_id, display_name: k.display_name ?? null, ek_fingerprint: k.ek_fingerprint }));
|
|
1732
|
+
}
|
|
1733
|
+
catch {
|
|
1734
|
+
// best-effort — the refusal is complete without the roster
|
|
1735
|
+
}
|
|
1736
|
+
const desired = coverage.desired?.find((d) => d.ek_fingerprint === ownFingerprint) ?? null;
|
|
1737
|
+
fail("GITVAULT_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 gitvault operation fulfils the org's desired state`, "opening gitvault vault", {
|
|
1738
|
+
repo_id: this.repoId,
|
|
1739
|
+
org_id: genesis.org_id,
|
|
1740
|
+
own_fingerprint: ownFingerprint,
|
|
1741
|
+
desired_state: desired ? desired.status : (coverage.desired ? "not_desired" : "unknown"),
|
|
1742
|
+
key_holders: keyHolders,
|
|
1743
|
+
covering_recipient_count: coverage.recipient_fingerprints.length,
|
|
1744
|
+
}, [
|
|
1745
|
+
{ 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" },
|
|
1746
|
+
{ action: "ask a key-holder to run any gitvault 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" },
|
|
1747
|
+
]);
|
|
1748
|
+
}
|
|
1749
|
+
const envelopeBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.envelope(genesis.epoch, ownFingerprint) });
|
|
1750
|
+
if (!envelopeBytes) {
|
|
1751
|
+
fail("GITVAULT_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 gitvault vault", { repo_id: this.repoId, epoch: genesis.epoch, recipient_fingerprint: ownFingerprint, reason: "envelope_fetch_failed" });
|
|
1752
|
+
}
|
|
1753
|
+
const envelope = parseGitvaultStrict(new TextDecoder().decode(envelopeBytes));
|
|
1754
|
+
if (envelope.repo_id !== this.repoId || envelope.epoch !== genesis.epoch || envelope.recipient_fingerprint !== ownFingerprint) {
|
|
1755
|
+
fail("GITVAULT_ENVELOPE_ALTERED", "the retrieved base key_envelope does not address this vault, epoch, and recipient — refusing to open it", "opening gitvault 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 } });
|
|
1756
|
+
}
|
|
1757
|
+
const restored = await this.keystore.restoreRepoFromEnvelope({ genesis, envelope, allocation_attested: allocationAttested });
|
|
1758
|
+
this.keystore.writeCachedGenesis(this.repoId, restored.repo.genesis_sha256, genesisBytes);
|
|
1759
|
+
this.genesisCache = { genesis, sha256: restored.repo.genesis_sha256 };
|
|
1760
|
+
return {
|
|
1761
|
+
repo_id: this.repoId,
|
|
1762
|
+
org_id: genesis.org_id,
|
|
1763
|
+
provenance: "restored_from_envelope",
|
|
1764
|
+
trust: restored.trust,
|
|
1765
|
+
continuity: restored.continuity,
|
|
1766
|
+
independently_verified: restored.independently_verified,
|
|
1767
|
+
epoch: envelope.epoch,
|
|
1768
|
+
recipient_fingerprint: ownFingerprint,
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
/** {@link open}, but a missing repo file triggers {@link ensureRepoState} first. */
|
|
1772
|
+
static async openOrRestore(options) {
|
|
1773
|
+
const v = new GitvaultVault(options);
|
|
1774
|
+
const restored = await v.ensureRepoState();
|
|
1775
|
+
v.repoFile();
|
|
1776
|
+
return { vault: v, restored };
|
|
1777
|
+
}
|
|
1641
1778
|
kRepo() { return hexToBytes(this.repoFile().k_repo_hex); }
|
|
1642
1779
|
epoch() { return this.repoFile().epoch; }
|
|
1643
1780
|
/**
|
|
@@ -1946,10 +2083,44 @@ export class GitvaultVault {
|
|
|
1946
2083
|
const decryptValidate = options.decryptValidate ?? false;
|
|
1947
2084
|
const strict = options.strict ?? true;
|
|
1948
2085
|
const { genesis, sha256: genesisSha } = await this.genesis();
|
|
2086
|
+
// gitvault-multi-writer rev 47: `writerKey`/`writerKeyId` (kept for the
|
|
2087
|
+
// rotation-envelope open below, which is signed by whoever DROVE that
|
|
2088
|
+
// specific rotation — resolved per-rotation from `writerState`, not this
|
|
2089
|
+
// fallback) name just the genesis creator, retained as the seed for
|
|
2090
|
+
// `writerState` and as the fallback signer for methods that do not
|
|
2091
|
+
// resolve a per-rotation writer.
|
|
1949
2092
|
const writerKey = genesis.creator_signing_pubkey;
|
|
1950
2093
|
const writerKeyId = genesis.writer_key_id;
|
|
1951
2094
|
const repo = this.repoFile();
|
|
1952
2095
|
let pin = repo.verified_prefix ?? repo.head_pin ?? { generation: GITVAULT_GENESIS_GENERATION, head_sha256: genesisSha, pinned_at: formatGitvaultTimestamp(this.now()) };
|
|
2096
|
+
// gitvault-multi-writer rev 47 (task 5.1): the writer set as chain state.
|
|
2097
|
+
// Trusted as the starting point at `pin`'s generation exactly the same
|
|
2098
|
+
// way `pin` itself is trusted as the starting point for the head chain —
|
|
2099
|
+
// `writer_set_pin` advances in LOCKSTEP with `head_pin`/`verified_prefix`
|
|
2100
|
+
// (see `GitvaultRepoFile.writer_set_pin`'s own doc comment), so a repeat
|
|
2101
|
+
// call never re-walks the writer set from genesis. `initialWriterState`
|
|
2102
|
+
// covers the true first-ever call (no persisted pin yet).
|
|
2103
|
+
//
|
|
2104
|
+
// `burnedWriterKeyIds`/`consumedHandoffIds` are NOT persisted and reset
|
|
2105
|
+
// to empty on every resume — deliberately, not an oversight. This is safe
|
|
2106
|
+
// for THIS function's own job (verifying an ALREADY-ADMITTED chain):
|
|
2107
|
+
// the gateway is the sole admission authority, so a burned key or a
|
|
2108
|
+
// consumed handoff_id can never appear in a real `add_writer_key`
|
|
2109
|
+
// transition on the actual chain — the gateway already refused it before
|
|
2110
|
+
// it could be admitted. These two sets only matter as a CLIENT-SIDE
|
|
2111
|
+
// pre-check before PROPOSING a new transition (tasks 5.4/5.5), where an
|
|
2112
|
+
// incomplete local set costs at most a wasted round trip — the gateway's
|
|
2113
|
+
// own fence-time re-check (which DOES hold the complete history) is the
|
|
2114
|
+
// real backstop either way. If a future caller ever needs these complete
|
|
2115
|
+
// across a resume, persist them on `writer_set_pin` too; not needed yet.
|
|
2116
|
+
let writerState = repo.writer_set_pin
|
|
2117
|
+
? { version: repo.writer_set_pin.version, writers: repo.writer_set_pin.writers, sha256: repo.writer_set_pin.sha256, burnedWriterKeyIds: new Set(), consumedHandoffIds: new Set() }
|
|
2118
|
+
: initialWriterState(this.repoId, genesis);
|
|
2119
|
+
const persistWriterSetPin = () => {
|
|
2120
|
+
if (!persist)
|
|
2121
|
+
return;
|
|
2122
|
+
this.keystore.updateRepo(this.repoId, { writer_set_pin: { version: writerState.version, sha256: writerState.sha256, writers: [...writerState.writers], pinned_at: formatGitvaultTimestamp(this.now()) } });
|
|
2123
|
+
};
|
|
1953
2124
|
// gitvault-composite-state-read design D1: try the pin-current fast path
|
|
1954
2125
|
// FIRST — see {@link tryStateFastPath}'s own doc comment. `null` means
|
|
1955
2126
|
// ineligible (genuinely more than one generation behind, or the pin's
|
|
@@ -2033,7 +2204,11 @@ export class GitvaultVault {
|
|
|
2033
2204
|
payload: rot.payload,
|
|
2034
2205
|
own_fingerprint: ownFingerprint,
|
|
2035
2206
|
own_encryption_keypair: ownKeypair,
|
|
2036
|
-
|
|
2207
|
+
// gitvault-multi-writer rev 47: the writer who actually drove
|
|
2208
|
+
// THIS rotation (resolved by the forward walk when it collected
|
|
2209
|
+
// `rot` — see `GitvaultEncounteredRotation.signing_pubkey`'s own
|
|
2210
|
+
// doc comment for the one narrower gap this falls back for).
|
|
2211
|
+
writer_signing_public_key: rot.signing_pubkey ?? writerKey,
|
|
2037
2212
|
get_envelope_bytes: (path) => this.transport.getObject({ repo_id: this.repoId, path }),
|
|
2038
2213
|
envelope_path: (epoch, fp, rotationId) => gitvaultPaths.envelope(epoch, fp, rotationId),
|
|
2039
2214
|
});
|
|
@@ -2050,7 +2225,12 @@ export class GitvaultVault {
|
|
|
2050
2225
|
// Design D2: ref_state + retention_roots both ride `head`, so one
|
|
2051
2226
|
// batched presign (or a cache hit) serves both instead of two
|
|
2052
2227
|
// independent presign-then-GET round trips.
|
|
2053
|
-
|
|
2228
|
+
// gitvault-multi-writer rev 47: `head`'s OWN signer, resolved from
|
|
2229
|
+
// the writer state — by the time this runs, `writerState` already
|
|
2230
|
+
// reflects `head`'s own transition (if any), so this correctly finds
|
|
2231
|
+
// a just-self-admitted handoff writer too, not only a pre-existing one.
|
|
2232
|
+
const headSigner = resolveActiveWriter(writerState, head.writer_key_id)?.signing_pubkey ?? writerKey;
|
|
2233
|
+
const { refState, roots } = await this.openMaterializeCarriers(head.ref_state, gitvaultPaths.refState(head.ref_state.object_id), head.retention_roots, gitvaultPaths.retentionRoots(head.retention_roots.object_id), headSigner, { epoch: head.epoch, k_repo: kRepo });
|
|
2054
2234
|
if (refState.generation !== head.generation || roots.generation !== head.generation)
|
|
2055
2235
|
fail("CHAIN_UNUSABLE", "carrier generation does not match the head", "materializing gitvault head");
|
|
2056
2236
|
decryptedRefState = refState;
|
|
@@ -2157,6 +2337,7 @@ export class GitvaultVault {
|
|
|
2157
2337
|
if (verified >= this.budget) {
|
|
2158
2338
|
if (persist)
|
|
2159
2339
|
this.keystore.updateRepo(this.repoId, { verified_prefix: pin });
|
|
2340
|
+
persistWriterSetPin();
|
|
2160
2341
|
persistMaterializedIfAny();
|
|
2161
2342
|
fail("VERIFICATION_BUDGET_EXCEEDED", persist
|
|
2162
2343
|
? `${verified} heads verified this call; the verified prefix (generation ${pin.generation}) is persisted — call again to continue`
|
|
@@ -2166,7 +2347,53 @@ export class GitvaultVault {
|
|
|
2166
2347
|
if (!bytes)
|
|
2167
2348
|
fail("CHAIN_BROKEN", `listed head ${entry.generation} is absent from storage`, "verifying gitvault chain", { generation: entry.generation });
|
|
2168
2349
|
const head = parseGitvaultStrict(new TextDecoder().decode(bytes));
|
|
2169
|
-
|
|
2350
|
+
// gitvault-multi-writer rev 47 (task 5.1): resolve WHO must have
|
|
2351
|
+
// signed this specific head from the writer state as of the
|
|
2352
|
+
// PREDECESSOR generation (`writerState`, not yet advanced past this
|
|
2353
|
+
// head) — never the single fixed genesis key. A head carrying an
|
|
2354
|
+
// admitted `add_writer_key` transition is the one case the signer
|
|
2355
|
+
// might NOT already be in `writerState`: the "handoff" door is
|
|
2356
|
+
// self-signed by the key being added, which is only proven legitimate
|
|
2357
|
+
// by `validateAddWriterKeyPayload` succeeding on THIS SAME payload —
|
|
2358
|
+
// so that validation runs BEFORE signature verification, not after,
|
|
2359
|
+
// and its own success is what licenses trusting `added_writer.signing_pubkey`
|
|
2360
|
+
// as the correct verification key.
|
|
2361
|
+
const failClosedTransition = (code, detail) => {
|
|
2362
|
+
if (persist)
|
|
2363
|
+
this.keystore.updateRepo(this.repoId, { head_pin: pin, verified_prefix: null });
|
|
2364
|
+
persistWriterSetPin();
|
|
2365
|
+
persistMaterializedIfAny();
|
|
2366
|
+
fail(code, `head ${head.generation}: ${detail}`, "verifying gitvault chain", { generation: head.generation });
|
|
2367
|
+
};
|
|
2368
|
+
const isWriterAdd = head.transition !== null && head.transition.kind === "add_writer_key";
|
|
2369
|
+
let addWriterKeyPayload = null;
|
|
2370
|
+
let resolvedWriterPubkey = writerKey;
|
|
2371
|
+
let resolvedWriterKeyId = writerKeyId;
|
|
2372
|
+
if (isWriterAdd) {
|
|
2373
|
+
addWriterKeyPayload = parseAddWriterKeyPayload(head); // throws CHAIN_BROKEN on any wire-shape defect
|
|
2374
|
+
const v = validateAddWriterKeyPayload(this.repoId, writerState, addWriterKeyPayload, head.writer_key_id);
|
|
2375
|
+
if (!v.ok)
|
|
2376
|
+
failClosedTransition(v.code, v.detail);
|
|
2377
|
+
if (addWriterKeyPayload.authorization.kind === "writer") {
|
|
2378
|
+
// The head's own signer is an EXISTING writer — same resolution as the ordinary case below.
|
|
2379
|
+
const signer = resolveActiveWriter(writerState, head.writer_key_id); // non-null: v.ok already proved it
|
|
2380
|
+
resolvedWriterPubkey = signer.signing_pubkey;
|
|
2381
|
+
resolvedWriterKeyId = signer.writer_key_id;
|
|
2382
|
+
}
|
|
2383
|
+
else {
|
|
2384
|
+
// "handoff" door: self-signed by the key being admitted — validated above, not yet in writerState.
|
|
2385
|
+
resolvedWriterPubkey = addWriterKeyPayload.added_writer.signing_pubkey;
|
|
2386
|
+
resolvedWriterKeyId = addWriterKeyPayload.added_writer.writer_key_id;
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
else {
|
|
2390
|
+
const signer = resolveActiveWriter(writerState, head.writer_key_id);
|
|
2391
|
+
if (!signer)
|
|
2392
|
+
failClosedTransition("GITVAULT_WRITER_NOT_ADMITTED", `writer_key_id ${head.writer_key_id} is not a currently admitted writer`);
|
|
2393
|
+
resolvedWriterPubkey = signer.signing_pubkey;
|
|
2394
|
+
resolvedWriterKeyId = signer.writer_key_id;
|
|
2395
|
+
}
|
|
2396
|
+
checkChainLink({ head, stored_bytes: bytes, listed_sha256: entry.stored_bytes_sha256, expected_generation: nextGeneration(pin.generation), prev_sha256: pin.head_sha256, repo_id: this.repoId, writer_public_key: resolvedWriterPubkey, writer_key_id: resolvedWriterKeyId, prev_epoch: prevEpoch });
|
|
2170
2397
|
try {
|
|
2171
2398
|
assertNoTransition(head);
|
|
2172
2399
|
}
|
|
@@ -2174,6 +2401,7 @@ export class GitvaultVault {
|
|
|
2174
2401
|
// fail closed: pin stays BELOW the transition head; the verified prefix is cleared (this is the final state, not a budget pause)
|
|
2175
2402
|
if (persist)
|
|
2176
2403
|
this.keystore.updateRepo(this.repoId, { head_pin: pin, verified_prefix: null });
|
|
2404
|
+
persistWriterSetPin();
|
|
2177
2405
|
persistMaterializedIfAny();
|
|
2178
2406
|
throw e;
|
|
2179
2407
|
}
|
|
@@ -2186,7 +2414,33 @@ export class GitvaultVault {
|
|
|
2186
2414
|
// later `materialize()` call over an already-chain-verified prefix
|
|
2187
2415
|
// can still resolve the epoch keys it needs.
|
|
2188
2416
|
rotationPayload = parseRotateEpochPayload(head);
|
|
2189
|
-
rotations.push({ generation: head.generation, epoch: head.epoch, payload: rotationPayload });
|
|
2417
|
+
rotations.push({ generation: head.generation, epoch: head.epoch, payload: rotationPayload, signing_pubkey: resolvedWriterPubkey });
|
|
2418
|
+
// gitvault-multi-writer rev 47 (D227): writer REMOVAL rides
|
|
2419
|
+
// rotate_epoch as an additive `writer_set_update` field. The
|
|
2420
|
+
// "blocked set" gate inside `validateWriterSetUpdate` is a
|
|
2421
|
+
// gateway-only, live-state fact this reader cannot independently
|
|
2422
|
+
// re-derive (the module's own header doc names this boundary) —
|
|
2423
|
+
// trusting the update's OWN declared `removed[]` as the "blocked
|
|
2424
|
+
// set" input makes that ONE check a structural no-op while every
|
|
2425
|
+
// OTHER invariant (base/next commitments, the writers-array
|
|
2426
|
+
// arithmetic, the signer-survives-or-explicit-terminal rule) is
|
|
2427
|
+
// still independently re-verified in full. Honest: this reader
|
|
2428
|
+
// trusts the GATEWAY already answered "were these the right keys
|
|
2429
|
+
// to remove" at admission time, same as it already trusts every
|
|
2430
|
+
// other admission decision it did not itself make.
|
|
2431
|
+
if (rotationPayload.writer_set_update) {
|
|
2432
|
+
const wsu = rotationPayload.writer_set_update;
|
|
2433
|
+
const declaredBlocked = new Set(wsu.removed.map((r) => r.writer_key_id));
|
|
2434
|
+
const allowEmpty = wsu.writers.length === 0;
|
|
2435
|
+
const wv = validateWriterSetUpdate(this.repoId, writerState, wsu, head.writer_key_id, declaredBlocked, allowEmpty);
|
|
2436
|
+
if (!wv.ok)
|
|
2437
|
+
failClosedTransition(wv.code, wv.detail);
|
|
2438
|
+
writerState = applyWriterSetUpdate(this.repoId, writerState, wsu.removed.map((r) => r.writer_key_id));
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
if (isWriterAdd && addWriterKeyPayload) {
|
|
2442
|
+
const consumedHandoffId = addWriterKeyPayload.authorization.kind === "handoff" ? addWriterKeyPayload.authorization.grant.handoff_id ?? null : null;
|
|
2443
|
+
writerState = applyAddWriterKey(this.repoId, writerState, { writer_key_id: addWriterKeyPayload.added_writer.writer_key_id, signing_pubkey: addWriterKeyPayload.added_writer.signing_pubkey }, consumedHandoffId);
|
|
2190
2444
|
}
|
|
2191
2445
|
prevEpoch = head.epoch;
|
|
2192
2446
|
pin = { generation: head.generation, head_sha256: entry.stored_bytes_sha256, pinned_at: formatGitvaultTimestamp(this.now()) };
|
|
@@ -2205,6 +2459,7 @@ export class GitvaultVault {
|
|
|
2205
2459
|
// verified prefix persists per page (resumable) — skipped entirely in no-write mode
|
|
2206
2460
|
if (persist)
|
|
2207
2461
|
this.keystore.updateRepo(this.repoId, { verified_prefix: pin });
|
|
2462
|
+
persistWriterSetPin();
|
|
2208
2463
|
persistMaterializedIfAny();
|
|
2209
2464
|
const next = nextListingRequest(request, page);
|
|
2210
2465
|
if (!next)
|
|
@@ -2240,9 +2495,16 @@ export class GitvaultVault {
|
|
|
2240
2495
|
while (cur && !epochKeys[cur.epoch]) {
|
|
2241
2496
|
if (cur.transition !== null && cur.transition.kind === "rotate_epoch") {
|
|
2242
2497
|
const payload = parseRotateEpochPayload(cur);
|
|
2243
|
-
|
|
2498
|
+
// gitvault-multi-writer rev 47: best-effort resolution against the
|
|
2499
|
+
// FINAL (post-forward-walk) writer state — correct unless `cur`'s
|
|
2500
|
+
// own signer was removed by a LATER rotation between `cur` and
|
|
2501
|
+
// `lastHead`, the one documented residual gap (falls back to the
|
|
2502
|
+
// genesis-creator key at the `openEpochRotationForRecipient`
|
|
2503
|
+
// call site when this resolves to `undefined`).
|
|
2504
|
+
const curSigner = resolveActiveWriter(writerState, cur.writer_key_id)?.signing_pubkey;
|
|
2505
|
+
backwardRotations.unshift({ generation: cur.generation, epoch: cur.epoch, payload, signing_pubkey: curSigner });
|
|
2244
2506
|
if (!rotations.some((r) => r.generation === cur.generation))
|
|
2245
|
-
rotations.push({ generation: cur.generation, epoch: cur.epoch, payload });
|
|
2507
|
+
rotations.push({ generation: cur.generation, epoch: cur.epoch, payload, signing_pubkey: curSigner });
|
|
2246
2508
|
}
|
|
2247
2509
|
if (cur.generation === "0000000000000001") {
|
|
2248
2510
|
cur = null;
|
|
@@ -2277,6 +2539,7 @@ export class GitvaultVault {
|
|
|
2277
2539
|
}
|
|
2278
2540
|
if (persist)
|
|
2279
2541
|
this.keystore.updateRepo(this.repoId, { head_pin: pin, verified_prefix: null });
|
|
2542
|
+
persistWriterSetPin();
|
|
2280
2543
|
persistMaterializedIfAny();
|
|
2281
2544
|
// `strict` (materialize()'s ordinary, fail-closed read path) is enforced
|
|
2282
2545
|
// HERE, once, after the full keyless chain walk has already run to
|
|
@@ -2498,10 +2761,9 @@ export class GitvaultVault {
|
|
|
2498
2761
|
* **This is task 1.1's residual WORKAROUND, not the design D5 ideal.** D5
|
|
2499
2762
|
* describes a recipient-set change as an epoch rotation — "history epochs
|
|
2500
2763
|
* stay wrapped as they were; a new member reads from their first covered
|
|
2501
|
-
* epoch forward" — which needs a protocol revision
|
|
2502
|
-
*
|
|
2503
|
-
*
|
|
2504
|
-
* speak of yet). What this method actually does, legally, without any
|
|
2764
|
+
* epoch forward" — which needs a protocol revision: V0 pins `epoch` to
|
|
2765
|
+
* the single constant `GITVAULT_GENESIS_EPOCH` on every head, so there
|
|
2766
|
+
* is no "forward" to speak of. What this method actually does, legally, without any
|
|
2505
2767
|
* protocol change: `key_envelope` objects are never head-referenced (not
|
|
2506
2768
|
* even genesis's own envelope is), so uploading an ADDITIONAL one at
|
|
2507
2769
|
* `envelopes/<current epoch>/<recipient fingerprint>` for a missing
|
|
@@ -2524,7 +2786,7 @@ export class GitvaultVault {
|
|
|
2524
2786
|
* this SDK does not make unattended.
|
|
2525
2787
|
*
|
|
2526
2788
|
* **The gateway directory route carries `public_key` on every row**
|
|
2527
|
-
* (`GET /orgs/v1/:org_id/encryption-keys
|
|
2789
|
+
* (`GET /orgs/v1/:org_id/encryption-keys` — see the
|
|
2528
2790
|
* doc comment on {@link GitvaultOrgEncryptionKeyEntry}), so against a
|
|
2529
2791
|
* current gateway entries actually wrap. A directory entry that arrives
|
|
2530
2792
|
* WITHOUT the field (an older/rolling-deploy gateway) is still tolerated
|
|
@@ -2613,6 +2875,33 @@ export class GitvaultVault {
|
|
|
2613
2875
|
// recipient is now genuinely covered. Treat exactly that code as a
|
|
2614
2876
|
// benign race, not a failure; anything else propagates.
|
|
2615
2877
|
if (isRun402Error(e) && e.code === "GITVAULT_OBJECT_EXISTS_DIFFERENT") {
|
|
2878
|
+
// gitvault-agent-envelopes (consult 7.6): a benign race is only
|
|
2879
|
+
// benign once the WINNING envelope has been read back and verified
|
|
2880
|
+
// — same repo, same epoch, this recipient, signed by the vault's
|
|
2881
|
+
// registered writer. Anything else is recorded as skipped, never as
|
|
2882
|
+
// coverage, and never pinned.
|
|
2883
|
+
const winnerPath = gitvaultPaths.envelope(epoch, sealed.receipt.recipient_fingerprint);
|
|
2884
|
+
const winnerBytes = await this.transport.getObject({ repo_id: this.repoId, path: winnerPath }).catch(() => null);
|
|
2885
|
+
let winnerOk = false;
|
|
2886
|
+
if (winnerBytes) {
|
|
2887
|
+
try {
|
|
2888
|
+
const winner = parseGitvaultStrict(new TextDecoder().decode(winnerBytes));
|
|
2889
|
+
const g = (await this.genesis()).genesis;
|
|
2890
|
+
winnerOk = winner.repo_id === this.repoId && winner.epoch === epoch && winner.recipient_fingerprint === entry.ek_fingerprint
|
|
2891
|
+
&& winner.created_by === g.writer_key_id
|
|
2892
|
+
&& verifyGitvaultObject(winner, g.creator_signing_pubkey);
|
|
2893
|
+
}
|
|
2894
|
+
catch {
|
|
2895
|
+
winnerOk = false;
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
if (!winnerOk) {
|
|
2899
|
+
// Consult round 2 §7: a conflicting IMMUTABLE object whose stored
|
|
2900
|
+
// winner does not verify is evidence of tampering, equivocation,
|
|
2901
|
+
// or broken object identity — categorically different from a
|
|
2902
|
+
// stale recipient. FATAL, never an ordinary skip.
|
|
2903
|
+
fail("GITVAULT_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 gitvault envelope recipients", { repo_id: this.repoId, path: winnerPath, epoch, recipient_fingerprint: entry.ek_fingerprint });
|
|
2904
|
+
}
|
|
2616
2905
|
alreadyCovered.push(entry.ek_fingerprint);
|
|
2617
2906
|
}
|
|
2618
2907
|
else {
|
|
@@ -2657,6 +2946,49 @@ export class GitvaultVault {
|
|
|
2657
2946
|
return kp.seed;
|
|
2658
2947
|
}
|
|
2659
2948
|
writerKeyId() { return this.keystore.ensureIdentity().signing_fingerprint; }
|
|
2949
|
+
/**
|
|
2950
|
+
* gitvault-multi-writer (task 5.8) — the push pre-check: before signing an
|
|
2951
|
+
* ORDINARY head (any operation that expects this session to already be an
|
|
2952
|
+
* active writer), refuse EARLY and LOCALLY when this session's own key is
|
|
2953
|
+
* not (or is no longer) an active writer, rather than let real
|
|
2954
|
+
* crypto/upload work run only to be refused by the gateway with a less
|
|
2955
|
+
* specific message once the head finally reaches it. Reads the FRESHLY-
|
|
2956
|
+
* pinned `writer_set_pin`; every call site below calls this immediately
|
|
2957
|
+
* after its own `materialize()`/`verifyToNewest()` (same generation the
|
|
2958
|
+
* caller is about to build against), so this performs no verification of
|
|
2959
|
+
* its own — it only reads what the caller already froze.
|
|
2960
|
+
*
|
|
2961
|
+
* `removedMidRace` (design D8/D10) distinguishes the two shapes this
|
|
2962
|
+
* refusal takes: `false` (the default) is the ORDINARY pre-check — this
|
|
2963
|
+
* session was never (or is not currently) an admitted writer, thrown as
|
|
2964
|
+
* `GITVAULT_WRITER_NOT_ADMITTED`. `true` is the CAS-LOSER path
|
|
2965
|
+
* specifically: a retry, after re-materializing from the winner, that
|
|
2966
|
+
* discovers THIS session's own key was removed by whatever won the race —
|
|
2967
|
+
* "stop if removed" in D8's loser-rule sequence (fetch winner → verify →
|
|
2968
|
+
* apply writer transition → stop if removed → rebase → rebuild → bounded
|
|
2969
|
+
* backoff) — thrown as the more specific, client-local
|
|
2970
|
+
* `GITVAULT_WRITER_REMOVED` (D10) instead: a real prior attempt just lost
|
|
2971
|
+
* to a removal, not a caller who was never eligible.
|
|
2972
|
+
*
|
|
2973
|
+
* Deliberately NOT called for a "handoff"-door `add_writer_key` head
|
|
2974
|
+
* ({@link submitWriterActivationHead}): there the signer is BY DESIGN not
|
|
2975
|
+
* yet a writer — becoming one is what that exact head does.
|
|
2976
|
+
*/
|
|
2977
|
+
assertCallerIsWriter(context, removedMidRace = false) {
|
|
2978
|
+
const pin = this.repoFile().writer_set_pin;
|
|
2979
|
+
const myKey = this.writerKeyId();
|
|
2980
|
+
if (pin && pin.writers.some((w) => w.writer_key_id === myKey))
|
|
2981
|
+
return;
|
|
2982
|
+
const code = removedMidRace ? "GITVAULT_WRITER_REMOVED" : "GITVAULT_WRITER_NOT_ADMITTED";
|
|
2983
|
+
fail(code, removedMidRace
|
|
2984
|
+
? `this session's writer key (${myKey}) was removed while a conflicting head was admitted first`
|
|
2985
|
+
: `this session's writer key (${myKey}) is not an active writer on this vault`, context, {}, [
|
|
2986
|
+
{
|
|
2987
|
+
type: "request_writer_sync",
|
|
2988
|
+
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 gitvault operation (push, deploy, or r.gitvault.reconcile()) admits pending writers automatically.",
|
|
2989
|
+
},
|
|
2990
|
+
]);
|
|
2991
|
+
}
|
|
2660
2992
|
/** The owner's full signing keypair, or `GITVAULT_READ_ONLY` — same refusal path as {@link signer}, which returns only the seed; {@link sealKeyEnvelope} needs both halves. */
|
|
2661
2993
|
signingKeypair() {
|
|
2662
2994
|
const identity = this.keystore.ensureIdentity();
|
|
@@ -2872,7 +3204,17 @@ export class GitvaultVault {
|
|
|
2872
3204
|
if (!block)
|
|
2873
3205
|
fail("CHECKPOINT_INCOMPLETE", `head ${head.generation} carries no checkpoint to verify`, "verifying a stored checkpoint", { generation: head.generation });
|
|
2874
3206
|
const { genesis } = await this.genesis();
|
|
2875
|
-
|
|
3207
|
+
// gitvault-multi-writer rev 47: `head` may have been signed by any
|
|
3208
|
+
// CURRENTLY-KNOWN writer, not just the genesis creator — resolved from
|
|
3209
|
+
// the keystore's persisted `writer_set_pin` (the same substrate
|
|
3210
|
+
// `verifyToNewest` maintains), falling back to the genesis-creator key
|
|
3211
|
+
// for a pre-rev-47 keystore or one that hasn't run `verifyToNewest` yet
|
|
3212
|
+
// (byte-identical to this method's own pre-rev-47 behavior in that case).
|
|
3213
|
+
// A writer removed SINCE this checkpoint's own head was admitted is the
|
|
3214
|
+
// same documented residual as the backward-catch-up decrypt path above —
|
|
3215
|
+
// `writer_set_pin` only remembers currently-active writers, not burned ones.
|
|
3216
|
+
const pinnedWriters = this.repoFile().writer_set_pin?.writers ?? [];
|
|
3217
|
+
const writerKey = pinnedWriters.find((w) => w.writer_key_id === head.writer_key_id)?.signing_pubkey ?? genesis.creator_signing_pubkey;
|
|
2876
3218
|
const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.claimSet(block.claim_set.object_id), expected_sha256: block.claim_set.stored_bytes_sha256 });
|
|
2877
3219
|
if (!claimBytes || sha256Hex(claimBytes) !== block.claim_set.stored_bytes_sha256) {
|
|
2878
3220
|
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 });
|
|
@@ -2945,11 +3287,17 @@ export class GitvaultVault {
|
|
|
2945
3287
|
const generations = [...chain.keys()].sort();
|
|
2946
3288
|
const out = [];
|
|
2947
3289
|
const { genesis } = await this.genesis();
|
|
3290
|
+
// gitvault-multi-writer rev 47: `verifyToNewest()` above already
|
|
3291
|
+
// persisted the current `writer_set_pin` — same per-head resolution as
|
|
3292
|
+
// `verifyStoredCheckpoint`, same documented residual (a writer removed
|
|
3293
|
+
// since falls back to the genesis-creator key).
|
|
3294
|
+
const pinnedWriters = this.repoFile().writer_set_pin?.writers ?? [];
|
|
2948
3295
|
for (const gen of generations) {
|
|
2949
3296
|
const entry = chain.get(gen);
|
|
2950
3297
|
let claimSet = null;
|
|
2951
3298
|
const block = entry.head.checkpoint;
|
|
2952
3299
|
if (block) {
|
|
3300
|
+
const writerKey = pinnedWriters.find((w) => w.writer_key_id === entry.head.writer_key_id)?.signing_pubkey ?? genesis.creator_signing_pubkey;
|
|
2953
3301
|
// Plaintext-structured and stored-bytes-receipted: no decryption, but
|
|
2954
3302
|
// the hash and the owner signature are still checked before a single
|
|
2955
3303
|
// pack receipt inside it is believed.
|
|
@@ -2958,7 +3306,7 @@ export class GitvaultVault {
|
|
|
2958
3306
|
fail("CHECKPOINT_INCOMPLETE", `checkpoint claim set ${block.claim_set.object_id} (generation ${gen}) is absent or altered`, "walking the gitvault chain", { generation: gen, object_id: block.claim_set.object_id });
|
|
2959
3307
|
}
|
|
2960
3308
|
claimSet = parseGitvaultStrict(new TextDecoder().decode(bytes));
|
|
2961
|
-
if (!verifyGitvaultObject(claimSet,
|
|
3309
|
+
if (!verifyGitvaultObject(claimSet, writerKey)) {
|
|
2962
3310
|
fail("CHECKPOINT_INCOMPLETE", `checkpoint claim set ${claimSet.object_id} signature fails`, "walking the gitvault chain", { generation: gen });
|
|
2963
3311
|
}
|
|
2964
3312
|
}
|
|
@@ -3041,7 +3389,7 @@ export class GitvaultVault {
|
|
|
3041
3389
|
rawPackBytes += pack.length;
|
|
3042
3390
|
}
|
|
3043
3391
|
}
|
|
3044
|
-
//
|
|
3392
|
+
// everything above this point is REAL local work —
|
|
3045
3393
|
// real signing, real pack building, real encryption. Stopping HERE is what
|
|
3046
3394
|
// makes the dry run honest: nothing below this line has run yet, so
|
|
3047
3395
|
// nothing was uploaded and no generation was admitted.
|
|
@@ -3055,7 +3403,13 @@ export class GitvaultVault {
|
|
|
3055
3403
|
ref_state: { object_id: refState.object.object_id, object_kind: "ref_state", ciphertext_sha256: refState.upload.sha256, size_bytes: refState.upload.size_bytes },
|
|
3056
3404
|
retention_roots: { object_id: rootsObj.object.object_id, object_kind: "retention_roots", ciphertext_sha256: rootsObj.upload.sha256, size_bytes: rootsObj.upload.size_bytes },
|
|
3057
3405
|
checkpoint, checkpoint_purpose: checkpoint ? "ordinary_push" : null, capture_binding: binding, repair: null,
|
|
3058
|
-
}
|
|
3406
|
+
},
|
|
3407
|
+
// gitvault-multi-writer (task 5.6) — an `add_writer_key` activation
|
|
3408
|
+
// head rides THIS same content-neutral machinery: `input.transition`
|
|
3409
|
+
// is undefined for every pre-existing caller (push/publishCheckpoint),
|
|
3410
|
+
// so their heads are byte-for-byte unchanged; `submitWriterActivationHead`
|
|
3411
|
+
// below is the one caller that passes it.
|
|
3412
|
+
input.transition !== undefined ? { transition: input.transition } : undefined);
|
|
3059
3413
|
const admitted = await this.admit(head);
|
|
3060
3414
|
if (admitted.outcome === "conflict")
|
|
3061
3415
|
return { outcome: "conflict", generation, winner: admitted.winner };
|
|
@@ -3073,6 +3427,14 @@ export class GitvaultVault {
|
|
|
3073
3427
|
let conflicts = 0;
|
|
3074
3428
|
let base = options.base ?? (await this.materialize());
|
|
3075
3429
|
for (;;) {
|
|
3430
|
+
// gitvault-multi-writer (task 5.8) — checked fresh on EVERY attempt
|
|
3431
|
+
// (including a CAS-loser retry, design D8's "stop if removed"): a
|
|
3432
|
+
// concurrent rotation could remove this session's own writer key
|
|
3433
|
+
// between one attempt and the next. `conflicts > 0` is exactly the
|
|
3434
|
+
// CAS-loser path (this iteration only runs after losing a race and
|
|
3435
|
+
// re-materializing from the winner) — GITVAULT_WRITER_REMOVED there,
|
|
3436
|
+
// the ordinary GITVAULT_WRITER_NOT_ADMITTED on the first attempt.
|
|
3437
|
+
this.assertCallerIsWriter("publishing gitvault head", conflicts > 0);
|
|
3076
3438
|
const evaluation = await evaluateRefTransaction(base.refs, options.transaction, { isAncestor: (a, d) => isAncestor(this.git(), a, d), protocol_refs: options.protocol_refs });
|
|
3077
3439
|
const published = await this.publishGeneration({
|
|
3078
3440
|
base, refs: evaluation.refs, dropped: evaluation.dropped, head_target: options.head_target ?? base.head_target,
|
|
@@ -3094,7 +3456,7 @@ export class GitvaultVault {
|
|
|
3094
3456
|
}
|
|
3095
3457
|
}
|
|
3096
3458
|
/**
|
|
3097
|
-
* A REAL preview of what {@link push} would publish
|
|
3459
|
+
* A REAL preview of what {@link push} would publish
|
|
3098
3460
|
* — runs the SAME local pipeline `push` runs (materialize → evaluate →
|
|
3099
3461
|
* evolve retention roots → build refState/retentionRoots → build packs or a
|
|
3100
3462
|
* checkpoint set → seal/encrypt) and stops BEFORE the two network
|
|
@@ -3302,9 +3664,9 @@ export class GitvaultVault {
|
|
|
3302
3664
|
* `POST …/object-reads` rejected every `recipient_pin_manifest` read
|
|
3303
3665
|
* (its null-`idScalar` validation was hardcoded to `key_envelope`'s
|
|
3304
3666
|
* `{epoch, recipient_fingerprint}` shape, never generalized when D197
|
|
3305
|
-
* shipped the second path-addressed kind),
|
|
3306
|
-
*
|
|
3307
|
-
*
|
|
3667
|
+
* shipped the second path-addressed kind), which 400'd the network
|
|
3668
|
+
* fallback below. That gateway bug is fixed: the network path works for
|
|
3669
|
+
* any keystore, including
|
|
3308
3670
|
* §4.11's fresh-client "SEEDS its local pin file from it" onboarding.
|
|
3309
3671
|
* The cache stays purely as the round-trip saver described above.
|
|
3310
3672
|
*/
|
|
@@ -3319,7 +3681,11 @@ export class GitvaultVault {
|
|
|
3319
3681
|
}
|
|
3320
3682
|
const manifest = parseGitvaultStrict(new TextDecoder().decode(bytes));
|
|
3321
3683
|
const { genesis } = await this.genesis();
|
|
3322
|
-
|
|
3684
|
+
// gitvault-multi-writer rev 47: resolve the manifest's OWN declared
|
|
3685
|
+
// signer against the persisted writer set (same pattern + same
|
|
3686
|
+
// documented residual as `verifyStoredCheckpoint`/`chainEntries` above).
|
|
3687
|
+
const manifestSigner = this.repoFile().writer_set_pin?.writers.find((w) => w.writer_key_id === manifest.writer_key_id)?.signing_pubkey ?? genesis.creator_signing_pubkey;
|
|
3688
|
+
if (!verifyGitvaultObject(manifest, manifestSigner)) {
|
|
3323
3689
|
fail("GITVAULT_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 });
|
|
3324
3690
|
}
|
|
3325
3691
|
const pinnedFingerprintOf = new Map(manifest.pins.map((p) => [p.principal_id, p.ek_fingerprint]));
|
|
@@ -3392,6 +3758,7 @@ export class GitvaultVault {
|
|
|
3392
3758
|
let conflicts = 0;
|
|
3393
3759
|
for (;;) {
|
|
3394
3760
|
const base = await this.materialize();
|
|
3761
|
+
this.assertCallerIsWriter("publishing a recipient_pin_manifest update", conflicts > 0); // gitvault-multi-writer task 5.8
|
|
3395
3762
|
const prior = await this.loadEffectivePinManifest(base.generation);
|
|
3396
3763
|
const { nextVersion, manifestSha, upload: manifestUpload, pins } = this.buildPinManifestUpdate(prior, [input]);
|
|
3397
3764
|
await this.uploadAll([manifestUpload]);
|
|
@@ -3459,10 +3826,10 @@ export class GitvaultVault {
|
|
|
3459
3826
|
* `publishPinManifestUpdate` is an ORDINARY admission (`transition:
|
|
3460
3827
|
* null`) and is therefore itself refused `EPOCH_ROTATION_REQUIRED` while
|
|
3461
3828
|
* this vault has an urgent/migration condition outstanding — the exact
|
|
3462
|
-
* state a `rotateEpoch` call is being made to clear.
|
|
3463
|
-
*
|
|
3464
|
-
*
|
|
3465
|
-
*
|
|
3829
|
+
* state a `rotateEpoch` call is being made to clear. On an
|
|
3830
|
+
* `epoch_secret_exposed` rekey `/confirm` mints a receipt server-side,
|
|
3831
|
+
* but the ordinary push that would publish it never admits. Pass the
|
|
3832
|
+
* pending receipted updates
|
|
3466
3833
|
* here instead — they ride the SAME head as this rotation's `transition`,
|
|
3467
3834
|
* which IS `EPOCH_ROTATION_REQUIRED`'s own escape valve, so the publish
|
|
3468
3835
|
* is no longer blocked. **This does NOT include these principals in
|
|
@@ -3490,6 +3857,7 @@ export class GitvaultVault {
|
|
|
3490
3857
|
let conflicts = 0;
|
|
3491
3858
|
for (;;) {
|
|
3492
3859
|
const base = await this.materialize();
|
|
3860
|
+
this.assertCallerIsWriter("driving an epoch rotation", conflicts > 0); // gitvault-multi-writer task 5.8
|
|
3493
3861
|
const repo = this.repoFile();
|
|
3494
3862
|
const currentEpoch = base.head?.epoch ?? this.epoch();
|
|
3495
3863
|
const newEpoch = nextEpoch(currentEpoch);
|
|
@@ -3579,6 +3947,65 @@ export class GitvaultVault {
|
|
|
3579
3947
|
excluded_keyless_principal_ids: excludedKeyless,
|
|
3580
3948
|
excluded_unconfirmed_principal_ids: excludedUnconfirmed,
|
|
3581
3949
|
});
|
|
3950
|
+
// gitvault-multi-writer (task 5.9, D6/D227) — fold in a writer_set_update
|
|
3951
|
+
// whenever the gateway's own blocked set (`ineligible_members`, read
|
|
3952
|
+
// fresh) is non-empty. This is what closes D6's "ordinary
|
|
3953
|
+
// admissions... refuse EPOCH_ROTATION_REQUIRED... until a rotate_epoch
|
|
3954
|
+
// carries writer_set_update" deadlock, automatically, on EVERY
|
|
3955
|
+
// rotation regardless of `options.reason` — the SAME "self-healing"
|
|
3956
|
+
// shape `pending_confirmations` already gives the recipient side.
|
|
3957
|
+
//
|
|
3958
|
+
// Deliberately NO caller-supplied "also remove this OTHER writer key"
|
|
3959
|
+
// option: the gateway's own admission rule requires `removed[]` to
|
|
3960
|
+
// equal the gateway-computed blocked set EXACTLY (D6's own words,
|
|
3961
|
+
// confirmed against `validateWriterSetUpdate`'s identical
|
|
3962
|
+
// RECIPIENT_SET_MISMATCH check) — a client-named superset would be
|
|
3963
|
+
// refused, not honored. A genuinely NEW, deliberate writer-key
|
|
3964
|
+
// revocation (the "repos access revoke-key" aspiration this task's
|
|
3965
|
+
// own text names) needs a gateway-side "declare this writer key
|
|
3966
|
+
// revoked" mutation FIRST (the writer-dimension analog of
|
|
3967
|
+
// `declareRecipientKeyRevoked`) to populate `ineligible_members` in
|
|
3968
|
+
// the first place — confirmed absent from every gateway route as of
|
|
3969
|
+
// this task (`grep -rn "declareWriterKeyRevoked|revoke-key"
|
|
3970
|
+
// packages/gateway/src/routes/` — zero hits). That server-side
|
|
3971
|
+
// mutation is real, new, owner+step-up-gated authorization logic, not
|
|
3972
|
+
// a client-side gap this task can close — filed as a residual rather
|
|
3973
|
+
// than half-built against a route that doesn't exist. Once it ships,
|
|
3974
|
+
// this SAME automatic fold-in picks up whatever it gateway-blocks
|
|
3975
|
+
// with zero client changes — the fold-in doesn't care WHY a key
|
|
3976
|
+
// is blocked, only THAT it is.
|
|
3977
|
+
const writerPin = this.repoFile().writer_set_pin;
|
|
3978
|
+
const vaultRecord = await this.transport.getVaultRecord({ repo_id: this.repoId });
|
|
3979
|
+
const ineligible = vaultRecord.ineligible_members ?? [];
|
|
3980
|
+
let writerSetUpdate = null;
|
|
3981
|
+
if (ineligible.length > 0) {
|
|
3982
|
+
if (!writerPin) {
|
|
3983
|
+
fail("GITVAULT_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");
|
|
3984
|
+
}
|
|
3985
|
+
const predecessorState = { version: writerPin.version, writers: writerPin.writers, sha256: writerPin.sha256, burnedWriterKeyIds: new Set(), consumedHandoffIds: new Set() };
|
|
3986
|
+
// A rotation's own top-level `reason:"epoch_secret_exposed"` is the
|
|
3987
|
+
// one unambiguous signal worth carrying down to each removed entry
|
|
3988
|
+
// (a genuinely distinct, meaningful narrative); everything else —
|
|
3989
|
+
// including the gateway's own finer-grained `ineligible_members[].
|
|
3990
|
+
// reason` taxonomy (membership_revoked/role_below_developer/
|
|
3991
|
+
// encryption_key_revoked/gateway_blocked_pending_removal), which
|
|
3992
|
+
// has no clean mapping onto this field's narrower 3-value wire
|
|
3993
|
+
// vocabulary — collapses to the safe, generic "member_removed".
|
|
3994
|
+
const perEntryReason = options.reason === "epoch_secret_exposed" ? "epoch_secret_exposed" : "member_removed";
|
|
3995
|
+
const removed = ineligible.map((m) => ({ writer_key_id: m.writer_key_id, principal_id: m.principal_id, reason: perEntryReason }));
|
|
3996
|
+
const removedIds = removed.map((r) => r.writer_key_id);
|
|
3997
|
+
const nextState = applyWriterSetUpdate(this.repoId, predecessorState, removedIds);
|
|
3998
|
+
const update = {
|
|
3999
|
+
base_version: writerPin.version, base_sha256: writerPin.sha256,
|
|
4000
|
+
next_version: nextState.version, next_sha256: nextState.sha256,
|
|
4001
|
+
removed, writers: nextState.writers,
|
|
4002
|
+
};
|
|
4003
|
+
const wsuCheck = validateWriterSetUpdate(this.repoId, predecessorState, update, this.writerKeyId(), new Set(removedIds), options.force_empty_writer_set === true);
|
|
4004
|
+
if (!wsuCheck.ok) {
|
|
4005
|
+
fail(wsuCheck.code, `internal: the writer_set_update this producer built is not valid (${wsuCheck.detail}) — refusing to submit a rotation the gateway would refuse`, "computing the writer_set_update", { removed: removedIds });
|
|
4006
|
+
}
|
|
4007
|
+
writerSetUpdate = { base_version: update.base_version, base_sha256: update.base_sha256, next_version: update.next_version, next_sha256: update.next_sha256, removed, writers: [...update.writers] };
|
|
4008
|
+
}
|
|
3582
4009
|
// D195: build + sign the rotation_attempt_descriptor, derive rotation_id, submit the create-only CAS BEFORE any envelope upload.
|
|
3583
4010
|
const clientIdempotencyKey = options.client_idempotency_key ?? newHex32();
|
|
3584
4011
|
const descriptorFields = {
|
|
@@ -3587,6 +4014,8 @@ export class GitvaultVault {
|
|
|
3587
4014
|
recipient_state_version: options.recipient_state_version, recipient_revocation_version: options.recipient_revocation_version,
|
|
3588
4015
|
pin_manifest_sha256: pinManifest.pinManifestSha256, target_partition_digest: targetPartitionDigest,
|
|
3589
4016
|
client_idempotency_key: clientIdempotencyKey, writer_key_id: this.writerKeyId(),
|
|
4017
|
+
// D227 (rev 47): present together IFF this rotation carries a writer_set_update — frozen at THIS attempt's own admission fence, mirroring recipient_state_version/recipient_revocation_version's existing discipline.
|
|
4018
|
+
...(writerSetUpdate ? { writer_revocation_version: vaultRecord.writer_revocation_version ?? "0", writer_set_base_sha256: writerSetUpdate.base_sha256, writer_set_next_sha256: writerSetUpdate.next_sha256 } : {}),
|
|
3590
4019
|
};
|
|
3591
4020
|
const attemptCommitment = attemptKeyCommitment(kE, this.repoId, newEpoch, descriptorFields);
|
|
3592
4021
|
const signedDescriptor = signGitvaultObject({ ...descriptorFields, attempt_key_commitment: attemptCommitment }, this.signer());
|
|
@@ -3614,6 +4043,8 @@ export class GitvaultVault {
|
|
|
3614
4043
|
pin_manifest_sha256: pinManifest.pinManifestSha256, target_partition_digest: targetPartitionDigest,
|
|
3615
4044
|
epoch_key_commitment: epochKeyCommitmentValue, excluded_keyless_principal_ids: excludedKeyless, excluded_unconfirmed_principal_ids: excludedUnconfirmed,
|
|
3616
4045
|
recipient_authority_attestation: null, envelopes: sealedReceipts,
|
|
4046
|
+
// gitvault-multi-writer (task 5.9, D227): present IFF this rotation ALSO removes one or more writers.
|
|
4047
|
+
...(writerSetUpdate ? { writer_set_update: writerSetUpdate } : {}),
|
|
3617
4048
|
};
|
|
3618
4049
|
// D209 (rev 44) — round-trip THIS principal's own new-epoch
|
|
3619
4050
|
// key_envelope through the REAL reader entry point
|
|
@@ -3623,11 +4054,10 @@ export class GitvaultVault {
|
|
|
3623
4054
|
// EPOCH_ROTATION_SELF_OPEN_UNPROVEN on any rotate_epoch admission
|
|
3624
4055
|
// that omits this or whose claim disagrees with its own
|
|
3625
4056
|
// server-computed writer-in-envelopes biconditional — this call site
|
|
3626
|
-
//
|
|
3627
|
-
//
|
|
3628
|
-
//
|
|
3629
|
-
//
|
|
3630
|
-
// below (which called openKeyEnvelope directly, bypassing the
|
|
4057
|
+
// closes the gap where a client can WRITE a rotated vault it cannot
|
|
4058
|
+
// READ, because the reader never implements rotation traversal while
|
|
4059
|
+
// the write side stays green on its own tests. It replaces a bare
|
|
4060
|
+
// post-commit self-check (calling openKeyEnvelope directly, bypassing the
|
|
3631
4061
|
// membership lookup, the envelope_path callback derivation, and the
|
|
3632
4062
|
// reader's own error framing) — moving the round-trip BEFORE
|
|
3633
4063
|
// admission means a genuine failure aborts this call before anything
|
|
@@ -3737,12 +4167,20 @@ export class GitvaultVault {
|
|
|
3737
4167
|
if (pinManifestFold) {
|
|
3738
4168
|
this.keystore.updateRepo(this.repoId, { known_pin_manifest: { pin_manifest_version: pinManifestFold.nextVersion, stored_bytes_sha256: pinManifestFold.manifestSha, pins: pinManifestFold.pins } });
|
|
3739
4169
|
}
|
|
4170
|
+
// gitvault-multi-writer (task 5.9) — the SAME local-pin advance
|
|
4171
|
+
// #publishAddWriterKeyTransition performs on ITS success path: this
|
|
4172
|
+
// head just admitted a writer_set_update, so the locally-pinned
|
|
4173
|
+
// writer set is now stale until refreshed here.
|
|
4174
|
+
if (writerSetUpdate) {
|
|
4175
|
+
this.keystore.updateRepo(this.repoId, { writer_set_pin: { version: writerSetUpdate.next_version, sha256: writerSetUpdate.next_sha256, writers: [...writerSetUpdate.writers], pinned_at: formatGitvaultTimestamp(this.now()) } });
|
|
4176
|
+
}
|
|
3740
4177
|
return {
|
|
3741
4178
|
outcome: "admitted", generation, head_sha256: admitted.head_sha256, new_epoch: newEpoch, rotation_id: rotationId, reason: options.reason,
|
|
3742
4179
|
included: included.map((p) => ({ principal_id: p.principal_id, ek_fingerprint: p.ek_fingerprint })),
|
|
3743
4180
|
excluded_keyless_principal_ids: excludedKeyless, excluded_unconfirmed_principal_ids: excludedUnconfirmed,
|
|
3744
4181
|
admission_record_sha256: admitted.admission_record_sha256, capture_receipt: admitted.capture_receipt, self_check: selfCheck,
|
|
3745
4182
|
pin_manifest_published: pinManifestFold ? { pin_manifest_version: pinManifestFold.nextVersion, stored_bytes_sha256: pinManifestFold.manifestSha, principal_ids: pendingConfirmations.map((p) => p.principal_id) } : null,
|
|
4183
|
+
writers_removed: writerSetUpdate ? writerSetUpdate.removed.map((r) => ({ writer_key_id: r.writer_key_id, principal_id: r.principal_id, reason: r.reason })) : [],
|
|
3746
4184
|
};
|
|
3747
4185
|
}
|
|
3748
4186
|
}
|
|
@@ -3756,6 +4194,183 @@ export class GitvaultVault {
|
|
|
3756
4194
|
const counters = await this.transport.declareRecipientKeyRevoked({ repo_id: this.repoId, principal_id: principalId });
|
|
3757
4195
|
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 });
|
|
3758
4196
|
}
|
|
4197
|
+
// ── writer activation (gitvault-multi-writer task 5.6, design D4/D5) ──────
|
|
4198
|
+
/**
|
|
4199
|
+
* Submit a REF-NEUTRAL `add_writer_key{authorization.kind:"handoff"}`
|
|
4200
|
+
* activation head — no ref/checkpoint/repair changes ride it (protocol
|
|
4201
|
+
* §4.17's own "ref-neutral head shape... for the activation head" rule),
|
|
4202
|
+
* built by `publishGeneration` the SAME way every ordinary no-op push
|
|
4203
|
+
* would be (`refs`/`head_target` carried forward from `base` UNCHANGED),
|
|
4204
|
+
* just with `transition` set. Retries `HEAD_CAS_CONFLICT` like every
|
|
4205
|
+
* other publish path here. The caller (`resume()`) has already built and
|
|
4206
|
+
* locally sanity-checked the grant + acceptance; this method only reads
|
|
4207
|
+
* the FRESHLY-verified `writer_set_pin` (set by the `materialize()` this
|
|
4208
|
+
* same call performs, mirroring `push()`'s own base-then-retry shape) to
|
|
4209
|
+
* compute `base_writer_set`/`next_writer_set`, signs the head under
|
|
4210
|
+
* `this.signer()` (the ADDED key itself — protocol §4.17's "head signed
|
|
4211
|
+
* by the added key" — since `signer()` reads straight from THIS
|
|
4212
|
+
* keystore's identity, and `resume()` mints/loads that identity before
|
|
4213
|
+
* ever reaching this call), and advances the local `writer_set_pin` on
|
|
4214
|
+
* success so a subsequent read reflects the new writer without a second
|
|
4215
|
+
* chain walk.
|
|
4216
|
+
*
|
|
4217
|
+
* Crash-resumable (task 5.6): the chain burns `handoff_id` single-use, so
|
|
4218
|
+
* a naive resubmission after a crash between a prior attempt's successful
|
|
4219
|
+
* admission and this checkout learning about it would be refused
|
|
4220
|
+
* VALIDATION_FAILED. Returns `{outcome:"already_admitted", generation}`
|
|
4221
|
+
* instead of submitting anything whenever the freshly-verified writer set
|
|
4222
|
+
* ALREADY contains `input.addedWriterKeyId` — the caller treats both
|
|
4223
|
+
* outcomes as success (the writer IS active either way) and proceeds.
|
|
4224
|
+
*/
|
|
4225
|
+
async submitWriterActivationHead(input) {
|
|
4226
|
+
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",
|
|
4227
|
+
// gitvault-multi-writer task 5.8: the handoff door is signed by the
|
|
4228
|
+
// ADDED key itself, which is by design NOT YET a writer — the pre-
|
|
4229
|
+
// check must be SKIPPED here, or this call could never succeed.
|
|
4230
|
+
false);
|
|
4231
|
+
}
|
|
4232
|
+
/**
|
|
4233
|
+
* gitvault-multi-writer (task 5.7) — the "writer"-door twin of {@link
|
|
4234
|
+
* submitWriterActivationHead}: an ALREADY-ACTIVE writer (this vault
|
|
4235
|
+
* session's own key) admits `input`, an eligible org member with a
|
|
4236
|
+
* published signing key but no writer standing yet, via a fresh
|
|
4237
|
+
* `add_writer_key{"writer"}` head. No grant/acceptance — the carrying
|
|
4238
|
+
* head's own signer being an active writer at the predecessor generation
|
|
4239
|
+
* IS the authorization (`validateAddWriterKeyPayload`'s `"writer"`
|
|
4240
|
+
* branch), re-verified admission-side by `checkTransitionAdmissible`.
|
|
4241
|
+
* Task 5.8 adds the SAME local pre-check every other head-signing path
|
|
4242
|
+
* gets: if THIS session's own key is not (or is no longer) an active
|
|
4243
|
+
* writer, refuse EARLY and LOCALLY (`GITVAULT_WRITER_NOT_ADMITTED` /
|
|
4244
|
+
* `GITVAULT_WRITER_REMOVED`) rather than let the head reach the gateway
|
|
4245
|
+
* only to be refused there — redundant with `reconcileWriterAdmissions`'s
|
|
4246
|
+
* own upfront gate for that caller, but this method is public and a
|
|
4247
|
+
* direct caller should get the same fast, specific refusal.
|
|
4248
|
+
*/
|
|
4249
|
+
async admitPendingWriter(input) {
|
|
4250
|
+
return this.#publishAddWriterKeyTransition(input.addedWriterKeyId, (pin) => buildWriterDoorAddWriterKeyPayload(this.repoId, pin, { writer_key_id: input.addedWriterKeyId, signing_pubkey: input.addedSigningPubkeyB64u, principal_id: input.addedPrincipalId }), "admitting a pending writer", true);
|
|
4251
|
+
}
|
|
4252
|
+
/**
|
|
4253
|
+
* Shared publish+retry+pin-update tail for BOTH doors of `add_writer_key`
|
|
4254
|
+
* ({@link submitWriterActivationHead}'s handoff door, task 5.6; {@link
|
|
4255
|
+
* admitPendingWriter}'s writer door, task 5.7). `buildPayload` constructs
|
|
4256
|
+
* a fresh {@link AddWriterKeyPayload} against whatever `writer_set_pin`
|
|
4257
|
+
* the CURRENT attempt's freshly-materialized base carries, so a
|
|
4258
|
+
* conflict-retry rebuilds against the winner's own pin, never a stale
|
|
4259
|
+
* one. Crash/race-resumable the same way for both doors: if
|
|
4260
|
+
* `addedWriterKeyId` is ALREADY in the freshly-verified writer set before
|
|
4261
|
+
* anything is built, returns `{outcome:"already_admitted"}` instead of
|
|
4262
|
+
* attempting a resubmission the chain would refuse.
|
|
4263
|
+
*
|
|
4264
|
+
* `requireCallerIsWriter` (task 5.8) gates {@link assertCallerIsWriter} —
|
|
4265
|
+
* `false` for the handoff door (the signer is deliberately not yet a
|
|
4266
|
+
* writer), `true` for the writer door (the signer must already be one).
|
|
4267
|
+
*/
|
|
4268
|
+
async #publishAddWriterKeyTransition(addedWriterKeyId, buildPayload, errorContext, requireCallerIsWriter) {
|
|
4269
|
+
let conflicts = 0;
|
|
4270
|
+
for (;;) {
|
|
4271
|
+
const base = await this.materialize();
|
|
4272
|
+
if (requireCallerIsWriter)
|
|
4273
|
+
this.assertCallerIsWriter(errorContext, conflicts > 0);
|
|
4274
|
+
const pin = this.repoFile().writer_set_pin;
|
|
4275
|
+
if (!pin) {
|
|
4276
|
+
fail("GITVAULT_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);
|
|
4277
|
+
}
|
|
4278
|
+
if (pin.writers.some((w) => w.writer_key_id === addedWriterKeyId)) {
|
|
4279
|
+
return { outcome: "already_admitted", generation: base.generation };
|
|
4280
|
+
}
|
|
4281
|
+
const payload = buildPayload(pin);
|
|
4282
|
+
const nextState = payload.next_writer_set;
|
|
4283
|
+
const payloadBytes = jcs(payload);
|
|
4284
|
+
const transition = { kind: "add_writer_key", payload_format: "base64url-jcs", payload: toBase64url(payloadBytes), payload_sha256: sha256Hex(payloadBytes) };
|
|
4285
|
+
const published = await this.publishGeneration({
|
|
4286
|
+
base, refs: base.refs, dropped: [], head_target: base.head_target,
|
|
4287
|
+
force_checkpoint: false, cutoff: null, transition,
|
|
4288
|
+
});
|
|
4289
|
+
if (published.outcome === "conflict") {
|
|
4290
|
+
conflicts += 1;
|
|
4291
|
+
if (conflicts > this.retries)
|
|
4292
|
+
fail("HEAD_CAS_CONFLICT", `the writer activation lost ${conflicts} races at generation ${published.generation}; giving up`, errorContext, { generation: published.generation, winner: published.winner });
|
|
4293
|
+
continue;
|
|
4294
|
+
}
|
|
4295
|
+
if (published.outcome === "dry_run")
|
|
4296
|
+
fail("GIT_COMMAND_FAILED", "internal: #publishAddWriterKeyTransition() received a dry-run result it never requested", errorContext);
|
|
4297
|
+
this.keystore.updateRepo(this.repoId, { writer_set_pin: { version: nextState.version, sha256: nextState.sha256, writers: [...nextState.writers], pinned_at: formatGitvaultTimestamp(this.now()) } });
|
|
4298
|
+
return {
|
|
4299
|
+
outcome: "activated",
|
|
4300
|
+
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) },
|
|
4301
|
+
};
|
|
4302
|
+
}
|
|
4303
|
+
}
|
|
4304
|
+
/**
|
|
4305
|
+
* gitvault-multi-writer (task 5.7) — the vault's OWN writer-admission
|
|
4306
|
+
* reconcile: reads `pending_writers[]` off the vault record (eligible org
|
|
4307
|
+
* members with no writer standing yet), resolves each candidate's
|
|
4308
|
+
* published signing key off the org's encryption-key directory (the SAME
|
|
4309
|
+
* `/orgs/v1/:org_id/encryption-keys` row {@link
|
|
4310
|
+
* reconcileEnvelopeRecipients} reads, widened D9 to also carry the
|
|
4311
|
+
* signing half), and admits each via {@link admitPendingWriter} — one
|
|
4312
|
+
* `add_writer_key{"writer"}` head per candidate, sequentially: each
|
|
4313
|
+
* admission materializes fresh against the PRIOR one's own updated writer
|
|
4314
|
+
* set, so there is no batched/parallel form.
|
|
4315
|
+
*
|
|
4316
|
+
* Requires THIS session's own key to already be an active writer (the
|
|
4317
|
+
* chain's own authorization rule for the "writer" door) — checked ONCE,
|
|
4318
|
+
* upfront, off the locally-pinned `writer_set_pin` (no network call),
|
|
4319
|
+
* rather than discovered N times over from N identical gateway refusals:
|
|
4320
|
+
* a session that reached this call already pushed successfully as an
|
|
4321
|
+
* active writer in every wired call site (push/snapshot/deploy) OR is a
|
|
4322
|
+
* member that simply is not one yet (session-start/read) — the SAME
|
|
4323
|
+
* "not yet admitted" case {@link Gitvault.push}'s own pre-push check
|
|
4324
|
+
* (task 5.8) names, not a new refusal shape. Returns `{eligible: false}`
|
|
4325
|
+
* with every other field empty (never throws) for that case: every OTHER
|
|
4326
|
+
* `pending_writers[]` entry would fail identically, so there is nothing
|
|
4327
|
+
* this call can usefully do, and "I am not a writer" is not this vault's
|
|
4328
|
+
* fault — but it is distinguishable from "there was nothing pending"
|
|
4329
|
+
* (`eligible: true`, still all empty), which matters to a caller trying
|
|
4330
|
+
* to explain an all-empty result to a human.
|
|
4331
|
+
*/
|
|
4332
|
+
async reconcileWriterAdmissions() {
|
|
4333
|
+
const repo = this.repoFile();
|
|
4334
|
+
const admitted = [];
|
|
4335
|
+
const alreadyCovered = [];
|
|
4336
|
+
const skipped = [];
|
|
4337
|
+
const assembleResult = (eligible) => ({ repo_id: this.repoId, org_id: repo.org_id, eligible, admitted, already_covered: alreadyCovered, skipped });
|
|
4338
|
+
const myWriterKeyId = this.writerKeyId();
|
|
4339
|
+
const startingPin = repo.writer_set_pin;
|
|
4340
|
+
if (!startingPin || !startingPin.writers.some((w) => w.writer_key_id === myWriterKeyId))
|
|
4341
|
+
return assembleResult(false);
|
|
4342
|
+
const record = await this.transport.getVaultRecord({ repo_id: this.repoId });
|
|
4343
|
+
const pending = record.pending_writers ?? [];
|
|
4344
|
+
if (pending.length === 0)
|
|
4345
|
+
return assembleResult(true);
|
|
4346
|
+
const directory = await this.transport.listOrgEncryptionKeys({ org_id: repo.org_id });
|
|
4347
|
+
const byPrincipal = new Map(directory.keys.map((k) => [k.principal_id, k]));
|
|
4348
|
+
for (const candidate of pending) {
|
|
4349
|
+
const currentPin = this.repoFile().writer_set_pin;
|
|
4350
|
+
if (currentPin?.writers.some((w) => w.writer_key_id === candidate.writer_key_id)) {
|
|
4351
|
+
alreadyCovered.push(candidate.writer_key_id);
|
|
4352
|
+
continue;
|
|
4353
|
+
}
|
|
4354
|
+
const entry = byPrincipal.get(candidate.principal_id);
|
|
4355
|
+
const signingPubkey = entry?.signing_pubkey;
|
|
4356
|
+
if (typeof signingPubkey !== "string" || signingPubkey.length === 0) {
|
|
4357
|
+
skipped.push({ principal_id: candidate.principal_id, writer_key_id: candidate.writer_key_id, reason: "missing_signing_pubkey" });
|
|
4358
|
+
continue;
|
|
4359
|
+
}
|
|
4360
|
+
if (writerKeyIdOf(signingPubkey) !== candidate.writer_key_id) {
|
|
4361
|
+
skipped.push({ principal_id: candidate.principal_id, writer_key_id: candidate.writer_key_id, reason: "invalid_signing_pubkey" });
|
|
4362
|
+
continue;
|
|
4363
|
+
}
|
|
4364
|
+
const outcome = await this.admitPendingWriter({ addedWriterKeyId: candidate.writer_key_id, addedSigningPubkeyB64u: signingPubkey, addedPrincipalId: candidate.principal_id });
|
|
4365
|
+
if (outcome.outcome === "already_admitted") {
|
|
4366
|
+
alreadyCovered.push(candidate.writer_key_id);
|
|
4367
|
+
}
|
|
4368
|
+
else {
|
|
4369
|
+
admitted.push({ principal_id: candidate.principal_id, writer_key_id: candidate.writer_key_id, generation: outcome.result.generation });
|
|
4370
|
+
}
|
|
4371
|
+
}
|
|
4372
|
+
return assembleResult(true);
|
|
4373
|
+
}
|
|
3759
4374
|
// ── repair (owner-only; mandatory fresh checkpoint; repair resource lane server-side) ──
|
|
3760
4375
|
/**
|
|
3761
4376
|
* Publish a repair head over `base_generation` (§4.3): superseded tips that
|
|
@@ -3766,6 +4381,14 @@ export class GitvaultVault {
|
|
|
3766
4381
|
*/
|
|
3767
4382
|
async repair(input) {
|
|
3768
4383
|
const newest = await this.verifyToNewest();
|
|
4384
|
+
// gitvault-multi-writer task 5.8 — repair requires OWNER+step-up
|
|
4385
|
+
// server-side (`authority.repair`), but that is layered ON TOP of the
|
|
4386
|
+
// baseline writer requirement every head needs (`authority.writer`,
|
|
4387
|
+
// confirmed against the gateway's own admission fixture: an owner who
|
|
4388
|
+
// is not also a chain-recognized writer is refused just the same). No
|
|
4389
|
+
// retry loop here (a losing repair is a hard failure below, not a
|
|
4390
|
+
// rebuild-and-retry), so there is no CAS-loser variant to distinguish.
|
|
4391
|
+
this.assertCallerIsWriter("preparing repair");
|
|
3769
4392
|
const baseGen = generationToBigInt(input.base_generation);
|
|
3770
4393
|
const newestGen = generationToBigInt(newest.generation);
|
|
3771
4394
|
if (baseGen >= newestGen)
|