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.
Files changed (115) hide show
  1. package/cli.mjs +6 -6
  2. package/git-remote-run402.mjs +328 -0
  3. package/lib/command-manifest.mjs +13 -1
  4. package/lib/credentials.mjs +4 -0
  5. package/lib/deploy-v2.mjs +78 -1
  6. package/lib/doctor.mjs +80 -1
  7. package/lib/domains.mjs +0 -14
  8. package/lib/gitvault.mjs +582 -0
  9. package/lib/init.mjs +153 -5
  10. package/lib/org-context.mjs +44 -1
  11. package/lib/pay.test.mjs +0 -4
  12. package/package.json +8 -2
  13. package/sdk/dist/index.d.ts +20 -3
  14. package/sdk/dist/index.d.ts.map +1 -1
  15. package/sdk/dist/index.js +22 -3
  16. package/sdk/dist/index.js.map +1 -1
  17. package/sdk/dist/namespaces/admin.d.ts +0 -5
  18. package/sdk/dist/namespaces/admin.d.ts.map +1 -1
  19. package/sdk/dist/namespaces/admin.js +0 -9
  20. package/sdk/dist/namespaces/admin.js.map +1 -1
  21. package/sdk/dist/namespaces/assets.d.ts +1 -20
  22. package/sdk/dist/namespaces/assets.d.ts.map +1 -1
  23. package/sdk/dist/namespaces/assets.js +0 -27
  24. package/sdk/dist/namespaces/assets.js.map +1 -1
  25. package/sdk/dist/namespaces/domains.d.ts +0 -19
  26. package/sdk/dist/namespaces/domains.d.ts.map +1 -1
  27. package/sdk/dist/namespaces/domains.js +0 -19
  28. package/sdk/dist/namespaces/domains.js.map +1 -1
  29. package/sdk/dist/namespaces/gitvault.crypto.d.ts +319 -0
  30. package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -0
  31. package/sdk/dist/namespaces/gitvault.crypto.js +690 -0
  32. package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -0
  33. package/sdk/dist/namespaces/gitvault.d.ts +521 -0
  34. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -0
  35. package/sdk/dist/namespaces/gitvault.js +786 -0
  36. package/sdk/dist/namespaces/gitvault.js.map +1 -0
  37. package/sdk/dist/namespaces/gitvault.types.d.ts +481 -0
  38. package/sdk/dist/namespaces/gitvault.types.d.ts.map +1 -0
  39. package/sdk/dist/namespaces/gitvault.types.js +19 -0
  40. package/sdk/dist/namespaces/gitvault.types.js.map +1 -0
  41. package/sdk/dist/namespaces/org.d.ts +1 -3
  42. package/sdk/dist/namespaces/org.d.ts.map +1 -1
  43. package/sdk/dist/namespaces/org.js +6 -10
  44. package/sdk/dist/namespaces/org.js.map +1 -1
  45. package/sdk/dist/namespaces/pay.d.ts +0 -8
  46. package/sdk/dist/namespaces/pay.d.ts.map +1 -1
  47. package/sdk/dist/namespaces/pay.js.map +1 -1
  48. package/sdk/dist/namespaces/projects.d.ts +2 -2
  49. package/sdk/dist/namespaces/projects.d.ts.map +1 -1
  50. package/sdk/dist/namespaces/projects.js +4 -12
  51. package/sdk/dist/namespaces/projects.js.map +1 -1
  52. package/sdk/dist/namespaces/secrets.d.ts +0 -2
  53. package/sdk/dist/namespaces/secrets.d.ts.map +1 -1
  54. package/sdk/dist/namespaces/secrets.js +3 -10
  55. package/sdk/dist/namespaces/secrets.js.map +1 -1
  56. package/sdk/dist/namespaces/subdomains.d.ts +0 -2
  57. package/sdk/dist/namespaces/subdomains.d.ts.map +1 -1
  58. package/sdk/dist/namespaces/subdomains.js +5 -16
  59. package/sdk/dist/namespaces/subdomains.js.map +1 -1
  60. package/sdk/dist/namespaces/transfers.d.ts +0 -2
  61. package/sdk/dist/namespaces/transfers.d.ts.map +1 -1
  62. package/sdk/dist/namespaces/transfers.js +8 -10
  63. package/sdk/dist/namespaces/transfers.js.map +1 -1
  64. package/sdk/dist/namespaces/wallets.d.ts +0 -12
  65. package/sdk/dist/namespaces/wallets.d.ts.map +1 -1
  66. package/sdk/dist/namespaces/wallets.js +0 -14
  67. package/sdk/dist/namespaces/wallets.js.map +1 -1
  68. package/sdk/dist/node/gitvault-creation-journal.d.ts +222 -0
  69. package/sdk/dist/node/gitvault-creation-journal.d.ts.map +1 -0
  70. package/sdk/dist/node/gitvault-creation-journal.js +365 -0
  71. package/sdk/dist/node/gitvault-creation-journal.js.map +1 -0
  72. package/sdk/dist/node/gitvault-deploy.d.ts +211 -0
  73. package/sdk/dist/node/gitvault-deploy.d.ts.map +1 -0
  74. package/sdk/dist/node/gitvault-deploy.js +292 -0
  75. package/sdk/dist/node/gitvault-deploy.js.map +1 -0
  76. package/sdk/dist/node/gitvault-keystore.d.ts +207 -0
  77. package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -0
  78. package/sdk/dist/node/gitvault-keystore.js +529 -0
  79. package/sdk/dist/node/gitvault-keystore.js.map +1 -0
  80. package/sdk/dist/node/gitvault-prune.d.ts +341 -0
  81. package/sdk/dist/node/gitvault-prune.d.ts.map +1 -0
  82. package/sdk/dist/node/gitvault-prune.js +410 -0
  83. package/sdk/dist/node/gitvault-prune.js.map +1 -0
  84. package/sdk/dist/node/gitvault-publication.d.ts +730 -0
  85. package/sdk/dist/node/gitvault-publication.d.ts.map +1 -0
  86. package/sdk/dist/node/gitvault-publication.js +1475 -0
  87. package/sdk/dist/node/gitvault-publication.js.map +1 -0
  88. package/sdk/dist/node/gitvault-snapshot.d.ts +227 -0
  89. package/sdk/dist/node/gitvault-snapshot.d.ts.map +1 -0
  90. package/sdk/dist/node/gitvault-snapshot.js +556 -0
  91. package/sdk/dist/node/gitvault-snapshot.js.map +1 -0
  92. package/sdk/dist/node/index.d.ts +11 -0
  93. package/sdk/dist/node/index.d.ts.map +1 -1
  94. package/sdk/dist/node/index.js +9 -0
  95. package/sdk/dist/node/index.js.map +1 -1
  96. package/sdk/dist/node/paid-fetch.d.ts.map +1 -1
  97. package/sdk/dist/node/paid-fetch.js +0 -4
  98. package/sdk/dist/node/paid-fetch.js.map +1 -1
  99. package/sdk/dist/node/sites-node.d.ts +1 -8
  100. package/sdk/dist/node/sites-node.d.ts.map +1 -1
  101. package/sdk/dist/node/sites-node.js +1 -7
  102. package/sdk/dist/node/sites-node.js.map +1 -1
  103. package/sdk/dist/scoped.d.ts +4 -35
  104. package/sdk/dist/scoped.d.ts.map +1 -1
  105. package/sdk/dist/scoped.js +9 -74
  106. package/sdk/dist/scoped.js.map +1 -1
  107. package/lib/sender-domain.mjs +0 -36
  108. package/sdk/dist/deprecate.d.ts +0 -23
  109. package/sdk/dist/deprecate.d.ts.map +0 -1
  110. package/sdk/dist/deprecate.js +0 -42
  111. package/sdk/dist/deprecate.js.map +0 -1
  112. package/sdk/dist/namespaces/sender-domain.d.ts +0 -47
  113. package/sdk/dist/namespaces/sender-domain.d.ts.map +0 -1
  114. package/sdk/dist/namespaces/sender-domain.js +0 -45
  115. 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