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.
- package/README.md +2 -2
- package/lib/argparse.mjs +1 -1
- package/lib/assets.mjs +10 -14
- package/lib/command-manifest.mjs +28 -27
- package/lib/deploy-v2.mjs +34 -14
- package/lib/doctor.mjs +8 -1
- package/lib/gitvault-target.mjs +7 -9
- package/lib/gitvault.mjs +7 -8
- package/lib/next-actions.mjs +5 -2
- package/lib/notifications.mjs +4 -8
- package/lib/operator.mjs +1 -1
- package/lib/org.mjs +3 -3
- package/lib/repos.mjs +536 -164
- package/lib/rooms-context.mjs +2 -3
- package/lib/rooms.mjs +11 -14
- package/lib/stats.mjs +57 -0
- package/lib/update-check.mjs +6 -8
- package/package.json +1 -1
- package/sdk/dist/index.d.ts +10 -1
- package/sdk/dist/index.d.ts.map +1 -1
- package/sdk/dist/index.js +11 -0
- package/sdk/dist/index.js.map +1 -1
- package/sdk/dist/kernel.d.ts +31 -0
- package/sdk/dist/kernel.d.ts.map +1 -1
- package/sdk/dist/kernel.js +116 -3
- package/sdk/dist/kernel.js.map +1 -1
- package/sdk/dist/namespaces/admin.d.ts +1 -1
- package/sdk/dist/namespaces/admin.js +1 -1
- package/sdk/dist/namespaces/deploy.types.d.ts +2 -2
- package/sdk/dist/namespaces/gitvault.crypto.d.ts +188 -5
- package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.crypto.js +277 -8
- package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.d.ts +239 -44
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +327 -49
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.types.d.ts +184 -9
- package/sdk/dist/namespaces/gitvault.types.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.types.js +2 -1
- package/sdk/dist/namespaces/gitvault.types.js.map +1 -1
- package/sdk/dist/namespaces/projects.d.ts +1 -1
- package/sdk/dist/namespaces/projects.js +1 -1
- package/sdk/dist/namespaces/projects.types.d.ts +1 -1
- package/sdk/dist/namespaces/projects.types.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-apply.d.ts +8 -0
- package/sdk/dist/node/gitvault-apply.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-apply.js +1 -0
- package/sdk/dist/node/gitvault-apply.js.map +1 -1
- package/sdk/dist/node/gitvault-deploy.d.ts +13 -0
- package/sdk/dist/node/gitvault-deploy.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-deploy.js +28 -0
- package/sdk/dist/node/gitvault-deploy.js.map +1 -1
- package/sdk/dist/node/gitvault-keystore.d.ts +54 -4
- package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-keystore.js +20 -0
- package/sdk/dist/node/gitvault-keystore.js.map +1 -1
- package/sdk/dist/node/gitvault-publication.d.ts +376 -21
- package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-publication.js +567 -19
- package/sdk/dist/node/gitvault-publication.js.map +1 -1
- package/sdk/dist/node/gitvault-recover.d.ts +15 -1
- package/sdk/dist/node/gitvault-recover.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-recover.js +8 -4
- package/sdk/dist/node/gitvault-recover.js.map +1 -1
- package/sdk/dist/node/gitvault-snapshot.d.ts +40 -7
- package/sdk/dist/node/gitvault-snapshot.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-snapshot.js +67 -19
- package/sdk/dist/node/gitvault-snapshot.js.map +1 -1
- package/sdk/dist/scoped.d.ts +2 -2
- 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
|
|
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`
|
|
126
|
-
*
|
|
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
|
|
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
|
|
304
|
-
*
|
|
305
|
-
* push-to-create it
|
|
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
|
-
|
|
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
|
|
565
|
-
//
|
|
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
|
|
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
|
|
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
|
|
582
|
-
// case is therefore an `origin` remote, not a `run402` one
|
|
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
|
|
614
|
-
// a perfectly-configured remote
|
|
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
|
-
|
|
683
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1203
|
-
//
|
|
1204
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1470
|
+
this_keystore: thisKeystore,
|
|
1471
|
+
stale_access: staleAccess,
|
|
1472
|
+
envelope_state_available: envelopeStateAvailable,
|
|
1232
1473
|
history_scope_available: false,
|
|
1233
|
-
gap
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
|
1478
|
-
*
|
|
1479
|
-
*
|
|
1480
|
-
*
|
|
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
|
-
|
|
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}`;
|