Skip to content

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.

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:

Terminal window
curl -X PUT "$URL/api/v1/storage/avatars/u/123/photo.png" \
-H "authorization: Bearer $TOKEN" -H 'content-type: image/png' \
--data-binary @photo.png

Path segments after the bucket are percent-decoded and rejoined with /, so nested keys work naturally.

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.

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 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'); // idempotent

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).

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.

Terminal window
S3_ENDPOINT=https://s3.example.com
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_BUCKET=groveback
S3_REGION=us-east-1

make minio brings up a local MinIO for development.

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,
});
}