---
changelog:
  category: Release
  version: files-sdk@3.0.0
date: '2026-10-09T07:56:36Z'
seo:
  description: >-
    files-sdk/cache no longer shares entries between instances. Store keys now
    include the instance prefix, and a custom store requires a namespace naming
    the…
title: files-sdk@3.0.0
type: changelog
---
## Major Changes

- 0223b30: `files-sdk/cache` no longer shares entries between instances. Store keys now include the instance `prefix`, and a custom `store` requires a `namespace` naming the bucket it caches (`cache({ store, namespace: "uploads-prod" })`); without one, `cache()` throws `Invalid`. Reusing one `cache()` on an instance with a different adapter or `prefix` also throws `Invalid`, so create one per instance. Before, two tenants sharing a store could read each other's cached bytes, URLs, and metadata.
- 0223b30: The `files-sdk/api` gateway now refuses client keys and `list`/`search` prefixes inside the `versioning()` store (`.versions/`) or the `softDelete()` trash (`.trash/`) with a 403, so a client can't hard-delete trashed objects, wipe version history, or forge versions through the core operations. Use the plugin operations instead. Under an authorize `keyPrefix`, a tenant's own folders with those names stay ordinary keys.
- 0223b30: The Google Drive adapter (`files-sdk/google-drive`) now refuses `upload({ cacheControl })` with `Unsupported` (`capabilities.cacheControl` is `false`): Drive never serves it, and the value was silently dropped. It also declares `signedUpload.contentType: true`, since the session binds the type, and no longer offers resumable uploads when built from a pre-built `client`, where they always threw.
- 0223b30: `signedUploadUrl({ minSize })` without `maxSize` now throws `Unsupported` on `files-sdk/s3`, `files-sdk/s3-fetch`, `files-sdk/r2`, `files-sdk/bun-s3`, and the adapters built on them, instead of returning a presigned PUT that silently accepts empty uploads. On `s3()`, pass `maxSize` too to get a POST policy that enforces both. `minSize: 0` still returns a PUT.
- 40a3694: The array (bulk) forms of `upload`, `download`, `head`, and `delete` now resolve to one shape, `BulkResult<T>` = `{ results: T[]; errors?: BulkError[] }`, instead of four differently named arrays.
  
  - **Field renames:** read `results` where you read `uploaded`, `downloaded`, `files`, or `deleted`. `exists([…])` keeps its `{ existing, missing, errors? }` split.
  - **Type changes:** `UploadManyResult`, `DownloadManyResult`, `HeadManyResult`, and `DeleteManyResult` are now aliases of `BulkResult`. `DeleteManyOptions` is an alias of `BulkOptions`, and `DeleteManyError` is removed in favour of `BulkError` (the same `{ key, error }` shape).
  - **`stopOnError` on a bulk `delete`:** it now always deletes one key at a time and skips the adapter's native batch, because a batch request can't stop partway. So it returns the same result with or without plugins installed, where before the native path and the plugin path could disagree.
  - **Custom adapters:** an `Adapter.deleteMany` returns `{ results, errors? }`.
  - **Gateway, client, CLI, and MCP:** the `files-sdk/api` wire, `files-sdk/client`, the `useFiles` bindings, and the CLI and MCP bulk output use the same shape.
- 58e15bc: Adapters now declare what they can do as one `capabilities` object, and `files.capabilities` gains the fields the old flags couldn't express.
  
  - **Custom adapters:** the separate `supportsRange`, `supportsDelimiter`, `supportsMetadata`, `supportsCacheControl`, `supportsServerSideCopy`, `reportsUploadProgress`, and `signedUrl` fields on `Adapter` are replaced by `capabilities: { rangeRead, delimiter, metadata, cacheControl, serverSideCopy, uploadProgress, signedUrl, signedUpload }`. Every field is optional and defaults to unsupported. `resumable` and `conditional` are still derived from `resumableUpload` and `conditional`. Declare it per instance, so a constructor option that changes what the adapter can do (a `publicBaseUrl`, an access mode, the `fetch` client) is reflected. Rename the old flags: `supportsRange` → `rangeRead`, `supportsDelimiter: true` → `delimiter: "any"` (or `"slash"`), `supportsMetadata` → `metadata`, `supportsCacheControl` → `cacheControl`, `supportsServerSideCopy` → `serverSideCopy`, `reportsUploadProgress` → `uploadProgress`, top-level `signedUrl` → `capabilities.signedUrl`. The old fields are ignored and anything you leave out is off: without `signedUrl.supported`, `url(key, { expiresIn })` throws `Unsupported` before your `url()` runs; without `signedUpload`, the gateway proxies every upload; without `signedUrl.disposition` or `publicUrl`, it proxies downloads.
  - **`files.capabilities`:** `multipart` is renamed `resumable` (it always described `upload({ control })`). `delimiter` is now `"any" | "slash" | false`, since Vercel Blob, Netlify Blobs, Supabase, Dropbox, Box, OneDrive, and SharePoint only accept `"/"`. `signedUrl` gains `expiry: "exact" | "provider" | "none"`: Box, PocketBase, and Dropbox's 4-hour temporary links are `"provider"`, because the provider sets the lifetime. There's a new `signedUpload: { supported, maxSize, contentType, maxExpiresIn? }` describing `signedUploadUrl()`, and plugins that refuse direct uploads (`encryption`, `compression`, `dedup`, `validation`, `contentType`) turn it off.
  - **Gateway (`files-sdk/api`):** presigning uploads now follows `signedUpload` instead of the download `signedUrl` flag. An upload is presigned only when the adapter can bind the gateway's `maxUploadSize` and the file's content type; otherwise it goes through the proxy, which enforces both. UploadThing in its default public-read mode now reports `signedUrl.supported: false` (its `url()` is a permanent link) while still presigning uploads, and Vercel Blob in public mode now gets direct presigned uploads instead of proxied ones.
  - **Checks run before plugins:** an unsupported `range`, `metadata`, `cacheControl`, `control`, or `delimiter` is now refused before any plugin runs, checked against the plugin-narrowed capabilities, so a plugin can no longer do I/O (a version snapshot, a trash move) for a call that's about to be rejected. When a plugin is what turned the option off, the error names it, for example `range downloads are not supported by the "encryption" plugin`.
