Provider gaps

A register of per-adapter runtime quirks that don't fit the capability matrix — signed-URL caps, ignored expiries, copy costs, and Content-Disposition support across every provider.

files.capabilities answers the clean yes/no questions — can this adapter range-read, sign a URL, copy server-side. This page is the register for everything that doesn't reduce to a flag: ceilings, silently-ignored options, and cost surprises that only show up against the real provider. It pairs with the capability matrix — check capabilities to branch in code, read this to understand the edges.

Signed URLs and expiry

capabilities.signedUrl.supported tells you whether url() can mint a signed or tokenized URL at all. The wrinkles below are about what happens to expiresIn once it can.

expiresIn is silently ignored

Some adapters return a working URL but don't honor the requested lifetime — the URL is either permanent or has a provider-fixed TTL.

  • Vercel Blob — public blobs return the permanent CDN URL; expiresIn is ignored (there is no signing primitive). Private blobs have no public URL at all, so url() throws — use download().
  • Boxurl() returns a tokenized download URL, but Box controls its TTL server-side. expiresIn is accepted for API symmetry and ignored.
  • PocketBase — file-token URLs have a server-controlled TTL (short, ~minutes). expiresIn is ignored.
  • Convex — serving URLs do not expire while the file exists. expiresIn is ignored.

expiresIn has a hard ceiling

  • Dropbox — temporary links cap at 4 hours (14400s), enforced in code: url() throws above that. Use publicByDefault: true for a permanent shared link instead. This ceiling is surfaced as capabilities.signedUrl.maxExpiresIn.
  • Azure — user-delegation SAS (AAD credentials) cannot exceed 7 days from startsOn; the adapter clamps the delegation key to that window. Account-key SAS has no such limit, so the cap is config-dependent and is not surfaced as maxExpiresIn — it would be wrong in shared-key mode.
  • S3 and S3-compatible providers — SigV4 presigned URLs cap at 604800 seconds (7 days). This is an AWS infra limit rejected at request time, not enforced by the SDK, so it's not surfaced as maxExpiresIn. S3-compatible providers (MinIO, Wasabi, and the other s3() wrappers) may enforce a different limit or none.

url() requires explicit public-mode configuration

These adapters have no signing primitive; url() throws unless you opt into a permanent public link at construction:

  • OneDrive / SharePoint — require publicByDefault: true; Graph has no signed-URL primitive. The result is an anonymous (permanent) link.
  • Google Drive — requires publicByDefault: true; returns a permanent uc?export=download link.
  • Appwrite — requires { public: true }; returns a permanent view URL. Appwrite SDKs can't mint signed read URLs with API keys.
  • Bunny Storage — requires publicBaseUrl (a Pull Zone / CDN host); the Storage API URL needs an AccessKey header and can't be handed out.
  • FTP / SFTP — require publicBaseUrl pointing at an HTTP server fronting the same tree; the protocols serve no HTTP.
  • R2 (Workers binding) — requires either publicBaseUrl or HTTP credentials (hybrid mode) for presigned URLs; a bare binding can't sign.
  • Netlify Blobs — has no public-URL primitive at all; url() always throws. Use download().

Copy semantics

capabilities.serverSideCopy is true when copy() is a provider-side operation with no body re-transfer. When it's false, copy() still works but routes the bytes through your process — which matters for large objects and serverless memory limits.

  • UploadThingcopy() buffers the whole object in memory (the upload API needs a Blob). A multi-GB copy will exhaust serverless memory; do it at the application layer instead.
  • SFTPcopy() buffers the whole object over a single connection. FTP streams instead but still round-trips the bytes through the client.
  • Bun S3 — Bun's S3 client has no CopyObject helper, so copy() streams source→dest through the process rather than issuing a server-side copy.
  • R2 (Workers binding) — bindings have no server-side copy; copy() streams getput. Source and destination are not atomic.
  • Bunny Storage / Netlify Blobs — no native copy; copy() reads the source and re-writes the body at the destination. Not atomic.
  • PocketBase / Appwritecopy() downloads the source and creates a new record/file. Not atomic.
  • Cloudinary — Cloudinary has no native copy; copy() re-uploads by URL, which produces a new asset with a new asset_id and etag — not a byte-identical reference. The source must be fetchable by Cloudinary.
  • Convexcopy() is unsupported and throws: storage ids are immutable and can't be assigned to a caller-chosen key. Download the source and upload it back, tracking the new id.

responseContentDisposition

The url() option that forces a download (and prevents stored-XSS on user-uploaded HTML/SVG) requires a signature to bind into. Adapters that can sign support it — S3 and the S3-compatible wrappers, Azure, GCS, Firebase Storage, Supabase, and R2 in HTTP / hybrid mode. The rest throw rather than silently dropping the security ask: Vercel Blob, Dropbox, Box, OneDrive / SharePoint, Google Drive, UploadThing, Cloudinary, Convex, Bunny Storage, PocketBase, FTP, and SFTP. For buckets with untrusted content, pick a provider that can sign.

Uncommitted uploads

  • Azure — uncommitted blocks (from an interrupted multipart / staged-block upload) are garbage-collected by Azure after ~7 days. A resume past that window starts fresh rather than continuing.

On this page