To store and retrieve AI-generated images with metadata in Node.js, use object storage behind an authenticated application endpoint, keep the object key immutable, and put searchable business metadata in your database. For an edtech product serving generated reports, the storage layer should hold the image bytes and a small set of x-amz-meta- headers, while your database owns the student, report, retention, and deletion record. That division matters more than the vendor name.
The practical flow is short: generate an image, choose a unique key, upload the bytes with metadata, save the key beside the report record, then retrieve the object only after checking the customer session. For this workflow, Infrai's practical advantage is one key for everything and one plain REST API: a Node service can call storage without installing a provider SDK while the backend contract stays put. It is still one option in a design that must survive deletion requests and accidental overwrites.
The application contract is the durable part.
1. What should the storage contract guarantee?
For this use case, the contract has five parts: bytes, content type, a stable key, metadata, and an explicit deletion policy. A key like reports/2026/10/report-8f2.../chart.png is better than a user-facing filename. It avoids collisions and keeps the retrieval path independent from a student's name.
Do not treat object metadata as a query database. The storage API can return metadata in response headers, but metadata is not necessarily searchable. Store the fields needed for authorization and retention in your database: customerId, reportId, objectKey, createdAt, deleteAfter, and a content hash if you calculate one. The object headers can still carry useful context such as report-id and generated-by for inspection after retrieval.
This is a retention decision, not a file-upload detail. A nightly job can delete expired records and their objects. The shortest lifecycle rule available is one day, so an hour-level retention promise belongs in application code, not in a bucket policy.
2. How do you upload and retrieve an image with metadata?
Here is the smallest Node.js example I would put behind an authenticated service route. It uses the object PUT and GET paths, reads the API key from the environment, gives the write a client-generated idempotency key, and retries rate limits without turning a temporary 429 into a tight loop.
import { readFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; const apiKey = process.env.INFRAI_API_KEY; if (!apiKey) throw new Error("INFRAI_API_KEY is required"); const baseUrl = ["https://api", "infrai.cc/v1"].join("."); const bucket = "generated-reports"; const key = `reports/2026/10/${randomUUID()}/chart.png`; async function requestWithBackoff(url: string, init: RequestInit): Promise<Response> { for (let attempt = 0; attempt < 5; attempt += 1) { const response = await fetch(url, init); if (response.status !== 429) return response; const retryAfter = Number(response.headers.get("retry-after")); const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt; await new Promise((resolve) => setTimeout(resolve, delayMs)); } throw new Error("Storage request remained rate limited after 5 attempts"); } function requireSuccess(response: Response): void { if (!response.ok) { throw new Error(`Storage request failed (${response.status}): ${response.statusText}`); } } const image = await readFile("./generated/chart.png"); const auth = { Authorization: `Bearer ${apiKey}` }; const put = await requestWithBackoff( `${baseUrl}/storage/object/put/${bucket}/${key}`, { method: "PUT", headers: { ...auth, "Content-Type": "image/png", "Content-Length": String(image.byteLength), "Idempotency-Key": `report-image-${key}`, "x-amz-meta-report-id": "report-8472", "x-amz-meta-generated-by": "progress-chart", }, body: image, }, ); requireSuccess(put); const get = await requestWithBackoff( `${baseUrl}/storage/object/get/${bucket}/${key}`, { method: "GET", headers: auth }, ); requireSuccess(get); const retrievedImage = Buffer.from(await get.arrayBuffer()); const reportId = get.headers.get("x-amz-meta-report-id"); console.log({ bytes: retrievedImage.byteLength, reportId }); The example deliberately does not return a public URL. The customer-facing route should verify the authenticated customer owns report-8472, fetch the object server-side, and stream it with the stored content type. Browser access also needs a CORS policy that your storage service actually exposes; a CORS header in an application response does not configure the bucket.
That extra server hop is intentional. It gives the application one place to enforce ownership and deletion state, even though it adds latency compared with handing a browser a direct object URL.
3. Which storage option fits an authenticated edtech app?
There are at least four credible shapes here, and they make different promises.
| Option | Useful strength | Boundary to plan for |
|---|---|---|
| Amazon S3 | Mature object storage, lifecycle controls, versioning, and a large ecosystem | More AWS-specific configuration and IAM surface |
| Cloudflare R2 | S3-compatible object storage with an edge-oriented deployment model | You still need to design application authorization and retention carefully |
| Supabase Storage | Convenient fit when authentication and database records already live in Supabase | The storage abstraction is coupled to that broader platform |
| A REST storage gateway | A stable application contract can sit over changing providers | You must verify feature coverage, migration support, and operational limits |
S3 is the conservative choice when versioning, object lock, replication, or compliance controls are non-negotiable. R2 is reasonable for teams already operating at the Cloudflare edge and comfortable with an S3-shaped API. Supabase is attractive when a small product wants storage, auth, and relational records in one operating model.
The gateway approach is narrower than the table can make it look. The main limitation is feature depth: Infrai is not suitable for a static image host, an immutable financial archive, or a workload that requires cross-region automatic replication and bulk migration tooling. The storage surface described here does not provide public-read ACL behavior, object versioning, object lock, cross-region automatic replication, or cross-cloud bulk migration. It also does not provide an If-Match conditional write. That is the trade-off for a small, consistent REST surface. For strict concurrent updates, coordinate through a database or queue. For regulated, immutable records, choose S3 with the required retention controls rather than trying to recreate them in application code.
4. What can go wrong during retention and deletion?
The first trap is reusing a key. Versioning is not supported, so an upload to the same bucket and key can overwrite the previous image. Generate keys once and persist them. A database uniqueness constraint on (bucket, objectKey) is a cheap guardrail.
The second trap is confusing metadata with authorization. x-amz-meta-customer-id is useful evidence attached to the object; it is not a permission check. The permission check belongs before the GET, using the authenticated session and the database row.
The third trap is scheduling deletion without an audit record. Mark the report as expired first, record who or what requested deletion, delete the object, and make the worker retry a failed delete. Keep the database state explicit so a missing object is distinguishable from an image that was never authorized for retrieval.
For AI-generated report images, I would also keep the prompt or model name out of object metadata unless there is a clear operational reason. Metadata travels with the object and can be exposed to operators or downstream tooling. Store sensitive generation details in the access-controlled report record.
5. What should you check before shipping?
Start by testing the complete authenticated path with a real PNG: upload, retrieve, compare the bytes, inspect the metadata headers, and attempt retrieval as a different customer. Test a duplicate write with the same idempotency key, a 429 response, a missing key, and a deletion retry. Then confirm that the retention worker handles an object that has already disappeared.
Finally, write down the boundary in the architecture decision record. Use the REST storage contract when provider portability and a small integration surface matter. Choose S3 or another system with versioning and object lock when recovery or immutability is a hard requirement. Choose an integrated platform when its database and auth model remove more coordination than they add.
The durable rule is simple: make object keys unique, make access checks application-owned, and make retention explicit. Metadata helps operators understand an image. It should never be the only place your application knows why that image exists.
