Storage

File storage behind one StorageService — public objects served direct from the bucket/CDN, private objects gated by an access policy + per-object grants. S3 / R2 / GCS / MinIO / filesystem / memory providers, presigned URLs, a dashboard browser.

@voltro/plugin-storage is file storage behind one StorageService. Wire a provider in app.config.ts; consume it in handlers and actions via yield* StorageService. The bytes live in the provider (S3-compatible bucket, filesystem, or in-memory); metadata rows live in _voltro_storage_refs + _voltro_storage_grants (both auto-migrated on a SQL store).

Every object is public or private:

  • Public — cacheable + no auth. With cdnBaseUrl (or STORAGE_CDN_URL) set to a CDN / publicly-readable bucket endpoint, the serve route 302s straight there and the app never touches the read path (edge-cacheable). WITHOUT cdnBaseUrl, the serve route streams the bytes through the app from the (private) bucket with a 1-year immutable cache — still browser-cacheable, bucket stays private. A bare S3/MinIO bucket is private by default, so the framework never 302s to a raw bucket URL unless you've declared a public base via cdnBaseUrl (otherwise it would land on a 403).
  • Private (the default — secure by default) — gated by an access policy plus per-object grants; delivered via a presigned URL or a short-lived signed grant token.

Quick start

// app.config.ts
import { storagePlugin } from '@voltro/plugin-storage'

export default {
  type: 'api' as const,
  name: 'myApi',
  store: 'postgres' as const,
  plugins: [
    // dev: zero-config in-memory. prod: swap the provider (see below).
    storagePlugin({ provider: 'memory' }),
  ],
}
// actions/uploadAvatar.action.server.ts (+ matching .action.ts)
import { StorageService } from '@voltro/plugin-storage'
import { Effect } from 'effect'

export default (input: { bytes: Uint8Array; contentType: string }, ctx) =>
  Effect.gen(function* () {
    const storage = yield* StorageService
    const ref = yield* storage.put({
      bytes: input.bytes,
      contentType: input.contentType,
      visibility: 'public',                // public avatar
      ownerId: ctx.request.subject.id,
      key: `avatars/${ctx.request.subject.id}/avatar.png`,
    })
    // For a public object this is the CDN/bucket URL (no app round-trip).
    return { url: yield* storage.getUrl(ref.id) }
  })

The ref id is a file_… TypeID — URL-safe and unguessable.

Uploading files — useUpload() (client)

The one-liner. Works on every provider (filesystem/dev, s3/r2/prod), cross-origin, no base64:

import { useUpload } from '@voltro/web'   // re-exported from @voltro/client

function AvatarUpload() {
  const { upload, progress, status, cancel } = useUpload('myApi')
  return (
    <input type="file" onChange={async (e) => {
      const file = e.target.files?.[0]
      if (!file) return
      const asset = await upload(file, { visibility: 'public', folder: 'avatars' })
      // asset: { id, url, name, mime, size, width?, height? }
    }} />
  )
}

useUpload(apiName) returns { upload, uploadMany, progress (0..1), status, error, cancel, reset }. Under the hood it calls the authenticated storage.mintUploadTicket rpc (which runs your auth chain and returns a short-lived signed URL), then POSTs the file's raw bytes via XHR with real per-byte progress — binary, so a 50 MB video never bloats the rpc payload. The stored ref row appears live in any useSubscription over that table (normal reactivity). For an instant preview, pair with URL.createObjectURL(file) and swap to asset.url on completion.

Drag-drop + paste come for free. The handle also returns onDrop, onPaste, and a dropzoneProps you spread onto any element for a zero-boilerplate dropzone:

const { dropzoneProps, onPaste, uploadMany } = useUpload('myApi')

<div {...dropzoneProps}>Drop files here</div>              // drop → uploadMany
<textarea onPaste={(e) => onPaste(e, { folder: 'notes' })} />  // paste a screenshot

onDrop(event, opts?) / onPaste(event, opts?) pull the files off the event and call uploadMany (bounded concurrency); both resolve [] when the event carries no files. dropzoneProps also prevents the browser's default "navigate to the dropped file" behavior.

