Storage
Per-project file storage organized into logical buckets. The bytes live in MongoDB, an S3-compatible store, or memory — the API is the same either way.
Buckets
Section titled “Buckets”await client.storage.buckets.create('avatars', 'public');await client.storage.buckets.list();await client.storage.buckets.setAccess('avatars', 'private');await client.storage.buckets.delete('avatars');Access is private (the default) or public. Bucket management requires the
storage:manage permission, so buckets.* needs an Admin/Owner caller or an admin-scoped
key.
A non-empty bucket cannot be deleted (409 Conflict).
await client.storage.upload('avatars', 'u/123/photo.png', file, 'image/png');const { data, contentType } = await client.storage.download('avatars', 'u/123/photo.png');await client.storage.list('avatars', { prefix: 'u/123/', limit: 50 });await client.storage.delete('avatars', 'u/123/photo.png');Over raw HTTP, with scope:storage:
curl -X PUT "$URL/api/v1/storage/avatars/u/123/photo.png" \ -H "authorization: Bearer $TOKEN" -H 'content-type: image/png' \ --data-binary @photo.pngPath segments after the bucket are percent-decoded and rejoined with /, so nested keys work
naturally.
Who can do what
Section titled “Who can do what”| Operation | Requires |
|---|---|
| Create/delete/modify a bucket | storage:manage |
| Upload | Any authenticated caller |
| Download, delete, overwrite | Owner or admin — or a public bucket, or a valid signed URL |
Ownership is forced, never client-supplied: ownerId is set from the authenticated caller
on upload. An overwrite preserves the original owner, createdAt and id.
Denials are 404, never 403. A file’s existence is never revealed — same convention as the data plane.
Signed URLs
Section titled “Signed URLs”For handing a private object to a browser without giving it a token:
const { url, expiresAt } = await client.storage.signUrl('docs', 'invoice.pdf', 3600);Or over HTTP, by adding ?sign=<ttlSeconds> to a GET.
The signature is HMAC-SHA256 over [projectId, bucket, path, expires], with a TTL from 1
second to 7 days. The URL is served by the API itself, so it survives a change of blob
backend.
Folders
Section titled “Folders”Folders are a convention, not a structure: an empty folder is a zero-byte .keep marker with
content type application/x-directory.
await client.storage.createFolder('avatars', 'u/123'); // idempotentLimits
Section titled “Limits”The default per-file cap is 10 MB (GROVEBACK_MAX_FILE_SIZE), overridable per project via
the control plane’s maxFileSize. Mongo-backed blobs must additionally stay under the 16 MB
BSON document limit — configure S3 for anything larger.
On hosted deployments a storageBytes gauge is enforced; over cap returns 403 with a message
starting plan limit: . Overwrites are charged only the growth (new − old).
Backends
Section titled “Backends”Set all three of S3_ENDPOINT, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY to use an
S3-compatible store; otherwise blobs fall back to MongoDB binary documents. S3_BUCKET
defaults to groveback.
S3_ENDPOINT=https://s3.example.comS3_ACCESS_KEY_ID=…S3_SECRET_ACCESS_KEY=…S3_BUCKET=grovebackS3_REGION=us-east-1make minio brings up a local MinIO for development.
Events
Section titled “Events”An upload fires the storage:fileUploaded function trigger — the hook
for thumbnailing, virus scanning, or writing a metadata document.
export default async function (event, ctx) { await ctx.db.insertOne('uploads', { bucket: event.bucket, path: event.path, ownerId: event.ownerId, });}