API · Backend + Storage

The api-backend base plus file storage wired — @voltro/plugin-storage with public (CDN-direct) and private (policy + grants) objects, with upload examples.

The same minimal notes base as api-backend, plus file storage wired out of the box via @voltro/plugin-storage. It ships two upload actions demonstrating the public and private object classes. Template id: api-backend-storage.

Scaffold

voltro create-project acme --api=api-backend-storage

What ships

apps/acme/api/                          # dir named by the app, not the template
├── app.config.ts                       # api + storagePlugin({ provider: 'memory' })
├── package.json
├── tsconfig.json
├── README.md
├── database/
│   └── schema.ts                       # actors + tenants + notes (same as api-backend)
├── queries/
│   └── notes.query.ts + .query.server.ts
├── mutations/
│   └── notes.create.mutation.ts + .mutation.server.ts
└── actions/
    ├── uploadAvatar.action.ts + .action.server.ts      # storage.uploadAvatar — PUBLIC
    └── uploadDocument.action.ts + .action.server.ts    # storage.uploadDocument — PRIVATE

The wiring

app.config.ts adds the storage plugin with the zero-config memory provider:

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

export default {
  type: 'api' as const,
  name: '{{projectNamePascal}}{{appNamePascal}}',
  store: 'memory' as const,
  plugins: [
    storagePlugin({ provider: 'memory' }),
  ],
}

The memory provider keeps blobs in-process (dev only). Swap to 's3' / 'minio' (any S3-compatible bucket via endpoint — AWS, R2, GCS, B2, Wasabi), 'azure', 'filesystem', or 'database' for real object storage. Set cdnBaseUrl on the plugin for production public delivery.

Two object classes

  • PUBLIC (visibility: 'public') — served direct from the bucket/CDN; the returned url is what you put in an <img src>. The app's serve route is only a dev fallback.
  • PRIVATE (default) — gated by an access policy (any-of rules over owner / roles / groups / scopes / tenant / apiKey / password / a custom guard) PLUS per-object grants (share a file with a specific user / group / api-key, optionally expiring).

actions/uploadAvatar.action.server.ts — a PUBLIC object

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

const execute = (input: { bytesBase64: string; contentType: string }, ctx: AppContext) =>
  Effect.gen(function* () {
    const storage = yield* StorageService
    const bytes = new Uint8Array(Buffer.from(input.bytesBase64, 'base64'))
    const ref = yield* storage.put({
      bytes, contentType: input.contentType,
      visibility: 'public',
      ownerId: ctx.request.subject.id, tenantId: ctx.request.subject.tenantId,
      key: `avatars/${ctx.request.subject.id ?? 'anon'}/avatar`,
    })
    const url = yield* storage.getUrl(ref.id)
    return { id: ref.id, url }
  })

export default execute

The shipped uploadDocument action mirrors this with visibility: 'private' so the file is access-checked on read. File upload is external I/O → an action, not a mutation. For large files prefer a presigned direct-to-bucket upload via the plugin's built-in storage.mintUploadUrl route.

Built-in routes + serve endpoint

The plugin ships typed client routes — storage.upload, storage.mintUrl, storage.mintUploadUrl, storage.share, storage.revoke, storage.listGrants — plus the serve endpoint GET /_voltro/storage/:id (public → 302 to CDN; private → access-checked). Browse and share stored objects from the dashboard's Storage tab. _voltro_storage_refs + _voltro_storage_grants are auto-migrated on a SQL store.

Pairs well with

  • Any web template — wire the upload actions behind a file picker.

See also