@krovacloud/sdk
v0.4.14
Published
Official TypeScript SDK for Krova Cloud — a typed client for provisioning and managing Cubes (Firecracker microVMs) with dedicated resources.
Downloads
1,430
Maintainers
Readme
@krovacloud/sdk
The official TypeScript SDK for the Krova Cloud API — a fully typed client for provisioning and managing Cubes (Firecracker microVMs).
Highlights
- Fully typed — request bodies, responses, and path params are generated from the Krova Cloud OpenAPI spec via
openapi-typescript+openapi-fetch. - Ergonomic helpers —
client.cubes,domains,snapshots,tcpMappings,imports,backups,webhooksandcatalogunwrap the response body and throw a typedKrovaErroron failure. - Full escape hatch —
client.rawexposes the underlying typed client for every operation in the bundled OpenAPI spec (Domains, TCP mappings, Snapshots, Backups, Imports, Webhooks, and more). - Zero-config resilience — automatic retries on
429/503, honoringRetry-After. - ESM + CJS — ships both, with bundled
.d.tsdeclarations. No runtime dependencies beyondopenapi-fetch.
Install
npm i @krovacloud/sdk
# or: pnpm add @krovacloud/sdk
# or: yarn add @krovacloud/sdkRequires Node.js ≥ 18 (uses the global fetch).
Quickstart
import { KrovaClient, KrovaError } from "@krovacloud/sdk";
const krova = new KrovaClient({
apiKey: process.env.KROVA_API_KEY!, // a "kro_..." token
});
// List Cubes in a Space
const cubes = await krova.cubes.list("space_123");
console.log(cubes);
// Create a Cube (sshPublicKey is required)
const cube = await krova.cubes.create("space_123", {
name: "web-server",
image: "ubuntu-24.04",
resources: { vcpu: 2, ramGb: 4, diskGb: 40 },
sshPublicKey: "ssh-ed25519 AAAA...your-key... you@host",
});
console.log(`Created cube ${cube.id} (${cube.state})`);
console.log(` ${cube.resources.vcpu} vCPU / ${cube.resources.ramGb} GB RAM, image ${cube.image}`);
// Everything needed to SSH in — including WHICH USER to log in as.
// Read `user`; do not derive it from `image`. It is "ubuntu" or "debian" on
// Cubes from images that ship a default user (both with passwordless sudo)
// and "root" on Cubes created before that change, so the image id cannot
// tell you. `cube.sshUser` on the Cube object carries the same value.
//
// `ssh.host` is the Cube's own stable SSH hostname — it survives migration
// to another server, falling back to the server's public IPv4 only when the
// Cube has no DNS record yet. If you pin host keys in your own known_hosts
// (as the CLI does), expect a one-time re-pin for any Cube you'd previously
// connected to by IP: the host string changes from an IP to a name, so it's
// a new known_hosts entry, not a compromised host.
const ssh = await krova.cubes.ssh("space_123", cube.id);
console.log(`ssh ${ssh.user}@${ssh.host} -p ${ssh.port}`);
// Power it off, then start it again
await krova.cubes.powerOff("space_123", cube.id);
await krova.cubes.wake("space_123", cube.id);
await krova.cubes.restart("space_123", cube.id); // cold restartAuthentication
Get an API key at krova.cloud → your Space settings. Keys are scoped per Space, inherit the permissions of the membership that created them, and look like kro_....
By default the client sends the key as the X-API-KEY header (the API's security scheme). If your gateway expects a bearer token instead, pass authScheme: "bearer":
const krova = new KrovaClient({ apiKey: "kro_...", authScheme: "bearer" });Keep keys secret. Never commit a key or embed it in a browser bundle. Load it from an environment variable or a secrets manager.
API reference
Every public export, with its real signature and a short example.
new KrovaClient(options)
new KrovaClient({
apiKey: string, // required — your "kro_..." token
baseUrl?: string, // default: "https://krova.cloud/api/v1"
authScheme?: "x-api-key" | "bearer", // default: "x-api-key"
maxRetries?: number, // default: 2 — retries 429/503 (honors Retry-After); 0 disables
fetch?: typeof fetch, // optional fetch override (proxy, tests)
});Throws if apiKey is missing. Exposes client.baseUrl (the resolved base URL), client.getSpace(), and the resource helpers client.cubes, client.domains, client.snapshots, client.tcpMappings, client.imports, client.backups, client.catalog (each documented in its section below), plus the raw typed client client.raw.
client.getSpace()
Resolve the Space your API key is scoped to — so you don't have to hardcode a spaceId:
const space = await krova.getSpace(); // { id, name, tier, createdAt }
const cubes = await krova.cubes.list(space.id);client.cubes
Ergonomic helpers for the Cube lifecycle. Each unwraps the response body and throws KrovaError on a non-2xx status.
| Method | Signature | Returns |
| --- | --- | --- |
| list | (spaceId: string) | the Cube list body |
| create | (spaceId, body, opts?) | the created Cube |
| get | (spaceId, cubeId) | the Cube body |
| update | (spaceId, cubeId, body) | changes the port sshd listens on inside the Cube |
| setTerminationProtection | (spaceId, cubeId, enabled) | the updated Cube. While protection is on, delete throws TerminationProtectedError (a KrovaError for the API's 409) |
| delete | (spaceId, cubeId) | enqueues deletion |
| powerOff | (spaceId, cubeId) | enqueues a power-off |
| wake | (spaceId, cubeId) | enqueues a start |
| ssh | (spaceId, cubeId) | the Cube's SSH connection info — host is its stable SSH hostname (falls back to the server's public IPv4 with no DNS record yet), plus port, user, hostKeys |
| restore | (spaceId, cubeId, snapshotId) | enqueues a restore — replaces the Cube's disk from a snapshot |
// create — sshPublicKey is required; region + userData (cloud-init) are optional.
// opts.idempotencyKey (≤255 chars, per-space) makes retries safe.
const cube = await krova.cubes.create(
"space_123",
{
name: "web-server",
image: "ubuntu-24.04",
resources: { vcpu: 2, ramGb: 4, diskGb: 40 },
sshPublicKey: "ssh-ed25519 AAAA... you@host",
region: "us", // optional — slug from catalog.regions()
userData: "#cloud-config\n", // optional — cloud-init (max 16 KB)
terminationProtection: true, // optional — refuse deletes until turned off
},
{ idempotencyKey: "deploy-2026-07-01" },
);
// get / list
const one = await krova.cubes.get("space_123", cube.id);
const all = await krova.cubes.list("space_123");
// update — the port sshd listens on inside the Cube
await krova.cubes.update("space_123", cube.id, { cubePort: 2222 });
// termination protection — delete is refused until it is turned off
await krova.cubes.setTerminationProtection("space_123", cube.id, false);
// lifecycle — powerOff, wake, delete are asynchronous (enqueued)
await krova.cubes.powerOff("space_123", cube.id);
await krova.cubes.wake("space_123", cube.id);
// Cold restart: reboots against the host's current kernel, preserving the disk.
// The only way to pick up a refreshed guest kernel — a `reboot` run INSIDE the
// Cube cannot, because the kernel is supplied by the host, and it reports no
// error. Requires the Cube to be `running`; a concurrent restart 409s.
await krova.cubes.restart("space_123", cube.id);
await krova.cubes.delete("space_123", cube.id);The Cube type is exported for your own signatures:
import type { Cube } from "@krovacloud/sdk";
// {
// id: string; name: string;
// state: "pending" | "booting" | "running" | "stopped" | "stopping" | "resizing" | "error" | "deleted";
// publicIpv4: string | null;
// resources: { vcpu: number; ramGb: number; diskGb: number };
// image: string; costPerHour: number;
// terminationProtection: boolean;
// createdAt: string; updatedAt: string;
// }client.catalog
Public catalog endpoints (no auth required by the API; the client sends your key harmlessly).
const regions = await krova.catalog.regions(); // regions with available capacity
const images = await krova.catalog.images(); // available OS images
const pricing = await krova.catalog.pricing(); // per-resource hourly rates + volume tiersDomains, snapshots & TCP mappings
Typed helpers for a Cube's attached resources — each unwraps the response and throws KrovaError on failure.
// Custom domains — create() resolves to { domain, records } (BREAKING in 0.4.0).
// The records are the DNS entries you must publish for the domain to work, so
// you can create them in the same run: a wildcard needs three, an exact host one.
const domains = await krova.domains.list("space_123", "cube_123");
const { domain, records } = await krova.domains.create("space_123", "cube_123", {
domain: "app.example.com",
port: 8080,
});
for (const r of records) console.log(`${r.type} ${r.host} → ${r.value}`);
await krova.domains.update("space_123", "cube_123", domain.id, { responseCompression: true });
await krova.domains.delete("space_123", "cube_123", domain.id);
// Snapshots + restore
const snap = await krova.snapshots.create("space_123", "cube_123", { name: "nightly" });
const snaps = await krova.snapshots.list("space_123", "cube_123");
await krova.cubes.restore("space_123", "cube_123", snap.id); // replace the disk from a snapshot
await krova.snapshots.delete("space_123", "cube_123", snap.id);
// TCP port mappings (expose a Cube port on the host)
const mapping = await krova.tcpMappings.create("space_123", "cube_123", {
cubePort: 5432,
whitelistedIps: ["203.0.113.4/32"],
});
// `mapping.host` is where to connect — the Cube's OWN stable hostname, which
// survives migration to another server. Use it rather than the Cube's
// `serverDomain`, which is the current physical host and changes when the
// Cube moves. Null only when the Cube has no server assigned.
console.log(`psql -h ${mapping.host} -p ${mapping.hostPort}`);
await krova.tcpMappings.list("space_123", "cube_123");
await krova.tcpMappings.delete("space_123", "cube_123", mapping.id);Domain, Snapshot, and TcpMapping are exported for your own signatures.
domains.records — the DNS records a domain needs
The same records domains.create() returns, but each one checked against live
DNS. Poll it after publishing them — summary.complete turns true only once
every record is found. Each call performs real DNS lookups and is rate
limited, so poll on an interval rather than in a tight loop.
const status = await krova.domains.records("space_123", "cube_123", domain.id);
// { domain, isWildcard, records: DnsRecordStatus[], summary: { found, total, complete }, checkedAt }
for (const r of status.records) console.log(`${r.host}: ${r.state}`); // found | missing | mismatch | unknown
if (status.summary.complete) console.log("all records resolve — the domain can go live");Two record states are worth knowing before you alert on them: missing means
NOT PUBLISHED YET — the expected state before the records are created, never an
error — and unknown means the lookup itself could not complete, which says
nothing about your DNS. Each record also carries mustBeGrey and proxyOk:
the routing record may sit behind Cloudflare's proxy, the _acme-challenge
record must not — automation needs both flags, one alone would let you
orange-cloud the single record that has to stay grey.
DnsRecord (what create returns) and DnsRecordStatus (the checked variant,
adding state, detail, observed) are exported for your own signatures.
Imports & backups
Move .cube archives in and out. imports.create returns a multipart upload target; upload the archive to the presigned parts, then call imports.complete.
const start = await krova.imports.create("space_123", { name: "restored", fileSizeBytes: 1_048_576 });
// ...upload the archive to start.parts (presigned URLs)...
await krova.imports.complete("space_123", start.importId, { parts, config });
const status = await krova.imports.get("space_123", start.importId);
// Export: get a time-limited download URL for a backup archive
const dl = await krova.backups.download("space_123", "backup_123");The download URL is valid for 15 minutes and needs no further authentication. Anyone holding it can fetch the whole disk, so treat it as a secret.
Backups and sharing them with another space
A backup is a Cube's disk and configuration, kept after the Cube is gone. You can offer another space its own copy: nothing is copied until that space accepts, within 48 hours, and from then the copy is the destination's own and billed to it. Your backup and bill are unchanged, and deleting either copy leaves the other alone.
const backups = await krova.backups.list("space_123");
const backup = await krova.backups.get("space_123", backups[0]!.id);
// Offer a copy. Always a pending request through the API, even when your user owns both spaces.
const share = await krova.backups.share(
"space_123",
backup.id,
{ destinationSpaceId: "space_456" },
{ idempotencyKey: "share-nightly-2026-10-07" },
);
// In the destination space: list what is waiting, then accept or decline.
const { incoming, outgoing } = await krova.backupShares.list("space_456");
const { backup: copy } = await krova.backupShares.accept("space_456", share.id);
// copy.sharedFromBackupId === backup.id
// Or: krova.backupShares.decline("space_456", share.id)
// The source can withdraw it before then: krova.backupShares.cancel("space_123", share.id)These calls need the Backups permissions on the key's member: View Backups to list and get, Manage Backups for everything else. Only the destination space's owner, or a member there with Manage Backups, can accept or decline. accept throws a KrovaError with status 422 when the destination cannot take the copy (no card or credit, owner not verified, an open abuse notice, or no backup allowance left on its plan).
Webhooks
client.webhooks manages a space's webhook endpoints. create is the only call that returns the signing secret, so store it straight away. To verify deliveries, use @krovacloud/webhook.
const endpoint = await krova.webhooks.create("space_123", {
url: "https://example.com/hook",
events: ["cube.running", "cube.stopped"],
description: "deploy notifier", // optional
});
// endpoint.secret is returned only here: store it now
await krova.webhooks.list("space_123");
await krova.webhooks.get("space_123", endpoint.id);
await krova.webhooks.deliveries("space_123", endpoint.id, { limit: 20 }); // last 30 days
await krova.webhooks.delete("space_123", endpoint.id);client.raw — every endpoint
The helpers cover Cubes, the catalog, domains, snapshots, TCP mappings, imports, backups, and webhooks. When you would rather handle the error yourself than catch an exception, use client.raw, the fully typed openapi-fetch client. It returns { data, error, response } and never throws:
const { data, error } = await krova.raw.GET("/spaces/{spaceId}/cubes/{cubeId}", {
params: { path: { spaceId: "space_123", cubeId: "cube_123" } },
});
if (error) {
console.error("Cube lookup failed:", error.error);
} else {
console.log(data);
}Path, method, params, and body are all type-checked against the spec. The generated paths and components types are also exported for advanced use:
import type { paths, components } from "@krovacloud/sdk";
type Domain = components["schemas"]["Domain"];KrovaError
Thrown by every helper on any non-2xx response. (client.raw never throws — it returns the error in { error }.)
| Field | Type | Source |
| --- | --- | --- |
| status | number | HTTP status code |
| message | string | the API's error string, else statusText |
| code | string \| undefined | X-Error-Code response header |
| requestId | string \| undefined | X-Request-Id response header |
| body | object \| undefined | the parsed JSON error body |
| response | Response \| undefined | the raw Response |
import { KrovaError } from "@krovacloud/sdk";
try {
await krova.cubes.get("space_123", "cube_missing");
} catch (err) {
if (err instanceof KrovaError) {
console.error(`[${err.status}] ${err.message}`);
if (err.code) console.error("code:", err.code);
if (err.requestId) console.error("request id:", err.requestId); // quote this to support
} else {
throw err;
}
}Mutating POST / DELETE endpoints are rate-limited (10 requests / 60s per client IP). The client automatically retries 429 and 503 up to maxRetries times, honoring the Retry-After header.
Configuration
Point the client at a different base URL (self-hosted gateway, staging, a proxy):
const krova = new KrovaClient({
apiKey: "kro_...",
baseUrl: "https://gateway.internal/krova/api/v1",
});TypeScript
The package ships its own type declarations — no @types/* install needed. Cube, Backup, BackupShare, KrovaError, KrovaClientOptions, AuthScheme, and the generated paths / components are all exported.
Related packages
| Package | What it is |
| --- | --- |
| @krovacloud/cli | Command-line interface for Krova Cloud |
| @krovacloud/webhook | Verify and parse Krova Cloud webhook events |
| @krovacloud/mcp | Model Context Protocol server for Krova Cloud |
| n8n-nodes-krova | n8n community nodes for Krova Cloud |
Requirements
- Node.js ≥ 18 (global
fetch). - Works in any modern runtime with a WHATWG
fetch(Deno, Bun, edge). Pass a customfetchif the global isn't available.
Contributing
See CONTRIBUTING.md. Report security issues privately per SECURITY.md.
Custom domains: HTTPS to the Cube
By default the edge reaches your Cube over cleartext HTTP — visitors are on
HTTPS either way, since TLS terminates at the edge. Set originScheme: "https"
when the Cube terminates TLS itself: a control panel holding its own
certificate, or an app listening on HTTPS, answers plain HTTP with a redirect
and cannot be served over cleartext at all.
// When attaching the domain…
await krova.domains.create(spaceId, cubeId, {
domain: "panel.example.com",
port: 443,
originScheme: "https",
});
// …or switch an already-attached domain over.
await krova.domains.update(spaceId, cubeId, mappingId, {
originScheme: "https",
});The dial port is derived: https on the default port 80 connects on 443, and a
deliberate custom port is honored exactly. The setting applies as saved, with no
check: if nothing in the Cube answers HTTPS for the domain, visitors get an
error page until you set it back to http.
Custom domains: the visitor's address on the connection
The edge always passes the visitor's address in the X-Real-IP and
X-Forwarded-For headers. For software that reads it from the TCP connection
instead, set proxyProtocol to "v1" (a line of text) or "v2" (binary): the
edge then sends a PROXY protocol header naming the visitor at the start of every
connection to the Cube. null turns it off.
await krova.domains.update(spaceId, cubeId, mappingId, { proxyProtocol: "v2" });The app must accept the PROXY protocol on that port before you turn it on, or
every request to the domain fails. An app accepts it per port, so every domain
mapped to the same Cube port needs the same setting: a create or update that
would mix them is rejected with a 409 whose message names the other domains.
Send confirmMixedProxyProtocol: true to save anyway, when the app handles both
on that port.
License
MIT © 2026 Krova Inc.
