---
changelog:
  category: Release
  version: files-sdk@3.1.0
date: '2026-10-10T04:48:30Z'
seo:
  description: >-
    Add an Effect v4 bridge (files-sdk/effect). Files is a Context.Service
    provided by Files.layer(), from a Files instance or the options to build
    one. Its…
title: files-sdk@3.1.0
type: changelog
---
## Minor Changes

- d02e8ca: Add an Effect v4 bridge (`files-sdk/effect`). `Files` is a `Context.Service` provided by `Files.layer()`, from a `Files` instance or the options to build one. Its methods mirror `Files` and return `Effect`s, with `listAll`, `search`, and download bodies as `Stream`s. Every operation fails with a `FilesError` whose `reason` is a tagged error for the SDK's code (`NotFound`, `Unauthorized`, `Conflict`, `ReadOnly`, `Invalid`, `Unsupported`, `Provider`), so `Effect.catchReason("FilesError", "NotFound", …)` recovers from one code, and each reason keeps the SDK error's flags and the original error as its `cause`. Each call receives its fiber's `AbortSignal`, so interrupting the fiber (a timeout, a lost race, a closed scope) aborts the provider request. With the `events()` plugin, `files.events.on()` runs an Effect handler for each storage event and keeps delivery at-least-once, and `files.events.stream()` serves the events as a `Stream`. `tryPromise` runs plugin methods with the same error mapping and cancellation, and `make()` provides the service under your own key for multiple buckets or typed plugin methods. `effect` is an optional peer dependency.

## Patch Changes

