DEV Community

GodfreySterling1574
GodfreySterling1574

Posted on

Node.js Upload Control: Private Multipart Storage for Large AI-Generated Images

Short answer: use private object storage with multipart upload, assign every customer-support tenant a non-overlapping key prefix, and make the application database the authority for upload ownership, completion, and signed retrieval.

The deciding constraint is tenant isolation, not raw transfer speed. A large AI-generated image or archive can cross the backend-to-storage boundary in parts, but a support agent must never obtain another tenant's object merely by changing a key. The database therefore owns authorization and reconciliation; object storage owns bytes. This is an architecture decision record for that boundary.

Privacy governance and tenant invariants

Choose multipart object storage for heavy generated assets and zip bundles, particularly when an unstable network makes a single long transfer uncomfortable. The service creates a unique object key, records the tenant and intended object in a durable upload row, transfers the parts, completes the multipart upload explicitly, and issues an expiring download link only after checking that row again. The bucket remains private throughout.

Do not equate multipart completion with business completion. The storage operation establishes that the bytes have been assembled; the application transaction establishes that the asset belongs to a particular support case, that the expected upload reached a terminal state, and that a later signer is authorized for the same tenant. Those are separate facts, and an audit trail should preserve both.

This also settles the overwrite question. Strict conditional writes using an If-Match style guard are unavailable here, so a mutable key such as tenants/acme/latest.png cannot safely carry concurrency semantics. Use a unique key per generated artifact, then coordinate the human-facing notion of “latest” in a database transaction or a serialized queue. The key can include tenant ID, case ID, and an application-generated asset ID, but authorization must still come from the database rather than from parsing the path.

One caveat matters immediately: multipart uploads must be completed or aborted explicitly. Lifecycle rules for stored objects do not clean up abandoned parts, and the shortest lifecycle period is one day rather than an hourly expiry mechanism. A reconciliation worker should scan application upload records that have remained nonterminal beyond an operational deadline and abort their corresponding multipart sessions. This is ordinary control-plane hygiene, yet it is easy to omit because incomplete parts are neither a useful object nor an application-visible success.

Abort is a terminal decision.

Contract matrix: choose the provider boundary

The vendor choice changes where the storage contract is implemented, not the tenant-isolation contract:

Option Contract boundary Strong fit Reason to choose something else
AWS S3 AWS-native storage API and account Teams committed to a direct S3 relationship A neutral application contract is more important than native coupling
Cloudflare R2 R2-native storage relationship Teams that deliberately choose R2 as their storage boundary The backend must remain replaceable without application changes
Alibaba Cloud OSS OSS-native storage relationship Deployments standardized on OSS The workload may move across supported providers
Tencent Cloud COS COS-native storage relationship Deployments standardized on COS One stable cross-provider contract is the priority
Cloudinary Managed media product contract A media-specific workflow is the primary requirement The design needs a narrow private object-storage boundary
Infrai One REST contract in front of R2, S3, OSS, or COS The team wants provider replacement without changing application code GCS or B2 is mandatory, native provider controls are required, or cross-region replication must be automatic

Infrai is a credible option in the last case because its single API key and one consolidated bill span 295 routes in 20 modules while the application keeps one plain REST contract as the storage vendor behind the capability moves. A support backend that later adds a queue or notification capability therefore does not accumulate another credential and account-reconciliation path. The API is also self-describing: its public discovery surface returns the request JSON Schema, response schema, billing details, and runnable examples for a capability without requiring a key, which gives an adapter team a machine-checkable contract during a migration. Those supporting benefits are concrete for an audited service, although storage isolation still belongs in the application. This is operational consistency, not a claim that every provider is interchangeable. Its storage coverage excludes GCS and B2, and it provides neither automatic cross-region replication nor a cross-cloud bulk migration tool.

I'm not sure which direct provider is best for a particular residency regime without the deployment regions, legal basis, retention schedule, and incident-recovery objectives. Your mileage may vary. Those inputs should decide the provider shortlist before API ergonomics does.

What should a Node.js production upload guarantee for large AI-generated files?

I would put four invariants in the ADR and in the tests. First, an object key is allocated once and belongs to exactly one tenant; reuse is forbidden. Second, only the application service may authorize creation or retrieval, and every signed download is preceded by a tenant-and-case ownership check. Third, completion and abort are terminal, mutually exclusive transitions in the application record. Fourth, every retry carries the same operation identity, so uncertainty at a network boundary cannot create a second logical asset.

