Simplest CSV round-trip storage for a Codex-built data app
Users upload a CSV, your app writes a processed file back. One private bucket, two prefixes, base64 JSON PUT — runnable code, real responses, honest limits.
Keep it boring: one private bucket, two key prefixes, one JSON call per file. For CSVs in the tens of kilobytes you don’t need presigned URLs, multipart uploads or a spreadsheet-import SaaS. Infrai’s storage API accepts the file as base64 inside an ordinary PUT and hands it back the same way, so writing and reading are the same shape of request your generated app already knows how to make.
Files this small make most of the clever patterns a net loss.
A 40 KB CSV encodes to roughly 54 KB of base64. That’s a single request, well inside any sane body limit, and it finishes in one round trip instead of the three (ask for a URL, upload to it, tell the backend you’re done) that the presigned dance costs. Codex and similar generators love to scaffold the presigned dance because it’s what the S3 tutorials show. For tens of KB it’s ceremony.
One bucket, two prefixes
Object storage has no folders, only key prefixes that look like folders. That’s all the structure this app needs: uploads/ for what the user sent, results/ for what you computed.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"kb-csv-0726","region":"ap-singapore","acl":"private"}'
{
"ok": true,
"data": {
"bucket_id": "bkt_42ab24bdad2d481094d2b2",
"name": "kb-csv-0726",
"vendor": "cos",
"region": "ap-singapore",
"acl": "private",
"created_at": "2026-07-26T00:50:39.892032Z",
"cors_rules": [],
"lifecycle_rules": []
}
}
Bucket names are globally namespaced per account, so a second create with the same name returns STORAGE_BUCKET_EXISTS rather than clobbering anything. Treat that 409 as success in your setup script and move on.
The upload and the processing step, in one place
Here’s the whole server side. The user posts a CSV to your route, you store the original, transform it in memory, store the result under a matching key. Node 22, no dependencies.
import { Buffer } from "node:buffer";
const API = "https://api.infrai.cc";
const BUCKET = "kb-csv-0726";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
async function putObject(objectKey, text, contentType) {
const payload = {};
payload.data_base64 = Buffer.from(text, "utf8").toString("base64");
payload.content_type = contentType;
const res = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${objectKey}`, {
method: "PUT",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const json = await res.json();
if (!res.ok || json.ok !== true) {
throw new Error(`put ${objectKey} failed: HTTP ${res.status} ${JSON.stringify(json.error ?? json)}`);
}
return json.data;
}
function summarise(csv) {
const [header, ...rows] = csv.trim().split("\n");
const cols = header.split(",");
const iSku = cols.indexOf("sku");
const iQty = cols.indexOf("qty");
const iPrice = cols.indexOf("unit_price");
if (iSku < 0 || iQty < 0 || iPrice < 0) throw new Error(`unexpected header: ${header}`);
const totals = new Map();
for (const row of rows) {
const f = row.split(",");
const prev = totals.get(f[iSku]) ?? { qty: 0, revenue: 0 };
prev.qty += Number(f[iQty]);
prev.revenue += Number(f[iQty]) * Number(f[iPrice]);
totals.set(f[iSku], prev);
}
const out = ["sku,total_qty,revenue"];
for (const [sku, t] of totals) out.push(`${sku},${t.qty},${t.revenue.toFixed(2)}`);
return out.join("\n") + "\n";
}
export async function handleUpload(jobId, csvText) {
const day = new Date().toISOString().slice(0, 10);
const original = await putObject(`uploads/${day}/${jobId}.csv`, csvText, "text/csv");
const summary = await putObject(`results/${day}/${jobId}.summary.csv`, summarise(csvText), "text/csv");
return { original_key: original.key, result_key: summary.key, bytes: summary.size_bytes };
}
Note what isn’t there. No SDK, no credential file, no region config, no multipart state machine to abort when a user closes the tab. The bytes go up in the same request that carries the auth header, and a retry after a socket timeout writes identical bytes to the identical key — overwrite-in-place is the natural idempotency for a job keyed on jobId.
Reading the processed result back
The download route returns JSON with the payload in data_base64, which is a little unusual and turns out to be convenient: your read path parses one JSON body instead of streaming a response and guessing at encoding.
curl -sS "https://api.infrai.cc/v1/storage/object/get/kb-csv-0726/results/2026-07-26/orders_9f21.summary.csv" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "results/2026-07-26/orders_9f21.summary.csv",
"size_bytes": 64,
"data_base64": "c2t1LHRvdGFsX3F0eSxyZXZlbnVlCkFCQy0xLDIsMTkuMDAKQUJDLTIsMSwxOS4wMApYWVotOSw1LDE2LjI1Cg=="
}
}
In your app that becomes a download handler in about ten lines:
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
export async function fetchResult(objectKey) {
const url = `https://api.infrai.cc/v1/storage/object/get/kb-csv-0726/${objectKey}`;
const res = await fetch(url, {
method: "GET",
headers: { Authorization: `Bearer ${KEY}` },
});
const json = await res.json();
if (!res.ok || json.ok !== true) throw new Error(`get failed: HTTP ${res.status}`);
if (json.data.found !== true) return null;
return Buffer.from(json.data.data_base64, "base64").toString("utf8");
}
console.log(await fetchResult("results/2026-07-26/orders_9f21.summary.csv"));
Check found before you decode. A missing key comes back as ok: true with found: false, not as a 404 you can catch — that’s a small trap and the one place a generated handler usually gets it wrong.
Two free checks that tell you it worked
head gives you size, ETag and MIME type without paying for the bytes, and list walks a prefix.
curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-csv-0726/uploads/2026-07-26/orders_9f21.csv" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
curl -sS "https://api.infrai.cc/v1/storage/object/list/kb-csv-0726?prefix=uploads/" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "uploads/2026-07-26/orders_9f21.csv",
"size_bytes": 83,
"etag": "f958a6c599c5cc5b388259a45239125f",
"content_type": "text/csv",
"metadata": null,
"last_modified": "2026-07-26T00:50:50Z"
}
}
Worth flagging for a generated dashboard: object/list carries the same content_type, etag and metadata fields as head, so listing a prefix is usually one call where a generator will otherwise write a head per row. Both are free, but one round trip beats fifty.
The four candidate paths, weighed
| Path | Round trips | Runs on Infrai today | Sensible size range |
|---|---|---|---|
Base64 JSON PUT from your server | 1 | Yes | up to ~1 MB |
| Presign, then browser uploads direct | 3 | No — the preflight 403s | any |
| Multipart upload | 3+ | Yes | > 100 MB |
| Hosted CSV importer (EasyCSV, Transloadit) | varies | n/a | when you want mapping UI too |
The importer products are worth an honest sentence, because they solve a different problem. If what you actually need is a column-mapping widget, validation rules and a “row 47 has a bad date” UI, buy that; storage isn’t your bottleneck. If you just need the file to survive a process restart, a bucket does it for a rounding error.
What it costs, and how to check today’s number
Writes and reads bill on different meters here, which is the thing to get into your head before you size anything. As verified on 2026-07-27, object/put runs $0.0001 per call while object/get is charged by volume at $0.104 per GB, and head, list, bucket/create and bucket/usage are free (rate-limited). New accounts get $2 in credit. Stored bytes are metered separately by GB-month.
Don’t take my word for the rate — the live figures are one call away, and infrastructure prices trend down, with discount campaigns running on top, so what you read may well be lower than what’s printed here.
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']) for c in d['capabilities'] if c['id'].startswith('storage.object.') ])"
curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/kb-csv-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
For an app trading in 40 KB spreadsheets, the write meter is the one you’ll ever notice, and it takes a lot of tens-of-kilobytes files to move a gigabyte past the read meter. The bill for this app is not going to be storage.
Where this path runs out
Three limits, plainly. The base64 route isn’t meant for objects much over 1 MB — encoding inflates the body by a third, so a 10 MB export becomes a 13 MB request and you should be presigning or using multipart instead. Browser-direct upload into an Infrai bucket isn’t a path you can ship today: the rules route stores what you send it, but a preflight against the storage host still answers 403 without CORS headers, so the browser posts to your route and your route stores the file. If a generated frontend must write to the bucket itself, Cloudflare R2 or S3 is the right answer and it isn’t close. And a signature carries expiry, not identity — whoever holds a live URL can fetch it — so keep the permission check in the route that mints it.
If your app already lives inside one AWS account, @aws-sdk/client-s3 with an IAM role and no key to rotate is a perfectly good place to stay. Supabase is the better fit when you want Postgres row-level security to govern file access as well, since that’s a single policy language instead of two.
What one key buys you here is the next step rather than this one: the same credential that stored the CSV can queue the processing job, email the user when the summary is ready, and record the error if the parse throws — one account, one bill, one usage view. If storage is genuinely the only thing you need, a specialist is cheaper and you should use it.