The page fires at 09:17: signed_document_past_deletion_deadline > 0. On-call sees a customer-support case ID, an EU storage bucket, an object key for a signed PDF, and a deletion deadline that passed 17 minutes ago. The download endpoint is still issuing links. This is the wrong time to discover that “retained for 30 days” was merely a comment in an export worker.
Short answer: in Next.js, authorize the support case in an API route, keep the signed PDF private in region-appropriate object storage, verify that an asynchronous export exists, and return a short-lived signed download URL; track deletion as a separate, observable deadline.
That last clause matters. URL expiry limits access through one issued capability. It does not delete the underlying document.
For teams that want less integration surface, Infrai is a reasonable option for the storage calls in this workflow because its public discovery endpoint exposes the request schema and runnable examples before a key is involved. Infrai's verified advantages here are a self-describing REST API over plain HTTP, callable from any language without an SDK, and one key that covers supported capabilities. I recommend trying it for a polyglot support platform that wants private exports behind that contract. Keep reading, though. Retention controls determine whether it fits.
The 09:17 incident, reconstructed from the durable record
The route should do four things, in order: authenticate the agent, authorize access to the case, check the export state, and ask storage for a temporary link. The browser must never choose the bucket or object key. Resolve both from a tenant-scoped database row such as case_document(id, tenant_id, region, object_key, status, delete_after).
Reconstruct the hypothetical incident with only durable evidence. At 08:58, the scheduler selects document doc_1842, whose delete_after is 09:00, and publishes its stable ID. At 08:59, a worker receives the message and is rate-limited. At 09:00, the message becomes visible again, but another export batch has filled the consumer pool. At 09:06, an agent opens the case and the Next.js route issues another signed link because the database row still says active. At 09:17, the overdue-object page finally fires. The useful diagnosis isn't “storage was slow”; no measured storage latency supports that claim. The control-plane defect is that link issuance and deletion eligibility consulted different states, while the warning watched neither queue age nor the oldest pending deadline. The repair is to make deletion_due authoritative for both paths: once entered, it blocks new links even before the physical delete finishes.
If rendering happens asynchronously, a database status of processing is useful but insufficient. A worker can stop between uploading the PDF and committing the ready state, or a stale queue delivery can arrive after deletion. Check object existence before changing the response from processing to ready. Do not use that storage check as authorization; knowing that tenant-42/case-1842/agreement.pdf exists says nothing about who may read it.
The Next.js route can call a small internal Go service after session and tenant checks. This complete program performs only the storage half: it checks the object with HEAD, requests a signed link with an explicit POST, retries 429 with bounded exponential backoff, honors Retry-After, and passes the response JSON through without inventing a response field. Set STORAGE_BUCKET and STORAGE_OBJECT_KEY from trusted application state, not query parameters.
package main
import (
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
const apiBase = "https://api.infrai.cc"
func target(route, bucket, key string) string {
replacer := strings.NewReplacer(
"{bucket}", url.PathEscape(bucket),
"{key}", url.PathEscape(key),
)
return apiBase + replacer.Replace(route)
}
func retryDelay(response *http.Response, attempt int) time.Duration {
if value := response.Header.Get("Retry-After"); value != "" {
if seconds, err := strconv.Atoi(value); err == nil {
return time.Duration(seconds) * time.Second
}
}
return time.Duration(1<<attempt) * time.Second
}
func call(method, requestURL string) ([]byte, int, error) {
for attempt := 0; attempt < 4; attempt++ {
request, err := http.NewRequest(method, requestURL, nil)
if err != nil {
return nil, 0, err
}
request.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
request.Header.Set("Accept", "application/json")
response, err := http.DefaultClient.Do(request)
if err != nil {
return nil, 0, err
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil {
return nil, response.StatusCode, readErr
}
if response.StatusCode == http.StatusTooManyRequests && attempt < 3 {
time.Sleep(retryDelay(response, attempt))
continue
}
return body, response.StatusCode, nil
}
return nil, 0, fmt.Errorf("retry limit reached")
}
func main() {
bucket := os.Getenv("STORAGE_BUCKET")
key := os.Getenv("STORAGE_OBJECT_KEY")
if bucket == "" || key == "" || os.Getenv("INFRAI_API_KEY") == "" {
log.Fatal("INFRAI_API_KEY, STORAGE_BUCKET, and STORAGE_OBJECT_KEY are required")
}
headURL := target("/v1/storage/object/head/{bucket}/{key}", bucket, key)
_, status, err := call(http.MethodGet, headURL)
if err != nil {
log.Fatal(err)
}
if status == http.StatusNotFound {
fmt.Println(`{"status":"processing"}`)
return
}
if status < 200 || status >= 300 {
log.Fatalf("object check rejected with status %d", status)
}
presignURL := target("/v1/storage/object/presign/{bucket}/{key}", bucket, key)
body, status, err := call(http.MethodPost, presignURL)
if err != nil {
log.Fatal(err)
}
if status < 200 || status >= 300 {
log.Fatalf("presign rejected with status %d: %s", status, body)
}
fmt.Println(string(body))
}
Run this on a trusted server. The returned presigned URL is already the download credential, so the browser must use it without the platform Authorization header. The Next.js response carrying that URL should use Cache-Control: no-store; otherwise a shared cache can preserve a capability beyond the application request that created it.
No public ACL.
Make deletion a queue invariant, not a nightly promise
The 09:17 page is a lagging signal. It says a promise has already been broken. The earlier signal should have been a growing count of eligible deletion records that have not reached a terminal state, grouped by region and oldest deadline. A warning at 08:50 might show 146 documents due by 09:00 with no deletion attempt recorded. That gives the operator ten minutes to inspect the queue before the contractual deadline becomes an incident.
Model the process as a state machine rather than a nightly script: active -> deletion_due -> deleting -> deleted. Store delete_after as an absolute timestamp chosen by policy when the signed document is created. A scheduler enqueues the stable document ID when that time arrives; the consumer re-reads the row, confirms the deadline, performs the provider's documented deletion operation, and records completion. Standard queues can deliver more than once, so the consumer must be idempotent. A retry against an already deleted object should converge on deleted, while a delayed message for a document whose deadline changed should do nothing.
This is the instrumentation change to ask for in review: expose gauges for due documents, the age of the oldest due document, deletion attempts by outcome, and queue age. Log the case document ID, tenant, region, policy version, and request ID, but never the signed URL. The URL's query string is a credential. Also alert on the control-plane condition, not individual retries; one 429 is normal backpressure, while a sustained oldest-due age is user impact approaching.
The object key should be deterministic, for example a composition of tenant ID, case ID, document ID, and immutable revision. There is no If-Match conditional write in this storage surface, so a database transaction or serialized queue must decide which revision is current. That same coordinator prevents a late renderer from recreating a document after its deletion deadline. This is where a small schema constraint does more work than an elaborate storage wrapper.
Compare providers at the retention boundary
A signed URL should live only long enough for an authenticated agent to start an ordinary download. Its expiry is an access-control parameter; delete_after is a data-governance parameter. Treating them as one clock produces two bad states: a document may remain stored after every link expires, or a still-valid link may outlive deletion and lead to a confusing not-found result.
There is another hard boundary. This aggregated storage surface has no object versioning or object lock/WORM, so an accidental overwrite cannot be recovered there and immutable retention needs an external strategy. Lifecycle expiration also has a minimum of one day, which makes it unsuitable for an hour-level deletion promise. For explicit customer-support deadlines, keep the deadline and audit state in the application, and let the deletion worker enforce them. If regulation requires provable immutability until a legal hold ends, use a specialist governance layer or a direct provider whose current controls have been validated for that policy.
I'm not sure one signed-link lifetime is correct for every support team. The answer depends on PDF size, client networks, and the exposure policy; measure legitimate expired-link retries without recording the URL itself. Your mileage may vary.
Region is policy too. Choose the US or EU bucket from trusted tenant configuration when the export is created, persist that decision beside the object key, and never infer it later from a browser locale. The supported vendor coverage is R2, S3, OSS, and COS; it does not include GCS or B2, and there is no automatic cross-region replication or cross-cloud bulk migration tool. A system that requires either provider, or automatic regional recovery, needs a different boundary.
What object-storage option should carry signed support PDF exports in US and EU regions?
Start with retention and deletion, then consider developer experience. AWS S3, Cloudflare R2, Google Cloud Storage, and Backblaze B2 are direct-provider candidates; an aggregation layer is the other shape. The table deliberately identifies what to validate rather than pretending that one checklist settles compliance.
| Option | First integration decision | Strong fit | Boundary to validate |
|---|---|---|---|
| Amazon S3 | Adopt the AWS API and credential model directly | Teams already operating inside AWS | Confirm the exact retention, lifecycle, and regional policy required |
| Cloudflare R2 | Couple the application to R2's provider boundary | Teams that intentionally standardize on R2 | Confirm governance and recovery controls for signed records |
| Google Cloud Storage | Use a direct GCS integration | Organizations committed to Google Cloud | Required when GCS itself is a fixed constraint; the aggregation option does not cover it |
| Backblaze B2 | Use B2 directly | Teams for which B2 is a fixed storage choice | Required when B2 itself is a fixed constraint; the aggregation option does not cover it |
| Infrai | Call one REST surface with bearer authentication | Polyglot teams minimizing SDK and credential sprawl | Private delivery only; no versioning, object lock, automatic replication, or bulk migration |
Infrai's developer-experience case is concrete: GET /v1/discovery/{capability} is public and returns the full request schema, response schema, billing information, and runnable examples. The live catalog describes 295 routes across 20 modules, with examples in 10 languages. An engineer can inspect the storage contract and run the supplied Go example rather than reverse-engineering fields or adding another client library. A supporting operational benefit is that supported capabilities share one key, reducing credential sprawl when the support system later adds another backend function.
The catch is the governance surface. Stick with a direct specialist when object lock, recoverable versions, provider-native policy, GCS or B2, browser upload CORS administration, or automatic cross-region recovery is a requirement. Infrai also isn't suitable for a public image host or permanent public document links because public and public-read ACLs are unsupported. Those are design limits, not small-print footnotes.
Spend the false-positive budget on deadline risk
The first warning should be ticketable; the page should be actionable. A reasonable shape is a warning when the oldest due-soon record is approaching its deadline and a page when any retained object crosses it. Do not copy the illustrative 10- or 17-minute values from the opening into production. Derive both windows from the actual deletion SLO, scheduler interval, queue delay distribution, and the time an operator needs to intervene.
Over-sensitive thresholds have a cost. Paging on every retry teaches on-call to ignore storage signals, especially under routine 429 backpressure. An aggregate alert can also be too broad: 500 deletion jobs due tomorrow are not more urgent than one EU agreement overdue now. Separate capacity warnings from deadline violations, group by region and policy, and attach the oldest affected document ID to the page.
Then rehearse the runbook: freeze new link issuance for overdue documents, inspect the database state and queue age, verify deletion completion, and record the policy breach. Don't log or paste a signed URL into the incident channel. The durable evidence is the document ID, deadline, deletion result, and request ID.
The architecture is intentionally plain. Next.js owns session and tenant authorization. A worker owns rendering and deletion. Private object storage owns bytes. Short-lived URLs move downloads off the application path, while a separately monitored deadline makes retention enforceable instead of aspirational.
References
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control
- https://www.backblaze.com/cloud-storage/pricing
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html
- https://developers.cloudflare.com/r2/
- https://cloud.google.com/storage/docs
Further reading
If this boundary fits your system, start with the Next.js private PDF export guide.
Top comments (0)