The restore half of a backup: an admin download flow in Node 22
Listing restore points, verifying a dump before you offer it, handing an operator a five-minute signed link, and rehearsing the restore that follows.
An admin panel that restores backups needs four things and no more: a list of restore points, a check that the one you’re about to offer is intact, a short-lived signed link so the operator’s browser pulls bytes straight from storage, and an audit row saying who asked. Infrai covers the first three with free calls — GET /v1/storage/object/list/{bucket}, GET /v1/storage/object/head/{bucket}/{key} and POST /v1/storage/object/presign/{bucket}/{key} — and the fourth belongs in your database.
What you should not build is a /admin/download route that streams a 40 GB dump through Node. Your admin server becomes a bandwidth bottleneck, a memory risk and a single point of failure in the exact moment you can least afford one.
Listing the restore points
Backups written under a dated prefix list in lexicographic order, which for ISO dates is chronological order for free:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/storage/object/list/kb-restore-0726?prefix=daily/" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{ "key": "daily/2026-07-25/db.sql.gz", "size_bytes": 21, "etag": "cf899d4c6c7b3f1043bed5adc6fbdbf7", "last_modified": "2026-07-26T00:36:21Z" },
{ "key": "daily/2026-07-25/manifest.json", "size_bytes": 11, "etag": "03fc5f23a9ffb188ad89e26b1e0c094d", "last_modified": "2026-07-26T00:36:22Z" }
],
"next_cursor": null
}
}
next_cursor is null here because two objects fit in one page. With a year of nightly runs it won’t be, and the rule is to page until it is — pass the value back as cursor and keep going. A restore UI that silently shows the first page is a restore UI that hides your oldest recovery point.
Verify before you offer it
head costs nothing, transfers nothing, and answers the only question that matters before you show a “Restore” button:
curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-restore-0726/daily/2026-07-25/db.sql.gz" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "daily/2026-07-25/db.sql.gz",
"size_bytes": 21,
"etag": "cf899d4c6c7b3f1043bed5adc6fbdbf7",
"content_type": "application/gzip",
"last_modified": "2026-07-26T00:36:21Z"
}
}
Compare size_bytes against the manifest your backup job wrote. A dump that’s 4 KB when last week’s was 900 MB is a truncated pg_dump, and the time to find that out is now — not at 03:00 during an incident, which is the only other time anyone reads this screen.
The manifest itself is small enough to pull through the API, base64 in the JSON body:
curl -sS "https://api.infrai.cc/v1/storage/object/get/kb-restore-0726/daily/2026-07-25/manifest.json" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "daily/2026-07-25/manifest.json",
"size_bytes": 11,
"data_base64": "eyJmaWxlcyI6Mn0="
}
}
That’s the one billable read in this flow. Everything else on this page is free.
The admin route: authorise, audit, then sign
import express from "express";
import { Pool } from "pg";
import { requireAdmin } from "./auth.mjs";
const API = "https://api.infrai.cc";
const BUCKET = "kb-restore-0726";
const LINK_TTL = 300;
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const app = express();
app.post("/admin/restore-points/:day/link", requireAdmin, async (req, res) => {
const day = req.params.day;
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return res.status(400).json({ error: "bad day" });
const key = `daily/${day}/db.sql.gz`;
try {
const head = await fetch(`${API}/v1/storage/object/head/${BUCKET}/${key}`, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
});
const meta = (await head.json()).data;
if (!meta?.found) return res.status(404).json({ error: "no backup for that day" });
if (meta.size_bytes < 1024) return res.status(409).json({ error: "backup looks truncated", size_bytes: meta.size_bytes });
await pool.query(
"INSERT INTO restore_audit (admin_id, object_key, etag, size_bytes) VALUES ($1,$2,$3,$4)",
[req.admin.id, key, meta.etag, meta.size_bytes],
);
const signed = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${key}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ op: "get", expires_seconds: LINK_TTL }),
});
const slot = await signed.json();
if (!signed.ok || slot.ok === false) throw new Error(slot?.error?.code ?? `HTTP ${signed.status}`);
res.json({ url: slot.data.url, expires_at: slot.data.expires_at, etag: meta.etag, size_bytes: meta.size_bytes });
} catch (err) {
console.error("restore link failed", err);
res.status(502).json({ error: "could not issue link" });
}
});
app.listen(3000);
Audit before signing, not after. If the process dies between the two, you’d rather have a log line for a link that was never issued than a download nobody can account for.
Five minutes is deliberate. It’s long enough to click and start a transfer — downloads that have begun aren’t interrupted when the signature expires — and short enough that a URL pasted into a group chat is useless by the time anyone else opens it. The API will let you go to 604800 seconds, but a week-long link to a database dump is a credential in a query string.
Rehearsing the restore
A backup you’ve never restored is a hypothesis. Prove it monthly against a scratch database, using the same signed link the admin panel hands out:
set -euo pipefail
DAY="2026-07-25"
SIGNED_URL=$(curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-restore-0726/daily/${DAY}/db.sql.gz" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":900}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'])")
curl -sS --fail-with-body -o /tmp/db.sql.gz "${SIGNED_URL}"
gunzip -t /tmp/db.sql.gz
dropdb --if-exists restore_check
createdb restore_check
gunzip -c /tmp/db.sql.gz | psql -v ON_ERROR_STOP=1 -d restore_check >/dev/null
psql -d restore_check -Atc "SELECT count(*) FROM users" | tee /tmp/restore_rowcount
rm -f /tmp/db.sql.gz
gunzip -t before psql is the cheap half of the test — a corrupt archive fails in a second instead of forty minutes in. The row count at the end is what you actually compare against production, and it’s the number to put in the runbook.
What the restore path costs
Listing, head and presigning are free and rate-limited, and none of them draw down the new-account trial. Verified 26 July 2026, the billable pieces are storage.object.get at $0.0002 per call (the manifest read) and storage.object.put at $0.0001 (the nightly write), plus stored GB-months and egress GB for the bytes the operator pulls. A monthly restore rehearsal of a 2 GB dump is dominated entirely by that egress — the call fees round to nothing.
curl -sS https://api.infrai.cc/v1/discovery \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"
Storage pricing trends downward and discounts run, so read that live rather than trusting the figures above six months from now. The consolidation argument is the durable one: the cron that takes the backup, the queue that retries a failed leg, the email that tells the on-call engineer, and the storage holding the dump all sit behind one key and one invoice, with per-tenant cost as a query rather than a spreadsheet merge.
When another backend is the better home
| Requirement | Best fit | Why |
|---|---|---|
| Nightly dumps, restore from an admin panel | Infrai bucket + signed links | Free listing and signing, one key with the rest of the stack |
| Seven-year archival at the lowest possible $/GB | Amazon S3 Glacier, Backblaze B2, Wasabi | Cold tiers and retrieval pricing this API doesn’t expose |
| Provider-enforced immutability for audit | S3 Object Lock, or MinIO with locking | No support for version IDs or write-once retention here |
| Air-gapped or on-premise | MinIO | You keep the hardware and the keys |
If your backup story is mostly “keep 10 TB cheaply for a decade”, the cold-tier vendors win on the axis that matters and you should stick with them. If it’s “restore last night’s dump before the standup”, the flow above is the whole implementation.