---
title: Capabilities
description: Query what an adapter can do up front with files.capabilities - including range reads, signed URLs, server-side copy, and native conditional operations.
related:
  - /guides/unified-storage-api-typescript
  - /guides/test-s3-code-without-aws
  - /guides/client-side-encryption-s3-r2
---

The unified surface is the common subset every adapter implements, but adapters differ at the edges: some honor byte-range reads, some can mint a signed URL, some copy server-side. The wrapper already gates on these per-adapter — pass a `range` to an adapter that has no range primitive and it throws _before_ any provider call. `files.capabilities` turns that implicit knowledge into a queryable surface, so you can branch up front instead of discovering a limit by catching an error.

```ts
const files = new Files({ adapter: r2({ bucket: "uploads" }) });

if (files.capabilities.rangeRead) {
  // Safe to stream a byte range — the adapter has a range primitive.
  const clip = await files.download(key, { range: { start: 0, end: 1023 } });
}

if (files.capabilities.signedUrl.supported) {
  return files.url(key, { expiresIn: 600 });
}
// No signing primitive — stream the bytes through the SDK instead.
return files.download(key);
```

## The shape

```ts
interface AdapterCapabilities {
  rangeRead: boolean;
  uploadProgress: boolean;
  delimiter: "any" | "slash" | false;
  metadata: boolean;
  publicUrl: boolean;
  cacheControl: boolean;
  resumable: boolean;
  serverSideCopy: boolean;
  events: { format: EventFormat } | false;
  signedUrl: {
    supported: boolean;
    expiry: "exact" | "provider" | "none";
    disposition: boolean;
    maxExpiresIn?: number;
  };
  signedUpload: {
    supported: boolean;
    maxSize: boolean;
    contentType: boolean;
    maxExpiresIn?: number;
  };
  conditional: {
    create: boolean;
    replace: boolean;
    exactRead: boolean;
    delete: boolean;
    copy: {
      sourceEtag: boolean;
      destinationCreate: boolean;
      destinationReplace: boolean;
      atomicSourceDestination: boolean;
    };
    multipart: { create: false; replace: false };
  };
}
```

| Field | `true` means | Maps to |
| --- | --- | --- |
| `rangeRead` | `download({ range })` returns only the requested bytes | `download` |
| `uploadProgress` | `upload({ onProgress })` reports byte-level progress natively | `upload` |
| `delimiter` | `list({ delimiter })` returns S3-style common prefixes, for any delimiter (`"any"`) or only `"/"` (`"slash"`) | `list` |
| `metadata` | `upload({ metadata })` persists arbitrary user metadata | `upload` |
| `cacheControl` | `upload({ cacheControl })` stores a `Cache-Control` header | `upload` |
| `resumable` | the adapter exposes a pause-able, resumable upload primitive | `upload({ control })` |
| `serverSideCopy` | `copy()` runs server-side, with no body re-transfer through your process | `copy` |
| `publicUrl` | a plain `url(key)` returns a permanent link (a `publicBaseUrl` CDN, a public bucket or blob, a public share link) | `url` |
| `events` | the provider sends bucket notifications in this format (`"s3"`, `"r2"`, `"gcs"`, …), for [`files-sdk/events`](/docs/events) to parse | `files.events` |
| `signedUrl` | `url()` can mint a signed or tokenized URL — see below | `url` |
| `signedUpload` | `signedUploadUrl()` hands out a direct upload target — see below | `signedUploadUrl` |
| `conditional` | the requested create, replace, exact-read, delete, or copy predicate has a provider-native primitive | `upload`, `download`, `delete`, `copy` |

Every field mirrors an operation the unified API actually has. There are deliberately no flags for `raw`-only territory such as object versioning, checksums, or POST policies — advertising a flag for a non-operation would turn the matrix into a back-door spec, where a wrong flag is worse than no flag.

### `conditional`

