# Storage

> Declared buckets, function-authorized access, and signed URLs.

[HTML version](https://snoozestack.com/docs/storage/) · [Docs index](https://snoozestack.com/llms.txt)

Object storage for user uploads — avatars, attachments, exports. Buckets are declared in `project.toml`, so what storage exists is a reviewable file; access goes through your [functions](https://snoozestack.com/docs/functions.md) via the `storage` capability, so *who may touch which path* is code you wrote. Locally each bucket is a real directory under `.snoozestack/storage/`; hosted, the same declarations are backed by managed object storage — your application code doesn't change.

**Why use it**

Files could technically go in the database as blob columns, and that is almost always the wrong call. A database is tuned for many small rows read transactionally; a 4 MB photo in a row bloats every backup and pushes useful data out of cache. Object storage is built for the opposite shape — few, large, immutable blobs, served directly.

The pattern worth internalizing, because it's how essentially every production app handles uploads: **the bytes go to storage, and the database row holds only the path.** Your `messages` table stores `photo_path`, not the image. Queries stay small, the file is served without streaming through your handler, and the two are authorized independently.

Access control is the same story as [Permissions](https://snoozestack.com/docs/permissions.md) everywhere else: there is no policy language to learn, because there is no client-facing write route to police. A function mints the signed operation, and the function knows who's asking.

### Declaring buckets

```bash title="snoozestack/project.toml"
[[storage]]
name = "avatars"
public = true          # objects readable at a stable URL

[[storage]]
name = "documents"     # private (the default): reads need a signed URL
```

A bucket is its declaration: the runtime serves exactly the buckets the file declares, so `snoozestack publish` brings production's in line and `snoozestack preview` your preview's. There is no command that creates, flips or deletes a bucket — change the file and publish. Each namespace (dev, live, every preview) holds genuinely separate files.

- **User-generated media** — Avatars, post images, message attachments. Have the function namespace the path by the caller's id (`<uid>/<file>`) so every user gets their own area — enforced by the code that mints the path, in one place.
- **Private documents** — Invoices, contracts — a private bucket plus short-lived signed URLs, so a leaked link stops working instead of exposing the file forever.
- **Generated artifacts** — CSV exports, PDF reports, thumbnails: a function writes the file with `capabilities.storage.put()` and hands back a signed URL to download it.
- **Public static assets** — Logos, product images — a public bucket serves them at a stable URL with no auth round-trip. (For a whole site of files, see [Websites](https://snoozestack.com/docs/websites.md) instead.)

| Surface | Availability |
| --- | --- |
| Portal UI | Read-only bucket and object listing (hosted console and local dashboard alike); files move with the CLI and signed operations |
| CLI | `snoozestack storage list/push/create/public/private/delete`, each scoped by `--dev`/`--preview` |
| SDK / HTTP | No storage client, deliberately — the app asks your function, then PUTs/GETs the signed URL directly |

**Diagram:** Client · wants to upload · ask · Your function · capabilities.storage · signed URL · Client PUTs bytes · direct, time-limited · Bucket · declared [[storage]] · Public bucket read · stable URL, no auth · GET /storage/v1/object/public/… · The function decides the path and whether this caller may write it; the bytes never stream through your handler.

### The capability surface

A function declaring the `storage` capability can reach every declared bucket:

```bash title="inside a function"
await capabilities.storage.put("avatars", `${user.id}/avatar.png`, bytes);
const file  = await capabilities.storage.get("avatars", key);
const files = await capabilities.storage.list("avatars", `${user.id}/`);
await capabilities.storage.delete("avatars", key);

// Time-limited read for a private object:
const url = await capabilities.storage.signedUrl("documents", key, { expiresIn: 3600 });

// Hosted: authorize an upload without the bytes touching your function:
const upload = await capabilities.storage.signedUploadUrl("avatars", `${user.id}/avatar.png`);
```

Every method is `await`-able in both dev and hosted, so the same function code runs in both. `signedUploadUrl` is the path for large uploads — the client PUTs straight against it, so the bytes never pass through your function. Hosted that is a presigned S3 URL; under `snoozestack dev` it points at the dev server itself and works the same way (same PUT, same content-type rule, CORS answered for a site on its own port), with uploads capped at 64 MB locally. Signed URLs, read and upload, are minted with a key kept in `.snoozestack/`, so one issued before a dev restart is still valid after it; `snoozestack dev reset` rotates it.

### The usual pattern: upload, then store the path

```bash title="app code (snoozestack-js)"
// 1. Ask your function for an upload slot. It namespaces the path by the
//    caller's own id — the client never chooses where it may write.
const { data: slot } = await snoozestack.functions.invoke("photo-upload-url", {
  body: { contentType: file.type },
});

// 2. Bytes → storage, directly against the signed URL.
await fetch(slot.url, { method: "PUT", body: file, headers: { "content-type": file.type } });

// 3. Path → database, via a function. The row stays tiny.
await snoozestack.functions.invoke("post-message", {
  body: { body: text, photo_path: slot.path },
});

// 4. Reading back from a public bucket: build the URL from the stored path.
const url = `${SNOOZESTACK_URL}/storage/v1/object/public/photos/${slot.path}`;
```

Store the *path*, not the full URL. Paths stay valid if the bucket's visibility changes, if you attach a custom domain, or if a private bucket later needs signed URLs — a full URL baked into a row has to be rewritten in all of those cases.

### Sharing files: public vs. signed URLs

A **public** bucket serves objects at a stable URL, for anyone, forever — right for content that is genuinely public. A **private** bucket serves only through `signedUrl()`, whose links expire — right for anything where “who may see this” is a question. If you find a function proxying file bytes to apply a rule, mint a signed URL instead: the rule runs in the function, the bytes don't.
