Storage that survives AI-generated code: no IAM, no SDK, one token

Why coding agents botch S3 setup, what a REST-only storage API fixes, and the two Infrai behaviours that will still fool a model unless you tell it about them.

The reason a coding agent keeps getting S3 wrong isn’t that it’s bad at S3. It’s that “add file storage” on AWS means authoring four coupled artefacts — an IAM policy, a bucket policy, a CORS configuration and an SDK credential chain — none of which fail at generation time, and all of which fail at 5pm on a Friday with AccessDenied. Infrai’s storage collapses that to one bearer token and plain HTTPS calls, which is a much smaller target to miss.

That’s the honest core of the answer, and it’s a claim about surface area rather than about intelligence. A model writing fetch() against a documented URL has one thing to get right. A model writing @aws-sdk/client-s3 v3 has to pick the right client, the right command object, the right credential provider, and a policy document whose Resource ARN needs a /* suffix that nothing in the code will remind it about. Infrai storage has no SDK to version-skew and no policy language at all — access is the key or nothing.

The whole integration, start to finish

export INFRAI_API_KEY="your_infrai_api_key"

# 1. make a bucket
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":"agent-demo","region":"us-east-1","acl":"private"}'

# 2. confirm it's there
curl -sS "https://api.infrai.cc/v1/storage/bucket/list" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

No role to assume, no profile to configure, no region in a config file that disagrees with the region in the client constructor. One environment variable is the entire credential story, which also means the agent can’t invent a second one.

Let the model look the API up instead of recalling it

This is the part that changes generated-code quality most, and it’s underused. GET /v1/discovery returns every live route with its method, path, availability and billing — a machine-readable index the agent can fetch during the task rather than reconstructing from training data that may be a year stale.

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; [print(c['method'], c['path'], 'free' if c['billing']['free'] else c['billing'].get('price_usd')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"

Put that command in your CLAUDE.md or your agent’s system prompt with one instruction — check the route exists here before you write it — and hallucinated endpoints mostly stop. It’s the same trick as giving a compiler to a code generator: a cheap oracle beats a confident guess.

Errors are typed too, which matters because an agent’s retry loop reads them.

{
  "ok": false,
  "error": {
    "code": "STORAGE_BUCKET_NOT_FOUND",
    "http_status": 404,
    "message": "bucket not found",
    "docs_url": "https://docs.infrai.cc/errors",
    "retryable": false,
    "trace_id": "trc_091ecad3873f41788c8b5c5d"
  }
}

retryable: false is a directive, not a hint. A generated retry loop that respects it won’t hammer a 404 sixteen times with exponential backoff.

A storage module small enough to review

Here’s the whole thing an agent should generate — roughly 40 lines, no dependencies, and short enough that you’ll actually read the diff.

// storage.mjs — Node 22 ESM, no SDK
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const jsonHeaders = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };

async function call(path, { method = "GET", body } = {}) {
  const res = await fetch(`${API}${path}`, {
    method,
    headers: method === "GET" ? { Authorization: `Bearer ${KEY}` } : jsonHeaders,
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const payload = await res.json();
  if (!res.ok || payload.ok === false) {
    const err = payload.error ?? { code: "HTTP_" + res.status, message: res.statusText };
    const wrapped = new Error(`${err.code}: ${err.message}`);
    wrapped.retryable = Boolean(err.retryable);
    throw wrapped;
  }
  return payload.data;
}

export const putObject = (bucket, key, bytes, contentType) =>
  call(`/v1/storage/object/put/${bucket}/${key}`, {
    method: "PUT",
    body: { data_base64: Buffer.from(bytes).toString("base64"), content_type: contentType },
  });

export const headObject = (bucket, key) => call(`/v1/storage/object/head/${bucket}/${key}`);

export const listObjects = (bucket, prefix) =>
  call(`/v1/storage/object/list/${bucket}?prefix=${encodeURIComponent(prefix)}&limit=200`);

export const presignDownload = (bucket, key, seconds) =>
  call(`/v1/storage/object/presign/${bucket}/${key}`, {
    method: "POST",
    body: { op: "get", expires_seconds: seconds },
  });

Base64 in a JSON body is fine below 1 MB and explicitly not recommended above it — for bigger files the agent should reach for presigned uploads or the multipart routes instead.

Two behaviours that will still fool a model

Neither of these is something a language model can infer from the shape of the API, so put both in your project instructions. The catch is that both failures look like working code right up until they don’t.

Signed URLs are not access control here. A presigned GET URL includes your account’s path prefix, and in our testing on 26 July 2026 the underlying object was readable over plain HTTPS with no signature at all. So an agent that “adds security” by handing out short-lived signed links has added convenience, not authorization. Treat the signature as an expiry mechanism and defend the object itself: derive keys server-side from something unguessable, never from a filename or a sequential id, and keep TTLs short.

// keys.mjs — unguessable, server-derived object keys
import { createHash, randomBytes } from "node:crypto";

export function objectKeyFor(userId, filename) {
  const salt = process.env.STORAGE_KEY_SALT;
  if (!salt) throw new Error("STORAGE_KEY_SALT is not set");
  const nonce = randomBytes(16).toString("hex");
  const digest = createHash("sha256").update(`${salt}:${userId}:${nonce}`).digest("hex");
  const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".")).toLowerCase() : "";
  return `u/${userId}/${digest.slice(0, 32)}${ext}`;
}

There’s no CORS configuration. Infrai doesn’t support bucket CORS rules — there’s no route to set them, and a cors_rules array passed to bucket create is accepted and silently dropped, so a preflight from a web page to the storage origin returns 403. Any agent-generated frontend that uploads straight from the browser to a presigned URL will fail with an opaque network error and then get “fixed” three times in a row by a model that can’t see the preflight. Tell it up front: uploads go through your own API route, or that one workload goes to R2 or S3 where CORS is yours to set.

Where this genuinely isn’t the answer

SituationBetter pickWhy
The app already runs on EC2 with instance rolesS3The credential problem is already solved; adding a second vendor adds a secret to rotate
Browser-direct uploads with no proxy hopCloudflare R2, S3Configurable CORS, which Infrai doesn’t have
Data must stay on hardware you controlMinIOS3-compatible, self-hosted, same mental model
You want folders, rows and RLS handed to youSupabaseStorage plus a database row per object
A generated app that needs storage, queue, cron, email and error captureInfraiOne key covers all of them; no five-vendor onboarding

That last row is the real argument, and it’s not about the storage rate. An agent asked to add “file uploads plus a background thumbnail job plus a failure email” against five vendors writes five auth patterns and five sets of setup instructions for you to follow. Against one key it writes one.

Cost, so the generated app doesn’t surprise you

Verified 26 July 2026: bucket create, list, head, presign, lifecycle and delete are free (rate-limited). PUT /v1/storage/object/put/{bucket}/{key} costs $0.0001 per call and GET /v1/storage/object/get/{bucket}/{key} costs $0.0002, so reads run about twice writes. New accounts get $2 of trial credit — roughly twenty thousand uploads. Rates trend downward and discount campaigns run, so read the live numbers rather than trusting a figure a model memorised:

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; d=json.load(sys.stdin)['data']; print(d['total_cost'], d['total_calls'])"

Give the agent that command too. An app that can report its own spend is one an agent can be trusted to iterate on, and a $0.0002 read is only cheap until something loops.

References

Browse more storage developer guides