run402 4.41.0 → 4.43.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 (71) hide show
  1. package/README.md +2 -2
  2. package/lib/argparse.mjs +1 -1
  3. package/lib/assets.mjs +10 -14
  4. package/lib/command-manifest.mjs +28 -27
  5. package/lib/deploy-v2.mjs +34 -14
  6. package/lib/doctor.mjs +8 -1
  7. package/lib/gitvault-target.mjs +7 -9
  8. package/lib/gitvault.mjs +7 -8
  9. package/lib/next-actions.mjs +5 -2
  10. package/lib/notifications.mjs +4 -8
  11. package/lib/operator.mjs +1 -1
  12. package/lib/org.mjs +3 -3
  13. package/lib/repos.mjs +536 -164
  14. package/lib/rooms-context.mjs +2 -3
  15. package/lib/rooms.mjs +11 -14
  16. package/lib/stats.mjs +57 -0
  17. package/lib/update-check.mjs +6 -8
  18. package/package.json +1 -1
  19. package/sdk/dist/index.d.ts +10 -1
  20. package/sdk/dist/index.d.ts.map +1 -1
  21. package/sdk/dist/index.js +11 -0
  22. package/sdk/dist/index.js.map +1 -1
  23. package/sdk/dist/kernel.d.ts +31 -0
  24. package/sdk/dist/kernel.d.ts.map +1 -1
  25. package/sdk/dist/kernel.js +116 -3
  26. package/sdk/dist/kernel.js.map +1 -1
  27. package/sdk/dist/namespaces/admin.d.ts +1 -1
  28. package/sdk/dist/namespaces/admin.js +1 -1
  29. package/sdk/dist/namespaces/deploy.types.d.ts +2 -2
  30. package/sdk/dist/namespaces/gitvault.crypto.d.ts +188 -5
  31. package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
  32. package/sdk/dist/namespaces/gitvault.crypto.js +277 -8
  33. package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
  34. package/sdk/dist/namespaces/gitvault.d.ts +239 -44
  35. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  36. package/sdk/dist/namespaces/gitvault.js +327 -49
  37. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  38. package/sdk/dist/namespaces/gitvault.types.d.ts +184 -9
  39. package/sdk/dist/namespaces/gitvault.types.d.ts.map +1 -1
  40. package/sdk/dist/namespaces/gitvault.types.js +2 -1
  41. package/sdk/dist/namespaces/gitvault.types.js.map +1 -1
  42. package/sdk/dist/namespaces/projects.d.ts +1 -1
  43. package/sdk/dist/namespaces/projects.js +1 -1
  44. package/sdk/dist/namespaces/projects.types.d.ts +1 -1
  45. package/sdk/dist/namespaces/projects.types.d.ts.map +1 -1
  46. package/sdk/dist/node/gitvault-apply.d.ts +8 -0
  47. package/sdk/dist/node/gitvault-apply.d.ts.map +1 -1
  48. package/sdk/dist/node/gitvault-apply.js +1 -0
  49. package/sdk/dist/node/gitvault-apply.js.map +1 -1
  50. package/sdk/dist/node/gitvault-deploy.d.ts +13 -0
  51. package/sdk/dist/node/gitvault-deploy.d.ts.map +1 -1
  52. package/sdk/dist/node/gitvault-deploy.js +28 -0
  53. package/sdk/dist/node/gitvault-deploy.js.map +1 -1
  54. package/sdk/dist/node/gitvault-keystore.d.ts +54 -4
  55. package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
  56. package/sdk/dist/node/gitvault-keystore.js +20 -0
  57. package/sdk/dist/node/gitvault-keystore.js.map +1 -1
  58. package/sdk/dist/node/gitvault-publication.d.ts +376 -21
  59. package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
  60. package/sdk/dist/node/gitvault-publication.js +567 -19
  61. package/sdk/dist/node/gitvault-publication.js.map +1 -1
  62. package/sdk/dist/node/gitvault-recover.d.ts +15 -1
  63. package/sdk/dist/node/gitvault-recover.d.ts.map +1 -1
  64. package/sdk/dist/node/gitvault-recover.js +8 -4
  65. package/sdk/dist/node/gitvault-recover.js.map +1 -1
  66. package/sdk/dist/node/gitvault-snapshot.d.ts +40 -7
  67. package/sdk/dist/node/gitvault-snapshot.d.ts.map +1 -1
  68. package/sdk/dist/node/gitvault-snapshot.js +67 -19
  69. package/sdk/dist/node/gitvault-snapshot.js.map +1 -1
  70. package/sdk/dist/scoped.d.ts +2 -2
  71. package/sdk/dist/scoped.js +1 -1
@@ -28,8 +28,8 @@
28
28
  * docs/agent-response-design.md a secret-bearing response is never cached,
29
29
  * never persisted into an agent-surface result store, and never logged.
30
30
  */
31
- import { LocalError } from "../errors.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";
31
+ import { LocalError, isRun402Error } from "../errors.js";
32
+ import { GITVAULT_DURABILITY_STATEMENT, 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)
@@ -98,7 +98,7 @@ export class Gitvault {
98
98
  }
