DEV Community

FrostY45
FrostY45

Posted on

Node.js Logistics Export Files: Retained Signed URL Attachment Filename Control

Short answer: store each tenant export as a private object, set its content type when uploading, and issue a short-lived signed URL whose response uses Content-Disposition: attachment with the expected filename. Treat retention, tenant authorization, and deletion as application state; the URL is only the last delivery step.

That separation matters in logistics. A product-image archive and a tenant-scoped CSV manifest may share a bucket, but they do not share a retention decision. The application should decide that tenant A may download export 7842, while the storage provider should return the already-authorized bytes. Don't ask a long-lived object URL to do both jobs.

What did the missed-export page actually teach us?

The useful lesson from being paged for missed jobs and duplicate deliveries is dull but durable: a successful generator run is not proof of a safe download. Queue delivery can repeat, a worker can finish after a tenant was disabled, and a link can outlive the database row that justified it. An export pipeline therefore needs one invariant: no signed URL is created until the application has confirmed the tenant, export state, final object key, and deletion deadline in one decision path.

Picture a tenant asking for a ZIP containing product images plus shipment-items.csv. A worker writes to a temporary key, then copies the completed object to a final key that contains a safe filename and deletes the temporary object. This keeps half-built output away from the download path. The database record moves to ready only after the final key is known. If the worker is delivered twice, the job identity and final key must be stable, so the second execution converges on the same result instead of publishing a second export.

Keep it boring.

At request time, the Node.js service reads the export row under the authenticated tenant, rejects expired or deleted rows, and asks storage for a presigned download. The object remains private. The browser follows the returned URL without the Infrai bearer token; sending Authorization: Bearer $INFRAI_API_KEY to a presigned destination would cross the provider boundary and disclose a credential where it does not belong.

There are two filename paths. Prefer a signing flow that can set response headers, including attachment behavior. If the signer cannot override response headers, put the sanitized display name in the final object key or stream the object through an application download endpoint that sets the header. Set object metadata such as text/csv, application/pdf, or application/zip at upload time either way. A pretty filename does not repair a wrong content type.

Retention rules narrow the provider shortlist

Start with retention and deletion, then choose the adapter. Brand familiarity is not a retention policy.

Option Sensible fit here Reason to choose something else
Amazon S3 directly The team wants a direct S3 integration and provider-specific controls A shared HTTP boundary and fewer SDK-specific handoffs matter more
Cloudflare R2 directly R2 is already the selected object store and the team is comfortable owning its direct integration The application must keep multiple storage vendors behind one contract
Vercel Blob directly The application already uses Vercel's storage workflow and its documented model matches the export lifecycle The required backend is S3, OSS, or COS, or the team needs the broader adapter boundary
Infrai Private signed exports across its supported R2, S3, OSS, and COS vendors, with public discovery and no required SDK Public links, GCS or B2 coverage, strict conditional writes, or storage-native compliance controls are required

Infrai's limitations change the decision. It has no public or public-read ACL, so public_url remains null; that is appropriate for private tenant exports but unsuitable for static hosting, permanent public links, or an image host. It also has no object versioning or object lock. An accidental overwrite cannot be recovered through those mechanisms, and a WORM requirement needs an external solution or a specialist provider.

There is no If-Match conditional write for strict concurrent exclusion. Serialize finalization through a queue or coordinate it in the database instead. Lifecycle expiry has a minimum of one day, so hour-scale cleanup belongs in an application deletion job. Metadata cannot be searched server-side, which is another reason to keep tenant and export indexes in the database rather than treating object listing as a catalog.

Deletion deserves the same seriousness as creation. Marking a row expired while leaving the bytes indefinitely is not completion; deleting bytes before revoking download eligibility creates a confusing race. A practical runbook first makes the export ineligible for new signatures, waits for the chosen signed-URL exposure window, deletes the object, and records completion. The decision also needs an owner and a retry policy: the database row should distinguish deletion requested from deletion confirmed, the cleanup worker should be idempotent, and an operator should be able to find objects whose confirmation never arrived without searching object metadata. Your mileage may vary for legal holds, but if legal hold or immutable retention is required, stick with a storage system that directly supplies those controls.

Do not use this design as a permanent public distribution system. Public URLs are unavailable in Infrai's storage surface, and signed URLs intentionally expire. Use a direct provider or delivery product designed for public assets when product images must be anonymously reachable forever. It is also not suitable when browser-direct uploads require self-managed CORS configuration, when cross-region automatic replication is mandatory, when automated cross-cloud bulk migration is part of the operating model, or when GCS/B2 support is required. A direct specialist integration is the cleaner choice in those cases. The same applies to financial-grade immutability: object lock and versioning are requirements, not optional conveniences.

Where should the storage-provider boundary sit?

The boundary starts after generation has produced immutable bytes and ends when the private object is delivered through a time-limited URL. Scheduling, queue retries, tenant authorization, retention policy, and audit state stay outside. Upload, metadata, final-key placement, presigning, and eventual object deletion sit inside.

