Skip to content

Storage ​

@basaltkit/storage gives every backend one API — a Disk with put/get/exists/delete/list and signed temporaryUrls — and scopes every path by tenant automatically. The local-filesystem driver ships in the core; every cloud backend — S3, Google Cloud Storage, Azure Blob — is a drop-in driver package, so you install only the SDK you actually use.

Setup ​

storagePlugin registers a Storage under the STORAGE token. Declare one or more named disks; start with the local driver, which only needs a folder:

ts
import { createApp } from '@basaltkit/core'
import { storagePlugin, STORAGE } from '@basaltkit/storage'

const app = await createApp({
  plugins: [
    storagePlugin({
      default: 'uploads',
      disks: {
        uploads: { driver: 'local', root: './storage' },
      },
    }),
  ],
}).boot()

const disk = app.container.get(STORAGE).disk()   // the default disk ('uploads')
await disk.put('avatars/1.png', buffer, { contentType: 'image/png' })

Each Disk prefixes paths with tenants/<id> from ctx().tenant — so the same code keeps every tenant's files isolated. Pass scope: null on a disk to turn that off.

Fails closed without a tenant. A disk with a scope refuses to run with no tenant in context and throws StorageTenantRequiredError (400 STORAGE_TENANT_REQUIRED). Without that, a request that simply omitted its tenant would resolve the caller's key against the bucket root, where tenants/<other-tenant>/… is reachable by name and list('') enumerates every tenant. It holds for the default scope when @basaltkit/tenancy is registered, for every custom scope that resolves nothing, and for a hand-built new Disk() — which cannot know whether tenancy exists. A deliberately central disk (backups, platform branding) says so explicitly: scope: null, or onMissingScope: 'root' for a disk that is tenant-scoped inside a tenant and central outside one. The one implicit root is a storagePlugin disk on the default scope in an app without tenancy — single-tenant apps configured through the plugin are unaffected.

A tenant id that is not one canonical path segment is refused with StorageInvalidScopeError rather than joined into the path: the default scope accepts lowercase ASCII letters, digits, -, _ and inner . — every id @basaltkit/tenancy's default grammar produces. .., a/b and control characters would escape the tenant's tree; Acme (or a decomposed é) would, on a case- or normalization-insensitive filesystem (macOS APFS, Windows NTFS), open the same directory as acme on the local driver while S3/GCS/Azure keep them apart. Refusing non-canonical ids keeps every driver identical. Apps whose tenant ids are case-sensitive (nanoid, ULID) map them to a canonical segment with a custom scope:

ts
const tenantSegment = () => {
  const id = tryCtx()?.tenant?.id
  return id ? `tenants/x${Buffer.from(id).toString('hex')}` : undefined
}
storagePlugin({ disks: { uploads: { driver: 'local', root: './storage', scope: tenantSegment } } })

put / get / exists / delete / list ​

put accepts a string or Buffer and creates intermediate folders; get always returns raw bytes as a Buffer:

ts
await disk.put('docs/read-me.txt', 'hello')
await disk.put('img/pixel.bin', Buffer.from([1, 2, 3]))
await disk.put('report.pdf', pdfBuffer, { contentType: 'application/pdf' }) // S3 sets Content-Type

const text = (await disk.get('docs/read-me.txt')).toString()  // Buffer → string

await disk.exists('docs/read-me.txt')  // true
await disk.delete('docs/read-me.txt')  // true (existed and was deleted)
await disk.delete('docs/read-me.txt')  // false (no longer existed)

await disk.list('docs')  // ['docs/read-me.txt', ...] — recursive, sorted
await disk.list()        // every file in the current scope

list returns keys relative to the disk's scope — the keys put/get take — so a listed key goes straight back into get(). Inside tenant acme, the object tenants/acme/docs/read-me.txt lists as docs/read-me.txt; only a central disk (scope: null) sees the tenants/… prefix, because there it is part of the key. A prefix is a directory on every driver: list('docs') means list('docs/') and never matches docs2/…, even on object stores whose own listing is a plain string match.