99
99
  /**
100
100
  * Every vault the organization owns, one round trip — `repos list`'s bulk
101
- * read (repo-surface-consolidation task 2.4). See {@link
101
+ * read. See {@link
102
102
  * GitvaultOrgVaultsListing}'s doc comment for the FROZEN response shape and
103
103
  * the 404-until-shipped fallback contract.
104
104
  */
@@ -122,8 +122,8 @@ export class Gitvault {
122
122
  return { vaults: all, has_more: true, next_cursor: cursor ?? null };
123
123
  }
124
124
  /**
125
- * Resolve a vault by its address-form `org-slug/name` (repo-first-onramp
126
- * task 4.3, design D6) — `GET /gitvault/v1/vaults?repo=<org-slug>/<name>`.
125
+ * Resolve a vault by its address-form `org-slug/name`
126
+ * `GET /gitvault/v1/vaults?repo=<org-slug>/<name>`.
127
127
  * `RESOURCE_NOT_FOUND` for no such org OR no such name (deliberately
128
128
  * collapsed — see the design's slug-namespace-probing note);
129
129
  * `SLUG_RELEASED` (read it with {@link gitvaultSlugReleasedInfo}) while the
@@ -135,7 +135,7 @@ export class Gitvault {
135
135
  }
136
136
  /**
137
137
  * Resolve a parsed remote address (`parseGitvaultRemoteUrl`'s output),
138
- * dispatching on its form (design D6): id-form resolves exactly like
138
+ * dispatching on its form: id-form resolves exactly like
139
139
  * {@link forProject}; slug-form resolves via {@link forRepo}. A pure read —
140
140
  * no pinning, no creation. Node-only callers wanting BOTH should use
141
141
  * {@link resolveOrCreateAddress} instead, which also drives the local pin
@@ -212,6 +212,56 @@ export class Gitvault {
212
212
  * instrument for heartbeat/release. Never log it, never cache it, never place
213
213
  * it in an agent-surface result store.
214
214
  */
