# Object Storage

Lakebed has built-in object storage for user-uploaded files: avatars, attachments, documents. File bytes go browser ↔ runner ↔ S3-compatible bucket directly. They never pass through your queries, mutations, endpoints, or the isolate.

Storage is a plain HTTP surface served at your app's own origin, plus a tiny client SDK for uploads and deletes. There is no `ctx.storage` server-side API in this version. Reads are just URLs.

## Upload And Delete From The Client

The SDK lives on your Lakebed client as `client.storage`:

```ts
const { key, url } = await client.storage.upload(file, { public: true });
await client.storage.delete(key);
```

`upload` takes a browser `File` or `Blob`. It returns:

- `key`: `"public/<id>"` or `"private/<id>"`. Store this if you ever want to delete the object.
- `url`: an absolute URL you can use directly, for example in `<img src>`.
- `size` and `contentType`: the stored byte length and MIME type.

Pass `{ public: true }` to make the object readable by anyone with the URL. Omit it (the default) to keep the object private to signed-in users of this deploy.

## Reads Are Just URLs

You do not need the SDK to read a file. Drop the returned `url` into an element:

```tsx
<img src={user.avatarUrl} alt="" />
```

A public URL renders for anyone, signed in or not. A private URL requires a signed-in user of this deploy. The SDK is only needed for uploads and deletes.

## HTTP Surface

The runner serves these at your app's origin. The client SDK calls them for you, but they are plain HTTP if you need them directly.

```http
POST   /storage                 # upload
GET    /storage/public/<id>     # public download (no auth)
GET    /storage/private/<id>    # private download (signed-in user)
DELETE /storage/public/<id>     # delete
DELETE /storage/private/<id>    # delete
```

- `POST /storage` — auth required. Pass the token the client already holds as `?lakebed_token=<jwt>`. Add `?public=true` to make the object public; the default is private. The request body is the raw file bytes, and the `Content-Type` header sets the stored type. Returns `201` with JSON `{ key, url, size, contentType }`.
- `GET /storage/public/<id>` — no auth. Served to anyone, including signed-out visitors. Cached with `Cache-Control: public, max-age=3600` (a moderate TTL so a deleted object does not linger in shared caches for long).
- `GET /storage/private/<id>` — requires a signed-in user of this deploy (token query param).
- `DELETE /storage/public/<id>` or `DELETE /storage/private/<id>` — auth required. Idempotent.

## Auth And Safety

- Uploads and deletes require a real signed-in Google account with a verified email. Guests cannot upload, so every object is attributable. Each stored object records the uploader's id, email, and timestamp in its metadata.
- Public objects are readable by anyone with the URL. The `id` is an unguessable random token, so treat the URL as a capability: holding it is permission to read.
- Private objects require a signed-in user of the deploy. The app keeps private keys secret, typically by storing them in its database tied to ownership and only returning them from queries the owner is allowed to run.

## Blocked File Types

Storage is for user files (images, documents, media), not code. Uploads are rejected with `415 blocked_type` when either:

- the declared `Content-Type` is an executable, installer, script, HTML, or JavaScript type (for example `application/x-msdownload`, `application/java-archive`, `text/html`, `text/javascript`), or
- the file's leading bytes identify an executable regardless of declared type: Windows PE (`.exe`/`.dll`), ELF, Mach-O, Java class files, and shebang scripts, plus obvious HTML documents.

Serving is hardened independently: every download is sent with `X-Content-Type-Options: nosniff` and a sandboxing `Content-Security-Policy`, and anything stored with an HTML or JavaScript content type is served as `application/octet-stream` with `Content-Disposition: attachment`, never renderable. SVG is allowed and safe to embed with `<img>`; the sandbox CSP prevents script execution if one is opened directly.

## Developer Dashboard And Moderation

Deploy owners can see and moderate everything their users uploaded:

- **Dashboard**: `/deploys/<deployId>/storage` on the Lakebed dashboard host lists each file's key, visibility, size, type, uploader, and age, with per-file delete.
- **Owner delete**: `DELETE /v1/me/deploys/<deployId>/storage/<public|private>/<id>` (developer session or token) — the path suffix is the object's `key`. This is an owner override — it works on any object in the deploy, regardless of uploader. Use it to reclaim quota from unwanted uploads.
- **Inspect route**: `GET <deploy-url>/__lakebed/storage` returns the same listing as JSON. Like `db/export`, it always requires credentials (claim token, developer token, or admin session) because listings include uploader emails.

## Limits

- 5 MiB per file.
- 100 MiB total stored per developer, summed across all of their deploys. Deleting files frees space.
- Exceeding either returns `413`.

## Errors

Failures return JSON `{ error: { code, message } }`.

| Code | Status | Meaning |
| --- | --- | --- |
| `invalid_request` | 400 | Malformed request. |
| `forbidden` | 403 | Not a verified signed-in account, or not allowed. |
| `not_found` | 404 | No such object. |
| `too_large` | 413 | File exceeds 5 MiB. |
| `quota_exceeded` | 413 | Developer is over 100 MiB total. |
| `blocked_type` | 415 | Executables, HTML, and scripts cannot be uploaded. |
| `unavailable` | 503 | Storage is temporarily unavailable; retry later. |

## Example: Profile Picture

Upload the file as public, save the returned `url` on the user's row with a normal mutation, then render it for any visitor.

Server:

```ts
import { capsule, mutation, query, string, table } from "lakebed/server";

export default capsule({
  schema: {
    users: table({
      ownerId: string(),
      avatarUrl: string().optional()
    }).index("by_owner", ["ownerId"])
  },

  queries: {
    me: query(async (ctx) =>
      ctx.db.users
        .withIndex("by_owner", (q) => q.eq("ownerId", ctx.auth.userId))
        .first()
    )
  },

  mutations: {
    setAvatar: mutation(async (ctx, avatarUrl: string) => {
      const user = await ctx.db.users
        .withIndex("by_owner", (q) => q.eq("ownerId", ctx.auth.userId))
        .first();
      if (!user) {
        await ctx.db.users.insert({ ownerId: ctx.auth.userId, avatarUrl });
        return;
      }
      await ctx.db.users.update(user.id, { avatarUrl });
    })
  }
});
```

Client:

```tsx
import { createClient } from "lakebed/client";
import type app from "../server";

const client = createClient<typeof app>();

export function App() {
  const me = client.useQuery("me");
  const setAvatar = client.useMutation("setAvatar");

  async function onPick(event: Event) {
    const file = (event.currentTarget as HTMLInputElement).files?.[0];
    if (!file) {
      return;
    }
    const { url } = await client.storage.upload(file, { public: true });
    await setAvatar(url);
  }

  return (
    <main className="p-6">
      {me?.avatarUrl ? <img className="h-16 w-16 rounded-full" src={me.avatarUrl} alt="" /> : null}
      <input type="file" accept="image/*" onChange={(event) => void onPick(event)} />
    </main>
  );
}
```

The avatar is public, so it renders for any visitor, signed in or not. The server only stores the URL string; the bytes live in the bucket.

## Local Dev

`npx lakebed dev` uses an in-memory store that resets when the dev process restarts. It also relaxes the verified-account requirement, so you can test uploads locally without Google sign-in.

Hosted deploys store objects in Lakebed-managed buckets. There is nothing to configure.

## Not Included

This version is intentionally small. Build app-specific behavior by storing the returned keys in your own tables.

- No server-side `ctx.storage` API.
- No presigned URLs.
- No multipart, resumable, or streaming uploads.
- No public listing API.
- No image transforms.

Apps that need indexing or per-file authorization store the returned `key` in their own tables and enforce ownership in queries and mutations.