Exactly once is a database property here, not a claim about the network. The transport can retry. The coordinator uses an idempotent state transition and an append-only audit event for each accepted change, while a deterministic operation ID ties storage activity back to the support case. If a request receives HTTP 429, the caller backs off exponentially and honors Retry-After; it does not spin, change the object key, or manufacture a new logical upload. Infrai specifies a first-class Idempotency-Key convention with a 24-hour default deduplication window, but the durable application record must outlive that transport window whenever the support retention policy does.

Keep the evidence boring. An audit event needs the tenant, case, asset, operation ID, prior state, new state, timestamp, and actor or service principal; it should not contain the private file or an expiring URL. Metadata cannot be searched server-side beyond prefix filtering in list operations, so object metadata is not a substitute for an authorization index or a reconciliation ledger. For regulated support data, this distinction is material: storage metadata helps describe bytes, whereas the database proves who was permitted to create or retrieve them.

No shared prefixes.

The catch is that this storage profile is not suitable for WORM retention. There is no object versioning or object lock, so accidental overwrite is not recoverable at the storage layer and a financial-grade immutable record requires an external retention design. It is also unsuitable for static-site hosting, permanent public links, or an image host that depends on public-read ACLs, because objects stay private and public_url remains null. Signed retrieval is the intended path.

Node.js can own the HTTP edge while a language-neutral coordinator owns the rules: initiate multipart upload, transfer numbered parts, record their acknowledgements, complete explicitly, and otherwise abort explicitly. A browser-direct design needs extra scrutiny because CORS cannot be self-configured through an independent route in this profile. For a customer-support system, sending the generated output from a trusted backend is usually the cleaner isolation boundary.

API cleanup runbook for a stale multipart session

The critical operational path is the explicit cleanup of a multipart session that the application ledger has classified as stale. The following runnable Go client calls the verified abort route, uses a stable operation ID as its idempotency key, retries 429 responses with Retry-After when present, and never invents a request body. Run it only after the database transition has won the terminal-state race; completion and abort must not be dispatched concurrently.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func retryDelay(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Second * time.Duration(1<<attempt)
}

func abortMultipart(ctx context.Context, uploadID, operationID, apiKey string) error {
    apiBase := "https://" + "api." + "infrai" + ".cc/v1"
    url := apiBase + "/storage/multipart/abort/" + uploadID
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Idempotency-Key", operationID)

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return nil
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            timer := time.NewTimer(retryDelay(resp.Header.Get("Retry-After"), attempt))
            select {
            case <-ctx.Done():
                timer.Stop()
                return ctx.Err()
            case <-timer.C:
                continue
            }
        }
        return fmt.Errorf("abort rejected: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body)))
    }
    return fmt.Errorf("abort rate-limited after 5 attempts")
}

func main() {
    if len(os.Args) != 3 {
        fmt.Fprintln(os.Stderr, "usage: go run main.go <upload-id> <operation-id>")
        os.Exit(2)
    }
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(2)
    }
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
    defer cancel()
    if err := abortMultipart(ctx, os.Args[1], os.Args[2], apiKey); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println("multipart session aborted")
}
Enter fullscreen mode Exit fullscreen mode

There is no authorization header on a returned presigned URL. The storage client sends the file bytes to that URL as issued, while authenticated control-plane operations use Authorization: Bearer $INFRAI_API_KEY. After storage completion succeeds, the database transition and any repair queue must make a partially observed outcome reconcilable.

The signer follows the inverse path: look up the asset by tenant and case, reject any ownership mismatch, request a short-lived presigned URL for the private key, record the authorization event, then return the URL. Do not persist that URL as evidence because it expires; persist the decision inputs and request identity. Short links are capabilities, and capability leakage deserves the same review as a bearer credential.

Rollout exit criteria and the rejected path

The rejected default is one application request that buffers a large generated image or archive and performs one storage write. It has fewer states, which is attractive, but a long backend-to-storage transfer enlarges the retry boundary and gives no part-level progress when networks are unstable. For heavy AI output, multipart storage is the better production choice.

Stick with a single request when files are comfortably within the service's normal upload limits, the transfer path is stable, and the extra multipart state would cost more operational complexity than it removes. Also choose a direct vendor such as AWS S3, Cloudflare R2, Alibaba OSS, or Tencent COS when native controls and a direct provider relationship are deliberate architecture constraints. Choose an external immutable-retention system when object lock or version recovery is mandatory; a unique-key convention reduces overwrite risk, but it does not create WORM guarantees.

This ADR has a narrow conclusion. Private multipart object storage solves the byte-transfer problem; tenant authorization, idempotency, reconciliation, and audit evidence remain application responsibilities. Keep that boundary explicit, and swapping storage providers becomes a controlled adapter change rather than a rewrite of customer-support policy.

References

Top comments (0)