run402 4.40.0 → 4.42.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 +7 -4
- package/cli.mjs +10 -2
- package/lib/argparse.mjs +1 -1
- package/lib/assets.mjs +10 -14
- package/lib/command-manifest.mjs +58 -49
- package/lib/deploy-v2.mjs +7 -10
- package/lib/doctor.mjs +12 -5
- package/lib/gitvault-scaffold.mjs +1 -1
- package/lib/gitvault-target.mjs +7 -9
- package/lib/gitvault.mjs +117 -1163
- package/lib/init.mjs +2 -2
- package/lib/notifications.mjs +4 -8
- package/lib/operator.mjs +1 -1
- package/lib/org.mjs +3 -3
- package/lib/repos.mjs +1261 -249
- package/lib/rooms-context.mjs +2 -3
- package/lib/rooms.mjs +11 -14
- package/lib/update-check.mjs +6 -8
- package/package.json +1 -1
- package/sdk/dist/errors.d.ts +5 -2
- package/sdk/dist/errors.d.ts.map +1 -1
- package/sdk/dist/errors.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 +10 -0
- package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.crypto.js +11 -1
- package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.d.ts +267 -31
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +299 -51
- package/sdk/dist/namespaces/gitvault.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 +1 -1
- package/sdk/dist/node/gitvault-apply.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-apply.js +6 -6
- package/sdk/dist/node/gitvault-apply.js.map +1 -1
- package/sdk/dist/node/gitvault-creation-journal.js +1 -1
- package/sdk/dist/node/gitvault-creation-journal.js.map +1 -1
- package/sdk/dist/node/gitvault-deploy.js +2 -2
- package/sdk/dist/node/gitvault-deploy.js.map +1 -1
- package/sdk/dist/node/gitvault-mirror-backend.js +1 -1
- package/sdk/dist/node/gitvault-mirror-backend.js.map +1 -1
- package/sdk/dist/node/gitvault-mirror.js +1 -1
- package/sdk/dist/node/gitvault-mirror.js.map +1 -1
- package/sdk/dist/node/gitvault-publication.d.ts +70 -20
- package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-publication.js +44 -17
- 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/scoped.d.ts +2 -2
- package/sdk/dist/scoped.js +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_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT, GITVAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT, GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT, GITVAULT_TERMINAL_LOSS_STATEMENT, } from "./gitvault.crypto.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)
|
|
@@ -97,8 +97,33 @@ export class Gitvault {
|
|
|
97
97
|
return this.#client.request(`/gitvault/v1/vaults?project_id=${encodeURIComponent(projectId)}`, { context: "resolving the project's gitvault" });
|
|
98
98
|
}
|
|
99
99
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
100
|
+
* Every vault the organization owns, one round trip — `repos list`'s bulk
|
|
101
|
+
* read. 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
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Resolve a vault by its address-form `org-slug/name` —
|
|
126
|
+
* `GET /gitvault/v1/vaults?repo=<org-slug>/<name>`.
|
|
102
127
|
* `RESOURCE_NOT_FOUND` for no such org OR no such name (deliberately
|
|
103
128
|
* collapsed — see the design's slug-namespace-probing note);
|
|
104
129
|
* `SLUG_RELEASED` (read it with {@link gitvaultSlugReleasedInfo}) while the
|
|
@@ -110,7 +135,7 @@ export class Gitvault {
|
|
|
110
135
|
}
|
|
111
136
|
/**
|
|
112
137
|
* Resolve a parsed remote address (`parseGitvaultRemoteUrl`'s output),
|
|
113
|
-
* dispatching on its form
|
|
138
|
+
* dispatching on its form: id-form resolves exactly like
|
|
114
139
|
* {@link forProject}; slug-form resolves via {@link forRepo}. A pure read —
|
|
115
140
|
* no pinning, no creation. Node-only callers wanting BOTH should use
|
|
116
141
|
* {@link resolveOrCreateAddress} instead, which also drives the local pin
|
|
@@ -275,9 +300,9 @@ export class Gitvault {
|
|
|
275
300
|
}
|
|
276
301
|
/**
|
|
277
302
|
* Resolve a parsed remote address to an OPEN handle, pinning `repo_id` in
|
|
278
|
-
* local git state on the first successful slug-form resolution
|
|
279
|
-
*
|
|
280
|
-
* push-to-create it
|
|
303
|
+
* local git state on the first successful slug-form resolution, and —
|
|
304
|
+
* when `allow_create` is set and resolution misses —
|
|
305
|
+
* push-to-create it. This is what the remote helper and
|
|
281
306
|
* `gitvault snapshot` drive for a `run402::<org-slug>/<name>` remote; an
|
|
282
307
|
* id-form remote resolves through here too (no pin, since it needs none)
|
|
283
308
|
* so a caller need not branch on the address's form itself.
|
|
@@ -519,6 +544,26 @@ export class Gitvault {
|
|
|
519
544
|
pending = [];
|
|
520
545
|
}
|
|
521
546
|
}
|
|
547
|
+
// Best-effort covering-recipient count (dogfood item 2): the same
|
|
548
|
+
// envelope-recipients read `access()` uses. Decides whether the V0-A
|
|
549
|
+
// single-principal terminal-loss statement is still honest to print for
|
|
550
|
+
// THIS vault, or whether this client has locally proven a second covering
|
|
551
|
+
// recipient. Only attempted when a vault record is already in hand
|
|
552
|
+
// (`record` non-null means the credentials that fetched it already work)
|
|
553
|
+
// — a session-less or offline caller never pays for a second failing
|
|
554
|
+
// request, and any failure here falls back to exactly today's
|
|
555
|
+
// single-principal behavior, never a new failure mode for `status`.
|
|
556
|
+
let coveringRecipients = null;
|
|
557
|
+
if (repoId && record) {
|
|
558
|
+
try {
|
|
559
|
+
const coverage = await this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}/envelope-recipients`, { context: "reading the gitvault envelope recipients" });
|
|
560
|
+
coveringRecipients = coverage.recipient_fingerprints.length;
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
coveringRecipients = null;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const isMultiPrincipal = coveringRecipients !== null && coveringRecipients >= 2;
|
|
522
567
|
const warnings = [...(record?.warnings ?? [])];
|
|
523
568
|
if (keystorePresent && !canSign) {
|
|
524
569
|
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" });
|
|
@@ -529,18 +574,28 @@ export class Gitvault {
|
|
|
529
574
|
// D7 (repo-first-onramp task 2.7): a vault that has accrued enough value
|
|
530
575
|
// at risk gets a STANDING warning instead of the one-time genesis note.
|
|
531
576
|
// `record` is `null` for an unallocated project, which is the ordinary
|
|
532
|
-
// "nothing to warn about" shape, not a gap in this check.
|
|
577
|
+
// "nothing to warn about" shape, not a gap in this check. Once this
|
|
578
|
+
// client has locally proven >= 2 covering recipients, the SAME threshold
|
|
579
|
+
// still fires — real value at risk still deserves a standing reminder —
|
|
580
|
+
// but the copy downgrades to `keystore_backup_reminder`, which never
|
|
581
|
+
// claims terminal loss (dogfood item 2: the single-principal premise
|
|
582
|
+
// behind `terminal_loss_risk` is provably false for this vault).
|
|
533
583
|
if (record) {
|
|
534
584
|
const trip = gitvaultLossWarningTrip(record);
|
|
535
585
|
if (gitvaultLossWarningTripped(trip)) {
|
|
536
|
-
|
|
586
|
+
if (isMultiPrincipal) {
|
|
587
|
+
warnings.push({ kind: "keystore_backup_reminder", message: gitvaultKeystoreBackupReminderMessage(trip, coveringRecipients) });
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
warnings.push({ kind: "terminal_loss_risk", message: gitvaultLossWarningMessage(trip) });
|
|
591
|
+
}
|
|
537
592
|
}
|
|
538
593
|
}
|
|
539
|
-
// The local id-pin, when there is a repository to read it from
|
|
540
|
-
//
|
|
594
|
+
// The local id-pin, when there is a repository to read it from — a pure
|
|
595
|
+
// read, same discipline as `remote` below. Read
|
|
541
596
|
// BEFORE `remote` so a slug-form remote's `matches` comparison can use
|
|
542
597
|
// it: the pin is the only LOCAL ground truth a slug-form address's own
|
|
543
|
-
// URL text does not carry
|
|
598
|
+
// URL text does not carry.
|
|
544
599
|
let pinned = null;
|
|
545
600
|
if (options.repo_dir) {
|
|
546
601
|
const { readPinnedGitvaultRepo } = await this.#address();
|
|
@@ -551,13 +606,10 @@ export class Gitvault {
|
|
|
551
606
|
// pure read: `status` must never write git configuration.
|
|
552
607
|
//
|
|
553
608
|
// Checks BOTH conventional names, `run402` first then `origin` —
|
|
554
|
-
// matching `scaffoldRemote`'s own naming
|
|
609
|
+
// matching `scaffoldRemote`'s own naming: it claims `origin`
|
|
555
610
|
// additively when the repository has none yet, falling back to `run402`
|
|
556
|
-
// only when `origin` is already taken by something else
|
|
557
|
-
// case is therefore an `origin` remote, not a `run402` one
|
|
558
|
-
// only "run402" (the pre-fix behavior) reported `remote: null` for most
|
|
559
|
-
// repositories even though a run402-form remote was sitting right there
|
|
560
|
-
// under `origin` (kychee-com/run402#559c). A name whose URL exists but
|
|
611
|
+
// only when `origin` is already taken by something else. The common
|
|
612
|
+
// case is therefore an `origin` remote, not a `run402` one. A name whose URL exists but
|
|
561
613
|
// does not parse as a run402 address (someone's own unrelated remote
|
|
562
614
|
// happening to be named "run402") is skipped rather than reported, so
|
|
563
615
|
// the other conventional name still gets a chance.
|
|
@@ -585,8 +637,8 @@ export class Gitvault {
|
|
|
585
637
|
}
|
|
586
638
|
else {
|
|
587
639
|
// Slug-form: the URL's second half is a repo NAME, not a project
|
|
588
|
-
// id — comparing it against `project` always
|
|
589
|
-
// a perfectly-configured remote
|
|
640
|
+
// id — comparing it against `project` would always mismatch, even for
|
|
641
|
+
// a perfectly-configured remote. The only
|
|
590
642
|
// LOCAL ground truth for a slug-form remote's real identity is the
|
|
591
643
|
// id-pin; without one there is nothing to compare against, and
|
|
592
644
|
// that absence is NOT evidence of a mismatch.
|
|
@@ -620,11 +672,11 @@ export class Gitvault {
|
|
|
620
672
|
// nothing; pointing at it here sent users to a command that silently did
|
|
621
673
|
// not do what this line promised (dogfood #1, finding A).
|
|
622
674
|
if (!record)
|
|
623
|
-
nextActions.push({ action: "allocate the project's vault", command: "run402
|
|
675
|
+
nextActions.push({ action: "allocate the project's vault", command: "run402 repos create --project <id>" });
|
|
624
676
|
else if (pending.length > 0)
|
|
625
|
-
nextActions.push({ action: `complete ${pending.length} unvaulted-override journal(s)`, command: "run402
|
|
677
|
+
nextActions.push({ action: `complete ${pending.length} unvaulted-override journal(s)`, command: "run402 repos snapshot" });
|
|
626
678
|
else if (record && !holdsRepoKey)
|
|
627
|
-
nextActions.push({ action: "this machine holds no key for the vault — allocate resolves to the existing vault and is idempotent", command: "run402
|
|
679
|
+
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>" });
|
|
628
680
|
return {
|
|
629
681
|
repo_id: repoId,
|
|
630
682
|
project_id: options.project_id ?? record?.project_id ?? null,
|
|
@@ -654,8 +706,10 @@ export class Gitvault {
|
|
|
654
706
|
pins: { highest_authenticated: authenticated, highest_materialized: materialized },
|
|
655
707
|
gitvault_policy: record?.gitvault_policy ?? null,
|
|
656
708
|
pending_overrides: pending.length,
|
|
657
|
-
|
|
658
|
-
|
|
709
|
+
covering_recipients: coveringRecipients,
|
|
710
|
+
terminal_loss_statement: isMultiPrincipal ? null : GITVAULT_TERMINAL_LOSS_STATEMENT,
|
|
711
|
+
terminal_loss_detail: isMultiPrincipal ? null : GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT,
|
|
712
|
+
durability_statement: isMultiPrincipal ? GITVAULT_DURABILITY_STATEMENT : null,
|
|
659
713
|
warnings,
|
|
660
714
|
next_actions: nextActions,
|
|
661
715
|
};
|
|
@@ -713,13 +767,12 @@ export class Gitvault {
|
|
|
713
767
|
...(options.checkpoint ? { checkpoint: true } : {}),
|
|
714
768
|
};
|
|
715
769
|
const result = await handle.vault.push(push);
|
|
716
|
-
// Capture-time dual-push hook
|
|
770
|
+
// Capture-time dual-push hook: fires only when a
|
|
717
771
|
// mirror is configured; NEVER throws, NEVER alters the vault outcome
|
|
718
772
|
// above (already returned/committed) — a mirror failure is a named
|
|
719
773
|
// pending finding reported BESIDE the vault result, on its own field.
|
|
720
774
|
const mirrorPush = await this.#tryMirrorPush(handle.repo_id, handle.keystore);
|
|
721
|
-
// Deploy-time reconcile hook
|
|
722
|
-
// gitvault-human-envelopes task 4.1): fires on every successful push,
|
|
775
|
+
// Deploy-time reconcile hook: fires on every successful push,
|
|
723
776
|
// best-effort — a reconcile failure (including a read-only principal
|
|
724
777
|
// with no signing key) is reported BESIDE the vault result, never a
|
|
725
778
|
// `push()` throw, same non-blocking contract as the mirror hook above.
|
|
@@ -952,7 +1005,7 @@ export class Gitvault {
|
|
|
952
1005
|
submitted: false,
|
|
953
1006
|
intent: null,
|
|
954
1007
|
confirmation: null,
|
|
955
|
-
note: "run `run402
|
|
1008
|
+
note: "run `run402 repos gc` to publish a checkpoint bound to a fresh retention_cutoff ticket, then plan the prune again.",
|
|
956
1009
|
};
|
|
957
1010
|
}
|
|
958
1011
|
const record = await handle.vault.transport.getVaultRecord({ repo_id: handle.repo_id });
|
|
@@ -1075,7 +1128,55 @@ export class Gitvault {
|
|
|
1075
1128
|
*/
|
|
1076
1129
|
async verify(options = {}) {
|
|
1077
1130
|
const handle = await this.open(options);
|
|
1078
|
-
return handle.vault.verifyToNewest();
|
|
1131
|
+
return handle.vault.verifyToNewest({ persist: options.persist ?? true });
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* `repos fsck` (repo-surface-consolidation D2/D3): walk the head chain
|
|
1135
|
+
* (what `verify()` did) AND materialize the ref map (what `status({refs:
|
|
1136
|
+
* true})` used to do before `--refs` was removed from `view`), reporting
|
|
1137
|
+
* BOTH local trust pins — authenticated and materialized — before and
|
|
1138
|
+
* after, with an explicit `local_state_changed` flag. This is the one
|
|
1139
|
+
* place chain materialization and pin advance live now; `view` never
|
|
1140
|
+
* calls it.
|
|
1141
|
+
*
|
|
1142
|
+
* `options.write` (default `true`) is the inverse of the CLI's
|
|
1143
|
+
* `--no-write`: `false` still walks and decrypts everything (the returned
|
|
1144
|
+
* `refs`/`verified_to_generation` are real, computed answers, not
|
|
1145
|
+
* estimates) but persists neither pin — a genuine audit mode, not a
|
|
1146
|
+
* simulation. `options.mirror` additionally runs the keyless mirror
|
|
1147
|
+
* verification ({@link mirrorVerify}) and folds its report in; that half
|
|
1148
|
+
* proves the mirror's validity, never its freshness (its own honesty
|
|
1149
|
+
* statements ride the result unchanged).
|
|
1150
|
+
*/
|
|
1151
|
+
async fsck(options = {}) {
|
|
1152
|
+
const before = await this.status(options);
|
|
1153
|
+
const repoId = before.repo_id;
|
|
1154
|
+
if (!repoId) {
|
|
1155
|
+
throw new LocalError(options.repo_id || options.project_id
|
|
1156
|
+
? "no vault is allocated for this project yet — nothing to fsck"
|
|
1157
|
+
: "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 } });
|
|
1158
|
+
}
|
|
1159
|
+
const write = options.write ?? true;
|
|
1160
|
+
const handle = await this.open({ ...options, repo_id: repoId });
|
|
1161
|
+
const state = await handle.vault.materialize({ persist: write });
|
|
1162
|
+
const mirror = options.mirror ? await this.mirrorVerify({ ...options, repo_id: repoId }) : null;
|
|
1163
|
+
const pinBefore = { highest_authenticated: before.pins.highest_authenticated, highest_materialized: before.pins.highest_materialized };
|
|
1164
|
+
const pinAfter = write
|
|
1165
|
+
? { highest_authenticated: state.generation, highest_materialized: state.generation }
|
|
1166
|
+
: pinBefore;
|
|
1167
|
+
const localStateChanged = write && (pinBefore.highest_authenticated !== pinAfter.highest_authenticated || pinBefore.highest_materialized !== pinAfter.highest_materialized);
|
|
1168
|
+
return {
|
|
1169
|
+
repo_id: repoId,
|
|
1170
|
+
write,
|
|
1171
|
+
verified_from_generation: pinBefore.highest_authenticated,
|
|
1172
|
+
verified_to_generation: state.generation,
|
|
1173
|
+
local_state_changed: localStateChanged,
|
|
1174
|
+
pin_before: pinBefore,
|
|
1175
|
+
pin_after: pinAfter,
|
|
1176
|
+
refs: { ...state.refs },
|
|
1177
|
+
head_target: state.head_target,
|
|
1178
|
+
mirror,
|
|
1179
|
+
};
|
|
1079
1180
|
}
|
|
1080
1181
|
/**
|
|
1081
1182
|
* Wrap this vault's current epoch key to every org member who has
|
|
@@ -1086,19 +1187,133 @@ export class Gitvault {
|
|
|
1086
1187
|
* gateway `public_key` gap, and why this is a workaround rather than the
|
|
1087
1188
|
* eventual epoch-rotation design).
|
|
1088
1189
|
*
|
|
1089
|
-
* `run402 gitvault reconcile
|
|
1090
|
-
*
|
|
1091
|
-
*
|
|
1092
|
-
*
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
1095
|
-
*
|
|
1096
|
-
*
|
|
1190
|
+
* `run402 gitvault reconcile` was this method's explicit standalone CLI
|
|
1191
|
+
* surface (design D5's "session start" hook); repo-surface-consolidation
|
|
1192
|
+
* D5/D7/D10 REMOVED it (no `repos` equivalent — `reconcile` is a
|
|
1193
|
+
* workaround, not a permanent verb) and it now answers `COMMAND_REMOVED`
|
|
1194
|
+
* pointing at `repos access` for inspection. This method itself is
|
|
1195
|
+
* unchanged and un-retired: `deploy()` still runs it, best-effort,
|
|
1196
|
+
* whenever a deploy lands a new generation in the vault — design D5's
|
|
1197
|
+
* "deploy time" hook, "the same 'one command every agent runs' argument
|
|
1198
|
+
* that decided deploy-implies-capture." `push()` (capture-and-publish
|
|
1199
|
+
* outside a deploy) runs the identical hook after every successful
|
|
1200
|
+
* publish, for the vault-only-project cadence. See
|
|
1201
|
+
* `#tryReconcileEnvelopeRecipients` below for both call sites, and
|
|
1202
|
+
* {@link Gitvault.access} for the READ half repo-surface-consolidation
|
|
1203
|
+
* ships in its place.
|
|
1097
1204
|
*/
|
|
1098
1205
|
async reconcileEnvelopeRecipients(options = {}) {
|
|
1099
1206
|
const handle = await this.open(options);
|
|
1100
1207
|
return handle.vault.reconcileEnvelopeRecipients();
|
|
1101
1208
|
}
|
|
1209
|
+
/**
|
|
1210
|
+
* `repos access` (repo-surface-consolidation D5/D10) — a READ-ONLY report
|
|
1211
|
+
* of who can open this vault, composed from whatever the live gateway
|
|
1212
|
+
* surface exposes today. Never wraps, never mutates a `key_envelope` —
|
|
1213
|
+
* that mutating half stays {@link reconcileEnvelopeRecipients}, reachable
|
|
1214
|
+
* only through the deploy/push best-effort hooks until `access repair`
|
|
1215
|
+
* ships (gated on `gitvault-human-envelopes`' epoch-rotation work). See
|
|
1216
|
+
* {@link GitvaultAccessResult}'s doc comment for the honest gap this
|
|
1217
|
+
* reports rather than invents.
|
|
1218
|
+
*/
|
|
1219
|
+
async access(options = {}) {
|
|
1220
|
+
const repoId = await this.#resolveRepoId(options);
|
|
1221
|
+
const record = await this.get(repoId).catch(() => null);
|
|
1222
|
+
const orgId = record?.org_id ?? null;
|
|
1223
|
+
const [directory, coverage] = await Promise.all([
|
|
1224
|
+
orgId
|
|
1225
|
+
? this.#client.request(`/orgs/v1/${encodeURIComponent(orgId)}/encryption-keys`, { context: "reading the org encryption-key directory" })
|
|
1226
|
+
: Promise.resolve({ org_id: "", keys: [] }),
|
|
1227
|
+
this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}/envelope-recipients`, { context: "reading the gitvault envelope recipients" }),
|
|
1228
|
+
]);
|
|
1229
|
+
const covered = new Set(coverage.recipient_fingerprints);
|
|
1230
|
+
// `desired` is OPTIONAL on the wire (see GitvaultEnvelopeRecipientsResponse):
|
|
1231
|
+
// absent means an older gateway that predates this field — genuinely
|
|
1232
|
+
// unknown, not "no desired recipients." Distinguish "absent" from
|
|
1233
|
+
// "present and empty" via the array itself, not a boolean flag, so a
|
|
1234
|
+
// null/undefined check is the single source of truth for both this
|
|
1235
|
+
// method and any future caller of the same wire type.
|
|
1236
|
+
const desiredList = coverage.desired ?? null;
|
|
1237
|
+
const envelopeStateAvailable = desiredList !== null;
|
|
1238
|
+
const desiredByPrincipal = new Map(desiredList?.map((d) => [d.principal_id, d]) ?? []);
|
|
1239
|
+
// Node-only, best-effort: THIS machine's own local TOFU pins, and this
|
|
1240
|
+
// keystore's own encryption-key fingerprint (used below to keep the
|
|
1241
|
+
// vault creator's own machine out of `unmatched_covered_fingerprints` —
|
|
1242
|
+
// the org directory only lists human-enrolled keys, so a wallet-principal
|
|
1243
|
+
// creator's fingerprint is legitimately absent from it, not orphaned).
|
|
1244
|
+
// Never fails the whole read — a browser/worker caller, or a machine that
|
|
1245
|
+
// has never wrapped anyone, simply reports every `tofu_pin` as `null` and
|
|
1246
|
+
// `this_keystore` as `null`.
|
|
1247
|
+
let pins = {};
|
|
1248
|
+
let ownFingerprint = null;
|
|
1249
|
+
try {
|
|
1250
|
+
const { GitvaultKeystore } = await this.#keystore();
|
|
1251
|
+
const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
|
|
1252
|
+
pins = keystore.readRepo(repoId)?.envelope_recipient_pins ?? {};
|
|
1253
|
+
ownFingerprint = keystore.readIdentity()?.encryption_fingerprint ?? null;
|
|
1254
|
+
}
|
|
1255
|
+
catch {
|
|
1256
|
+
// Not Node, or no local keystore for this repo — pins/ownFingerprint stay empty/null.
|
|
1257
|
+
}
|
|
1258
|
+
const recipients = directory.keys.map((k) => {
|
|
1259
|
+
const pinned = pins[k.principal_id];
|
|
1260
|
+
const desired = desiredByPrincipal.get(k.principal_id) ?? null;
|
|
1261
|
+
const envelopeState = desired == null ? null : desired.status === "pending_removal" ? "pending_removal" : covered.has(k.ek_fingerprint) ? "converged" : "pending";
|
|
1262
|
+
return {
|
|
1263
|
+
principal_id: k.principal_id,
|
|
1264
|
+
display_name: k.display_name,
|
|
1265
|
+
fingerprint: k.ek_fingerprint,
|
|
1266
|
+
covered: covered.has(k.ek_fingerprint),
|
|
1267
|
+
envelope_state: envelopeState,
|
|
1268
|
+
tofu_pin: pinned !== undefined ? { fingerprint: pinned, matches_directory: pinned === k.ek_fingerprint } : null,
|
|
1269
|
+
};
|
|
1270
|
+
});
|
|
1271
|
+
const directoryFingerprints = new Set(directory.keys.map((k) => k.ek_fingerprint));
|
|
1272
|
+
// Removed members (desired status pending_removal) whose fingerprint is
|
|
1273
|
+
// STILL covered: real, continuing access that membership removal did not
|
|
1274
|
+
// revoke, because gitvault v0 has no epoch-rotation mechanism yet. See
|
|
1275
|
+
// GitvaultAccessResult's doc comment.
|
|
1276
|
+
const staleAccess = (desiredList ?? [])
|
|
1277
|
+
.filter((d) => d.status === "pending_removal" && d.ek_fingerprint != null && covered.has(d.ek_fingerprint))
|
|
1278
|
+
.map((d) => ({ principal_id: d.principal_id, display_name: d.display_name, fingerprint: d.ek_fingerprint }));
|
|
1279
|
+
const staleAccessFingerprints = new Set(staleAccess.map((s) => s.fingerprint));
|
|
1280
|
+
// Genuinely unexplained: covering fingerprints that match neither a
|
|
1281
|
+
// current directory entry NOR a desired-state row (already broken out
|
|
1282
|
+
// above as stale_access) — orphaned, externally revoked, or a recipient
|
|
1283
|
+
// outside this org's membership model entirely. THIS keystore's own
|
|
1284
|
+
// fingerprint is a separate, locally-provable case: the org directory
|
|
1285
|
+
// only lists human-enrolled keys, so the vault creator's own
|
|
1286
|
+
// wallet-principal keystore legitimately never appears there — that is
|
|
1287
|
+
// not orphaned/external, it is provably this machine, so it is broken
|
|
1288
|
+
// out into `this_keystore` instead of left to read as a misconfiguration.
|
|
1289
|
+
const unmatchedRaw = coverage.recipient_fingerprints.filter((fp) => !directoryFingerprints.has(fp) && !staleAccessFingerprints.has(fp));
|
|
1290
|
+
const thisKeystore = ownFingerprint !== null && unmatchedRaw.includes(ownFingerprint) ? { fingerprint: ownFingerprint, covered: true } : null;
|
|
1291
|
+
const unmatched = thisKeystore ? unmatchedRaw.filter((fp) => fp !== thisKeystore.fingerprint) : unmatchedRaw;
|
|
1292
|
+
const gap = envelopeStateAvailable
|
|
1293
|
+
? "history_scope (which epochs each recipient can read) is not available: gitvault protocol v0 pins a single fixed epoch for a " +
|
|
1294
|
+
"vault's entire lifetime, so there is no per-epoch scope to report — that lands with the epoch-rotation protocol revision " +
|
|
1295
|
+
"gitvault-human-envelopes is mid-fold on, under adversarial review. envelope_state per recipient IS available today, from the " +
|
|
1296
|
+
`gateway's desired-recipient-state substrate (desired_state_version ${String(coverage.desired_state_version ?? "unknown")}): ` +
|
|
1297
|
+
"\"converged\" means desired and covered, \"pending\" means desired but not yet wrapped, \"pending_removal\" means membership " +
|
|
1298
|
+
"removed them but this vault has not been re-keyed away from them. pending_removal does NOT mean revoked — with covered:true " +
|
|
1299
|
+
"they still decrypt this vault's entire history, past and future, until epoch rotation ships; see stale_access for exactly who."
|
|
1300
|
+
: "the gateway did not report desired-recipient state (desired[]) for this vault_id — likely an older gateway than this SDK " +
|
|
1301
|
+
"expects, so per-recipient envelope_state and stale_access are unavailable. history_scope is unavailable regardless: gitvault " +
|
|
1302
|
+
"protocol v0 pins a single fixed epoch, so there is no per-epoch scope to report. This reports what the read surface has today: " +
|
|
1303
|
+
"the org's directory of encryption-key-holding members, which of the vault's current envelope-recipient fingerprints match a " +
|
|
1304
|
+
"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.";
|
|
1305
|
+
return {
|
|
1306
|
+
repo_id: repoId,
|
|
1307
|
+
org_id: orgId,
|
|
1308
|
+
recipients,
|
|
1309
|
+
unmatched_covered_fingerprints: unmatched,
|
|
1310
|
+
this_keystore: thisKeystore,
|
|
1311
|
+
stale_access: staleAccess,
|
|
1312
|
+
envelope_state_available: envelopeStateAvailable,
|
|
1313
|
+
history_scope_available: false,
|
|
1314
|
+
gap,
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1102
1317
|
/**
|
|
1103
1318
|
* The push-gated deploy: both lanes under one fresh `capture_id`, resolving
|
|
1104
1319
|
* to exactly one of five outcomes — `DEPLOYED_AND_VAULTED`,
|
|
@@ -1216,7 +1431,7 @@ export class Gitvault {
|
|
|
1216
1431
|
return {
|
|
1217
1432
|
...base, configured: true, destination: formatMirrorDestination(config.destination), credential_kind: config.credential?.kind ?? null,
|
|
1218
1433
|
mirrored_generation: mirroredGeneration, newest_generation: newestGeneration, is_current: isCurrent,
|
|
1219
|
-
closing_command: isCurrent === false ? "run402
|
|
1434
|
+
closing_command: isCurrent === false ? "run402 repos mirror --backfill" : null,
|
|
1220
1435
|
};
|
|
1221
1436
|
}
|
|
1222
1437
|
/** List the vault's stored objects, diff against the mirror, fetch + hash-verify + write what's missing, in admission order. Resumable and idempotent. */
|
|
@@ -1226,7 +1441,7 @@ export class Gitvault {
|
|
|
1226
1441
|
const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
|
|
1227
1442
|
return mirrorSync(this.#client, repoId, { keystore });
|
|
1228
1443
|
}
|
|
1229
|
-
/** Keyless integrity probe against the CONFIGURED mirror: discovery + chain verification + closure/absence adjudication, never decryption (`run402
|
|
1444
|
+
/** Keyless integrity probe against the CONFIGURED mirror: discovery + chain verification + closure/absence adjudication, never decryption (`run402 repos fsck --mirror`). */
|
|
1230
1445
|
async mirrorVerify(options = {}) {
|
|
1231
1446
|
const [{ GitvaultKeystore }, { readMirrorConfig }, { openGitvaultMirrorBackend }, { verifyGitvaultMirror }] = await Promise.all([
|
|
1232
1447
|
this.#keystore(), this.#mirrorConfig(), this.#mirrorBackend(), this.#recovery(),
|
|
@@ -1235,7 +1450,7 @@ export class Gitvault {
|
|
|
1235
1450
|
const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
|
|
1236
1451
|
const config = readMirrorConfig(keystore, repoId);
|
|
1237
1452
|
if (!config)
|
|
1238
|
-
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
|
|
1453
|
+
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>" }] });
|
|
1239
1454
|
const backend = openGitvaultMirrorBackend(config.destination, repoId, config.credential);
|
|
1240
1455
|
return verifyGitvaultMirror(backend, { keystore });
|
|
1241
1456
|
}
|
|
@@ -1336,10 +1551,16 @@ export const GITVAULT_LOSS_WARNING_THRESHOLDS = {
|
|
|
1336
1551
|
* strings (protocol convention for values that could exceed safe-integer
|
|
1337
1552
|
* precision in principle; comfortably within it here).
|
|
1338
1553
|
*
|
|
1339
|
-
* There is deliberately no companion "is this resolved" function
|
|
1340
|
-
*
|
|
1341
|
-
*
|
|
1342
|
-
*
|
|
1554
|
+
* There is deliberately no companion "is this resolved" function on the
|
|
1555
|
+
* TRIP computation itself: whether the generations/bytes/days thresholds
|
|
1556
|
+
* crossed is independent of how many principals cover the vault, and this
|
|
1557
|
+
* function stays a pure threshold check forever — a caller does not un-trip
|
|
1558
|
+
* it, ever, in V0. What DOES now exist, one call site up in `status()`, is a
|
|
1559
|
+
* way to downgrade the COPY once tripped: a locally-provable read of the
|
|
1560
|
+
* vault's covering-recipient count (the same envelope-recipients read
|
|
1561
|
+
* `Gitvault.access` uses) can show this is no longer the single-principal
|
|
1562
|
+
* case the message below describes. See
|
|
1563
|
+
* {@link gitvaultKeystoreBackupReminderMessage} for that downgraded form.
|
|
1343
1564
|
*/
|
|
1344
1565
|
export function gitvaultLossWarningTrip(record, now = new Date()) {
|
|
1345
1566
|
const generations = Number(record.admitted_generations ?? "0");
|
|
@@ -1356,12 +1577,8 @@ export function gitvaultLossWarningTrip(record, now = new Date()) {
|
|
|
1356
1577
|
export function gitvaultLossWarningTripped(trip) {
|
|
1357
1578
|
return trip.generations || trip.source_bytes || trip.days_since_genesis;
|
|
1358
1579
|
}
|
|
1359
|
-
/**
|
|
1360
|
-
|
|
1361
|
-
* resolution honestly (a second principal or human envelope — nothing this
|
|
1362
|
-
* client can verify yet), and never claims an attestation would clear it.
|
|
1363
|
-
*/
|
|
1364
|
-
export function gitvaultLossWarningMessage(trip) {
|
|
1580
|
+
/** Shared by both D7 message forms below — which threshold(s) tripped, in prose. */
|
|
1581
|
+
function gitvaultLossWarningReasons(trip) {
|
|
1365
1582
|
const reasons = [];
|
|
1366
1583
|
if (trip.generations)
|
|
1367
1584
|
reasons.push(`≥${GITVAULT_LOSS_WARNING_THRESHOLDS.generations} generations`);
|
|
@@ -1369,10 +1586,41 @@ export function gitvaultLossWarningMessage(trip) {
|
|
|
1369
1586
|
reasons.push(`≥${Math.round(GITVAULT_LOSS_WARNING_THRESHOLDS.source_bytes / (1024 * 1024))} MB of source`);
|
|
1370
1587
|
if (trip.days_since_genesis)
|
|
1371
1588
|
reasons.push(`≥${GITVAULT_LOSS_WARNING_THRESHOLDS.days_since_genesis} days since genesis`);
|
|
1589
|
+
return reasons;
|
|
1590
|
+
}
|
|
1591
|
+
/**
|
|
1592
|
+
* The standing warning text (design D7): names what tripped, states the
|
|
1593
|
+
* resolution honestly (a second principal or human envelope — nothing this
|
|
1594
|
+
* client can verify yet), and never claims an attestation would clear it.
|
|
1595
|
+
*
|
|
1596
|
+
* This is the SINGLE-PRINCIPAL form — use it only when the vault's
|
|
1597
|
+
* covering-recipient count is unknown or <= 1. Once a caller has locally
|
|
1598
|
+
* proven >= 2 covering recipients, its premise ("only one principal can open
|
|
1599
|
+
* it") is false; use {@link gitvaultKeystoreBackupReminderMessage} instead.
|
|
1600
|
+
*/
|
|
1601
|
+
export function gitvaultLossWarningMessage(trip) {
|
|
1602
|
+
const reasons = gitvaultLossWarningReasons(trip);
|
|
1372
1603
|
return (`this vault has accrued real value at risk (${reasons.join(", ")}) while only one principal can open it. ` +
|
|
1373
1604
|
"Only a second principal — another keystore, or later a human envelope — demonstrably able to open the vault clears this warning; " +
|
|
1374
1605
|
"this client cannot verify that yet, so it stands until you add one. No attestation or flag clears it.");
|
|
1375
1606
|
}
|
|
1607
|
+
/**
|
|
1608
|
+
* D7's downgraded form (dogfood item 2): once `status()` has locally proven
|
|
1609
|
+
* this vault carries >= 2 covering recipients (via the same envelope-recipients
|
|
1610
|
+
* read `Gitvault.access` uses), the single-principal premise behind
|
|
1611
|
+
* {@link gitvaultLossWarningMessage} is false for THIS vault — printing it
|
|
1612
|
+
* anyway would be a false terminal-loss claim. The composite threshold still
|
|
1613
|
+
* fires the SAME way (this function does not touch `gitvaultLossWarningTrip`/
|
|
1614
|
+
* `gitvaultLossWarningTripped` at all — a real value-at-risk signal is still
|
|
1615
|
+
* worth a reminder), but the copy switches to the protocol's own
|
|
1616
|
+
* keystore-qualified durability sentence instead of asserting single-principal
|
|
1617
|
+
* risk, and never claims terminal loss.
|
|
1618
|
+
*/
|
|
1619
|
+
export function gitvaultKeystoreBackupReminderMessage(trip, coveringRecipients) {
|
|
1620
|
+
const reasons = gitvaultLossWarningReasons(trip);
|
|
1621
|
+
return (`this vault has accrued real value at risk (${reasons.join(", ")}), but it is covered by ${coveringRecipients} recipients today — not the single-principal case. ` +
|
|
1622
|
+
`${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.`);
|
|
1623
|
+
}
|
|
1376
1624
|
/** `run402::<org_id>/<project_id>` — what `git-remote-run402` resolves. */
|
|
1377
1625
|
export function gitvaultRemoteUrl(orgId, projectId) {
|
|
1378
1626
|
return `run402::${orgId}/${projectId}`;
|