Node 22 without the S3 SDK: store a render, sign a download
The AWS SDK v3 put/presign/head trio rewritten as plain REST calls against Infrai storage, plus the four behaviours that don't carry over.
Three HTTP calls cover the whole job and none of them needs a package. PUT /v1/storage/object/put/{bucket}/{key} writes the PNG into a bucket you created with acl: "signed-only". POST /v1/storage/object/presign/{bucket}/{key} with op: "download" hands back a link that stops working on a timer. GET /v1/storage/object/head/{bucket}/{key} confirms the bytes are really there. Infrai exposes all three as ordinary REST over https://api.infrai.cc.
The reference example most people land on for this is the AWS SDK for JavaScript v3 one, which opens with npm i @aws-sdk/client-s3 @aws-sdk/s3-request-presigner and a client object holding credentials. Command-to-route, the mapping is nearly one-for-one — the interesting part is the four places where it isn’t, because those are the ones that turn into bug reports a week after launch.
Command for command, and where the mapping stops
| AWS SDK v3 | Infrai route | What changes |
|---|---|---|
CreateBucketCommand | POST /v1/storage/bucket/create | region is accepted and echoed back, but it doesn’t decide where bytes land |
PutObjectCommand | PUT /v1/storage/object/put/{bucket}/{key} | the request body is JSON carrying base64, not a stream |
getSignedUrl(GetObjectCommand) | POST /v1/storage/object/presign/{bucket}/{key} | signing runs server-side, so no access key ever reaches your process |
HeadObjectCommand | GET /v1/storage/object/head/{bucket}/{key} | a missing key answers HTTP 200 with found: false — there is no 404 to catch |
getSignedUrl(PutObjectCommand) | the same presign route with op: "upload" | no CORS rule setter exists, so a browser can’t PUT to the result |
That last row is the one to read twice. Everything else on this page is a straight translation; browser-direct upload is the case where you should reach for something else.
Creating the bucket
The bucket is a control-plane object, and creating one is free rather than metered. acl takes signed-only, which is the setting you want for renders that belong to a specific customer:
export INFRAI_API_KEY=your_infrai_api_key
curl -s -X POST "https://api.infrai.cc/v1/storage/bucket/create" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"kb-renders-node22","acl":"signed-only","region":"us-east-1"}'
{
"ok": true,
"data": {
"bucket_id": "bkt_004a0a3888494cd48d7fd8",
"name": "kb-renders-node22",
"vendor": "cos",
"region": "us-east-1",
"acl": "signed-only",
"created_at": "2026-07-26T05:45:53.389110Z",
"cors_rules": [],
"lifecycle_rules": []
}
}
Notice region came back exactly as sent. It’s stored, and later calls report it, but the signed URLs this bucket produces resolve to a host in Asia regardless. If a contract obliges you to keep customer renders inside a named jurisdiction, that’s a limitation you have to design around rather than a setting you can flip — choosing object storage for AI-generated images works through that decision properly.
Writing the render
data_base64 is where the image goes. Anything above a few hundred kilobytes overflows a shell argument, so build the payload into a file first and hand curl the file:
node -e 'const fs=require("fs");
const b64=fs.readFileSync("render.png").toString("base64");
fs.writeFileSync("put.json",JSON.stringify({
data_base64:b64,
content_type:"image/png",
cache_control:"private, max-age=86400",
metadata:{"render-id":"r-41c8",tenant:"acct-2207"}
}));'
curl -s -X PUT \
"https://api.infrai.cc/v1/storage/object/put/kb-renders-node22/renders/2026-07/acct-2207/r-41c8.png" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @put.json
The key is renders/2026-07/acct-2207/r-41c8.png and every slash in it is a real path segment after the bucket name. A 474,694-byte PNG became 632,928 base64 characters and took roughly 3.3 seconds end to end in our testing on 2026-07-26 — base64 costs you the usual third on the wire.
{
"ok": true,
"data": {
"bucket_id": "bkt_004a0a3888494cd48d7fd8",
"key": "renders/2026-07/acct-2207/r-41c8.png",
"size_bytes": 474694,
"etag": "22adc99be83f9b044e61dbab1fb0ef6b",
"content_type": "image/png",
"metadata": { "render-id": "r-41c8", "tenant": "acct-2207" },
"created_at": "2026-07-26T05:46:22.627961Z"
}
}
Two field notes. cache_control round-trips — it comes back as a real Cache-Control header on every later fetch of the object, which is how you stop a browser re-downloading a 460 KB image on every page view, and it is set at write time rather than at signing time, so you cannot change your mind about it later without rewriting the object. Metadata keys must use hyphens; an underscore in a key name fails the whole write with a 503 rather than a validation error, which is a miserable thing to debug at 2am.
Hyphens only.
Minting the download link
curl -s -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-renders-node22/renders/2026-07/acct-2207/r-41c8.png" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"op":"download","expires_seconds":900,"response_disposition":"attachment; filename=\"sunset-r-41c8.png\""}'
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-renders-node22/renders/2026-07/acct-2207/r-41c8.png?response-content-disposition=attachment%3B%20filename%3D%22sunset-r-41c8.png%22&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=22005c9b620248c4ab3f9e524cb9a5e811b59d3b730c8627371c2c5bd78520ed",
"expires_at": "2026-07-26T06:01:33.531643Z"
}
}
response_disposition renames the downloaded file, which is the thing product managers actually ask for. It won’t make the browser render the image inline; the storage layer stamps Content-Disposition: attachment and an x-amz-force-download: true header onto every object read, and asking for inline is silently overridden. So a signed URL can’t back an <img src> tag. If you need the picture on the page rather than in the downloads folder, proxy the bytes through your own route and set your own headers.
One file, no dependencies
import { readFile } from "node:fs/promises";
import process from "node:process";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("set INFRAI_API_KEY");
const headers = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };
async function call(method, path, body) {
const res = await fetch(`${API}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json().catch(() => null);
if (!res.ok || json?.ok === false) {
const code = json?.error?.code ?? res.status;
throw new Error(`${method} ${path} failed: ${code}`);
}
return json.data;
}
export async function publishRender({ bucket, key, file, tenant, ttlSeconds = 900 }) {
const bytes = await readFile(file);
const put = await call("PUT", `/v1/storage/object/put/${bucket}/${key}`, {
data_base64: bytes.toString("base64"),
content_type: "image/png",
cache_control: "private, max-age=86400",
metadata: { tenant },
});
const link = await call("POST", `/v1/storage/object/presign/${bucket}/${key}`, {
op: "download",
expires_seconds: ttlSeconds,
});
return { etag: put.etag, bytes: put.size_bytes, url: link.url, expiresAt: link.expires_at };
}
const result = await publishRender({
bucket: "kb-renders-node22",
key: "renders/2026-07/acct-2207/r-41c8.png",
file: "render.png",
tenant: "acct-2207",
});
console.log(result.bytes, "bytes ·", result.expiresAt);
No client construction, no credential provider chain, no region config. The env var is the entire setup, and the same variable already reaches inference, queues, cron and error capture on this account — which is the part that stops mattering when you have one service and starts mattering badly when you have six.
What the signature is actually worth
Strip the query string off a signed URL and fetch the bare object path. It returns 200:
SIGNED=$(curl -s -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-renders-node22/renders/2026-07/acct-2207/r-41c8.png" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"op":"download","expires_seconds":900}' | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).data.url')
curl -s -o /dev/null -w '%{http_code}\n' "${SIGNED%%\?*}"
Two hundred, with the object body attached. So signed-only is not an authorization boundary — the signature caps how long a URL stays useful, not who may use it. Treat the key itself as the secret: high-entropy path segments, never a guessable renders/1042.png. That, plus a short TTL, is the real control.
The catch is that this rules out a few designs. Public buckets fronted by a CDN aren’t a supported mode here, and neither is browser-direct upload, because the bucket record carries cors_rules but there’s no route to set them — Amazon S3, Cloudflare R2 or a self-run MinIO all give you a CORS setter and are the right pick if the browser must talk to storage directly. Stick with those for a photo-heavy consumer app.
Proving it and paying for it
curl -s "https://api.infrai.cc/v1/storage/object/head/kb-renders-node22/renders/2026-07/acct-2207/r-41c8.png" \
-H "Authorization: Bearer $INFRAI_API_KEY"
curl -s "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
| node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).capabilities.filter(c=>c.id.startsWith("storage.")&&c.billing.is_billable).map(c=>c.id+" "+c.billing.price_usd).join("\n")'
Branch on found, not on the status code.
Verified 2026-07-26, that lookup reports the write at $0.0001 per call and the API-mediated read at $0.0002 per call; bucket create, presign, head, list and usage are all free but rate-limited, and new accounts start with $2 of credit. Read those numbers from /v1/discovery rather than from this paragraph — rates here move down over time and discount campaigns run, so what you find is likely lower than what we measured.
The shape underneath survives any repricing. Minting a link is free, and the link itself is served by the storage host without touching the API, so delivering a render to a customer costs egress and nothing per call. Writes are the metered event. That means a gallery serving the same twenty images ten thousand times is dominated by bandwidth, and a batch job producing ten thousand new renders is dominated by writes — two different bills, and worth knowing which one you’re signing up for before you pick a backend on sticker price.