Browse the docs
Build
Storage
Store user uploads and server-generated files. client.storage, ctx.storage, HTTP routes, access rules, blocked types, limits, errors, and CLI.
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
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. Infuast devit uses the host you opened the app on (http://localhost:3000/storage/...when you browselocalhost), 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:
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 aUint8Arrayor 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 }ornullfor a missing or malformed key.uploadedByis the uploader'sctx.auth.userId(nullfor objects stored by the system) anduploadedAtis 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, anddeletework 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
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-Typeis 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.
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.
// 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 }));
})// 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 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
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.