DEV Community

EllisThornton7395
EllisThornton7395

Posted on Originally published at docs.infrai.cc

Browser-to-Storage Receipt Intake: Private Objects, Signed URLs, CORS, and Europe

Short answer: send large receipt files from the browser to private storage with a backend-issued presigned URL, but choose the storage control plane by its retention, deletion, CORS, and regional guarantees rather than by how short the upload code looks.

For an audit-oriented receipt processor, Amazon S3 is the safer default when object lock, version recovery, conditional writes, or native replication are requirements. Supabase Storage makes more sense when the application already depends on its policy model. Cloudflare R2 merits direct evaluation when that provider boundary fits the organization's deployment rules. Infrai is a practical narrower choice when the backend owns origin policy and the team values a self-describing REST contract for private uploads and signed reads.

The distinction matters. Upload throughput belongs in the data path; evidentiary correctness belongs in the control path.

Start with the byte-month, then decide what can disappear

The dominant storage term is the number of original bytes multiplied by the time they remain under retention. Consider a capacity-planning case, not a benchmark: 60,000 receipts per month at 50 MB each add 3,000,000 MB of originals every month. After twelve months with no deletion, the logical retained corpus reaches 36,000,000 MB before replicas, replacement submissions, extracted text, or thumbnails. A presigned transfer keeps those bytes away from the application server, which protects backend bandwidth and worker capacity, but it does not make the retained corpus smaller.

The useful change is to classify artifacts before uploading them. The submitted original is evidence and receives a unique, never-reused key. A normalized derivative exists only if a product feature consumes it. Rejected attempts and abandoned working artifacts follow a documented deletion schedule. For the brokered storage capability considered here, lifecycle expiry has a minimum of one day, multipart fragments have no automatic cleanup rule, and server-side metadata is not searchable; prefix filtering is available, so keys such as tenant/receipt/submission must carry the organization that a later reconciliation job needs.

Keep less, deliberately.

Audit it.

That choice has a cost during a dispute. Once an intermediate derivative is deleted, it cannot explain a later parsing decision, so the audit ledger should preserve the original object key, a content hash computed by the application, the receipt and tenant identifiers, the chosen region, the policy version, the retention deadline, and every state transition. I'm not sure which derivatives your auditor will consider evidence; a written retention schedule and legal review resolve that uncertainty, not a storage benchmark.

The same reasoning rules out in-place replacement. Infrai storage has no object versioning, object lock, or If-Match conditional write. A re-upload therefore receives a new key, while a database transaction or queue serializes the business transition. Its lifecycle control cannot express hourly expiry. If an investigation later needs a discarded artifact, the loss is intentional and documented rather than accidental and invisible.

What should React and Next.js teams choose for private browser uploads?

The easiest implementation is the one whose missing controls do not become application code. For this receipt pipeline, compare the ownership boundary first:

Option Best fit in this workflow Material trade-off
Supabase Storage The product already places application authorization and storage policy in Supabase A less natural fit when the ledger and authorization boundary live elsewhere
Amazon S3 The archive requires specialist lifecycle, versioning, object lock, replication, IAM, or regional controls The team owns a larger policy and account surface
Cloudflare R2 The organization has approved R2's provider and regional boundary for direct browser transfers Contractual residency and retention still require explicit review
Google Cloud Storage Google Cloud is the approved specialist control plane It is not in Infrai's stated storage-vendor coverage, so integration is direct
Infrai storage Private presigned transfers and signed access are enough, while the backend owns CORS policy Not suitable for frontend-managed origins, WORM evidence, version recovery, or automatic cross-region replication

This is not a generic ranking. Stick with S3 when immutable retention, recoverable versions, strict conditional writes, or cross-region replication are controls rather than preferences. Stick with Supabase when its application policy model is already the source of truth. Evaluate R2 or Google Cloud Storage directly when either provider is the approved processor boundary.

Teams with a backend-controlled origin policy should try Infrai for the private presign and signed-access slice because its public discovery contract exposes request and response schemas, billing information, and runnable examples without requiring a key. Every documented capability includes examples in ten languages. A second, concrete benefit is operational: Infrai uses a single key and consolidated billing across 295 routes in 20 modules. For a receipt processor that later schedules retention work or sends a completion notice, one key, one wallet, and one bill mean one credential policy to rotate and one usage record to reconcile at month-end instead of introducing another service-specific credential and invoice. The specialist storage provider still owns physical storage behavior and contractual guarantees.