- 7ca626f: `FilesError` gains two codes, `Invalid` and `Unsupported`, split out of `Provider`, which now only means the backend or transport failed.
  
  - **`Invalid`:** the call itself is wrong. That covers an empty, null-byte, or `..` key, a malformed range, ETag, or condition, contradictory options, an `expiresIn` past a hard cap, missing or bad constructor config, and a file a `validation()` rule rejects (`ValidationError` keeps its `reason`).
  - **`Unsupported`:** the call is well-formed but this adapter, in this mode and with these plugins, can't do it. That covers a `range`, `metadata`, `cacheControl`, `delimiter`, or `control` the adapter has no primitive for, `url()` without a `publicBaseUrl` on FTP/SFTP/WebDAV, a `signedUploadUrl()` `maxSize` or `contentType` the provider can't bind, and the fail-closed refusals of `encryption()`, `compression()`, `dedup()`, `validation()`, and `contentType()`.
  - **`permanent`:** both new codes are always `permanent` and are never retried or failed over. `Provider` is still the only retried code. `versioning()`'s `restoreVersion()` and `softDelete()`'s `restoreTrashed()` now throw `NotFound` when there's nothing to restore.
  - **Code that branches on `error.code === "Provider"`** to catch bad input or unsupported options must also check `Invalid` / `Unsupported`.
  - **Gateway (`files-sdk/api`):** an `Invalid` error is answered with a `422` and the wire code `Validation` (it was a `500`), and an `Unsupported` one with a `422` and the new wire code `Unsupported`. `files-sdk/client` and the `useFiles` bindings turn both back into the matching `FilesError` code, and map any wire code they don't know to `Provider`. When presigning an upload, the gateway falls back to its proxy only for an `Unsupported` or `Invalid` refusal; a backend failure or an abort now surfaces instead of being masked as a proxy target.
  - **CLI:** `files` now exits `5` for a `Provider` failure, so a script can tell "the backend failed, retry" from "the command is wrong". `Invalid`, `Unsupported`, and `ReadOnly` exit `2`, like a usage error. A missing optional `@modelcontextprotocol/sdk` for `files mcp` is reported as `Unsupported`.
- 9765e54: `head()`, `list()`, `listAll()`, and `search()` now return a `FileInfo` (`{ key, size, contentType, etag?, lastModified?, metadata? }`), metadata with no body, instead of a `StoredFile`.
  
  - **No more hidden downloads:** in v2, head and list results carried `text()` / `arrayBuffer()` / `blob()` / `stream()` accessors that quietly issued a full download when called. Reading bytes is now always an explicit `download()`, which still returns a `StoredFile`. A `StoredFile` is now a `FileInfo` plus the `File`-like `name` / `type` aliases and the body accessors.
  - **Renamed fields:** on head and list results, read `contentType` where you read `type`, and `key` where you read `name`.
  - **Upload results:** `UploadResult` is now the same `FileInfo` shape.
  - **Plugin results:** `versioning()`'s `restoreVersion()` and `softDelete()`'s `restoreTrashed()` resolve to a `FileInfo`.
  - **Custom adapters:** `Adapter.head()` and `Adapter.list()` return `FileInfo`. `createStoredFile()` takes a `FileInfo` (with `contentType`, not `type`), and the `StoredFileMeta` type is removed.
  - **Gateway, client, and UI:** the `files-sdk/api` wire, `files-sdk/client`, the `useFiles` bindings, and the shadcn registry components use the same shape: `contentType` instead of `type`. The client's `upload()` result is now a `FileInfo` too, and the registry components' `onSelect` / `file` / `renderPreview` values are `FileInfo`.
  - **AI tools:** the AI tool results (`getFileMetadata`, `listFiles`, `downloadFile`) also carry `contentType`.
  - **CLI and MCP:** `head`, `list`, `search`, and `download` output prints `contentType` instead of `type`, and no `name`.
