Express, presigned URLs and user documents: uploads vs downloads
The upload leg and the download leg have different browser rules. A Node 22 Express design for PDF and DOCX that works today, multipart included.
Split the problem in two, because the browser treats the halves differently. Handing a user a signed download URL works from any page today — it’s a navigation, so no CORS rule is involved. Handing the browser a signed upload URL requires the bucket to answer a preflight, and on Infrai that’s the one piece you can’t configure yet, so the upload leg goes through a thin Express relay while the download leg goes straight from the browser to the bucket.
That asymmetry decides the architecture, so this page is organised around it rather than around a happy-path tutorial. Infrai signs both kinds of URL for free through one route, POST /v1/storage/object/presign/{bucket}/{key}, and the rest is Node.
Why the two legs differ
A cross-origin PUT from a page is never a “simple” request, so the browser fires an OPTIONS preflight first and refuses to send bytes unless the bucket replies with matching CORS headers. The Infrai API exposes a bucket’s cors_rules on GET /v1/storage/bucket/get/{bucket} but has no route to write them, and a fresh bucket comes back with an empty list. So: fetch(signedUrl, {method:"PUT"}) from a tab fails, while the identical URL works from curl, a mobile app, or your own server.
Downloads are different. When a user clicks a link and the browser navigates, there’s no preflight and no origin check — the file arrives, and Infrai’s signed GET responses carry Content-Disposition: attachment, so the browser saves rather than renders.
One consequence worth stating plainly: if a proxy-less browser upload is a hard requirement, you’d be better off on Amazon S3, Cloudflare R2 or a self-hosted MinIO, where CORS is yours to edit. Everything below assumes you’re fine relaying the upload.
A relay that doesn’t buffer
The naive Express handler reads the whole file into memory (or worse, /tmp) and then uploads it. A 60 MB DOCX times four concurrent users is how a 512 MB container dies. Pipe the request straight into the signed URL instead:
import express from "express";
const app = express();
const API = "https://api.infrai.cc";
const BUCKET = "user-documents";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const ALLOWED = new Set([
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
]);
app.put("/documents/:filename", async (req, res) => {
const contentType = req.headers["content-type"] ?? "";
if (!ALLOWED.has(contentType)) return res.status(415).json({ error: "pdf or docx only" });
const userId = req.header("x-user-id");
if (!userId) return res.status(401).json({ error: "unauthenticated" });
const key = `u/${userId}/${new Date().toISOString().slice(0, 7)}/${req.params.filename}`;
try {
const slotRes = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${key}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ op: "put", expires_seconds: 900, content_type: contentType, max_bytes: 104_857_600 }),
});
const slot = await slotRes.json();
if (!slotRes.ok || slot.ok === false) throw new Error(slot?.error?.code ?? `HTTP ${slotRes.status}`);
const upstream = await fetch(slot.data.url, {
method: slot.data.method,
headers: slot.data.headers ?? {},
body: req,
duplex: "half",
});
if (!upstream.ok) throw new Error(`vendor rejected the upload: HTTP ${upstream.status}`);
res.status(201).json({ key, etag: upstream.headers.get("etag") });
} catch (err) {
res.status(502).json({ error: String(err.message ?? err) });
}
});
app.listen(3000);
body: req with duplex: "half" is the whole trick — bytes cross your process without ever landing in a buffer you own. Memory stays flat whether the document is 200 KB or 200 MB.
The signing call itself, if you want to see it on its own:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/user-documents/u/usr_2291/2026-07/handbook.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":900,"content_type":"application/pdf","max_bytes":104857600}'
max_bytes is enforced by the signature, not by your handler, which means a client that lies about Content-Length still can’t overrun it.
Files big enough to need parts
Above a couple of gigabytes a single PUT is a bad bet — one dropped connection and the whole transfer restarts. Multipart splits it:
curl -sS -X POST https://api.infrai.cc/v1/storage/multipart/create/user-documents \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"key":"u/usr_2291/2026-07/handbook.pdf","content_type":"application/pdf"}'
{
"ok": true,
"data": {
"upload_id": "1785025689c35f92ffac286bf934062c0d9953b58dd188566de97fd6",
"bucket_id": "bkt_5b3aa9688b214ae79b4c18",
"key": "u/usr_2291/2026-07/handbook.pdf",
"started_at": "2026-07-26T00:28:09.237830Z",
"part_size_min": 5242880,
"part_count_max": 10000
}
}
part_size_min is 5 MiB and every part except the last must reach it — a 4 MB part in the middle is rejected at completion time, not at upload time, which is a nasty way to find out. Sign one URL per part, upload the chunk, keep the ETag:
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
const API = "https://api.infrai.cc";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const PART = 8 * 1024 * 1024;
export async function uploadInParts(uploadId, filePath) {
const { size } = await stat(filePath);
const parts = [];
for (let n = 1, offset = 0; offset < size; n++, offset += PART) {
const end = Math.min(offset + PART, size) - 1;
const res = await fetch(`${API}/v1/storage/multipart/presign_part/${uploadId}/${n}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const slot = await res.json();
if (!res.ok || slot.ok === false) throw new Error(`presign_part ${n}: ${slot?.error?.code ?? res.status}`);
const put = await fetch(slot.data.url, {
method: slot.data.method,
body: createReadStream(filePath, { start: offset, end }),
duplex: "half",
});
if (!put.ok) throw new Error(`part ${n} failed: HTTP ${put.status}`);
parts.push({ part_number: n, etag: (put.headers.get("etag") ?? "").replaceAll('"', "") });
}
const done = await fetch(`${API}/v1/storage/multipart/complete/${uploadId}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ parts }),
});
const result = await done.json();
if (!done.ok || result.ok === false) throw new Error(`complete: ${result?.error?.code ?? done.status}`);
return result.data;
}
Strip the quotes off each ETag before you send it back — the vendor returns "8eee9ac5…" with them, and completion wants the bare hex. If the user closes the tab, call DELETE /v1/storage/multipart/abort/{upload_id}; abandoned parts otherwise sit there billed as storage.
The download leg, which is the easy half
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/user-documents/u/usr_2291/2026-07/handbook.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":300}'
Return the url from your own API and let the page navigate to it. Five minutes is usually plenty; longer links end up pasted into chat threads. Confirm the object first with the free head call:
curl -sS \
"https://api.infrai.cc/v1/storage/object/head/user-documents/u/usr_2291/2026-07/handbook.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
How the approaches compare
| Approach | Works in a browser today | Server memory | Best for |
|---|---|---|---|
| Browser → Infrai bucket, direct | No — preflight has no CORS rule to match | None | Nothing yet; revisit when a CORS setter ships |
| Browser → Express relay → bucket | Yes | Flat, if you stream | The default for documents up to a few hundred MB |
| Server-side multipart | Yes (server does the work) | One part at a time | Multi-GB files, resumable jobs |
| Browser → S3/R2 direct | Yes | None | Teams whose only requirement is proxy-less upload |
What it costs
Signing, head, list and multipart setup are free and rate-limited. You pay per stored write and per server-side read: verified 26 July 2026, storage.object.put is $0.0001 per call, each storage.multipart.upload_part is $0.0001 and the completion is $0.0002, with bytes and egress metered separately. A 1 GB document in 8 MB parts is 128 part calls — about $0.013 in call fees.
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.')]"
Prices here move down rather than up, so read the live figure before you build a spreadsheet on it. The argument that doesn’t move: the key that signed this upload also runs the queue that converts the DOCX, the cron that expires it and the error tracker that catches the failed part — one account, one invoice.
Limits worth knowing
Rejected content types come back as STORAGE_CONTENT_TYPE_BLOCKED, which is easy to misread as a signature problem. There’s no server-side virus scan or DOCX-to-PDF conversion in the storage layer — that’s your job or another capability’s. And the relay design costs you bandwidth on your own host, which is the price of the CORS gap; if that bandwidth is the dominant cost in your app, the specialist buckets win on that axis alone.