Choosing object storage for private SaaS user documents in Node 22

Four features decide a document vault: prefix listing, bucket-enforced retention, batch erasure and per-tenant usage. Working Infrai calls plus an honest S3 and R2 read.

Price is rarely what decides this one. A vault holding customers’ contracts, invoices and ID scans lives or dies on four capabilities: listing one tenant’s keys without scanning the bucket, retention the storage enforces rather than a cron job, bulk erasure when somebody invokes their rights, and a usage figure you can attribute per tenant. Infrai exposes all four as free REST calls, which is the reason it’s worth a look next to Amazon S3 — not the per-gigabyte number.

Everything below runs against a bucket we created for this page on 2026-07-26, so the paths are real ones you can substitute your own names into.

One bucket, one prefix per tenant

Resist a bucket per customer. Bucket counts hit provider quotas, and every bucket is another object to configure, meter and forget about. Keys are free and hierarchical enough:

export INFRAI_API_KEY="your_infrai_api_key"

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": "kbs6-userdocs-0726", "acl": "private"}'

acl takes private and nothing else — there’s no public-read mode here, which removes the single most common way a document store leaks. The drawback of that design shows up later, when you want a CDN in front of marketing assets and discover you need a second provider for it.

Retention the bucket enforces

A rule per prefix, and the bucket does the deleting. Note that set_lifecycle replaces the whole rule set rather than appending, so always send the complete list:

curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kbs6-userdocs-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"rules": [{"prefix": "orgs/", "expire_days": 2555}, {"prefix": "tmp/", "expire_days": 1}]}'

Seven years for signed agreements, one day for the scratch prefix that half-finished uploads land in. An invalid shape comes back as STORAGE_INVALID_LIFECYCLE_RULES rather than silently doing nothing, which is the behaviour you want from a compliance control.

Storing a document

The write is a single PUT carrying the bytes as base64 plus the content type. Base64 costs you about 33% in transfer, so keep this path for documents under roughly 10 MB and switch to the multipart routes beyond that size.

DOC_B64=$(base64 < msa-2026.pdf | tr -d '\n')
printf '{"data_base64":"%s","content_type":"application/pdf"}' "$DOC_B64" > body.json

curl -sS -X PUT \
  "https://api.infrai.cc/v1/storage/object/put/kbs6-userdocs-0726/orgs/org_31/contracts/msa-2026.pdf" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @body.json

One thing to know before you wire in a retry loop: if you send an idempotency_key and later reuse it with different bytes, the call returns success while keeping the original object. Derive it from a hash of the content, never from a request id.

Listing one tenant’s folder

This is the call that makes a flat key space feel like folders. delimiter=/ rolls everything below one level into common_prefixes, exactly like a directory listing:

curl -sS "https://api.infrai.cc/v1/storage/object/list/kbs6-userdocs-0726?prefix=orgs/&delimiter=/" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [],
    "next_cursor": null,
    "common_prefixes": ["orgs/org_31/"]
  }
}

Drop the delimiter and you get objects instead, with limit and a next_cursor that is simply the last key returned. Two fields are worth knowing about before you build a UI on this: content_type and metadata come back null in listings even when GET /v1/storage/object/head/{bucket}/{key} reports them, and the created_at in a listing reflects when the listing was assembled. Trust last_modified.

The vault module

Node 22, no dependencies, ESM. It stores a document, then returns a short-lived URL and the file’s real size for the download endpoint to send as Content-Length.

import { readFile } from "node:fs/promises";

const BASE = "https://api.infrai.cc";
const BUCKET = "kbs6-userdocs-0726";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

async function api(method, path, payload) {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: payload === undefined ? undefined : JSON.stringify(payload),
    signal: AbortSignal.timeout(30000),
  });
  const json = await res.json();
  if (!json.ok) throw new Error(`${json.error.code}: ${json.error.message} (${res.status})`);
  return json.data;
}