- 6682759: Remove the `defaultUrlExpiresIn` option from the filesystem adapter (`files-sdk/fs`). It was accepted for backward compatibility and ignored, because `url()` returns a permanent `file://` or `urlBaseUrl` link that can't expire. Delete it from your `fs({ … })` options. The CLI's global `--default-url-expires-in` flag no longer reaches the fs adapter, which never used it.
- 6682759: Require Node.js 22 or later. Node 20 reached end of life in April 2026, and the package's `engines` field now reads `>=22`. Bun and the edge runtimes the gateways target are unaffected. No peer dependency range is narrowed: Nitro 2 / h3 1 stays supported because Nuxt 4 still runs on it.
- 21dd625: `url(key, { expiresIn })` now always means "give me a link that expires", instead of sometimes returning a permanent link that silently ignored the request.
  
  - **Adapters that can sign:** S3 and the S3-compatible adapters, R2, GCS, Firebase, Azure, Supabase, and Bun's S3 now return a signed URL for an explicit `expiresIn` even when a `publicBaseUrl` is configured, the same way `responseContentDisposition` already did. A plain `url(key)` still returns the permanent CDN link.
  - **Adapters that only have permanent links:** Vercel Blob in public mode, UploadThing `public-read`, Convex, Appwrite, the filesystem, and OneDrive / Google Drive in public-link mode now throw an `Unsupported` `FilesError` for an explicit `expiresIn`, before the adapter is called. Before, they returned the permanent link. Leave `expiresIn` out to get that link.
  - **Provider-set lifetimes:** Box, PocketBase, and Dropbox's tokenized links keep a lifetime set by the provider (`signedUrl.expiry: "provider"`). Box and Dropbox in their public-link modes (`publicBaseUrl` / `publicByDefault`) now return that tokenized link for an explicit `expiresIn`, instead of the permanent share link.
  - **Two new capabilities:**
    - `files.capabilities.publicUrl` reports whether a plain `url(key)` returns a permanent link on this instance.
    - `files.capabilities.signedUrl.disposition` reports whether a `responseContentDisposition` is bound into the signed URL.
    - Custom adapters declare both in their `capabilities` (they default to `false`).
  - **Gateway downloads (`files-sdk/api`):** a download now redirects to a signed URL only when the adapter can also bind the forced `Content-Disposition`, and otherwise streams through the proxy. With no forced disposition and no `authorize` lifetime cap, an adapter with a permanent public link is redirected to it.
  - **Gateway `url` operation:** it signs whenever the adapter can. On an adapter that can't sign, a client-requested `expiresIn` or an `authorize` `maxExpiresIn` is answered with a `422` instead of a permanent link.
  - **`signedUrlPolicy()`:** it no longer pins a missing `expiresIn` on an instance that can't sign, which would now throw.

## Minor Changes

- d70c0d2: The Dropbox adapter (`files-sdk/dropbox`) now forwards the abort signal to every Dropbox API request. Cancelling a call, or hitting its `timeout`, aborts the in-flight upload, download, listing, copy, delete, metadata, link, or resumable-chunk request instead of letting it run to completion in the background. This uses the SDK's per-request transport options (`dropbox` 10.42 and later); older versions ignore them and behave as before.
  
  Buffered uploads over Dropbox's 150 MB single-call limit now use a concurrent upload session when `dropbox` 10.47 or later is installed: `multipart.concurrency` chunks (default 4, matching the S3 adapter) upload in parallel through the SDK's new `uploadFile` helper, with retries left to the `Files` wrapper's `retries` and `onRetry`. Pass `multipart: { concurrency: 1 }` to keep the sequential session. Stream bodies and resumable uploads stay sequential, and older `dropbox` versions keep the sequential session for every upload. The peer range is unchanged.
- 44b0a35: `files-sdk/events` now reads notifications from Backblaze B2, Tigris, Supabase (a Database Webhook on `storage.objects`), Cloudinary, Appwrite and Box, from S3 delivered by SNS over HTTPS, and from Storj, which publishes S3-format events to Google Pub/Sub. The `s3` format unwraps a Pub/Sub message, whether pushed, pulled, or handed over by the Node client library. The matching adapters pick their format automatically. `webhook()` verifies each provider's own signature with `verify: { secret }`:
  
  - B2: HMAC-SHA256.
  - Cloudinary: SHA-1 or SHA-256, checked for freshness.
  - Appwrite: HMAC-SHA1 over the configured URL, so pass `url`.
  - Box: primary or secondary key, checked for freshness.
  
  `verify: { sns }` checks SNS message signatures against the certificate at `SigningCertURL`. That URL must be an HTTPS SNS host on the default port, with no credentials, query or fragment. `topicArn` (one ARN or several) is required. SNS signs messages for any topic, including one an attacker owns, so the topic is checked on every message, subscription confirmations included. The signed `Timestamp` must be no older than `maxAge` (default one hour). Both checks run before any certificate is fetched, and only a few signing keys are cached. With `confirm: true` it also confirms new subscriptions to the pinned topic. B2 hide markers, which a delete through `files-sdk/backblaze-b2` produces, count as deletes. Box keys are rebuilt relative to the adapter's `rootFolderId`. Box reports a trashed file at its Trash location, so `FILE.TRASHED` gets a key only for a file that sat directly in the root folder. Cloudinary events for another resource type are dropped.
