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, and it is billable by weight rather than by count — an 11-byte manifest is charged as 11 bytes of egress. 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 27 July 2026, the two billable pieces are metered on different axes. storage.object.put is $0.0001 per call, so the nightly write is a fixed, countable cost. storage.object.get is $0.104 per GB of response body, so reading through the API costs whatever you actually pulled — nothing for a manifest, real money for a dump.
That is the reason this design routes the operator’s download through a presigned URL rather than through object/get. Those bytes move between the browser and the storage host, so they never become an API read at all; what remains on the bill is stored GB-months plus egress. Design a restore path around volume, in other words, not around how many times somebody clicks Restore — a weekly rehearsal against a 40 GB dump and a weekly rehearsal against a 40 MB dump differ by three orders of magnitude while looking identical in your request logs.
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, and a backup flow is where it bites hardest, because a backup is never only a file. The schedule that takes the dump is POST /v1/cron/create, the retry for a leg that failed at 03:00 is POST /v1/queue/publish, the “last night’s backup did not run” message to the on-call engineer is POST /v1/email/send, and the stack trace behind it is POST /v1/errors/capture. Every one of those is already on the same account as the bucket holding the dump — no second vendor to onboard before the alerting half of your backup story exists, and no second bill for it.
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.