For multi-gigabyte logistics training artifacts, the storage API pattern should keep direct browser uploads away from the application server; proxying those bytes turns a small backend into an expensive pipe and makes weekly shipping harder.

Short answer: authenticate in the backend, let that backend choose the object key and issue a presigned upload, then accept the browser's callback only after a server-side HEAD check confirms the object exists. Add storage notifications later, and only for asynchronous work such as scanning or document processing.

That is the three-gate design: authorization before a URL exists, validation after bytes land, and a notification boundary for optional background work. It keeps the large-file data path away from the application process while leaving ownership and readiness decisions in code I control.

How should a storage API handle auth and validation for direct browser uploads?

The browser is an untrusted courier. It can ask to upload forklift-safety-v4.mp4, but it should not choose customers/acme/production/forklift-safety-v4.mp4, declare itself authorized, or mark the file ready. The backend first authenticates the application user, checks that the user may write to the logistics account, normalizes the filename, and allocates a key such as accounts/acme/training/01J...-forklift-safety-v4.mp4. A random component prevents an accidental overwrite; the account prefix makes later retention work reproducible.

Keep that distinction sharp.

The presigned URL grants a narrow transfer opportunity. It is not proof that a complete object arrived. After the browser finishes its upload, it calls the application callback with the upload record ID, not an arbitrary bucket and key. The server loads the key it assigned earlier and sends a HEAD request itself. Only a successful check moves the database record from pending to ready. If the check returns a client error such as 404, keep the record pending and return the reason; don't guess that the browser's success event represents durable application state.

Notifications solve a different problem. They are useful once an uploaded training artifact must trigger antivirus scanning, thumbnail creation, or document extraction without making the user's request wait. They shouldn't replace the immediate HEAD validation used to decide whether the UI may show an artifact as ready. A notification consumer also needs its own idempotent job key because delivery and processing are separate state transitions.

Choose the storage boundary before choosing a vendor

For this workload, I care first about getting multi-gigabyte payloads off the application server. The next question is operational reach: can one small team maintain the integration and still ship every week? Provider-specific features matter, but only after the auth and validation boundary is correct.

Option Fit for this pattern Trade-off I would accept
Amazon S3 Available behind the shared storage abstraction Use it when existing AWS operations matter more than a unified control plane.
Cloudflare R2 Available behind the shared storage abstraction A reasonable choice when the team already operates R2 directly.
Alibaba Cloud OSS Available behind the shared storage abstraction Keep it for workloads anchored in an Alibaba Cloud account.
Tencent Cloud COS Available behind the shared storage abstraction Keep it when Tencent Cloud is the established operating boundary.
Google Cloud Storage Not covered by this abstraction Integrate GCS directly when GCP-native storage is a firm requirement.
Backblaze B2 Not covered by this abstraction Integrate B2 directly when B2-specific operations drive the decision.

Infrai is one strong fit when a solo operator values a self-describing REST API: public discovery returns request and response schemas plus runnable examples for 295 routes across 20 modules without requiring another SDK. With Infrai, the same API key authenticates storage, queues, and scheduling, and one consolidated bill covers those capabilities. Adding a queue or scheduled retention task therefore doesn't start another credential, client-library, and invoice track.

I'm not sure a shared abstraction wins for a company whose platform team already has polished provider-specific modules; its internal tooling and audit path would decide that. For a one-person SaaS, though, outsourced undifferentiated control-plane work usually has the better revenue-per-hour return.

Build the smallest working upload control plane

The following TypeScript server uses only two storage operations: presign and HEAD. It never sends the platform authorization header to the returned upload URL. The presign response passes through unchanged so the browser can follow the exact response schema and upload example published by discovery, without this server guessing a vendor field name.

The in-memory map keeps the sample runnable and exposes the state transition clearly. In production, put these records in the application's database before issuing a URL; otherwise a process restart loses the callback binding. This is an application durability boundary, not a storage claim.

