@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.
Files changed (48) hide show
  1. package/README.md +4 -4
  2. package/dist/errors.d.ts +5 -2
  3. package/dist/errors.d.ts.map +1 -1
  4. package/dist/errors.js.map +1 -1
  5. package/dist/namespaces/gitvault.crypto.d.ts +10 -0
  6. package/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
  7. package/dist/namespaces/gitvault.crypto.js +10 -0
  8. package/dist/namespaces/gitvault.crypto.js.map +1 -1
  9. package/dist/namespaces/gitvault.d.ts +285 -5
  10. package/dist/namespaces/gitvault.d.ts.map +1 -1
  11. package/dist/namespaces/gitvault.js +369 -8
  12. package/dist/namespaces/gitvault.js.map +1 -1
  13. package/dist/node/gitvault-apply.d.ts +1 -1
  14. package/dist/node/gitvault-apply.d.ts.map +1 -1
  15. package/dist/node/gitvault-apply.js +6 -6
  16. package/dist/node/gitvault-apply.js.map +1 -1
  17. package/dist/node/gitvault-creation-journal.js +1 -1
  18. package/dist/node/gitvault-creation-journal.js.map +1 -1
  19. package/dist/node/gitvault-deploy.js +2 -2
  20. package/dist/node/gitvault-deploy.js.map +1 -1
  21. package/dist/node/gitvault-keystore.d.ts +11 -1
  22. package/dist/node/gitvault-keystore.d.ts.map +1 -1
  23. package/dist/node/gitvault-keystore.js.map +1 -1
  24. package/dist/node/gitvault-mirror-backend.d.ts +88 -0
  25. package/dist/node/gitvault-mirror-backend.d.ts.map +1 -0
  26. package/dist/node/gitvault-mirror-backend.js +374 -0
  27. package/dist/node/gitvault-mirror-backend.js.map +1 -0
  28. package/dist/node/gitvault-mirror-config.d.ts +53 -0
  29. package/dist/node/gitvault-mirror-config.d.ts.map +1 -0
  30. package/dist/node/gitvault-mirror-config.js +112 -0
  31. package/dist/node/gitvault-mirror-config.js.map +1 -0
  32. package/dist/node/gitvault-mirror.d.ts +120 -0
  33. package/dist/node/gitvault-mirror.d.ts.map +1 -0
  34. package/dist/node/gitvault-mirror.js +464 -0
  35. package/dist/node/gitvault-mirror.js.map +1 -0
  36. package/dist/node/gitvault-publication.d.ts +153 -3
  37. package/dist/node/gitvault-publication.d.ts.map +1 -1
  38. package/dist/node/gitvault-publication.js +182 -13
  39. package/dist/node/gitvault-publication.js.map +1 -1
  40. package/dist/node/gitvault-recover.d.ts +136 -0
  41. package/dist/node/gitvault-recover.d.ts.map +1 -0
  42. package/dist/node/gitvault-recover.js +412 -0
  43. package/dist/node/gitvault-recover.js.map +1 -0
  44. package/dist/node/gitvault-snapshot.d.ts +8 -0
  45. package/dist/node/gitvault-snapshot.d.ts.map +1 -1
  46. package/dist/node/gitvault-snapshot.js +11 -0
  47. package/dist/node/gitvault-snapshot.js.map +1 -1
  48. 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 gitvault init" });
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 gitvault snapshot" });
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 gitvault init" });
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
- return { ...result, snapshot, gitvault_commit: snapshot.oid, gitvault_commit_line: line };
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 gitvault compact` to publish a checkpoint bound to a fresh retention_cutoff ticket, then plan the prune again.",
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
- return runGitvaultDeploy({ ...options, vault: handle.vault, repo_dir: options.repo_dir ?? process.cwd() });
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
  //