$npx -y skills add butterbase-ai/butterbase-skills --skill storageUse when uploading or downloading files, generating presigned URLs, configuring storage ACLs, or persisting file references (avatars, attachments, images) in a Butterbase app
| 1 | # Butterbase Storage |
| 2 | |
| 3 | Butterbase stores files in S3 (or LocalStack in dev) and exposes them via presigned URLs. Every file gets a stable `object_id` (UUID) that you persist in your tables; URLs are generated on demand and expire. |
| 4 | |
| 5 | All storage operations go through one tool: **`manage_storage`** with an `action` parameter. |
| 6 | |
| 7 | | Action | Purpose | |
| 8 | |--------|---------| |
| 9 | | `upload_url` | Generate a 15-minute presigned PUT URL and reserve an `object_id` | |
| 10 | | `download_url` | Generate a 1-hour presigned GET URL for a stored object | |
| 11 | | `list` | List objects (scoped by caller's role) | |
| 12 | | `delete` | Permanently remove an object from S3 + database | |
| 13 | | `update_config` | Toggle app-level `publicReadEnabled` and other storage settings | |
| 14 | |
| 15 | --- |
| 16 | |
| 17 | ## 1. The mental model: `object_id` vs `s3_key` |
| 18 | |
| 19 | | Field | What it is | When you use it | |
| 20 | |-------|-----------|-----------------| |
| 21 | | `object_id` | UUID, stable, app-level handle | Persist in your tables (e.g. `users.avatar_id`, `posts.image_id`) | |
| 22 | | `s3_key` | Internal bucket path like `app_abc/user_uuid/file.jpg` | Internal only — **never** treat this as a URL | |
| 23 | |
| 24 | **Critical:** `s3_key` is **not** a URL. You cannot use it as `<img src>` or `<a href>`. Always store the `object_id` and resolve a fresh download URL at render time. |
| 25 | |
| 26 | --- |
| 27 | |
| 28 | ## 2. The upload lifecycle |
| 29 | |
| 30 | A single upload is two HTTP calls and one DB insert in your app: |
| 31 | |
| 32 | ``` |
| 33 | ┌─────────────────────────┐ |
| 34 | │ 1. manage_storage( │ → returns { upload_url, object_id, expires_at } |
| 35 | │ action: upload_url)│ |
| 36 | ├─────────────────────────┤ |
| 37 | │ 2. PUT file -> S3 │ → must include exact Content-Type header |
| 38 | ├─────────────────────────┤ |
| 39 | │ 3. INSERT INTO ... │ → save object_id alongside the user/post/etc. |
| 40 | └─────────────────────────┘ |
| 41 | ``` |
| 42 | |
| 43 | If you skip step 3, the file lives in S3 but no row references it — an **orphaned object** counting against your quota. Always persist the `object_id`. |
| 44 | |
| 45 | ### Step 1 — request an upload URL |
| 46 | |
| 47 | ```js |
| 48 | manage_storage({ |
| 49 | app_id: "app_abc123", |
| 50 | action: "upload_url", |
| 51 | filename: "avatar.jpg", |
| 52 | content_type: "image/jpeg", |
| 53 | size_bytes: 245123, |
| 54 | public: false // optional; default false |
| 55 | }) |
| 56 | ``` |
| 57 | |
| 58 | Returns: |
| 59 | |
| 60 | ```json |
| 61 | { |
| 62 | "upload_url": "https://s3.amazonaws.com/...", |
| 63 | "object_id": "9c14b2e0-...", |
| 64 | "expires_at": "2026-05-08T22:15:00Z" |
| 65 | } |
| 66 | ``` |
| 67 | |
| 68 | The `object_id` is created **immediately** in the database; the file just hasn't been uploaded yet. |
| 69 | |
| 70 | ### Step 2 — PUT the bytes |
| 71 | |
| 72 | The Content-Type on the PUT must match exactly the `content_type` you sent in step 1. If it doesn't, S3 rejects the upload or stores the wrong MIME — breaking browser previews. |
| 73 | |
| 74 | ```bash |
| 75 | curl -X PUT "{upload_url}" \ |
| 76 | -H "Content-Type: image/jpeg" \ |
| 77 | --data-binary @avatar.jpg |
| 78 | ``` |
| 79 | |
| 80 | From the browser: |
| 81 | |
| 82 | ```js |
| 83 | await fetch(uploadUrl, { |
| 84 | method: "PUT", |
| 85 | headers: { "Content-Type": file.type }, |
| 86 | body: file |
| 87 | }); |
| 88 | ``` |
| 89 | |
| 90 | ### Step 3 — persist the `object_id` |
| 91 | |
| 92 | ```sql |
| 93 | UPDATE users SET avatar_id = $1 WHERE id = $2 |
| 94 | ``` |
| 95 | |
| 96 | Or via the auto-API / SDK — whatever your app uses for writes. Without this step, the file is unreachable. |
| 97 | |
| 98 | --- |
| 99 | |
| 100 | ## 3. Generating download URLs |
| 101 | |
| 102 | Each call returns a fresh URL valid for 1 hour. Don't bake URLs into static HTML or long-lived caches — re-generate per render or per session. |
| 103 | |
| 104 | ```js |
| 105 | manage_storage({ |
| 106 | app_id: "app_abc123", |
| 107 | action: "download_url", |
| 108 | object_id: "9c14b2e0-..." |
| 109 | }) |
| 110 | // → { download_url: "https://s3.amazonaws.com/..." } |
| 111 | ``` |
| 112 | |
| 113 | For lists with many files, resolve URLs in parallel: |
| 114 | |
| 115 | ```js |
| 116 | const urls = await Promise.all( |
| 117 | posts.map(p => getDownloadUrl(p.image_id)) |
| 118 | ); |
| 119 | ``` |
| 120 | |
| 121 | If the caller is unauthorized, the response is `404` (not `403`) — Butterbase deliberately hides existence to avoid leaking object IDs. |
| 122 | |
| 123 | --- |
| 124 | |
| 125 | ## 4. Access control |
| 126 | |
| 127 | Three tiers, evaluated in order: |
| 128 | |
| 129 | | Caller | What they can read | |
| 130 | |--------|--------------------| |
| 131 | | Service key (`bb_sk_*`) | Everything in the app — RLS bypassed | |
| 132 | | End-user JWT | Files where `(user_id === caller_id) OR object.public === true OR app.publicReadEnabled === true` | |
| 133 | | Anonymous (no auth) | Only public objects (and only if app access mode allows anon) | |
| 134 | |
| 135 | ### Per-object public flag |
| 136 | |
| 137 | Set at upload time: |
| 138 | |
| 139 | ```js |
| 140 | manage_storage({ app_id, action: "upload_url", filename, content_type, size_bytes, public: true }) |
| 141 | ``` |
| 142 | |
| 143 | Use this for one-off public files (a marketing image, a shared avatar) without flipping the whole app to public-read. |
| 144 | |
| 145 | ### App-wide public read |
| 146 | |
| 147 | ```js |
| 148 | manage_storage({ |
| 149 | app_id: "app_abc123", |
| 150 | action: "update_config", |
| 151 | publicReadEnabled: true |
| 152 | }) |
| 153 | ``` |
| 154 | |
| 155 | When `true`, any authenticated user in the app can download any file. Uploads and deletes stay user-scoped. |
| 156 | |
| 157 | > Storage ACL is hardcoded — you cannot layer Postgres RLS policies on top of `storage_objects`. If you need fine-grained custom rules, gate downloads through a serverless function instead of handing out dir |