storage logo

storage

Handles all file operations in Butterbase apps through a single manage_storage tool with five actions: upload_url, download_url, list,...

SKILL.md

Full skill instructions

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.

ActionPurpose
upload_urlGenerate a 15-minute presigned PUT URL and reserve an object_id
download_urlGenerate a 1-hour presigned GET URL for a stored object
listList objects (scoped by caller's role)
deletePermanently remove an object from S3 + database
update_configToggle app-level publicReadEnabled and other storage settings

1. The mental model: object_id vs s3_key

FieldWhat it isWhen you use it
object_idUUID, stable, app-level handlePersist in your tables (e.g. users.avatar_id, posts.image_id)
s3_keyInternal bucket path like app_abc/user_uuid/file.jpgInternal only — never treat this as a URL

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.


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

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:

{
  "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.

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

From the browser:

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

Step 3 — persist the object_id

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.

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:

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:

CallerWhat they can read
Service key (bb_sk_*)Everything in the app — RLS bypassed
End-user JWTFiles 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:

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

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

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.

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

LimitDefaultOverride
Per-file size10 MBstorage_config
Total app storagePlan-dependentUpgrade plan
Allowed content typesAll by defaultstorage_config.allowedContentTypes whitelist
ErrorWhen
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:

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'tDo
Store the s3_key in your tablesStore the object_id UUID
Embed presigned URLs in HTML or DB rowsRe-fetch per render — they expire in 1 hour
Send the wrong Content-Type on PUTMatch exactly what you sent to upload_url
Call upload_url and forget step 3Always 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 FKsUpdate 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.