Object storage

Lakebed has built-in object storage for user-uploaded files such as avatars, attachments, and documents. File bytes never pass through your queries, mutations, or custom application endpoints.

Storage uses HTTP routes at your app's origin and a client SDK for uploads and deletes. There is no ctx.storage server-side API in this version.

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 public. Anyone with the URL can read it if the app allows guests and the deployment is unrestricted. Omit the option to keep the object private to signed-in users of this deployment.

client.storage has type StorageClient, and upload resolves to an UploadedObject. Import both from lakebed/client when you pass the result through your own functions:

import type { UploadedObject } from "lakebed/client";

async function saveAvatar(object: UploadedObject): Promise<void> {
  await setAvatar(object.url);
}

Hosted uploads and deletes require a claimed deployment and a signed-in Google account with a verified email. Local development does not have these requirements.

Read stored objects

Use a public object's returned url directly in an element:

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

Public URLs still follow the app's access policy. A private URL requires an authenticated request from a signed-in user of the same deployment. The returned private URL does not include authentication, so it does not work as an <img src> or other anonymous browser request. The SDK does not provide a private object download method.

If the capsule sets auth: { requireSignIn: true }, stored-file routes also require sign-in, including public object routes. Restricted deployments also require an approved account. A plain <img> request does not add a sign-in token. To serve images without tokens, use an unrestricted deployment, leave the app-wide sign-in policy off, and guard private operations in their handlers. Changing the policy does not remove copies already downloaded or cached.

HTTP routes

These routes are available at your app's origin. The client SDK uses them for uploads and deletes.

Built-in storage routes accept a Lakebed identity token in Authorization: Bearer <token>. Custom app endpoints use a separate header for Lakebed identity.

POST   /storage                 # upload
GET    /storage/public/<id>     # public download, subject to app access policy
GET    /storage/private/<id>    # private download (signed-in user)
DELETE /storage/public/<id>     # delete
DELETE /storage/private/<id>    # delete

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

CodeStatusMeaning
invalid_request400Malformed request.
forbidden403Deployment is unclaimed, the account is not verified, or access is denied.
not_found404No such object.
too_large413File exceeds 5 MiB.
quota_exceeded413Developer is over 100 MiB total.
blocked_type415Executables, HTML, and scripts cannot be uploaded.
unavailable503Storage 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, userId } from "lakebed/server";

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

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

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

Client:

import { createClient, retryAuth, SignInWithGoogle, useAuth } from "lakebed/client";
import type app from "../server/index";

const client = createClient<typeof app>();

export function App() {
  const auth = useAuth();
  if (auth.isLoading) {
    return <main className="min-h-screen bg-black p-6 text-white">Checking session</main>;
  }
  if (auth.error || !auth.isSignedIn) {
    return (
      <main className="min-h-screen bg-black p-6 text-white">
        {auth.error ? <p role="alert">{auth.error}</p> : null}
        {auth.error ? <button type="button" onClick={() => void retryAuth()}>Retry</button> : null}
        <SignInWithGoogle />
      </main>
    );
  }
  return <Profile />;
}

function Profile() {
  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="min-h-screen bg-black p-6 text-white">
      {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>
  );
}

Keep Google sign-in available when auth.error is set. retryAuth() cannot renew an expired or revoked token for a pending guest upgrade. Signing in again can finish that upgrade.

The avatar is public. Use an unrestricted deployment so it renders for any visitor. The table stores a Lakebed user reference and the URL string. The handlers require sign-in because hosted uploads need a signed-in account. The app-wide policy stays off so public image requests can load without a token.

Local development

Files uploaded with npx lakebed dev disappear when the development server restarts. Local uploads do not require Google sign-in.

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.

From the CLI

npx lakebed storage reaches the same objects your app does, without a browser:

npx lakebed storage list --port 3000
npx lakebed storage list <deploy-id> --prefix public/
npx lakebed storage put ./avatar.png --public
npx lakebed storage get public/<id> --out ./avatar.png
npx lakebed storage delete public/<id>

With no deploy id the commands talk to npx lakebed dev on --port (3000 by default). Pass a deploy id or URL to reach a hosted app. Every command takes --json. get --json also needs --out, because the file bytes cannot share standard output with the JSON.

Against npx lakebed dev the CLI acts as the guest from npx lakebed auth as <name>, or guest:cli when none is set, so every command works locally. Two operations are hosted-limited, because the routes behind them belong to app users rather than to you as the developer. Hosted put needs a signed-in account with a verified email, so upload from the app with client.storage.upload instead. Hosted get works for public keys when the app does not require sign-in and is not restricted. Private keys, and public keys on a sign-in-only app, fail with 401 or 403. Hosted list and delete both work: delete goes through the owner route, so you can remove any object in your app regardless of who uploaded it.