export async function storeDocument(orgId, filename, localPath, contentType) {
  const bytes = await readFile(localPath);
  const key = `orgs/${orgId}/contracts/${filename}`;
  const written = await api("PUT", `/v1/storage/object/put/${BUCKET}/${key}`, {
    data_base64: bytes.toString("base64"),
    content_type: contentType,
  });
  return { key, etag: written.etag, size: written.size_bytes };
}

export async function documentLink(orgId, filename, ttlSeconds = 300) {
  const key = `orgs/${orgId}/contracts/${filename}`;
  const head = await api("GET", `/v1/storage/object/head/${BUCKET}/${key}`);
  if (!head.found) return null;
  const signed = await api("POST", `/v1/storage/object/presign/${BUCKET}/${key}`, {
    op: "get",
    expires_seconds: ttlSeconds,
  });
  return { url: signed.url, expiresAt: signed.expires_at, bytes: head.size_bytes };
}

const stored = await storeDocument("org_31", "msa-2026.pdf", "./msa-2026.pdf", "application/pdf");
console.log(stored, await documentLink("org_31", "msa-2026.pdf"));

A link is a bearer credential in a query string, so mint it per click with a tight window and do the “does this session belong to org_31?” check before you call presign. Don’t cache the URL, and don’t treat its expiry as an access control layer — for ID scans and anything a regulator will ask about, stream the bytes through your own authenticated route instead.

Erasure requests

One call takes a list and reports per-key results, which beats N round trips when a deletion request covers a few hundred objects:

curl -sS -X POST "https://api.infrai.cc/v1/storage/object/delete_batch/kbs6-userdocs-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"keys": ["tmp/probe-a.txt"]}'
{
  "ok": true,
  "data": { "deleted": ["tmp/probe-a.txt"], "errors": [] }
}

There’s no versioning and no object lock here. If your obligation runs the other way — write-once, provably unalterable — that’s a hard gap, and Backblaze B2’s Object Lock or S3’s compliance mode is the correct purchase.

How the candidates line up

Infrai storageAmazon S3Cloudflare R2Backblaze B2MinIO (self-hosted)
Prefix listing + common prefixesyes, freeyesyesyesyes
Retention rulesper-prefix daysfull lifecycle policylifecycle ruleslifecycle ruleslifecycle rules
Object lock / WORMnoyesnoyesyes
Batch deleteone call, per-key errors1000 keys per callS3-compatibleS3-compatibleS3-compatible
Region you can proveverify the signed hostyesyes, with hintsyesyour rack
Browser-direct uploadno CORS route yetyesyesyesyes
Same credential also runsemail, queues, cron, AI, errorsS3 onlyR2 onlyB2 onlynothing

The US and EU part

Be careful here. The bucket record accepts a region and reports it back, but the presigned host we got for an eu-central-1 bucket resolved to an ap-singapore endpoint, and the SigV4 scope matched the host rather than the requested region. So the region field is not evidence of residency. If your contracts name a jurisdiction, verify the host that comes back from a presign call, or stick with S3 or R2, where the endpoint itself tells you where the object sits. We’d rather lose that comparison than have you discover it during an audit.

Cost, and how to re-read it

Bucket creation, lifecycle, listing, head, presign and delete are free and rate-limited. Object writes bill at $0.0001 per call, verified 2026-07-26 against metered usage rather than the rate card, and a new account starts with $2 of credit. Reads bill per call too, at a figure well below the published one, so measure instead of quoting:

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.data.breakdown[] | select(.key | startswith("storage."))'

Prices move down over time and promotional pricing runs, so treat that command as the source and this paragraph as an illustration. The durable structure: metadata operations free, byte operations metered per call, stored volume metered separately.

If a document vault is the only thing you’re buying, S3 with lifecycle policies and Object Lock is a stronger, more mature product and you should probably take it. The case for consolidating is different — the same key that stored the contract also renders it, emails it, schedules the retention sweep and captures the exception when the render fails, and every one of those is a vendor you didn’t onboard.

References

Browse more storage developer guides