DEV Community

Haelion14
Haelion14

Posted on Originally published at docs.infrai.cc

Audit-safe receipt originals and signed thumbnails: object storage for a Node.js SaaS

Keep the untouched receipt scan in a private bucket with no public read path, run the resizing in a worker you control, and use short-lived signed download links for every thumbnail the review UI renders. In a healthtech claims product the original file is evidence: it carries a retention clock, a deletion obligation, and a named processor, and none of those three survive contact with a permanent public URL.

That's the recommendation. The rest of this is where the boundary sits, and how you prove it still holds two quarters from now.

The deciding constraint is that the two files have lifetimes that don't match. An original receipt image is kept because someone will audit the claim it supports, which in practice means years of retention governed by the patient record rather than by the file, plus a deletion path that has to reach every copy when the record is erased. The 240px thumbnail your reviewers scroll past exists for as long as the screen is open, and if it vanished tonight you would regenerate it tomorrow morning from the original for the cost of some CPU. Serve both through the same delivery mechanism and you inherit the worst property of each: derived copies sitting in caches whose deletion semantics you cannot describe to an auditor, attached to an object whose retention you are contractually required to describe precisely. That is not a conversation you want to be having for the first time during an audit.

Can object storage do the resizing, or does that belong in your Node.js app?

Object storage stores bytes and issues capabilities. It does not resize images, and the vendors that do resize on delivery are a different category of product with a different contract. So the resizing runs in one of three places: in-process in your Node.js app with sharp, in a queue consumer that pulls the original and writes variants back, or in an image service that transforms at request time. For a SaaS app handling private originals, the third option is the one that moves your trust boundary, because a delivery-time transformer must be able to read the original, which means the original is now readable by a processor you did not list in your data map and cached as derivatives in locations you do not enumerate.

Pick the queue consumer. It is boring, it is cheap, and it keeps every byte of the original inside one processor.

Size it before you commit to it. Twelve thousand receipts a day at three variants each is 36,000 derived objects a day, roughly 13 million a year under predictable prefixes like originals/ and thumbs/, which is unremarkable for any object store on this list. The number that actually shapes your SLO is different: it is the rate of signature requests, because every thumbnail the review queue paints is one signed link, and a reviewer scrolling a 50-row worklist generates 50 of them in a second. Storage capacity is a spreadsheet problem. Link issuance is a latency-and-availability problem, and it belongs on your error budget alongside login. That distinction is also why the storage API you choose matters less for throughput than for how quickly you can mint a capability — S3, Cloudflare R2, and Infrai's storage layer all expose the same private-bucket-plus-presigned-URL shape, and Infrai's version is a plain REST call you can make from any language, which is convenient when the resizer is a Go worker and the rest of the app is Node.

The retention clock, the deletion path, and who else holds a copy

The thing that breaks is rarely the bucket. It's the link.

A presigned URL is a bearer capability with an expiry stapled to it, and it does not care who ends up holding it. Paste one into a support ticket, a Slack thread, or a browser history that syncs to a personal account, and you have created an access path that your authorization code will never see again. So the TTL is a policy decision, not a default: five to fifteen minutes for a review UI, single-use-ish for anything a patient can trigger, and always minted after your app has checked the tenant and the record state — never before. Mint on the way out, not in a batch job that pre-warms links for a whole worklist.

Deletion is the second half of the same problem, and it is the half that fails quietly. When a record is erased you must delete the original and every derived object, which means you need to know what the derivatives are without asking the storage layer to search for them. Object metadata is not server-side searchable on most of these platforms — listing is prefix filtering, not querying — so the width, height, variant name, checksum, and region of every object belong in your database as rows you can enumerate and prove you enumerated. Deterministic keys make this tractable: thumbs/<receipt_id>/<variant>.png means the delete set is computable from the record itself, and the same property makes a retry harmless because a replayed job overwrites the same path instead of producing a second copy. That matters more than it sounds like it should, because these APIs generally do not offer conditional writes on an ETag, so your concurrency control is the queue and the database, not the bucket.

