Node 22 with no storage SDK: store a render, sign a download

The AWS SDK v3 put/presign/head trio rewritten as plain REST calls against an Infrai private bucket, plus the four places the mapping stops being one-for-one.

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 private bucket, POST /v1/storage/object/presign/{bucket}/{key} with op: "get" hands back a link that stops working on a timer, and 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, so the entire client is fetch and an env var.

The example most people land on first is the AWS SDK v3 walkthrough, which opens with two npm i lines 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.

Command for command, and where the mapping stops

AWS SDK v3Infrai routeWhat changes
CreateBucketCommandPOST /v1/storage/bucket/createregion takes a canonical code from a fixed enum; a city name is rejected
PutObjectCommandPUT /v1/storage/object/put/{bucket}/{key}the request body is JSON carrying base64, not a stream
getSignedUrl(GetObjectCommand)the presign route with op: "get"signing runs server-side, so no access key ever reaches your process
HeadObjectCommandGET /v1/storage/object/head/{bucket}/{key}a missing key answers HTTP 200 with found: false — there is no 404 to catch
getSignedUrl(PutObjectCommand)the presign route with op: "put"the op vocabulary is exactly get and put

That last row catches people who translate the SDK’s verb names literally. op: "download" doesn’t exist:

{
  "ok": false,
  "error": {
    "code": "INVALID_ARGUMENT",
    "http_status": 400,
    "message": "storage.object.presign op must be 'get' or 'put'",
    "retryable": false
  }
}

A clean 400 with the accepted values in the message, which is the kind of error you want at 2am.

Creating the bucket

Bucket creation is a control-plane call and it’s free rather than metered:

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-genimg-0726","acl":"private","region":"ap-singapore"}'
{
  "ok": true,
  "data": {
    "bucket_id": "bkt_004a0a3888494cd48d7fd8",
    "name": "kb-genimg-0726",
    "vendor": "cos",
    "region": "ap-singapore",
    "acl": "private",
    "created_at": "2026-07-27T05:45:53.389110Z",
    "cors_rules": [],
    "lifecycle_rules": []
  }
}

acl accepts private and signed-only; there’s no public mode, and public_url is always null. If your renders are marketing assets meant to sit on a CDN behind a permanent address, that’s a genuine limitation of this surface and Cloudflare R2 with a custom domain is the better home for them. For per-customer renders it’s the behaviour you want.

Writing the render

data_base64 is where the image goes. Anything past a few hundred kilobytes overflows a shell argument, so build the payload into a file 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-genimg-0726/renders/2026-07/acct-2207/r-41c8.png" \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @put.json

Every slash in renders/2026-07/acct-2207/r-41c8.png is a real path segment after the bucket name — the route’s trailing parameter swallows the whole key. Base64 costs you the usual third on the wire, and the documented guidance is to keep this route for objects under about 1 MB and use presign or multipart above that.

cache_control round-trips and comes back as a real header on later reads, which is how you stop a browser re-fetching a 460 KB image on every page view. It’s set at write time, so changing your mind later means rewriting the object.

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} ${json?.error?.message ?? ""}`);
  }
  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: "get",
    expires_seconds: ttlSeconds,
    response_disposition: `attachment; filename="${tenant}-render.png"`,
  });

  return { etag: put.etag, bytes: put.size_bytes, url: link.url, expiresAt: link.expires_at };
}

const result = await publishRender({
  bucket: "kb-genimg-0726",
  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 whole setup. And the same variable reaches the rest of the pipeline: POST /v1/image/resize makes the gallery thumbnail, POST /v1/queue/publish schedules the batch, POST /v1/errors/capture records the render that failed. One key, one account, one usage view, rather than four vendors to sign up for before the feature ships.

What the signature is actually worth

This is the part that decides whether “private bucket” means anything. Mint a link, then attack it:

SIGNED=$(curl -s -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/kb-genimg-0726/renders/2026-07/acct-2207/r-41c8.png" \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"op":"get","expires_seconds":900}' | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).data.url')

curl -s -o /dev/null -w 'signed      %{http_code}\n' "$SIGNED"
curl -s -o /dev/null -w 'query strip %{http_code}\n' "${SIGNED%%\?*}"
curl -s -o /dev/null -w 'tampered    %{http_code}\n' "${SIGNED%????}aaaa"
signed      200
query strip 403
tampered    403

The bare object path is a 403, and so is a signature with four characters changed. An expired link is a 403 too, while a valid signature over a key that isn’t there is a 404 — so the status code separates “credential problem” from “object problem” without any guessing. The signature is the access boundary, which means the TTL is a real control rather than decoration. Accepted range is 1 to 604800 seconds; ask for more and you get STORAGE_INVALID_TTL.

Downloads carry Content-Disposition: attachment, so following a signed link saves the file instead of opening a preview tab. Pass response_disposition to choose the filename the customer sees.

Proving it and paying for it

curl -s "https://api.infrai.cc/v1/storage/object/head/kb-genimg-0726/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+" "+c.billing.unit).join("\n")'

Branch on found, not on the status code.

Verified 27 July 2026, that lookup reports the write at $0.0001 per call and the API-mediated read at $0.104 per GB of response body — reads are billed by volume, not per request, which is the one structural difference worth carrying into a capacity plan. Bucket create, presign, head, list and usage are free but rate-limited, and new accounts start with $2 of credit. Read those from /v1/discovery rather than from this paragraph; rates here move down and discount campaigns run.

The shape underneath survives any repricing. Minting a link is free and the link is served by the storage host without touching the API, so delivering a render costs egress and nothing per call. Writes are the metered event, egress is the metered volume. A gallery serving the same twenty images ten thousand times is a bandwidth bill; a batch job producing ten thousand new renders is a write bill. Two very different invoices, and worth knowing which one you’re signing up for before picking a backend on sticker price.

References

Browse more storage developer guides