Private PDF and DOCX uploads in Node, with signed links and a user id

Upload contracts to a private S3-compatible bucket from Node 22, tag every object with its owner's id, and hand back download URLs that expire.

A private bucket, a signed upload slot, the owner’s id written onto the object itself, and a download URL that dies in fifteen minutes. That’s the entire design, and on Infrai it’s four REST calls behind one API key — no SDK to install, no IAM policy document to argue with, no permanent object URL that can leak into a support ticket.

Signing costs nothing here, which matters more than it sounds: you can mint a fresh download link on every page render instead of caching one and praying. What you pay for is the write and the server-side read. The bucket comes first.

A bucket that is private by construction

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":"docs-vault","region":"ap-singapore","acl":"private"}'
{
  "ok": true,
  "data": {
    "bucket_id": "bkt_84ce5107f2664d91b38478",
    "name": "docs-vault",
    "vendor": "cos",
    "region": "ap-singapore",
    "acl": "private",
    "created_at": "2026-07-26T00:21:19.342914Z",
    "cors_rules": [],
    "lifecycle_rules": []
  }
}

acl accepts private and signed-only. It does not support public-read — there is no permanent public link on this surface at all, which is exactly what you want for signed contracts and exactly wrong if you’re serving marketing images.

Sign the slot, then PUT the bytes

One call returns a URL, the HTTP method to use, and any headers the signature covers. Pin the content type and the URL only accepts that content type; pin max_bytes and an oversized upload is rejected by the bucket rather than by your quota alert at 3am.

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/docs-vault/u/usr_8412/2026-07/msa-signed.pdf" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"op":"put","expires_seconds":900,"content_type":"application/pdf","max_bytes":26214400}'
{
  "ok": true,
  "data": {
    "url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.docs-vault/u/usr_8412/2026-07/msa-signed.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-SignedHeaders=content-type%3Bhost&X-Amz-Signature=049dba81610e5ebf",
    "method": "PUT",
    "headers": { "Content-Type": "application/pdf" },
    "fields": null,
    "expires_at": "2026-07-26T00:36:10.592944Z",
    "max_bytes": 26214400
  }
}

Echo headers verbatim on the upload. Miss the Content-Type and the signature won’t match, because the header is inside X-Amz-SignedHeaders.

Here’s the server-side version end to end — reads a file from disk, signs, uploads, then confirms with a free head:

import { readFile } from "node:fs/promises";
import { basename, extname } from "node:path";

const API = "https://api.infrai.cc";
const BUCKET = "docs-vault";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is not set");

const MIME = {
  ".pdf": "application/pdf",
  ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  ".tiff": "image/tiff",
};

async function infrai(path, init = {}) {
  const res = await fetch(`${API}${path}`, {
    ...init,
    headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...(init.headers ?? {}) },
  });
  const json = await res.json();
  if (!res.ok || json.ok === false) {
    throw new Error(`${path} -> ${res.status} ${json?.error?.code ?? "unknown"}: ${json?.error?.message ?? ""}`);
  }
  return json.data;
}

export async function storeDocument(userId, filePath) {
  const bytes = await readFile(filePath);
  const contentType = MIME[extname(filePath).toLowerCase()] ?? "application/octet-stream";
  const month = new Date().toISOString().slice(0, 7);
  const objectKey = `u/${userId}/${month}/${basename(filePath)}`;

  const slot = await infrai(`/v1/storage/object/presign/${BUCKET}/${objectKey}`, {
    method: "POST",
    body: JSON.stringify({ op: "put", expires_seconds: 900, content_type: contentType, max_bytes: 26_214_400 }),
  });

  const upload = await fetch(slot.url, { method: slot.method, headers: slot.headers ?? {}, body: bytes });
  if (!upload.ok) throw new Error(`vendor upload failed: HTTP ${upload.status}`);

  await infrai(`/v1/storage/object/set_metadata/${BUCKET}/${objectKey}`, {
    method: "POST",
    body: JSON.stringify({
      content_type: contentType,
      metadata: { "user-id": userId, "doc-kind": "msa", "original-filename": basename(filePath) },
    }),
  });

  const head = await infrai(`/v1/storage/object/head/${BUCKET}/${objectKey}`);
  return { objectKey, etag: head.etag, size: head.size_bytes };
}

The user id goes on the object, not only in the key

