# Pattern 17 — Public read, authenticated write

## What this is

A storage bucket for public-facing assets (product images, blog media, marketing materials) where anyone can read but only credentialed team members can write. The most common CMS-adjacent storage pattern.

## Public bucket vs public SELECT policy

Supabase has two distinct meanings of "public" for storage:

**Bucket-level public** (set in dashboard or Supabase API):
- Files accessible via a CDN URL without any token
- URL: `https://<project>.supabase.co/storage/v1/object/public/<bucket>/<path>`
- RLS does not apply to CDN-served requests

**RLS SELECT policy to `anon`**:
- Allows unauthenticated requests through the Supabase REST API (not CDN)
- RLS is evaluated; policy must permit the `anon` role

For truly public assets, use a public bucket. The SELECT policy is still worth having — it documents intent and supports API-level reads (e.g., listing available assets from a server-side script).

## Why role-gate the INSERT

Without an INSERT policy, any authenticated user (or any anonymous user if not restricted) could upload to your public bucket. A bucket that stores product images could be flooded with arbitrary content.

Gating INSERT on `app_role in ('editor', 'admin')` means only team members with the right permission can publish assets:

```sql
with check (
  bucket_id = 'public-assets'
  and (auth.jwt() -> 'app_metadata' ->> 'app_role') in ('editor', 'admin')
)
```

## DELETE vs UPDATE asymmetry

In many CMS-style apps, editors can publish content (upload) but shouldn't be able to permanently delete published assets — that's a more destructive action. The pattern uses a separate admin-only DELETE policy to encode this:

- Editors: upload and update metadata
- Admins: can also delete

## Cache note

Supabase Storage CDN caches responses. If you replace a file at the same path, the CDN may serve the old version for up to 60 seconds. For versioned content, use unique paths rather than overwriting (`logo-v2.svg` rather than `logo.svg`).

## Testing

```bash
# Public read — no token needed
curl "https://<project>.supabase.co/storage/v1/object/public/public-assets/logo.svg"
# Expected: 200 with file content

# Authenticated read without role — should succeed (the SELECT policy allows all authenticated)
curl "https://<project>.supabase.co/storage/v1/object/public-assets/logo.svg" \
  -H "Authorization: Bearer <viewer-token>"

# Upload without editor/admin role — should fail
curl -X POST "https://<project>.supabase.co/storage/v1/object/public-assets/malicious.js" \
  -H "Authorization: Bearer <viewer-token>" --data-binary "evil"
# Expected: 400 "new row violates row-level security policy"
```
