Storage
Files live in S3-compatible object storage. PotaLab Base checks each request against your policies and hands the client a short-lived signed URL, so file bytes go straight between the browser and the object store.
Buckets
Section titled “Buckets”A bucket has rules: public, max_file_size (default 50 MiB) and allowed_mime_types (null allows any type, ["image/*"] allows a family). Create buckets in the dashboard (Storage → Buckets), with the Management API, or from a server with a secret key:
const admin = createClient({ url, key: process.env.POTALAB_BASE_SECRET_KEY! })await admin.storage.createBucket("avatars", { public: false, maxFileSize: 5 * 1024 * 1024, allowedMimeTypes: ["image/*"] })await admin.storage.updateBucket("avatars", { public: true })await admin.storage.listBuckets()await admin.storage.getBucket("avatars")await admin.storage.emptyBucket("avatars")await admin.storage.deleteBucket("avatars") // 409 bucket_not_empty unless emptiedPublic buckets are readable by anyone through an unsigned URL. Uploads are single requests up to the bucket’s size limit (also capped by your plan); multipart and resumable uploads are not available yet.
Policies
Section titled “Policies”End users can do nothing until you add policies on storage.objects:
| Operation | Needs |
|---|---|
| upload | INSERT (overwriting with upsert: true also needs UPDATE) |
| download, sign, list (private buckets) | SELECT |
| delete | SELECT and DELETE |
-- users manage their own files in "avatars", under a folder named after their idcreate policy avatars_insert on storage.objects for insert to authenticated with check (bucket_id = 'avatars' and owner_id = (select auth.uid()) and (storage.foldername(path))[1] = (select auth.uid())::text);create policy avatars_select on storage.objects for select to authenticated using (bucket_id = 'avatars' and owner_id = (select auth.uid()));create policy avatars_update on storage.objects for update to authenticated using (bucket_id = 'avatars' and owner_id = (select auth.uid()));create policy avatars_delete on storage.objects for delete to authenticated using (bucket_id = 'avatars' and owner_id = (select auth.uid()));Path helpers: storage.foldername(path) ('a/b/c.png' gives {a,b}), storage.filename(path) and storage.extension(path). owner_id is the uploader’s auth.uid(), NULL for anon and service uploads. Policies can only target the anon and authenticated roles, and other DDL on storage.objects and storage.buckets is refused.
Upload, download, list, delete
Section titled “Upload, download, list, delete”const bucket = base.storage.from("avatars")
const { data, error } = await bucket.upload(`${userId}/avatar.png`, file, { contentType: "image/png", upsert: true,})
await bucket.download(`${userId}/avatar.png`) // Blob, via a 5 minute signed URLawait bucket.createSignedUrl(`${userId}/avatar.png`, 60) // { signedUrl }; { download: true | "name.png" } for attachmentbucket.getPublicUrl("logo.png").data.publicUrl // public buckets only, no requestawait bucket.list(`${userId}/`, { limit: 100, offset: 0, search: "ava" })await bucket.remove([`${userId}/avatar.png`])The body can be a Blob, File, ArrayBuffer, typed array or string. upload() needs a known size, so ReadableStream bodies and progress events are not supported. Signed URLs default to 300 s and last at most 3600 s. An object a user may not see answers object_not_found, never “forbidden”, so existence is not leaked. remove skips paths the caller may not delete and takes at most 1000 paths per call.
Errors
Section titled “Errors”| Code | When |
|---|---|
file_too_large (413), mime_type_not_allowed (415) |
bucket rules |
quota_exceeded (413) |
project storage quota reached |
forbidden (403) |
policies deny the operation, or bucket admin without a secret key |
object_already_exists, upload_in_progress (409) |
path taken (use upsert: true), or another upload runs |
object_not_found (404) |
missing or not visible to this user |
upload_mismatch (422) |
stored object differs from the declared size or type |
over_request_rate_limit (429) |
signed URL issuance rate limit |