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. In fuast dev it uses the host you opened the app on (http://localhost:3000/storage/... when you browse localhost), 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 a Uint8Array or 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 } or null for a missing or malformed key. uploadedBy is the uploader's ctx.auth.userId (null for objects stored by the system) and uploadedAt is 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, and delete work 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-Type is 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.