- 44b0a35: Add `files-sdk/events`, a plugin that turns each provider's bucket notifications into one event shape. Install `events()` with `createFiles` and `files.events` gains:
  
  - `on(type, glob?, handler)` for `created` / `deleted` events, matched against the caller-facing key.
  - `dispatch(delivery)` and `parse(delivery)` for queue consumers. They accept an SQS or Lambda event, an R2 Queue message, a Pub/Sub message, an EventBridge event, or an array of them.
  - `webhook({ verify })`, an endpoint with the gateway's `{ handle }` shape for providers that push over HTTP. It answers the Event Grid and CloudEvents handshakes and authenticates every delivery: with a shared token, or with a Google-signed OIDC token for Pub/Sub push. A Google token must match both the `audience` and the push subscription's service account (`email`), and both are required, since anyone can mint a Google-signed token for any audience. A failed certificate or key fetch answers `502` and a throwing handler `500`, so the provider redelivers. A handler's error message never reaches the response; it goes to `onError`. Other unexpected failures go to the webhook's own `onError`.
  
  It reads S3 (SQS, Lambda, SNS → SQS, SNS → Lambda, EventBridge), MinIO and RustFS webhooks, Wasabi (through SNS), R2 Queues, GCS Pub/Sub and Eventarc, and Azure Event Grid in either schema. Adapters declare the format their provider sends as a new `capabilities.events`, which `files.capabilities.events` reports: `s3()` and `s3Fetch()` declare `"s3"` only against AWS, since an S3-compatible endpoint may not send S3's shape, while `minio`, `rustfs`, `wasabi`, `r2`, `gcs`, `firebase-storage` and `azure` declare theirs. Parsing on an adapter with no format throws `Unsupported` instead of guessing, and a malformed delivery throws `Invalid`.
  
  Delivery is at least once and out of order on every provider, so each event carries an `id` to dedupe on, and an optional `dedupe` store drops repeats. The id is the provider's event id, or is built from the key and the provider's sequencer, version, timestamp or ETag. A record with none of those gets a hash of itself, never the clock, so a redelivery always keeps its id. Events outside the instance `prefix` are dropped. So are events for another bucket: the `bucket` filter defaults to the adapter's own bucket when it exposes one, and `bucket: false` turns it off. Gateway uploads reach the same handlers, and `events({ sdk: true })` adds writes made through the instance. With `sdk: true`, `events()` must come before any plugin that maps storage keys or sizes; otherwise the instance throws `Invalid` when it's built. On an instance that reads a format, `on()` throws when a plugin refuses provider events, as `webhook()` does. The memory adapter emits events natively, with `settled()` for tests.
  
  Plugins gain an optional `event` hook for mapping provider events. `dedup()`, `encryption()`, `compression()`, `versioning()`, `softDelete()` and `tiering()` use it, so their internal keys and stored sizes don't leak into events. The `files` CLI gains `files events parse [file] --format <format>` for debugging a payload.
- 10674b2: Add `files.abortUpload(key, token)` to discard a persisted resumable upload from its session token. `UploadControl.abort()` discards the provider-side session only while the control is driving an upload: a control rebuilt with `UploadControl.from(token)` in a new process has no adapter to call, so its `abort()` marked it aborted and left the session behind (an S3 multipart upload kept its billed parts). `abortUpload()` takes the same logical key and the `control.toJSON()` token and does what `abort()` does mid-upload: S3 and the S3-compatible adapters issue `AbortMultipartUpload`, GCS, Firebase Storage, Google Drive, OneDrive, SharePoint, and Supabase cancel the session, `fs`, FTP, and SFTP remove the staged partial, and Appwrite deletes the partial file. Adapters whose provider has no cancel primitive (Azure, Dropbox, Cloudinary, Vercel Blob) resolve and let the session expire. A token for another key, bucket, or provider throws `Invalid` before anything is discarded, a session that's already gone resolves while a cancel the provider refuses rejects, and the call throws `ReadOnly` on a read-only instance and `Unsupported` on an adapter without resumable uploads.
  
  The Appwrite adapter (`files-sdk/appwrite`) also now checks that a file is still partially uploaded before a discard deletes it, so aborting from a stale token, or after a resumed upload failed against a file that had since completed, no longer deletes the finished file. The resumable-upload docs now say plainly that the browser client (`files-sdk/client`, `useFiles`) can't pause or resume across a reload in 3.x.