Server-side, the same path is the mintUploadTicket action + the POST /_voltro/storage/upload route (ticket-verified, no auth re-run). The storage.upload base64 action also exists for tiny files.

Transports — prefer

through-app (the default) works everywhere and needs nothing extra. Two opt-in transports handle large media:

useUpload('myApi').upload(file, { prefer: 'presign' })    // offload the upload leg
useUpload('myApi').upload(file, { prefer: 'resumable' })  // survive dropped connections
useUpload('myApi').upload(file, { prefer: 'multipart' })  // multi-GB, direct to bucket
  • presign — on a presigning provider (s3/minio) the browser PUTs straight to the bucket (offloading the client→server leg), then the server fetches the bytes back to scan + checksum + derive and register a non-degraded ref (storage.mintPresignedUpload → direct PUT → storage.finalizeUpload). On a non-presigning provider it transparently falls back to through-app. A virus hit fails closed (temp bytes deleted, nothing registered) — so presigned uploads are scanned, unlike a raw bucket PUT.
  • resumable — splits the file into chunks POSTed one at a time to /_voltro/storage/upload/resumable (storage.beginResumableUpload); each chunk is retried independently and is idempotent by index, so a dropped connection resumes instead of restarting. Works on every provider (chunks are stored, then assembled through the same scan pipeline). Tune with chunkSize.
  • multipart — for multi-GB media: each part is PUT directly to the bucket via a presigned URL (storage.beginMultipartUploadstorage.signMultipartPart per part → storage.completeMultipartUpload), so the bytes never touch the app — offloaded and resumable (a failed part is re-signed + re-sent). s3/minio only; falls back to resumable elsewhere. Needs the bucket CORS to expose the ETag header (voltro storage cors prints the rule). Tradeoff: completing registers the ref straight from the bucket object (size via HEAD, etag as checksum) — it does not fetch the bytes back, so a multipart upload is not scanned or derived inline (that would mean pulling GB through the app). Scan multi-GB out of band (a bucket-event job) and note checksum is the S3 etag, not a sha-256.

Media derivatives + rich metadata

put() (and every upload) auto-extracts, for raster images (via optional sharp, graceful when absent):

  • width / height (pixels) — the fields your app used to leave null.
  • placeholder — a tiny data:image/webp;base64,… LQIP (drops into <img src> / a CSS background; no decoder library).

Opt into storagePlugin({ normalizeImages: true }) to apply EXIF orientation and strip ALL metadata (GPS/camera), re-encoding — the normalized bytes are what get checksummed + stored.

Uploads also carry app metadata onto the ref — folder, tags, alt, caption — so the ref row is rich enough that apps no longer need a parallel assets table.

For video/audio, wire a videoProbe hook — the core bundles no transcoder (ffmpeg is a large binary + licensing), so you plug in ffprobe / a cloud API and it runs in put() for video/audio content types:

storagePlugin({
  videoProbe: async ({ bytes, contentType }) => {
    const { duration, width, height, posterDataUri } = await probeWithFfprobe(bytes)
    return { duration, width, height, poster: posterDataUri } // → ref.duration + ref.placeholder
  },
})

Best-effort: a null / throw / failed probe never blocks the upload. An explicit duration passed to put() wins over the probe.

Transcoding video — renditions

Produce playable variants (720p mp4, webm, a poster…) from an uploaded video. The first-party ffmpegTranscoder spawns the ffmpeg binary (no npm dep — like clamavScanner talks to a daemon), so install ffmpeg on the host (or pass ffmpegPath); absent it, transcoding is skipped and the upload still succeeds.

import { storagePlugin, ffmpegTranscoder } from '@voltro/plugin-storage'

storagePlugin({
  transcode: ffmpegTranscoder({
    renditions: [
      { kind: 'rendition', label: '720p', contentType: 'video/mp4', ext: 'mp4',
        args: ['-vf', 'scale=-2:720', '-c:v', 'libx264', '-crf', '23', '-c:a', 'aac', '-movflags', '+faststart'] },
      { kind: 'poster', label: 'poster', contentType: 'image/jpeg', ext: 'jpg',
        args: ['-frames:v', '1', '-vf', 'thumbnail'] },
    ],
  }),
})