Upgrading from @basaltkit/storage 4.x

A tenant-scoped disk used to return tenants/<id>/docs/read-me.txt, which get() then prefixed a second time. Remove any code that stripped the prefix by hand, and do not rely on list('invoice') matching invoice-2026/… on a cloud driver — list the directory itself. Central disks return what they did.

get on a missing file throws StorageFileNotFoundError.

Validating keys & uploads ​

Object keys are validated on every operation across all drivers: a key with a leading slash, a .. segment, or control characters is rejected with StorageInvalidKeyError — so a user-supplied key can never escape its prefix or collide with another tenant's.

Keys must also be canonical: a . segment or an empty one (a/./b, ./a, a//b, a trailing /, '') is rejected too. The local driver resolves those to the same file as a/b, while S3, GCS and Azure keep them as distinct objects — so the same key would name one file on one backend and three on another. Basalt refuses rather than normalizes: silently rewriting a key would let two strings your app compares (an allow-list, a dedupe, an audit trail) address the same object. Build keys with [a, b].join('/') from non-empty parts. A list() prefix may be '' (the disk root) or end in one / (list('avatars/')).

Uploads are unrestricted by default at this layer (the higher-level @basaltkit/files pipeline caps uploads at 25 MiB even when you configure nothing); pass opt-in limits to put to cap size and constrain the content type (enforced at the facade, before any driver runs):

ts
await disk.put(key, buffer, {
  contentType: 'image/png',
  maxBytes: 5 * 1024 * 1024,                          // → StorageTooLargeError above 5 MiB
  allowedContentTypes: ['image/png', 'image/jpeg'],   // → StorageContentTypeError otherwise
})

Large files ​

put/get move the whole object through memory, which is the wrong shape for a 2 GB video, a CSV import or a database dump. Four optional driver capabilities cover that case. local, s3, azure and gcs implement all four; a driver that does not throws a clear STORAGE_*_UNSUPPORTED error, and disk.supports(capability) answers before you call.

ts
import { createWriteStream } from 'node:fs'
import { pipeline } from 'node:stream/promises'

// Upload without ever holding the body. Source: Node Readable, web
// ReadableStream, or any AsyncIterable<Uint8Array>.
await disk.putStream('imports/2026.csv', request.raw, {
  contentType: 'text/csv',
  contentLength: declaredSize,        // when the client sent a Content-Length
  maxBytes: 200 * 1024 * 1024,        // enforced WHILE it streams
})

// Download as a stream — consume it or destroy() it, never abandon it.
await pipeline(await disk.getStream('imports/2026.csv'), createWriteStream('/tmp/2026.csv'))

// Copy without the bytes leaving the backend.
await disk.copy('drafts/a.pdf', 'final/a.pdf')
await disk.copy('drafts/a.pdf', 'a.pdf', { disk: storage.disk('cold') })
// From a tenant-scoped disk, a central (scope: null) destination may not be
// inside tenants/ — that would be some tenant's tree: StorageCrossTenantCopyError.

// Metadata without a download.
const { size, contentType, etag, lastModified } = await disk.stat('final/a.pdf')

Same safety rules as put: the key is validated and tenant-prefixed (failing closed without a tenant), allowedContentTypes is checked before a single byte is read, and past maxBytes the upload is aborted with StorageTooLargeError while the source is destroyed (Node Readable) or cancelled (web ReadableStream) — nothing beyond the cap is ever read.

CapabilityS3AzureGCSLocal
putStreamPutObject with contentLength; multipart without ituploadStream (any length)createWriteStream (any length)fs write stream
getStreamGetObject bodydownload()createReadStreamfs read stream
copyCopyObjectsyncCopyFromURL (≤ 256 MiB)file.copy()fs.copyFile
statHeadObjectgetProperties()getMetadata()fs.stat (size + mtime only)

contentLength is held to its word. A value that is not a non-negative safe integer is refused before anything is read (400 STORAGE_CONTENT_LENGTH_INVALID); a body that carries more or fewer bytes fails with 400 STORAGE_CONTENT_LENGTH_MISMATCH — always before its end reaches the driver. S3, GCS and Azure commit an upload only when the body ends, so nothing is stored, and local removes its partial file.

S3 and the body length

PutObject cannot send a body of unknown size. putStream streams straight through when you pass contentLength. Without it, the driver uploads the body multipart — any size, with only partSizeBytes × queueSize (20 MiB by default) in memory, even when maxBytes is set: the cap is enforced mid-stream, it never becomes a buffer — provided the optional peer @aws-sdk/lib-storage is installed:

bash
pnpm add @aws-sdk/lib-storage

It is loaded lazily, only on that path. Without it an unknown length can only be one buffered PutObject: with maxBytes, up to that cap in memory; with neither option, StorageStreamLengthRequiredError (400 STORAGE_STREAM_LENGTH_REQUIRED), naming the package that would allow it. Tune the upload with the partSizeBytes (default 5 MiB, S3's minimum) and queueSize (default 4) disk options. A failed part — the maxBytes cap included — aborts the upload and destroys the source, so no orphan parts are left; add an AbortIncompleteMultipartUpload lifecycle rule to the bucket as the belt-and-braces, since incomplete parts are invisible in listings and billed until removed. Azure and GCS chunk unknown-length streams natively, in bounded memory.

copy falls back when a server-side copy is impossible — a different driver, or one without copy: first getStream → putStream, then get → put. Both fallbacks move the bytes through this process, so pass { requireServerSide: true } where a quiet download-and-re-upload of a huge object would be a bug (CopyUnsupportedError). A failed streaming upload can leave a partial object on backends that cannot roll one back; delete the key when that matters (@basaltkit/files already does).

Multiple named disks ​

Declare as many disks as you like — e.g. public uploads on one backend, invoices on another — and pick one by name:

ts
storagePlugin({
  default: 'uploads',
  disks: {
    uploads:  { driver: 'local', root: './storage/uploads' },
    invoices: s3Disk({ bucket: 'company-invoices', region: 'eu-west-1' }),
  },
})

const storage = app.container.get(STORAGE)
await storage.disk().put('avatar.png', image)              // default disk
await storage.disk('invoices').put('2026/01.pdf', invoice) // by name

storage.disk('unknown') throws UnknownDiskError.

Drivers ​

The backend is chosen per disk. local is the only string — it needs no client library, just fs. Every cloud driver arrives as an instance from its own package, with the SDK as a peer dependency you install:

ts
import { s3Disk } from '@basaltkit/storage-s3'
import { GcsStorageDriver } from '@basaltkit/storage-gcs'
import { AzureBlobStorageDriver } from '@basaltkit/storage-azure'

storagePlugin({
  disks: {
    uploads: { driver: 'local', root: './storage' },
    s3:      s3Disk({ bucket: 'my-bucket', region: 'eu-west-1' }),
    gcs:   { driver: new GcsStorageDriver({ bucket: 'my-bucket', projectId: 'my-project' }) },
    azure: { driver: new AzureBlobStorageDriver({ container: 'uploads', connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING }) },
  },
})
DriverPackageNotes
Local@basaltkit/storageFilesystem — dev and single-node. No temporaryUrl
S3@basaltkit/storage-s3AWS S3, MinIO, Cloudflare R2 (peers: @aws-sdk/client-s3, @aws-sdk/s3-request-presigner)
GCS@basaltkit/storage-gcsGoogle Cloud Storage (peer: @google-cloud/storage)
Azure Blob@basaltkit/storage-azureAzure Blob (SAS signed URLs; peer: @azure/storage-blob)

S3, MinIO and Cloudflare R2 ​

bash
pnpm add @basaltkit/storage-s3 @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

s3Disk() talks to any S3-compatible service. For AWS, bucket (and usually region) is enough — credentials come from the standard AWS chain. For MinIO or R2, set an endpoint:

ts
import { s3Disk } from '@basaltkit/storage-s3'
ts
storagePlugin({
  disks: {
    uploads: s3Disk({
      bucket: 'my-app',
      region: 'eu-west-1',
      endpoint: 'http://localhost:9000',          // MinIO / R2 — forcePathStyle becomes true automatically
      credentials: { accessKeyId: '…', secretAccessKey: '…' }, // omit to use the AWS environment
    }),
  },
})

Every disk option (scope, onMissingScope, maxTemporaryUrlTtl, maxTemporaryUploadUrlTtl) can be passed to s3Disk() next to the driver options — it splits them and forwards each to the right place.

Encryption at rest. The simplest setup is the bucket's own default encryption (AWS already encrypts new objects with SSE-S3 by default; set a bucket default KMS key if you need one) — nothing to configure here. When the app must pin it, set serverSideEncryption and the driver sends it on every put and signs it into every pre-signed upload:

ts
s3Disk({ bucket: 'docs', serverSideEncryption: 'AES256' })                        // SSE-S3
s3Disk({ bucket: 'docs', serverSideEncryption: { kms: 'alias/docs-key' } })        // SSE-KMS

Signed URLs ​

Hand a client a time-limited URL straight to the object, no proxying:

ts
const url = await disk.temporaryUrl('reports/q1.pdf', '15m')
// top-level rendering (e.g. a PDF preview tab) is a deliberate opt-in:
const preview = await disk.temporaryUrl('reports/q1.pdf', '15m', { disposition: 'inline' })

Signed URLs serve Content-Disposition: attachment by default — an uploaded HTML or SVG file downloads instead of rendering on the storage/CDN origin (a stored-XSS vector when that origin shares cookies with your app). Embedded uses (<img>, <video>) render regardless of disposition, so avatars and previews inside pages keep working.

The expiry accepts a duration string ('500ms', '30s', '15m', '2h', '7d') or milliseconds. It is capped at 7 days by default (the S3 and GCS signature limit, now enforced for every driver, Azure included): a signed URL is a bearer credential that outlives the holder's membership, so a longer (or non-positive) lifetime throws TemporaryUrlTtlTooLongError (400 STORAGE_TEMPORARY_URL_TTL). Lower the cap per disk with maxTemporaryUrlTtl. Supported by s3, GCS and Azure; the local driver throws TemporaryUrlUnsupportedError (serve local files through a route in dev, or run MinIO locally with an s3 disk).

Direct browser uploads ​

For large files, let the browser PUT straight to the bucket instead of streaming through your server. The server mints a short-lived, pre-signed upload URL bound to the exact content type (and size / checksum when given):

ts
import { randomUUID } from 'node:crypto'

const ALLOWED = { 'image/png': 'png', 'image/jpeg': 'jpg', 'application/pdf': 'pdf' } as const

// POST /uploads — the client says what it wants to upload; the server decides where.
const { contentType, size } = req.body            // validate with your schema first
const upload = await disk.temporaryUploadUrl(`uploads/${randomUUID()}.${ALLOWED[contentType]}`, {
  expiresIn: '5m',
  contentType,                                    // required, always signed
  contentLength: size,                            // signed: any other size is rejected
  maxBytes: 20 * 1024 * 1024,                     // → StorageTooLargeError above 20 MiB
  allowedContentTypes: Object.keys(ALLOWED),      // → StorageContentTypeError otherwise
})
return { url: upload.url, method: upload.method, headers: upload.headers, key: upload.key }
ts
// Browser
const res = await fetch(url, { method, headers, body: file })   // send `headers` verbatim
if (!res.ok) throw new Error('upload failed')
await fetch('/uploads/complete', { method: 'POST', body: JSON.stringify({ key }) })

temporaryUploadUrl follows the same safety rules as temporaryUrl: the key is validated, tenant-prefixed (and fails closed without a tenant), and the lifetime is capped — by maxTemporaryUploadUrlTtl, which defaults to 1 hour (or maxTemporaryUrlTtl when that is lower). It returns { url, method: 'PUT', headers, expiresAt, key }; key is the full object key, tenant prefix included.

DriverBinds contentTypeBinds contentLengthchecksumSha256
S3signed headersigned headersigned; S3 verifies the body
GCSsigned (V4)signed x-goog-content-length-rangerefused (STORAGE_UPLOAD_URL_UNSUPPORTED)
Azurenot enforceable (sent as a header)not enforceablerefused (STORAGE_UPLOAD_URL_UNSUPPORTED)
Local— the driver throws TemporaryUploadUrlUnsupportedError (STORAGE_UPLOAD_URL_UNSUPPORTED)

On S3 the SSE headers from serverSideEncryption are signed too, so the client cannot skip encryption. Azure uses a create/write-only SAS, which cannot bind request headers.

Security checklist

  • Generate the key on the server (e.g. a UUID) — never accept a path from the client.
  • Always bind contentType (required) and contentLength — without a length the client can upload any size. Keep an allowlist of types.
  • Keep the TTL short — minutes. The URL is a write credential for that key until it expires; anyone who has it can upload.
  • Tenant-prefix the key — the default disk scope does this; don't turn it off for user uploads.
  • Treat the uploaded object as untrusted until a "complete" step checks it (it exists, it is the size/type you expect — mandatory on Azure, where nothing is bound). Serve it with temporaryUrl (attachment by default), never inline.
  • Configure bucket CORS to allow PUT from your app's origin with the returned headers, and nothing wider.

Signing for another endpoint ​

Sometimes the process that will use the URL reaches the bucket under a different host than the API does: an isolated ingest worker on http://minio:9000 inside the container network, a public CDN alias in front of S3. Sign for that host with a per-call endpoint, or a per-disk default:

ts
await disk.temporaryUploadUrl(key, { expiresIn: '5m', contentType, endpoint: 'http://minio:9000' })
await disk.temporaryUrl(key, '15m', { endpoint: 'https://files.example.com' })

// default for every URL this disk signs (a per-call endpoint still wins)
s3Disk({ bucket: 'uploads', endpoint: 'http://minio:9000', signingEndpoint: 'https://files.example.com' })

Only the signed host changes — region, path style, credentials and SSE stay as configured, and the bound Content-Type / Content-Length / checksum headers are unchanged. S3 only: Azure derives a SAS from the blob client's account host and GCS binds V4 signatures to the bucket host, so both refuse an override with STORAGE_TEMPORARY_URL_UNSUPPORTED / STORAGE_UPLOAD_URL_UNSUPPORTED rather than mint a URL for the wrong host.

A deployment value, never client input

The endpoint must be another name for the same bucket. A signature minted for a host you do not control is a credential handed to that host, so never build it from a request. It is validated (absolute http(s), no credentials, no query or fragment) — anything else throws StorageSigningEndpointInvalidError (400 STORAGE_SIGNING_ENDPOINT_INVALID).

@basaltkit/files builds an upload pipeline on top of this (validation, quota, metadata) — see the File uploads guide.

Image pipeline ​

Every disk exposes a fluent image pipeline when storagePlugin is given an imageProcessor (from @basaltkit/image-sharp — kept out of the core so apps that never process images carry no native dependency):

ts
import { SharpImageProcessor } from '@basaltkit/image-sharp'

storagePlugin({ disks: { /* … */ }, imageProcessor: new SharpImageProcessor() })

await disk.image('avatar.png').resize(256, 256).webp().save('avatar.webp')

Without a processor, the pipeline's terminal throws ImageProcessingUnavailableError.

Options reference ​

storagePlugin(options) ​

OptionTypeDefaultWhy
disksRecord<string, DiskConfig>— (required)The named disks; each picks a driver
defaultstringfirst declared diskDisk returned by storage.disk() with no argument
imageProcessorImageProcessornoneEngine behind disk.image(…) — pass SharpImageProcessor from @basaltkit/image-sharp

DiskConfig (per disk) ​

OptionTypeDefaultWhy
driver'local' | 's3' | StorageDriver— (required)'local' needs root; 's3' takes the S3 options; an instance plugs in GCS/Azure/custom
scope(() => string | undefined) | nulltenants/<ctx().tenant.id>Dynamic path prefix resolved on every operation — automatic tenant isolation. null disables it
onMissingScope'root' | 'error''error' for every scoped disk; 'root' only for a storagePlugin disk on the default scope in an app without tenancyWhat an operation does when no tenant is in context: 'error' throws StorageTenantRequiredError, 'root' uses the key against the disk root. An explicit value always wins
maxTemporaryUrlTtlDurationInput'7d'Longest lifetime temporaryUrl accepts; above it throws TemporaryUrlTtlTooLongError
maxTemporaryUploadUrlTtlDurationInput'1h' (or maxTemporaryUrlTtl if lower)Longest lifetime temporaryUploadUrl accepts; above it throws TemporaryUrlTtlTooLongError

PutOptions (per put) ​

OptionTypeDefaultWhy
contentTypestringnoneStored/served content type (S3 sets Content-Type)
maxBytesnumberuncappedFacade-enforced size cap — rejects with STORAGE_TOO_LARGE before any driver runs
allowedContentTypesreadonly string[]anyFacade-enforced allowlist — a missing or unlisted contentType rejects with STORAGE_CONTENT_TYPE

PutStreamInput (per putStream) ​

Everything from PutOptions (maxBytes, allowedContentTypes) plus:

OptionTypeDefaultWhy
contentTypestring— (required)A stream has no bytes to fall back on, so the type is declared up front and checked against allowedContentTypes before any byte is read
contentLengthnumbernoneExact body size when known — S3 streams it into one PutObject. Validated up front and verified against the body (STORAGE_CONTENT_LENGTH_INVALID / _MISMATCH, 400)

CopyOptions (per copy) ​

OptionTypeDefaultWhy
diskDiskthe source diskDestination disk; the key is scoped against that disk
contentTypestringthe source'sContent type for the destination object
maxBytesnumberuncappedCap for a fallback copy — the only one whose bytes pass through this process
requireServerSidebooleanfalseThrow CopyUnsupportedError instead of falling back to a download-and-re-upload

StorageStat (returned by stat) ​

FieldTypeNotes
sizenumberBytes
contentTypestring | undefinedNot reported by local
etagstring | undefinedAs the backend returns it; not reported by local
lastModifiedDate | undefinedmtime on local

TemporaryUrlOptions (per temporaryUrl) ​

OptionTypeDefaultWhy
disposition'attachment' | 'inline''attachment'Fail-closed against uploaded HTML/SVG rendering top-level on the storage/CDN origin (stored XSS). Opt into 'inline' only when top-level rendering is deliberate
endpointstringthe driver's ownSign for another host of the same bucket (S3 only; Azure/GCS refuse it). See Signing for another endpoint

TemporaryUploadUrlOptions (per temporaryUploadUrl) ​

OptionTypeDefaultWhy
expiresInDurationInput— (required)URL lifetime, capped by maxTemporaryUploadUrlTtl
contentTypestring— (required)The only content type the upload may declare — signed into the URL (S3, GCS)
contentLengthnumbernoneExact body size in bytes — signed (S3, GCS). Omit it and any size is accepted
checksumSha256string (base64)noneSHA-256 of the body — signed and verified by S3; refused by GCS and Azure
maxBytesnumberuncappedFacade-enforced cap on the declared contentLength (which becomes mandatory)
allowedContentTypesreadonly string[]anyFacade-enforced allowlist for contentType
endpointstringthe driver's ownSign for another host of the same bucket — a deployment value, never client input (S3 only)

s3Disk / S3StorageDriver options ​

OptionTypeDefaultWhy
bucketstring— (required)Target bucket
regionstring'us-east-1'AWS region
endpointstringAWSMinIO / R2 / any S3-compatible endpoint
credentials{ accessKeyId, secretAccessKey }AWS credential chainStatic credentials
forcePathStylebooleantrue when endpoint is setPath-style URLs (MinIO)
serverSideEncryption'AES256' | { kms: string }none (bucket default applies)SSE sent on every put and signed into every pre-signed upload
signingEndpointstringendpointDefault host pre-signed URLs are signed for, when it differs from the one this process talks to. A per-call endpoint wins

The disposition default is honoured by all three signing drivers — S3 (ResponseContentDisposition), GCS (responseDisposition) and Azure (SAS contentDisposition).

Failure modes & troubleshooting ​

ClassCodeWhen
StorageFileNotFoundErrorSTORAGE_FILE_NOT_FOUNDget on a file that doesn't exist
StorageInvalidKeyErrorSTORAGE_INVALID_KEYThe key starts with //\\, contains a .., . or empty segment (a//b, a trailing /, '') or control characters — the facade choke point rejects it on every operation, for every driver, before the tenant prefix is applied
StorageInvalidPathErrorSTORAGE_INVALID_PATHA path escapes the disk root — the local driver's own second line of defence
StorageTooLargeErrorSTORAGE_TOO_LARGEput (or temporaryUploadUrl) with maxBytes set and a larger payload / declared length
StorageContentTypeErrorSTORAGE_CONTENT_TYPEput (or temporaryUploadUrl) with allowedContentTypes set and a missing/unlisted content type
UnknownDiskErrorSTORAGE_UNKNOWN_DISKdisk('name') for a disk that isn't declared
TemporaryUrlUnsupportedErrorSTORAGE_TEMPORARY_URL_UNSUPPORTEDtemporaryUrl on a driver without support (e.g. local)
TemporaryUrlTtlTooLongErrorSTORAGE_TEMPORARY_URL_TTL (400)temporaryUrl with a lifetime ≤ 0 or above maxTemporaryUrlTtl (default 7 days); temporaryUploadUrl above maxTemporaryUploadUrlTtl (default 1 hour)
TemporaryUploadUrlUnsupportedErrorSTORAGE_UPLOAD_URL_UNSUPPORTEDtemporaryUploadUrl on a driver without support (e.g. local), or an option the backend cannot bind (checksumSha256 on GCS/Azure)
StorageUploadUrlInvalidErrorSTORAGE_UPLOAD_URL_INVALID (400)temporaryUploadUrl with a missing/malformed contentType, a non-integer contentLength, a malformed checksumSha256, or maxBytes without contentLength
StorageTenantRequiredErrorSTORAGE_TENANT_REQUIRED (400)A scoped disk ran with no tenant in context (the default scope under tenancy, any custom scope, any hand-built new Disk()) — resolve a tenant, or give a central disk scope: null / onMissingScope: 'root'
StorageInvalidScopeErrorSTORAGE_INVALID_SCOPEThe tenant id (or a custom scope) is not a safe path prefix (.., a / inside the id, control characters) — or, with the default scope, not canonical (uppercase, non-ASCII, a trailing .)
StorageCrossTenantCopyErrorSTORAGE_CROSS_TENANT_COPY (403)copy() from a tenant-scoped disk to a central (scope: null) disk with a destination inside tenants/
ImageProcessingUnavailableErrorSTORAGE_IMAGE_UNAVAILABLEdisk.image(…) terminal with no imageProcessor configured
PutStreamUnsupportedErrorSTORAGE_PUT_STREAM_UNSUPPORTEDputStream on a driver without the capability — check disk.supports('putStream') first
GetStreamUnsupportedErrorSTORAGE_GET_STREAM_UNSUPPORTEDgetStream on a driver without the capability
CopyUnsupportedErrorSTORAGE_COPY_UNSUPPORTEDcopy({ requireServerSide: true }) with no server-side copy available (a different driver, or one without copy)
StatUnsupportedErrorSTORAGE_STAT_UNSUPPORTEDstat on a driver without the capability
StorageContentLengthInvalidErrorSTORAGE_CONTENT_LENGTH_INVALID (400)putStream with a contentLength that is negative, fractional, not finite or past MAX_SAFE_INTEGER — nothing is read
StorageContentLengthMismatchErrorSTORAGE_CONTENT_LENGTH_MISMATCH (400)The putStream body carried more or fewer bytes than its contentLength — nothing is committed
StorageStreamLengthRequiredErrorSTORAGE_STREAM_LENGTH_REQUIRED (400)putStream on S3 with neither contentLength nor maxBytes, and without the optional @aws-sdk/lib-storage peer that enables multipart
StorageSigningEndpointInvalidErrorSTORAGE_SIGNING_ENDPOINT_INVALID (400)An endpoint override that is not an absolute http(s) URL, or carries credentials, a query string or a fragment

All extend BasaltError and carry the code above.

Writing a driver ​

A driver implements the StorageDriver contract — six required methods, plus the optional capabilities it can honour:

ts
import {
  StorageFileNotFoundError,
  type PutOptions,
  type StorageDriver,
  type TemporaryUploadUrl,
  type TemporaryUploadUrlDriverOptions,
} from '@basaltkit/storage'

export class MyStorageDriver implements StorageDriver {
  readonly name = 'my-backend'
  async put(path: string, content: Buffer | string, options?: PutOptions): Promise<void> { /* … */ }
  async get(path: string): Promise<Buffer> { /* throw StorageFileNotFoundError on miss */ throw 0 }
  async exists(path: string): Promise<boolean> { /* … */ return false }
  async delete(path: string): Promise<boolean> { /* returns whether it existed */ return false }
  async list(prefix: string): Promise<string[]> { /* full keys starting with prefix — the Disk strips the scope */ return [] }
  async temporaryUrl(path: string, expiresInMs: number): Promise<string> { /* optional */ throw 0 }
  // optional: pre-signed PUT — bind options.contentType (+ length/checksum) and return the headers to send
  async temporaryUploadUrl(path: string, expiresInMs: number, options: TemporaryUploadUrlDriverOptions): Promise<TemporaryUploadUrl> { throw 0 }
  // optional: large-object capabilities. `source` is ONE Node Readable that the
  // Disk layer already normalized and capped at options.maxBytes.
  async putStream(path: string, source: Readable, options: PutStreamOptions): Promise<void> { /* … */ }
  async getStream(path: string): Promise<Readable> { /* throw StorageFileNotFoundError on miss */ throw 0 }
  async copy(from: string, to: string, options?: CopyDriverOptions): Promise<void> { /* … */ }
  async stat(path: string): Promise<StorageStat> { /* … */ throw 0 }
  async disconnect(): Promise<void> {}
}

Leave out what your backend cannot do: Disk reports the gap as the matching STORAGE_*_UNSUPPORTED error and disk.supports(...) returns false. One rule is not optional: a driver that cannot honour a TemporaryUrlOptions.endpoint override must throw rather than ignore it — a URL signed for the wrong host is a silently broken one.

Then plug it in as an instance: disks: { d: { driver: new MyStorageDriver() } }. The bundled cloud drivers (@basaltkit/storage-gcs, -azure) take an injectable client, so their logic is unit-tested with a fake — no cloud account. Do the same and your driver is testable in CI.

Released under the MIT License.