A path prefix like u/usr_8412/ scopes listing, but it’s a naming convention, not an attribute — rename the file and the ownership claim moves with it. Custom metadata survives copies and shows up on every head:

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/set_metadata/docs-vault/u/usr_8412/2026-07/msa-signed.pdf" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"content_type":"application/pdf","metadata":{"user-id":"usr_8412","doc-kind":"msa","original-filename":"MSA signed.pdf"}}'
curl -sS \
  "https://api.infrai.cc/v1/storage/object/head/docs-vault/u/usr_8412/2026-07/msa-signed.pdf" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "found": true,
    "status": "found",
    "key": "u/usr_8412/2026-07/msa-signed.pdf",
    "size_bytes": 48,
    "etag": "c6116a52a7cc358b43064beac09a8e77",
    "content_type": "application/pdf",
    "metadata": { "doc-kind": "msa", "original-filename": "MSA signed.pdf", "user-id": "usr_8412" },
    "last_modified": "2026-07-26T00:21:40Z"
  }
}

One trap that cost us an hour, and it isn’t in anyone’s docs: metadata keys containing an underscore fail. In our testing on 26 July 2026, {"user_id":"usr_8412"} came back as a 503 with SignatureDoesNotMatch from the storage vendor, while {"user-id":"usr_8412"} on the identical object succeeded. Hyphenate your metadata keys — user-id, doc-kind, original-filename — and the problem disappears. Values are fine with spaces and mixed case; it’s only the key names.

Same route, op: "get", and no bytes pass through your process:

const API = "https://api.infrai.cc";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is not set");

export async function downloadLink(objectKey, ownerId, seconds = 300) {
  const headRes = await fetch(`${API}/v1/storage/object/head/docs-vault/${objectKey}`, {
    method: "GET",
    headers: { Authorization: `Bearer ${key}` },
  });
  const head = await headRes.json();
  if (!headRes.ok || head.ok === false || head.data?.found !== true) throw new Error("object not found");
  if (head.data.metadata?.["user-id"] !== ownerId) throw new Error("not your document");

  const res = await fetch(`${API}/v1/storage/object/presign/docs-vault/${objectKey}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
    body: JSON.stringify({ op: "get", expires_seconds: seconds }),
  });
  const json = await res.json();
  if (!res.ok || json.ok === false) throw new Error(`presign failed: ${json?.error?.code ?? res.status}`);
  return { url: json.data.url, expiresAt: json.data.expires_at, filename: head.data.metadata?.["original-filename"] };
}

The authorisation check reads metadata rather than trusting the key prefix, which is the point of putting user-id there. Fetching that signed URL returns Content-Disposition: attachment and echoes your metadata as x-amz-meta-user-id and friends — handy in a proxy, and a reminder that metadata is visible to whoever holds the link.

Which upload path for which file

PathFile sizeWhat it costs per fileThe trade-off
PUT /v1/storage/object/put/{bucket}/{key} with base64 JSONunder ~1 MBone billable writeBytes go through your server; base64 inflates the payload by a third
Presigned op: "put"1 MB to a few GBsigning is free, the vendor PUT isn’t metered as a callClient must echo signed headers; browsers hit CORS (see below)
POST /v1/storage/multipart/create/{bucket} then partsmulti-GBone billable call per part plus the completeParts must be at least 5 MiB except the last; you own retry bookkeeping

For a document workflow the middle row is almost always right. A signed contract is 200 KB to 8 MB; multipart is bookkeeping you don’t need, and base64 through your Node process is memory you don’t need to spend.

What it costs, and how to get today’s number

Structure first, because that outlives any rate: bucket create, presign, head, list, set_metadata and lifecycle rules are free and rate-limited. Object writes and server-side reads are billable per call. Verified 26 July 2026, storage.object.put is $0.0001 per call and storage.object.get is $0.0002 — reads cost about twice writes — with stored bytes and egress metered separately. New accounts start with $2 of credit, which is roughly twenty thousand writes.

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

Rates here drift downward and discount campaigns run, so treat those figures as the ceiling and the live call as the truth. The durable argument isn’t the rate anyway: the same key that stored this PDF also runs the queue that virus-scans it, the cron that expires it, and the email that tells the customer it’s ready — one bill, one usage view, no second vendor onboarding.

Where another backend wins

Cross-origin browser uploads straight to the bucket don’t work today — the API reports cors_rules but has no route to set them, so the preflight fails. If your uploader runs in a browser tab and you refuse to proxy, you’d be better off on Amazon S3 or Cloudflare R2, where CORS is yours to edit. Running MinIO on your own hardware is the right answer when the documents can’t legally leave your building. And if you need server-side virus scanning or OCR built into the storage layer, this isn’t the tool — that’s a separate step you wire up yourself.

Blocked content types return STORAGE_CONTENT_TYPE_BLOCKED, so check that before assuming a signature problem.

References

Browse more storage developer guides