run402 4.24.1 → 4.26.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/cli.mjs +6 -6
- package/git-remote-run402.mjs +328 -0
- package/lib/command-manifest.mjs +13 -1
- package/lib/credentials.mjs +4 -0
- package/lib/deploy-v2.mjs +78 -1
- package/lib/doctor.mjs +80 -1
- package/lib/domains.mjs +0 -14
- package/lib/gitvault.mjs +582 -0
- package/lib/init.mjs +153 -5
- package/lib/org-context.mjs +44 -1
- package/lib/pay.test.mjs +0 -4
- package/package.json +8 -2
- package/sdk/dist/index.d.ts +20 -3
- package/sdk/dist/index.d.ts.map +1 -1
- package/sdk/dist/index.js +22 -3
- package/sdk/dist/index.js.map +1 -1
- package/sdk/dist/namespaces/admin.d.ts +0 -5
- package/sdk/dist/namespaces/admin.d.ts.map +1 -1
- package/sdk/dist/namespaces/admin.js +0 -9
- package/sdk/dist/namespaces/admin.js.map +1 -1
- package/sdk/dist/namespaces/assets.d.ts +1 -20
- package/sdk/dist/namespaces/assets.d.ts.map +1 -1
- package/sdk/dist/namespaces/assets.js +0 -27
- package/sdk/dist/namespaces/assets.js.map +1 -1
- package/sdk/dist/namespaces/domains.d.ts +0 -19
- package/sdk/dist/namespaces/domains.d.ts.map +1 -1
- package/sdk/dist/namespaces/domains.js +0 -19
- package/sdk/dist/namespaces/domains.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.crypto.d.ts +319 -0
- package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -0
- package/sdk/dist/namespaces/gitvault.crypto.js +690 -0
- package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -0
- package/sdk/dist/namespaces/gitvault.d.ts +521 -0
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -0
- package/sdk/dist/namespaces/gitvault.js +786 -0
- package/sdk/dist/namespaces/gitvault.js.map +1 -0
- package/sdk/dist/namespaces/gitvault.types.d.ts +481 -0
- package/sdk/dist/namespaces/gitvault.types.d.ts.map +1 -0
- package/sdk/dist/namespaces/gitvault.types.js +19 -0
- package/sdk/dist/namespaces/gitvault.types.js.map +1 -0
- package/sdk/dist/namespaces/org.d.ts +1 -3
- package/sdk/dist/namespaces/org.d.ts.map +1 -1
- package/sdk/dist/namespaces/org.js +6 -10
- package/sdk/dist/namespaces/org.js.map +1 -1
- package/sdk/dist/namespaces/pay.d.ts +0 -8
- package/sdk/dist/namespaces/pay.d.ts.map +1 -1
- package/sdk/dist/namespaces/pay.js.map +1 -1
- package/sdk/dist/namespaces/projects.d.ts +2 -2
- package/sdk/dist/namespaces/projects.d.ts.map +1 -1
- package/sdk/dist/namespaces/projects.js +4 -12
- package/sdk/dist/namespaces/projects.js.map +1 -1
- package/sdk/dist/namespaces/secrets.d.ts +0 -2
- package/sdk/dist/namespaces/secrets.d.ts.map +1 -1
- package/sdk/dist/namespaces/secrets.js +3 -10
- package/sdk/dist/namespaces/secrets.js.map +1 -1
- package/sdk/dist/namespaces/subdomains.d.ts +0 -2
- package/sdk/dist/namespaces/subdomains.d.ts.map +1 -1
- package/sdk/dist/namespaces/subdomains.js +5 -16
- package/sdk/dist/namespaces/subdomains.js.map +1 -1
- package/sdk/dist/namespaces/transfers.d.ts +0 -2
- package/sdk/dist/namespaces/transfers.d.ts.map +1 -1
- package/sdk/dist/namespaces/transfers.js +8 -10
- package/sdk/dist/namespaces/transfers.js.map +1 -1
- package/sdk/dist/namespaces/wallets.d.ts +0 -12
- package/sdk/dist/namespaces/wallets.d.ts.map +1 -1
- package/sdk/dist/namespaces/wallets.js +0 -14
- package/sdk/dist/namespaces/wallets.js.map +1 -1
- package/sdk/dist/node/gitvault-creation-journal.d.ts +222 -0
- package/sdk/dist/node/gitvault-creation-journal.d.ts.map +1 -0
- package/sdk/dist/node/gitvault-creation-journal.js +365 -0
- package/sdk/dist/node/gitvault-creation-journal.js.map +1 -0
- package/sdk/dist/node/gitvault-deploy.d.ts +211 -0
- package/sdk/dist/node/gitvault-deploy.d.ts.map +1 -0
- package/sdk/dist/node/gitvault-deploy.js +292 -0
- package/sdk/dist/node/gitvault-deploy.js.map +1 -0
- package/sdk/dist/node/gitvault-keystore.d.ts +207 -0
- package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -0
- package/sdk/dist/node/gitvault-keystore.js +529 -0
- package/sdk/dist/node/gitvault-keystore.js.map +1 -0
- package/sdk/dist/node/gitvault-prune.d.ts +341 -0
- package/sdk/dist/node/gitvault-prune.d.ts.map +1 -0
- package/sdk/dist/node/gitvault-prune.js +410 -0
- package/sdk/dist/node/gitvault-prune.js.map +1 -0
- package/sdk/dist/node/gitvault-publication.d.ts +730 -0
- package/sdk/dist/node/gitvault-publication.d.ts.map +1 -0
- package/sdk/dist/node/gitvault-publication.js +1475 -0
- package/sdk/dist/node/gitvault-publication.js.map +1 -0
- package/sdk/dist/node/gitvault-snapshot.d.ts +227 -0
- package/sdk/dist/node/gitvault-snapshot.d.ts.map +1 -0
- package/sdk/dist/node/gitvault-snapshot.js +556 -0
- package/sdk/dist/node/gitvault-snapshot.js.map +1 -0
- package/sdk/dist/node/index.d.ts +11 -0
- package/sdk/dist/node/index.d.ts.map +1 -1
- package/sdk/dist/node/index.js +9 -0
- package/sdk/dist/node/index.js.map +1 -1
- package/sdk/dist/node/paid-fetch.d.ts.map +1 -1
- package/sdk/dist/node/paid-fetch.js +0 -4
- package/sdk/dist/node/paid-fetch.js.map +1 -1
- package/sdk/dist/node/sites-node.d.ts +1 -8
- package/sdk/dist/node/sites-node.d.ts.map +1 -1
- package/sdk/dist/node/sites-node.js +1 -7
- package/sdk/dist/node/sites-node.js.map +1 -1
- package/sdk/dist/scoped.d.ts +4 -35
- package/sdk/dist/scoped.d.ts.map +1 -1
- package/sdk/dist/scoped.js +9 -74
- package/sdk/dist/scoped.js.map +1 -1
- package/lib/sender-domain.mjs +0 -36
- package/sdk/dist/deprecate.d.ts +0 -23
- package/sdk/dist/deprecate.d.ts.map +0 -1
- package/sdk/dist/deprecate.js +0 -42
- package/sdk/dist/deprecate.js.map +0 -1
- package/sdk/dist/namespaces/sender-domain.d.ts +0 -47
- package/sdk/dist/namespaces/sender-domain.d.ts.map +0 -1
- package/sdk/dist/namespaces/sender-domain.js +0 -45
- package/sdk/dist/namespaces/sender-domain.js.map +0 -1
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `r.gitvault` — the host-blind encrypted Git remote, as the SDK's programmatic
|
|
3
|
+
* API (add-gitvault task 5.10).
|
|
4
|
+
*
|
|
5
|
+
* ARCHITECTURAL LAW (client-surface spec, "All protocol logic lives in the
|
|
6
|
+
* SDK"): every piece of vault protocol behaviour — crypto core, keystore,
|
|
7
|
+
* creation journal, snapshot + capture, publication state machines, ref
|
|
8
|
+
* transactions, verification budget, token exchange, repair — is implemented
|
|
9
|
+
* ONCE here. `run402 gitvault …`, `git-remote-run402`, and the MCP tools are
|
|
10
|
+
* adapters over this namespace: argument parsing, TTY output, exit codes, and
|
|
11
|
+
* local file I/O only. Anything the CLI can do is reachable programmatically
|
|
12
|
+
* with identical semantics.
|
|
13
|
+
*
|
|
14
|
+
* `r402s-verify` is the deliberate exception: an independent second lineage
|
|
15
|
+
* that must NOT share implementation code with this namespace, because
|
|
16
|
+
* differential verification is its entire purpose.
|
|
17
|
+
*
|
|
18
|
+
* ISOMORPHIC / NODE SPLIT. Vault reads (the record, heads listing, policy,
|
|
19
|
+
* override completion) need nothing but the HTTP client and run anywhere. The
|
|
20
|
+
* verbs that touch a git working tree or the on-disk keystore — `init`,
|
|
21
|
+
* `push`, `compact`, `verify`, `deploy`, `restore` — are Node-only and are
|
|
22
|
+
* reached through DYNAMIC imports of `../node/*`, so importing `@run402/sdk`
|
|
23
|
+
* in a browser or worker never pulls `node:fs` into the graph.
|
|
24
|
+
*
|
|
25
|
+
* CACHING. Nothing here is memoised. Two of these responses are
|
|
26
|
+
* secret-bearing — the maintenance lease's `holder_token` (returned exactly
|
|
27
|
+
* once) and anything derived from the keystore — and per
|
|
28
|
+
* docs/agent-response-design.md a secret-bearing response is never cached,
|
|
29
|
+
* never persisted into an agent-surface result store, and never logged.
|
|
30
|
+
*/
|
|
31
|
+
import { LocalError } from "../errors.js";
|
|
32
|
+
import { GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT, GITVAULT_TERMINAL_LOSS_STATEMENT } from "./gitvault.crypto.js";
|
|
33
|
+
/** A keystore path, or `null` when there is no id to derive it from (or it is malformed). */
|
|
34
|
+
function safePath(derive, repoId) {
|
|
35
|
+
if (!repoId)
|
|
36
|
+
return null;
|
|
37
|
+
try {
|
|
38
|
+
return derive();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function nodeOnly(load, verb) {
|
|
45
|
+
try {
|
|
46
|
+
return await load();
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
throw new LocalError(`\`r.gitvault.${verb}\` needs the Node runtime (keystore + git); import it from a Node process, or use the read-only vault methods in a browser.`, `running gitvault ${verb}`, { code: "GITVAULT_NODE_ONLY", details: { cause: e instanceof Error ? e.message : String(e) } });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// ─── The namespace ───────────────────────────────────────────────────────────
|
|
53
|
+
export class Gitvault {
|
|
54
|
+
#client;
|
|
55
|
+
constructor(client) {
|
|
56
|
+
this.#client = client;
|
|
57
|
+
}
|
|
58
|
+
// ── Control-plane reads (isomorphic) ──────────────────────────────────────
|
|
59
|
+
/** The vault record — policy, allocation generation, storage + maintenance state. */
|
|
60
|
+
async get(repoId) {
|
|
61
|
+
return this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}`, { context: "reading the gitvault record" });
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolve a project's vault with no local state. This is the cold-restart
|
|
65
|
+
* entry point: an agent that lost its machine but still holds authority on
|
|
66
|
+
* the project learns its `repo_id` here.
|
|
67
|
+
*/
|
|
68
|
+
async forProject(projectId) {
|
|
69
|
+
return this.#client.request(`/gitvault/v1/vaults?project_id=${encodeURIComponent(projectId)}`, { context: "resolving the project's gitvault" });
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* One page of the heads listing (D186).
|
|
73
|
+
*
|
|
74
|
+
* `after_generation` is the REQUIRED verification anchor — a semantic input,
|
|
75
|
+
* never a paging knob — and must stay CONSTANT across a page sequence.
|
|
76
|
+
* `limit` is required. `cursor` is omitted on the first request and is then
|
|
77
|
+
* the prior page's `next_cursor` echoed UNCHANGED: store and echo, never
|
|
78
|
+
* parse. A malformed or stale cursor is `INVALID_CURSOR`; recover by
|
|
79
|
+
* restarting from `after_generation` with no cursor.
|
|
80
|
+
*/
|
|
81
|
+
async heads(repoId, request) {
|
|
82
|
+
const { validateHeadsListingRequest } = await this.#publication();
|
|
83
|
+
validateHeadsListingRequest(request);
|
|
84
|
+
const qs = new URLSearchParams({ after_generation: request.after_generation, limit: request.limit });
|
|
85
|
+
if (request.cursor !== undefined)
|
|
86
|
+
qs.set("cursor", request.cursor);
|
|
87
|
+
return this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}/heads?${qs.toString()}`, { context: "listing gitvault heads" });
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Walk the whole listing above the anchor, verifying page coupling, ordering,
|
|
91
|
+
* and gaplessness as it goes (a gap is `CHAIN_BROKEN`, never a silent skip).
|
|
92
|
+
*/
|
|
93
|
+
async allHeads(repoId, options) {
|
|
94
|
+
const { verifyHeadsListingPage, nextListingRequest } = await this.#publication();
|
|
95
|
+
let request = { after_generation: options.after_generation, limit: options.limit ?? "1000" };
|
|
96
|
+
let progress = { after_generation: options.after_generation, last_generation: options.after_generation, delivered: 0 };
|
|
97
|
+
const heads = [];
|
|
98
|
+
let pages = 0;
|
|
99
|
+
let total = null;
|
|
100
|
+
while (request) {
|
|
101
|
+
const page = await this.heads(repoId, request);
|
|
102
|
+
progress = verifyHeadsListingPage(page, request, progress, repoId);
|
|
103
|
+
heads.push(...page.heads);
|
|
104
|
+
total = page.total;
|
|
105
|
+
pages += 1;
|
|
106
|
+
request = nextListingRequest(request, page);
|
|
107
|
+
}
|
|
108
|
+
return { heads, pages, total };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Set the activation policy. Owner + step-up, audited, reason required.
|
|
112
|
+
* `grandfathered` leaves a doctor-persistent warning until it returns to
|
|
113
|
+
* `required`.
|
|
114
|
+
*/
|
|
115
|
+
async setPolicy(repoId, input) {
|
|
116
|
+
return this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}/policy`, {
|
|
117
|
+
method: "PATCH",
|
|
118
|
+
body: { gitvault_policy: input.gitvault_policy, ...(input.reason !== undefined ? { reason: input.reason } : {}) },
|
|
119
|
+
context: "setting the gitvault activation policy",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Clear an unvaulted-override advisory by presenting a capture receipt that
|
|
124
|
+
* matches the journaled operation on EVERY field. A partial match never
|
|
125
|
+
* clears it.
|
|
126
|
+
*/
|
|
127
|
+
async completeOverride(repoId, input) {
|
|
128
|
+
return this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}/override-completions`, { method: "POST", body: input, context: "submitting the gitvault override completion" });
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Take the owner's maintenance reservation for a compact/prune cycle.
|
|
132
|
+
*
|
|
133
|
+
* SECRET-BEARING: `holder_token` is returned exactly ONCE and is the liveness
|
|
134
|
+
* instrument for heartbeat/release. Never log it, never cache it, never place
|
|
135
|
+
* it in an agent-surface result store.
|
|
136
|
+
*/
|
|
137
|
+
async acquireMaintenanceLease(request) {
|
|
138
|
+
const { repo_id, ...body } = request;
|
|
139
|
+
return this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repo_id)}/maintenance-leases`, {
|
|
140
|
+
method: "POST",
|
|
141
|
+
body: {
|
|
142
|
+
base_head_sha256: body.base_head_sha256,
|
|
143
|
+
current_checkpoint_hash: body.current_checkpoint_hash ?? null,
|
|
144
|
+
r1_size_bytes: body.r1_size_bytes,
|
|
145
|
+
r2_cap_size_bytes: body.r2_cap_size_bytes,
|
|
146
|
+
p_before_c1_size_bytes: body.p_before_c1_size_bytes ?? "0",
|
|
147
|
+
p_before_c2_size_bytes: body.p_before_c2_size_bytes ?? "0",
|
|
148
|
+
},
|
|
149
|
+
context: "acquiring the gitvault maintenance lease",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
// ── Node-only verbs ───────────────────────────────────────────────────────
|
|
153
|
+
/**
|
|
154
|
+
* Open the vault: keystore + HTTP transport + the protocol object. Every verb
|
|
155
|
+
* below builds on this; call it directly when you need the raw protocol
|
|
156
|
+
* surface (ref transactions, repair, checkpoint building).
|
|
157
|
+
*/
|
|
158
|
+
async open(options = {}) {
|
|
159
|
+
const [{ GitvaultVault, createGitvaultHttpTransport }, { GitvaultKeystore }] = await Promise.all([this.#publication(), this.#keystore()]);
|
|
160
|
+
const repoId = await this.#resolveRepoId(options);
|
|
161
|
+
const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
|
|
162
|
+
const transport = createGitvaultHttpTransport(this.#client);
|
|
163
|
+
const vault = new GitvaultVault({
|
|
164
|
+
keystore,
|
|
165
|
+
transport,
|
|
166
|
+
repo_id: repoId,
|
|
167
|
+
...(options.repo_dir !== undefined ? { repo_dir: options.repo_dir } : {}),
|
|
168
|
+
...(options.verification_budget !== undefined ? { verification_budget: options.verification_budget } : {}),
|
|
169
|
+
...(options.service_public_key !== undefined ? { service_public_key: options.service_public_key } : {}),
|
|
170
|
+
});
|
|
171
|
+
return { repo_id: repoId, keystore, transport, vault };
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Create the vault for a project and scaffold the git remote.
|
|
175
|
+
*
|
|
176
|
+
* Runs the six-stage creation journal — `LOCAL_KEYS_PREPARED → ALLOCATED →
|
|
177
|
+
* OBJECTS_PREPARED → OBJECTS_FINALIZED → GENESIS_PREPARED → ACTIVE`, each
|
|
178
|
+
* fsynced before the next step, with no ciphertext existing before allocation
|
|
179
|
+
* supplies the `repo_id` the key derivation needs. Crash-safe and resumable
|
|
180
|
+
* on `client_creation_id`.
|
|
181
|
+
*
|
|
182
|
+
* An existing `origin` remote is never modified or claimed; only a remote
|
|
183
|
+
* named `run402` is added.
|
|
184
|
+
*/
|
|
185
|
+
async init(options) {
|
|
186
|
+
const [{ createGitvaultHttpTransport }, { GitvaultKeystore }, { createGitvault }] = await Promise.all([this.#publication(), this.#keystore(), this.#creation()]);
|
|
187
|
+
const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
|
|
188
|
+
const created = await createGitvault({
|
|
189
|
+
keystore,
|
|
190
|
+
transport: createGitvaultHttpTransport(this.#client),
|
|
191
|
+
org_id: options.org_id,
|
|
192
|
+
project_id: options.project_id,
|
|
193
|
+
...(options.client_creation_id !== undefined ? { client_creation_id: options.client_creation_id } : {}),
|
|
194
|
+
...(options.service_public_key !== undefined ? { service_public_key: options.service_public_key } : {}),
|
|
195
|
+
});
|
|
196
|
+
let remote = null;
|
|
197
|
+
if (options.scaffold_git !== false && options.repo_dir) {
|
|
198
|
+
remote = await this.scaffoldRemote({
|
|
199
|
+
repo_dir: options.repo_dir,
|
|
200
|
+
org_id: options.org_id,
|
|
201
|
+
project_id: options.project_id,
|
|
202
|
+
...(options.remote_name !== undefined ? { remote_name: options.remote_name } : {}),
|
|
203
|
+
...(options.remote_url !== undefined ? { remote_url: options.remote_url } : {}),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
repo_id: created.repo_id,
|
|
208
|
+
project_id: options.project_id,
|
|
209
|
+
recovery_receipt: created.recovery_receipt,
|
|
210
|
+
genesis_sha256: created.genesis_sha256,
|
|
211
|
+
remote,
|
|
212
|
+
deduplicated: created.how === "reconciled",
|
|
213
|
+
terminal_loss_statement: GITVAULT_TERMINAL_LOSS_STATEMENT,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Add the `run402` git remote, initialising the repository if absent.
|
|
218
|
+
*
|
|
219
|
+
* Deliberately additive: an existing `origin` is never modified or claimed,
|
|
220
|
+
* and an existing `run402` remote pointing elsewhere is reported rather than
|
|
221
|
+
* silently rewritten. No key material or allocation is required — the
|
|
222
|
+
* cold-start path gains no prompts or network dependencies from this.
|
|
223
|
+
*/
|
|
224
|
+
async scaffoldRemote(options) {
|
|
225
|
+
const { hardenedGit } = await this.#snapshot();
|
|
226
|
+
const name = options.remote_name ?? "run402";
|
|
227
|
+
const url = options.remote_url ?? gitvaultRemoteUrl(options.org_id, options.project_id);
|
|
228
|
+
let createdRepository = false;
|
|
229
|
+
try {
|
|
230
|
+
await hardenedGit(options.repo_dir, ["rev-parse", "--git-dir"]);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
await hardenedGit(options.repo_dir, ["init"]);
|
|
234
|
+
createdRepository = true;
|
|
235
|
+
}
|
|
236
|
+
let existing = null;
|
|
237
|
+
try {
|
|
238
|
+
existing = (await hardenedGit(options.repo_dir, ["remote", "get-url", name])).text().trim();
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
existing = null;
|
|
242
|
+
}
|
|
243
|
+
if (existing)
|
|
244
|
+
return { name, url, created_repository: createdRepository, already_present: true, existing_url: existing };
|
|
245
|
+
await hardenedGit(options.repo_dir, ["remote", "add", name, url]);
|
|
246
|
+
return { name, url, created_repository: createdRepository, already_present: false, existing_url: null };
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* What this machine and the control plane each believe about the vault.
|
|
250
|
+
*
|
|
251
|
+
* Truthful for a VAULT-ONLY project (protocol D183): a project that has never
|
|
252
|
+
* deployed raises no deploy-related warning, and the terminal-loss statement
|
|
253
|
+
* is stated verbatim exactly as for any other vault.
|
|
254
|
+
*/
|
|
255
|
+
async status(options = {}) {
|
|
256
|
+
const { GitvaultKeystore } = await this.#keystore();
|
|
257
|
+
const { listPendingOverrideJournals } = await this.#deploy();
|
|
258
|
+
const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
|
|
259
|
+
let repoId = options.repo_id ?? null;
|
|
260
|
+
let record = null;
|
|
261
|
+
if (!repoId && options.project_id) {
|
|
262
|
+
record = await this.forProject(options.project_id).catch(() => null);
|
|
263
|
+
repoId = record?.repo_id ?? null;
|
|
264
|
+
}
|
|
265
|
+
else if (repoId) {
|
|
266
|
+
record = await this.get(repoId).catch(() => null);
|
|
267
|
+
}
|
|
268
|
+
// READ, never `ensureIdentity()`: that mints an Ed25519 + X25519 keypair
|
|
269
|
+
// and writes it to disk. `status` is an observation — it must not create
|
|
270
|
+
// the very key material it is reporting on, and the client-surface spec is
|
|
271
|
+
// explicit that no key material exists until first capture.
|
|
272
|
+
let identityFingerprint = null;
|
|
273
|
+
let canSign = false;
|
|
274
|
+
let keystorePresent = false;
|
|
275
|
+
try {
|
|
276
|
+
const identity = keystore.readIdentity();
|
|
277
|
+
if (identity) {
|
|
278
|
+
keystorePresent = true;
|
|
279
|
+
identityFingerprint = identity.signing_fingerprint;
|
|
280
|
+
canSign = keystore.signingKeypair(identity) !== null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
keystorePresent = false;
|
|
285
|
+
}
|
|
286
|
+
let holdsRepoKey = false;
|
|
287
|
+
let authenticated = null;
|
|
288
|
+
let materialized = null;
|
|
289
|
+
if (repoId && keystorePresent) {
|
|
290
|
+
const repoFile = keystore.readRepo(repoId);
|
|
291
|
+
holdsRepoKey = repoFile !== null;
|
|
292
|
+
authenticated = repoFile?.head_pin?.generation ?? null;
|
|
293
|
+
materialized = repoFile?.materialized_pin?.generation ?? null;
|
|
294
|
+
}
|
|
295
|
+
let pending = [];
|
|
296
|
+
if (repoId && keystorePresent) {
|
|
297
|
+
try {
|
|
298
|
+
pending = listPendingOverrideJournals(keystore, repoId);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
pending = [];
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const warnings = [...(record?.warnings ?? [])];
|
|
305
|
+
if (keystorePresent && !canSign) {
|
|
306
|
+
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" });
|
|
307
|
+
}
|
|
308
|
+
if (record?.gitvault_policy === "grandfathered") {
|
|
309
|
+
warnings.push({ kind: "policy_grandfathered", message: "activation does not require vault admission on this project; return it to `required` when the migration is done" });
|
|
310
|
+
}
|
|
311
|
+
// The local git remote, when there is a repository to read it from. A
|
|
312
|
+
// pure read: `status` must never write git configuration.
|
|
313
|
+
let remote = null;
|
|
314
|
+
if (options.repo_dir) {
|
|
315
|
+
const { hardenedGit } = await this.#snapshot();
|
|
316
|
+
const name = "run402";
|
|
317
|
+
try {
|
|
318
|
+
const url = (await hardenedGit(options.repo_dir, ["remote", "get-url", name])).text().trim();
|
|
319
|
+
if (url) {
|
|
320
|
+
const parsed = parseGitvaultRemoteUrl(url);
|
|
321
|
+
const project = options.project_id ?? record?.project_id ?? null;
|
|
322
|
+
remote = { name, url, matches: parsed !== null && (project === null || parsed.project_id === project) };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
remote = null; // not a repository, or no such remote — both are ordinary
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// The ref map, only when asked (see the `refs` option's doc). Best-effort:
|
|
330
|
+
// a status that cannot materialize still reports everything else.
|
|
331
|
+
let refs = null;
|
|
332
|
+
let headTarget = null;
|
|
333
|
+
if (options.refs === true && repoId && holdsRepoKey) {
|
|
334
|
+
try {
|
|
335
|
+
const handle = await this.open({ ...options, repo_id: repoId });
|
|
336
|
+
const state = await handle.vault.materialize();
|
|
337
|
+
refs = { ...(state.refs ?? {}) };
|
|
338
|
+
headTarget = state.head_target ?? null;
|
|
339
|
+
}
|
|
340
|
+
catch (e) {
|
|
341
|
+
warnings.push({ kind: "refs_unavailable", message: `the vault's ref map could not be materialized: ${e instanceof Error ? e.message : String(e)}` });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
else if (options.refs === true && !holdsRepoKey) {
|
|
345
|
+
warnings.push({ kind: "refs_unavailable", message: "this machine does not hold K_repo for the vault, so its ref map cannot be decrypted here" });
|
|
346
|
+
}
|
|
347
|
+
const nextActions = [];
|
|
348
|
+
// `run402 init` scaffolds the git remote and deliberately allocates
|
|
349
|
+
// nothing; pointing at it here sent users to a command that silently did
|
|
350
|
+
// not do what this line promised (dogfood #1, finding A).
|
|
351
|
+
if (!record)
|
|
352
|
+
nextActions.push({ action: "allocate the project's vault", command: "run402 gitvault init" });
|
|
353
|
+
else if (pending.length > 0)
|
|
354
|
+
nextActions.push({ action: `complete ${pending.length} unvaulted-override journal(s)`, command: "run402 gitvault push" });
|
|
355
|
+
else if (record && !holdsRepoKey)
|
|
356
|
+
nextActions.push({ action: "this machine holds no key for the vault — allocate resolves to the existing vault and is idempotent", command: "run402 gitvault init" });
|
|
357
|
+
return {
|
|
358
|
+
repo_id: repoId,
|
|
359
|
+
project_id: options.project_id ?? record?.project_id ?? null,
|
|
360
|
+
vault: record,
|
|
361
|
+
keystore: {
|
|
362
|
+
present: keystorePresent,
|
|
363
|
+
identity_fingerprint: identityFingerprint,
|
|
364
|
+
can_sign: canSign,
|
|
365
|
+
holds_repo_key: holdsRepoKey,
|
|
366
|
+
root: keystore.rootDir,
|
|
367
|
+
paths: {
|
|
368
|
+
identity: keystore.identityPath,
|
|
369
|
+
repos: keystore.reposDir,
|
|
370
|
+
receipts: keystore.receiptsDir,
|
|
371
|
+
journal: keystore.journalDir,
|
|
372
|
+
audit_log: keystore.auditLogPath,
|
|
373
|
+
// Path derivation validates the id shape and refuses a malformed
|
|
374
|
+
// one; a bad `--repo` must not turn `status` into a stack trace.
|
|
375
|
+
repo: safePath(() => keystore.repoPath(repoId), repoId),
|
|
376
|
+
recovery_receipt: safePath(() => keystore.recoveryReceiptPath(repoId), repoId),
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
remote,
|
|
380
|
+
refs,
|
|
381
|
+
head_target: headTarget,
|
|
382
|
+
pins: { highest_authenticated: authenticated, highest_materialized: materialized },
|
|
383
|
+
gitvault_policy: record?.gitvault_policy ?? null,
|
|
384
|
+
pending_overrides: pending.length,
|
|
385
|
+
terminal_loss_statement: GITVAULT_TERMINAL_LOSS_STATEMENT,
|
|
386
|
+
terminal_loss_detail: GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT,
|
|
387
|
+
warnings,
|
|
388
|
+
next_actions: nextActions,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Capture the working tree and publish it — the push half of a deploy, run on
|
|
393
|
+
* its own. Never gated on a deploy: a vault-only project pushes for months
|
|
394
|
+
* without one.
|
|
395
|
+
*
|
|
396
|
+
* WHAT THIS PUBLISHES, and what it deliberately does not. This is the CAPTURE
|
|
397
|
+
* lane: it publishes the protocol-owned `refs/run402/deploys/latest` plus the
|
|
398
|
+
* `head_target` read off the local HEAD. The repository's OWN refs —
|
|
399
|
+
* `refs/heads/*` and `refs/tags/*` — reach the vault through
|
|
400
|
+
* `git push run402 …` via `git-remote-run402`, which is the lane the spec
|
|
401
|
+
* charges with reproducing them ("the helper SHALL reproduce the exact set of
|
|
402
|
+
* objects reachable from the declared canonical refs (`refs/heads/*`,
|
|
403
|
+
* `refs/tags/*`, protocol-owned `refs/run402/*`), plus the `HEAD` target" —
|
|
404
|
+
* gitvault-client-surface, "Faithful helper"; `run402 gitvault push` is
|
|
405
|
+
* separately defined as "capture and push outside a deploy" under "Source
|
|
406
|
+
* verbs"). The split is forced by §6.6: a dirty tree captures as a SYNTHETIC
|
|
407
|
+
* commit that sits on no branch, so moving `refs/heads/main` onto it would
|
|
408
|
+
* rewrite the user's branch on every dirty push — exactly the history
|
|
409
|
+
* clobbering §6.1's fast-forward + force-with-lease rules exist to prevent.
|
|
410
|
+
*
|
|
411
|
+
* Hence `protocol_refs: "allow"`: this lane BUILDS the protocol ref itself,
|
|
412
|
+
* so it opts in. The remote helper must keep the `"refuse"` default, because
|
|
413
|
+
* there the refnames are user-supplied and a user must not be able to squat
|
|
414
|
+
* the `refs/run402/*` namespace.
|
|
415
|
+
*
|
|
416
|
+
* Before reporting a push as landed the vault compares finalization receipts
|
|
417
|
+
* against its expected manifest and reads the admitted head back from
|
|
418
|
+
* storage. A 200 alone is never enough (§0 client obligations).
|
|
419
|
+
*/
|
|
420
|
+
async push(options) {
|
|
421
|
+
const [{ deployRefTransaction }, { captureSnapshot, gitvaultCommitLine }] = await Promise.all([this.#publication(), this.#snapshot()]);
|
|
422
|
+
const handle = await this.open(options);
|
|
423
|
+
const repoDir = options.repo_dir ?? process.cwd();
|
|
424
|
+
const snapshot = await captureSnapshot({ dir: repoDir, ...(options.snapshot ?? {}) });
|
|
425
|
+
const line = gitvaultCommitLine(snapshot);
|
|
426
|
+
options.onCommitLine?.(line);
|
|
427
|
+
const materialized = await handle.vault.materialize();
|
|
428
|
+
const push = {
|
|
429
|
+
transaction: deployRefTransaction(materialized.refs, snapshot.oid),
|
|
430
|
+
head_target: snapshot.head,
|
|
431
|
+
// The transaction above names `refs/run402/deploys/latest`, which
|
|
432
|
+
// `evaluateRefTransaction` refuses under its `?? "refuse"` default. Same
|
|
433
|
+
// opt-in the deploy lane makes for the identical move — see the doc
|
|
434
|
+
// comment for why the remote helper must NOT make it.
|
|
435
|
+
protocol_refs: "allow",
|
|
436
|
+
...(options.checkpoint ? { checkpoint: true } : {}),
|
|
437
|
+
};
|
|
438
|
+
const result = await handle.vault.push(push);
|
|
439
|
+
return { ...result, snapshot, gitvault_commit: snapshot.oid, gitvault_commit_line: line };
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Publish a checkpoint covering the canonical refs, every root unexpired at
|
|
443
|
+
* the cutoff, and the `HEAD` target — under a maintenance lease so a
|
|
444
|
+
* concurrent cycle cannot race it.
|
|
445
|
+
*
|
|
446
|
+
* The lease's `holder_token` is held in memory for the duration and released
|
|
447
|
+
* in a `finally`; it is never returned to the caller, logged, or cached.
|
|
448
|
+
* A repository beyond the V0 checkpoint maximum is refused at preflight with
|
|
449
|
+
* `CHECKPOINT_SET_LIMIT_EXCEEDED`.
|
|
450
|
+
*/
|
|
451
|
+
async compact(options = {}) {
|
|
452
|
+
const handle = await this.open(options);
|
|
453
|
+
const base = await handle.vault.materialize();
|
|
454
|
+
let lease = null;
|
|
455
|
+
if (options.lease !== false) {
|
|
456
|
+
lease = await this.acquireMaintenanceLease({
|
|
457
|
+
repo_id: handle.repo_id,
|
|
458
|
+
base_head_sha256: base.head_sha256,
|
|
459
|
+
current_checkpoint_hash: base.head?.checkpoint?.claim_set.stored_bytes_sha256 ?? null,
|
|
460
|
+
r1_size_bytes: options.r1_size_bytes ?? "0",
|
|
461
|
+
r2_cap_size_bytes: options.r2_cap_size_bytes ?? "0",
|
|
462
|
+
}).catch(() => null);
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
// A cutoff ticket lets EXPIRED roots leave the map. Without one the roots
|
|
466
|
+
// are RETAINED — expiry is permissive by design, so an unavailable ticket
|
|
467
|
+
// costs storage, never history.
|
|
468
|
+
let cutoffBound = true;
|
|
469
|
+
let published;
|
|
470
|
+
try {
|
|
471
|
+
published = await handle.vault.publishCheckpoint({ cutoff: {} });
|
|
472
|
+
}
|
|
473
|
+
catch (e) {
|
|
474
|
+
if (!isMissingCutoffRoute(e))
|
|
475
|
+
throw e;
|
|
476
|
+
cutoffBound = false;
|
|
477
|
+
published = await handle.vault.publishCheckpoint({ cutoff: false });
|
|
478
|
+
}
|
|
479
|
+
return {
|
|
480
|
+
generation: published.generation,
|
|
481
|
+
head_sha256: published.head_sha256,
|
|
482
|
+
form: published.form,
|
|
483
|
+
maintenance_lease_id: lease?.maintenance_lease_id ?? null,
|
|
484
|
+
cutoff_bound: cutoffBound,
|
|
485
|
+
covered_refs: Object.keys(published.refs).length,
|
|
486
|
+
covered_roots: base.roots.length,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
finally {
|
|
490
|
+
if (lease) {
|
|
491
|
+
await this.#client
|
|
492
|
+
.request(`/gitvault/v1/vaults/${encodeURIComponent(handle.repo_id)}/maintenance-leases/${encodeURIComponent(lease.maintenance_lease_id)}`, { method: "DELETE", body: { holder_token: lease.holder_token }, context: "releasing the gitvault maintenance lease" })
|
|
493
|
+
.catch(() => undefined);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Plan a prune — and, with both verifier receipts in hand, submit it.
|
|
499
|
+
*
|
|
500
|
+
* TWO PHASES, because the protocol is two-phase (§7.3) and no amount of API
|
|
501
|
+
* sugar can collapse it:
|
|
502
|
+
*
|
|
503
|
+
* 1. `prune()` walks the verified chain, computes the GC root set, subtracts
|
|
504
|
+
* it from the pruneable universe, and returns a SIGNED
|
|
505
|
+
* `prune_intent_core` plus its `intent_core_sha256`. Nothing is
|
|
506
|
+
* submitted and nothing is deleted.
|
|
507
|
+
* 2. Run `r402s-verify` against that core, then call
|
|
508
|
+
* `prune({ submit: { core, verifier_receipt } })` with the core
|
|
509
|
+
* ROUND-TRIPPED VERBATIM. This SDK produces its own `run402-cli` receipt
|
|
510
|
+
* by restoring the latest checkpoint and recomputing its commitments;
|
|
511
|
+
* the second receipt is `r402s-verify`'s and is never synthesized here,
|
|
512
|
+
* because two receipts from one lineage prove nothing.
|
|
513
|
+
*
|
|
514
|
+
* The `deleted` list in the result comes from the control-plane-signed
|
|
515
|
+
* completion and nothing else. `present_after_attempt` is a FAILED deletion
|
|
516
|
+
* and is never counted as one.
|
|
517
|
+
*/
|
|
518
|
+
async prune(options = {}) {
|
|
519
|
+
const pub = await this.#publication();
|
|
520
|
+
const prune = await this.#prune();
|
|
521
|
+
const { isRootEligibleForRemoval, GITVAULT_RETENTION_MIN_DAYS } = pub;
|
|
522
|
+
const handle = await this.open(options);
|
|
523
|
+
const base = await handle.vault.materialize();
|
|
524
|
+
const cutoffAt = (options.now ?? (() => new Date()))().toISOString();
|
|
525
|
+
const resolve = options.effective_admitted_at ?? (() => null);
|
|
526
|
+
// ── the retention-ROOT view (unchanged) ──
|
|
527
|
+
const candidates = base.roots.map((root) => {
|
|
528
|
+
const admittedAt = resolve(root.dropped_at_generation);
|
|
529
|
+
const eligible = admittedAt !== null && isRootEligibleForRemoval(admittedAt, cutoffAt);
|
|
530
|
+
return {
|
|
531
|
+
ref: root.ref,
|
|
532
|
+
oid: root.oid,
|
|
533
|
+
dropped_at_generation: root.dropped_at_generation,
|
|
534
|
+
eligible,
|
|
535
|
+
reason: admittedAt === null
|
|
536
|
+
? "this client cannot resolve the drop's effective_admitted_at, which RETAINS the root — expiry is permissive by design"
|
|
537
|
+
: eligible
|
|
538
|
+
? `dropped more than ${GITVAULT_RETENTION_MIN_DAYS} days before the cutoff; removable at the next checkpoint-bearing generation, then at the next successful prune`
|
|
539
|
+
: `still inside the ${GITVAULT_RETENTION_MIN_DAYS}-day retention window`,
|
|
540
|
+
};
|
|
541
|
+
});
|
|
542
|
+
const rootView = {
|
|
543
|
+
candidates,
|
|
544
|
+
eligible_count: candidates.filter((c) => c.eligible).length,
|
|
545
|
+
retained_count: candidates.filter((c) => !c.eligible).length,
|
|
546
|
+
};
|
|
547
|
+
// ── the OBJECT view: universe − GC root set ──
|
|
548
|
+
const entries = await handle.vault.chainEntries();
|
|
549
|
+
const plan = prune.planPruneCandidates(entries);
|
|
550
|
+
const objectCandidates = plan.candidates.map((r) => ({ object_id: r.object_id, object_kind: r.object_kind, size_bytes: r.size_bytes }));
|
|
551
|
+
const blocked = plan.root_set.blocked_reason ??
|
|
552
|
+
(plan.candidates.length === 0 ? "every stored object is still inside the GC root set — nothing is superseded yet" : null);
|
|
553
|
+
if (blocked || !plan.root_set.latest_checkpoint) {
|
|
554
|
+
return {
|
|
555
|
+
...rootView,
|
|
556
|
+
object_candidates: objectCandidates,
|
|
557
|
+
deferred_object_count: plan.deferred_count,
|
|
558
|
+
blocked_reason: blocked,
|
|
559
|
+
intent_core: null,
|
|
560
|
+
intent_core_sha256: null,
|
|
561
|
+
attestation: null,
|
|
562
|
+
submitted: false,
|
|
563
|
+
intent: null,
|
|
564
|
+
confirmation: null,
|
|
565
|
+
note: `no prune intent was built: ${blocked}. Retention is an operational promise of the platform, not a cryptographic guarantee against it.`,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
// ── restore-and-verify the latest checkpoint: the receipt's evidence ──
|
|
569
|
+
const latest = plan.root_set.latest_checkpoint;
|
|
570
|
+
const attestation = await handle.vault.verifyStoredCheckpoint(latest.head, latest.head_sha256);
|
|
571
|
+
if (attestation.cutoff_ticket_sha256 === null) {
|
|
572
|
+
return {
|
|
573
|
+
...rootView,
|
|
574
|
+
object_candidates: objectCandidates,
|
|
575
|
+
deferred_object_count: plan.deferred_count,
|
|
576
|
+
blocked_reason: "the latest checkpoint binds no retention_cutoff ticket, so no root's window can be evaluated and no prune is authorizable",
|
|
577
|
+
intent_core: null,
|
|
578
|
+
intent_core_sha256: null,
|
|
579
|
+
attestation,
|
|
580
|
+
submitted: false,
|
|
581
|
+
intent: null,
|
|
582
|
+
confirmation: null,
|
|
583
|
+
note: "run `run402 gitvault compact` to publish a checkpoint bound to a fresh retention_cutoff ticket, then plan the prune again.",
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
const record = await handle.vault.transport.getVaultRecord({ repo_id: handle.repo_id });
|
|
587
|
+
const gcRootSetHmac = handle.vault.keyedDigest("gcrootset", { receipts: plan.root_set.receipts });
|
|
588
|
+
const core = options.submit?.core ??
|
|
589
|
+
prune.buildPruneIntentCore({
|
|
590
|
+
repo_id: handle.repo_id,
|
|
591
|
+
gc_epoch: record.gc_epoch,
|
|
592
|
+
authorizing_head_sha256: base.head_sha256,
|
|
593
|
+
checkpoint_claim_set_sha256: attestation.claim_set_sha256,
|
|
594
|
+
gc_root_set_hmac: gcRootSetHmac,
|
|
595
|
+
retention_state_hmac: attestation.retention_state_hmac,
|
|
596
|
+
delete_set: plan.candidates,
|
|
597
|
+
}, handle.vault.signer());
|
|
598
|
+
const coreSha = prune.pruneIntentCoreSha256(core);
|
|
599
|
+
// A supplied core may have been planned against a chain that has since
|
|
600
|
+
// moved. The rule lives in the prune module (and is unit-tested there); the
|
|
601
|
+
// namespace only supplies the current facts.
|
|
602
|
+
if (options.submit) {
|
|
603
|
+
prune.assertPruneCoreStillCurrent(core, { repo_id: handle.repo_id, gc_epoch: record.gc_epoch, checkpoint_claim_set_sha256: attestation.claim_set_sha256 });
|
|
604
|
+
}
|
|
605
|
+
// Faithful: when a core was supplied, report ITS candidates — the ones
|
|
606
|
+
// actually submitted — not a freshly-planned set that may differ.
|
|
607
|
+
const reportedCandidates = options.submit
|
|
608
|
+
? core.delete_set.map((r) => ({ object_id: r.object_id, object_kind: r.object_kind, size_bytes: r.size_bytes }))
|
|
609
|
+
: objectCandidates;
|
|
610
|
+
const planned = {
|
|
611
|
+
...rootView,
|
|
612
|
+
object_candidates: reportedCandidates,
|
|
613
|
+
deferred_object_count: plan.deferred_count,
|
|
614
|
+
blocked_reason: null,
|
|
615
|
+
intent_core: core,
|
|
616
|
+
intent_core_sha256: coreSha,
|
|
617
|
+
attestation,
|
|
618
|
+
submitted: false,
|
|
619
|
+
intent: null,
|
|
620
|
+
confirmation: null,
|
|
621
|
+
note: "planned, not submitted. Run r402s-verify against this intent_core, then call prune({ submit: { core, verifier_receipt } }) " +
|
|
622
|
+
"with the core round-tripped verbatim — a rebuilt core carries a different nonce and the receipt would no longer bind to it.",
|
|
623
|
+
};
|
|
624
|
+
if (!options.submit)
|
|
625
|
+
return planned;
|
|
626
|
+
// ── attest, upload both receipts, submit the exact bytes ──
|
|
627
|
+
const rootsEvolution = await this.#checkRootsEvolution(handle, entries, resolve, isRootEligibleForRemoval, attestation.cutoff_at);
|
|
628
|
+
const candidateIds = new Set(core.delete_set.map((r) => r.object_id));
|
|
629
|
+
const outsideRoots = plan.root_set.receipts.every((r) => !candidateIds.has(r.object_id));
|
|
630
|
+
const ours = prune.buildVerifierReceipt({
|
|
631
|
+
repo_id: handle.repo_id,
|
|
632
|
+
intent_core_sha256: coreSha,
|
|
633
|
+
checkpoint_head_sha256: attestation.checkpoint_head_sha256,
|
|
634
|
+
cutoff_ticket_sha256: attestation.cutoff_ticket_sha256,
|
|
635
|
+
restored_object_set_hmac: attestation.restored_object_set_hmac,
|
|
636
|
+
// Both booleans are OBSERVATIONS. A false one produces a `failed`
|
|
637
|
+
// receipt and `buildPruneIntent` then refuses — which is the point:
|
|
638
|
+
// the SDK never signs an attestation it did not earn.
|
|
639
|
+
retention_evolution_ok: rootsEvolution.ok && attestation.object_set_matches && attestation.ref_state_matches && attestation.retention_roots_matches,
|
|
640
|
+
candidates_outside_roots_ok: outsideRoots,
|
|
641
|
+
implementation_id: prune.GITVAULT_SDK_VERIFIER_IMPLEMENTATION,
|
|
642
|
+
implementation_version: prune.GITVAULT_SDK_VERIFIER_VERSION,
|
|
643
|
+
}, handle.vault.signer());
|
|
644
|
+
const intent = prune.buildPruneIntent(core, [ours, options.submit.verifier_receipt], handle.vault.signer());
|
|
645
|
+
await this.#uploadVerifierReceipts(handle, pub, [ours, options.submit.verifier_receipt]);
|
|
646
|
+
let stored = await handle.vault.transport.submitPruneIntent({ repo_id: handle.repo_id, intent_bytes: prune.pruneIntentBytes(intent) });
|
|
647
|
+
let intentRecord = stored;
|
|
648
|
+
const wait = options.submit.wait;
|
|
649
|
+
if (wait) {
|
|
650
|
+
const attempts = wait.attempts ?? 20;
|
|
651
|
+
const intervalMs = wait.interval_ms ?? 3_000;
|
|
652
|
+
for (let i = 0; i < attempts && !(intentRecord?.completion ?? null); i++) {
|
|
653
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
654
|
+
intentRecord = await handle.vault.transport.getPruneIntent({ repo_id: handle.repo_id, prune_intent_object_id: intent.object_id });
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
const confirmation = prune.summarizePruneCompletion(core.delete_set.map((r) => r.object_id), intentRecord);
|
|
658
|
+
return {
|
|
659
|
+
...planned,
|
|
660
|
+
submitted: true,
|
|
661
|
+
intent: intentRecord,
|
|
662
|
+
confirmation,
|
|
663
|
+
note: confirmation.outcome === null
|
|
664
|
+
? "the intent is accepted and the gateway's worker drives deletion; poll it until a signed completion appears. Nothing is deleted until the completion says so."
|
|
665
|
+
: `the signed completion confirms ${confirmation.deleted.length} object(s) deleted and ${confirmation.present.length} still present. Only \`deleted\` means the bytes are gone.`,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
/** Upload both receipts so the gateway can claim them at the intent's fence. */
|
|
669
|
+
async #uploadVerifierReceipts(handle, pub, receipts) {
|
|
670
|
+
const { storedBytes, sha256Hex } = await import("./gitvault.crypto.js");
|
|
671
|
+
const objects = receipts.map((r) => {
|
|
672
|
+
const bytes = storedBytes(r);
|
|
673
|
+
return {
|
|
674
|
+
path: pub.gitvaultPaths.verifierReceipt(r.object_id),
|
|
675
|
+
object_kind: "verifier_receipt",
|
|
676
|
+
object_id: r.object_id,
|
|
677
|
+
bytes,
|
|
678
|
+
sha256: sha256Hex(bytes),
|
|
679
|
+
size_bytes: String(bytes.length),
|
|
680
|
+
};
|
|
681
|
+
});
|
|
682
|
+
await handle.vault.transport.uploadObjects({ repo_id: handle.repo_id, objects });
|
|
683
|
+
}
|
|
684
|
+
/** Did every retention root that LEFT the map leave legally? A `false` here is honest, not fatal. */
|
|
685
|
+
async #checkRootsEvolution(handle, entries, resolve, isEligible, _cutoffAt) {
|
|
686
|
+
const { checkRetentionEvolution } = await this.#prune();
|
|
687
|
+
const rootsByGeneration = new Map();
|
|
688
|
+
for (const e of entries) {
|
|
689
|
+
const carrier = await handle.vault.openRetentionRootsAt(e.head.retention_roots);
|
|
690
|
+
rootsByGeneration.set(e.head.generation, carrier.roots.map((r) => ({ ref: r.ref, oid: r.oid, dropped_at_generation: r.dropped_at_generation })));
|
|
691
|
+
}
|
|
692
|
+
return checkRetentionEvolution(entries, (entry) => rootsByGeneration.get(entry.head.generation) ?? [], resolve, isEligible);
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Verify the head chain from the authenticated pin up to the newest listed
|
|
696
|
+
* generation, then return the verified state.
|
|
697
|
+
*
|
|
698
|
+
* Fails closed: a regression below the pin is `GENERATION_REGRESSION`, a gap
|
|
699
|
+
* or bad link is `CHAIN_BROKEN`, an unvalidatable transition descriptor is
|
|
700
|
+
* `UPGRADE_REQUIRED` (read-only past it, never skipped). The verification
|
|
701
|
+
* budget is resumable — a `VERIFICATION_BUDGET_EXCEEDED` client continues
|
|
702
|
+
* from its verified prefix rather than restarting.
|
|
703
|
+
*/
|
|
704
|
+
async verify(options = {}) {
|
|
705
|
+
const handle = await this.open(options);
|
|
706
|
+
return handle.vault.verifyToNewest();
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* The push-gated deploy: both lanes under one fresh `capture_id`, resolving
|
|
710
|
+
* to exactly one of five outcomes — `DEPLOYED_AND_VAULTED`,
|
|
711
|
+
* `DEPLOY_BLOCKED_PUSH_FAILED`, `DEPLOY_FAILED_VAULTED`,
|
|
712
|
+
* `DEPLOY_FAILED_UNVAULTED`, `DEPLOYED_UNVAULTED_OVERRIDE`.
|
|
713
|
+
*
|
|
714
|
+
* The push is never gated on deploy success, and a build that fails before a
|
|
715
|
+
* canonical apply plan exists still captures (with a null plan digest, so no
|
|
716
|
+
* activation token can be minted from it).
|
|
717
|
+
*/
|
|
718
|
+
async deploy(options) {
|
|
719
|
+
const { runGitvaultDeploy } = await this.#deploy();
|
|
720
|
+
const handle = await this.open(options);
|
|
721
|
+
return runGitvaultDeploy({ ...options, vault: handle.vault, repo_dir: options.repo_dir ?? process.cwd() });
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Drain every unvaulted-override journal on this machine: push the exact
|
|
725
|
+
* journaled snapshot and present the capture receipt for full-field
|
|
726
|
+
* comparison. A partial match never clears the advisory.
|
|
727
|
+
*/
|
|
728
|
+
async drainOverrides(options = {}) {
|
|
729
|
+
const { drainOverrideJournals } = await this.#deploy();
|
|
730
|
+
const handle = await this.open(options);
|
|
731
|
+
return drainOverrideJournals(handle.vault);
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Reproduce the vault's object database into a git repository — the clone-back
|
|
735
|
+
* path. Needs only git and a surviving keystore: no deployment artifact, CAS
|
|
736
|
+
* entry, or apply operation is consulted.
|
|
737
|
+
*/
|
|
738
|
+
async restore(options) {
|
|
739
|
+
const handle = await this.open(options);
|
|
740
|
+
const out = await handle.vault.restoreObjectsInto(options.target_dir);
|
|
741
|
+
return { refs: out.refs, generation: out.generation };
|
|
742
|
+
}
|
|
743
|
+
// ── internals ─────────────────────────────────────────────────────────────
|
|
744
|
+
async #resolveRepoId(options) {
|
|
745
|
+
if (options.repo_id)
|
|
746
|
+
return options.repo_id;
|
|
747
|
+
if (options.project_id)
|
|
748
|
+
return (await this.forProject(options.project_id)).repo_id;
|
|
749
|
+
throw new LocalError("pass repo_id, or project_id to resolve it from the control plane", "opening the gitvault", { code: "GITVAULT_VAULT_UNRESOLVED" });
|
|
750
|
+
}
|
|
751
|
+
#publication() {
|
|
752
|
+
return nodeOnly(() => import("../node/gitvault-publication.js"), "open");
|
|
753
|
+
}
|
|
754
|
+
#deploy() {
|
|
755
|
+
return nodeOnly(() => import("../node/gitvault-deploy.js"), "deploy");
|
|
756
|
+
}
|
|
757
|
+
#keystore() {
|
|
758
|
+
return nodeOnly(() => import("../node/gitvault-keystore.js"), "open");
|
|
759
|
+
}
|
|
760
|
+
#creation() {
|
|
761
|
+
return nodeOnly(() => import("../node/gitvault-creation-journal.js"), "init");
|
|
762
|
+
}
|
|
763
|
+
#snapshot() {
|
|
764
|
+
return nodeOnly(() => import("../node/gitvault-snapshot.js"), "push");
|
|
765
|
+
}
|
|
766
|
+
#prune() {
|
|
767
|
+
return nodeOnly(() => import("../node/gitvault-prune.js"), "prune");
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
/** `run402::<org_id>/<project_id>` — what `git-remote-run402` resolves. */
|
|
771
|
+
export function gitvaultRemoteUrl(orgId, projectId) {
|
|
772
|
+
return `run402::${orgId}/${projectId}`;
|
|
773
|
+
}
|
|
774
|
+
/** Parse a `run402::<org>/<project>` remote URL. `null` when it is not one. */
|
|
775
|
+
export function parseGitvaultRemoteUrl(url) {
|
|
776
|
+
const m = /^run402::([^/]+)\/(.+)$/.exec(url.trim());
|
|
777
|
+
if (!m)
|
|
778
|
+
return null;
|
|
779
|
+
return { org_id: m[1], project_id: m[2] };
|
|
780
|
+
}
|
|
781
|
+
/** A 404/absent-route refusal for the unshipped `retention-cutoffs` endpoint. */
|
|
782
|
+
function isMissingCutoffRoute(e) {
|
|
783
|
+
const err = e;
|
|
784
|
+
return Boolean(err && (err.status === 404 || err.code === "RESOURCE_NOT_FOUND" || err.code === "ROUTE_NOT_FOUND"));
|
|
785
|
+
}
|
|
786
|
+
//# sourceMappingURL=gitvault.js.map
|