Each rendition is stored as a normal storage ref linked to the original (derivedFrom = the parent id, kind = 'rendition'/'poster', caption = the label) — so renditions get serving, grants, transforms and GC for free, and inherit the original's tenant + visibility.

  • Runs out of band. Transcoding forks in the background after the upload returns (never blocks the client). It's ON by default once a transcode is configured — set autoTranscode: false to only run it explicitly.
  • storage.renditions(refId) — list the derivatives of an asset.
  • Bring your own transcode (a cloud video API, HLS packager) by implementing the Transcoder interface — ffmpegTranscoder is just one implementation.

Durability — the auto-fork is best-effort

The background fork is fire-and-forget: if the process crashes or is redeployed mid-transcode, that rendition is simply never produced (the original upload is unaffected). For long or business-critical transcodes, turn the auto-fork off and drive storage.transcode(refId) from a durable workflow — you get retries, backoff, and at-least-once execution across restarts:

// 1. Disable the fire-and-forget fork:
storagePlugin({ transcode: ffmpegTranscoder({ renditions }), autoTranscode: false })

// 2. Kick a durable workflow when a video ref is created (e.g. from the upload
//    mutation or a reactive hook), and transcode inside it — retried until it
//    succeeds, surviving restarts:
export const transcodeVideo = workflow('transcodeVideo', (refId: string) =>
  Effect.gen(function* () {
    const storage = yield* StorageService
    const renditions = yield* storage.transcode(refId)   // retried by the workflow engine
    return { count: renditions.length }
  }),
)

So: convenient by default (auto-fork), durable when you need it (workflow + the explicit primitive). The two coexist — same transcode() call underneath.

ffmpeg transcode (re-encoding) is opt-in as above. On-the-fly image resizing stays separate (query params on the serve URL, powered by sharp).

Binding an entity to an asset — assetRef()

import { table, id } from '@voltro/database'
import { assetRef } from '@voltro/plugin-storage'

const employees = table('employees', {
  id: id(),
  avatar: assetRef(),          // stores a _voltro_storage_refs id
})

assetRef() is a real FK to _voltro_storage_refs.id (nullable, onDelete: 'setNull' — deleting the blob clears the link) by default; pass { fk: false } for a plain typed id column, { nullable: false } to require it, or { onDelete: 'cascade' } to change the FK semantics. Resolve the linked asset with the storage service (head / mintUrl).

For the one-column shorthand there's a withStorage() mixin (applied via .with, the same convention as tenant() / audit()), which adds a single asset column:

import { withStorage } from '@voltro/plugin-storage'

const posts = table('posts', { id: id() }).with(withStorage())   // → posts.asset

Prefer assetRef() inline when you want a custom column name (avatar, cover) or several asset columns.

Swappable engine

The provider is the swap point; the rest of your code is provider-agnostic.

storagePlugin({ provider: 'memory' })                                   // dev / tests
storagePlugin({ provider: 'database' })                                 // bytes in your DB — zero extra infra
storagePlugin({ provider: 'filesystem', root: '.voltro-storage' })      // on-disk dev
storagePlugin({ provider: 's3', bucket: 'prod', region: 'eu-central-1',
                cdnBaseUrl: 'https://cdn.example.com' })                 // AWS S3 + CDN
storagePlugin({ provider: 'minio', bucket: 'prod',
                endpoint: 'http://localhost:9000' })                     // MinIO
storagePlugin({ provider: 'azure', bucket: 'prod',
                accountName: 'acct', accountKey: process.env.AZURE_STORAGE_KEY }) // Azure Blob
// Cloudflare R2 / GCS / Backblaze B2 / Wasabi — any S3-compatible bucket via `endpoint`.
storagePlugin({ provider: 's3', bucket: 'prod',
                endpoint: 'https://<acct>.r2.cloudflarestorage.com',
                cdnBaseUrl: 'https://files.example.com', publicAcl: false })