Conditional capabilities are granular because providers often support only part of the surface. `create`, `replace`, `exactRead`, and `delete` map to their native single-object primitives. Conditional copy separately declares source ETag checks, destination create/replace checks, and whether the selected source plus destination predicates are evaluated atomically in one request. Invocation rechecks the declaration and fails before provider I/O when any required part is absent. These flags start from native adapter support, and every bundled plugin that vetoes a conditional mode (because its own policy cannot preserve that atomic boundary) also clears the matching flag - see below. A third-party plugin can still veto a mode without narrowing it.

There is no probe-then-mutate fallback. See [Conditional operations](/docs/conditional-operations) for the API, ETag rules, provider matrix, and retry/observer semantics.

### `signedUrl`

```ts
signedUrl: {
  supported: boolean;
  expiry: "exact" | "provider" | "none";
  disposition: boolean;
  maxExpiresIn?: number;
}
```

`supported` is `true` when `url()` mints a signed or tokenized download URL that grants access without the caller's own credentials and is more than a permanent public link — an S3 SigV4 URL, an Azure SAS, a GCS signed URL, a Box or PocketBase access-token URL. It's `false` when the adapter has no signing primitive: it returns only a permanent public URL (Vercel Blob in public mode, UploadThing `public-read`, Appwrite, Convex, OneDrive / Google Drive / SharePoint in public-link mode) or throws because it can't mint one at all (FTP/SFTP/WebDAV without `publicBaseUrl`, Netlify Blobs, OneDrive / Google Drive outside their public-link mode). The local filesystem returns an unsigned `file://` or static-server URL. When `false`, prefer `download()`.