- 44b0a35: Add an upload lifecycle hook to the gateway (`files-sdk/api`). `createFilesRouter({ onUploadComplete })` runs on the server once per verified upload: in `complete` for keyless uploads, after the gateway `head`s the landed object and checks `maxUploadSize`, and after a keyed `upload(key, body)` stores its body. Record the upload there instead of building a second endpoint and trusting the client to call it. The hook receives the caller-facing `file` metadata, its `storageKey`, the request, a stable `uploadId` and how the bytes arrived (`via`). `authorize` can now return `context` (the signed-in user, say), which reaches the hook typed.
  
  Whatever the hook returns comes back to the browser as the upload result's `data`, in `createFilesClient`, the React, Vue and Svelte `useFiles` bindings (results and `uploads` entries), and the registry `Dropzone` / `MultipartUploader` `onUploaded` callbacks. Type it with `useFiles<InferUploadData<typeof router>>()`.
  
  Throwing from the hook rejects the upload: the gateway deletes the object and the client's `upload()` rejects with the error (`UploadRejectedError` gives a 422 with your message). A removal that fails is reported alongside the rejection. Set `onRejected: "keep"` to keep rejected objects, including ones the complete-time `maxUploadSize` check refuses. Upload tokens are stateless, so a replayed `complete` fires the hook again with the same `uploadId`; pass a `completions` store to make completions single-use.
- d70c0d2: Add a `region` option to the Netlify Blobs adapter (`files-sdk/netlify-blobs`). Site-wide stores don't read a region from the environment, so until now they always used the Netlify API's default region rather than the site's Functions region. Pass `region: "eu-central-1"` (or any region `@netlify/blobs` supports) to keep a store's data next to the functions that read it. Deploy-scoped stores keep defaulting to the deploy's region, and changing `region` later doesn't move data a store already holds. No peer range change: every supported `@netlify/blobs` version forwards the option.
- 90fc706: Support Nitro 3 / h3 2 in the Nitro gateway binding (`files-sdk/nitro`), alongside Nitro 2 / h3 1. h3 2 hands every runtime a Web `Request` on `event.req`, and the binding now passes it to the gateway as-is. Before, it read `event.node`, which h3 2 only provides on Node, so the route failed with a 500 on Bun, Deno, and edge presets. On h3 1 nothing changes: the Node request is still marshalled, and in-process requests (`localFetch`, SSR `$fetch`) still read their body from the event. The binding no longer imports anything from `h3`, including types, so it typechecks in Nitro 3 apps that only reach h3 through `nitro/h3`. `createRouteHandler`'s parameter is the new structural `NitroEvent` type, which both majors' `H3Event` satisfy, and the `h3` peer range widens to `^1.0.0 || ^2.0.0`.
- 6682759: An empty `prefix` (`""`) or an all-slash one (`"/"`) passed to `new Files({ prefix })` now means no prefix, the same as leaving the option out, instead of throwing "prefix must be a non-empty string". This keeps `prefix: process.env.FILES_PREFIX ?? ""` working when the variable is unset.

## Patch Changes

