@run402/sdk 4.39.1 → 4.41.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 +4 -4
- package/dist/errors.d.ts +5 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/namespaces/gitvault.crypto.d.ts +10 -0
- package/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
- package/dist/namespaces/gitvault.crypto.js +10 -0
- package/dist/namespaces/gitvault.crypto.js.map +1 -1
- package/dist/namespaces/gitvault.d.ts +285 -5
- package/dist/namespaces/gitvault.d.ts.map +1 -1
- package/dist/namespaces/gitvault.js +369 -8
- package/dist/namespaces/gitvault.js.map +1 -1
- package/dist/node/gitvault-apply.d.ts +1 -1
- package/dist/node/gitvault-apply.d.ts.map +1 -1
- package/dist/node/gitvault-apply.js +6 -6
- package/dist/node/gitvault-apply.js.map +1 -1
- package/dist/node/gitvault-creation-journal.js +1 -1
- package/dist/node/gitvault-creation-journal.js.map +1 -1
- package/dist/node/gitvault-deploy.js +2 -2
- package/dist/node/gitvault-deploy.js.map +1 -1
- package/dist/node/gitvault-keystore.d.ts +11 -1
- package/dist/node/gitvault-keystore.d.ts.map +1 -1
- package/dist/node/gitvault-keystore.js.map +1 -1
- package/dist/node/gitvault-mirror-backend.d.ts +88 -0
- package/dist/node/gitvault-mirror-backend.d.ts.map +1 -0
- package/dist/node/gitvault-mirror-backend.js +374 -0
- package/dist/node/gitvault-mirror-backend.js.map +1 -0
- package/dist/node/gitvault-mirror-config.d.ts +53 -0
- package/dist/node/gitvault-mirror-config.d.ts.map +1 -0
- package/dist/node/gitvault-mirror-config.js +112 -0
- package/dist/node/gitvault-mirror-config.js.map +1 -0
- package/dist/node/gitvault-mirror.d.ts +120 -0
- package/dist/node/gitvault-mirror.d.ts.map +1 -0
- package/dist/node/gitvault-mirror.js +464 -0
- package/dist/node/gitvault-mirror.js.map +1 -0
- package/dist/node/gitvault-publication.d.ts +153 -3
- package/dist/node/gitvault-publication.d.ts.map +1 -1
- package/dist/node/gitvault-publication.js +182 -13
- package/dist/node/gitvault-publication.js.map +1 -1
- package/dist/node/gitvault-recover.d.ts +136 -0
- package/dist/node/gitvault-recover.d.ts.map +1 -0
- package/dist/node/gitvault-recover.js +412 -0
- package/dist/node/gitvault-recover.js.map +1 -0
- package/dist/node/gitvault-snapshot.d.ts +8 -0
- package/dist/node/gitvault-snapshot.d.ts.map +1 -1
- package/dist/node/gitvault-snapshot.js +11 -0
- package/dist/node/gitvault-snapshot.js.map +1 -1
- package/package.json +1 -1
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* never persisted into an agent-surface result store, and never logged.
|
|
30
30
|
*/
|
|
31
31
|
import { LocalError } from "../errors.js";
|
|
32
|
-
import { GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT, GITVAULT_TERMINAL_LOSS_STATEMENT } from "./gitvault.crypto.js";
|
|
32
|
+
import { GITVAULT_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT, GITVAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT, GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT, GITVAULT_TERMINAL_LOSS_STATEMENT, } from "./gitvault.crypto.js";
|
|
33
33
|
/** A keystore path, or `null` when there is no id to derive it from (or it is malformed). */
|
|
34
34
|
function safePath(derive, repoId) {
|
|
35
35
|
if (!repoId)
|
|
@@ -49,6 +49,34 @@ async function nodeOnly(load, verb) {
|
|
|
49
49
|
throw new LocalError(`\`r.gitvault.${verb}\` needs the Node runtime (keystore + git); import it from a Node process, or use the read-only vault methods in a browser.`, `running gitvault ${verb}`, { code: "GITVAULT_NODE_ONLY", details: { cause: e instanceof Error ? e.message : String(e) } });
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* {@link Gitvault.deploy}'s post-push glue: attach the best-effort
|
|
54
|
+
* mirror/reconcile hooks to a deploy result, gated on whether this deploy
|
|
55
|
+
* actually landed a new generation in the vault. Extracted as a standalone
|
|
56
|
+
* function (rather than inlined in `deploy()`) purely so the gating can be
|
|
57
|
+
* unit-tested with fake thunks — the real hooks (`#tryMirrorPush` /
|
|
58
|
+
* `#tryReconcileEnvelopeRecipients`) are private class methods that already
|
|
59
|
+
* catch everything and never reject, so this function does not need its own
|
|
60
|
+
* try/catch: it only decides WHETHER to call them and how to merge what they
|
|
61
|
+
* resolve to.
|
|
62
|
+
*
|
|
63
|
+
* `DEPLOYED_AND_VAULTED` and `DEPLOY_FAILED_VAULTED` are the only two
|
|
64
|
+
* outcomes that carry a `generation` — a vault push actually landed. The
|
|
65
|
+
* other three (`DEPLOY_BLOCKED_PUSH_FAILED`, `DEPLOY_FAILED_UNVAULTED`,
|
|
66
|
+
* `DEPLOYED_UNVAULTED_OVERRIDE`) published nothing new, so `mirror_push` /
|
|
67
|
+
* `reconcile_recipients` are OMITTED rather than a faked `skipped_*` value —
|
|
68
|
+
* there is nothing this deploy did that either hook could report on.
|
|
69
|
+
*/
|
|
70
|
+
export async function attachGitvaultDeployHooks(result, mirror, reconcile) {
|
|
71
|
+
if (result.outcome !== "DEPLOYED_AND_VAULTED" && result.outcome !== "DEPLOY_FAILED_VAULTED")
|
|
72
|
+
return result;
|
|
73
|
+
// Sequential, not Promise.all — same ordering push() uses, so a reconcile
|
|
74
|
+
// failure can never be misread as a mirror failure in a log line that
|
|
75
|
+
// assumed ordering.
|
|
76
|
+
const mirrorPush = await mirror();
|
|
77
|
+
const reconcileRecipients = await reconcile();
|
|
78
|
+
return { ...result, mirror_push: mirrorPush, reconcile_recipients: reconcileRecipients };
|
|
79
|
+
}
|
|
52
80
|
// ─── The namespace ───────────────────────────────────────────────────────────
|
|
53
81
|
export class Gitvault {
|
|
54
82
|
#client;
|
|
@@ -68,6 +96,31 @@ export class Gitvault {
|
|
|
68
96
|
async forProject(projectId) {
|
|
69
97
|
return this.#client.request(`/gitvault/v1/vaults?project_id=${encodeURIComponent(projectId)}`, { context: "resolving the project's gitvault" });
|
|
70
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Every vault the organization owns, one round trip — `repos list`'s bulk
|
|
101
|
+
* read (repo-surface-consolidation task 2.4). See {@link
|
|
102
|
+
* GitvaultOrgVaultsListing}'s doc comment for the FROZEN response shape and
|
|
103
|
+
* the 404-until-shipped fallback contract.
|
|
104
|
+
*/
|
|
105
|
+
async listByOrg(orgId) {
|
|
106
|
+
// The gateway keyset-paginates (has_more/next_cursor, opaque store-and-
|
|
107
|
+
// echo). This method's contract is "the org's vaults", so it follows the
|
|
108
|
+
// cursor and aggregates — a silently-truncated page one would be the
|
|
109
|
+
// Faithful breach agent-response-design.md names. The page bound is a
|
|
110
|
+
// runaway guard, far above any real org; hitting it surfaces has_more:
|
|
111
|
+
// true honestly instead of looping forever on a misbehaving cursor.
|
|
112
|
+
const all = [];
|
|
113
|
+
let cursor;
|
|
114
|
+
for (let page = 0; page < 100; page++) {
|
|
115
|
+
const url = `/gitvault/v1/vaults?org_id=${encodeURIComponent(orgId)}` + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : "");
|
|
116
|
+
const res = await this.#client.request(url, { context: "listing the organization's vaults" });
|
|
117
|
+
all.push(...res.vaults);
|
|
118
|
+
if (!res.has_more || !res.next_cursor)
|
|
119
|
+
return { vaults: all, has_more: false, next_cursor: null };
|
|
120
|
+
cursor = res.next_cursor;
|
|
121
|
+
}
|
|
122
|
+
return { vaults: all, has_more: true, next_cursor: cursor ?? null };
|
|
123
|
+
}
|
|
71
124
|
/**
|
|
72
125
|
* Resolve a vault by its address-form `org-slug/name` (repo-first-onramp
|
|
73
126
|
* task 4.3, design D6) — `GET /gitvault/v1/vaults?repo=<org-slug>/<name>`.
|
|
@@ -592,11 +645,11 @@ export class Gitvault {
|
|
|
592
645
|
// nothing; pointing at it here sent users to a command that silently did
|
|
593
646
|
// not do what this line promised (dogfood #1, finding A).
|
|
594
647
|
if (!record)
|
|
595
|
-
nextActions.push({ action: "allocate the project's vault", command: "run402
|
|
648
|
+
nextActions.push({ action: "allocate the project's vault", command: "run402 repos create --project <id>" });
|
|
596
649
|
else if (pending.length > 0)
|
|
597
|
-
nextActions.push({ action: `complete ${pending.length} unvaulted-override journal(s)`, command: "run402
|
|
650
|
+
nextActions.push({ action: `complete ${pending.length} unvaulted-override journal(s)`, command: "run402 repos snapshot" });
|
|
598
651
|
else if (record && !holdsRepoKey)
|
|
599
|
-
nextActions.push({ action: "this machine holds no key for the vault — allocate resolves to the existing vault and is idempotent", command: "run402
|
|
652
|
+
nextActions.push({ action: "this machine holds no key for the vault — allocate resolves to the existing vault and is idempotent", command: "run402 repos create --project <id>" });
|
|
600
653
|
return {
|
|
601
654
|
repo_id: repoId,
|
|
602
655
|
project_id: options.project_id ?? record?.project_id ?? null,
|
|
@@ -685,7 +738,38 @@ export class Gitvault {
|
|
|
685
738
|
...(options.checkpoint ? { checkpoint: true } : {}),
|
|
686
739
|
};
|
|
687
740
|
const result = await handle.vault.push(push);
|
|
688
|
-
|
|
741
|
+
// Capture-time dual-push hook (design D6/task 2.4): fires only when a
|
|
742
|
+
// mirror is configured; NEVER throws, NEVER alters the vault outcome
|
|
743
|
+
// above (already returned/committed) — a mirror failure is a named
|
|
744
|
+
// pending finding reported BESIDE the vault result, on its own field.
|
|
745
|
+
const mirrorPush = await this.#tryMirrorPush(handle.repo_id, handle.keystore);
|
|
746
|
+
// Deploy-time reconcile hook (design D5's "deploy time" cadence,
|
|
747
|
+
// gitvault-human-envelopes task 4.1): fires on every successful push,
|
|
748
|
+
// best-effort — a reconcile failure (including a read-only principal
|
|
749
|
+
// with no signing key) is reported BESIDE the vault result, never a
|
|
750
|
+
// `push()` throw, same non-blocking contract as the mirror hook above.
|
|
751
|
+
const reconcileRecipients = await this.#tryReconcileEnvelopeRecipients(handle.vault);
|
|
752
|
+
return { ...result, snapshot, gitvault_commit: snapshot.oid, gitvault_commit_line: line, mirror_push: mirrorPush, reconcile_recipients: reconcileRecipients };
|
|
753
|
+
}
|
|
754
|
+
/** Best-effort dual-push: catches EVERYTHING, including the lazy module import itself, so a mirror problem can never surface as a `push()` throw. */
|
|
755
|
+
async #tryMirrorPush(repoId, keystore) {
|
|
756
|
+
try {
|
|
757
|
+
const { mirrorPushForGeneration } = await this.#mirror();
|
|
758
|
+
return await mirrorPushForGeneration(this.#client, repoId, { keystore });
|
|
759
|
+
}
|
|
760
|
+
catch (e) {
|
|
761
|
+
return { attempted: false, outcome: "skipped_no_mirror", error: e instanceof Error ? e.message : String(e) };
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
/** Best-effort envelope-recipient reconcile: catches EVERYTHING so a reconcile problem can never surface as a `push()` throw (mirrors {@link #tryMirrorPush}'s contract exactly). */
|
|
765
|
+
async #tryReconcileEnvelopeRecipients(vault) {
|
|
766
|
+
try {
|
|
767
|
+
const result = await vault.reconcileEnvelopeRecipients();
|
|
768
|
+
return { attempted: true, outcome: "reconciled", result };
|
|
769
|
+
}
|
|
770
|
+
catch (e) {
|
|
771
|
+
return { attempted: false, outcome: "skipped_error", error: e instanceof Error ? e.message : String(e) };
|
|
772
|
+
}
|
|
689
773
|
}
|
|
690
774
|
/**
|
|
691
775
|
* A REAL preview of what {@link push} would publish (kychee-com/run402#565)
|
|
@@ -893,7 +977,7 @@ export class Gitvault {
|
|
|
893
977
|
submitted: false,
|
|
894
978
|
intent: null,
|
|
895
979
|
confirmation: null,
|
|
896
|
-
note: "run `run402
|
|
980
|
+
note: "run `run402 repos gc` to publish a checkpoint bound to a fresh retention_cutoff ticket, then plan the prune again.",
|
|
897
981
|
};
|
|
898
982
|
}
|
|
899
983
|
const record = await handle.vault.transport.getVaultRecord({ repo_id: handle.repo_id });
|
|
@@ -1016,7 +1100,142 @@ export class Gitvault {
|
|
|
1016
1100
|
*/
|
|
1017
1101
|
async verify(options = {}) {
|
|
1018
1102
|
const handle = await this.open(options);
|
|
1019
|
-
return handle.vault.verifyToNewest();
|
|
1103
|
+
return handle.vault.verifyToNewest({ persist: options.persist ?? true });
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* `repos fsck` (repo-surface-consolidation D2/D3): walk the head chain
|
|
1107
|
+
* (what `verify()` did) AND materialize the ref map (what `status({refs:
|
|
1108
|
+
* true})` used to do before `--refs` was removed from `view`), reporting
|
|
1109
|
+
* BOTH local trust pins — authenticated and materialized — before and
|
|
1110
|
+
* after, with an explicit `local_state_changed` flag. This is the one
|
|
1111
|
+
* place chain materialization and pin advance live now; `view` never
|
|
1112
|
+
* calls it.
|
|
1113
|
+
*
|
|
1114
|
+
* `options.write` (default `true`) is the inverse of the CLI's
|
|
1115
|
+
* `--no-write`: `false` still walks and decrypts everything (the returned
|
|
1116
|
+
* `refs`/`verified_to_generation` are real, computed answers, not
|
|
1117
|
+
* estimates) but persists neither pin — a genuine audit mode, not a
|
|
1118
|
+
* simulation. `options.mirror` additionally runs the keyless mirror
|
|
1119
|
+
* verification ({@link mirrorVerify}) and folds its report in; that half
|
|
1120
|
+
* proves the mirror's validity, never its freshness (its own honesty
|
|
1121
|
+
* statements ride the result unchanged).
|
|
1122
|
+
*/
|
|
1123
|
+
async fsck(options = {}) {
|
|
1124
|
+
const before = await this.status(options);
|
|
1125
|
+
const repoId = before.repo_id;
|
|
1126
|
+
if (!repoId) {
|
|
1127
|
+
throw new LocalError(options.repo_id || options.project_id
|
|
1128
|
+
? "no vault is allocated for this project yet — nothing to fsck"
|
|
1129
|
+
: "pass repo_id, or project_id to resolve it from the control plane", "running gitvault fsck", { code: "GITVAULT_VAULT_UNRESOLVED", details: { project_id: options.project_id ?? null } });
|
|
1130
|
+
}
|
|
1131
|
+
const write = options.write ?? true;
|
|
1132
|
+
const handle = await this.open({ ...options, repo_id: repoId });
|
|
1133
|
+
const state = await handle.vault.materialize({ persist: write });
|
|
1134
|
+
const mirror = options.mirror ? await this.mirrorVerify({ ...options, repo_id: repoId }) : null;
|
|
1135
|
+
const pinBefore = { highest_authenticated: before.pins.highest_authenticated, highest_materialized: before.pins.highest_materialized };
|
|
1136
|
+
const pinAfter = write
|
|
1137
|
+
? { highest_authenticated: state.generation, highest_materialized: state.generation }
|
|
1138
|
+
: pinBefore;
|
|
1139
|
+
const localStateChanged = write && (pinBefore.highest_authenticated !== pinAfter.highest_authenticated || pinBefore.highest_materialized !== pinAfter.highest_materialized);
|
|
1140
|
+
return {
|
|
1141
|
+
repo_id: repoId,
|
|
1142
|
+
write,
|
|
1143
|
+
verified_from_generation: pinBefore.highest_authenticated,
|
|
1144
|
+
verified_to_generation: state.generation,
|
|
1145
|
+
local_state_changed: localStateChanged,
|
|
1146
|
+
pin_before: pinBefore,
|
|
1147
|
+
pin_after: pinAfter,
|
|
1148
|
+
refs: { ...state.refs },
|
|
1149
|
+
head_target: state.head_target,
|
|
1150
|
+
mirror,
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Wrap this vault's current epoch key to every org member who has
|
|
1155
|
+
* published an encryption key but has no `key_envelope` on this vault yet
|
|
1156
|
+
* — gitvault-human-envelopes task 4.1's ADD-path workaround. See {@link
|
|
1157
|
+
* import("../node/gitvault-publication.js").GitvaultVault.
|
|
1158
|
+
* reconcileEnvelopeRecipients} for the full design (TOFU pinning, the
|
|
1159
|
+
* gateway `public_key` gap, and why this is a workaround rather than the
|
|
1160
|
+
* eventual epoch-rotation design).
|
|
1161
|
+
*
|
|
1162
|
+
* `run402 gitvault reconcile` was this method's explicit standalone CLI
|
|
1163
|
+
* surface (design D5's "session start" hook); repo-surface-consolidation
|
|
1164
|
+
* D5/D7/D10 REMOVED it (no `repos` equivalent — `reconcile` is a
|
|
1165
|
+
* workaround, not a permanent verb) and it now answers `COMMAND_REMOVED`
|
|
1166
|
+
* pointing at `repos access` for inspection. This method itself is
|
|
1167
|
+
* unchanged and un-retired: `deploy()` still runs it, best-effort,
|
|
1168
|
+
* whenever a deploy lands a new generation in the vault — design D5's
|
|
1169
|
+
* "deploy time" hook, "the same 'one command every agent runs' argument
|
|
1170
|
+
* that decided deploy-implies-capture." `push()` (capture-and-publish
|
|
1171
|
+
* outside a deploy) runs the identical hook after every successful
|
|
1172
|
+
* publish, for the vault-only-project cadence. See
|
|
1173
|
+
* `#tryReconcileEnvelopeRecipients` below for both call sites, and
|
|
1174
|
+
* {@link Gitvault.access} for the READ half repo-surface-consolidation
|
|
1175
|
+
* ships in its place.
|
|
1176
|
+
*/
|
|
1177
|
+
async reconcileEnvelopeRecipients(options = {}) {
|
|
1178
|
+
const handle = await this.open(options);
|
|
1179
|
+
return handle.vault.reconcileEnvelopeRecipients();
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* `repos access` (repo-surface-consolidation D5/D10) — a READ-ONLY report
|
|
1183
|
+
* of who can open this vault, composed from whatever the live gateway
|
|
1184
|
+
* surface exposes today. Never wraps, never mutates a `key_envelope` —
|
|
1185
|
+
* that mutating half stays {@link reconcileEnvelopeRecipients}, reachable
|
|
1186
|
+
* only through the deploy/push best-effort hooks until `access repair`
|
|
1187
|
+
* ships (gated on `gitvault-human-envelopes`' epoch-rotation work). See
|
|
1188
|
+
* {@link GitvaultAccessResult}'s doc comment for the honest gap this
|
|
1189
|
+
* reports rather than invents.
|
|
1190
|
+
*/
|
|
1191
|
+
async access(options = {}) {
|
|
1192
|
+
const repoId = await this.#resolveRepoId(options);
|
|
1193
|
+
const record = await this.get(repoId).catch(() => null);
|
|
1194
|
+
const orgId = record?.org_id ?? null;
|
|
1195
|
+
const [directory, coverage] = await Promise.all([
|
|
1196
|
+
orgId
|
|
1197
|
+
? this.#client.request(`/orgs/v1/${encodeURIComponent(orgId)}/encryption-keys`, { context: "reading the org encryption-key directory" })
|
|
1198
|
+
: Promise.resolve({ org_id: "", keys: [] }),
|
|
1199
|
+
this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}/envelope-recipients`, { context: "reading the gitvault envelope recipients" }),
|
|
1200
|
+
]);
|
|
1201
|
+
const covered = new Set(coverage.recipient_fingerprints);
|
|
1202
|
+
// Node-only, best-effort: THIS machine's own local TOFU pins. Never
|
|
1203
|
+
// fails the whole read — a browser/worker caller, or a machine that has
|
|
1204
|
+
// never wrapped anyone, simply reports every `tofu_pin` as `null`.
|
|
1205
|
+
let pins = {};
|
|
1206
|
+
try {
|
|
1207
|
+
const { GitvaultKeystore } = await this.#keystore();
|
|
1208
|
+
const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
|
|
1209
|
+
pins = keystore.readRepo(repoId)?.envelope_recipient_pins ?? {};
|
|
1210
|
+
}
|
|
1211
|
+
catch {
|
|
1212
|
+
// Not Node, or no local keystore for this repo — pins stay empty.
|
|
1213
|
+
}
|
|
1214
|
+
const recipients = directory.keys.map((k) => {
|
|
1215
|
+
const pinned = pins[k.principal_id];
|
|
1216
|
+
return {
|
|
1217
|
+
principal_id: k.principal_id,
|
|
1218
|
+
display_name: k.display_name,
|
|
1219
|
+
fingerprint: k.ek_fingerprint,
|
|
1220
|
+
covered: covered.has(k.ek_fingerprint),
|
|
1221
|
+
tofu_pin: pinned !== undefined ? { fingerprint: pinned, matches_directory: pinned === k.ek_fingerprint } : null,
|
|
1222
|
+
};
|
|
1223
|
+
});
|
|
1224
|
+
const directoryFingerprints = new Set(directory.keys.map((k) => k.ek_fingerprint));
|
|
1225
|
+
const unmatched = coverage.recipient_fingerprints.filter((fp) => !directoryFingerprints.has(fp));
|
|
1226
|
+
return {
|
|
1227
|
+
repo_id: repoId,
|
|
1228
|
+
org_id: orgId,
|
|
1229
|
+
recipients,
|
|
1230
|
+
unmatched_covered_fingerprints: unmatched,
|
|
1231
|
+
envelope_state_available: false,
|
|
1232
|
+
history_scope_available: false,
|
|
1233
|
+
gap: "the gateway does not yet expose per-recipient envelope_state (converged/pending) or history_scope — " +
|
|
1234
|
+
"that is server-authoritative desired-recipient-state work owned by gitvault-human-envelopes and has not shipped. " +
|
|
1235
|
+
"This reports what the read surface has today: the org's directory of encryption-key-holding members, which of " +
|
|
1236
|
+
"the vault's current envelope-recipient fingerprints match a directory entry (covered), and this machine's own " +
|
|
1237
|
+
"local TOFU pin per principal when it has ever wrapped one (tofu_pin) — never a server-side fact.",
|
|
1238
|
+
};
|
|
1020
1239
|
}
|
|
1021
1240
|
/**
|
|
1022
1241
|
* The push-gated deploy: both lanes under one fresh `capture_id`, resolving
|
|
@@ -1027,11 +1246,33 @@ export class Gitvault {
|
|
|
1027
1246
|
* The push is never gated on deploy success, and a build that fails before a
|
|
1028
1247
|
* canonical apply plan exists still captures (with a null plan digest, so no
|
|
1029
1248
|
* activation token can be minted from it).
|
|
1249
|
+
*
|
|
1250
|
+
* `run402 deploy` is design D5's PRIMARY envelope-recipient reconcile hook
|
|
1251
|
+
* ("the same 'one command every agent runs' argument that decided
|
|
1252
|
+
* deploy-implies-capture") and design D6's primary dual-push mirror trigger
|
|
1253
|
+
* — both fire HERE, not inside {@link runGitvaultDeploy} itself, mirroring
|
|
1254
|
+
* {@link push}'s exact non-blocking contract: best-effort, NEVER throw,
|
|
1255
|
+
* NEVER alter the deploy outcome already resolved above, reported BESIDE it
|
|
1256
|
+
* on `mirror_push` / `reconcile_recipients`. They fire only when this
|
|
1257
|
+
* deploy actually landed a new generation in the vault
|
|
1258
|
+
* (`DEPLOYED_AND_VAULTED` / `DEPLOY_FAILED_VAULTED` — the outcomes carrying
|
|
1259
|
+
* a `generation`); the other three outcomes published nothing new, so
|
|
1260
|
+
* there is nothing to mirror or reconcile against ({@link
|
|
1261
|
+
* import("../node/gitvault-mirror.js").mirrorPushForGeneration}'s own
|
|
1262
|
+
* contract: "fires after an ordinary vault push/deploy produces a new
|
|
1263
|
+
* generation"). Both fields are therefore OMITTED — never a faked
|
|
1264
|
+
* `skipped_*` outcome — when no generation landed.
|
|
1030
1265
|
*/
|
|
1031
1266
|
async deploy(options) {
|
|
1032
1267
|
const { runGitvaultDeploy } = await this.#deploy();
|
|
1033
1268
|
const handle = await this.open(options);
|
|
1034
|
-
|
|
1269
|
+
const result = await runGitvaultDeploy({ ...options, vault: handle.vault, repo_dir: options.repo_dir ?? process.cwd() });
|
|
1270
|
+
// Same ordering push() uses (mirror, then reconcile) — sequential, not
|
|
1271
|
+
// Promise.all, so a reconcile failure can never be misread as a mirror
|
|
1272
|
+
// failure in a log line that assumed ordering. Extracted to a standalone
|
|
1273
|
+
// function so the outcome-gating is unit-testable with fake thunks,
|
|
1274
|
+
// without standing up a live vault — see gitvault-deploy-hooks.test.ts.
|
|
1275
|
+
return attachGitvaultDeployHooks(result, () => this.#tryMirrorPush(handle.repo_id, handle.keystore), () => this.#tryReconcileEnvelopeRecipients(handle.vault));
|
|
1035
1276
|
}
|
|
1036
1277
|
/**
|
|
1037
1278
|
* Drain every unvaulted-override journal on this machine: push the exact
|
|
@@ -1053,6 +1294,114 @@ export class Gitvault {
|
|
|
1053
1294
|
const out = await handle.vault.restoreObjectsInto(options.target_dir);
|
|
1054
1295
|
return { refs: out.refs, generation: out.generation };
|
|
1055
1296
|
}
|
|
1297
|
+
// ── mirror (gitvault-mirror-and-recover, design D1/D2/D7) ─────────────────
|
|
1298
|
+
/**
|
|
1299
|
+
* Configure (or replace) the customer-owned mirror destination for one
|
|
1300
|
+
* vault. Client-side ONLY — the destination + credential NAME are written
|
|
1301
|
+
* beside the keystore (design D2); run402 never sees or stores a raw
|
|
1302
|
+
* secret value. `s3://<bucket>/<prefix>` needs `credential`; a plain
|
|
1303
|
+
* filesystem path needs none.
|
|
1304
|
+
*/
|
|
1305
|
+
async mirrorSet(options) {
|
|
1306
|
+
const [{ GitvaultKeystore }, { parseMirrorDestinationUrl, saveMirrorConfig }] = await Promise.all([this.#keystore(), this.#mirrorConfig()]);
|
|
1307
|
+
const repoId = await this.#resolveRepoId(options);
|
|
1308
|
+
const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
|
|
1309
|
+
const destination = parseMirrorDestinationUrl(options.destination_url, { region: options.region, endpoint: options.endpoint });
|
|
1310
|
+
return saveMirrorConfig(keystore, { repo_id: repoId, destination, ...(options.credential ? { credential: options.credential } : {}) });
|
|
1311
|
+
}
|
|
1312
|
+
/** Remove the mirror config for one vault. Never touches the mirror's own bytes (design: config removal ≠ data deletion). */
|
|
1313
|
+
async mirrorRemove(options = {}) {
|
|
1314
|
+
const [{ GitvaultKeystore }, { removeMirrorConfig }] = await Promise.all([this.#keystore(), this.#mirrorConfig()]);
|
|
1315
|
+
const repoId = await this.#resolveRepoId(options);
|
|
1316
|
+
const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
|
|
1317
|
+
return { repo_id: repoId, ...removeMirrorConfig(keystore, repoId) };
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* What this machine and the mirror each believe: whether a mirror is
|
|
1321
|
+
* configured, the newest generation the MIRROR holds and can chain-verify
|
|
1322
|
+
* (keyless — never touches keys), the LIVE vault's newest generation (one
|
|
1323
|
+
* read of the vault record), and — when they disagree — the closing
|
|
1324
|
+
* command. Both honesty statements (design D8) ride every response.
|
|
1325
|
+
*/
|
|
1326
|
+
async mirrorStatus(options = {}) {
|
|
1327
|
+
const [{ GitvaultKeystore }, { readMirrorConfig, formatMirrorDestination }, { openGitvaultMirrorBackend }, { verifyGitvaultMirror }] = await Promise.all([
|
|
1328
|
+
this.#keystore(), this.#mirrorConfig(), this.#mirrorBackend(), this.#recovery(),
|
|
1329
|
+
]);
|
|
1330
|
+
const repoId = await this.#resolveRepoId(options);
|
|
1331
|
+
const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
|
|
1332
|
+
const config = readMirrorConfig(keystore, repoId);
|
|
1333
|
+
const base = { repo_id: repoId, validity_not_freshness: GITVAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT, keystore_still_required: GITVAULT_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT };
|
|
1334
|
+
if (!config) {
|
|
1335
|
+
return { ...base, configured: false, destination: null, credential_kind: null, mirrored_generation: null, newest_generation: null, is_current: null, closing_command: null };
|
|
1336
|
+
}
|
|
1337
|
+
let mirroredGeneration = null;
|
|
1338
|
+
try {
|
|
1339
|
+
const backend = openGitvaultMirrorBackend(config.destination, repoId, config.credential);
|
|
1340
|
+
const report = await verifyGitvaultMirror(backend, { keystore });
|
|
1341
|
+
mirroredGeneration = report.recovered_generation;
|
|
1342
|
+
}
|
|
1343
|
+
catch {
|
|
1344
|
+
// A configured-but-unreachable/empty mirror is still `configured: true` — the honest report is "unknown", not a thrown status call.
|
|
1345
|
+
}
|
|
1346
|
+
let newestGeneration = null;
|
|
1347
|
+
try {
|
|
1348
|
+
newestGeneration = (await this.get(repoId)).newest_generation;
|
|
1349
|
+
}
|
|
1350
|
+
catch {
|
|
1351
|
+
/* the live vault read is best-effort here; the mirror half of status still answers */
|
|
1352
|
+
}
|
|
1353
|
+
const isCurrent = mirroredGeneration !== null && newestGeneration !== null ? mirroredGeneration === newestGeneration : null;
|
|
1354
|
+
return {
|
|
1355
|
+
...base, configured: true, destination: formatMirrorDestination(config.destination), credential_kind: config.credential?.kind ?? null,
|
|
1356
|
+
mirrored_generation: mirroredGeneration, newest_generation: newestGeneration, is_current: isCurrent,
|
|
1357
|
+
closing_command: isCurrent === false ? "run402 repos mirror --backfill" : null,
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
/** List the vault's stored objects, diff against the mirror, fetch + hash-verify + write what's missing, in admission order. Resumable and idempotent. */
|
|
1361
|
+
async mirrorSync(options = {}) {
|
|
1362
|
+
const [{ GitvaultKeystore }, { mirrorSync }] = await Promise.all([this.#keystore(), this.#mirror()]);
|
|
1363
|
+
const repoId = await this.#resolveRepoId(options);
|
|
1364
|
+
const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
|
|
1365
|
+
return mirrorSync(this.#client, repoId, { keystore });
|
|
1366
|
+
}
|
|
1367
|
+
/** Keyless integrity probe against the CONFIGURED mirror: discovery + chain verification + closure/absence adjudication, never decryption (`run402 repos fsck --mirror`). */
|
|
1368
|
+
async mirrorVerify(options = {}) {
|
|
1369
|
+
const [{ GitvaultKeystore }, { readMirrorConfig }, { openGitvaultMirrorBackend }, { verifyGitvaultMirror }] = await Promise.all([
|
|
1370
|
+
this.#keystore(), this.#mirrorConfig(), this.#mirrorBackend(), this.#recovery(),
|
|
1371
|
+
]);
|
|
1372
|
+
const repoId = await this.#resolveRepoId(options);
|
|
1373
|
+
const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
|
|
1374
|
+
const config = readMirrorConfig(keystore, repoId);
|
|
1375
|
+
if (!config)
|
|
1376
|
+
throw new LocalError(`no mirror is configured for ${repoId}`, "verifying gitvault mirror", { code: "GITVAULT_MIRROR_NOT_CONFIGURED", details: { repo_id: repoId }, next_actions: [{ action: "run402 repos mirror <destination>" }] });
|
|
1377
|
+
const backend = openGitvaultMirrorBackend(config.destination, repoId, config.credential);
|
|
1378
|
+
return verifyGitvaultMirror(backend, { keystore });
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* `r402s-recover` (design D4): rebuild a working git repository straight
|
|
1382
|
+
* from a mirrored `source/<repo_id>/` prefix — NO SERVER INVOLVED. `source`
|
|
1383
|
+
* is `s3://<bucket>[/<prefix>]` or a local directory; when it names more
|
|
1384
|
+
* than one mirrored vault, pass `repo_id` explicitly. Recovery proves
|
|
1385
|
+
* validity, never freshness (both honesty statements ride every result).
|
|
1386
|
+
*/
|
|
1387
|
+
async recover(options) {
|
|
1388
|
+
const [{ GitvaultKeystore }, { parseMirrorDestinationUrl }, { openGitvaultMirrorBackend, discoverMirroredRepoIds }, { recoverGitvaultMirror }] = await Promise.all([
|
|
1389
|
+
this.#keystore(), this.#mirrorConfig(), this.#mirrorBackend(), this.#recovery(),
|
|
1390
|
+
]);
|
|
1391
|
+
const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
|
|
1392
|
+
const destination = parseMirrorDestinationUrl(options.source, { region: options.region, endpoint: options.endpoint });
|
|
1393
|
+
let repoId = options.repo_id;
|
|
1394
|
+
if (!repoId) {
|
|
1395
|
+
const found = await discoverMirroredRepoIds(destination, options.credential);
|
|
1396
|
+
if (found.length === 0)
|
|
1397
|
+
throw new LocalError(`${options.source} holds no mirrored vault (no source/<repo_id>/ prefix found)`, "recovering gitvault vault", { code: "GITVAULT_MIRROR_EMPTY", details: { source: options.source } });
|
|
1398
|
+
if (found.length > 1)
|
|
1399
|
+
throw new LocalError(`${options.source} holds ${found.length} mirrored vaults; pass repo_id to pick one`, "recovering gitvault vault", { code: "GITVAULT_MIRROR_AMBIGUOUS", details: { source: options.source, repo_ids: found }, next_actions: found.map((id) => ({ action: `recover --repo ${id}` })) });
|
|
1400
|
+
repoId = found[0];
|
|
1401
|
+
}
|
|
1402
|
+
const backend = openGitvaultMirrorBackend(destination, repoId, options.credential);
|
|
1403
|
+
return recoverGitvaultMirror({ backend, out_dir: options.out_dir, keystore, ...(options.recovery_receipt ? { recovery_receipt: options.recovery_receipt } : {}) });
|
|
1404
|
+
}
|
|
1056
1405
|
// ── internals ─────────────────────────────────────────────────────────────
|
|
1057
1406
|
async #resolveRepoId(options) {
|
|
1058
1407
|
if (options.repo_id)
|
|
@@ -1085,6 +1434,18 @@ export class Gitvault {
|
|
|
1085
1434
|
#address() {
|
|
1086
1435
|
return nodeOnly(() => import("../node/gitvault-address.js"), "resolveOrCreateAddress");
|
|
1087
1436
|
}
|
|
1437
|
+
#mirror() {
|
|
1438
|
+
return nodeOnly(() => import("../node/gitvault-mirror.js"), "mirrorSync");
|
|
1439
|
+
}
|
|
1440
|
+
#mirrorConfig() {
|
|
1441
|
+
return nodeOnly(() => import("../node/gitvault-mirror-config.js"), "mirrorSet");
|
|
1442
|
+
}
|
|
1443
|
+
#mirrorBackend() {
|
|
1444
|
+
return nodeOnly(() => import("../node/gitvault-mirror-backend.js"), "mirrorSet");
|
|
1445
|
+
}
|
|
1446
|
+
#recovery() {
|
|
1447
|
+
return nodeOnly(() => import("../node/gitvault-recover.js"), "recover");
|
|
1448
|
+
}
|
|
1088
1449
|
}
|
|
1089
1450
|
// ─── D7 — progressive terminal-loss warning (repo-first-onramp task 2.7) ────
|
|
1090
1451
|
//
|