215
+ // ── epoch rotation ceremonies + declarations (D193-D203, rev 42) ──────────
216
+ //
217
+ // Five isomorphic, no-local-key-material API calls (owner + step-up,
218
+ // `gitvault.rotate`) — same "direct #client.request" shape as `setPolicy`/
219
+ // `completeOverride` above. The actual ROTATION (sampling K_e, sealing
220
+ // envelopes, submitting the head) needs the Node-only keystore + crypto
221
+ // core and lives on `GitvaultVault` (`sdk/src/node/gitvault-publication.ts`);
222
+ // see {@link rotateEpoch} / {@link rotateEpochForKeyRevocation} below.
223
+ /** `POST …/recipients/:principal_id/confirm` (D197) — first-seen pin confirmation. */
224
+ async confirmRecipient(repoId, principalId, newFingerprint) {
225
+ return this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}/recipients/${encodeURIComponent(principalId)}/confirm`, {
226
+ method: "POST", body: { new_fingerprint: newFingerprint }, context: "confirming a gitvault recipient's first pin",
227
+ });
228
+ }
229
+ /** `POST …/recipients/:principal_id/repin` (D197) — re-pin ceremony. */
230
+ async repinRecipient(repoId, principalId, input) {
231
+ return this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}/recipients/${encodeURIComponent(principalId)}/repin`, {
232
+ method: "POST", body: input, context: "re-pinning a gitvault recipient",
233
+ });
234
+ }
235
+ /**
236
+ * `POST …/recipients/:principal_id/key-revocation` (D199) — declares
237
+ * `reason:"recipient_key_revoked"` admissible for the NEXT rotation this
238
+ * org's vaults submit; org-scoped, advances the same watermark a member
239
+ * removal does. Returns the D194 counters — the ONE client-visible read of
240
+ * them, which is why {@link rotateEpochForKeyRevocation} exists as the
241
+ * fully self-contained entry point.
242
+ */
243
+ async declareRecipientKeyRevoked(repoId, principalId) {
244
+ return this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}/recipients/${encodeURIComponent(principalId)}/key-revocation`, {
245
+ method: "POST", body: {}, context: "declaring a gitvault recipient key revoked",
246
+ });
247
+ }
248
+ /**
249
+ * `POST …/epoch-secret-exposure` (D199) — declares `reason:"epoch_secret_exposed"`
250
+ * admissible for THIS vault (deliberately vault-scoped, not org-wide — one
251
+ * vault's leaked `K_repo`/`K_e` is not evidence any sibling vault is
252
+ * compromised). This is the rekey remedy for a leaked/exposed vault key:
253
+ * declare exposure here, then drive a `rotate_epoch` with
254
+ * `reason:"epoch_secret_exposed"` (the required counters must be supplied
255
+ * from a source other than this call — see {@link rotateEpoch}'s doc
256
+ * comment on the confirmed gap in what the gateway exposes today).
257
+ */
258
+ async declareEpochSecretExposed(repoId) {
259
+ return this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}/epoch-secret-exposure`, { method: "POST", body: {}, context: "declaring a gitvault epoch secret exposed" });
260
+ }
261
+ /** `POST …/writer-authority/declare-unavailable` (D202) — an explicit, audited fact that the writer signing key is gone. */
262
+ async declareWriterAuthorityUnavailable(repoId) {
263
+ return this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}/writer-authority/declare-unavailable`, { method: "POST", body: {}, context: "declaring gitvault writer authority unavailable" });
264
+ }
215
265
  async acquireMaintenanceLease(request) {
216
266
  const { repo_id, ...body } = request;
217
267
  return this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repo_id)}/maintenance-leases`, {
@@ -300,9 +350,9 @@ export class Gitvault {
300
350
  }
301
351
  /**
302
352
  * Resolve a parsed remote address to an OPEN handle, pinning `repo_id` in
303
- * local git state on the first successful slug-form resolution (design D6,
304
- * task 4.5), and — when `allow_create` is set and resolution misses —
305
- * push-to-create it (task 4.4/4.5). This is what the remote helper and
353
+ * local git state on the first successful slug-form resolution, and
354
+ * when `allow_create` is set and resolution misses —
355
+ * push-to-create it. This is what the remote helper and
306
356
  * `gitvault snapshot` drive for a `run402::<org-slug>/<name>` remote; an
307
357
  * id-form remote resolves through here too (no pin, since it needs none)
308
358
  * so a caller need not branch on the address's form itself.
@@ -544,6 +594,26 @@ export class Gitvault {
544
594
  pending = [];
545
595
  }
546
596
  }
597
+ // Best-effort covering-recipient count (dogfood item 2): the same
598
+ // envelope-recipients read `access()` uses. Decides whether the V0-A
599
+ // single-principal terminal-loss statement is still honest to print for
600
+ // THIS vault, or whether this client has locally proven a second covering
601
+ // recipient. Only attempted when a vault record is already in hand
602
+ // (`record` non-null means the credentials that fetched it already work)
603
+ // — a session-less or offline caller never pays for a second failing
604
+ // request, and any failure here falls back to exactly today's
605
+ // single-principal behavior, never a new failure mode for `status`.
606
+ let coveringRecipients = null;
607
+ if (repoId && record) {
608
+ try {
609
+ const coverage = await this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}/envelope-recipients`, { context: "reading the gitvault envelope recipients" });
610
+ coveringRecipients = coverage.recipient_fingerprints.length;
611
+ }
612
+ catch {
613
+ coveringRecipients = null;
614
+ }
615
+ }
616
+ const isMultiPrincipal = coveringRecipients !== null && coveringRecipients >= 2;
547
617
  const warnings = [...(record?.warnings ?? [])];
548
618
  if (keystorePresent && !canSign) {
549
619
  warnings.push({ kind: "read_only", message: "the signing key is missing from identity.json — this principal can decrypt and verify but cannot publish a new head" });
@@ -554,18 +624,28 @@ export class Gitvault {
554
624
  // D7 (repo-first-onramp task 2.7): a vault that has accrued enough value
555
625
  // at risk gets a STANDING warning instead of the one-time genesis note.
556
626
  // `record` is `null` for an unallocated project, which is the ordinary
557
- // "nothing to warn about" shape, not a gap in this check.
627
+ // "nothing to warn about" shape, not a gap in this check. Once this
628
+ // client has locally proven >= 2 covering recipients, the SAME threshold
629
+ // still fires — real value at risk still deserves a standing reminder —
630
+ // but the copy downgrades to `keystore_backup_reminder`, which never
631
+ // claims terminal loss (dogfood item 2: the single-principal premise
632
+ // behind `terminal_loss_risk` is provably false for this vault).
558
633
  if (record) {
559
634
  const trip = gitvaultLossWarningTrip(record);
560
635
  if (gitvaultLossWarningTripped(trip)) {
561
- warnings.push({ kind: "terminal_loss_risk", message: gitvaultLossWarningMessage(trip) });
636
+ if (isMultiPrincipal) {
637
+ warnings.push({ kind: "keystore_backup_reminder", message: gitvaultKeystoreBackupReminderMessage(trip, coveringRecipients) });
638
+ }
639
+ else {
640
+ warnings.push({ kind: "terminal_loss_risk", message: gitvaultLossWarningMessage(trip) });
641
+ }
562
642
  }
563
643
  }
564
- // The local id-pin, when there is a repository to read it from (design
565
- // D6, task 4.5) — a pure read, same discipline as `remote` below. Read
644
+ // The local id-pin, when there is a repository to read it from — a pure
645
+ // read, same discipline as `remote` below. Read
566
646
  // BEFORE `remote` so a slug-form remote's `matches` comparison can use
567
647
  // it: the pin is the only LOCAL ground truth a slug-form address's own
568
- // URL text does not carry (kychee-com/run402#562).
648
+ // URL text does not carry.
569
649
  let pinned = null;
570
650
  if (options.repo_dir) {
571
651
  const { readPinnedGitvaultRepo } = await this.#address();
@@ -576,13 +656,10 @@ export class Gitvault {
576
656
  // pure read: `status` must never write git configuration.
577
657
  //
578
658
  // Checks BOTH conventional names, `run402` first then `origin` —
579
- // matching `scaffoldRemote`'s own naming (design D1: it claims `origin`
659
+ // matching `scaffoldRemote`'s own naming: it claims `origin`
580
660
  // additively when the repository has none yet, falling back to `run402`
581
- // only when `origin` is already taken by something else). The common
582
- // case is therefore an `origin` remote, not a `run402` one checking
583
- // only "run402" (the pre-fix behavior) reported `remote: null` for most
584
- // repositories even though a run402-form remote was sitting right there
585
- // under `origin` (kychee-com/run402#559c). A name whose URL exists but
661
+ // only when `origin` is already taken by something else. The common
662
+ // case is therefore an `origin` remote, not a `run402` one. A name whose URL exists but
586
663
  // does not parse as a run402 address (someone's own unrelated remote
587
664
  // happening to be named "run402") is skipped rather than reported, so
588
665
  // the other conventional name still gets a chance.
@@ -610,8 +687,8 @@ export class Gitvault {
610
687
  }
611
688
  else {
612
689
  // Slug-form: the URL's second half is a repo NAME, not a project
613
- // id — comparing it against `project` always mismatched, even for
614
- // a perfectly-configured remote (kychee-com/run402#562). The only
690
+ // id — comparing it against `project` would always mismatch, even for
691
+ // a perfectly-configured remote. The only
615
692
  // LOCAL ground truth for a slug-form remote's real identity is the
616
693
  // id-pin; without one there is nothing to compare against, and
617
694
  // that absence is NOT evidence of a mismatch.
@@ -679,8 +756,10 @@ export class Gitvault {
679
756
  pins: { highest_authenticated: authenticated, highest_materialized: materialized },
680
757
  gitvault_policy: record?.gitvault_policy ?? null,
681
758
  pending_overrides: pending.length,
682
- terminal_loss_statement: GITVAULT_TERMINAL_LOSS_STATEMENT,
683
- terminal_loss_detail: GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT,
759
+ covering_recipients: coveringRecipients,
760
+ terminal_loss_statement: isMultiPrincipal ? null : GITVAULT_TERMINAL_LOSS_STATEMENT,
761
+ terminal_loss_detail: isMultiPrincipal ? null : GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT,
762
+ durability_statement: isMultiPrincipal ? GITVAULT_DURABILITY_STATEMENT : null,
684
763
  warnings,
685
764
  next_actions: nextActions,
686
765
  };
@@ -737,14 +816,13 @@ export class Gitvault {
737
816
  protocol_refs: "allow",
738
817
  ...(options.checkpoint ? { checkpoint: true } : {}),
739
818
  };
740
- const result = await handle.vault.push(push);
741
- // Capture-time dual-push hook (design D6/task 2.4): fires only when a
819
+ const result = await handle.vault.push(push).catch((e) => { throw this.#enrichEpochRotationRequired(e, handle.repo_id); });
820
+ // Capture-time dual-push hook: fires only when a
742
821
  // mirror is configured; NEVER throws, NEVER alters the vault outcome
743
822
  // above (already returned/committed) — a mirror failure is a named
744
823
  // pending finding reported BESIDE the vault result, on its own field.
745
824
  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,
825
+ // Deploy-time reconcile hook: fires on every successful push,
748
826
  // best-effort — a reconcile failure (including a read-only principal
749
827
  // with no signing key) is reported BESIDE the vault result, never a
750
828
  // `push()` throw, same non-blocking contract as the mirror hook above.
@@ -752,6 +830,43 @@ export class Gitvault {
752
830
  return { ...result, snapshot, gitvault_commit: snapshot.oid, gitvault_commit_line: line, mirror_push: mirrorPush, reconcile_recipients: reconcileRecipients };
753
831
  }
754
832
  /** Best-effort dual-push: catches EVERYTHING, including the lazy module import itself, so a mirror problem can never surface as a `push()` throw. */
833
+ /**
834
+ * `EPOCH_ROTATION_REQUIRED` (D193) is left THROWN — never swallowed into a
835
+ * silent auto-rotation — because this call site cannot legally decide the
836
+ * two D194 counters (`recipient_state_version`/`recipient_revocation_version`)
837
+ * a rotation attempt must be fenced against: no shipped gateway route
838
+ * exposes them for `reason:"member_removed"`/`"elective_rekey"`/
839
+ * `"epoch_secret_exposed"` (verified against the live gateway route
840
+ * source — see `GitvaultVault.rotateEpoch`'s own doc comment). Submitting
841
+ * a GUESSED pair would either fail loudly (`RECIPIENT_SET_MISMATCH`, the
842
+ * honest outcome) or — worse — never be reachable at all for a fresh
843
+ * counter row. Rather than let a caller decode `details.migration_required`/
844
+ * `revocation_outstanding`/`exposure_outstanding` themselves, this
845
+ * decorates the SAME thrown error with the exact next command for each
846
+ * cause (`repos access repair` / `repos access revoke-key` / `repos
847
+ * access declare-exposure`) so "the next push" surfaces its own remedy
848
+ * instead of an opaque 409 — "wire into the natural path" without
849
+ * pretending a blind retry could ever succeed.
850
+ */
851
+ #enrichEpochRotationRequired(e, repoId) {
852
+ if (!isRun402Error(e) || e.code !== "EPOCH_ROTATION_REQUIRED")
853
+ return e;
854
+ // Mirrors `epochRotationRequiredNextActions` in
855
+ // `../node/gitvault-deploy.ts` byte-for-byte (same three causes, same
856
+ // three commands) — kept as a small standalone copy rather than a
857
+ // shared import so this namespace-level module (isomorphic — Deno/Bun/V8
858
+ // isolates, no Node-only imports) never has to pull in the Node-only
859
+ // deploy module just to decode three booleans. Update BOTH on drift.
860
+ const details = e.details ?? {};
861
+ const nextActions = [];
862
+ if (details.migration_required)
863
+ nextActions.push({ action: "run402 repos access repair", why: "this vault predates rev-42 epoch rotation and must complete one first-ever rotation (owner + step-up)" });
864
+ if (details.revocation_outstanding)
865
+ nextActions.push({ action: "run402 repos access revoke-key <principal_id>", why: "an org membership removal or key revocation is outstanding for this vault (owner + step-up)" });
866
+ if (details.exposure_outstanding)
867
+ nextActions.push({ action: "run402 repos access declare-exposure", why: "this vault's own epoch secret has been declared exposed (owner + step-up)" });
868
+ return new LocalError(`this vault requires a rotate_epoch admission before an ordinary push is admissible (repo_id ${repoId})`, "pushing to gitvault", { code: "EPOCH_ROTATION_REQUIRED", details, next_actions: nextActions.length > 0 ? nextActions : undefined, cause: e });
869
+ }
755
870
  async #tryMirrorPush(repoId, keystore) {