- 5cdab4f: Fix `createFileTools()` from `files-sdk/ai-sdk` not typechecking with AI SDK 7. `FileTools` was declared as an `interface`, which has no implicit index signature, so it wasn't assignable to `ai`'s `ToolSet` and `generateText({ tools: createFileTools({ files }) })`, `streamText`, and `new ToolLoopAgent({ tools })` failed to compile with "Index signature for type 'string' is missing". `FileTools` is now a type alias with the same members. `AgentsFileTools` from `files-sdk/openai` gets the same change, so it's assignable to a `Record<string, Tool>`. The Vercel AI SDK docs also now spell out that an AI SDK 7 `toolApproval` function overrides every tool's `needsApproval`, and that returning `undefined` from it runs a write without approval.
- 647b04a: The published build no longer imports `node:module` from the root `files-sdk` entry, `files-sdk/r2`, or the other adapter and plugin entries. Bun 1.4.0's bundler emitted an unused `createRequire` shim chunk and imported it from almost every entry, so a Cloudflare Worker without the `nodejs_compat` flag failed to bundle Files SDK with `Could not resolve "node:module"`. The package is now built with Bun 1.4.2, which doesn't emit the shim, and a build-output test bundles the Worker-facing subpaths the way Wrangler does to keep it that way.
- d70c0d2: Fix error classification in the Bunny Storage adapter (`files-sdk/bunny-storage`) on `@bunny.net/storage-sdk` 0.3.2, which started putting the key into its 400 error message. A rejected request for a key containing words like "not found", "forbidden", or "conflict" was misread as `NotFound`, `Unauthorized`, or `Conflict`; the SDK's own message templates are now matched exactly first, so a 400 stays a `Provider` error whatever the key says.
- de04cc9: Security: the `files-sdk/api` gateway's `complete` step now checks that an upload token was issued for the caller. It verified the token's signature but never compared its key against the calling request's authorized `keyPrefix`, so one tenant could complete another tenant's token and read back that upload's full storage key and metadata. A token whose key lies outside the caller's prefix now gets an `Unauthorized` entry ("upload token was not issued for this caller") that echoes only the key the caller sent. The proxy upload `PUT` is unchanged: like a presigned URL, its token alone authorizes writing the one key `presign` minted until it expires, and the authorization docs now say so.
- de04cc9: The `files-sdk/api` gateway no longer breaks on adapters that can't set `responseContentDisposition` on their URLs (Vercel Blob, Box, Dropbox, PocketBase, UploadThing, Cloudinary, the local `fs` adapter, and the others whose `url()` refuses the option). Before, the default `download` returned a 500 on private Vercel Blob and the other signing adapters among them, and the `url` operation returned a 500 on all of them. Now `downloadMode: "auto"` proxies such a download so `Content-Disposition: attachment` still applies (`"redirect"` still surfaces the refusal), and when `authorize` returns `{ disposition: "inline" }` the gateway mints the URL without a disposition for both `url` and the download redirect. Without that inline policy, the `url` operation still fails (a `422` `Unsupported`), because the gateway can't guarantee `attachment`, but the error now explains how to proceed: allow inline from `authorize`, or use `download`. The adapters' refusal keeps its message and is now an `Unsupported` error, so it is never retried or failed over.
- de04cc9: Proxied downloads from the `files-sdk/api` gateway play media and send proper validators. With the default `onUnsupportedRange: "reject"`, a `Range: bytes=0-` request to an adapter that can't serve ranges got a `416`, which broke every `<video>` and `<audio>` element (browsers open media with that range). The whole body satisfies it, so it now gets the full object with a `200`; any other range on such an adapter is still a `416`. The `ETag` header is now always a quoted entity-tag, even when the adapter reports it bare as the S3 family does, and `If-Range` matches either form. Proxied responses also send `Last-Modified` when the adapter knows it. `files-sdk/client` keeps reporting the adapter's own etag on proxied downloads, as `head()` does.
- de04cc9: The `files-sdk/api` gateway enforces `maxUploadSize` at both ends of a keyless upload. `presign` now refuses a file whose declared `size` is over the limit with a `422` (`reason: "size"`) before signing anything. The declared size is only advisory, so `complete` still checks the stored object, and when that object is over the token's limit it is now deleted instead of left in storage behind the error entry. The key was minted by the server for that upload alone, so nothing else is touched; a failed removal is reported in the entry's message.
- d70c0d2: Accept `@googleapis/drive` 25 and 26 as peers of the Google Drive adapter (`files-sdk/google-drive`). Both majors only raise the package's own Node.js floor to 22 (23 and 24 were never published), matching Files SDK 3's Node 22 floor, and the Drive v3 surface the adapter uses is unchanged.
- d974716: The package's `homepage` now points to the documentation site, [https://files-sdk.dev](/), instead of the GitHub README, so the npm page and other registries link straight to the docs.
- 134f880: Give `head()` of a missing key a useful error message on the S3 adapter (`files-sdk/s3`) and every adapter built on it. A HEAD 404 has no body, so the AWS SDK's placeholder message `UnknownError` came through as the `NotFound` error's message. It now reads "The specified key does not exist.", the same text a `download()` of that key gets from S3. The `fetch` engine (`files-sdk/s3-fetch`, and R2, MinIO, and RustFS on `client: "fetch"`) uses the same message instead of the generic "Not found". Other bodyless failures on the AWS SDK path (a HEAD 403 or 500) fall back to the standard `Unauthorized` / provider message instead of `UnknownError`, and `mapS3Error` treats the placeholder as no message.
- 2cbf291: Bind `contentType` into presigned PUT URLs from the S3 adapter (`files-sdk/s3`). `signedUploadUrl(key, { contentType })` without `maxSize` returned a URL signed over the `host` header only, because the AWS presigner leaves `Content-Type` out of the signature by default. The `Content-Type` header it handed back was advisory, and a client could upload any type, despite `contentType` being documented as bound into the signature. The URL now signs `content-type` too, so an upload with a different Content-Type is rejected with a 403. This covers every S3-compatible adapter built on `s3()` (Spaces, Wasabi, Backblaze B2, Tigris, Hetzner, and the rest) and R2, MinIO, and RustFS on `client: "aws-sdk"`; the `fetch` engine already signed it. Send the returned `headers` unchanged with the upload.
- 90fc706: Accept `@sveltejs/kit` 3 as a peer of the SvelteKit gateway binding (`files-sdk/sveltekit`), alongside 2. `createRouteHandler` only relies on the `RequestHandler` type, which is unchanged in SvelteKit 3, so the same `{ GET, POST, PUT }` exports work on either major.
- dc0b34e: CLI, MCP, and AI tool fixes:
  
  - Bad integer flags and local file problems exit `2` instead of the retryable `5`, and a missing provider SDK is `Unsupported` with the `npm install` command.
  - `files events parse` uses the configured provider alongside `--format`, accepts `--header` (needed for Appwrite), and no longer waits on a terminal.
  - The MCP and AI tools describe `expiresIn` and capabilities the v3 way, report `maxBytes` and bad base64 as `Invalid`, and the MCP `upload` infers the content type from the key.
