Export download links: the link clock and the object clock are different
Signed-link TTL and object retention are two separate timers, and only one of them revokes anything. How to set both for SaaS exports, with a Node 22 download endpoint.
Every temporary download link for a user export runs on two timers, and most implementations only set one. The signed URL has a TTL, capped at 604800 seconds on Infrai storage, and the exported object has a retention window governed by a bucket lifecycle rule. The first controls how long a specific link works. The second controls how long the data exists at all — and it’s the only one of the two that actually revokes anything.
Getting that backwards produces a familiar bug. A CSV export from March is still sitting in the bucket in July, its original link long dead, and the moment anyone mints a fresh signature the data is downloadable again. Infrai’s presign route will happily do that, because minting is a free, unconditional operation: it doesn’t check the object exists, and it certainly doesn’t check whether you still wanted it to.
Set both clocks
The object clock is a lifecycle rule, and it belongs on the prefix you write exports into:
export INFRAI_API_KEY=your_infrai_api_key
curl -sS -X POST https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kbg-exports-0726 \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"rules":[{"prefix":"exports/","expire_days":7}]}'
The rule set replaces whatever was there — it isn’t merged — so send the full list each time. The minimum window is one day, which makes lifecycle a retention policy and not a scheduler; if you need an export gone in four hours, delete it from a job.
The link clock is expires_seconds on each mint, and it’s bounded:
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kbg-exports-0726/exports/acct_771/orders-2026-07-26.csv" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":2592000}'
{
"ok": false,
"error": {
"code": "STORAGE_INVALID_TTL",
"http_status": 400,
"message": "ttl_seconds 2592000 out of range [1..604800]",
"retryable": false
}
}
Seven days is the ceiling. That’s a sensible bound and you should treat it as far more than you need.
| TTL | What it’s good for | What it costs you |
|---|---|---|
| 60s | Immediate click-through from a page you just rendered | Breaks on a slow mobile connection or a paused download |
| 300s | The default we’d pick for a “Download” button | Nothing much; long enough for a retry, short enough to be uninteresting once leaked |
| 3600s | Links handed to a support agent to relay | An hour of exposure if the URL leaks into a log or a chat |
| 86400s | A link inside an email the user opens later | A full day live in an inbox that may not be theirs any more |
| 604800s | Nothing. This is the ceiling, not a target | A week-long bearer token in plain text |
For emailed exports, don’t put the signed URL in the message at all. Link to a route on your own domain, authenticate the click, and mint there. The email then contains a URL that means “the export for account 771”, not “these bytes, to whoever holds this string”.
The signature gates access; it still can’t revoke
Worth proving to yourself, because it decides where the authorisation has to live:
URL=$(curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kbg-exports-0726/exports/acct_771/orders-2026-07-26.csv" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":600}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'])")
curl -sS -o /dev/null -w "signed %{http_code}\n" "$URL"
curl -sS -o /dev/null -w "unsigned %{http_code}\n" "${URL%%\?*}"
That prints signed 200 and unsigned 403. Take the query string off and the storage host serves nothing, so the object is genuinely private and the signature is the boundary.
What the signature can’t do is come back. Once minted, that string works for anyone holding it until the TTL runs out; there’s no revocation call, and public-read isn’t even an option — set_acl rejects it with STORAGE_ACL_INVALID. So an export has four protections and they do different jobs: keys nobody can guess (derive them server-side from an account id and a random token, never from a filename a user chose), a short TTL so a leaked link dies quickly, authorisation in the endpoint that mints, and a lifecycle rule that removes the bytes — the last being the only one that takes anything away after the fact.
Three states, one endpoint
Your UI needs to distinguish “still building”, “ready”, and “the retention window closed” — and the third one is where most export features fail, because a 404 from a dead link looks identical to a bug.
import { createServer } from "node:http";
const API = "https://api.infrai.cc";
const BUCKET = "kbg-exports-0726";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const auth = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };
async function exportState(accountId, filename) {
const objectKey = `exports/${accountId}/${filename}`;
const head = await fetch(`${API}/v1/storage/object/head/${BUCKET}/${objectKey}`, { headers: auth });
if (!head.ok) throw new Error(`head ${head.status}: ${await head.text()}`);
const meta = (await head.json()).data;
if (!meta?.found) return { state: "expired" };
const res = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${objectKey}`, {
method: "POST",
headers: auth,
body: JSON.stringify({
op: "get",
expires_seconds: 300,
response_disposition: `attachment; filename="${filename}"`,
}),
});
if (!res.ok) throw new Error(`presign ${res.status}: ${await res.text()}`);
const { data } = await res.json();
return { state: "ready", url: data.url, expiresAt: data.expires_at, sizeBytes: meta.size_bytes };
}
createServer(async (req, res) => {
const m = req.url?.match(/^\/exports\/([a-z0-9_]+)\/([a-z0-9._-]+)$/i);
if (!m) { res.writeHead(404).end(); return; }
// Authorise: does the session own account m[1]? Do that before anything else.
try {
const out = await exportState(m[1], m[2]);
if (out.state === "expired") {
res.writeHead(410, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "export expired", retention_days: 7 }));
return;
}
res.writeHead(302, { location: out.url, "cache-control": "no-store" });
res.end();
} catch (err) {
console.error("export lookup failed", err);
res.writeHead(502).end();
}
}).listen(8080);
410 rather than 404 is deliberate. It tells the client the resource existed and is gone on purpose, which is exactly the message the UI should render — “this export expired after 7 days, generate a new one” — instead of a generic error.
The filename the user actually gets
response_disposition works, and it’s the difference between orders-2026-07-26.csv and a browser saving a 40-character object key. Check what the link really sends before you ship it:
curl -sS "https://api.infrai.cc/v1/storage/object/head/kbg-exports-0726/exports/acct_771/orders-2026-07-26.csv" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
One caveat: you can rename the download but you can’t make it inline. Every object GET carries Content-Disposition: attachment and a force-download header, and passing inline doesn’t override it. For a CSV export that’s what you wanted; for a preview of a PDF it isn’t, and you’d need your own proxy route to serve those bytes with headers you control.
What it costs
Presign, head, list, lifecycle and delete are free and rate-limited. A write is $0.0001 per object/put, and new accounts start with $2 free credit.
Pulling an export back through the API is priced differently: object/get meters egress at $0.104 per GB rather than charging per call, so for exports — the one workload where a single object can be hundreds of megabytes — the cost follows file size, not download count. That is a real argument for redirecting the browser to a signed link instead of streaming the bytes through your own route: same entitlement check, but the file never crosses the API. Verified 2026-07-27 — rates on this API have trended downward, so read them live:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" | \
python3 -c "import json,sys; d=json.load(sys.stdin); print([(c['id'], c['billing'].get('price_usd')) for c in d['capabilities'] if c['id'].startswith('storage.')])"
If exports are the whole product, S3 with bucket policies and object-lock, or Cloudflare R2 behind a Worker that enforces per-request authorisation, will give you controls this API doesn’t support — real revocation, IP conditions, audit trails per download. Stick with those when compliance asks for them by name. The reason to keep exports here is the rest of the pipeline. POST /v1/cron/create schedules the report, POST /v1/queue/publish builds it, POST /v1/email/send announces it and GET /v1/account/usage attributes the cost to a tenant — all already on the same account as the bucket, with no second vendor to sign up for and one bill at the end of the month.