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.
486 lines • 26.5 kB
TypeScript
/**
* vault — the principal keystore (protocol rev 41 §5.1; task 5.2).
*
* Layout (under the active profile's config dir, beside the project-key cache
* `core/src/keystore.ts` manages — `RUN402_CONFIG_DIR` / `RUN402_WALLET` aware):
*
* <config>/vault/
* identity.json Ed25519 seed + X25519 private key (0600)
* repos/<repo_id>.json K_repo, trust pin, dual pins, last ref transaction (0600)
* repos/<repo_id>.lock/ per-repo lock (mkdir-atomic, 0700)
* receipts/<repo_id>.recovery_receipt.json the recovery receipt (NOT a secret; more copies better)
* journal/<client_creation_id>.json six-stage creation journals (task 5.3)
* audit.log append-only JSON lines (0600)
*
* Discipline (spec: "Writes SHALL be atomic with per-repo locks, no-follow
* creation, and permission audits"): directories 0700, files 0600, every
* write = temp file (O_NOFOLLOW|O_EXCL) + fsync + rename + directory fsync,
* and `auditKeystorePermissions()` runs at open so a world-readable identity
* is a named finding, never silent.
*
* Transitions (protocol §5.1 partial-loss table) are DERIVED by
* {@link VaultKeystore.assess} and acted on by explicit methods — the
* keystore never guesses:
* - repo file lost + identity intact → `restoreRepoFromEnvelope` (own envelope)
* - signing key lost, K_repo held → `read_only` (clear report)
* - stale pin → `stale_pin` (re-verify from genesis; 5.4)
* - whole keystore lost → `VAULT_UNRECOVERABLE` (§0, verbatim)
*/
import type { VaultEncryptionKeypair, VaultKeyEnvelope, VaultRecoveryReceipt, VaultSigningKeypair, VaultGenesis } from "../namespaces/vault.types.js";
/** `identity.json` — the principal's two private keys. Either half MAY be absent after partial loss. */
export interface VaultIdentityFile {
version: 1;
/** Ed25519 32-byte seed, lowercase hex. Absent ⇒ the signing key is lost (read-only principal). */
signing_seed_hex?: string;
/** Raw Ed25519 public key, base64url. */
signing_pubkey: string;
/** `vk_` fingerprint. */
signing_fingerprint: string;
/** X25519 32-byte private key, lowercase hex. Absent ⇒ no envelope can be opened. */
encryption_private_key_hex?: string;
/** Raw X25519 public key, base64url. */
encryption_pubkey: string;
/** `ek_` fingerprint. */
encryption_fingerprint: string;
/**
* The control-plane principal this identity ENROLLED as (consult round 2
* §9e) — persisted after the first successful authenticated enrollment so a
* later run under a DIFFERENT profile fails loudly instead of silently
* reusing another principal's custody identity. Absent on identities that
* have never enrolled.
*/
enrolled_principal_id?: string;
created_at: string;
}
/** The dual pins (protocol §6.4): the newest authenticated head and its generation. */
export interface VaultHeadPin {
generation: string;
head_sha256: string;
pinned_at: string;
}
/** `repos/<repo_id>.json` — everything per-vault the principal must not lose. */
export interface VaultRepoFile {
version: 1;
repo_id: string;
org_id: string;
project_id: string;
/** Raw 32-byte secret for {@link VaultRepoFile.epoch} (`K_repo`/`K_1` pre-rotation, `K_e` after one), lowercase hex. */
k_repo_hex: string;
/** The current epoch — `0000000000000001` until this vault's first rotation (D194, rev 42); advances on every rotation this principal drove or opened its own envelope for. */
epoch: string;
/**
* D194/D202, rev 42: every symmetric epoch key this principal has EVER
* locally held, keyed by 16-hex epoch — the substrate `checkFreshEpochKeyAgainstPriorKeys`
* (D195's inequality obligation) and the full-history-on-join branch (a)
* (`FULL_VIA_EPOCH_KEYS`) both need. `epoch` -> `k_repo_hex` above is
* ALWAYS mirrored here too (an entry for the current epoch always
* exists), so this map alone is sufficient for both purposes — a reader
* never needs to special-case "the current one lives elsewhere."
* Absent/`null` for a pre-rotation vault (equivalent to the single
* `{[epoch]: k_repo_hex}` entry).
*/
epoch_keys?: Record<string, string> | null;
/** Trust pin: the admitted genesis stored-bytes hash. */
genesis_sha256: string;
/** `highest_authenticated` (§6.4): chain-verified; a listing below it is `GENERATION_REGRESSION`. `null` until the first head is authenticated. */
head_pin: VaultHeadPin | null;
/** `highest_materialized` (§6.4): decrypted + applied — the ONLY push base. Absent/`null` ⇒ never materialized. */
materialized_pin?: VaultHeadPin | null;
/** The verified-prefix watermark of a budget-interrupted verification (§9.3 — resumable). Absent/`null` when none is pending. */
verified_prefix?: VaultHeadPin | null;
/**
* vault-clone-scaling (bench P3): the newest checkpoint coverage this
* checkout has LOCALLY learned — from pushing a checkpoint-form head,
* walking past a checkpoint head, a genesis-anchored walk that saw none
* (proving coverage = genesis), or a restore's checkpoint apply. Feeds
* the checkpoint-staleness advisory ONLY; absent/`null` means coverage
* is unknown on this checkout and the advisory stays silent — it is
* never protocol state and never consulted by verification or restore.
*/
checkpoint_covers_through?: string | null;
/**
* vault-object-host-predial (design D1): the object-store ORIGINS
* (`scheme://host` — never a path, query, key, or credential) this
* checkout has observed a presigned or edge URL actually served bytes
* from, most-recently-observed first, deduped, capped at
* {@link VAULT_OBJECT_STORE_ORIGINS_CAP}. Same locally-learned,
* monotonic-in-usefulness pattern as {@link checkpoint_covers_through}:
* absent/`null`/`[]` means nothing is known yet (a first-ever session,
* byte-identical to before this field existed) and is never protocol
* state — it feeds the connection prewarm ONLY (`predialVaultObjectStore`
* in `vault-prewarm.ts`), never verification, never a source of truth
* for where an object actually lives. A stale entry (bucket migrated)
* costs at most one harmless background dial and is overwritten by the
* next observed fetch.
*/
object_store_origins?: string[];
/**
* TOFU pins for {@link Vault.reconcileEnvelopeRecipients}
* (vault-human-envelopes design D4 point 3): `principal_id ->` the
* `ek_` fingerprint this repo last wrapped a `key_envelope` for (or
* observed already covering the vault). A later reconcile whose org
* directory reports a DIFFERENT fingerprint for the same `principal_id`
* refuses to wrap under it — a pinned-key change is a refusal, never a
* silent re-wrap. Absent/`null` until reconcile has run at least once.
*/
envelope_recipient_pins?: Record<string, string> | null;
/**
* The MOST RECENT `recipient_pin_manifest` this principal itself built,
* signed, and successfully admitted (D197) — via {@link
* Vault.publishPinManifestUpdate} or {@link
* Vault.rotateEpoch}'s `pending_confirmations` fold. Read-side
* short-circuit ONLY: {@link Vault} `readPinManifestObject`
* consults this before a network `object-reads` round trip, and uses it
* ONLY when `pin_manifest_version` + `stored_bytes_sha256` match the
* receipt it is resolving — never as a substitute for verification of a
* manifest this principal did not itself author (a version/hash mismatch
* falls through to the network path unchanged). Safe to skip the network
* fetch + re-verify-own-signature step in the match case: those exact
* bytes were built with `this.signer()` moments earlier and the upload
* already round-tripped a checksum-verified PUT, so re-fetching and
* re-checking our own signature over our own just-authored bytes proves
* nothing a network failure couldn't ALSO independently fail to prove.
* Absent/`null` until this principal has published its first manifest.
*/
known_pin_manifest?: {
pin_manifest_version: string;
stored_bytes_sha256: string;
pins: {
principal_id: string;
ek_fingerprint: string;
}[];
} | null;
/**
* vault-multi-writer rev 47 (protocol §4.15) — the writer-set analog of
* {@link head_pin}: the chain-replayed writer state as of THIS repo's own
* `head_pin`/`verified_prefix` generation, persisted so `Vault.verifyToNewest`
* never has to re-walk the writer set from genesis on every call. Advances
* in lockstep with `head_pin`/`verified_prefix` — always at the SAME
* generation, never ahead or behind. `null` until the first `verifyToNewest`
* call on this checkout (equivalent to the genesis-only singleton, which
* that call derives for free without needing this field yet).
*/
writer_set_pin?: {
version: string;
sha256: string;
writers: {
writer_key_id: string;
signing_pubkey: string;
}[];
retired_writers?: {
writer_key_id: string;
signing_pubkey: string;
}[];
pinned_at: string;
} | null;
/**
* vault-multi-writer rev 47 — THIS principal's own relationship to the
* chain-verified `writer_set_pin` above, as of the last time it was
* computed (`repos view`/`doctor`/reconcile — task 5.7). `"pending"`: a
* claimed handoff or a directory publish is waiting on the actual
* `add_writer_key` push (see `pending_writer_admission` below).
* `"active"`: this principal's own signing key IS in `writer_set_pin.writers`.
* `"not_admitted"`: an active, eligible org member whose key has never
* been added. `"removed"`: a key that WAS a writer and was chain-removed
* (permanently — see `writer-state.ts`'s `burnedWriterKeyIds`). `null`
* until first computed (e.g. a checkout with no local signing key at all).
*/
writer_status?: "pending" | "active" | "not_admitted" | "removed" | null;
/**
* vault-multi-writer rev 47 (task 5.6) — set by `resume()` the moment a
* handoff claim response's `writer_admission_grant` is verified and
* persisted, BEFORE the recipient's own `add_writer_key{"handoff"}`
* activation push lands on-chain; cleared once that push is admitted
* (`writer_status` flips to `"active"` at the same time). Surviving this
* field across a crash is exactly what makes the activation push
* idempotently resumable (task 5.6's "crash replay") — the grant + the
* writer_key_id it names are the only state a retry needs, and both are
* already locally held (the grant was echoed verbatim in the claim
* response; nothing here is ever re-derived from a network call).
*/
pending_writer_admission?: {
handoff_id: string;
writer_admission_grant: Record<string, unknown>;
claimed_writer_key_id: string;
} | null;
/** The last ref transaction this principal published (5.4 fills it). */
last_ref_transaction: Record<string, unknown> | null;
/** How this file came to exist — creation, a §5.1 restore from the principal's own envelope, or a bearer-key restore (`resume`/`join`, kygit-handoff/kygit-invite). */
provenance: "created" | "restored_from_envelope" | "restored_from_handoff" | "restored_from_invite";
updated_at: string;
}
/** One `audit.log` line. */
export interface VaultAuditEntry {
at: string;
event: VaultAuditEvent;
repo_id?: string;
details?: Record<string, unknown>;
}
export type VaultAuditEvent = "keystore_opened" | "identity_created" | "identity_principal_bound" | "repo_saved" | "repo_restored_from_envelope" | "recovery_receipt_stored" | "permission_finding" | "lock_acquired" | "lock_released" | "lock_stale_reclaimed" | "transition_assessed" | "journal_stage" | "epoch_rotation_recorded";
/** A permission-audit finding (never fatal by default; surfaced to doctor). */
export interface VaultPermissionFinding {
path: string;
problem: "world_or_group_accessible" | "symlink" | "not_owned_by_user" | "missing";
mode?: string;
}
/** The §5.1 partial-loss transition the keystore is in for one vault. */
export type VaultKeystoreState = {
state: "ready";
repo: VaultRepoFile;
} | {
state: "repo_state_lost_identity_intact";
next_action: "restore_from_envelope";
} | {
state: "read_only";
repo: VaultRepoFile;
reason: "signing_key_lost";
} | {
state: "stale_pin";
repo: VaultRepoFile;
next_action: "reverify_from_genesis";
} | {
state: "unrecoverable";
code: "VAULT_UNRECOVERABLE";
statement: string;
doctor_text: string;
};
export interface VaultKeystoreOptions {
/** Override the keystore root (tests). Defaults to `<config dir>/vault`. */
rootDir?: string;
/** Clock injection (tests). */
now?: () => Date;
/** When true, a permission finding on open throws `VAULT_KEYSTORE_PERMISSIONS` instead of only auditing. */
strictPermissions?: boolean;
}
export interface VaultLockOptions {
/** How long a lock is considered live before it is reclaimed as stale (ms). Default 10 min. */
staleAfterMs?: number;
/** Total wait budget before `VAULT_KEYSTORE_LOCKED` (ms). Default 30 s. */
timeoutMs?: number;
}
/** The local object cache's recency window (design D3: "genesis + newest N=8 heads + newest carriers"). */
export declare const VAULT_OBJECT_CACHE_WINDOW = 8;
/** vault-object-host-predial (design D1): the max persisted object-store origins per repo — small on purpose, this is a predial hint, not an inventory. */
export declare const VAULT_OBJECT_STORE_ORIGINS_CAP = 4;
/**
* Default keystore root: `<active profile config dir>/vault`.
*
* This is the one place the keystore path is first resolved, so it is also
* where a keystore still at `<config>/gitvault` moves to `<config>/vault`:
* one same-directory `rename`, which is atomic, and only when `vault` does
* not exist yet. Nothing reads the earlier path after that. A move another
* process already made is not an error; any other refusal throws
* `VAULT_KEYSTORE_MOVE_FAILED` naming both paths, because answering with an
* empty `vault` directory would make a live keystore look lost.
*/
export declare function getVaultKeystoreRoot(): string;
/**
* Atomic, no-follow, fsynced write: `O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW` on a
* temp sibling, fsync, rename over the target, fsync the directory.
*/
export declare function writeFileAtomic0600(path: string, text: string): void;
/** No-follow read: a symlinked keystore file is a refusal, not a read. */
export declare function readFileNoFollow(path: string): string | null;
export declare class VaultKeystore {
readonly rootDir: string;
private readonly now;
private readonly strictPermissions;
constructor(options?: VaultKeystoreOptions);
/** Open (creating the directory skeleton) and run the permission audit. */
static open(options?: VaultKeystoreOptions): VaultKeystore;
get identityPath(): string;
get reposDir(): string;
get receiptsDir(): string;
get journalDir(): string;
get auditLogPath(): string;
repoPath(repoId: string): string;
recoveryReceiptPath(repoId: string): string;
/** Audit every keystore path that exists; findings are recorded in `audit.log` and returned. */
auditPermissions(): VaultPermissionFinding[];
/** Append one JSON line to `audit.log` (0600, created no-follow). */
audit(event: VaultAuditEvent, repoId?: string, details?: Record<string, unknown>): void;
/** Read the audit log (newest last). */
readAuditLog(): VaultAuditEntry[];
readIdentity(): VaultIdentityFile | null;
/** Create the identity if absent; never overwrites an existing one. */
ensureIdentity(): VaultIdentityFile;
/**
* Record (idempotently) which principal this identity enrolled as. A
* DIFFERENT already-recorded principal is refused — a deliberate migration
* deletes/replaces the identity, it never silently rebinds.
*/
bindIdentityPrincipal(principalId: string): void;
/** The signing keypair, or `null` when the signing seed is lost (read-only principal). */
signingKeypair(identity?: VaultIdentityFile): VaultSigningKeypair | null;
/** The encryption keypair, or `null` when the X25519 private key is lost. */
encryptionKeypair(identity?: VaultIdentityFile): VaultEncryptionKeypair | null;
private requireIdentity;
readRepo(repoId: string): VaultRepoFile | null;
listRepoIds(): string[];
/**
* vault-offline-clone-resolve (design D1/D2/D6, task 1.1): the
* keystore's project→repo lookup that lets {@link resolveVaultAddress}
* (`vault-address.ts`) answer an id-form address WITHOUT the network
* `findVaultByProject` round trip. D2's completeness argument: decrypting
* anything requires exactly this repo file (`k_repo_hex`), so "this
* keystore holds a matching file" is exactly the set of clones that can
* complete regardless of how resolution went — the network call was only
* ever confirming a fact this machine already held.
*
* `orgId`, when supplied, is a HARD equality filter, never a hint: a
* `project_id` match under a different `org_id` is a miss (D1, "mismatched
* org → treat as miss, never guess") — an org transfer or an id collision
* must never resolve to the wrong vault silently. Passing `undefined`
* accepts any org for that project id.
*
* Multiple repo files can legitimately name the same `project_id` — most
* commonly a re-vaulted project that left its old file behind under its
* old `repo_id` on THIS machine. D3: the most-recently-modified file (by
* filesystem mtime — D6 rules out a maintained on-disk index for v1) wins;
* a wrong pick is indistinguishable from an ordinary stale pin and heals
* through the same {@link recoverStaleVaultPin} first-use-refusal path.
*
* Cheap by construction (D6): one directory listing plus one JSON read per
* candidate — no maintained project→repo index. NEVER throws: a corrupt,
* unreadable, or concurrently-deleted repo file is skipped exactly as a
* cache miss, because a project-id scan has no single `repo_id` to blame a
* throw on (unlike {@link readRepo}'s single-id contract, where throwing on
* corruption is the right, attributable failure). Returns `null` on no
* match, an empty keystore, or a not-yet-created `repos/` directory.
*/
findRepoByProject(projectId: string, orgId?: string): VaultRepoFile | null;
/** Save (create or replace) a repo file under the per-repo lock. */
saveRepo(repo: Omit<VaultRepoFile, "version" | "updated_at">): VaultRepoFile;
/** Update the dual pins / last ref transaction without touching key material. */
updateRepo(repoId: string, patch: Partial<Pick<VaultRepoFile, "head_pin" | "materialized_pin" | "verified_prefix" | "last_ref_transaction" | "epoch" | "envelope_recipient_pins" | "k_repo_hex" | "epoch_keys" | "known_pin_manifest" | "checkpoint_covers_through" | "object_store_origins" | "writer_set_pin" | "writer_status" | "pending_writer_admission">>): VaultRepoFile;
/**
* vault-object-host-predial (design D1/D4, task 1.2): record that
* `origins` (already-normalized `scheme://host` strings) were observed
* serving THIS repo's objects — the transport's write-through-on-change
* hook. Most-recently-observed wins ties over anything already recorded;
* deduped; capped at {@link VAULT_OBJECT_STORE_ORIGINS_CAP}. A true
* no-op (no lock taken, no write, `updated_at` untouched) when the
* resulting set is IDENTICAL — in order — to what is already on disk, so
* the steady state (every session re-observes the same one or two
* origins) costs nothing. Best-effort by contract: a missing repo file
* (this call racing a not-yet-`saveRepo`'d creation) or any read/write
* failure is swallowed — this is a latency hint, never load-bearing, and
* must never surface into a caller's own object-read path.
*/
recordObjectStoreOrigins(repoId: string, origins: readonly string[]): void;
private objectsRepoDir;
private objectsGenesisPath;
private objectsHeadsDir;
private objectsCarriersDir;
private objectsHeadPath;
private objectsCarrierPath;
private readCachedFile;
private writeCachedFile;
private decodeCachedBytes;
/** Genesis is one small object per vault, immutable forever — cached and never evicted. */
readCachedGenesis(repoId: string): {
sha256: string;
bytes: Uint8Array;
} | null;
writeCachedGenesis(repoId: string, sha256: string, bytes: Uint8Array): void;
readCachedHead(repoId: string, generation: string): {
sha256: string;
bytes: Uint8Array;
} | null;
/** Writes the head, then evicts anything more than {@link VAULT_OBJECT_CACHE_WINDOW} generations behind it. */
writeCachedHead(repoId: string, generation: string, sha256: string, bytes: Uint8Array): void;
readCachedCarrier(repoId: string, objectId: string): {
sha256: string;
bytes: Uint8Array;
generation: string;
} | null;
/** Writes the carrier (keyed by `object_id`, tagged with the generation that referenced it), then sweeps the window. */
writeCachedCarrier(repoId: string, objectId: string, generation: string, sha256: string, bytes: Uint8Array): void;
/**
* Drop heads and carriers more than {@link VAULT_OBJECT_CACHE_WINDOW}
* generations behind `latestGeneration` — genesis is exempt (kept
* forever). Runs inline on every cache write; also callable directly
* (`sweepObjectCache`, wired into `repos gc`) as a periodic backstop that
* catches any orphan a crashed write might have left behind. Best-effort:
* an unlink failure (e.g. already gone) is silently ignored, matching the
* cache's own "a miss just refetches" posture.
*/
private evictObjectCache;
/** The `repos gc`-time sweep (task 4.2): re-applies the SAME eviction window as an inline write would, for every repo this keystore holds. Safe to call on a repo with no cache directory at all (a no-op). */
sweepObjectCache(repoId: string): void;
/**
* Record a COMMITTED epoch rotation (D194, rev 42): advances the local
* "current" pointer (`epoch` + `k_repo_hex`) to the new epoch's key AND
* appends it to `epoch_keys` (never overwriting a prior entry — every key
* this principal has ever held stays locally available for historical
* decrypt / the D195 prior-key-inequality check on the NEXT rotation).
* Audited distinctly from an ordinary `updateRepo` patch.
*/
recordEpochRotation(repoId: string, input: {
new_epoch: string;
new_k_repo_hex: string;
}): VaultRepoFile;
saveRecoveryReceipt(receipt: VaultRecoveryReceipt): void;
readRecoveryReceipt(repoId: string): VaultRecoveryReceipt | null;
/**
* Run `fn` holding the per-repo lock (`repos/<repo_id>.lock/`, mkdir-atomic).
* A lock older than `staleAfterMs` whose recorded pid is dead is reclaimed
* (audited). Re-entrant for the same process+repo.
*/
withRepoLock<T>(repoId: string, fn: () => T, options?: VaultLockOptions): T;
private readonly held;
private reclaimStaleLock;
/**
* Derive the partial-loss transition for one vault. `expectedGenesisSha256`
* (from a freshly listed genesis, 5.4) turns a pin mismatch into `stale_pin`.
*/
assess(repoId: string, expectedGenesisSha256?: string): VaultKeystoreState;
/**
* §5.1 "repo file lost + identity intact → restore K_repo from own envelope".
* The genesis is checked (signature + key bindings against the stored
* envelope + the recovery receipt/pin when one survives) BEFORE the envelope
* is opened; the restored file is marked `restored_from_envelope`.
*/
restoreRepoFromEnvelope(input: {
genesis: VaultGenesis;
envelope: VaultKeyEnvelope;
/** A surviving receipt (on disk or supplied) authenticates the genesis; without one the restore is labeled unauthenticated salvage. */
recovery_receipt?: VaultRecoveryReceipt | null;
/**
* vault-agent-envelopes D4: the caller verified genesis's creator
* fingerprints against the control plane's SIGNED allocation record. That
* is platform-attested consistency, NOT independent authentication — the
* platform serves both sides of the comparison — so it labels the restore
* `platform_attested`, never `receipt`. `continuity` says whether this
* keystore had already pinned this genesis (a later open) or is seeing it
* for the first time.
*/
allocation_attested?: boolean;
/**
* vault-multi-writer (rev 47): the vault's CURRENTLY admitted writers,
* resolved by the caller from the chain-verified writer set (the admitted
* heads' `add_writer_key` / `writer_set_update` transitions). A
* non-creator envelope may be wrapped by ANY admitted writer — whichever
* key-holder reconciled first — so `created_by` must name the genesis
* writer OR one of these, and the envelope's signature is verified under
* THAT writer's key. Omitted: only the genesis writer is accepted (V0).
*/
admitted_writers?: readonly {
writer_key_id: string;
signing_pubkey: string;
}[];
}): Promise<{
repo: VaultRepoFile;
trust: "receipt" | "platform_attested" | "unauthenticated_salvage";
continuity: "first_seen" | "pinned";
independently_verified: boolean;
}>;
/** Identity fingerprints for display/doctor (never key material). */
identitySummary(): {
signing_fingerprint: string;
encryption_fingerprint: string;
signing_key_present: boolean;
encryption_key_present: boolean;
} | null;
}
//# sourceMappingURL=vault-keystore.d.ts.map