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:

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

upload takes a browser File or Blob. It returns:

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:

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

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

Auth And Safety

Blocked File Types

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

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:

Limits

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:

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:

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.

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