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:
key:"public/<id>"or"private/<id>". Store this if you ever want to delete the object.url: an absolute URL for the stored object. Public URLs work directly in<img src>when the app allows public reads. Private URLs require an authenticated request.sizeandcontentType: the stored byte length and MIME type.
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
POST /storagerequires a claimed deployment and an authenticated Google account with a verified email. Add?public=trueto make the object public. Objects are private by default. The request body contains the file bytes, and theContent-Typeheader sets the stored type. Returns201with JSON{ key, url, size, contentType }.GET /storage/public/<id>requires no sign-in only on unrestricted deployments withrequireSignIn: false. Those responses useCache-Control: public, max-age=3600. Sign-in-only or restricted apps require an account that passes the app's access policy and useCache-Control: private, no-store.GET /storage/private/<id>requires an authenticated request from a signed-in user of the same deployment.DELETE /storage/public/<id>orDELETE /storage/private/<id>requires a claimed deployment and an authenticated Google account with a verified email. Deletes are idempotent.
Authentication and safety
- Hosted uploads and deletes require a claimed deployment and a signed-in Google account with a verified email. Guests cannot upload. Each stored object records the uploader's ID, email, and upload time.
- On unrestricted apps that allow guests, anyone with a public object's URL can read it. The
idis an unguessable random token, so treat the URL as a read credential. Sign-in-only and restricted apps still enforce their access policy. - Private objects require an authenticated request from a signed-in user of the same deployment. Store private object keys in your database and return them only from queries that verify ownership.
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-Typeis an executable, installer, script, HTML, or JavaScript type (for exampleapplication/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>/storageon 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>requires a developer session or token. The path suffix is the object'skey. Deployment owners can delete any object, regardless of who uploaded it. - Inspect route:
GET <deploy-url>/__lakebed/storagereturns the same listing as JSON. Likedb/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 | Deployment is unclaimed, the account is not verified, or access is denied. |
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, 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.
- No server-side
ctx.storageAPI. - 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.
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.
listprints key, size, type, and uploader, then the total count and bytes. Local listings need no credentials. Hosted listings include uploader emails, so they need credentials: run the command from the capsule directory afternpx lakebed auth login, or pass--inspect-token(LAKEBED_INSPECT_TOKENin CI). Hosteddeleteneeds the deploy owner's developer credentials:npx lakebed auth loginorLAKEBED_TOKEN. The server lists the newest 500 files, so--prefixfilters that page and says when older files were not checked.putuploads one file under the 5 MiB limit and prints the assigned key and URL. Storage assigns the key, so there is no way to choose it. Add--publicfor a public object and--content-typeto override the type guessed from the file extension.getwrites the bytes to--out, or to standard output when--outis omitted.deleteremoves one object by key. Use a deploy ID when a hosted URL does not match the current capsule's saved or bound deploy.
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.