Skip to content

Storage

Storage organizes files into per-project buckets. Each object's metadata (path, size, mime type, owner) is a row in project_storage_objects; the binary content is written to local disk. Access control reuses the same RLS engine as table RLS — buckets get storage policies with the same operation / expression shape (a raw SQL boolean expression, see keys-and-rls.md for the full model), just scoped to a bucket instead of a table.

Management (platform owner)

Method Route Description
GET /projects/{id}/storage/buckets List a project's buckets
POST /projects/{id}/storage/buckets Create a bucket ({ name, public? })
PATCH /projects/{id}/storage/buckets/{bucket_id} Toggle public
DELETE /projects/{id}/storage/buckets/{bucket_id} Delete a bucket (and its objects)
GET /projects/{id}/storage/buckets/{bucket_id}/policies List a bucket's storage policies
POST /projects/{id}/storage/buckets/{bucket_id}/policies Create a policy ({ name, operation, expression, enabled? })
DELETE /projects/{id}/storage/buckets/{bucket_id}/policies/{policy_id} Delete a policy

expression references the fixed columns of an object: id, bucket_id, path, owner_id, size, mime_type, created_at, updated_at — plus the usual $auth.id / $auth.email / $auth.role placeholders. owner_id is always set by Storage itself to the uploading end user (or NULL for an anonymous upload) — it's never client-choosable, so scoping to it is reliable. A common pattern is scoping uploads/reads/deletes to the uploader:

{ "operation": "ALL", "expression": "owner_id = $auth.id" }

On INSERT/UPDATE this is also the WITH CHECK: an upload whose (Storage-assigned) owner_id doesn't satisfy the policy is rejected with 403 before the file ever touches disk — e.g. an anonymous upload (owner_id is NULL) against the policy above, since SQL never treats NULL = NULL as true.

Objects (platform owner)

Method Route Description
GET /projects/{id}/storage/buckets/{bucket_id}/objects List a bucket's objects
POST /projects/{id}/storage/buckets/{bucket_id}/objects Upload (multipart/form-data, field file, optional field path)
GET /projects/{id}/storage/buckets/{bucket_id}/objects/{object_id}/download Download
DELETE /projects/{id}/storage/buckets/{bucket_id}/objects/{object_id} Delete

These are what the dashboard's Storage screen calls to browse/upload/download/ delete files. They're authenticated with the platform owner's own login (Authorization: Bearer <platform token>, same as every other /projects/{id}/... management route) and bypass bucket policies entirely — the owner can always see and manage every object in their own bucket, the same way the SQL Editor bypasses table RLS. Uploads through here set owner_id to NULL (there's no end-user identity in a platform-owner request).

Object passthrough

Method Route Auth Description
GET /{project_id}/storage/public/{bucket}/{object_id} none Download — only works if the bucket is public
GET /{project_id}/storage/{bucket} API key (storage:select) List objects
POST /{project_id}/storage/{bucket} API key (storage:insert) Upload (multipart/form-data, field file, optional field path)
GET /{project_id}/storage/{bucket}/{object_id} API key (storage:select) Download
PATCH /{project_id}/storage/{bucket}/{object_id} API key (storage:update) Rename ({ "path": "..." })
DELETE /{project_id}/storage/{bucket}/{object_id} API key (storage:delete) Delete

The list endpoint is paginated the same way as the management endpoints (?limit=, default 25, capped at 100; ?offset=), echoing X-Total-Count / X-Page-Limit / X-Page-Offset.

Like REST passthrough, every route but the public download one is authenticated with Authorization: Bearer <project API key> — the key needs the matching storage:select / storage:insert / storage:update / storage:delete permission — plus an optional X-User-Token: <end-user token> for RLS purposes. An object's path is a logical label (e.g. avatars/user-1.png) unique per bucket; it is never used as a filesystem path, so it may contain any characters, including /. Objects are addressed by numeric id in the URL because the router doesn't support slashes inside a single route segment.

Public vs. private buckets

A public bucket's objects are reachable at /{project_id}/storage/public/{bucket}/{id} with no authentication at all — no key, no RLS check. Everything else (listing, uploading, authenticated download, rename, delete) still goes through the usual key + policy gate regardless of a bucket's public flag. Marking a bucket public only opens up serving its files publicly, the way you'd point an <img> tag or a CDN at it.

Storage location

Files are written under STORAGE_PATH (default: storage/files at the project root — see Configuration). Local filesystem only for now; an S3-compatible backend is on the roadmap.