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.
- 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).
- 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).