- dc0b34e: Cloud adapter fixes:
  
  - Supabase: user metadata now round-trips exactly on every read (keys were camelCased or dropped, so `encryption()` returned ciphertext), there are no resumable uploads with a pre-built `client`, and `abortUpload()` checks the cancel status.
  - Cloudinary: signed URLs, private reads, and resumable uploads need an API key and secret, and are `Unsupported` without them instead of failing on every call.
  - GCS, Firebase Storage, and UploadThing reject an expiry over 7 days with `Invalid`.
  - UploadThing, Vercel Blob, and Cloudinary map direct-read HTTP statuses to the standard codes.
  - Netlify Blobs config errors and PocketBase records without a file are `Invalid`, and a Bunny Storage delete with a read-only key is `Unauthorized`.
  - Appwrite offers resumable uploads only with an API key.
- dc0b34e: Core fixes:
  
  - A plugin that only declares `capabilities` is now enforced like one that also wraps.
  - `delete(keys, { stopOnError: true })` goes key by key with or without plugins, so both return the same result.
  - `abortUpload()` fires `onAction`, `onError`, and `onRetry` (`type: "abortUpload"`), and rejects when the provider refuses the cancel (GCS, Firebase Storage, Google Drive, OneDrive, SharePoint, Supabase) instead of reporting success.
  - `sync({ prune: true, signal })` stops pruning once the signal aborts.
  - A plugin's `extend` can no longer replace a symbol-keyed `Files` member.
- dc0b34e: File and drive adapter fixes:
  
  - `fs`: `publicUrl` is declared only with `urlBaseUrl`, so the gateway no longer redirects to `file://` paths. Directories are no longer objects: `head`, `exists`, `download`, `copy`, and `move` report `NotFound`, `delete` is a no-op, and writing over one is a `Conflict`.
  - WebDAV, Dropbox, OneDrive, and SharePoint no longer delete or copy whole folders: a folder key is a no-op for `delete` and `NotFound` as a copy source. Dropbox and OneDrive refuse empty, `/`, and trailing-slash keys.
  - Dropbox, Box, OneDrive, and SharePoint `copy` and `move` now overwrite an existing file, like the other adapters. OneDrive copy failures are classified by Graph error code, and a copy timeout is no longer retried.
  - Box recovers when a key is deleted and re-created elsewhere.
  - Out-of-order resumable calls on Dropbox and OneDrive are `Invalid`.
  - `memory` refuses `url({ expiresIn })` when called directly.
- dc0b34e: Gateway and browser client fixes (`files-sdk/api`, `files-sdk/client`, and the framework bindings):
  
  - Errors that aren't a `FilesError` (from `authorize`, `onUploadComplete`, or a `completions` store) now reach the client as a generic 500, and the original goes to a new `onError(error, req)` option. Storage keys in error messages are rewritten to the client's key, so the `keyPrefix` doesn't leak.
  - Proxied downloads send `Cache-Control: private, no-store`, and content a browser could run (anything but images, media, and PDF) gets a sandboxing `Content-Security-Policy`.
  - With a `completions` store, the proxy refuses another `PUT` once `complete` has accepted the upload (409). It also refuses tokens minted for direct-to-storage uploads.
  - `complete` accepts a token for a grace period after it expires (`completeGracePeriod`, default one hour), and proxy tokens are no longer capped by the adapter's signed-upload limit.
  - Keys with backslash `..` segments are refused.
  - `authorize` now receives the declared file `name`, `size`, and `type` for uploads, and `filterKeys` also covers `versions`, `restore-version`, `restore-trashed`, and `complete`.
  - `search` reads at most `maxSearchScan` keys (default 10,000) and reports `truncated`; the client's `search()` returns `{ truncated }` (`SearchSummary`).
  - Corrected codes: an oversized upload at `complete` is `Validation` (reason `size`), a missing plugin is `Unsupported`, and a 416 carries an error body and maps to `Invalid` on the client.
  - `trashed` and a scoped `purge` read only the caller's part of the trash (`softDelete().trashed({ prefix })`), `presign` signs at most `maxConcurrency` targets at once, bulk operations don't start for a disconnected client, and `auto` downloads fall back to the proxy when a redirect URL is refused.
  - `UploadRejectedError` under `files-sdk/nestjs` now answers 422 instead of 500.
  - `files-sdk/versioning` prunes history only after a write succeeds.
- dc0b34e: Plugin fixes:
  
  - `files-sdk/tiering` and `files-sdk/failover` advertise only what every backend supports, so an unsupported option fails up front instead of only on some keys or after a failover. Tiering also reports `serverSideCopy: false`, and `events: false` with `fallback: true`.
  - `files-sdk/signed-url-policy` narrows `signedUpload`, `publicUrl`, and `signedUrl` to what still works under the policy.
  - `files-sdk/cache` invalidates a key on storage events.
  - `files-sdk/compression` reports an unknown stored algorithm as `Unsupported`.
  - `files-sdk/dedup` keeps `size` and `etag` on events for objects it didn't write.