756
871
  try {
757
872
  const { mirrorPushForGeneration } = await this.#mirror();
@@ -1178,6 +1293,78 @@ export class Gitvault {
1178
1293
  const handle = await this.open(options);
1179
1294
  return handle.vault.reconcileEnvelopeRecipients();
1180
1295
  }
1296
+ /**
1297
+ * Drive one epoch rotation (D193-D203, rev 42) — the client half of
1298
+ * epoch rotation: sample a fresh `K_e`, compute the H-partition from live
1299
+ * desired-recipient state + the effective pin manifest, seal one
1300
+ * `key_envelope` per included recipient, submit the create-only
1301
+ * `rotation_attempt_descriptor`, submit the `rotate_epoch` head, verify
1302
+ * this principal's own envelope opens to the committed key (when it is
1303
+ * itself a recipient), and advance the local keystore's epoch pointer.
1304
+ * See {@link import("../node/gitvault-publication.js").GitvaultVault.
1305
+ * rotateEpoch}'s own doc comment for the full obligations and — load-
1306
+ * bearing — the confirmed gap in what the gateway exposes for
1307
+ * `recipient_state_version`/`recipient_revocation_version` outside the
1308
+ * `recipient_key_revoked` reason, and (also load-bearing) `options.
1309
+ * pending_confirmations` for folding a receipted `/confirm`/`/repin`
1310
+ * result into THIS rotation's head instead of a separately-gated
1311
+ * `publishPinManifestUpdate` call.
1312
+ */
1313
+ async rotateEpoch(options) {
1314
+ const handle = await this.open(options);
1315
+ return handle.vault.rotateEpoch(options);
1316
+ }
1317
+ /**
1318
+ * The ONE fully self-contained rotation entry point: declares
1319
+ * `reason:"recipient_key_revoked"` for `principalId` (owner + step-up)
1320
+ * and drives the rotation from that call's OWN returned counters — no
1321
+ * external counter source needed. The rekey remedy for the exact witness
1322
+ * task 5.0 records: a specific member's key is compromised/should no
1323
+ * longer be trusted.
1324
+ */
1325
+ async rotateEpochForKeyRevocation(principalId, options = {}) {
1326
+ const handle = await this.open(options);
1327
+ return handle.vault.rotateEpochForKeyRevocation(principalId, options);
1328
+ }
1329
+ /**
1330
+ * Publish a receipted `recipient_pin_manifest` update (D197) — the
1331
+ * publication half of the `/confirm`/`/repin` ceremonies above. Call
1332
+ * after {@link confirmRecipient}/{@link repinRecipient} returns a receipt;
1333
+ * this is `gitvault.writer`-sufficient (the owner-gated half already
1334
+ * happened at the ceremony route).
1335
+ *
1336
+ * **This publish is an ORDINARY admission** and is therefore itself
1337
+ * refused `EPOCH_ROTATION_REQUIRED` while this vault has a migration/
1338
+ * revocation/exposure condition outstanding (D193) — reproduced live in
1339
+ * production 2026-08-27. `#enrichEpochRotationRequiredForPinManifest`
1340
+ * below decorates that refusal with the remedy: fold the SAME receipt
1341
+ * into `rotateEpoch({..., pending_confirmations: [...]})` instead, which
1342
+ * durably publishes it on a `rotate_epoch` admission (the gate's own
1343
+ * escape valve) rather than a separately-gated ordinary one.
1344
+ */
1345
+ async publishPinManifestUpdate(input) {
1346
+ const handle = await this.open(input);
1347
+ return handle.vault
1348
+ .publishPinManifestUpdate({ principal_id: input.principal_id, ek_fingerprint: input.ek_fingerprint, confirmed_by: "operator_confirmation", receipt: input.receipt })
1349
+ .catch((e) => { throw this.#enrichEpochRotationRequiredForPinManifest(e, handle.repo_id); });
1350
+ }
1351
+ /**
1352
+ * `EPOCH_ROTATION_REQUIRED` from a `publishPinManifestUpdate` call names
1353
+ * the SAME three causes {@link #enrichEpochRotationRequired} decodes for
1354
+ * `push()`, but the remedy is different: a manifest-only publish should
1355
+ * fold into the NEXT `rotateEpoch` call via `pending_confirmations`
1356
+ * rather than run a separate migration/revocation/exposure rotation
1357
+ * first and publish again afterward — one admission instead of two, and
1358
+ * the only path that works at all while EVERY predecessor-confirmed
1359
+ * principal is already exhausted (see `GitvaultVault.rotateEpoch`'s own
1360
+ * doc comment for the honest residual: this does not rescue a vault with
1361
+ * zero ever-confirmed principals, which needs an operator-side fix).
1362
+ */
1363
+ #enrichEpochRotationRequiredForPinManifest(e, repoId) {
1364
+ if (!isRun402Error(e) || e.code !== "EPOCH_ROTATION_REQUIRED")
1365
+ return e;
1366
+ return new LocalError(`this vault requires a rotate_epoch admission before an ordinary pin-manifest publish is admissible (repo_id ${repoId}) — fold this SAME receipt into rotateEpoch({..., pending_confirmations: [{principal_id, ek_fingerprint, receipt}]}) instead of retrying this call`, "publishing a recipient_pin_manifest update", { code: "EPOCH_ROTATION_REQUIRED", details: e.details, next_actions: [{ action: "r.gitvault.rotateEpoch({..., pending_confirmations: [{principal_id, ek_fingerprint, receipt}]})", why: "the manifest publish rides the SAME head as the required rotation, which is EPOCH_ROTATION_REQUIRED's own escape valve — a standalone publish never is" }], cause: e });
1367
+ }
1181
1368
  /**
1182
1369
  * `repos access` (repo-surface-consolidation D5/D10) — a READ-ONLY report
1183
1370
  * of who can open this vault, composed from whatever the live gateway
@@ -1199,42 +1386,100 @@ export class Gitvault {
1199
1386
  this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}/envelope-recipients`, { context: "reading the gitvault envelope recipients" }),
1200
1387
  ]);
1201
1388
  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`.
1389
+ // `desired` is OPTIONAL on the wire (see GitvaultEnvelopeRecipientsResponse):
1390
+ // absent means an older gateway that predates this field genuinely
1391
+ // unknown, not "no desired recipients." Distinguish "absent" from
1392
+ // "present and empty" via the array itself, not a boolean flag, so a
1393
+ // null/undefined check is the single source of truth for both this
1394
+ // method and any future caller of the same wire type.
1395
+ const desiredList = coverage.desired ?? null;
1396
+ const envelopeStateAvailable = desiredList !== null;
1397
+ const desiredByPrincipal = new Map(desiredList?.map((d) => [d.principal_id, d]) ?? []);
1398
+ // Node-only, best-effort: THIS machine's own local TOFU pins, and this
1399
+ // keystore's own encryption-key fingerprint (used below to keep the
1400
+ // vault creator's own machine out of `unmatched_covered_fingerprints` —
1401
+ // the org directory only lists human-enrolled keys, so a wallet-principal
1402
+ // creator's fingerprint is legitimately absent from it, not orphaned).
1403
+ // Never fails the whole read — a browser/worker caller, or a machine that
1404
+ // has never wrapped anyone, simply reports every `tofu_pin` as `null` and
1405
+ // `this_keystore` as `null`.
1205
1406
  let pins = {};
1407
+ let ownFingerprint = null;
1206
1408
  try {
1207
1409
  const { GitvaultKeystore } = await this.#keystore();
1208
1410
  const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
1209
1411
  pins = keystore.readRepo(repoId)?.envelope_recipient_pins ?? {};
1412
+ ownFingerprint = keystore.readIdentity()?.encryption_fingerprint ?? null;
1210
1413
  }
1211
1414
  catch {
1212
- // Not Node, or no local keystore for this repo — pins stay empty.
1415
+ // Not Node, or no local keystore for this repo — pins/ownFingerprint stay empty/null.
1213
1416
  }
1214
1417
  const recipients = directory.keys.map((k) => {
1215
1418
  const pinned = pins[k.principal_id];
1419
+ const desired = desiredByPrincipal.get(k.principal_id) ?? null;
1420
+ const envelopeState = desired == null ? null : desired.status === "pending_removal" ? "pending_removal" : covered.has(k.ek_fingerprint) ? "converged" : "pending";
1216
1421
  return {
1217
1422
  principal_id: k.principal_id,
1218
1423
  display_name: k.display_name,
1219
1424
  fingerprint: k.ek_fingerprint,
1220
1425
  covered: covered.has(k.ek_fingerprint),
1426
+ envelope_state: envelopeState,
1221
1427
  tofu_pin: pinned !== undefined ? { fingerprint: pinned, matches_directory: pinned === k.ek_fingerprint } : null,
1222
1428
  };
1223
1429
  });
1224
1430
  const directoryFingerprints = new Set(directory.keys.map((k) => k.ek_fingerprint));
1225
- const unmatched = coverage.recipient_fingerprints.filter((fp) => !directoryFingerprints.has(fp));
1431
+ // Removed members (desired status pending_removal) whose fingerprint is
1432
+ // STILL covered: real, continuing access that membership removal did not
1433
+ // revoke, because gitvault v0 has no epoch-rotation mechanism yet. See
1434
+ // GitvaultAccessResult's doc comment.
1435
+ const staleAccess = (desiredList ?? [])
1436
+ .filter((d) => d.status === "pending_removal" && d.ek_fingerprint != null && covered.has(d.ek_fingerprint))
1437
+ .map((d) => ({ principal_id: d.principal_id, display_name: d.display_name, fingerprint: d.ek_fingerprint }));
1438
+ const staleAccessFingerprints = new Set(staleAccess.map((s) => s.fingerprint));
1439
+ // Genuinely unexplained: covering fingerprints that match neither a
1440
+ // current directory entry NOR a desired-state row (already broken out
1441
+ // above as stale_access) — orphaned, externally revoked, or a recipient
1442
+ // outside this org's membership model entirely. THIS keystore's own
1443
+ // fingerprint is a separate, locally-provable case: the org directory
1444
+ // only lists human-enrolled keys, so the vault creator's own
1445
+ // wallet-principal keystore legitimately never appears there — that is
1446
+ // not orphaned/external, it is provably this machine, so it is broken
1447
+ // out into `this_keystore` instead of left to read as a misconfiguration.
1448
+ const unmatchedRaw = coverage.recipient_fingerprints.filter((fp) => !directoryFingerprints.has(fp) && !staleAccessFingerprints.has(fp));
1449
+ const thisKeystore = ownFingerprint !== null && unmatchedRaw.includes(ownFingerprint) ? { fingerprint: ownFingerprint, covered: true } : null;
1450
+ const unmatched = thisKeystore ? unmatchedRaw.filter((fp) => fp !== thisKeystore.fingerprint) : unmatchedRaw;
1451
+ const gap = envelopeStateAvailable
1452
+ ? "history_scope (which epochs each recipient can read) is not available: this read has no per-epoch view, only the vault's " +
1453
+ "CURRENT coverage — a recipient covered here may still be excluded from a PAST epoch's key (forward revocation is exactly what " +
1454
+ "an epoch rotation buys). envelope_state per recipient IS available today, from the " +
1455
+ `gateway's desired-recipient-state substrate (desired_state_version ${String(coverage.desired_state_version ?? "unknown")}): ` +
1456
+ "\"converged\" means desired and covered, \"pending\" means desired but not yet wrapped, \"pending_removal\" means membership " +
1457
+ "removed them but this vault has not yet completed a rotation away from them. pending_removal does NOT mean revoked — with " +
1458
+ "covered:true they still decrypt this vault until an owner runs `repos access repair` (or, for a targeted key, `repos access " +
1459
+ "revoke-key`); see stale_access for exactly who, and next_actions for the exact command."
1460
+ : "the gateway did not report desired-recipient state (desired[]) for this vault_id — likely an older gateway than this SDK " +
1461
+ "expects, so per-recipient envelope_state and stale_access are unavailable. history_scope is unavailable regardless: gitvault " +
1462
+ "protocol v0 pins a single fixed epoch, so there is no per-epoch scope to report. This reports what the read surface has today: " +
1463
+ "the org's directory of encryption-key-holding members, which of the vault's current envelope-recipient fingerprints match a " +
1464
+ "directory entry (covered), and this machine's own local TOFU pin per principal when it has ever wrapped one (tofu_pin) — never a server-side fact.";
1226
1465
  return {
1227
1466
  repo_id: repoId,
1228
1467
  org_id: orgId,
1229
1468
  recipients,
1230
1469
  unmatched_covered_fingerprints: unmatched,
1231
- envelope_state_available: false,
1470
+ this_keystore: thisKeystore,
1471
+ stale_access: staleAccess,
1472
+ envelope_state_available: envelopeStateAvailable,
1232
1473
  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.",
1474
+ gap,
1475
+ ...(staleAccess.length > 0
1476
+ ? {
1477
+ next_actions: [
1478
+ { action: "run402 repos access repair", why: "re-key this vault's current epoch away from every principal in stale_access at once (owner + step-up)" },
1479
+ { action: "run402 repos access revoke-key <principal_id>", why: "target exactly one stale_access principal for revocation-triggered rotation (owner + step-up)" },
1480
+ ],
1481
+ }
1482
+ : {}),
1238
1483
  };
1239
1484
  }
1240
1485
  /**
@@ -1474,10 +1719,16 @@ export const GITVAULT_LOSS_WARNING_THRESHOLDS = {
1474
1719
  * strings (protocol convention for values that could exceed safe-integer
1475
1720
  * precision in principle; comfortably within it here).
1476
1721
  *
1477
- * There is deliberately no companion "is this resolved" function: V0-A
1478
- * cannot detect a second principal or human envelope able to open the vault
1479
- * (design D7's stricter-than-drafted resolution), so once tripped this has
1480
- * nothing further to compute — a caller does not un-trip it, ever, in V0.
1722
+ * There is deliberately no companion "is this resolved" function on the
1723
+ * TRIP computation itself: whether the generations/bytes/days thresholds
1724
+ * crossed is independent of how many principals cover the vault, and this
1725
+ * function stays a pure threshold check forever — a caller does not un-trip
1726
+ * it, ever, in V0. What DOES now exist, one call site up in `status()`, is a
1727
+ * way to downgrade the COPY once tripped: a locally-provable read of the
1728
+ * vault's covering-recipient count (the same envelope-recipients read
1729
+ * `Gitvault.access` uses) can show this is no longer the single-principal
1730
+ * case the message below describes. See
1731
+ * {@link gitvaultKeystoreBackupReminderMessage} for that downgraded form.
1481
1732
  */
1482
1733
  export function gitvaultLossWarningTrip(record, now = new Date()) {
1483
1734
  const generations = Number(record.admitted_generations ?? "0");
@@ -1494,12 +1745,8 @@ export function gitvaultLossWarningTrip(record, now = new Date()) {
1494
1745
  export function gitvaultLossWarningTripped(trip) {
1495
1746
  return trip.generations || trip.source_bytes || trip.days_since_genesis;
1496
1747
  }
1497
- /**
1498
- * The standing warning text (design D7): names what tripped, states the
1499
- * resolution honestly (a second principal or human envelope — nothing this
1500
- * client can verify yet), and never claims an attestation would clear it.
1501
- */
1502
- export function gitvaultLossWarningMessage(trip) {
1748
+ /** Shared by both D7 message forms below — which threshold(s) tripped, in prose. */
1749
+ function gitvaultLossWarningReasons(trip) {
1503
1750
  const reasons = [];
1504
1751
  if (trip.generations)
1505
1752
  reasons.push(`≥${GITVAULT_LOSS_WARNING_THRESHOLDS.generations} generations`);
@@ -1507,10 +1754,41 @@ export function gitvaultLossWarningMessage(trip) {
1507
1754
  reasons.push(`≥${Math.round(GITVAULT_LOSS_WARNING_THRESHOLDS.source_bytes / (1024 * 1024))} MB of source`);
1508
1755
  if (trip.days_since_genesis)
1509
1756
  reasons.push(`≥${GITVAULT_LOSS_WARNING_THRESHOLDS.days_since_genesis} days since genesis`);
1757
+ return reasons;
1758
+ }
1759
+ /**
1760
+ * The standing warning text (design D7): names what tripped, states the
1761
+ * resolution honestly (a second principal or human envelope — nothing this
1762
+ * client can verify yet), and never claims an attestation would clear it.
1763
+ *
1764
+ * This is the SINGLE-PRINCIPAL form — use it only when the vault's
1765
+ * covering-recipient count is unknown or <= 1. Once a caller has locally
1766
+ * proven >= 2 covering recipients, its premise ("only one principal can open
1767
+ * it") is false; use {@link gitvaultKeystoreBackupReminderMessage} instead.
1768
+ */
1769
+ export function gitvaultLossWarningMessage(trip) {
1770
+ const reasons = gitvaultLossWarningReasons(trip);
1510
1771
  return (`this vault has accrued real value at risk (${reasons.join(", ")}) while only one principal can open it. ` +
1511
1772
  "Only a second principal — another keystore, or later a human envelope — demonstrably able to open the vault clears this warning; " +
1512
1773
  "this client cannot verify that yet, so it stands until you add one. No attestation or flag clears it.");
1513
1774
  }
1775
+ /**
1776
+ * D7's downgraded form (dogfood item 2): once `status()` has locally proven
1777
+ * this vault carries >= 2 covering recipients (via the same envelope-recipients
1778
+ * read `Gitvault.access` uses), the single-principal premise behind
1779
+ * {@link gitvaultLossWarningMessage} is false for THIS vault — printing it
1780
+ * anyway would be a false terminal-loss claim. The composite threshold still
1781
+ * fires the SAME way (this function does not touch `gitvaultLossWarningTrip`/
1782
+ * `gitvaultLossWarningTripped` at all — a real value-at-risk signal is still
1783
+ * worth a reminder), but the copy switches to the protocol's own
1784
+ * keystore-qualified durability sentence instead of asserting single-principal
1785
+ * risk, and never claims terminal loss.
1786
+ */
1787
+ export function gitvaultKeystoreBackupReminderMessage(trip, coveringRecipients) {
1788
+ const reasons = gitvaultLossWarningReasons(trip);
1789
+ return (`this vault has accrued real value at risk (${reasons.join(", ")}), but it is covered by ${coveringRecipients} recipients today — not the single-principal case. ` +
1790
+ `${GITVAULT_DURABILITY_STATEMENT} Back up this machine's keystore anyway: losing it does not lose the vault, but it does lose YOUR access to it.`);
1791
+ }
1514
1792
  /** `run402::<org_id>/<project_id>` — what `git-remote-run402` resolves. */
1515
1793
  export function gitvaultRemoteUrl(orgId, projectId) {
1516
1794
  return `run402::${orgId}/${projectId}`;