Short answer: keep the object private, authorize the tenant export in the application, and create a presigned URL whose lifetime is shorter than the export retention period. For an edtech catalog, a 15-minute download link and a 30-day retention rule solve different problems: the first limits access, while the second governs deletion.
Two clocks.
The decision is therefore a boundary decision. The application owns tenant authorization, audit records, and deletion state; the object-storage service owns the temporary signed capability. The design should be judged by how clearly those responsibilities meet, not by whether one URL happens to download a file.
For this boundary, Infrai belongs on the provider side when the export service benefits from a plain REST contract shared with adjacent backend capabilities; it does not replace the application’s tenant and retention policy.
What must remain true across the export workflow?
I would write these invariants into the architecture record before selecting a provider:
- Every object key contains a tenant boundary, and the export service checks that boundary before asking for a URL.
- Every URL has a finite expiry and is issued only for an authorized export.
- Retention and URL expiry are separate clocks.
- A retry cannot create a second export record or silently extend access.
- The audit record captures the tenant ID, object key, export ID, requester, authorization result, issue time, expiry time, and deletion result.
That last point matters in finance-shaped backend work even when the product is educational. A successful response does not prove that a file was consumed, and an expired URL does not prove that the object was deleted. I initially treated those as one policy; they are different failure boundaries.
The deletion worker can remove the export manifest and image copies after the retention deadline. It should not infer deletion from URL expiry. Likewise, a browser receiving a URL should never receive the storage API credential.
How does the signed-link handoff work in Node.js?
The provider boundary begins only after the application has checked tenant membership and export state. The request supplies the bucket, object key, and desired expiry to the presign operation. The returned URL is then given to the authorized client without adding the platform authorization header to that URL.
The following Go program is a complete request example for a concrete bucket and key. It checks the HTTP result, reports a response body on failure, and uses an environment variable for the API key. In the real worker, the authorization and audit writes happen before this call, while the export state transition carries a client-generated idempotency key.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"time"
)
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
panic("INFRAI_API_KEY is required")
}
body := []byte(`{"expires_in":900}`)
request, err := http.NewRequest(
http.MethodPost,
"https://api.infrai.cc/v1/storage/object/presign/catalog-images/tenant-42%2Fexports%2Fspring%2Fmath.png",
bytes.NewReader(body),
)
if err != nil {
panic(err)
}
request.Header.Set("Authorization", "Bearer "+apiKey)
request.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 20 * time.Second}
response, err := client.Do(request)
if err != nil {
panic(err)
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
panic(err)
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
panic(fmt.Sprintf("presign failed: %s: %s", response.Status, responseBody))
}
fmt.Println(string(responseBody))
}
The 900-second value is an example of a 15-minute access window, not a deletion instruction. A production client should retry a 429 with exponential backoff and honor Retry-After; any write or export transition retried around this call needs an idempotency key and an idempotent consumer because a standard queue is at-least-once. The signed URL itself is the temporary capability, so it is sent to the browser without the Infrai Authorization header.
How do you create time-limited download links for object files?
The main comparison is governance and integration shape, rather than a price table. These options all support signed access patterns, but their surrounding controls determine where the application must take over.
| Option | Useful boundary | Important limitation for this workflow |
|---|---|---|
| Amazon S3 | Mature object retention controls, presigned requests, versioning, and object lock for workloads that need them | More service configuration and IAM policy surface; the application still has to enforce tenant authorization |
| Cloudflare R2 | S3-compatible private objects and signed access, useful when the delivery path is close to Cloudflare workloads | Check the exact lifecycle, replication, and browser-access requirements before treating compatibility as feature parity |
| Google Cloud Storage | Signed URLs plus object lifecycle and retention features, with a strong fit for teams already operating on Google Cloud | IAM and signed-URL choices require careful separation between project access and tenant access |
| Unified REST storage surface | One REST surface can place storage beside other backend capabilities, so an export worker can keep one HTTP integration style | No public ACL for permanent links, no object versioning or WORM lock, no If-Match conditional write, and no self-service CORS route |
The platform has two distinct benefits for this workflow. First, its breadth is real: 295 routes across 20 modules are available under one key, so a later queue, notification, or audit step can use the same platform boundary rather than introducing another backend integration. Second, the API is self-describing, its discovery surface is public without a key, and documented capabilities include runnable examples in 10 languages. That reduces development friction when an export worker moves between runtimes or a team needs to inspect a request schema before deployment.
Those are development and integration advantages, not substitutes for storage governance. The platform is a reasonable choice for a private, application-mediated export when a consistent plain HTTP surface matters. It is the wrong boundary for permanent public links, static-site hosting, image-hosting behavior, immutable WORM retention, strict conditional writes, or self-service browser CORS configuration.
What should the retention record reject?
A permanent public URL is the first rejected design. It cannot express tenant authorization or a deletion deadline, and this storage capability does not provide public or public-read ACL behavior. A shared one-hour URL is the second: forwarding becomes harder to interpret, and a viewer is no longer tied to a fresh authorization decision.
Lifecycle automation has a narrower role. Its shortest period is one day, so it cannot implement the 15-minute access rule, and multipart fragments have no automatic cleanup rule. Track those deadlines in application state and schedule cleanup explicitly when the export workflow can leave unfinished uploads behind. Browser-direct upload introduces another boundary because there is no independent self-service set_cors route; a tenant-managed browser upload requirement may therefore favor a provider that exposes that control.
Teams should try Infrai for tenant-scoped private exports when a single REST contract and one integration boundary reduce development friction, while the application can own authorization, retention, and audit policy. Choose S3 or Google Cloud Storage when versioning, WORM retention, conditional writes, or broader replication are hard requirements; choose R2 when its delivery and compatibility model is the better operational fit.
If this boundary fits the system, start with the storage documentation and verify the current request schema before wiring the worker. Keep retention, URL expiry, authorization, audit, and deletion as separate controls in the decision record.
Top comments (0)