- dc0b34e: S3 family fixes:
  
  - Presigned PUT URLs on AWS no longer carry an empty-body checksum (`x-amz-checksum-crc32=AAAAAA==`) that made S3 reject every real upload.
  - Metadata that can't travel as a header (non-Latin-1 or control characters) and out-of-order resumable calls are `Invalid` instead of a retried `Provider` error.
  - Presigned POST uploads enforce the 7-day expiry limit.
  - An explicit `amazonaws.com` endpoint (or `AWS_ENDPOINT_URL*`) counts as AWS for events and conditional writes on both engines.
  - Backblaze B2 refuses `maxSize` up front, since it has no presigned POST.
  - Bun S3 reports a 401/403 on HEAD as `Unauthorized` instead of retrying it.
- 51e7bd1: Report failed keys through `onProgress` in `transfer()` and `sync()`, so `done` reaches `total`. Only successful and skipped keys fired a progress event, so a run with any failures never reached the documented denominator and a progress bar stalled short of 100%. A key that fails now fires an event with `status: "failed"` (its error is still in the result's `errors`), and `sync()` does the same for a failed prune. `TransferProgress["status"]` gains `"failed"` alongside `"transferred"` and `"skipped"`, and `SyncProgress["status"]` alongside `"uploaded"`, `"skipped"`, and `"deleted"`; an exhaustive `switch` over the status needs the new case. Under `stopOnError` the run still stops at the first failure, so the keys after it never settle.
- 86ec31c: Fix the Vercel Blob adapter (`files-sdk/vercel-blob`) choosing `BLOB_READ_WRITE_TOKEN` over OIDC on Vercel Functions. When a project had both `BLOB_STORE_ID` and `BLOB_READ_WRITE_TOKEN`, the adapter saw no OIDC token in `process.env` (on Functions it arrives per request, in the `x-vercel-oidc-token` header) and passed the long-lived read-write token explicitly, so `@vercel/blob` never looked for the request's token. With a store id and no `token` option, the adapter now always lets `@vercel/blob` pick the credential on each call, the way it does when called directly: the request header first, then `VERCEL_OIDC_TOKEN` (refreshed when expired on `@vercel/blob` 2.5 and later), then `BLOB_READ_WRITE_TOKEN`. An explicit `token` still wins, and an explicit `oidcToken` with no store id still throws. When only a store id is configured and no token turns up for a call, that operation now throws an `Invalid` `missing credentials` error, never retried, instead of `@vercel/blob`'s "No blob credentials found", which becomes its `cause`.
- 4a2b2b6: Fix OIDC authentication in the Vercel Blob adapter (`files-sdk/vercel-blob`) on Vercel Functions. There the OIDC token arrives per request, in the `x-vercel-oidc-token` header, rather than in `process.env`. With an OIDC-only store (`BLOB_STORE_ID` and no `BLOB_READ_WRITE_TOKEN`), `vercelBlob()` threw "missing credentials" at construction even though `@vercel/blob` could authenticate. The adapter now passes the store id and lets `@vercel/blob` find the OIDC token itself, for environment tokens too. So a fresh request-header token wins over a stale `VERCEL_OIDC_TOKEN`, and on `@vercel/blob` 2.5 and later an expired local token from `vercel env pull` is refreshed. An explicit `oidcToken` option is still used as given.
- 3a8da5e: List every current Wasabi region in the `region` option's documentation for the Wasabi adapter (`files-sdk/wasabi`). The list was missing `us-west-2` (San Jose), `eu-west-3` (United Kingdom), and `eu-south-1` (Milan). The adapter already built the right endpoint for them, since every Wasabi region uses `https://s3.<region>.wasabisys.com`; only the editor hint was out of date.
- bde4f74: Fix Cloudflare Worker builds of the R2, MinIO, and RustFS adapters (`files-sdk/r2`, `files-sdk/minio`, `files-sdk/rustfs`) without the `@aws-sdk/*` packages installed. A Worker that only used the R2 binding, hybrid signing, or `client: "fetch"` failed `wrangler deploy` and `wrangler dev` with `Could not resolve "@aws-sdk/client-s3"` (and the same for `@aws-sdk/lib-storage`, `@aws-sdk/s3-presigned-post`, and `@aws-sdk/s3-request-presigner`), because Wrangler's bundler resolves the dynamic imports behind the lazily loaded `"aws-sdk"` engine even when they never run. Those imports are now written so bundlers treat a missing package as a run-time failure: Wrangler, esbuild, Bun, and rolldown build without them, and `files-sdk` alone is enough, as the docs already said. When the packages are installed they are still resolved and bundled, so `client: "aws-sdk"` keeps working everywhere it did, including Workers with a `DOMParser` polyfill. If the `"aws-sdk"` engine runs without `@aws-sdk/client-s3` and the two presigners, its first call now rejects with a `FilesError` that names them and suggests `client: "fetch"`, rather than a bare module-not-found error or, under Next.js's webpack, which swaps a missing optional peer for an empty module, "is not a constructor".
