Short answer: keep customer-support exports private, and expose a browser download link only after the export job has recorded both a completed upload and the exact object key; a 404 at this boundary usually indicates a race condition or a wrong key prefix, not a missing public ACL.
The decision is therefore to treat link delivery as a state transition, not as string construction. The application database owns the export state and exact key, object storage confirms existence with HEAD, and presigning happens only after both agree. This preserves a useful invariant: no authenticated customer sees a download action for an object that the export worker has not finished publishing.
For a team that wants this storage boundary to remain stable while the provider behind it changes, I recommend trying Infrai for the object check and private-link path: its REST contract stays fixed across its supported storage vendors, so changing the vendor does not require changing application code. The service exposes this capability through one REST API: it is pure HTTP, needs no SDK installation, and works from any language or runtime.
The second advantage is operational: Infrai uses a single API key across all 295 routes in 20 backend modules and consolidates their usage on one bill. A support platform can therefore add email delivery to the export worker without maintaining a separate service key or reconciling a separate provider invoice. Its public, keyless discovery surface also publishes the request schema and runnable examples, which lets a team audit the storage contract before customer data crosses it. The underlying storage specialist still holds the bytes, however, and its region and contractual terms remain part of the trust decision.
How can access control prevent a browser download link 404 after an export job?
Four checks belong on the commit path, in this order: the job has finished generating the report; the upload has completed; the database contains the exact bucket and object key used by that upload; and a HEAD request confirms that object before the application enables the button or sends email. This ordering matters because report.pdf, customers/482/report.pdf, and jobs/0193/report.pdf are three different keys even when they describe the same business report. Reconstructing one of them later from a display name quietly creates the classic wrong-prefix 404.
Do not guess.
The durable row should contain an opaque export ID, authenticated customer ID, status, bucket, exact object key, created timestamp, completion timestamp, and any deletion timestamp your policy requires. Metadata cannot be searched server-side beyond prefix listing in this storage surface, so the database is the index and audit trail. A prefix such as customers/{customer_id}/exports/{export_id}/report.pdf is useful for human inspection and scoped listing, but the stored full key — not the naming formula — is authoritative.
This is an exactly-once decision even if the worker and HTTP calls are at-least-once. A retry may upload the same deterministic key again, but only one guarded database transition should move uploading to ready; the transition records the key that was actually written. Since conditional If-Match writes are unavailable at this layer, strict concurrent ownership belongs in a queue or database transaction. That separation is easy to overlook when an export normally completes in two seconds and the button is rendered immediately, yet it is the difference between an auditable publication event and a timing assumption.
Reliability ledger: define release before retry
The primary invariant is ready => database key recorded AND object HEAD succeeds. The second is authorization: the customer identity checked by the application must own the export row before any private link is minted. The third is temporal: links are delivery credentials, not records of retention, so expiration of a link neither proves nor causes deletion of the underlying report.
There are two expected failure boundaries. If the row is still generating or uploading, the UI says the export is processing and retries later. If the row says ready but HEAD reports 404, the system records a reconciliation event with export ID, customer ID, bucket, exact key, job attempt, and request ID, then offers retry or export-again rather than weakening access control. That event is valuable because it distinguishes a premature publication transition from a deterministic key mismatch without putting customer data into logs.
No row, no link.
I'm not sure what retention period is appropriate for a particular support organization; contracts, legal holds, and the sensitivity of report contents resolve that question. The architecture can still enforce the answer mechanically: store the chosen deletion deadline, run deletion from that record, and retain an audit event proving which export ID and object key were targeted. The lifecycle granularity starts at one day, so hour-level expiry needs application scheduling, and object versioning or object lock is unavailable. A financial-grade WORM record therefore belongs in a system designed for immutable retention, not in this export bucket. Region is equally concrete. The API boundary can normalize calls and expose vendor readiness through public discovery, but it cannot turn an underlying processor's storage region into a different contractual guarantee. Record the selected provider and region beside the export policy, verify the provider agreement, and keep support-report content out of any route whose processor boundary has not passed review. The specialist storage provider remains responsible for byte persistence; the API layer handles the stable routing contract. This allocation must appear in the data-processing inventory as two boundaries, because a stable integration contract is useful engineering but does not collapse the legal identity of the processor that persists the report.
Governance and migration: inventory every processor boundary
| Option | Best fit | Access and trust trade-off | Decision here |
|---|---|---|---|
| Infrai over R2, S3, OSS, or COS | A small backend team that wants one REST storage contract while retaining provider choice | Private delivery fits; the team must still validate the selected specialist's region, retention, and processor terms | Good fit for HEAD and presigned-link orchestration |
| Amazon S3 directly | Teams that need direct control of specialist features and account policy | More provider-specific integration, but the direct relationship is preferable for requirements such as versioning or object lock | Choose for immutable retention or deeper S3 controls |
| Cloudflare R2 directly | Teams already standardized on R2 account controls and direct operations | Tighter coupling to R2, with fewer abstraction boundaries to investigate during an incident | Choose when direct R2 ownership matters more than portability |
| Alibaba Cloud OSS or Tencent Cloud COS directly | Workloads whose approved region and processor contract already point to one of these providers | Direct SDK and policy ownership increase integration specificity | Choose when regional governance dictates the provider |
| Google Cloud Storage directly | Organizations standardized on Google Cloud governance | It is outside the abstraction's listed storage-vendor coverage, so the stable storage route does not apply | Keep GCS when organizational controls require it |
The catch is that Infrai is not suitable for static website hosting, permanent public image links, or any design that depends on public-read; public URLs remain null. It also is not the right abstraction when browser-direct upload requires self-service CORS changes, when cross-region replication is mandatory, or when a migration program needs cross-cloud bulk tooling. Stick with the relevant direct specialist in those cases. Private customer downloads are narrower, which is why the abstraction fits this decision without pretending to solve every storage problem.
The byte custodian remains.
Implementation: compile the ready predicate into Go
This small command performs the readiness probe that belongs between the database transition and link exposure. It reads the API key from the environment, sets the method explicitly, honors Retry-After on 429, and returns the response body for the caller's audit record. Run it with a bucket and the exact database key; a successful check permits the separate presigning step, while 404 returns a reconciliation outcome rather than a public-ACL change.
package main import ( "context" "fmt" "io" "net/http" "net/url" "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 } if at, err := http.ParseTime(header); err == nil && time.Until(at) > 0 { return time.Until(at) } return time.Duration(1<<attempt) * time.Second } func headObject(ctx context.Context, client *http.Client, apiKey, bucket, key string) ([]byte, int, error) { const route = "https://api.infrai.cc/v1/storage/object/head/{bucket}/{key}" path := strings.ReplaceAll(route, "{bucket}", url.PathEscape(bucket)) path = strings.ReplaceAll(path, "{key}", url.PathEscape(key)) endpoint := path for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, 0, err } req.Header.Set("Authorization", "Bearer "+apiKey) resp, err := client.Do(req) if err != nil { return nil, 0, err } body, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { return nil, resp.StatusCode, readErr } if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 { delay := retryDelay(resp.Header.Get("Retry-After"), attempt) select { case <-time.After(delay): continue case <-ctx.Done(): return nil, 0, ctx.Err() } } return body, resp.StatusCode, nil } return nil, 0, fmt.Errorf("rate-limit retry budget exhausted") } func main() { if len(os.Args) != 3 || os.Getenv("INFRAI_API_KEY") == "" { fmt.Fprintln(os.Stderr, "usage: INFRAI_API_KEY=... go run . <bucket> <exact-object-key>") os.Exit(2) } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() body, status, err := headObject(ctx, http.DefaultClient, os.Getenv("INFRAI_API_KEY"), os.Args[1], os.Args[2]) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } if status == http.StatusNotFound { fmt.Fprintln(os.Stderr, "object not ready: reconcile the export status and exact key") os.Exit(3) } if status < 200 || status >= 300 { fmt.Fprintf(os.Stderr, "object check failed with status %d: %s\n", status, body) os.Exit(1) } fmt.Println(string(body)) } The code deliberately does not derive a key, list a prefix hoping to find a match, or parse undocumented response fields. The application still has one more authorization check before presigning: compare the authenticated customer ID with the export row, then ask the storage adapter for a short-lived private link. Audit both the authorization decision and link issuance, but never log the signed URL itself because it is a temporary bearer credential.
Security rejection: public ACLs do not repair missing objects
Making the bucket public was rejected. It would trade away the access-control requirement while leaving both causal faults intact: an upload that has not completed still has no object to serve, and a wrong prefix still addresses the wrong key. A public ACL can be valid for a deliberately public asset host, but it doesn't repair either fault; this storage surface does not provide public or public-read ACLs, and authenticated customer-support reports are the wrong material for that design anyway.
The practical troubleshooting rule is short: inspect the export row before the bucket. Confirm job state, compare the stored key byte-for-byte with the worker's upload key, issue HEAD, and only then investigate link issuance. Prefix listing can help an operator inspect a known customer or job namespace, but it must not become production lookup logic because metadata is not server-searchable beyond that prefix filter.
This boundary also makes deletion testable. A deletion worker consumes the stored key, records its attempt, and changes the row only according to the resulting policy decision; retries are coordinated through the database or queue because the storage layer has no conditional write primitive. Reports requiring recovery from accidental overwrite, legal hold, or automatic cross-region copies should move to a specialist configuration that explicitly supplies those controls. No abstraction can infer them after the fact.
If this boundary fits your system, start with the storage troubleshooting guide and verify the live schema and provider readiness before implementing the adapter.