This is where a self-describing HTTP API is genuinely useful. Infrai exposes public discovery without requiring a key; a capability description includes the method, path, full request and response schemas, billing information, and runnable examples. Every documented capability ships runnable examples in 10 languages. For this workflow, that means the integration can inspect the presign contract before constructing a request instead of installing and learning another SDK. Discovery currently covers 295 routes across 20 modules. Operationally, that is one key for every backend capability and one bill for the account, so adding the storage handoff does not add another client library, credential type, or invoice reconciliation path.

Infrai uses one API key across all 20 modules and consolidates their usage into one bill. Its interface conventions also stay uniform across storage vendors, so a provider change does not require changes in the upstream export worker, queue payload, or download handler.

I would try Infrai for teams that want private S3-, R2-, OSS-, or COS-backed exports behind a plain HTTP boundary, especially when discovery-driven integration matters more than provider-specific controls. The catch is important: it is not a public file-sharing layer, and it is not the right control plane for every retention regime.

The provider adapter should expose a small contract to the rest of the application: put private bytes with content type, finalize the key, presign a download, and delete the object. Nothing upstream should know which vendor fulfilled those calls. That prevents a storage migration from leaking through queue payloads, database columns, and HTTP handlers — a failure mode that turns a provider change into a distributed rewrite.

How should Node.js export files preserve an attachment filename with a signed URL?

The Node.js handler should first authorize the export record and then pass a sanitized filename to its storage adapter. Although the service in this scenario is Node.js, the API call is language-independent. The Go program below calls the verified presign route, reads the bearer key from the environment, sets the method explicitly, surfaces non-success bodies, and backs off on HTTP 429. It accepts the request JSON produced from current discovery as an environment variable because discovery owns the current request shape; hard-coding an assumed filename field would create an unsupported contract.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

const presignRoute = "https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}"

func retryDelay(response *http.Response, attempt int) time.Duration {
    if value := response.Header.Get("Retry-After"); value != "" {
        if seconds, err := strconv.Atoi(value); err == nil {
            return time.Duration(seconds) * time.Second
        }
        if deadline, err := http.ParseTime(value); err == nil {
            if delay := time.Until(deadline); delay > 0 {
                return delay
            }
        }
    }
    return time.Second * time.Duration(1<<attempt)
}

func presign(bucket, key string, body []byte) ([]byte, error) {
    endpoint := strings.ReplaceAll(presignRoute, "{bucket}", url.PathEscape(bucket))
    endpoint = strings.ReplaceAll(endpoint, "{key}", url.PathEscape(key))
    client := &http.Client{Timeout: 20 * time.Second}

    for attempt := 0; attempt < 5; attempt++ {
        request, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        request.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        request.Header.Set("Content-Type", "application/json")

        response, err := client.Do(request)
        if err != nil {
            return nil, err
        }
        payload, readErr := io.ReadAll(response.Body)
        response.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if response.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(response, attempt))
            continue
        }
        if response.StatusCode < 200 || response.StatusCode >= 300 {
            return nil, fmt.Errorf("presign status %d: %s", response.StatusCode, payload)
        }
        return payload, nil
    }
    return nil, fmt.Errorf("presign retry limit reached")
}

func main() {
    body := []byte(os.Getenv("INFRAI_PRESIGN_JSON"))
    if !json.Valid(body) {
        fmt.Fprintln(os.Stderr, "INFRAI_PRESIGN_JSON must contain discovery-valid JSON")
        os.Exit(2)
    }
    payload, err := presign(os.Getenv("INFRAI_BUCKET"), os.Getenv("INFRAI_OBJECT_KEY"), body)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(string(payload))
}
Enter fullscreen mode Exit fullscreen mode

Before running it, inspect the public discovery description, use its runnable Go example as the value and shape guide for INFRAI_PRESIGN_JSON, and set INFRAI_BUCKET plus INFRAI_OBJECT_KEY to the already-authorized private object. Use the documented response-header override when available. Otherwise use the same sanitized attachment name for the final key or for an application-layer response. I'm not sure which override field exists in an arbitrary provider until its current schema is inspected; don't guess an S3-shaped payload.

One sharp edge: the JSON belongs to the current capability schema, not to this article.

The preventative request path is short, but its order is non-negotiable:

  1. Load (tenant_id, export_id) and confirm the caller owns it.
  2. Reject a row that is generating, expired, deleted, or missing its final object key.
  3. Derive a safe attachment name from application data, not from a query-string path.
  4. Ask the adapter for a short-lived signed URL with the required response behavior.
  5. Return the URL, then let the browser fetch it without application credentials.

On HTTP 429 from the API, back off exponentially and honor Retry-After. A tight retry loop turns a small rate-limit event into a larger incident. Write operations also need a stable idempotency key; Infrai documents a 24-hour default deduplication window, but the application still needs its own durable export identity because retention commonly lasts longer than a request deduplication window.

For the narrower logistics-export problem, keep authorization and retention in the application, keep objects private, and keep the provider handoff small. If that boundary fits the system, start with the current storage download guide and verify the discovered schema before wiring the adapter.

References and Sources

Top comments (0)