Storage

Upload and serve files in buckets. Access rules live as rows in your database, under the same Row-Level Security as everything else.

POST   /storage/v1/bucket
GET    /storage/v1/bucket
DELETE /storage/v1/bucket/{bucket}
POST   /storage/v1/object/{bucket}/{name}
GET    /storage/v1/object/{bucket}/{name}
GET    /storage/v1/object/public/{bucket}/{name}
DELETE /storage/v1/object/{bucket}/{name}
POST   /storage/v1/object/sign/{bucket}/{name}
POST · PUT /storage/v1/object/upload/sign/{bucket}/{name}
POST   /storage/v1/object/list/{bucket}
POST   /storage/v1/object/copy · /move

Buckets

Bucket management needs the secret key (kbs_…, service_role). Names are lowercase [a-z0-9._-], 2–63 characters:

curl -X POST "https://<ref>.kethosbase.com/storage/v1/bucket" \
  -H "Authorization: Bearer kbs_<secret-key>" \
  -H "Content-Type: application/json" \
  -d '{"name": "avatars", "public": true}'

Objects in a public bucket are downloadable without any key via /storage/v1/object/public/…. A bucket must be empty before it can be deleted.

Uploading

POST the raw bytes (up to 50 MiB per object):

curl -X POST \
  "https://<ref>.kethosbase.com/storage/v1/object/avatars/users/alice.png" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: image/png" \
  --data-binary @alice.png

Response (201):

{
  "bucket": "avatars",
  "name": "users/alice.png",
  "id": "…",
  "size": 12345,
  "content_type": "image/png"
}

Downloading

# Authenticated (RLS applies):
curl "https://<ref>.kethosbase.com/storage/v1/object/avatars/users/alice.png" \
  -H "Authorization: Bearer <token>"

# Public bucket, no auth:
curl "https://<ref>.kethosbase.com/storage/v1/object/public/avatars/users/alice.png"
Downloads of types that could execute in a browser are served with Content-Disposition: attachment; images, text, JSON, PDF, audio, and video render inline.

Signed URLs

Mint a time-limited URL that grants access to a single private object without exposing a key — for sharing a file or letting a browser download it directly:

curl -X POST \
  "https://<ref>.kethosbase.com/storage/v1/object/sign/avatars/users/alice.png" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"expiresIn": 60}'

→ { "signedURL": "/object/sign/avatars/users/alice.png?token=…" }

The link works for expiresIn seconds and is bound to that one object; a tampered link is rejected with 400. Minting it still runs under RLS, so you can only sign objects you may read. To sign several objects at once, POST an array of keys to /storage/v1/object/sign/{bucket}.

Signed upload URLs

The reverse direction: hand a client a one-time URL to upload into a private bucket without giving it a key. Mint it as a user who may write the object, then upload to it with no auth header:

# 1. Mint (requires write access under RLS)
curl -X POST \
  "https://<ref>.kethosbase.com/storage/v1/object/upload/sign/avatars/users/alice.png" \
  -H "Authorization: Bearer <token>"

→ { "url": "/object/upload/sign/avatars/users/alice.png?token=…", "token": "…" }

# 2. Upload the bytes to that URL — no bearer needed
curl -X PUT \
  "https://<ref>.kethosbase.com/storage/v1/object/upload/sign/avatars/users/alice.png?token=…" \
  -H "Content-Type: image/png" \
  --data-binary @alice.png

The token is bound to that one object and to the upload operation only, and is valid for 2 hours. The upload is insert-only (a signed URL can't overwrite an existing object) and records the minting user as the object's owner, so your owner_sub policies still hold.

Listing, copying, moving, removing

Manage objects with the standard operations, all under the caller's RLS:

POST   /storage/v1/object/list/{bucket}   {"prefix": "users/"}
POST   /storage/v1/object/copy            {"bucketId": "avatars", "sourceKey": "a.png", "destinationKey": "b.png"}
POST   /storage/v1/object/move            {"bucketId": "avatars", "sourceKey": "b.png", "destinationKey": "c.png"}
DELETE /storage/v1/object/{bucket}        {"prefixes": ["a.png", "b.png"]}   # bulk remove by exact key

A folder-style list returns the immediate children of a prefix — files carry their metadata, sub-folders collapse to a single entry. A same-bucket move is a metadata rename; a cross-bucket move copies the bytes then drops the source. These are the standard storage client calls (list, copy, move, remove).

Access control

Object metadata lives in your project database, so who can upload, read, and delete is governed by Row-Level Security on storage.objects — exactly like your own tables. The secret key (kbs_…, service_role, used from a server) bypasses these rules; requests made with the publishable key or an end-user access token follow them.

Defaults ship locked. A new project has a single storage policy: read objects in public buckets. That is the only access end-user requests have out of the box — so reading from or writing to a private bucket is denied (row violates row-level security policy) until you add a policy. This is by design: you decide who can touch what.

Per-user files in a private bucket

Every upload records the uploader's id in the owner_sub column, taken from the access token — the platform sets it for you, so a user can never claim another user's files. Gate access with auth.uid(), which returns the current user's id (and NULL when the request is not authenticated). Add one policy per operation you need:

-- Each signed-in user manages only their own files in the 'avatars' bucket.
-- No "to <role>" clause: the policy applies to every request, and
-- auth.uid() is NULL for the publishable key, so only the owner passes.
create policy "avatars read own"   on storage.objects for select
  using ( bucket = 'avatars' and owner_sub = auth.uid() );

create policy "avatars write own"  on storage.objects for insert
  with check ( bucket = 'avatars' and owner_sub = auth.uid() );

create policy "avatars update own" on storage.objects for update
  using      ( bucket = 'avatars' and owner_sub = auth.uid() )
  with check ( bucket = 'avatars' and owner_sub = auth.uid() );

create policy "avatars delete own" on storage.objects for delete
  using ( bucket = 'avatars' and owner_sub = auth.uid() );
Write a policy for each operation you want to allow — the built-in policy only covers reading public buckets, so a private bucket needs its own select policy for downloads too. Run this SQL over Direct SQL or the dashboard. Bucket creation stays service_role-only.

For a bucket that should be readable by everyone, mark it public instead of writing a read policy. For a shared team space, scope a policy by a claim in auth.jwt() rather than by owner_sub. To hand out one private object without broadening your policies, mint a signed URL — it grants time-limited access to just that object.

Storage counts against your plan's per-project quota; uploads over it answer 422 quota_exceeded.