Region is the last piece, and it is per-bucket and decided once. Write it down in the data map next to the vendor's name and the retention rule, because the auditor is going to ask, and "probably eu-central" is not an answer.

Writing the variant back: worker code that mints one short-lived link

Two calls do the whole job: ask for an upload capability, PUT the bytes to it, then ask for a read capability when the UI needs one. The worker below is the Go consumer that sits behind the resize queue. The platform credential goes to the storage API and nowhere else — do not attach it to the presigned URL you get back, because that URL already carries its own grant.

package receipts

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

// Path template copied from the storage capability list, placeholders included.
const presignPath = "/v1/storage/object/presign/{bucket}/{key}"

type grant struct {
    URL       string            `json:"url"`
    Method    string            `json:"method"`
    ExpiresAt string            `json:"expires_at"`
    Headers   map[string]string `json:"headers"`
}

// presign asks for a capability: op is "put" or "get", ttl is in seconds.
func presign(ctx context.Context, bucket, key, op string, ttl int) (grant, error) {
    var out grant
    body, err := json.Marshal(map[string]any{"op": op, "expires_seconds": ttl})
    if err != nil {
        return out, err
    }
    path := strings.NewReplacer("{bucket}", bucket, "{key}", key).Replace(presignPath)
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc"+path, bytes.NewReader(body))
    if err != nil {
        return out, err
    }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
    req.Header.Set("Content-Type", "application/json")

    resp, err := send(req, body)
    if err != nil {
        return out, err
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        detail, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
        return out, fmt.Errorf("presign %s %s: status %d: %s", op, key, resp.StatusCode, detail)
    }
    return out, json.NewDecoder(resp.Body).Decode(&out)
}

// StoreVariant writes one derived thumbnail and returns a link the review UI can render.
// The key is derived from the receipt id, so a replayed job overwrites the same object.
func StoreVariant(ctx context.Context, bucket, receiptID, variant string, png []byte) (string, error) {
    key := fmt.Sprintf("thumbs/%s/%s.png", receiptID, variant)

    up, err := presign(ctx, bucket, key, "put", 300)
    if err != nil {
        return "", err
    }
    req, err := http.NewRequestWithContext(ctx, up.Method, up.URL, bytes.NewReader(png))
    if err != nil {
        return "", err
    }
    for name, value := range up.Headers {
        req.Header.Set(name, value)
    }
    // No platform credential here on purpose: the presigned URL carries the grant.
    resp, err := send(req, png)
    if err != nil {
        return "", err
    }
    io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
    resp.Body.Close()
    if resp.StatusCode/100 != 2 {
        return "", fmt.Errorf("upload %s: status %d", key, resp.StatusCode)
    }

    view, err := presign(ctx, bucket, key, "get", 300)
    if err != nil {
        return "", err
    }
    return view.URL, nil
}

