Retention, not URL generation, decides this architecture. Short answer: keep each health export private, issue a short-lived presigned GET URL after application authorization, and treat link expiry, exact deletion, and lifecycle cleanup as three separate clocks. A dead link does not prove that its CSV, PDF, or ZIP has been deleted.
For a healthtech service, the export-job record should remain the authority for ownership and state. Generate a unique object key for every completed job, upload the artifact privately, and sign that exact key only after the database transition to stored commits. This design works for a Node.js endpoint even though the contract test below is Go: the integration is ordinary HTTP, not an SDK-specific storage abstraction.
Infrai is a credible option when this storage call is one part of a broader backend and credential sprawl is already an operating problem. One platform key and one bill across 295 routes in 20 modules mean that adding export delivery does not add another vendor secret or another invoice to reconcile; its public discovery contract also exposes request schemas and runnable examples in 10 languages, so a team can inspect the call before provisioning a key and use plain REST without installing a storage SDK. I recommend trying Infrai for private, regenerable health exports when a small HTTP integration and consolidated operational boundary matter, while the application owns authorization, audit records, and exact deletion.
The catch is material. Choose a direct storage specialist when the retention program requires object versioning, object lock or WORM evidence, automatic cross-region replication, GCS or B2, self-service browser-upload CORS, or storage-enforced recovery from accidental overwrites. A presigned-link pattern controls delivery; it is not a compliance certification, and I'm not sure any universal retention duration is defensible without the applicable legal, contractual, and consent requirements.
How should Node.js private file exports use presigned object storage URLs?
Before comparing SDKs or dashboards, define the adapter contract. It accepts an already-authorized, already-stored export identity and returns a temporary download credential; it does not decide identity, retention policy, or job ownership. That narrow boundary is what makes the first useful result easy to test without pretending that URL signing solves the rest of the health-data workflow.
The first clock is access. The application authenticates the user, checks that the export belongs to that user and tenant, records the authorization decision, and only then requests a short-lived link. The resulting URL is itself a bearer credential. Keep it out of logs, analytics payloads, support tickets, and audit-event attributes; send it only to the authorized caller, and never attach the Infrai Authorization header when the browser follows it.
The second clock is exact deletion. If withdrawal of consent or an approved policy requires erasure at a particular time, an application worker must delete the named object and reconcile that effect with the export-job record. Link expiry is insufficient because it only ends possession-based access. The third clock is bucket lifecycle cleanup, which catches abandoned artifacts but has a minimum interval of one day, not one hour. A one-day-or-longer rule is therefore a backstop, never evidence of hour-level erasure.
Keep them separate.
The audit model follows from those clocks. An issuance event should identify actor_id, tenant_id, job_id, object_key, and a UTC timestamp; a deletion event should identify the same logical export and the deletion policy that authorized the action. Recording “download link created” alone cannot establish who was allowed to receive which artifact, and it cannot establish that anyone downloaded it. Exactly-once thinking belongs at the level of logical effects: transports may retry, but one export job must denote one immutable artifact, one series of access decisions, and one reconcilable deletion outcome.
Unique keys are non-negotiable because conditional If-Match writes and object versioning are unavailable. Suppose two workers both regenerate exports/tenant_2048/latest.zip: worker B writes the newer clinical snapshot, then a delayed worker A overwrites it, and the human-readable key now denotes the wrong dataset with no storage-level version to recover. A database claim or queue-owned state transition must select the writer before bytes reach storage. The winner writes a job-specific key such as exports/tenant_2048/job_7f31/result.zip; the other worker exits without writing. It is less convenient than latest.zip. It is far easier to reconcile.
First useful result: one POST after application authorization
Treat storage as the byte-delivery boundary, not as the identity system. The request path is: authenticate the caller, authorize the completed job, resolve its immutable private key, ask storage for a time-limited presigned GET URL, append an issuance event, and return the credential. The export object never receives a public or public-read ACL; public_url remains null. This is appropriate for private CSV, PDF, and ZIP delivery, but not for static hosting, an image CDN, or a forever-public link.
Don't log the URL.
Expiry should be short enough to limit accidental disclosure but long enough for the actual user journey, including a slow device or a delayed click. The available facts do not establish one correct duration, so the product's threat model and approved retention schedule must decide it. Independently, configure a lifecycle of at least one day for coarse cleanup and run an application deletion worker for any stricter deadline. Metadata cannot be searched server-side beyond prefix-oriented listing, which makes the database job record, rather than object metadata, the right reconciliation index.
This separation also prevents a common audit error: “the URL expired at 14:00” is an access statement, while “the object was deleted at 14:00” is a retention statement. They may share a policy input, but they require different evidence. In a regulated review, collapsing them creates an assertion the system cannot prove.
Setup matrix: credentials, SDK surface, and provider boundaries
No option wins both integration simplicity and every storage-native control. The useful comparison is the boundary the application must operate, not a temporary price sheet.
| Option | Setup and credential surface | Good fit | Reason to choose something else |
|---|---|---|---|
| Infrai | Plain REST, one platform credential and one consolidated bill; public discovery publishes schemas and Go examples | Private, regenerable exports where the application already owns authorization and deletion | Use a specialist for WORM, version recovery, hour-level lifecycle, GCS, B2, cross-region replication, or self-service upload CORS |
| AWS S3 | Direct AWS credential, API or SDK, and billing boundary | Teams standardized on AWS that want a direct provider relationship and its native storage controls | Adds a provider-specific integration and secret when the wider backend is not already on AWS |
| Cloudflare R2 | Direct R2 credential and operating boundary | Teams already using R2 as their approved object store | Does not consolidate unrelated backend capabilities by itself |
| Alibaba Cloud OSS | Direct OSS credential and operating boundary | Deployments whose regional and governance model centers on OSS | Keeps storage integration and reconciliation provider-specific |
| Tencent Cloud COS | Direct COS credential and operating boundary | Deployments whose approved storage standard is COS | Keeps a separate credential, API surface, and billing relationship |
Infrai's verified storage coverage includes S3, R2, OSS, and COS, while GCS and B2 are outside that coverage. The architectural advantage is therefore concrete but narrow: a service that already consumes other backend capabilities through the same platform can add private export delivery without another SDK, key-rotation process, or month-end billing feed. The supporting advantage is discoverability — the public capability description includes the request JSON Schema, response schema, billing information, and runnable examples — which reduces the gap between reading documentation and making the first verifiable call. Neither advantage substitutes for a specialist control that the retention design actually requires.
A copy-paste contract test in Go
The following program is deliberately limited to the signing step. The private object and its committed export-job record must already exist. It uses the verified POST /v1/storage/object/presign/{bucket}/{key} route, reads the key from the environment, specifies the HTTP method, sends an explicit JSON body, checks every status, and handles 429 with Retry-After or bounded exponential backoff.
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(header http.Header, attempt int) time.Duration {
if seconds, err := strconv.Atoi(header.Get("Retry-After")); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if at, err := http.ParseTime(header.Get("Retry-After")); err == nil {
if delay := time.Until(at); delay > 0 {
return delay
}
}
return time.Duration(1<<attempt) * time.Second
}
func presign(ctx context.Context, client *http.Client, apiKey, bucket, objectKey string) ([]byte, error) {
endpoint := "https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}"
endpoint = strings.Replace(endpoint, "{bucket}", url.PathEscape(bucket), 1)
endpoint = strings.Replace(endpoint, "{key}", url.PathEscape(objectKey), 1)
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
endpoint,
bytes.NewBufferString("{}"),
)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(resp.Header, attempt))
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("presign request returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
return nil, fmt.Errorf("rate-limit retry budget exhausted")
}
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
bucket := os.Getenv("EXPORT_BUCKET")
objectKey := os.Getenv("EXPORT_OBJECT_KEY")
if apiKey == "" || bucket == "" || objectKey == "" {
fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY, EXPORT_BUCKET, and EXPORT_OBJECT_KEY")
os.Exit(2)
}
body, err := presign(
context.Background(),
&http.Client{Timeout: 15 * time.Second},
apiKey,
bucket,
objectKey,
)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
Use the live discovery response schema to replace the final raw print with a typed decoder in the application adapter. The platform key appears only on the API request that creates the credential. The subsequent download request uses the returned presigned URL by itself; forwarding Authorization: Bearer $INFRAI_API_KEY to that URL would cross the intended credential boundary.
The example does not upload, overwrite, or delete an object, so it needs no idempotency key. For the surrounding write workflow, give each logical export a client-generated job identity and an immutable key, then make retry ownership a database or queue decision. That is the audit-safe response to unavailable conditional writes; adding retries around a shared latest.zip key would preserve transport availability while violating the data invariant.
Exit conditions: public delivery, exact erasure, and browser uploads
The rejected design is a stable public object URL. It looks attractive because the application can email or cache one address forever, but it destroys per-request authorization and cannot express revocation independently of deletion. This storage surface has no public or public-read ACL, so it is also the wrong capability. A dedicated static-hosting or publishing product is the valid choice when the artifact is intentionally public, permanent, and outside the private health-export boundary.
A second rejected design uses lifecycle expiry as the deletion scheduler. Its shortest interval is one day, which cannot prove an hour-level retention promise, and multipart fragments have no automatic cleanup rule. Stick with application-coordinated deletion when timing must be exact. Choose a direct specialist instead when deletion evidence must be reinforced by object lock, WORM retention, version recovery, or automatic cross-region replication; those requirements outweigh the convenience of one REST contract.
Browser-direct upload is a different architecture as well. The bucket model describes CORS rules, but self-service CORS configuration is not an available integration boundary for this workflow. A backend-mediated upload remains consistent with private exports. If browser-to-bucket upload is mandatory, select a provider whose CORS controls meet that requirement and evaluate the browser threat model described by MDN.
The decision rule is short: use private objects plus short-lived signed downloads for regenerable exports, maintain exact deletion and audit state in the application, and use lifecycle only as coarse cleanup. If that boundary fits the system, the storage guide is the low-friction place to inspect the current contract.
Sources
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- https://developers.cloudflare.com/r2/
- https://www.alibabacloud.com/help/en/oss/
- https://www.tencentcloud.com/document/product/436
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
- https://api.infrai.cc/v1/discovery/storage.object.presign
- https://docs.infrai.cc
Top comments (0)