Guides

Storage

Upload and fetch project files from defineApi handlers. Visibility is a per-file public flag.

Every Starbase project has file storage. Apps write files the way they send email: a defineApi handler receives bytes (typically multipart) and calls the injected storage helper. Starbase holds the object-store credentials. You never configure a bucket or see raw keys.

Write, list, and delete files from injected storage in a defineApi handler, or from the org-member dashboard. api/storage.ts is a normal handler at /api/storage. Only GET /storage/objects/{key} and PUT /storage/uploads/{id} are reserved, so <img src> and public paths keep working. There is no platform per-file size limit: files over 100 MiB use the large-file upload URLs (/docs/storage#large-files).

defineApi storage

Every handler receives storage next to db and email. Typical app upload is multipart defineApi plus ctx.storage.upload. Overwrite of the same key is allowed. A missing key throws (the handler 500s unless you catch), same as db. Jobs and sockets use the same storage client.

robodev/api/planets.ts

import { defineApi, z } from "@robodev-ai/sdk";

export const post = defineApi({
  auth: "required",
  multipart: true,
  body: z.object({
    name: z.string().min(1),
  }),
  handler: async (ctx) => {
    const file = ctx.files.find((f) => f.fieldname === "file") ?? ctx.files[0];
    if (file) {
      await ctx.storage.upload(`planet-images/${crypto.randomUUID()}`, file.data, {
        public: true,
        contentType: file.mimetype,
      });
    }
    return { ok: true };
  },
});
  • storage.upload(key, data, { public?, contentType? }) — Buffer, Uint8Array, or string. Default private, application/octet-stream.
  • storage.get(key) — { body, contentType, public }.
  • storage.getUrl(key, { expiresIn? }) — stable public project-host URL, or a short-lived signed project-host URL for private files (default 15 minutes, max 1 hour). Never an object-store hostname.
  • storage.delete(key) and storage.list({ prefix?, limit?, offset? }). list and upload object.url is always the stable project-host path.
  • storage.createUploadUrl(key, options?), storage.createStreamUploadUrl(key, options?) and storage.confirmUpload(key) — upload URLs for large files, see Large files below.
  • upload and get hold the whole file in memory and take at most 100 MiB (413 object_too_large). Larger files go through the upload URLs and getUrl.
  • Files never run as active content: HTML, SVG and XML files download as attachments instead of rendering, and robodev dev and self-hosted servers send Content-Security-Policy: sandbox and X-Content-Type-Options: nosniff on every file. Serve pages from your web root, not from storage.
  • contentType must be one media type (image/png, text/plain; charset=utf-8) and is stored normalized (lowercase, charset only). A multi-valued or malformed one is 400 invalid_request on the upload URLs, and is stored as application/octet-stream when it comes with the upload itself.

Public vs private

  • Public — stable URL https://{projectId}.robodev.povio.dev/storage/objects/{key}. Hosted anonymous GET 302s to the bytes. robodev dev serves GET http://localhost:{port}/storage/objects/{key} as 200 and the bytes.
  • Private — same stable path 404s without a project-user Bearer or a valid exp+sig query (does not leak existence). getUrl and dashboard Copy URL return a 15-minute signed project-host URL.
  • Visibility is set at upload. To change it, delete and re-upload.

Large files

Request bodies that are buffered whole (JSON, forms, multipart ctx.files, rawBody) and the in-memory upload / get stop at 100 MiB. That cap protects server memory; it is not a storage limit, and there is no setting for it. Larger files never pass through your handler: the handler mints an upload URL, the client sends the bytes to it, and your code enforces its own limit with maxBytes. Both calls take { maxBytes?, contentType?, public?, expiresIn? }: maxBytes is a positive integer (omitted = no project limit); contentType pins the stored type; public defaults to false; expiresIn is the seconds the URL can be used to start an upload (default 900, max 3600).

robodev/api/uploads.ts

import { defineApi, z } from "@robodev-ai/sdk";

// Presigned POST straight to the object store; the client confirms afterwards.
export const post = defineApi({
  auth: "required",
  body: z.object({ filename: z.string() }),
  handler: async ({ storage, body, user }) =>
    storage.createUploadUrl(`uploads/${user!.id}/${body.filename}`, {
      maxBytes: 2 * 1024 ** 3, // your limit, enforced by the object store
    }),
});

// Streamed PUT through the project host.
export const put = defineApi({
  auth: "required",
  body: z.object({ filename: z.string() }),
  handler: async ({ storage, body, user }) =>
    storage.createStreamUploadUrl(`videos/${user!.id}/${body.filename}`, {
      maxBytes: 10 * 1024 ** 3,
      contentType: "video/mp4",
    }),
});

browser

// Presigned POST: every field first, the file last as "file". 204 on success.
const upload = await api.post("/api/uploads", { filename: file.name });
const form = new FormData();
for (const [k, v] of Object.entries(upload.fields)) form.append(k, v);
form.append("file", file);
await fetch(upload.url, { method: "POST", body: form });
await api.post("/api/uploads/confirm", { key: upload.key }); // your handler: ctx.storage.confirmUpload(key)

// Streamed PUT: the raw bytes, single use. The object is stored when the response arrives.
const stream = await api.put("/api/uploads", { filename: file.name });
const res = await fetch(stream.url, { method: "PUT", headers: stream.headers, body: file });
const stored = await res.json(); // { key, public, size, contentType, url }
  • createUploadUrl → { key, method: "POST", url, fields, expiresAt, maxBytes }. Hosted, url is the object store itself: the one API that returns an object-store URL. The store enforces maxBytes (or its own 5 GiB single-POST maximum) and answers 400 EntityTooLarge to a larger file. Then ctx.storage.confirmUpload(key) moves the bytes into place and returns the StorageObject. It is idempotent; 404 upload_not_found when there is no upload for the key, 409 upload_incomplete before the bytes arrived or while another confirm for the same upload is still running — retry after a moment. A confirm that failed or was interrupted can simply be called again.
  • createStreamUploadUrl → { key, method: "PUT", url, headers, expiresAt, maxBytes }. url is PUT /storage/uploads/{id}?exp&sig on the project host, which streams the body to the store without buffering it, never wakes your handler, and needs no confirm. Any Content-Type is accepted; the minted contentType wins, then the request's, then application/octet-stream. 200 returns the StorageObject.
  • Stream errors: 413 object_too_large as soon as more than maxBytes arrive (immediately when Content-Length says so); nothing is stored and an existing object at the key is untouched. 403 invalid_upload_url, 403 upload_url_expired, 409 upload_already_used (single use: a retry needs a new URL).
  • Expiry is checked when the upload starts; an upload that started in time may finish later. Minting never touches an existing object; a confirmed or completed upload replaces it, like upload().
  • Uploads that are never confirmed, and their bytes, are cleaned up 24 hours after expiresAt. A streamed upload must finish within 24 hours. A presigned POST tops out at 5 GiB (an object-store limit); larger files use the streamed PUT. A streamed PUT is one multipart upload of at most 10,000 parts of up to 64 MiB: with a Content-Length (or maxBytes) it tops out at 625 GiB; chunked with neither, parts are 16 MiB and it tops out at 156.25 GiB. A declared length above that, or above maxBytes, is refused with 413 before any byte is read.
  • robodev dev and the self-hosted server emulate both on the local server with the same fields, URLs and limits: the POST form is parsed as a stream and the bytes go to the local store.
  • Buffered request body (JSON, forms, multipart, rawBody): 100 MiB → 413 payload_too_large with limitBytes.
  • ctx.storage.upload / get: 100 MiB → 413 object_too_large.
  • createStreamUploadUrl: none unless you pass maxBytes; the multipart ceiling is 625 GiB with a Content-Length or maxBytes, 156.25 GiB chunked without either.
  • createUploadUrl: none unless you pass maxBytes; 5 GiB per POST at the object store.
  • Dashboard upload: no platform limit (the presigned POST, so 5 GiB).
  • Hosted only: large requests in flight at once share a server-wide memory budget, and one project may use at most half of it. Over that, a buffered body over 1 MiB or a createStreamUploadUrl PUT answers 503 server_busy with Retry-After: 5 and { scope: project | server, retryAfterSeconds }. A refused stream upload URL stays unused, so retry the same URL. createUploadUrl (presigned POST) and the dashboard upload go straight to the object store and never count.

Reserved routes

GET /storage/objects/{key} and PUT /storage/uploads/{id} are reserved on every project host, including projects that have never been deployed. The key is required and URL-encoded. api/storage/objects.ts and api/storage/objects/** are not registered. api/storage.ts deploys at /api/storage.

  • Public object — hosted 302, or robodev dev 200 + bytes, without auth, with Bearer, or with a valid exp+sig.
  • Private object — 404 unless the request has a valid project-user (or local Auth) Bearer or a valid exp+sig. 404 does not leak existence.

robodev dev

ctx.storage works offline. Bytes persist under .robodev/dev/storage; metadata is reserved robodev_storage.objects on the same local Postgres as jobs (not database.ts). Reserved GET returns 200 and the bytes with Content-Type from metadata — hosted still 302s to S3. Public objects are anonymous. Private objects 404 unless a local Auth Bearer or a valid unexpired exp+sig (same HMAC as hosted, secret robodev-dev-local-secret, projectId prj-local). Keys, list defaults, the 100 MiB in-memory cap, and the upload URLs (served by the dev server, bytes streamed to .robodev/dev/storage) match the handler API.

Keys, size, and access

  • Keys are 1–512 characters: A-Za-z0-9._- and /. No leading slash, no empty or .. segments.
  • No platform per-file limit, hosted or local: your code sets one with maxBytes. Buffered requests and in-memory upload / get have a 100 MiB cap; see Large files above.
  • Hosted: too many large requests at once (across the server, or from one project) answer 503 server_busy with Retry-After; retry after the delay. See Large files above.
  • Wrap storage in your own handlers for access control. The dashboard is org-member only.

Dashboard

Open /projects/:id/storage. Upload with a public/private switch (default private) and an optional key. The browser sends the file straight to the object store with a presigned POST, shows a progress bar, then confirms; a failed upload offers Retry. Copy URL uses the stable public path or a 15-minute signed private project-host URL. Delete asks for confirmation.

Object store host

getUrl, list/upload JSON, and dashboard Copy URL never return the object-store hostname. Browsers only see project-host /storage/objects/{key} (plus exp and sig for private files). The 302 target may be an internal presigned GET. The one exception is createUploadUrl: hosted, its url is a presigned POST on the object store (the bucket allows POST from browsers).