Skip to content

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.

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 emptied

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

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 id
create 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.

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 URL
await bucket.createSignedUrl(`${userId}/avatar.png`, 60) // { signedUrl }; { download: true | "name.png" } for attachment
bucket.getPublicUrl("logo.png").data.publicUrl // public buckets only, no request
await 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.

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