# Storage

Every app has object storage for files: avatars, attachments, PDFs, exports. Browsers upload with `client.storage`, and handlers read and write files with `ctx.storage`. Keep the returned `key` in your own tables and enforce ownership in your queries.

## Upload from the client

```ts
const object = await client.storage.upload(file, { public: true });
// { key: "public/<id>", url: "https://my-app.fuast.com/storage/public/<id>", size, contentType }

await client.storage.delete(object.key);
```

`upload(file, opts?)` takes a `File` or `Blob`. Without `{ public: true }` the object is private. It resolves to an `UploadedObject`:

- `key`: `"public/<id>"` or `"private/<id>"`. Store it to delete or serve the file later.
- `url`: absolute URL of the object on the app's origin. In `fuast dev` it uses the host you opened the app on (`http://localhost:3000/storage/...` when you browse `localhost`), so `<img src={url}>` stays same-origin and sends the session cookie.
- `size`, `contentType`: stored byte length and MIME type.

Uploads need a signed-in account. Guests on public apps cannot upload.

`uploadFile(file)` from older fuast apps still works and uploads a private object.

## Server-side storage

`ctx.storage` reads and writes the same objects from a handler:

```ts
const { key, url } = await ctx.storage.put(csv, { contentType: "text/csv" });   // private
const avatar = await ctx.storage.put(bytes, { contentType: "image/png", public: true });
const data = await ctx.storage.get(key);     // Uint8Array, or null when missing
const info = await ctx.storage.stat(key);    // metadata, or null when missing
ctx.storage.url(key);                        // absolute URL string
await ctx.storage.delete(key);
```

- `put(body, { contentType, public? })` takes a `Uint8Array` or a string and returns `{ key, url }`. The object is attributed to the caller (`ctx.auth.userId`), or to no one in schedules and jobs.
- `stat(key)` returns `{ key, size, contentType, public, uploadedBy, uploadedAt }` or `null` for a missing or malformed key. `uploadedBy` is the uploader's `ctx.auth.userId` (`null` for objects stored by the system) and `uploadedAt` is epoch milliseconds. Use it to check a key a client sends you before you save it.
- `url(key)` is available in every handler and returns an absolute URL on the app's origin. `get`, `stat`, `put`, and `delete` work in mutations, actions, endpoints, and schedules, not in queries.
- Storage is outside the database transaction. If a mutation stores a file and then throws, the file stays. Write files after the checks that can fail, or delete them in a `catch`.

A schedule that writes a nightly CSV export, an endpoint that receives a webhook attachment, or an action that renders a PDF all use `ctx.storage`.

## Who can read a file

| Object | Readable by |
| --- | --- |
| `public/<id>` | Anyone who can open the app. On a public app without `requireSignIn`, anyone with the URL. |
| `private/<id>` | Signed-in users who can open the app. |

Private URLs work in `<img>`, `<embed>`, and `<a>` inside your app, because the browser sends the app's session cookie with same-origin requests. They do not work for someone without access.

Every user with access to the app can fetch every private object whose key they know, and object ids are unguessable. Per-user privacy therefore comes from your queries: store keys in rows with an owner, and return a key or URL only to callers allowed to see it.

## HTTP routes

```http
POST   /storage[?public=true]     upload; body is the file, Content-Type sets the type
GET    /storage/public/<id>
GET    /storage/private/<id>
DELETE /storage/public/<id>
DELETE /storage/private/<id>
```

These live on the app origin. Uploads return `201` with `{ key, url, size, contentType }`. Deletes are idempotent and allowed for the uploader, the app owner, and workspace admins. Routes accept the app session cookie or `Authorization: Bearer <token>`.

Public responses on public apps use `Cache-Control: public, max-age=3600`. Everything else uses `Cache-Control: private, no-store`.

## Blocked types and safe serving

Storage is for files people use, not code. Uploads fail with `415 blocked_type` when:

- the declared `Content-Type` is an executable, installer, script, HTML, or JavaScript type, or
- the first bytes identify an executable (Windows PE, ELF, Mach-O, Java class, shebang script) or an HTML document, whatever the declared type.

Every download is served with `X-Content-Type-Options: nosniff` and a sandboxing `Content-Security-Policy`. Anything stored as HTML or JavaScript is served as `application/octet-stream` with `Content-Disposition: attachment`. SVG is allowed and safe in `<img>`.

## Limits

- 25 MiB per file.
- 1 GiB stored per workspace, across all its apps. Deleting files frees space.

Both are configurable on self-hosted platforms. See [limits](https://fuast.com/docs/limits.md).

## Errors

Failures return `{ "error": { "code", "message" } }`:

| Code | Status | Meaning |
| --- | --- | --- |
| `invalid_request` | 400 | Malformed request or empty body. |
| `unauthenticated` | 401 | No signed-in account. Guests cannot upload. |
| `forbidden` | 403 | No access to this app or object. |
| `not_found` | 404 | No such object. |
| `too_large` | 413 | File over 25 MiB. |
| `quota_exceeded` | 413 | Workspace over 1 GiB. |
| `blocked_type` | 415 | Executables, HTML, and scripts are rejected. |

`client.storage.upload` rejects with the message. `ctx.storage.put` throws it.

## Example: attachment on a document

The client uploads, then sends only the `key`. The server checks that the caller uploaded that object, stores the key, and computes the URL itself whenever it returns the document. Never store or return a URL the client sent you: a client can send any URL, including one that points at another site, and a key it did not upload may belong to someone else.

```ts
// server/index.ts
documents: table({
  title: string(),
  authorId: userId(),
  fileKey: string().optional(),
  fileType: string().optional()
}).index("by_author", ["authorId"])

createDocument: mutation(async (ctx, title: string, fileKey: string | null) => {
  const { userId } = ctx.auth.requireSignedIn();
  let fileType: string | null = null;
  if (fileKey) {
    const info = await ctx.storage.stat(fileKey);
    if (!info || info.uploadedBy !== userId) throw new Error("Upload the file before attaching it.");
    fileType = info.contentType;
  }
  return ctx.db.documents.insert({ title: title.trim(), authorId: userId, fileKey, fileType });
}),

myDocuments: query(async (ctx) => {
  const { userId } = ctx.auth.requireSignedIn();
  const docs = await ctx.db.documents.withIndex("by_author", (q) => q.eq("authorId", userId)).collect();
  return docs.map((d) => ({ ...d, fileUrl: d.fileKey ? ctx.storage.url(d.fileKey) : null }));
})
```

```tsx
// client/index.tsx
async function create(title: string, file: File | null) {
  const uploaded = file ? await client.storage.upload(file) : null;
  await createDocument(title, uploaded?.key ?? null);
}
```

Computing the URL in the query also keeps it right when the app moves to a custom domain, or when you open `fuast dev` on a different host.

The [signature template](https://fuast.com/docs/examples/signature.md) is a complete version.

## Local development

`fuast dev` stores files in `.fuast/dev` next to the database. They survive restarts and are wiped by `fuast dev --reset`. The same routes, blocked types, and limits apply.

## From the CLI

```sh
fuast storage list --port 3000
fuast storage put ./logo.png --public
fuast storage get private/<id> --out ./file.pdf
fuast storage delete public/<id>
```

Without a slug, commands talk to `fuast dev` on `--port` (default 3000). For a deployed app, pass its slug last: `fuast storage list my-app`, `fuast storage put ./logo.png my-app --public`. Every command takes `--json`. Hosted `list`, `get`, and `delete` need owner or workspace admin credentials. `put` uploads as you.

The dashboard Storage tab lists every object with its size, type, uploader, and age, and can delete any of them.
