# Storage

> Use when uploading or downloading files, generating presigned URLs, configuring storage ACLs, or persisting file references (avatars, attachments, images) in a Butterbase app

- **Type:** Skill
- **Install:** `agentstack add skill-butterbase-ai-butterbase-skills-storage`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [butterbase-ai](https://agentstack.voostack.com/s/butterbase-ai)
- **Installs:** 0
- **Category:** [Agent Skills](https://agentstack.voostack.com/c/agent-skills)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [butterbase-ai](https://github.com/butterbase-ai)
- **Source:** https://github.com/butterbase-ai/butterbase-skills/tree/main/skills/storage

## Install

```sh
agentstack add skill-butterbase-ai-butterbase-skills-storage
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# Butterbase Storage

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.

All storage operations go through one tool: **`manage_storage`** with an `action` parameter.

| Action | Purpose |
|--------|---------|
| `upload_url` | Generate a 15-minute presigned PUT URL and reserve an `object_id` |
| `download_url` | Generate a 1-hour presigned GET URL for a stored object |
| `list` | List objects (scoped by caller's role) |
| `delete` | Permanently remove an object from S3 + database |
| `update_config` | Toggle app-level `publicReadEnabled` and other storage settings |

---

## 1. The mental model: `object_id` vs `s3_key`

| Field | What it is | When you use it |
|-------|-----------|-----------------|
| `object_id` | UUID, stable, app-level handle | Persist in your tables (e.g. `users.avatar_id`, `posts.image_id`) |
| `s3_key` | Internal bucket path like `app_abc/user_uuid/file.jpg` | Internal only — **never** treat this as a URL |

**Critical:** `s3_key` is **not** a URL. You cannot use it as `` or ``. Always store the `object_id` and resolve a fresh download URL at render time.

---

## 2. The upload lifecycle

A single upload is two HTTP calls and one DB insert in your app:

```
┌─────────────────────────┐
│ 1. manage_storage(      │  → returns { upload_url, object_id, expires_at }
│      action: upload_url)│
├─────────────────────────┤
│ 2. PUT file -> S3       │  → must include exact Content-Type header
├─────────────────────────┤
│ 3. INSERT INTO ...      │  → save object_id alongside the user/post/etc.
└─────────────────────────┘
```

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

### Step 1 — request an upload URL

```js
manage_storage({
  app_id: "app_abc123",
  action: "upload_url",
  filename: "avatar.jpg",
  content_type: "image/jpeg",
  size_bytes: 245123,
  public: false                  // optional; default false
})
```

Returns:

```json
{
  "upload_url": "https://s3.amazonaws.com/...",
  "object_id": "9c14b2e0-...",
  "expires_at": "2026-05-08T22:15:00Z"
}
```

The `object_id` is created **immediately** in the database; the file just hasn't been uploaded yet.

### Step 2 — PUT the bytes

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.

```bash
curl -X PUT "{upload_url}" \
  -H "Content-Type: image/jpeg" \
  --data-binary @avatar.jpg
```

From the browser:

```js
await fetch(uploadUrl, {
  method: "PUT",
  headers: { "Content-Type": file.type },
  body: file
});
```

### Step 3 — persist the `object_id`

```sql
UPDATE users SET avatar_id = $1 WHERE id = $2
```

Or via the auto-API / SDK — whatever your app uses for writes. Without this step, the file is unreachable.

---

## 3. Generating download URLs

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.

```js
manage_storage({
  app_id: "app_abc123",
  action: "download_url",
  object_id: "9c14b2e0-..."
})
// → { download_url: "https://s3.amazonaws.com/..." }
```

For lists with many files, resolve URLs in parallel:

```js
const urls = await Promise.all(
  posts.map(p => getDownloadUrl(p.image_id))
);
```

If the caller is unauthorized, the response is `404` (not `403`) — Butterbase deliberately hides existence to avoid leaking object IDs.

---

## 4. Access control

Three tiers, evaluated in order:

| Caller | What they can read |
|--------|--------------------|
| Service key (`bb_sk_*`) | Everything in the app — RLS bypassed |
| End-user JWT | Files where `(user_id === caller_id) OR object.public === true OR app.publicReadEnabled === true` |
| Anonymous (no auth) | Only public objects (and only if app access mode allows anon) |

### Per-object public flag

Set at upload time:

```js
manage_storage({ app_id, action: "upload_url", filename, content_type, size_bytes, public: true })
```

Use this for one-off public files (a marketing image, a shared avatar) without flipping the whole app to public-read.

### App-wide public read

```js
manage_storage({
  app_id: "app_abc123",
  action: "update_config",
  publicReadEnabled: true
})
```

When `true`, any authenticated user in the app can download any file. Uploads and deletes stay user-scoped.

> 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 direct presigned URLs.

---

## 5. Listing and deleting

```js
manage_storage({ app_id: "app_abc123", action: "list" })
```

Service key sees everything; end-user JWT sees only their own files. Each item has `id, user_id, key, filename, content_type, size_bytes, created_at`.

```js
manage_storage({ app_id: "app_abc123", action: "delete", object_id: "9c14b2e0-..." })
```

Permanently removes the S3 object and DB row. **Clear foreign-key references first** (e.g. `UPDATE users SET avatar_id = NULL`) — `manage_storage` doesn't.

---

## 6. Quotas & error codes

| Limit | Default | Override |
|-------|---------|----------|
| Per-file size | 10 MB | `storage_config` |
| Total app storage | Plan-dependent | Upgrade plan |
| Allowed content types | All by default | `storage_config.allowedContentTypes` whitelist |

| Error | When |
|-------|------|
| `QUOTA_FILE_SIZE_EXCEEDED` (400) | `size_bytes` > per-file limit |
| `QUOTA_STORAGE_EXCEEDED` (429) | App total exhausted |
| `VALIDATION_INVALID_TYPE` (400) | `content_type` not in whitelist |
| `RESOURCE_NOT_FOUND` (404) | Object missing or caller unauthorized (deliberately ambiguous) |
| `S3_ERROR` (503) | Transient S3 failure — retry |

---

## 7. Common patterns

### User avatar

1. Upload form posts file → `manage_storage` (`upload_url`, `public: false`).
2. Browser PUTs to `upload_url`.
3. App calls `UPDATE users SET avatar_id = $object_id`.
4. On profile render: `manage_storage` (`download_url`, `object_id: user.avatar_id`).

### Public marketing image

Same flow, but `public: true` at upload time. Then the same download URL is reachable by anonymous visitors (no JWT needed) — useful for landing pages and OG images.

### File attachment with auth

For attachments where access depends on app-level rules richer than "owner or public" (e.g. "members of this workspace can read"), don't expose the presigned URL directly. Wrap downloads in a serverless function:

```ts
export async function handler(req, ctx) {
  const { rows } = await ctx.db.query(
    "SELECT object_id FROM attachments WHERE id = $1 AND workspace_id IN (SELECT workspace_id FROM members WHERE user_id = $2)",
    [attachmentId, ctx.user.id]
  );
  if (!rows.length) return new Response("forbidden", { status: 403 });
  const url = await getDownloadUrlServerSide(rows[0].object_id);
  return Response.redirect(url, 302);
}
```

The function runs as `butterbase_user` and uses your tables' RLS to authorize, then mints a presigned URL with the service key.

---

## 8. Anti-patterns

| Don't | Do |
|-------|-----|
| Store the `s3_key` in your tables | Store the `object_id` UUID |
| Embed presigned URLs in HTML or DB rows | Re-fetch per render — they expire in 1 hour |
| Send the wrong `Content-Type` on PUT | Match exactly what you sent to `upload_url` |
| Call `upload_url` and forget step 3 | Always persist `object_id` after a successful PUT |
| Use `public: true` everywhere "just in case" | Default to private; opt files into public explicitly |
| Delete a file without clearing FKs | Update referencing rows first, then `action: "delete"` |

---

If a `docs/butterbase/00-state.md` exists in the working directory, prefer invoking via `/butterbase-skills:journey-storage` so the journey orchestrator stays in sync.

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [butterbase-ai](https://github.com/butterbase-ai)
- **Source:** [butterbase-ai/butterbase-skills](https://github.com/butterbase-ai/butterbase-skills)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** yes
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-butterbase-ai-butterbase-skills-storage
- Seller: https://agentstack.voostack.com/s/butterbase-ai
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