Two providers stand apart from the S3-compatible family:

  • azure — Azure Blob Storage (not S3-compatible). Presigned URLs are SAS tokens; needs accountName + accountKey (or AZURE_STORAGE_ACCOUNT / AZURE_STORAGE_KEY), or a connectionString. Requires the optional @azure/storage-blob dependency.
  • database — stores the blob bytes IN the app's database (_voltro_storage_blobs, a bytes() / BYTEA column) via the same DataStore. Zero extra infra — no bucket, survives restarts (unlike memory). Served through the app query (no CDN-direct). Use for small files; large blobs belong in object storage.

The filesystem provider writes blobs atomically — bytes go to a unique temp file in the target directory, then rename() over the final path (POSIX-atomic). A reader never sees a half-written file and two concurrent writers of the same key can't interleave into a corrupt blob. That makes it safe on a shared ReadWriteMany volume (e.g. CephFS) with multiple app replicas — point STORAGE_ROOT at the mount. (A ReadWriteOnce block volume mounts on one pod only, so it can't back more than one replica.)

Options resolve from env when omitted: STORAGE_PROVIDER, S3_BUCKET / STORAGE_BUCKET, S3_REGION / AWS_REGION, S3_ENDPOINT / MINIO_ENDPOINT, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, STORAGE_ROOT, STORAGE_CDN_URL, S3_FORCE_PATH_STYLE=1. Pass a StorageProvider object to bring your own backend. publicAcl: false skips per-object public-read ACLs (use a bucket policy + cdnBaseUrl instead — required for R2).

Public objects — minimal middleware

A visibility: 'public' object is readable with no auth. When cdnBaseUrl is set, getUrl(id) returns ${cdnBaseUrl}/${key} and the serve route 302s there — reads never hit the app. WITHOUT cdnBaseUrl (a bare S3/MinIO bucket, which is private by default — and on filesystem / memory), the framework does NOT hand out a raw bucket URL (it would 403); the serve route streams the bytes through the app instead, with cache-control: public, max-age=31536000, immutable. So public objects are always cacheable; cdnBaseUrl is what moves the read path off the app and onto the edge.

Private objects — the access model

Access is the union of two layers; a request is allowed if either grants it.

1. Inline access policy

put({ access }) takes an OR-of-rules array. A rule GRANTS when EVERY condition it declares is satisfied (AND); access is allowed if ANY rule grants. The owner (subject.id === ref.ownerId) is always allowed.

yield* storage.put({
  bytes, contentType: 'application/pdf',
  ownerId: ctx.request.subject.id,
  password: 'optional-file-password',     // hashed; gates a { password: true } rule
  access: [
    { roles: ['admin'] },                 // admins, OR
    { tenant: true },                     // any same-tenant subject, OR
    { groups: ['finance'], scopes: ['files:read'] }, // finance AND files:read
  ],
})

Rule conditions: owner, roles[], groups[], scopes[], tenant, apiKey, password, guard (a named custom guard). Roles/groups come from subject.metadata.roles / .groups by default — override the resolution:

storagePlugin({
  access: {
    resolveGroups: (subject) => lookupGroups(subject.id),   // sync | Promise
    guards: { vip: ({ subject, ref }) => subject.metadata?.tier === 'vip' },
  },
})

2. Per-object grants

Explicitly share one object with a user / group / api-key, optionally expiring:

yield* storage.grant({ refId, principalType: 'user', principalId: 'user_123', permission: 'read' })
yield* storage.grant({ refId, principalType: 'group', principalId: 'team-a', expiresAt: in7Days })
yield* storage.revoke(grantId)
const grants = yield* storage.listGrants(refId)

Grants persist in _voltro_storage_grants and are managed from the dashboard's Storage tab.

Delivering a private file

// Access-checked. Returns a presigned URL (s3/minio) OR an app serve URL
// carrying a short-lived signed grant token (filesystem/memory). Fails with
// StorageAccessDenied (403) if the subject isn't allowed.
const url = yield* storage.mintUrl(fileId, ctx.request.subject, { password })

For large uploads, skip the wire and presign a direct-to-bucket PUT (s3/minio): storage.mintUploadUrl({ key, contentType }).

Typed RPC queries (client-facing)