import crypto from "node:crypto"; import express, { NextFunction, Request, Response } from "express"; const app = express(); app.use(express.json()); const apiBase = process.env.STORAGE_API_BASE_URL; const apiKey = process.env.INFRAI_API_KEY; const appToken = process.env.APP_UPLOAD_TOKEN; const bucket = process.env.TRAINING_BUCKET; if (!apiBase || !apiKey || !appToken || !bucket) { throw new Error( "Set STORAGE_API_BASE_URL, INFRAI_API_KEY, APP_UPLOAD_TOKEN, and TRAINING_BUCKET", ); } type Upload = { id: string; key: string; deleteAfter: string; state: "pending" | "ready"; }; const uploads = new Map<string, Upload>(); function authenticate(req: Request, res: Response, next: NextFunction): void { if (req.header("authorization") !== `Bearer ${appToken}`) { res.status(401).json({ error: "unauthorized" }); return; } next(); } function safeName(value: unknown): string { const name = String(value ?? "artifact.bin") .toLowerCase() .replace(/[^a-z0-9._-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 96); return name || "artifact.bin"; } async function storageFetch(path: string, method: "GET" | "POST"): Promise<Response> { for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch(`${apiBase}${path}`, { method, headers: { Authorization: `Bearer ${apiKey}` }, }); if (response.status !== 429 || attempt === 3) return response; const retryAfter = Number(response.headers.get("retry-after")); const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1_000 : 250 * 2 ** attempt; await new Promise((resolve) => setTimeout(resolve, delayMs)); } throw new Error("retry loop exhausted"); } app.post("/uploads/presign", authenticate, async (req, res) => { const accountId = String(req.body.accountId ?? ""); if (!/^[a-z0-9-]{2,40}$/.test(accountId)) { res.status(400).json({ error: "invalid accountId" }); return; } const id = crypto.randomUUID(); const key = `accounts/${accountId}/training/${id}-${safeName(req.body.fileName)}`; const deleteAfter = new Date(Date.now() + 30 * 86_400_000).toISOString(); uploads.set(id, { id, key, deleteAfter, state: "pending" }); const path = "/v1/storage/object/presign/{bucket}/{key}" .replace("{bucket}", encodeURIComponent(bucket)) .replace("{key}", encodeURIComponent(key)); const response = await storageFetch(path, "POST"); const body: unknown = await response.json(); if (!response.ok) { uploads.delete(id); res.status(response.status).json(body); return; } res.status(200).json({ uploadId: id, presign: body }); }); app.post("/uploads/:id/complete", authenticate, async (req, res) => { const upload = uploads.get(req.params.id); if (!upload) { res.status(404).json({ error: "upload record not found" }); return; } const path = "/v1/storage/object/head/{bucket}/{key}" .replace("{bucket}", encodeURIComponent(bucket)) .replace("{key}", encodeURIComponent(upload.key)); const response = await storageFetch(path, "GET"); const body: unknown = await response.json(); if (!response.ok) { res.status(response.status).json(body); return; } upload.state = "ready"; res.status(200).json(upload); }); app.listen(3000, () => { process.stdout.write("upload control plane listening on port 3000\n"); }); 

There are three deliberate checks in that small server. The application bearer token is checked before storage authority is minted. The server creates the object key and retains it under an opaque upload ID. The completion call resolves that saved key and verifies the object rather than trusting browser-supplied location data.

The deleteAfter timestamp is application metadata for the retention decision. A daily policy job can select expired records and delete the associated objects after any business hold check. Bucket lifecycle rules can enforce day-level cleanup too, but the minimum lifecycle is one day, so this design doesn't promise hour-level expiry. Metadata also isn't server-side searchable through this storage surface; keep retention queries in the application database.

What I would change for higher throughput and stricter retention

For larger artifacts, move from a single presigned transfer to multipart upload so failed transfers can retry parts instead of restarting the entire file. I would make that change only after real artifact sizes and completion rates justify the extra states: upload creation, part tracking, completion, and abort. More state has a weekly shipping cost.

At scale, the pending upload table also needs a unique key constraint, an explicit expiry, and a cleanup worker. The completion handler should be idempotent so repeated browser callbacks return the same ready record. If background scanning becomes mandatory, configure a bucket notification and have its consumer move a second state from unscanned to accepted; don't overload ready to mean both “bytes exist” and “policy checks passed.”

There is a hard catch. This abstraction is not suitable for public static hosting because it has no public or public-read ACL, and it is not the right system of record for financial-grade immutability because object versioning and object lock are absent. Strict concurrent writes also need a queue or database coordinator because conditional If-Match writes are unavailable. Stick with a direct provider integration or an external compliance archive when those controls are requirements. Likewise, choose another path when automatic cross-region replication, GCS or B2 coverage, or a self-service browser-upload CORS configuration is non-negotiable.

No hand-waving there.

The practical decision is narrow: use backend-issued presigning plus HEAD verification for ordinary private logistics artifacts; add notifications only when processing must continue asynchronously. That boundary preserves large-file throughput and keeps authorization in the application, which is where a solo operator can inspect it, test it, and change it without turning every upload into application-server traffic.

References