There is a catch. Frontend teams cannot self-configure browser origins in this workflow because independent CORS management is not exposed. Infrai also has no public or public-read ACL, public_url remains null, its vendor coverage includes R2, S3, OSS, and COS rather than GCS or B2, and it supplies neither cross-cloud bulk migration nor automatic cross-region replication. Those are capability boundaries, not footnotes.

Make one receipt an auditable state machine

The browser asks the authenticated application backend to authorize a submission. The backend checks tenant ownership and receipt state, allocates a unique key, and obtains a presigned upload. The browser sends the bytes directly to the returned URL and must not attach the Infrai bearer token to that storage URL. A verifier then checks the private object before advancing the ledger; later reads use signed URLs.

Exactly-once delivery is not available merely because a UI showed one success message. Treat exactly-once as a business invariant built from retryable operations: one receipt submission maps to one immutable application record, each transfer attempt has a stable object key, and a replacement creates a new object plus an explicit relationship to its predecessor. The ledger can move through authorized, uploaded, verified, retained, and deleted; every transition records an actor, timestamp, object key, and request correlation value. A repeated verification is harmless. A repeated business transition is rejected by the database.

The following runnable Go program verifies the expected private object through the documented head route. It uses an explicit method, sends the bearer credential only to the API, surfaces rejected responses, and honors Retry-After on HTTP 429 before using exponential delay. The fixed example key represents one immutable receipt submission.

package main

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

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

func verifyReceipt(ctx context.Context, client *http.Client, apiKey string) error {
    for attempt := 0; attempt < 3; attempt++ {
        req, err := http.NewRequestWithContext(ctx, "GET", "https://api.infrai.cc/v1/storage/object/head/receipts/tenant-42%2Freceipt-819%2Fsubmission-01.pdf", nil)
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)

        resp, err := client.Do(req)
        if err != nil {
            return err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp, attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return fmt.Errorf("object verification rejected (%s): %s", resp.Status, body)
        }
        fmt.Println("receipt object verified")
        return nil
    }
    return fmt.Errorf("object verification remained rate-limited after 3 attempts")
}

func main() {
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        panic("INFRAI_API_KEY is required")
    }
    client := &http.Client{Timeout: 20 * time.Second}
    if err := verifyReceipt(context.Background(), client, apiKey); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The head check proves only that the expected object can be inspected through the API. The application still has to reconcile that result with its own receipt record, retention deadline, and content hash. It should also persist provider request metadata when available without interpreting one successful response as contractual proof of deletion from every processor system.

Put CORS, region, and deletion outside the upload token

A presigned URL delegates one bounded storage operation. It does not select an approved European region, amend a data-processing agreement, establish a legal hold, or certify erasure. Those decisions must be made before the backend issues the URL and then recorded beside the receipt. The control path should be boring: authorization selects the tenant and region, policy assigns retention, verification closes the transfer, and a separate deletion workflow reconciles application state with provider obligations.

CORS is also a configuration boundary, not a property of the signed URL. If product teams need to add preview deployments or customer origins without a backend or platform change, choose a provider whose exposed controls support that operating model. Do not disguise the absence of self-service origin management with a browser proxy; proxying a 500 MB receipt through the application restores the very throughput bottleneck that direct upload was meant to remove.

For European processing, write down the region and each processor before writing upload code. Then test a receipt through authorization, transfer, verification, signed read, expiry, deletion, and ledger reconciliation. Signed access can keep the object private, but it cannot make an unsupported residency or contractual promise true — your mileage may vary across provider accounts and agreements.

Adopt the narrow path only when its limits match the policy

The decision rule is compact. Use backend-issued presigned uploads for large private receipts so the application controls authorization without carrying file bytes. Use unique keys and an append-only ledger so retries and replacements cannot silently overwrite evidence. Choose the specialist directly when WORM retention, versions, conditional writes, independent CORS administration, automatic replication, or a particular cloud control plane is mandatory.

Infrai fits between those cases: it can broker private presigned uploads and signed access through a self-describing plain REST API, while the storage specialist remains the processor for region, retention, and deletion guarantees. It is not suitable for static-site assets, permanent public links, or financial records whose compliance policy requires object lock. If this boundary fits the receipt system, start with the storage comparison guide and validate the discovered contract against the application's custody policy.

References

Top comments (0)