- 203ecfe: `files-sdk/openai`: a `needsApproval` override in `createAgentsFileTools({ overrides })` no longer breaks the tool. It used to replace the Agents SDK's approval function with a boolean, so the runner failed with `needsApproval is not a function` on the first call. Overrides are now applied when each tool is built, an override takes precedence over `requireApproval` (and can gate a read tool), and every single-tool factory accepts `{ description, needsApproval }`.
- 16c1079: Fix `files mcp` failing to start from the published package. The bundled MCP server read its version from a fixed `../../package.json` that only resolved from source, so in an installed package it failed at startup with a misleading "the `mcp` subcommand requires `@modelcontextprotocol/sdk` and `zod`" error even when both were installed. The CLI now finds its own `package.json` from wherever the build places each module.
- 203ecfe: The `files upload` CLI's `--metadata` flag now takes one `key=value` pair per flag; repeat the flag for more (`--metadata team=finance --metadata quarter=q1`). It used to be variadic and swallowed the positional key that followed it, so `files upload --metadata a=1 report.pdf --file r.pdf` failed. Several pairs after a single `--metadata` are now refused as extra arguments.
- 8529ee6: Bulk `upload([...])` and `download([...])` with `stopOnError` in `files-sdk/client` (and the `useFiles` bindings) now behave like the SDK: items run one at a time in input order, and the call resolves at the first failure with `{ results, errors: [thatFailure] }`. They used to reject while sibling items kept running, so writes could land after the call had failed and `isUploading` could read `false` while uploads were still in flight. Items that never started are still reported as `"aborted"` in the upload state.
- 8529ee6: `download()` in `files-sdk/client` now reports the right `size` and `lastModified` more often. On a full proxied download it takes `size` from the gateway's metadata header instead of `Content-Length`, which compression middleware can drop (giving `0`) or rewrite. A download the gateway redirected to storage now falls back to the storage host's `Last-Modified` header for `lastModified`.
- 8529ee6: `files-sdk/client` and the `useFiles` bindings (`files-sdk/react`, `files-sdk/vue`, `files-sdk/svelte`) now reject with a `FilesError` when a request never gets an answer. A network failure used to surface as the runtime's bare `TypeError` and a cancelled call as a raw `AbortError`, and the hooks recorded a hook-level `abort()` in `error` as a `Provider` failure with `aborted: false`. A network failure is now a `Provider` `FilesError` with the original error as its `cause`, and a cancelled call (per-call `signal`, hook-level `signal`, or `abort()`) is flagged `aborted: true`, matching the XHR upload path. The hooks rethrow the same error they record in `error`.
- 8529ee6: `files-sdk/client` now classifies error responses that don't come from the gateway by HTTP status, and says why. A body such as Supabase's `{ "error": "InvalidJWT", "message": "jwt expired" }` used to be read as the gateway's envelope and became a `Provider` error with an empty message, and a `401` from auth middleware in front of the gateway became a generic `Provider` error. Only a body whose `error.code` is a string is now treated as the envelope. Anything else maps `401`/`403` to `Unauthorized`, `404` to `NotFound` and `409`/`412` to `Conflict` (else `Provider`), and the message keeps the old `gateway responded 401` or `upload failed (403)` prefix with the body's reason appended when it has one.
- 8529ee6: A keyless `upload()` from `files-sdk/client` (and the `useFiles` bindings) of a React Native file reference without a `size` now works. The client used to declare such a file's size as `0`, and since the gateway holds an upload to its declared size, the upload was refused. The client now leaves `size` out of the presign request when it doesn't know it, so the gateway applies only its `maxUploadSize`.
- 8529ee6: The reactive `useFile`, `useList` and `useSearch` in `files-sdk/react` and `files-sdk/vue` no longer show the previous input's `data`. Switching to a new key, prefix, pattern, or endpoint used to keep the old result visible while loading (with `isLoading` false) and next to the new input's error, and a disabled query (`useFile(undefined)`) kept it too. A new input now starts with no data and `isLoading: true`, and a disabled query has no data or error. A `refetch()` of the same input still keeps its data while it reloads, and when the reload fails.
- 8529ee6: The reactive queries in `files-sdk/react`, `files-sdk/vue` and `files-sdk/svelte` (`useFile`, `useList`, `useSearch`) now honor the `signal` option. It was documented as merged into every call but was ignored. Aborting it cancels the query's request and settles the query with an `aborted` `FilesError`, and the query removes its listener once its request settles or is replaced.
- 8529ee6: A string body uploaded with `files-sdk/client` (or the `useFiles` bindings) and no `contentType` is now stored as `text/plain; charset=utf-8`, as the server SDK stores it. It used to be sent untyped and stored as `application/octet-stream`.
- 8529ee6: A global `Content-Type` in the `headers` option of `files-sdk/client` (and the `useFiles` bindings) no longer overrides the type of keyed uploads. It used to replace every `upload(key, body)` request's type, even an explicit `contentType`, so files were stored as, say, `application/json`. The body's own type or `contentType` now always wins, and the JSON verbs always send `application/json`.
- b557950: `files-sdk/cloudinary` now invalidates the CDN copy when `copy()` or a resumable (`control`) upload overwrites an existing asset, as a plain `upload()` already did. Without it the CDN kept serving the replaced content until its cache expired.
- b557950: `files-sdk/netlify-blobs` now rejects keys that `@netlify/blobs` can't address, with an `Invalid` error before any request. The SDK puts keys into request URLs unencoded, so `upload("Invoice #42.pdf")` stored `Invoice `, a `?` cut the key short in the same way, a `\` became `/`, tabs, newlines and trailing spaces were dropped, and `..` segments could even reach another store. Keys containing `#`, `?`, `%`, `\`, tabs or newlines, ending in a space or control character, or with `.` / `..` segments now throw instead of silently naming a different blob.
- b557950: `files-sdk/pocketbase` now prefers `adminEmail` / `adminPassword` passed as options over a `POCKETBASE_AUTH_TOKEN` environment variable. The env token was checked first, so a stale token left in the environment overrode valid credentials passed in code. The order is now the `authToken` option, admin credentials from options, `POCKETBASE_AUTH_TOKEN`, then `POCKETBASE_ADMIN_EMAIL` / `POCKETBASE_ADMIN_PASSWORD`.
- b557950: `files-sdk/supabase` `copy()` (and so `move()`) now replaces an existing destination. Supabase only overwrites on copy when asked to, so copying or moving onto a key that already existed failed with a `Conflict` error, which also broke re-deleting a key with `softDelete()` and restoring over an existing key. Copies are now sent with `x-upsert: true`.
- b557950: `files-sdk/supabase` bulk deletes of more than 1000 keys now succeed. All keys went out in one request, which Supabase Storage refuses past 1000 objects, so every key was reported as failed. Keys are now deleted in batches of 1000, and a refused batch fails only its own keys.
- b557950: `files-sdk/supabase` now percent-encodes keys in every request URL. Keys went into the path unencoded, so a `#` or `?` cut the key short: `upload("uploads/Invoice #42.pdf")` stored `uploads/Invoice `, a later `#43` upload silently overwrote it, and `head`, `download` and `delete` disagreed about which object the key named. Uploads, downloads, `head`, `exists`, `url()` (signed and public) and `signedUploadUrl()` now address exactly the key given, including keys with `?`, `%`, spaces or `+`. Keys that Supabase Storage itself refuses (such as ones containing `#` or non-ASCII characters) now fail with Supabase's `Invalid key` error instead of being truncated.
- b557950: `files-sdk/uploadthing` `list()` now returns only files that finished uploading. It also returned files UploadThing reported as still uploading, failed, or pending deletion, which can't be downloaded. Pagination is unchanged: the cursor still advances past every row UploadThing returned.
- b557950: `files-sdk/vercel-blob` `copy()` now keeps the source's content type and cache max-age. `blob.copy` doesn't carry them over, so every copy, move and versioning snapshot got a content type guessed from the destination's extension and the default one-month cache. The adapter now reads both from the source and passes them to the copy.
- b557950: `files-sdk/vercel-blob` downloads no longer return stale bytes right after an overwrite. The body came through the CDN cache while the size and ETag came from a fresh `head()`, so for up to a minute the old content was returned labelled with the new metadata. Private downloads now read from origin (`useCache: false`), and public downloads add a `v=<etag>` query parameter to the blob URL so each new version misses the cache once.
- b557950: `files-sdk/azure` no longer lets environment credentials for one storage account authenticate an adapter configured for another. Previously, `azure({ container, accountName: "a" })` picked up an `AZURE_STORAGE_CONNECTION_STRING` for account `b`, so data calls went to `b` and SAS URLs were signed with `a`'s name and `b`'s key (a 403). Now an env connection string whose `AccountName` differs from an explicit `accountName` is ignored, and so are `AZURE_STORAGE_ACCOUNT_KEY` and `AZURE_STORAGE_SAS_TOKEN` when `AZURE_STORAGE_ACCOUNT_NAME` (or `AZURE_STORAGE_ACCOUNT`) names a different account. Env credentials for the same account, or that name no account, still apply.
- b557950: `files-sdk/azure` `copy()` now copies blobs larger than 256 MiB. It used Copy Blob From URL only, which refuses bigger sources with a 409 that surfaced as a `Conflict` error. Now, when that cap is the cause, it falls back to the asynchronous Copy Blob and resolves once the copy finishes. A failed or aborted copy rejects with a `Provider` error carrying Azure's reason.
- b557950: `files-sdk/convex` `upload()` no longer fails after the file is stored when reading its metadata back fails. Previously that error escaped unmapped, and the caller never learned the new storage id, which orphaned the file. Now the upload resolves with the new id and the uploaded content type and size.
- b557950: `files-sdk/firebase-storage` now prefers a passed `app`'s own `storageBucket` over `FIREBASE_STORAGE_BUCKET`. Previously the env var won, so in a process serving several Firebase projects, an adapter built from one project's app used the env var's bucket with that app's credentials. The order is now `bucket`, then the app's `storageBucket`, then `FIREBASE_STORAGE_BUCKET`.
- b557950: `files-sdk/gcs` and `files-sdk/firebase-storage` `upload()` now take the result's `etag`, `lastModified`, and size from the upload response instead of reading the object back afterwards. A service account that can create objects but not read them (Storage Object Creator) no longer gets an `Unauthorized` error for an upload that landed, and concurrent writers to the same key no longer risk reporting each other's etag. If the response lacks an etag, the follow-up read is best-effort and can't fail the upload.
- b557950: `files-sdk/gcs`, `files-sdk/firebase-storage`, and `files-sdk/bunny-storage` now honor `signal` (and `timeout`) on uploads. Previously an aborted or timed-out upload kept running in the background and could land later, over a newer write. Now aborting cancels the in-flight request: GCS and Firebase destroy the upload stream, and Bunny Storage, whose SDK takes no signal, errors the request body instead. Bunny Storage's `download()` and `copy()` honor the signal the same way.
- db60e17: A conditional `upload()` in `files-sdk` whose write committed but whose adapter returned a malformed or weak ETag now rejects with `applied: true`. Before, the post-commit ETag check failed the call as a plain provider error, hiding that the object had already changed.
- db60e17: `url()` and `signedUploadUrl()` in `files-sdk` now reject an `expiresIn` that isn't a positive whole number of seconds with an `Invalid` error before anything is signed. Before, values like `-60`, `0`, `NaN`, or `0.5` were passed to the signer and came back as URLs that never worked.
- db60e17: `list()`, `listAll()`, and `search()` in `files-sdk` now reject a `limit` that isn't a positive integer, and `search()` a negative, fractional, or `NaN` `maxResults`, with an `Invalid` error before any provider call. Before, `limit: -1` made the `fs`, memory, FTP, and SFTP adapters silently drop the last key of each page, `0` or `NaN` walked nothing, and a bad `maxResults` returned nothing or everything. `maxResults: 0` still yields no matches.
- db60e17: `upload()` in `files-sdk` now rejects a `multipart.partSize` or `multipart.concurrency` that isn't a positive integer with an `Invalid` error before any request, on every upload path (single, bulk, and resumable). Before, a fractional or non-positive value could spin a resumable upload in an endless loop that starved the event loop, silently drop the last byte on Azure, or commit an empty object. The resumable orchestrator also refuses to finalize a part list that doesn't add up to the whole body, and fails a chunk the provider acknowledges without advancing instead of re-sending it forever.
- db60e17: The `files-sdk/ftp`, `files-sdk/sftp`, and `files-sdk/webdav` adapters now reject keys that contain a backslash or start with a Windows drive letter (`C:`) with an `Invalid` error. Before, a key like `..\..\secret` passed the `..` traversal guard as a single segment, and `C:/Windows/win.ini` resolved to an absolute path, so on servers running on Windows hosts either could reach files outside the configured root.
- db60e17: Resumable uploads in `files-sdk` now open, probe, and finalize their provider session under the same per-attempt `timeout`, caller `signal`, `control.abort()`, and `retries` as each part. Before, a hung session call ignored the timeout and abort, and a transient provider error there failed the upload despite `retries`. A session that opens after its attempt timed out or was aborted is discarded as soon as it lands, and a finalize retry that finds the session already gone now reports that the object may have been committed instead of a misleading "not found". Custom resumable drivers receive an optional `signal` in `begin()`, `probe()`, and `complete()`.
- 21c88cc: `files-sdk/box` OAuth auth now survives Box's single-use refresh tokens. Before, the rotated refresh token lived only in the instance's memory, so after a restart or in a second instance the configured token was already spent and every call failed with `Unauthorized`. A new `oauth.tokenStorage` option persists the rotated tokens (the configured `refreshToken` becomes the first-run seed), and concurrent refreshes now share one exchange instead of spending the same token several times.
- 21c88cc: `files-sdk/dropbox` now strips `rootFolderPath` from listed keys regardless of case. Dropbox paths are case-insensitive, so with `rootFolderPath: "uploads"` and a real folder named `/Uploads`, `list()` used to return keys like `Uploads/a.txt` that then addressed `/uploads/Uploads/a.txt`. Listed keys are now relative to the root (`a.txt`), as they are when the casing matches.
- 21c88cc: `files-sdk/google-drive` no longer trusts a cached file id after another process or the Drive UI deleted, trashed, or replaced the file. Before, `head` reported `NotFound`, `exists` returned `false`, and `delete` resolved while the live file stayed. Now, when Drive answers that a cached id is gone, the adapter drops it and looks the key up again once. A trashed file reads as missing, and `delete`, `copy`, and `url` check that a cached id isn't trashed before acting on it.
- 21c88cc: `files-sdk/onedrive` and `files-sdk/sharepoint` now create a missing destination folder before `copy()`, so `copy()` and `move()` into a new folder succeed. Before, Graph's copy failed because its destination folder must already exist, even though `upload()` to the same key creates intermediate folders, as copy does on Box, Dropbox, and WebDAV.
- 21c88cc: `files-sdk/onedrive` and `files-sdk/sharepoint` no longer reject valid `list()` cursors from page 2 onward when the drive path is percent-encoded differently in Graph's `@odata.nextLink`. This affected a `siteId` (its commas) and folder names containing characters such as `@ , ; = + $ &`. Cursors are now compared on their decoded paths, and a cursor for another folder is still refused.
- 21c88cc: `files-sdk/dropbox` and `files-sdk/onedrive` (and `files-sdk/sharepoint`) refresh-token auth now shares one in-flight token exchange. Before, a cold burst of calls sent one token request per call, which risked rate limiting and, with rotating refresh tokens, rejected redemptions. A failed exchange is retried by the next call instead of being replayed.
- 203ecfe: `files-sdk/events`: a Google OIDC token whose signature segment isn't base64 is now refused with a `401` ("malformed OIDC token"), before any key fetch. It used to escape as an unexpected error, answered `500` (so Pub/Sub redelivered it) and reported to `onError`.
- 203ecfe: `files-sdk/events`: SNS signature verification now accepts a `SigningCertURL` only at `https://sns.<region>.amazonaws.com/SimpleNotificationService-<id>.pem` (or `.amazonaws.com.cn`). The previous host check also matched S3 bucket endpoints such as `sns.s3-accelerate.amazonaws.com`, so a bucket named `sns` could have served a certificate.
- 203ecfe: `files-sdk/events`: a webhook with `verify: { sns }` now parses only the fields of an SNS message that its signature covers. Before, the raw body was parsed after verification, so unsigned `EventSource`/`Sns` fields added beside a genuine signed message could feed forged S3 events (a delete, say) to your handlers. The `s3` format also refuses a body that is both an SNS notification and a Lambda SNS record.
- 203ecfe: `files-sdk/events` no longer reports a `deleted` event when only one version of an object was removed, since the key may still have a live version. That covers S3 `ObjectRemoved:Delete` and `LifecycleExpiration:Delete` carrying a version id (including `NoncurrentVersionExpiration` cleanups) and EventBridge `Permanently Deleted` events with a `version-id`, Backblaze B2 `b2:ObjectDeleted:*`, and GCS `OBJECT_DELETE` / Eventarc `deleted` events whose payload shows the generation was already noncurrent (`timeDeleted` more than a minute before the event). Delete markers, B2 hide markers, GCS archives and unversioned deletes are still reported, so cleanup handlers no longer delete live data when a lifecycle rule prunes old versions.
- 51204c2: The `files-sdk/api` gateway's `url` operation, and `files-sdk/signed-url-policy`, now accept a requested `responseContentDisposition` as an attachment only when its type is exactly `attachment`. Values such as `attachment, inline`, `attachment inline`, or `attachment/x` passed the old check but render inline in Chromium; they are now replaced with a plain `attachment`.
- 51204c2: The `files-sdk/api` gateway no longer lets a comma-separated content type such as `image/png, text/html` skip the download sandbox. A browser renders such a value as its last entry, so a proxied download opened inline could run stored HTML as your app; the sandbox now applies to any stored type that isn't exactly one well-formed media type. The gateway also refuses such a content type from a client with a `422` (`reason: "type"`) on the keyed upload `PUT`, `presign`, and `signed-upload-url`, while an absent content type is still accepted.
- 51204c2: Proxied downloads from the `files-sdk/api` gateway now name the file in their default `Content-Disposition`, as `attachment; filename="report.pdf"` (with an RFC 6266 `filename*` for a non-ASCII name), taken from the key's last segment. Before, the bare `attachment` made a browser save a download opened directly from its URL as `files`. A disposition returned by `authorize` is still sent exactly as given.
- 51204c2: The `files-sdk/api` gateway now answers `HEAD` on the download route instead of refusing it with `422`. A proxied download returns the same headers a `GET` would (`Content-Length`, `ETag`, `Last-Modified`, the disposition) with no body, without reading the object, and a redirected download returns the same redirect.
- 51204c2: A full (`200`) proxied download from the `files-sdk/api` gateway now carries the object's `size` in its `X-Files-Meta` header, so a client can read the size even when compression middleware drops `Content-Length`. A `206` range response leaves it out.
- 51204c2: A zero-byte upload through the `files-sdk/api` gateway no longer fails with `422 missing request body` on Bun, Deno, or Cloudflare Workers. Those runtimes give a `PUT` with `Content-Length: 0` a `null` body, which the keyed upload and the proxy upload now treat as an empty body.
- 51204c2: The Node gateway bindings (`files-sdk/express`, `files-sdk/fastify`, `files-sdk/koa`, `files-sdk/nitro`, and `files-sdk/nestjs`) now work over HTTP/2. Under Fastify's `http2: true` or `node:http2`'s compatibility API every request failed with a `500`, because the HTTP/2 pseudo-headers (`:method`, `:path`, `:authority`, `:scheme`) were copied into the Web `Request`; they are now skipped, and the host is taken from `:authority` when there is no `Host` header.
- 51204c2: The `files-sdk/api` gateway no longer forwards a storage provider's error message to the client. Messages from an adapter's `NotFound`, `Unauthorized`, `Conflict`, and `Provider` errors could carry absolute filesystem paths, internal hostnames, or bucket names, so those now reach the client as a fixed message per code (`not found`, `storage provider error`, …) with the same code and status, and `Provider` and `Unauthorized` errors are passed to `onError` so the detail still reaches your logs. The SDK's own `Invalid`, `Unsupported`, and `ReadOnly` messages, and any `FilesError` thrown by your `authorize`, `files` factory, or `onUploadComplete`, are still sent as written.
- 51204c2: The `files-sdk/api` gateway now refuses keys inside a plugin's reserved storage however they're spelled. On a case-insensitive store or a Windows filesystem, `.TRASH/notes.txt` or `.trash\notes.txt` reached the `softDelete()` trash (and `.VERSIONS/…` the version store), so a client allowed to `delete` but not `purge` could hard-delete a trashed file; those now get `403` like `.trash/notes.txt`. A `restoreVersion` `versionId` containing a backslash, `..`, or a NUL byte is now refused with `422`, like one containing a slash.
- 51204c2: A `search` through the `files-sdk/api` gateway now always walks storage in pages of `maxListLimit` keys. The client's `limit` was used as the page size while `maxSearchScan` counts keys, so `limit: 1` turned one request into up to 10,000 provider `list()` calls; `limit` is now ignored by the gateway, and `maxResults` still caps the matches.
- 51204c2: A keyless upload through the `files-sdk/api` gateway is now held to the size the client declared at `presign`, capped by `maxUploadSize`. Before, `authorize` saw the declared size but the upload token allowed anything up to `maxUploadSize`, so a client could understate the size to pass a per-user quota. The proxy `PUT` and `complete` now refuse a larger body, a storage-signed target binds the declared size where the adapter can, and a client that doesn't know a file's size may omit `size` (that upload is held to `maxUploadSize` alone). A declared size that isn't a non-negative whole number is a `422`.
- 51204c2: Upload tokens from the `files-sdk/api` gateway are now bound to the origin (scheme and host) of the request that minted them, as well as its path and query. With a `files` factory that picks the instance from the host, a token minted on one tenant's host could previously be redeemed by the proxy upload `PUT` on another's; that request, and `complete` on the wrong host, now get `Unauthorized`. The docs now also warn that a factory must select the instance from the URL, not from a cookie or header, because the proxy `PUT` runs no `authorize`.
- 9fdc79f: `files-sdk/cache` no longer caches a stale read that overlapped a write. A slow `download()`, `head()`, or `url()` miss that was in flight while an `upload()` (or `delete`, `copy`, `move`) of the same key completed used to store what it had fetched after the write invalidated the key, serving the old value for the full `ttl`; such a read now returns its result without caching it. Writes also drop the key before they run and after any failure, not only after a success or a `Conflict`, since a timed-out write may still have landed.
- 9fdc79f: `files-sdk/content-type` now classifies a document behind an `<?xml` prolog by its root element, so an XHTML or SVG document can no longer pass as an arbitrary `+xml` type. Previously any declared `+xml` type agreed with an XML sniff, so an XHTML page uploaded as `image/x+xml` passed `onMismatch: "reject"` (and an `image/*` allowlist after it) and ran its scripts when served. An `html` or `svg` root (or one in the XHTML or SVG namespace) now needs exactly `application/xhtml+xml` or `image/svg+xml`, an XML document never agrees with an `image/`, `audio/`, `video/`, or `font/` type other than `image/svg+xml`, and `detectContentType()` reports `application/xhtml+xml` for an XHTML root.
- 9fdc79f: `files-sdk/dedup` now reserves its blob store (`.dedup` by default) for the instance, the way `versioning()` and `softDelete()` reserve theirs. Previously a `files-sdk/api` gateway client could list `.dedup/`, download another user's content by its hash, or delete or move a blob and break every pointer to it; those requests now get a `403`. A `move` out of the store through the instance is also refused (it would remove the blob), and a backslash spelling such as `.dedup\x` is recognized as a store key.
- 9fdc79f: `files-sdk/failover` no longer fails over an upload that carries a resumable `control`. An `UploadControl` drives exactly one upload, so replaying it on a secondary after the primary failed threw `This UploadControl has already driven an upload` and hid the primary's real error. Such uploads now go to the primary only, like stream uploads, and surface its error.
- 9fdc79f: `files-sdk/validation` and `files-sdk/content-type` no longer accept a declared content type that is really a list, such as `image/png;a=b, text/html`. Both plugins used to check only the text before the first `;` and then store the caller's value verbatim, which a browser renders as its last entry (`text/html`), so a PNG/HTML polyglot could pass `allowedTypes: ["image/*"]` or `contentType({ onMismatch: "reject" })`. A declared type that isn't exactly one well-formed media type is now refused with an `Invalid` error (by `validation()` whenever `allowedTypes` is set, and by `contentType()` in every mode), and the type either plugin forwards is stored with its type and subtype normalized.
- 9fdc79f: `files-sdk/soft-delete` no longer hard-deletes a live file through a trash key that resolves out of the trash. A `delete(".trash/../notes.txt")`, which a filesystem resolves to `notes.txt`, used to be treated as a delete inside the trash and destroyed the live file; it's now trashed like any live key. `purge()` and `restoreTrashed()` refuse a key that resolves out of the trash, such as `../notes.txt`, with an `Invalid` error.
- 9fdc79f: `files-sdk/soft-delete`'s `purge()` is now idempotent on adapters whose `delete` throws `NotFound` for a missing key, such as GCS and Firebase. `purge(key)` with nothing trashed used to reject with `NotFound` there, and a whole-trash `purge()` failed when an object was purged concurrently; both now resolve.
- 9fdc79f: `files-sdk/soft-delete` now explains a trash collision on hierarchical stores such as `files-sdk/fs` and SFTP. With `a` already in the trash, deleting `a/b` needs a `.trash/a/` folder where the trashed file sits, and used to fail with a bare `EEXIST … mkdir .trash/a` error; it now throws a `Conflict` that names the trashed entry in the way and says to `purge()` (or `restoreTrashed()`) it first. The live object is untouched either way.
- 9fdc79f: `files-sdk/tiering` with `fallback: true` now works over tiers whose `delete` throws `NotFound` for a missing key, as GCS and Firebase do. The plugin's cleanup deletes assumed a missing key was a no-op, so every first upload rejected with `NotFound` after it had landed, a `copy` or `move` rejected after landing, and a `delete` of a key held by the other tier threw before reaching it, leaving the object in place. Those cleanup deletes now ignore `NotFound`; a `delete` removes the key from whichever tier holds it, and throws `NotFound` only when both tiers report the key missing.
- 9fdc79f: `files-sdk/versioning`'s `restoreVersion()` now refuses a `versionId` that is empty or `.`, or that contains a backslash, `..`, or a NUL byte, as it already refused one containing `/` (the `files-sdk/api` gateway refuses the same ids). On a Windows filesystem a `versionId` such as `..\..\users\2\secret\<id>` restored another key's version (possibly another tenant's) into the caller's key. A write to a key such as `.versions/../notes.txt`, which a filesystem resolves to `notes.txt`, is also no longer mistaken for a write inside the version store, so it's snapshotted like the live key it is.
- 22f8f5d: `files-sdk/s3`, `files-sdk/s3-fetch`, and every S3-compatible adapter now refuse user `metadata` values that aren't printable ASCII, including Latin-1 text such as `café`, with an `Invalid` error before any request. Such a value used to go out and fail as `SignatureDoesNotMatch`, reported as `Unauthorized`, because the HTTP client sends a Latin-1 character as one byte while SigV4 signs it as two. Encode the value first, for example with `encodeURIComponent`. The R2 binding stores metadata without HTTP headers, so it still accepts any text.
- 22f8f5d: `files-sdk/bun-s3` now reports bad credentials on `list()`, `upload()`, `delete()`, and `copy()` as `Unauthorized`. Bun's S3 errors carry no HTTP status, and `SignatureDoesNotMatch`, `InvalidAccessKeyId`, `ExpiredToken`, and `InvalidToken` weren't recognized, so a wrong key or secret was a `Provider` error that `retries` reissued.
- 22f8f5d: `files-sdk/s3`, `files-sdk/s3-fetch`, every S3-compatible adapter, and `files-sdk/bun-s3` now refuse a `contentType` or `cacheControl` containing control characters (such as CR/LF) or non-ASCII text with an `Invalid` error before any request, on uploads, resumable uploads, and `signedUploadUrl()`. Such a value used to fail as a retried `Provider` error, or as `Unauthorized` on the aws-sdk client while the fetch client stored it mangled.
- 22f8f5d: A `download()` whose `range` starts past the end of the object (`416 InvalidRange`) is no longer retried on `files-sdk/s3`, `files-sdk/s3-fetch`, the S3-compatible adapters, `files-sdk/bun-s3`, or the `files-sdk/r2` binding (error code 10039). It stays a `Provider` error, since it's the provider's answer, but is now marked `permanent`, so `retries` doesn't reissue a request that can only fail the same way.
- 22f8f5d: `copy()` and `move()` on `files-sdk/s3`, `files-sdk/s3-fetch`, and the S3-compatible adapters now handle sources over 5 GiB. S3's single `CopyObject` request refuses them, which used to surface as a retried `Provider` error; the adapter now confirms the size with a HEAD and copies the object server-side with a multipart copy (`UploadPartCopy`), carrying its content type, cache and disposition headers, and user metadata. On AWS every part is pinned to the source's ETag, so a source overwritten mid-copy fails with `Conflict`. A conditional copy of such a source throws `Unsupported`, since it can't stay one atomic request.
- 22f8f5d: `multipart.partSize` on `files-sdk/s3` and the S3-compatible adapters' aws-sdk client is now rounded into what S3 accepts. A size under 5 MiB is raised to S3's minimum instead of failing every attempt with `EntityTooSmall`, a size over 5 GiB is lowered to the maximum, and when the body's length is known the size grows until the body fits in S3's 10,000 parts, instead of failing at part 10,001 after about 48.8 GiB had uploaded.
- 22f8f5d: `url()` and `signedUploadUrl()` on `files-sdk/s3`, `files-sdk/s3-fetch`, the S3-compatible adapters, `files-sdk/bun-s3`, and the `files-sdk/r2` binding's hybrid signer now throw `Invalid` for an `expiresIn` or `defaultUrlExpiresIn` that isn't a whole number of seconds of at least 1. A zero, negative, fractional, or `NaN` lifetime used to mint a URL that was dead on arrival; only the seven-day ceiling was checked.
- 22f8f5d: A resumable (`control`) upload on `files-sdk/s3` and the S3-compatible adapters no longer fails after the object has committed when the follow-up `HeadObject` is denied or fails (for example, a principal allowed `PutObject` but not `GetObject`). The upload now resolves with the summed part sizes and the upload's content type, where it used to reject with `Unauthorized` and leave a resume that could only hit `NoSuchUpload`.
- 22f8f5d: An expired or malformed session token (`ExpiredToken`, `InvalidToken`, which S3 returns as HTTP 400) is now `Unauthorized` on `files-sdk/s3`, `files-sdk/s3-fetch`, and the S3-compatible adapters, instead of a `Provider` error that `retries` reissued.
- 21c88cc: `files-sdk/fs` resumable uploads no longer follow a symlink planted at the `<key>.fls-part` staging path. Starting an upload used to open that path and truncate whatever the link pointed at, inside the root or not, and completing it then renamed the link into place. Now the upload replaces anything at the staging path and creates the file exclusively, and a chunk or completion that finds a symlink there fails with `Conflict` instead of writing or reading through it.
- 21c88cc: `files-sdk/fs` now rejects keys that end in `/`, or in a `.` or `..` segment, with `Invalid`. Path resolution used to drop the trailing part, so `upload("dir/")` wrote a file named `dir` and `delete("a.txt/")` deleted `a.txt`. A `delete("dir/")` that used to be a silent no-op now throws `Invalid` too.
- 21c88cc: `files-sdk/ftp` now runs calls on an injected `client` one at a time. An FTP connection can only run one command at a time, and `basic-ftp` closes the whole connection when a second command starts, so concurrent calls on a shared client (`Promise.all`, or the bulk forms of `upload`, `download`, and `head`) used to fail and leave the client closed for good. Calls now queue on the client, a failed call no longer blocks the ones behind it, and a streamed download holds the client until the stream is read to the end or cancelled.
- 21c88cc: `files-sdk/ftp` and `files-sdk/sftp` `move()` now overwrite an existing destination like every other adapter. A plain rename failed whenever the destination existed: OpenSSH and most SFTP servers answered with a generic failure that was retried and then thrown as `Provider`, and FTP servers that refuse to rename over a file, such as IIS, reported a misleading `NotFound`. When the server refuses, the destination is now deleted and the rename retried (so the key is briefly absent), except for a case-only rename, which could otherwise delete the source on a case-insensitive server.
- 21c88cc: `files-sdk/webdav` streamed downloads (`as: "stream"`) no longer report `size: 0` when the server sends a chunked response without a `Content-Length` header. The adapter now takes the size from a `PROPFIND`, narrowed to the requested slice for a ranged read.
- 21c88cc: `files-sdk/zip` `unzip()` now decodes entry names the way the archive declares them. Names were always read as UTF-8 with invalid bytes replaced, so archives from Windows Explorer and other tools that write code page 437 produced garbled keys, and two such entries could collapse into one name and fail as duplicates. Names flagged UTF-8, or carried in an Info-ZIP Unicode Path field, are now read as strict UTF-8 and fail closed on invalid bytes. Unflagged names are read as UTF-8 when they are valid UTF-8 and as code page 437 otherwise.
