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"
}
}
One quirk we hit in testing: object/list reports content_type as null even when the object has one. If you need the MIME type of every item, head each key — it’s free — rather than trusting the list rows.
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 — no CORS setter | 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
Reads cost about twice writes here. As verified on 2026-07-26, object/put runs $0.0001 per call and object/get $0.0002 per call, while head, list, bucket/create and bucket/usage are free (rate-limited). New accounts get $2 in credit, which is roughly 19,999 uploads. Storage and egress are metered separately by GB.
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}"
At three CSVs per user per day and a thousand users, you’re at roughly $0.09 a day in call charges. 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. Infrai doesn’t support setting bucket CORS rules, so browser-direct upload into an Infrai bucket can’t work today; if that’s your requirement, Cloudflare R2 or S3 is the right answer and it isn’t close. And a presigned URL’s signature encodes expiry, not authorisation — objects are readable through the API with a valid key regardless, so keep the real permission check in your own route and never treat an unguessable link as access control.
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.