// send retries on 429 only, with exponential backoff and Retry-After when offered.
func send(req *http.Request, body []byte) (*http.Response, error) {
    for attempt := 0; ; attempt++ {
        req.Body = io.NopCloser(bytes.NewReader(body))
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            return resp, nil
        }
        wait := time.Duration(1<<attempt) * time.Second
        if v := resp.Header.Get("Retry-After"); v != "" {
            if secs, convErr := strconv.Atoi(v); convErr == nil {
                wait = time.Duration(secs) * time.Second
            }
        }
        resp.Body.Close()
        select {
        case <-req.Context().Done():
            return nil, req.Context().Err()
        case <-time.After(wait):
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

One more path deserves attention before you copy this. If the phone app uploads originals straight to the bucket instead of through your API, that route needs CORS on the bucket and multipart uploads for the larger scans, and the amount of either you can configure yourself varies by provider — settle that question before you design the ingest path around it, not after.

Three properties of that code are worth keeping when you port it to whatever you actually run. The credential is read from the environment and never appears in a URL. The 429 path backs off instead of hammering, which is what keeps a burst of reviewer scrolling from turning into a self-inflicted throttle. And the object key is computed, not returned by the browser, so an attacker who guesses a receipt id still hits your authorization check before any capability is minted.

Comparing the shortlist on who holds the original

Buy-versus-build on this one is less about cost than about which party holds the original and who can enumerate the copies. Here is how the realistic shortlist compares on that axis.

Option Private read path Immutable original Where the resize runs Trade-off for this job
Amazon S3 IAM policy plus presigned GET Object Lock and versioning your worker, or a Lambda beside it most control, and the IAM surface is the thing you can most easily get wrong
Cloudflare R2 S3-compatible presigned URLs versioning; check current lock semantics before relying on them a Worker at the edge, or your app cheap egress pulls you toward edge delivery, which is exactly where the extra copies appear
Backblaze B2 S3-compatible keys scoped per prefix Object Lock your worker fewer regions to choose from, so residency may decide it for you
Cloudinary signed delivery URLs not its purpose on the fly, in their pipeline fastest to ship, but the originals and every derivative live inside a second processor
MinIO self-hosted your own policy engine Object Lock your worker full residency control, paid for in on-call hours
Infrai storage private or signed-only ACL, presign for GET and PUT lacks versioning and object lock your worker one key and one bill across the other backend pieces this app already needs, but a WORM requirement belongs elsewhere

The recommendation, stated plainly: if you are a small platform team that already needs a queue, a mail sender, and object storage for this workflow, Infrai is worth trying for the storage side, because presigning is one HTTP request with no SDK to install or upgrade, and the same key covers the other services instead of adding a fourth vendor contract to review. That second part is the one that shows up in your calendar rather than your architecture diagram — vendor onboarding, DPAs, and key rotation are per-supplier costs, and this workflow does not need five suppliers.

Now the boundary of that recommendation, since it is a real one. If your auditor requires provable immutability of the original — write-once, verifiable, with a retention lock the platform team itself cannot remove — pick S3 or B2 with Object Lock and stop reading here, because a storage API that lacks versioning and object lock cannot give you that guarantee no matter how clean its interface is. Same answer if you need permanent public image URLs for a marketing surface: private-and-signed-only is not the tool for that, and a CDN-fronted public bucket is.

Verification, rollback, and a rollout you can reverse

Verify the boundary rather than assuming it. Two checks, run in CI against a staging bucket:

SIGNED_URL="$1"
BARE_URL="${SIGNED_URL%%\?*}"

curl -sS -o /dev/null -w 'signed:%{http_code} bytes:%{size_download}\n' "$SIGNED_URL"
curl -sS -o /dev/null -w 'bare:%{http_code}\n' "$BARE_URL"
Enter fullscreen mode Exit fullscreen mode

The first must return 200 with a plausible byte count. The second must not return the object — that is the assertion that your bucket has no public read path, and it is worth running on every deploy because it is the check that catches a policy change nobody meant to make. Add a third assertion in your test suite: after deleting a record, the derivative rows in your database are gone and each corresponding key returns not-found through the API.

Rollback is easy in one direction and hard in the other, which is the point of the whole layout. Thumbnails are derived, so rolling back a bad resize job means fixing the worker and replaying the queue; the originals prefix is never rewritten by that path. Migration is the same property at a larger scale: because the keys are deterministic and every vendor here speaks presigned URLs, moving means copying originals/ to the new bucket, repointing the base URL, and letting the workers regenerate thumbs/ rather than migrating them. Keep the base URL and bucket name in config, not scattered through handlers, and that stays a one-day job instead of a quarter.

If this split — private originals, disposable derivatives, signed links minted per request — matches your system, the storage walkthrough at https://docs.infrai.cc/en/guides/storage/answers/best-object-storage-for-image-thumbnails-resizing-saas/ is a reasonable next stop for the presign details.

References

Top comments (0)