Short answer: use private object storage for receipt originals, give each tenant a non-overlapping key prefix, store the immutable object key beside the receipt record, and issue a short-lived presigned GET URL only after the Node.js application authorizes the requesting user. Do not treat ordinary private storage as a WORM archive: if regulation or policy requires tamper-proof retention, add an external immutable-record control or choose storage with native object lock.
The constraint is tenant isolation, not upload throughput. A B2B SaaS that processes receipts must be able to show which tenant owned a byte sequence, which application action accepted it, and which principal later received temporary read authority. Keeping those decisions in the application database produces a useful audit trail; putting a permanent public URL in a row does not. Small receipt images and PDFs also do not justify multipart machinery by default. A simple PUT is the easier unit to retry and reconcile, while thumbnails or normalized previews can be produced in application code or a worker and stored under distinct keys. Keep the original untouched.
What failure modes break private object storage uploads in a Node.js SaaS?
Start with an invariant: one accepted receipt maps to one unique object key, and that key is never reassigned. A practical shape is tenant/{tenant_id}/receipts/{receipt_id}/original/{random_id}. The tenant prefix supports operational inspection, but it is not authorization; every presign request still passes through the application, which checks the authenticated principal against the tenant and receipt records.
This distinction matters. A UUID makes guessing difficult, yet secrecy of a key is not an access-control model. The bucket remains private, the database remains authoritative, and the presigned URL is a narrow, expiring read capability. Public or public-read hosting is unavailable in the storage option discussed below, so a permanent CDN-style link or static-site pattern is the wrong design. For each receipt, persist at least the tenant ID, receipt ID, object key, content type observed by the application, size observed by the application, processing state, and timestamps for the state transitions. Those fields are an application schema recommendation rather than searchable object metadata: server-side metadata search is unavailable, and object listing filters only by prefix. The audit event should identify the actor and the transition, such as upload_authorized, upload_recorded, or read_authorized; it should not store the presigned URL itself because that bearer capability expires and has no durable evidentiary value.
Be strict here.
An exactly-once outcome is assembled from idempotent state changes, not wished into existence. Allocate the receipt ID and unique object key before sending bytes, use a client-supplied idempotency key on writes, and make the database transition conditional on the expected processing state. If a retry follows an HTTP 429, honor Retry-After when it is present and otherwise use exponential backoff. Because conditional If-Match writes are unavailable, never let two requests compete to overwrite a shared avatar.jpg-style key; serialize the transition in the database or put the work behind a queue, then write each version to a new key.
Retries happen.
How can a team test the HTTP contract before a browser upload?
The browser should not choose its own bucket, tenant prefix, or receipt ID. It asks the Node.js API for permission; the API derives those values from authenticated server-side state. Direct browser upload also depends on CORS, and bucket CORS is not self-configurable in this setup, so verify the deployed origin policy before making browser-to-storage PUT the default. If that policy cannot be established, proxy small files through the application rather than weakening bucket privacy.
The following runnable Go client is deliberately small. The application can be Node.js because the integration boundary is plain HTTP — there is no storage SDK or client-library version to install — while this contract probe makes the two allowed operations and their retry semantics visible. It uploads bytes with PUT /v1/storage/object/put/{bucket}/{key}, then requests a signed read capability with POST /v1/storage/object/presign/{bucket}/{key}. It reads the API base URL and key from the environment, sends an idempotency key for the write, handles 429 responses, checks every status, and never forwards the platform authorization header to the returned presigned URL.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func call(method, baseURL, path, apiKey, idempotencyKey string, body []byte) ([]byte, error) {
client := &http.Client{Timeout: 30 * time.Second}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(method, baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
if method == http.MethodPost {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("storage request returned %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
return data, nil
}
return nil, fmt.Errorf("storage request remained rate limited after 5 attempts")
}
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
if apiKey == "" || baseURL == "" {
panic("INFRAI_API_KEY and INFRAI_BASE_URL are required")
}
bucket := url.PathEscape("receipts-private")
key := url.PathEscape("tenant/acme/receipts/r_4821/original/01JZ8M4M6W.pdf")
object := []byte("receipt-original")
_, err := call(http.MethodPut, baseURL, "/storage/object/put/"+bucket+"/"+key, apiKey, "receipt-r_4821-original", object)
if err != nil {
panic(err)
}
presign, err := call(http.MethodPost, baseURL, "/storage/object/presign/"+bucket+"/"+key, apiKey, "", []byte("{}"))
if err != nil {
panic(err)
}
fmt.Println(string(presign))
}
Run it with a non-production bucket and a non-sensitive fixture, then inspect the JSON response rather than assuming a field name that may belong to another provider. In the application flow, return only the signed URL value to the authorized caller. A browser or download worker uses that URL without Authorization: Bearer $INFRAI_API_KEY; attaching the platform credential would disclose authority to the signed destination and is unnecessary.
Implement receipt custody with a state machine
Receipt display is a read-authorization event. The Node.js handler first resolves the receipt inside the caller's tenant, records or emits the authorization decision according to the application's audit policy, and only then asks storage to presign the exact object key. Short expiry limits the usefulness of a leaked URL, but the correct duration is contextual: a user actively reviewing one receipt needs less time than a controlled export job. I'm not sure a universal number can survive both threat models; the security owner should select it from measured client behavior and the organization's access policy.
Processing produces new objects. A thumbnail, OCR-friendly derivative, or normalized image belongs under a separate key and a separate database field, because storage-side image processing is not provided. This also prevents a worker retry from replacing the original. The database can move from uploaded to processing to ready with compare-and-set state transitions, and each worker execution can look up the receipt ID before applying an effect. The resulting behavior is effectively once for the business record even if delivery is repeated.
There is a harder audit limit. This storage surface has no object versioning or object lock, its lifecycle minimum is one day rather than hours, and multipart fragments do not receive an automatic cleanup rule. It also has no automatic cross-region replication or bulk cross-cloud migration tool. Therefore the design is suitable for private receipt processing only when the application audit trail and an external retention control satisfy the organization's requirements. It is not suitable as the sole immutable archive for a regulated ledger or evidentiary record. No API integration, by itself, proves compliance.
Proof beats assumption.
Govern tenant cutovers through reconciliation
The useful comparison is not a stale price grid. It is the location of the tenant boundary, the credential boundary, and the retention control. Infrai is a credible option when a team wants one plain REST API, one key, and one bill across R2, S3, OSS, and COS without installing a vendor SDK; its consistent HTTP contract is the main advantage for a polyglot backend. The catch is material: choose another arrangement when you need public hosting, native object lock or versioning, conditional If-Match writes, self-service CORS changes, automatic regional replication, or GCS/B2 coverage.
| Option | Useful fit for this receipt system | Decision boundary |
|---|---|---|
| Infrai | A polyglot service wants a direct REST contract over R2, S3, OSS, or COS | Do not use it as the sole WORM archive; GCS and B2 are outside its provider coverage |
| Amazon S3 | The organization wants a direct S3 relationship and may need its documented multipart workflow | Accept the provider-specific integration and validate required retention controls directly |
| Cloudflare R2 | Platform policy already mandates a direct R2 account | Keep the R2 contract, credentials, and audit evidence in the application's vendor boundary |
| Alibaba Cloud OSS | Data placement or procurement mandates direct OSS ownership | Prefer the native relationship over an abstraction when provider-specific controls govern approval |
| Tencent Cloud COS | The tenant environment mandates direct COS ownership | Treat portability as secondary to the mandated control boundary |
| Google Cloud Storage or Backblaze B2 | GCS or B2 is a non-negotiable platform standard | Use a direct integration because neither provider is covered by this Infrai storage surface |
Roll out by tenant cohort, not by replacing every object path in place. First, freeze the key grammar and database uniqueness constraint. Next, exercise PUT and presigned GET with synthetic receipts, including a deliberate repeated idempotency key and a 429 retry test at the client boundary. Then enable one internal tenant, reconcile database rows against object HEAD results, and record any mismatch as an operational exception. Expand only after the reconciliation is boring.
Keep the old read path available during migration until every row selected for the cohort points to its verified new object key. There is no bulk cross-cloud migration tool in this surface, so migration orchestration, checksums, progress accounting, and rollback ownership remain application responsibilities. Do not delete the source merely because a copy request was accepted; deletion follows verified reconciliation and the organization's retention approval.
The final decision rule is compact: private signed access plus unique keys is enough for ordinary receipt processing, while immutable retention, provider-specific controls, or mandated GCS/B2 placement should send the system to a direct provider integration or a dedicated archive. Tenant isolation comes first. Everything else follows.
References
- MDN, “Cross-Origin Resource Sharing (CORS)”: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
- AWS, “Multipart upload overview”: https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
- AWS, “Using S3 Object Lock”: https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html
- Cloudflare, “R2 documentation”: https://developers.cloudflare.com/r2/
- Google Cloud, “Cloud Storage documentation”: https://cloud.google.com/storage/docs
Top comments (0)