`maxExpiresIn` is set only when the adapter enforces a hard ceiling on `expiresIn` **in code**, throwing above it. Dropbox temporary links cap at 4 hours (14400s). The S3 family — `s3()` and its S3-compatible wrappers, R2 over HTTP or in hybrid mode, the `fetch` engine, and Bun's S3 — caps presigned URLs at SigV4's 604800 seconds (7 days). Google Cloud Storage and Firebase Storage declare the same 7-day V4 signing cap, and so does UploadThing with `acl: "private"`. Azure declares its 7-day cap only in user-delegation mode, since account-key SAS has no such limit. It stays unset where the limit isn't checked in code; those cases live in [Provider gaps](/docs/provider-gaps), not here. `expiry` says what `expiresIn` does on this instance: `"exact"` when the URL expires after exactly that many seconds, `"provider"` when the URL is tokenized but the provider sets its lifetime (Box, PocketBase, Dropbox's 4-hour links), and `"none"` when `url()` only returns permanent links (it's always `"none"` when `supported` is `false`, and then an explicit `expiresIn` throws `Unsupported`). `disposition` is `true` when a `responseContentDisposition` is bound into the signed URL; when it's `false`, passing one throws, so the gateway proxies downloads it must force `attachment` on.

### `publicUrl`

`true` when a plain `url(key)`, with no `expiresIn` and no `responseContentDisposition`, returns a permanent link on this instance: a configured `publicBaseUrl`, Vercel Blob in public mode, UploadThing's `public-read`, Supabase or Appwrite with `public: true`, a public share link (`publicByDefault` on Box, Dropbox, OneDrive, Google Drive), Convex serving URLs, or the filesystem's `urlBaseUrl` URL (a `file://` link doesn't count). It's independent of `signedUrl`: the S3 family with a `publicBaseUrl` has both, returning the CDN link for a plain `url(key)` and a signed one when you pass `expiresIn`.

### `events`

The bucket-notification format this instance's provider sends, or `false` when it sends none that `files-sdk/events` can read. [`events()`](/docs/plugins/events) reads it to parse deliveries. It's declared only where the provider is verified to send that format for the configuration: `s3()` against AWS reads `"s3"`, but `s3()` with a non-AWS `endpoint` reads `false` (an explicit `amazonaws.com` endpoint, or an `AWS_ENDPOINT_URL_S3` / `AWS_ENDPOINT_URL` pointing at AWS, still counts as AWS), since an S3-compatible service may not send S3's shape. MinIO, RustFS, Wasabi, and Storj, which do, declare it themselves. See [Events](/docs/events#supported-providers) for every format.

### `signedUpload`

```ts
signedUpload: {
  supported: boolean;
  maxSize: boolean;
  contentType: boolean;
  maxExpiresIn?: number;
}
```

`supported` is `true` when `signedUploadUrl()` returns a presigned target a client can upload to directly. `maxSize` is `true` when a `maxSize` is enforced server-side (an S3 POST policy, a Vercel Blob size limit); when it's `false`, passing `maxSize` throws rather than minting an unbounded URL. `contentType` is `true` when a `contentType` is bound into the signature; when it's `false`, passing one throws. The [gateway](/docs/ui/server/gateway) reads this to decide between a direct upload and its proxy path: it presigns only when the adapter can bind the configured `maxUploadSize` and the file's content type, and proxies the upload through the instance otherwise.

## How it's derived

Each adapter declares what it can do as a single `capabilities` object, per instance — so a constructor option that changes what it can do (a `publicBaseUrl`, an access mode, the `fetch` client) is reflected. `files.capabilities` reads that declaration live on every call, filling in the conservative value (`false`, or unsupported) for anything the adapter leaves out, so an adapter that says nothing never reads as a wrong "yes". `resumable` and `conditional` are derived from the methods that implement them (`resumableUpload`, and the adapter's native conditional operation group with its explicit copy guarantees). The wrapper gates on this same snapshot, so it can't drift from runtime behavior.

Capabilities also reflect the instance's installed [plugins](/docs/plugins). After reading the adapter, the getter folds each plugin's [`capabilities` hook](/docs/plugins/api#capabilities) over the snapshot in `plugins` order, and a plugin can only narrow it. [`encryption()`](/docs/plugins/encryption) and [`compression()`](/docs/plugins/compression) turn off `signedUrl`, `signedUpload`, `rangeRead`, and `resumable`, [`dedup()`](/docs/plugins/dedup) turns off `signedUrl`, `signedUpload`, and every `conditional` flag, [`validation()`](/docs/plugins/validation) and [`contentType()`](/docs/plugins/content-type) turn off `signedUpload`, [`tiering()`](/docs/plugins/tiering) and [`failover()`](/docs/plugins/failover) turn off every `conditional` flag, [`versioning()`](/docs/plugins/versioning) turns off every `conditional` flag except `exactRead`, and [`softDelete()`](/docs/plugins/soft-delete) turns off `conditional.delete`, because the plugin can't honor those paths end to end. So the same adapter can report different capabilities on two instances with different plugins.

The wrapper gates options on the narrowed snapshot before any plugin runs. Pass a `range` to an instance whose adapter has no range primitive, or whose `encryption()` plugin turns ranges off, and the call throws before a plugin can do any I/O, with an error naming whichever one refused.

## Declaring capabilities on a custom adapter

A custom adapter declares its capabilities as one object. Every field is optional and defaults to unsupported:

```ts
const myAdapter: Adapter = {
  name: "my-store",
  raw: client,
  capabilities: {
    rangeRead: true,
    delimiter: "slash",
    metadata: true,
    serverSideCopy: true,
    publicUrl: Boolean(publicBaseUrl),
    signedUrl: { supported: true, disposition: true, maxExpiresIn: 86_400 }, // expiry defaults to "exact"
    signedUpload: { supported: true, maxSize: true, contentType: true },
    events: { format: "s3" }, // only if the provider is verified to send S3 notifications
  },
  // upload, download, head, exists, delete, copy, list, url, signedUploadUrl…
};
```

Declare what the instance can actually do with its configuration. A `rangeRead`, `metadata`, `cacheControl`, or `delimiter` you declare is let through to your adapter; one you leave out is refused before your adapter is called. `serverSideCopy`, `signedUpload`, and `events` are descriptive: they don't gate the call, but callers, the gateway and `files-sdk/events` plan around them, so a wrong `true` sends them down a path that fails. `signedUrl.supported` gates `url(key, { expiresIn })`: leave it `false` and that call is refused with `Unsupported` before your adapter runs.