The plugin ships queries your client can call with no extra wiring — and their errors (StorageError, StorageAccessDenied) are merged into every procedure's wire-error union so they decode typed on the client:

storage.upload, storage.mintUploadTicket, storage.mintUrl, storage.mintUploadUrl, storage.mintPresignedUpload, storage.finalizeUpload, storage.beginResumableUpload, storage.beginMultipartUpload, storage.signMultipartPart, storage.completeMultipartUpload, storage.abortMultipartUpload, storage.ingestUrl, storage.share, storage.revoke, storage.listGrants, storage.listRefs. (The upload hook picks the right ones per prefer; you rarely call them directly.)

StorageService API

Method Returns
put(input) StorageRef{ id, tenantId, ownerId, bucket, key, contentType, size, checksum, visibility, accessPolicy, createdAt }
get(id, opts?) { bytes, ref } (tenant-guarded when opts.tenantId set)
getUrl(id, opts?) direct/CDN URL (public) or presigned/serve URL (private)
mintUrl(id, subject, opts?) access-checked delivery URL
mintUploadUrl(input) presigned PUT (s3/minio)
head(id, opts?) StorageRef | null
getRange(id, { start, endInclusive }, opts?) { bytes, ref, totalSize } — a byte slice (backs Range serving)
delete(id, opts?) refcount-aware — dedup'd siblings survive
listRefs(input?) { refs, nextOffset } — browse/search the ref index
checkAccess(ref, subject, opts?) boolean
grant / revoke / listGrants manage per-object grants

Failures are a typed StorageError (the transient flag drives retry — default 3 attempts) or StorageAccessDenied (403).

Browse / search files — listRefs()

The folder, tags, and ownerId you set on each put() are queryable first-class — a media library or file manager never has to touch _voltro_storage_refs by hand. listRefs() filters by folder prefix (a path descendant match), all-of tags (AND), and ownerId, newest-first, with offset paging:

import { StorageService } from '@voltro/plugin-storage'
import { Effect } from 'effect'

const browse = Effect.gen(function* () {
  const storage = yield* StorageService
  // Page 1: everything under `photos/` (incl. `photos/2026/…`) tagged `hero`.
  const page = yield* storage.listRefs({
    tenantId: 'org_123',
    folder: 'photos',
    tags: ['hero'],
    ownerId: 'user_42',
    limit: 24,
    offset: 0,
  })
  // page.refs: ReadonlyArray<StorageRef>, newest first.
  // page.nextOffset: number | null — pass back as `offset` for the next page,
  // or null when this was the last page.
  return page
})
  • folder matches the value exactly OR as a /-delimited ancestor (photosphotos, photos/2026; never photos-archive). A trailing slash is ignored.
  • tags requires ALL listed tags on the ref (AND).
  • ownerId / tenantId scope to one owner / tenant (tenantId: null is the system/global partition; omit tenantId only for a trusted admin read that spans tenants).
  • Transcode derivatives (renditions / posters) are excluded by default; pass includeDerived: true to include them.
  • Paging: limit is clamped to [1, 500] (default 50); nextOffset is non-null while another page follows.

listRefs filters the index — it does not run the per-object access policy (it delivers no bytes). Scope it to the caller's tenantId / ownerId for an end-user surface, and deliver any listed private object through mintUrl. The storage.listRefs rpc query takes the tenant from the caller's subject server-side, so a client can only browse its own tenant.

Reach the service with yield* StorageService inside an Effect handler — the plugin provides it as the handler base layer. The service methods are Effect-returning (storage.put(...) is an Effect<StorageRef>); from a plain async handler, run them with Effect.runPromise, or write the handler in Effect form.

Serve endpoint

GET /_voltro/storage/:id (mounted pre-auth):

  • public → 302 to the CDN/bucket URL when cdnBaseUrl is set; otherwise serve the bytes with cache-control: public, max-age=31536000, immutable (no auth either way).
  • private → checks a ?t=<grant-token> (minted by mintUrl) OR the session cookie + access policy; then 403, a presigned 302, or a served body with cache-control: private, no-store.

HTTP Range — 206 Partial Content (video seeking, bounded delivery)

