run402-mcp
Version:
MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.
643 lines • 35.8 kB
JavaScript
/**
* vault-mirror-and-recover — the mirror writer + reconcile/sync engine +
* capture-time mirror-write hook (design D1/D6/D7, tasks 2.2–2.4).
*
* ADMISSION-ORDER WRITE DISCIPLINE (D1's whole point). The gateway's objects
* listing (`GET /vaults/v1/:vault_id/objects`) is a flat, unordered
* set of `{key, object_kind, sha256, size_bytes}` — it does not group objects
* by generation. The correct, SUFFICIENT ordering rule does not need that
* grouping: write EVERY plain object first (any order among themselves — a
* WAL pack, a checkpoint pack, a prune intent never depend on one another),
* THEN write every `admissions/<gen>` and `head/<gen>` pair, interleaved and
* strictly ascending by generation, admission always immediately before its
* head. Any interruption therefore lands the mirror in one of exactly two
* shapes:
* - mid-objects: no admission/head has been written yet, so nothing in the
* mirror references anything absent — a completely inert, valid state
* (recovery just sees the mirror's newest COMPLETE prior generation, if
* any was mirrored on an earlier pass).
* - mid-admissions/heads at generation K: every object is present (phase
* one finished), every generation < K is fully written (admission +
* head), and K itself has AT MOST its admission record without its head
* — which recovery reads exactly as "the mirror's newest generation is
* K-1", a completely ordinary and valid earlier state (protocol §5A step
* 4: a record existing without its head PUT is a normal in-flight shape,
* never corruption).
* This is exactly the "torn mirror ≡ some earlier valid generation" property
* design D1 requires, and it needs no per-generation object grouping to prove.
*
* Read path: `GET …/objects` (paginated) for the listing, then the SAME
* presigned-read machinery the SDK's publication module uses for live pushes
* (`POST …/object-reads` for object-kind reads, `GET …/heads/:gen` /
* `GET …/admissions/:gen` for the two generation-addressed raw-byte routes) —
* reimplemented here as a THIN, self-contained reader (not exported from
* `vault-publication.ts`, which keeps those helpers module-private) so
* this file has no dependency on the live-push module and works from nothing
* but a `Client` + `repo_id`.
*
* MIRROR LAYOUT (task 5.3 fix — do not lose this again). The gateway's real
* bucket key for every stored artifact is FULLY QUALIFIED under the vault's
* own prefix (`storage-keys.ts` `vaultPrefix`): `source/<repo_id>/<rest>`.
* The gateway's own `GET …/objects` listing
* echoes that FULL key back on every entry — it never returns a
* repo-relative key. This module's own working representation, and the
* mirror BACKEND's key space (`vault-mirror-backend.ts`
* `openVaultMirrorBackend`, whose `root`/`prefix` already bake in
* `source/<repo_id>`), are both REPO-RELATIVE (`head/<gen>`,
* `wal/<id>.pack.enc`, `key-envelopes/<epoch>/<fp>.env`, …) — the same shape
* `vault-recover.ts` and `vault-publication.ts`'s `vaultPaths`
* already use. The mapping is therefore, in ONE place:
*
* bucket key = `source/<repo_id>/` + mirror key (repo-relative)
*
* `listVaultObjectsAll` is the ONLY function that ever sees a full,
* `source/<repo_id>/`-prefixed key (the raw wire shape, typed
* `RawVaultObjectEntry` below) — it strips the prefix via
* `stripSourcePrefix` before anything else in this module (or in
* `vault-recover.ts`, which never talks to the live gateway at all) sees
* the entry. Every `VaultObjectEntry` handed to `generationRouteForKey`,
* `objectReadRequestForEntry`, `planMirrorWrite`, `reconcileOne`, and the
* `VaultMirrorBackend` itself is repo-relative from that point on. An
* entry whose key names a DIFFERENT repo id than the one being synced is a
* listing-isolation violation and is refused loudly (never silently
* stripped-and-kept or silently dropped) — see `stripSourcePrefix`.
*
* Two chain kinds are part of this SAME listing (the pre-fix gateway omitted
* them entirely, which was task 5.3 finding (a); the listing now includes
* them end to end), both fetched via the exact-bytes generation routes
* (`.../heads/:generation`, `.../admissions/:generation`), never through
* `object-reads`:
* - `head/<generation>` — `object_kind: "head"` for every ordinary
* generation, but the GENESIS entry at generation `0000000000000000`
* carries `object_kind: "vault_genesis"` (the ledger's own
* `admitted_object_kind`) instead. Both route through the SAME
* `.../heads/:generation` fetch — the kind distinguishes the CONTENT,
* not the route.
* - `admissions/<generation>` — `object_kind: "admission_record"` at
* EVERY generation, including genesis (genesis has an admission record
* too).
* `size_bytes` is an EXACT decimal string for every kind, including these
* two (the ledger enforces the admitted byte count via CHECK constraints and
* the gateway serves it as `octet_length`) — never `null`. `reconcileOne`
* still diffs by size first (cheap) and hash-verifies on every actual copy
* (belt-and-suspenders, unconditional — never skipped just because a size
* happened to match).
*/
import { createHash } from "node:crypto";
import { LocalError, isRun402Error } from "../errors.js";
import { VAULT_GENESIS_GENERATION, VAULT_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT, VAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT, fromBase64url, } from "../namespaces/vault.crypto.js";
import { fetchVaultObjectBytes } from "./vault-edge-fetch.js";
import { VaultKeystore } from "./vault-keystore.js";
import { formatMirrorDestination, readMirrorConfig, recordMirrorSuccess } from "./vault-mirror-config.js";
import { openVaultMirrorBackend, resolveMirrorCredentials } from "./vault-mirror-backend.js";
import { readByoConfig } from "./vault-byo-config.js";
function fail(code, message, context, details, nextActions) {
throw new LocalError(message, context, { code, details, ...(nextActions ? { next_actions: nextActions } : {}) });
}
function sha256Hex(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
/** protocol §3 (`storage-keys.ts` `VAULT_SOURCE_PREFIX`) — duplicated here because the SDK carries no dependency on the gateway package. */
const VAULT_SOURCE_PREFIX = "source";
/**
* Turn one WIRE key (`source/<repo_id>/<rest>`) into the REPO-RELATIVE key
* every other function in this module expects. A key naming a DIFFERENT repo
* id than the one being synced is a listing-isolation violation — refused
* loudly rather than silently stripped-and-kept (it could only mean a
* gateway bug or a cross-tenant data leak, and a partial sync that quietly
* dropped it would misreport as a clean success). A key with no `source/`
* prefix at all is a listing-shape violation of the same severity.
*/
function stripSourcePrefix(repoId, key) {
const expected = `${VAULT_SOURCE_PREFIX}/${repoId}/`;
if (key.startsWith(expected))
return key.slice(expected.length);
const foreign = /^source\/([^/]+)\//.exec(key);
if (foreign) {
fail("VAULT_MIRROR_FOREIGN_REPO_KEY", `the objects listing for ${repoId} returned a key addressed to a different vault (${foreign[1]}): ${key}`, "listing vault objects", { key, repo_id: repoId, foreign_repo_id: foreign[1] });
}
fail("VAULT_MIRROR_LISTING_INCONSISTENT", `listed key is not in the expected source/<repo_id>/… layout: ${key}`, "listing vault objects", { key, repo_id: repoId });
}
/** Page through `GET /vaults/v1/:vault_id/objects`, collecting every entry, stripped to repo-relative keys. Store-and-echo cursor per D3a — never parsed here either. */
export async function listVaultObjectsAll(client, repoId) {
const base = `/vaults/v1/${encodeURIComponent(repoId)}/objects`;
const out = [];
let cursor;
for (;;) {
const qs = cursor ? `?cursor=${encodeURIComponent(cursor)}` : "";
const page = await client.request(`${base}${qs}`, { context: "listing vault objects" });
for (const raw of page.objects) {
out.push({ key: stripSourcePrefix(repoId, raw.key), object_kind: raw.object_kind, sha256: raw.sha256, size_bytes: raw.size_bytes });
}
if (!page.has_more)
break;
if (!page.next_cursor)
fail("VAULT_MIRROR_LISTING_INCONSISTENT", "the objects listing reported has_more with no next_cursor", "listing vault objects");
cursor = page.next_cursor;
}
return out;
}
/** Read one object's bytes by key, using the same wire identity every stored kind already carries (§3 layout). `null` when the gateway reports the object absent. */
export async function readVaultObjectBytes(client, repoId, entry) {
const base = `/vaults/v1/${encodeURIComponent(repoId)}`;
const generationRoute = generationRouteForKey(entry.key);
if (generationRoute) {
// Defense in depth: the key SHAPE alone is enough to route the read, but
// cross-checking the listing's own declared object_kind catches a
// mislabeled entry (gateway bug) loudly instead of silently reading the
// wrong thing under the right generation. `admissions/<gen>` is always
// `admission_record`; `head/<gen>` is `vault_genesis` ONLY at generation
// zero and `head` at every later generation — the kind names the
// CONTENT, both fetch through the same route.
const expectedKind = generationRoute.route === "admissions" ? "admission_record" : generationRoute.generation === VAULT_GENESIS_GENERATION ? "vault_genesis" : "head";
if (entry.object_kind !== expectedKind) {
fail("VAULT_MIRROR_KEY_UNRECOGNIZED", `listed object_kind '${entry.object_kind}' does not match its generation-addressed key ${entry.key} (expected '${expectedKind}')`, "reading vault mirror source object", { key: entry.key, object_kind: entry.object_kind, expected_object_kind: expectedKind });
}
const path = `${base}/${generationRoute.route}/${encodeURIComponent(generationRoute.generation)}`;
const auth = (await client.credentials.getAuth(path, { method: `vault.read_${generationRoute.route}` })) ?? {};
const r = await client.fetch(`${client.apiBase}${path}`, { method: "GET", headers: { ...auth, accept: "application/json" } });
if (r.status === 404)
return null;
if (!r.ok)
fail("VAULT_MIRROR_READ_FAILED", `${entry.key} read failed (HTTP ${r.status})`, "reading vault mirror source object", { key: entry.key, status: r.status });
return new Uint8Array(await r.arrayBuffer());
}
const read = objectReadRequestForEntry(entry);
// `edge_url` (vault-read-edge-cache design D5) is the same optional,
// same-bytes CDN companion `vault-publication.ts`'s object-reads
// consumers get — see `fetchVaultObjectBytes` (`vault-edge-fetch.ts`)
// for the prefer-edge/silent-fallback policy shared across both.
//
// `inline` (D-vault-5/D-vault-6) is the same
// optional, same-bytes small-object offer those consumers get too — see
// `vault-publication.ts`'s `resolveObjectReadTarget` for the sibling
// logic, DUPLICATED here (not imported) for the reason this module's own
// header names: this reader must have no dependency on the live-push
// module. `entry.sha256` is always known here (the listing supplies it
// for every entry), so unlike the live-push path this consumption is
// unconditional rather than optional-expectation — a lying `inline` is
// still just a plain miss for this one read.
let presigned;
try {
presigned = await client.request(`${base}/object-reads`, { method: "POST", body: { objects: [read] }, context: "resolving vault mirror source object" });
}
catch (e) {
if (isRun402Error(e) && (e.status === 404 || e.code === "RESOURCE_NOT_FOUND"))
return null;
throw e;
}
const target = presigned.reads[0];
if (!target)
return null;
if (target.inline !== undefined) {
const bytes = fromBase64url(target.inline, "reads[].inline");
if (sha256Hex(bytes) === entry.sha256)
return bytes;
}
const r = await fetchVaultObjectBytes(client, target);
if (r.status === 404)
return null;
if (!r.ok)
fail("VAULT_MIRROR_READ_FAILED", `${entry.key} GET failed (HTTP ${r.status})`, "reading vault mirror source object", { key: entry.key, status: r.status });
return new Uint8Array(await r.arrayBuffer());
}
/** Exported for the key-shape conformance test (`vault-mirror-recover.test.ts`) — never called for its own sake outside this module. */
export function generationRouteForKey(key) {
const head = /^head\/([0-9a-f]{16})$/.exec(key);
if (head)
return { route: "heads", generation: head[1] };
const adm = /^admissions\/([0-9a-f]{16})$/.exec(key);
if (adm)
return { route: "admissions", generation: adm[1] };
return null;
}
/**
* Repo-relative key shapes for every id-addressed, non-generation-addressed
* kind (`storage-keys.ts` `objectKeyFor` + `upload-sessions.ts`
* `UPLOADABLE_KINDS`, mirrored here since the SDK has no dependency on the
* gateway package). ANCHORED (`^…$`) on purpose — the bug this table
* replaces (task 5.3 finding (b)) was an UNANCHORED fallback regex that
* greedily matched the REPO ID itself (`src_<32hex>` satisfies the same
* `[a-z0-9_]+_[0-9a-f]{32}` shape as a real object id) out of a
* `source/<repo_id>/…` key, sending the wrong object_id to the gateway.
* Keyed by the listing's own declared `object_kind` (trusted metadata from
* the gateway) — the pattern then both extracts the id AND validates that
* the key's shape actually matches its declared kind.
*/
const NON_GENERATION_KEY_SPECS = [
{ kind: "wal_pack", pattern: /^wal\/(wal_[0-9a-f]{32})\.pack\.enc$/ },
{ kind: "ref_state", pattern: /^refs\/(refs_[0-9a-f]{32})\.enc$/ },
{ kind: "retention_roots", pattern: /^retention\/(rr_[0-9a-f]{32})\.enc$/ },
{ kind: "checkpoint_manifest", pattern: /^checkpoints\/(chk_[0-9a-f]{32})\.manifest\.enc$/ },
{ kind: "checkpoint_pack", pattern: /^checkpoints\/(ckp_[0-9a-f]{32})\.pack\.enc$/ },
{ kind: "checkpoint_claim_set", pattern: /^checkpoints\/(ccs_[0-9a-f]{32})\.claims\.json$/ },
{ kind: "maintenance_stage_claim_set", pattern: /^maintenance\/(msc_[0-9a-f]{32})\.stage\.json$/ },
{ kind: "maintenance_stage_page", pattern: /^maintenance\/(msp_[0-9a-f]{32})\.page\.json$/ },
{ kind: "maintenance_completion_cut", pattern: /^maintenance\/cuts\/(adm_[0-9a-f]{32})\.json$/ },
{ kind: "retention_cutoff", pattern: /^retention\/(rc_[0-9a-f]{32})\.ticket\.json$/ },
{ kind: "prune_intent", pattern: /^prune\/(pi_[0-9a-f]{32})\.intent\.json$/ },
// prune_completion is filed under the INTENT's own object_id (storage-keys.ts
// `pruneCompletionKey`) — its key literally carries a `pi_…` id, not `pc_…`.
{ kind: "prune_completion", pattern: /^prune\/(pi_[0-9a-f]{32})\.completion\.json$/ },
{ kind: "verifier_receipt", pattern: /^verifier-receipts\/(vr_[0-9a-f]{32})\.json$/ },
{ kind: "maintenance_cycle_issuance", pattern: /^maintenance\/issued\/(mc_[0-9a-f]{32})\.json$/ },
{ kind: "maintenance_cycle_terminal", pattern: /^maintenance\/terminals\/(mc_[0-9a-f]{32})\.terminal\.json$/ },
];
/**
* `key_envelope` is path-addressed by `(epoch, recipient_fingerprint)`, not a
* plain object_id — every other kind uses its object_id, read straight from
* the key's own filename via an ANCHORED, kind-specific pattern (never a
* loose, unanchored fallback — see {@link NON_GENERATION_KEY_SPECS}).
* Exported for the key-shape conformance test.
*/
export function objectReadRequestForEntry(entry) {
if (entry.object_kind === "key_envelope") {
// `key-envelopes/<epoch>/<recipient_fingerprint>.env` — the GATEWAY's
// real §3 wire key (`packages/gateway/src/services/vault/
// upload-sessions.ts` `UPLOADABLE_KINDS.key_envelope.key()`, mirrored by
// `storage-keys.ts` `objectKeyFor`). This is what the live objects
// listing actually returns, and is DIFFERENT from the SDK's own
// `vault-creation-journal.ts` envelope `path` string
// (`envelopes/<epoch>/<fp>`, no `key-` prefix, no `.env` suffix) — that
// string is CLIENT-LOCAL addressing for the upload manifest only ("never
// rides the wire", per its own doc comment); the gateway derives its own
// key independently and never echoes the client's `path` back. Do not
// re-derive this from that SDK constant.
const m = /^key-envelopes\/([0-9a-f]{16})\/(ek_[0-9a-f]{32})\.env$/.exec(entry.key);
if (!m)
fail("VAULT_MIRROR_KEY_UNRECOGNIZED", `key_envelope key does not match the expected layout: ${entry.key}`, "building vault mirror read request", { key: entry.key });
return { object_kind: "key_envelope", epoch: m[1], recipient_fingerprint: m[2] };
}
const spec = NON_GENERATION_KEY_SPECS.find((s) => s.kind === entry.object_kind);
if (!spec) {
fail("VAULT_MIRROR_KEY_UNRECOGNIZED", `unrecognized object_kind '${entry.object_kind}' for mirror read (key ${entry.key})`, "building vault mirror read request", { key: entry.key, object_kind: entry.object_kind });
}
const m = spec.pattern.exec(entry.key);
if (!m)
fail("VAULT_MIRROR_KEY_UNRECOGNIZED", `${entry.object_kind} key does not match the expected §3 layout: ${entry.key}`, "building vault mirror read request", { key: entry.key, object_kind: entry.object_kind });
return { object_kind: entry.object_kind, object_id: m[1] };
}
/**
* NOT a bug: the recipient-only read gate on
* `key_envelope` means a mirror can only ever hold envelopes wrapped for
* THIS machine's own identity. A multi-recipient vault (vault-human-
* envelopes) makes a foreign-recipient envelope the NORM, not an anomaly —
* it must never be synced as a failure. This extracts the `ek_` fingerprint
* straight from the key_envelope's own key (no network round-trip needed to
* classify it), reusing the exact pattern `objectReadRequestForEntry` uses;
* `null` for a key that doesn't match the expected layout at all (a real,
* separate problem — that case falls through to the normal read-and-fail
* path so `VAULT_MIRROR_KEY_UNRECOGNIZED` still surfaces).
*/
function keyEnvelopeRecipientFingerprintFromKey(key) {
const m = /^key-envelopes\/[0-9a-f]{16}\/(ek_[0-9a-f]{32})\.env$/.exec(key);
return m ? m[1] : null;
}
/** The local keystore's own `ek_` fingerprint, or `null` when no identity exists on this machine (keyless / never-`vault init`-ed). */
function localEncryptionFingerprint(keystore) {
return keystore.readIdentity()?.encryption_fingerprint ?? null;
}
/** Sort a flat listing into the admission-order write plan (module doc above). */
export function planMirrorWrite(entries) {
const objects = [];
const byGeneration = new Map();
for (const e of entries) {
const route = generationRouteForKey(e.key);
if (!route) {
objects.push(e);
continue;
}
const slot = byGeneration.get(route.generation) ?? {};
if (route.route === "admissions")
slot.admission = e;
else
slot.head = e;
byGeneration.set(route.generation, slot);
}
const generations = [...byGeneration.keys()].sort();
const admissionsAndHeads = [];
for (const gen of generations) {
const slot = byGeneration.get(gen);
if (slot.admission)
admissionsAndHeads.push(slot.admission);
if (slot.head)
admissionsAndHeads.push(slot.head);
}
return { objects, admissionsAndHeads };
}
/**
* Copy one entry (presigned GET → hash-verify → mirror create-only PUT) if
* the mirror does not already hold it at the same size. Idempotent: a
* matching size is treated as already-present WITHOUT a hash check (D7 —
* "hash-verified on copy" means on copy, not on every skip, and applies
* uniformly to every kind now that `size_bytes` is exact for all of them —
* see the module doc); a size mismatch re-copies and hash-verifies. Every
* failure is tagged with its `object_kind` so a sync summary's `errors[]` is
* self-explaining without cross-referencing the listing (task 5.3's "no
* per-entry reason surfaced" gap).
*
* `ownFingerprint` (task 5.3 follow-up): a `key_envelope` entry addressed to
* a DIFFERENT recipient is classified `skipped_foreign_recipient` BEFORE any
* network read is attempted (the fingerprint is parsed straight out of the
* entry's own key) — never a failure, and never even reaches the gateway,
* which would correctly 403 it under the recipient-only read gate.
*/
async function reconcileOne(client, repoId, backend, entry, ownFingerprint) {
const kindTag = `[${entry.object_kind}]`;
if (entry.object_kind === "key_envelope") {
const fp = keyEnvelopeRecipientFingerprintFromKey(entry.key);
if (fp !== null && fp !== ownFingerprint) {
return { key: entry.key, outcome: "skipped_foreign_recipient" };
}
}
try {
const existing = await backend.head(entry.key);
if (existing && existing.size_bytes === entry.size_bytes) {
return { key: entry.key, outcome: "already_present" };
}
const bytes = await readVaultObjectBytes(client, repoId, entry);
if (!bytes)
return { key: entry.key, outcome: "failed", error: `${kindTag} the source no longer serves this object (listed but unreadable)` };
const hash = sha256Hex(bytes);
if (hash !== entry.sha256) {
return { key: entry.key, outcome: "failed", error: `${kindTag} hash mismatch on copy: expected ${entry.sha256}, got ${hash}` };
}
const put = await backend.putCreateOnly(entry.key, bytes);
if (!put.created) {
// A concurrent writer (another sync, or the mirror-write hook) won the
// create-only race — benign; the mirror now holds SOME valid bytes at
// this key, and re-checking their size closes the loop honestly.
const after = await backend.head(entry.key);
if (after && after.size_bytes === entry.size_bytes)
return { key: entry.key, outcome: "already_present" };
return { key: entry.key, outcome: "failed", error: `${kindTag} mirror key already exists with a different size — refusing to overwrite (content-addressed keys should never collide; investigate)` };
}
return { key: entry.key, outcome: "copied", size_bytes: entry.size_bytes };
}
catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { key: entry.key, outcome: "failed", error: message.startsWith(kindTag) ? message : `${kindTag} ${message}` };
}
}
/**
* `run402 repos mirror sync` (task 2.3): list the vault's stored objects,
* diff against the mirror by key+size, fetch and hash-verify what's missing,
* write it in admission order. Resumable and idempotent — running it twice in
* a row with nothing new copies nothing the second time.
*/
export async function mirrorSync(client, repoId, options = {}) {
const keystore = options.keystore ?? VaultKeystore.open();
const backend = options.backend ?? openBackendFromConfig(keystore, repoId);
const config = readMirrorConfig(keystore, repoId);
const destination = options.backend ? backend.describe() : formatMirrorDestination(config.destination);
const ownFingerprint = localEncryptionFingerprint(keystore);
const entries = await listVaultObjectsAll(client, repoId);
const plan = planMirrorWrite(entries);
const ordered = [...plan.objects, ...plan.admissionsAndHeads];
let copied = 0;
let already = 0;
let skippedForeign = 0;
let failed = 0;
let bytesCopied = 0n;
const errors = [];
const skippedForeignKeys = [];
for (const entry of ordered) {
const result = await reconcileOne(client, repoId, backend, entry, ownFingerprint);
if (result.outcome === "copied") {
copied += 1;
bytesCopied += BigInt(entry.size_bytes);
}
else if (result.outcome === "already_present") {
already += 1;
}
else if (result.outcome === "skipped_foreign_recipient") {
skippedForeign += 1;
skippedForeignKeys.push(result.key);
}
else {
failed += 1;
errors.push({ key: result.key, error: result.error ?? "unknown failure" });
}
}
// vault-mirror-default: a zero-failure pass is the event that clears the
// vault_unmirrored finding — stamp it in the LOCAL config only (the gateway
// never learns any of this; recordMirrorSuccess no-ops when there is no
// config, e.g. a test-injected backend).
if (failed === 0)
recordMirrorSuccess(keystore, repoId);
return {
repo_id: repoId,
destination,
objects_listed: entries.length,
objects_copied: copied,
objects_already_present: already,
objects_skipped_foreign_recipient: skippedForeign,
skipped_foreign_recipient_keys: skippedForeignKeys,
objects_failed: failed,
bytes_copied: bytesCopied.toString(),
errors,
validity_not_freshness: VAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT,
keystore_still_required: VAULT_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT,
};
}
function openBackendFromConfig(keystore, repoId) {
const config = readMirrorConfig(keystore, repoId);
if (!config)
fail("VAULT_MIRROR_NOT_CONFIGURED", `no mirror is configured for ${repoId}`, "opening vault mirror", { repo_id: repoId }, [{ action: "run402 repos mirror <destination>" }]);
if (config.destination.kind === "s3" && config.credential) {
// Fail fast on a missing/misconfigured credential BEFORE any network call,
// so a sync's error names the credential problem instead of a confusing
// downstream 403.
resolveMirrorCredentials(config.credential);
}
return openVaultMirrorBackend(config.destination, repoId, config.credential);
}
/**
* Fires after an ordinary vault push/deploy produces a new generation. NEVER
* throws and NEVER alters the caller's deploy/push outcome (design D6) — a
* mirror is strictly extra durability, and its failure is reported BESIDE the
* vault result as a distinct line, not folded into it. Fires only when a
* mirror is configured for this vault; a vault with no mirror gets
* `skipped_no_mirror` (not an error) so callers can log it uniformly without
* branching on whether mirroring was ever opted into.
*
* Implemented as a full (but INCREMENTAL, idempotent) sync rather than a
* push-scoped upload: the objects listing + diff-by-size machinery already
* makes an all-caught-up sync cheap (one listing page, zero copies), and this
* keeps the admission-order write discipline in exactly one place instead of
* a second, push-scoped code path that could drift from it.
*/
export async function mirrorPushForGeneration(client, repoId, options = {}) {
let keystore;
try {
keystore = options.keystore ?? VaultKeystore.open();
if (!options.backend && !readMirrorConfig(keystore, repoId)) {
return { attempted: false, outcome: "skipped_no_mirror" };
}
}
catch (e) {
return { attempted: false, outcome: "skipped_no_mirror", error: e instanceof Error ? e.message : String(e) };
}
try {
const summary = await mirrorSync(client, repoId, { ...options, keystore });
return { attempted: true, outcome: summary.objects_failed > 0 ? "failed" : "pushed", summary };
}
catch (e) {
return { attempted: true, outcome: "failed", error: e instanceof Error ? e.message : String(e) };
}
}
// ─── BYO chain-copy dual-write (vault-byo-primary-bucket task 3.3) ─────────
/**
* The SAME six payload kinds the gateway's `VAULT_BYO_PAYLOAD_KINDS`
* names (`services/vault/upload-sessions.ts`) — client-written directly
* to the BYO destination at push time (task 3.2), so the chain-copy below
* MUST skip them (re-copying them here would try to READ them back from
* run402, which never held them for a BYO vault, and would 404). Every
* OTHER listed kind — the two generation-addressed chain kinds
* (`head`/`vault_genesis`, `admission_record`) AND the remaining
* chain-adjacent uploadable/gateway-only kinds (`maintenance_stage_*`,
* `verifier_receipt`, `retention_cutoff`, `prune_intent`/`prune_completion`,
* `maintenance_cycle_*`, and the writer-signed `checkpoint_claim_set`,
* plaintext receipts the gateway must read to admit a checkpoint) — stays
* run402-authoritative on EVERY vault regardless of storage_profile (design
* D1/D9) and is exactly what this dual-write copies, so the customer bucket
* alone becomes a complete `r402s-recover` source.
*/
const VAULT_BYO_PAYLOAD_OBJECT_KINDS = new Set([
"wal_pack",
"ref_state",
"retention_roots",
"checkpoint_manifest",
"checkpoint_pack",
"key_envelope",
]);
function openByoBackendFromConfig(keystore, repoId) {
const config = readByoConfig(keystore, repoId);
if (!config)
fail("VAULT_BYO_NOT_CONFIGURED", `no local BYO write config for ${repoId}`, "opening the vault BYO destination", { repo_id: repoId }, [{ action: "run402 repos create --byo <destination> allocated this vault; this machine needs the same destination + credential configured locally to write or mirror its chain" }]);
if (config.destination.kind === "s3" && config.credential) {
// Fail fast on a missing/misconfigured credential BEFORE any network
// call, same discipline as `openBackendFromConfig` above.
resolveMirrorCredentials(config.credential);
}
return openVaultMirrorBackend(config.destination, repoId, config.credential);
}
/**
* `plan.objects`, restricted to the chain-adjacent (NEVER payload) kinds —
* the write half of task 3.3. Combined with `plan.admissionsAndHeads`
* (already chain-only by construction — see {@link planMirrorWrite}), this
* is the FULL signed-chain set a BYO vault's dual-write copies.
*/
function chainOnlyEntries(entries) {
const plan = planMirrorWrite(entries);
return [...plan.objects.filter((e) => !VAULT_BYO_PAYLOAD_OBJECT_KINDS.has(e.object_kind)), ...plan.admissionsAndHeads];
}
/**
* `run402 repos create --byo`'s every-push dual-write (task 3.3, design
* D1): list the vault's stored objects, restrict to the signed CHAIN
* (never the payload kinds, which THIS SAME push already wrote directly to
* the destination — task 3.2), diff against the destination by key+size,
* fetch (from run402 — the chain stays run402-authoritative) and
* hash-verify what's missing, write it in admission order. Resumable and
* idempotent, reusing the SAME `reconcileOne` copy primitive and
* admission-order discipline the opt-in mirror uses — one implementation,
* not a second ordering to prove.
*/
export async function byoChainCopySync(client, repoId, options = {}) {
const keystore = options.keystore ?? VaultKeystore.open();
const backend = options.backend ?? openByoBackendFromConfig(keystore, repoId);
const destination = options.backend ? backend.describe() : formatMirrorDestination(readByoConfig(keystore, repoId).destination);
const ownFingerprint = localEncryptionFingerprint(keystore);
const entries = await listVaultObjectsAll(client, repoId);
const ordered = chainOnlyEntries(entries);
let copied = 0;
let already = 0;
let skippedForeign = 0;
let failed = 0;
let bytesCopied = 0n;
const errors = [];
const skippedForeignKeys = [];
for (const entry of ordered) {
const result = await reconcileOne(client, repoId, backend, entry, ownFingerprint);
if (result.outcome === "copied") {
copied += 1;
bytesCopied += BigInt(entry.size_bytes);
}
else if (result.outcome === "already_present") {
already += 1;
}
else if (result.outcome === "skipped_foreign_recipient") {
skippedForeign += 1;
skippedForeignKeys.push(result.key);
}
else {
failed += 1;
errors.push({ key: result.key, error: result.error ?? "unknown failure" });
}
}
return {
repo_id: repoId,
destination,
objects_listed: ordered.length,
objects_copied: copied,
objects_already_present: already,
objects_skipped_foreign_recipient: skippedForeign,
skipped_foreign_recipient_keys: skippedForeignKeys,
objects_failed: failed,
bytes_copied: bytesCopied.toString(),
errors,
validity_not_freshness: VAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT,
keystore_still_required: VAULT_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT,
};
}
/**
* Fires after an ordinary vault push produces a new generation, mirroring
* {@link mirrorPushForGeneration}'s exact contract (NEVER throws, NEVER
* alters the caller's push outcome — the chain-copy is reported BESIDE it).
* Gated on a LOCAL BYO write config existing for this repo — a managed
* vault (the common case) pays zero extra network calls, `skipped_no_byo`.
*/
export async function byoChainCopyPushForGeneration(client, repoId, options = {}) {
let keystore;
try {
keystore = options.keystore ?? VaultKeystore.open();
if (!options.backend && !readByoConfig(keystore, repoId)) {
return { attempted: false, outcome: "skipped_no_mirror" };
}
}
catch (e) {
return { attempted: false, outcome: "skipped_no_mirror", error: e instanceof Error ? e.message : String(e) };
}
try {
const summary = await byoChainCopySync(client, repoId, { ...options, keystore });
return { attempted: true, outcome: summary.objects_failed > 0 ? "failed" : "pushed", summary };
}
catch (e) {
return { attempted: true, outcome: "failed", error: e instanceof Error ? e.message : String(e) };
}
}
/**
* Adjudicates the BYO destination against run402's signed chain (task 3.3's
* read half, D6): HEAD-checks (cheap, no data transfer) every object the
* vault's OWN listing names — payload AND chain alike, since for a BYO
* vault the destination is supposed to hold BOTH (payload via task 3.2's
* direct write, chain via this file's dual-write above) — against the
* destination, by key + size. Never throws on an absence; it REPORTS every
* missing object by name so the caller (`repos fsck`) can refuse with
* `VAULT_BYO_OBJECT_MISSING` naming exactly what the chain says should
* exist and does not.
*/
export async function verifyByoObjectsPresent(client, repoId, options = {}) {
const keystore = options.keystore ?? VaultKeystore.open();
const backend = options.backend ?? openByoBackendFromConfig(keystore, repoId);
const destination = options.backend ? backend.describe() : formatMirrorDestination(readByoConfig(keystore, repoId).destination);
const entries = await listVaultObjectsAll(client, repoId);
const missing = [];
for (const entry of entries) {
const existing = await backend.head(entry.key);
if (!existing || existing.size_bytes !== entry.size_bytes)
missing.push({ key: entry.key, object_kind: entry.object_kind });
}
return { repo_id: repoId, destination, checked: entries.length, missing };
}
//# sourceMappingURL=vault-mirror.js.map