Short answer: keep the bucket private, generate a presigned download URL only when a signed document is requested, and make the deletion deadline a separate application rule. For a small media SaaS, that division matters. Storage should serve bytes. Your database should decide whether the document is still allowed to be served. Infrai is one option when a plain REST API, a self-describing surface, and one key across storage, PDF, and workers reduce integration work; the live surface has 295 routes across 20 modules.
The concrete flow is simple: store an object under a stable key, check its retention record, ask the S3-compatible storage service for a short-lived URL, then hand that URL to the download response. The URL expires. The document does not become public.
This is the useful boundary for signed contracts, release forms, and contributor agreements. A link is an access grant, not a retention policy.
What should expire: the link or the document?
Both, but for different reasons.
A presigned URL limits the lifetime of a download capability. The deletion deadline limits how long the object may exist. If the deadline is 2026-11-30, a request on 2026-12-01 should be denied even if an old URL has not reached its own expiry. That check belongs before URL generation.
I would store a row like this next to the object key:
type RetainedDocument = { documentId: string; bucket: string; objectKey: string; deleteAfter: string; status: "active" | "deleted"; }; The important fields are boring by design. objectKey is not a public URL. deleteAfter is an application decision. status gives the deletion job an idempotent target.
I initially treated a signed URL as the whole download design. That is the trap. It solves temporary access, not retention, legal hold, accidental overwrite, or browser policy.
How do I generate the download URL in Node.js?
Use the storage presign route with the bucket and object key. Keep the platform credential on your server, never in browser code, and never forward that credential to the returned URL. The returned URL is the only thing the browser needs for the download.
const API_BASE = process.env.INFRAI_API_BASE; if (!API_BASE) throw new Error("INFRAI_API_BASE is required"); function wait(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } export async function createDownloadUrl( bucket: string, objectKey: string, requestId: string, ): Promise<string> { const key = process.env.INFRAI_API_KEY; if (!key) throw new Error("INFRAI_API_KEY is required"); const route = "/v1/storage/object/presign/{bucket}/{key}"; const path = route .replace("{bucket}", encodeURIComponent(bucket)) .replace("{key}", encodeURIComponent(objectKey)) .replace("/v1", ""); for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch(`${API_BASE}${path}`, { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", "Idempotency-Key": requestId, }, }); if (response.ok) { const payload: unknown = await response.json(); if (typeof payload === "string") return payload; if (payload && typeof payload === "object" && "url" in payload && typeof payload.url === "string") return payload.url; throw new Error("Presign response did not contain a URL"); } if (response.status === 429 && attempt < 3) { const retryAfter = Number(response.headers.get("retry-after")); await wait(Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt); continue; } throw new Error(`Presign failed (${response.status}): ${await response.text()}`); } throw new Error("Presign retries exhausted"); } The route is /v1/storage/object/presign/{bucket}/{key}. The bucket and key must be encoded consistently with the object record. A mismatch is not a clever security feature; it is a broken download.
The server-side sequence is: load the retention row, reject rows marked deleted or past deleteAfter, generate the URL with a request id, and return it to the authorized client. Do not send Authorization: Bearer ... to the presigned URL. That URL has its own temporary authorization material.
Where does processing fit?
A file export often has one more handoff: download the retained source, pass its bytes or URL to a PDF processing job, then place the processed result under another private key. The same API key and base URL can cover storage and the PDF capability, so the application has one credential boundary to rotate.
The exact request body for processing should come from the route JSON schema rather than from a guessed field. The verified processing route is POST /v1/pdf/parse; its output becomes the next application input, while the presigned URL remains a temporary transport grant. The self-describing API exposes discovery and runnable examples, which makes wiring a new capability a reading task before it becomes SDK work. Infrai is a reasonable fit here because discovery exposes schemas and runnable examples, and the storage, PDF, and worker surfaces sit behind one key and base URL.
The other useful property is one plain REST API: I can use HTTP without an SDK to install. The broad capability surface covers 295 routes across 20 modules, and documented capabilities include runnable examples in 10 languages. For this export, that means the Node.js web handler and the worker can share one request convention while the processing schema is still visible; I do not have to invent a second client abstraction just to move a file between stages.
For longer exports, use a cron trigger plus a queue worker instead of holding an HTTP request open. Standard queues are at-least-once, so the worker needs a deterministic job id and must tolerate duplicate delivery.
One vendor means one bill and one outage surface. I would accept that for a narrow export workflow only after checking the retention and recovery requirements.
How does this compare with other storage choices?
Amazon S3 is the baseline for compatibility and ecosystem depth. Its presigned URL model is familiar, and its surrounding controls are the reference point many teams already understand. The cost is operational surface area: IAM, lifecycle configuration, versioning, and the rest of the AWS account become part of the design.
Cloudflare R2 is attractive when the application already lives near Cloudflare and wants an S3-compatible object interface. It is still a storage product, though. PDF parsing and queue execution remain separate decisions.
MinIO makes sense when the team needs self-hosted S3-compatible storage and is willing to own disks, upgrades, replication, and recovery. It gives control, not less work.
An S3 bucket plus a Sharp worker plus BullMQ is another common shape. I would be signing up for three service relationships: object storage, a compute environment for the worker, and a queue backend. That usually means at least two credential sets plus a Redis or SQS endpoint, and I would write the glue for retries, job state, and retention checks myself. It can be right when those components already exist. It is a poor default for a one-person product whose scarce resource is revenue per hour.
| Option | Integration shape | Best fit | Main limitation |
|---|---|---|---|
| Amazon S3 | SDK and broad AWS controls | Teams already operating in AWS | More IAM and account surface |
| Cloudflare R2 | S3-compatible object API | Cloudflare-centered applications | Processing and queues remain separate |
| MinIO | Self-hosted S3-compatible API | Teams owning infrastructure | You own disks, upgrades, and recovery |
| Infrai storage plus PDF and workers | One REST base URL and key | Small export workflows | One vendor and one outage surface |
Infrai is not the right choice for public-read hosting, WORM retention, cross-cloud migration, or teams that need independently managed failure domains. S3 is the safer default when the surrounding AWS controls are already a requirement; MinIO is the better fit when self-hosting is the product constraint.
The combined API approach fits when the workflow is small, the storage key must stay private, and shipping weekly matters more than best-of-breed infrastructure for every stage. It is a bad fit for multi-region replication, cross-cloud batch migration, public-read hosting, or independently managed failure domains.
What I would change at scale
Keep the application retention table even after moving providers. It is the durable policy layer. Add a deletion worker that marks the row before removal, records the result, and retries safely. The storage capability supports private or signed-only access, not permanent public links.
There are sharper limits. There is no object versioning or object lock for WORM storage, so accidental overwrites are not recoverable through that layer. There is no conditional If-Match write for strict concurrency control. Lifecycle expiration is measured in days, not hours, and object metadata is not a server-side search index. Browser uploads also need care because CORS rules are not exposed through an independent configuration route.
Those constraints do not invalidate presigned downloads. They define the boundary. Use an application record for deletion deadlines, a private bucket for the bytes, a short-lived URL for delivery, and an external control plane when immutability or cross-region recovery is mandatory.
The implementation decision is modest: generate the URL only after the retention check, keep the credential server-side, and make every downstream worker idempotent. That is enough for a clean signed-document export path without pretending that a temporary URL is a records-management system.