When the object is served through the app (a non-presigning provider, or a private object without a bucket presign), the serve route honours an HTTP Range: request header so a <video>/<audio> element can seek and a client can resume — only the requested slice leaves the backend (via the provider's getRange; memory/database fall back to a buffered slice):

  • A full GET responds 200 with Accept-Ranges: bytes.
  • A satisfiable Range: bytes=<start>-<end> responds 206 Partial Content with Content-Range: bytes <start>-<end>/<total> and just that slice. Open- ended (bytes=N-) reads to EOF; a suffix (bytes=-N) reads the last N bytes.
  • A range entirely past the object → 416 Range Not Satisfiable with Content-Range: bytes */<total>.
  • A malformed Range header falls back to the full 200 (per RFC 7233).

For presigning providers (s3/minio) a private object 302-redirects to the bucket, which serves Range natively — the client re-issues its Range against the presigned URL, so seeking works without the bytes ever passing through the app. The Range/Content-Range/Accept-Ranges headers are added to the serve route's CORS allow/expose lists so a cross-origin fetch() can drive them.

Cross-origin: absolute URLs + CORS

When the api is a different origin than your web app (the common deploy — api.example.com vs your web origin), the serve URL must be absolute or a browser <img> / a server-to-server AI-gateway fetch can't resolve it. Set the api's public base and getUrl/mintUrl/the serve route emit absolute URLs:

VOLTRO_PUBLIC_URL=https://api.example.com     # or storagePlugin({ publicBaseUrl })

Without it, serve URLs are relative (/_voltro/storage/:id) — correct only same-origin. For public objects a cdnBaseUrl / STORAGE_CDN_URL already yields an absolute CDN URL; VOLTRO_PUBLIC_URL covers the no-CDN + private cases.

To let a cross-origin browser fetch()/canvas the bytes (a plain <img> needs nothing), allow its origin:

STORAGE_ALLOWED_ORIGINS=https://app.example.com   # comma-separated, or '*'

Diagnostics: on boot the plugin logs storage: provider=… presign=… publicUrl=… transforms=…, and warns loudly when provider=memory outside tests (uploads are in-memory and lost on restart — empty/placeholder storage env silently falls back to memory) or when serve URLs would be relative on a non-presigning provider with no public base.

Diagnostics — voltro storage

Probe the configured backend from the terminal so a broken setup fails LOUD before an upload does:

voltro storage doctor   # provider + a real read/write round-trip + config sanity
voltro storage cors     # print the recommended bucket CORS for presigned uploads

doctor reads your storage env (STORAGE_PROVIDER / S3_* / VOLTRO_PUBLIC_URL / STORAGE_ALLOWED_ORIGINS), prints the resolved provider banner, does an actual putgetdelete round-trip against it, and flags the two silent footguns — provider=memory (data lost on restart) and relative serve URLs (cross-origin breakage). Exit code is non-zero when it finds an issue, so it drops into CI.

cors prints an S3/R2 CORSRules JSON to apply on your bucket — only needed if you upload via a presigned direct-to-bucket PUT (storage.mintUploadUrl); the through-app upload route sets its own CORS from STORAGE_ALLOWED_ORIGINS.

Dev restart-safety. memory (the empty-env fallback) loses everything on a process/pod restart. For a dev environment that survives restarts, use filesystem with a mounted volume (STORAGE_ROOT → a Docker/k8s volume — a ReadWriteOnce PVC is fine for a single dev replica) or the database provider (bytes in your DB, zero extra infra). voltro storage doctor warns when you're on memory so this never surprises you in a deployed dev cluster.

Upload constraints

Reject bad uploads before the bytes ever reach the provider:

storagePlugin({
  limits: {
    maxBytes: 10 * 1024 * 1024,                       // 10 MB cap
    allowedContentTypes: ['image/*', 'application/pdf'], // exact or `type/*`
    sniff: true,                                      // magic-byte check
  },
})

sniff verifies the bytes' magic number matches the declared contentType, so an executable can't slip in as image/png. A violation fails put with a typed StorageRejectedreason is too-large, content-type-not-allowed, or content-mismatch.

Virus scanning

A scan hook runs on every put BEFORE the bytes are stored. The first-party clamavScanner talks to a running ClamAV daemon over its INSTREAM protocol (no npm dependency):

import { storagePlugin, clamavScanner } from '@voltro/plugin-storage'

storagePlugin({ scan: clamavScanner({ host: '127.0.0.1', port: 3310 }) })

A detection fails put with a typed StorageScanRejected (carrying the threat name) and stores nothing. A scanner outage fails closed — a transient StorageError, so a misconfigured scanner never silently waves files through. Bring your own engine (VirusTotal, Cloudmersive, an ICAP gateway) by implementing StorageScanner:

const myScanner: StorageScanner = async ({ bytes, contentType }) => {
  const clean = await scanSomehow(bytes)
  return clean ? { clean: true } : { clean: false, threat: 'detected' }
}

Scanning applies to every path that runs put — through-app uploads, the resumable finalize, AND prefer: 'presign' (which fetches the bytes back and runs finalizeUpload, so a presigned upload is scanned out of band and fails closed on a hit). Only a raw storage.mintUploadUrl PUT that you finalize yourself bypasses the scan — prefer the hook's presign transport, which does not.

On-the-fly image transforms

Resize / re-encode images via query params on the serve URL — powered by the optional sharp dependency:

<img src="/_voltro/storage/file_01j…?w=400&h=300&format=webp&q=80" />

Params: w, h (px), format (webp / avif / jpeg / png), q (quality 1–100), fit (cover / contain / …). Transformed variants are cached.

The fast path stays fast: without transform params a public object 302s straight to the CDN (when cdnBaseUrl is set) or streams with the immutable cache otherwise; with params the app streams + transforms (opt-in cost only when you ask for it). Private objects are access-checked first, then transformed. If sharp isn't installed the original bytes are served unchanged. For CDN-direct public images at scale, a CDN-level image resizer (Cloudflare Images, etc.) avoids the app round-trip entirely.

Dashboard

The Storage tab (local devtools + cloud) lists every object with its visibility, size and owner, shows public/private + per-tenant stats, and lets you view + share + revoke per-object grants. Backed by /_voltro/inspect/plugins/storage/{refs,stats,grants,share,revoke}.

Lifecycle: quotas, GC, URL ingest

  • Per-tenant quotas: storagePlugin({ quota: { maxBytes, maxCount } }) — the headroom is consumed ATOMICALLY (a compare-and-set over the _voltro_storage_usage counter row) BEFORE bytes are stored, so concurrent uploads — even across replicas — cannot overshoot the cap; a failed write refunds the reservation, a delete releases it. Over-budget fails with a typed StorageRejected ('quota-exceeded'). storage.usage(tenantId) returns { bytes, count } for dashboards / limits.
  • Orphan GC: storage.sweepOrphans({ limit, dryRun }) removes dangling refs whose bytes are gone from the provider (a crash between store + insert, or a manual blob delete). Bounded + repeatable; run it from a scheduled task.
  • Ingest an external URL: storage.ingestUrl(url, opts) (also the storage.ingestUrl rpc) fetches a URL server-side and stores it as an asset — the one-liner for CMS migration ("adopt 21k legacy image URLs"). It fetches an arbitrary URL from the server, so pass trusted URLs only (SSRF); front with an allow-list if the URL is user-supplied.

Schema

_voltro_storage_refs (id, tenantId, ownerId, bucket, key, contentType, size, checksum, visibility, accessPolicy, passwordHash, createdAt, + width, height, duration, placeholder, folder, tags, alt, caption, derivedFrom, kind), _voltro_storage_grants (id, refId, principalType, principalId, permission, createdAt, expiresAt, createdBy), _voltro_storage_usage (id, tenantId UNIQUE, bytes, count, updatedAt — one counter row per tenant, the atomic arbiter for quota enforcement), and _voltro_storage_blobs (id, key, data BYTEA, contentType, size — only written by the database provider) are framework-internal and auto-migrated on every SQL-backed app — no *.entity.ts needed. passwordHash is server-only and never serialized.