Palbase
Sign inGet started

Backend SDK

Storage

The Storage service gives your backend file storage organised into buckets: upload and download objects, mint public or time-limited URLs, read the image renditions that were rendered when the file landed, and list, move, copy or remove what is there. Buckets themselves live on the Environment, not in your repository — palbase storage add writes one straight to the stack, and a deploy neither creates nor reconciles them. Storage is one of the ten service singletons you import from @palbase/backend; there is no ctx object to thread through your handlers.

Quick example

import { Controller, Post, User, Database, Storage, PalError, z } from "@palbase/backend";
import type { UserT } from "@palbase/backend";

const TodoExport = z.object({ path: z.string(), url: z.string() });
type TodoExport = z.infer<typeof TodoExport>;

@Controller("/exports")
export class ExportsController {
  @Post("")
  async create(@User() user: UserT): Promise<TodoExport> {
    const rows = await Database.public.todos.findMany({});
    const file = new Blob([JSON.stringify(rows)], { type: "application/json" });

    const bucket = Storage.bucket("exports");
    const path = `${user.id}/todos.json`;

    const { error } = await bucket.upload(path, file, {
      contentType: "application/json",
      upsert: true,
    });
    if (error) throw new PalError(502, "upload_failed", error.message ?? "storage upload failed");

    const { data: signed, error: signErr } = await bucket.createSignedUrl(path, { expiresIn: "1h" });
    if (signErr || !signed) throw new PalError(502, "sign_failed", signErr?.message ?? "could not sign URL");

    return { path, url: signed.signedUrl };
  }
}

Every Storage call except getPublicUrl resolves to the same { data, error, status } envelope the Documents service uses — check error before touching data. Nothing throws on a failed request, including a network failure, which arrives as error.code === "network_error" with status: 0.

This example does not move a client's bytes. The file it stores is one the handler produced itself. For bytes that come from a browser or a phone, the rail is @Upload, and the bytes never enter your process at all.

Buckets live on the stack, not in your repository

A bucket exists because somebody created it on the Environment. Three commands are the whole surface, and each of them writes the stack's own Management API immediately — no file, no commit, no deploy:

palbase storage list
palbase storage add <name> [--public] [--max-size 5MB] [--mime image/png,image/jpeg]
palbase storage remove <name>

add is create-or-update, so re-running it with different limits edits the bucket in place. The full command reference, including the flags each one takes, is on Stack Settings.

Warning: config/storage.ts is dead. defineStorage and bucket() are still exported from @palbase/backend and still validate their input when you author it, but nothing reads them: neither bundler evaluates config/, palbase build produces no evaluated configuration, and nothing about buckets travels in a deploy. A config/storage.ts left over from an older checkout changes nothing — delete it, because it reads like an authority and it is not one.

Image variants

A bucket can declare renditions, and the stack renders them the moment an image lands — no queue, no second request from your code:

palbase storage add posts \
  --variant card=640x480:cover:webp:82 \
  --variant thumb=160x160:cover:webp

name=WxH:fit:format[:quality], one flag per rendition. fit is cover, contain or inside; format has to be one this stack can render, and a format it cannot is refused when you declare it rather than at the first upload. Quality defaults to the stack's own when omitted.

The client then asks for one by name:

import { Storage } from "@palbase/backend";

declare const path: string;

// `Storage.buckets.<name>` is the TYPED accessor: the variant name is checked
// against the renditions THAT bucket declared, so `{ variant: "crad" }` is a
// compile error. A bucket with no declared renditions accepts no variant at all.
const original = Storage.buckets.posts.getPublicUrl(path);
const card = Storage.buckets.posts.getPublicUrl(path, { variant: "card" });

The second argument only accepts a rendition the bucket declared — { variant: "crad" } does not compile. palbase storage list prints the renditions a bucket carries, and move/copy take them along with the original.

Note: the declaration lives on the stack, like every other bucket setting. palbase storage add is idempotent, so re-running it with a different set of --variant flags is how you change them.

Two consequences follow, and both bite in production rather than at build time:

  • A push never creates a bucket. A call against a bucket the Environment does not hold answers 404 not_found on the first real request, at runtime. Note the message: storage says "No such object." whether the object is missing or the bucket is, so check palbase storage list before you go looking for a path bug.
  • @Upload is the one exception, and it is checked. palbase push reads the stack's live bucket list and refuses the deploy when a route decorated with @Upload names a bucket the stack does not have:
@Upload names 1 bucket(s) this stack does not have:
  posts.createImage → bucket "post-images"
It has: avatars, exports
Create it with `palbase storage add <name>` or fix the name — storage will not create it on demand

Warning: palbase storage remove does not work today, and it is not a safe no-op either. The storage module registers only PUT (create or update) and GET (list objects) on /v1/storage/bucket/{bucket}; there is no DELETE. The delegated call is answered 405 with Allow: PUT, and the management layer — which publishes only 200/204/404/409/501 for this operation — turns that into an HTTP 500. What you see is the stack answered 500: DeleteBucket: the module answered 405, which this operation does not publish. Nothing is deleted and nothing is changed. Studio's bucket-delete control calls the same missing route. To stop using a bucket, delete its objects and leave the bucket in place. When removal does ship it will be destructive and immediate — no confirmation, no undo, and the bucket's files go with it.

The bucket client

Storage.bucket(name) returns a client scoped to one bucket. The name is validated locally against [a-zA-Z0-9_-]+ before any request, so a malformed name throws immediately rather than 404ing later.

Note: Storage.buckets.<name> is the typed twin of Storage.bucket("<name>"), and it is filled from the stack, not from your schema. palbase build asks the linked Environment which buckets it holds and writes them into palbase-stack.d.ts, which augments the Buckets interface — so a bucket exists to the compiler because the stack has it, a typo is a compile error, and getPublicUrl(path, { variant }) refuses a rendition the bucket does not declare. A checkout that has never built against a live stack has an empty Buckets, and Storage.bucket("avatars") is the surface that works regardless.

MethodReturnsDescription
upload(path, file, options?)Promise<PalbaseResult<PalbaseFileObject>>Write a Blob or an ArrayBuffer. The declared type also admits a ReadableStream, which the transport cannot send — see below. Options: { contentType?, upsert? }.
download(path)Promise<PalbaseResult<unknown>>Read the object back over the authenticated route, so it works on private buckets. The signature says Blob; what arrives is never one — see Downloading.
getPublicUrl(path, options?)stringBuild the object's public URL. Synchronous, no network call, no envelope. { variant } selects a declared rendition.
createSignedUrl(path, options)Promise<PalbaseResult<{ signedUrl }>>A time-limited URL for one object. { expiresIn: "1h" } — a duration string, never a bare number.
list(prefix?, options?)Promise<PalbaseResult<PalbaseFileObject[]>>Every object under a path prefix, ordered by path.
remove(paths)Promise<PalbaseResult<PalbaseFileObject[]>>Delete objects — always an array, even for one path.
move(from, to)Promise<PalbaseResult<void>>Move or rename an object inside the bucket.
copy(from, to)Promise<PalbaseResult<void>>Copy an object inside the bucket.

Note: Object paths must match [a-zA-Z0-9_./-]+ and must contain no .. traversal segment. An invalid path throws synchronously, before any network call, with the offending path in the message.

What an object looks like

interface PalbaseFileObject {
  path: string;          // the object's path inside its bucket
  name: string;          // the same value
  bucket: string;
  size: number;          // bytes, as stored
  contentType: string;   // sniffed from the BYTES, not from the request header
  checksum: string;      // SHA-256, computed as the bytes were written

  // Images only:
  width?: number;        // the ORIGINAL's decoded dimensions
  height?: number;
  thumbhash?: string;    // a ~25-byte blurred placeholder, inline, no second request
  variants: Record<string, string>;  // rendition name → a RELATIVE URL
}

checksum is what the object's ETag derives from, so a client that already holds it can tell whether it has these bytes. thumbhash is the thing that replaces a grey skeleton with something already shaped like the picture.

Uploading

import { Storage } from "@palbase/backend";

declare const pdfBlob: Blob;

const bucket = Storage.bucket("posts");

const { data, error, status } = await bucket.upload(
  "u42/todo-7/receipt.pdf",
  pdfBlob,
  { contentType: "application/pdf", upsert: true },
);
  • contentType sets the header the bytes are written with. Storage sniffs the content itself and the stored contentType is what it decided, so treat the option as a hint rather than a declaration.
  • upsert: true becomes an x-upsert: true header and allows replacing an object already at that path.
  • Raw bytes need wrapping first: new Blob([bytes], { type: contentType }). An ArrayBuffer is accepted directly and converted to a Uint8Array before it is sent.

Warning: Never pass a ReadableStream, even though the parameter type accepts one. The type is a promise the transport does not keep: it forwards a string, a Uint8Array, a FormData and a Blob as the body, and sends everything else through JSON.stringify. JSON.stringify of a stream is "{}", so streaming a 1 GB video stores a two-byte object at the path you meant, answers 200 with a PalbaseFileObject whose size is 2, and reports no error anywhere — the failure is only visible the day somebody reads the file back. Buffer the bytes and hand upload a Blob or an ArrayBuffer. For genuinely large files, do not route them through your handler at all: use @Upload, which writes the bytes to storage without them entering your process.

The bucket's limits are enforced at the write, which is the one guard a caller cannot skip. The refusals are specific:

statuserror.codeWhat happened
409already_existsAn object is already at that path and upsert was not set.
413too_largeThe object is larger than the bucket's --max-size.
415unsupported_typeThe bucket's --mime allowlist does not accept the sniffed type.
404not_foundNo such object — or no such bucket; both answer with the same message.

Downloading

download uses the authenticated object route, so it reads private buckets your public URLs cannot. What it hands back is the sharp part.

Warning: download never resolves to a Blob, whatever the signature says. The declared PalbaseResult<Blob> is a bare cast in the SDK, and the transport that fulfils it has no branch that constructs a Blob at all. What data actually holds is decided by the response's Content-Type:

Stored contentTypeWhat data is
contains jsonthe parsed JSON value
starts with image/a Uint8Array of the bytes
anything elsea string — the body run through response.text()

So data.arrayBuffer(), data.stream(), data.type and data.size do not exist at run time. Writing the line the Blob type invites — await data.arrayBuffer() — throws TypeError: data.arrayBuffer is not a function, which is an unhandled 500 on every single call, images included.

The third row is worse than a wrong type: it is lossy. A PDF, a HEIC, a zip is UTF-8-decoded on the way through, and every byte that is not valid UTF-8 is replaced. The string you get back cannot be re-encoded into the bytes that were stored, and nothing anywhere reports an error.

Only two kinds of object survive the trip. A JSON object comes back parsed:

import { PalError, Storage } from "@palbase/backend";

type TodoRow = { id: string; title: string };

const { data, error } = await Storage.bucket("posts").download("u42/todos.json");
if (error) throw new PalError(502, "download_failed", error.message ?? "download failed");

// Already parsed — `data` is the JSON value, not text and not a Blob.
// `as unknown as` is required, and that is the warning above made concrete:
// the declared type is `Blob`, and the value is not one.
const rows = data as unknown as TodoRow[];

An image comes back as its bytes, and needs a cast through unknown because the declared type is wrong:

import { PalError, Storage } from "@palbase/backend";

const { data, error } = await Storage.bucket("avatars").download("u42/photo.jpg");
if (error || !data) throw new PalError(502, "download_failed", error?.message ?? "download failed");

const bytes = data as unknown as Uint8Array;

For any other binary — a PDF, a zip, an audio file — there is no correct way to read the bytes back through download today. They are already mangled by the time the envelope resolves. Hand the object out as a signed URL and let the client fetch the bytes directly from storage, which is both lossless and cheaper than moving them through your handler.

Three kinds of URL, and only one of them is absolute

This is the sharpest edge on the page. getPublicUrl returns an absolute URL; the other two ways a URL reaches you return a path, and a client that is not on your origin cannot fetch a path.

Where it comes fromWhat you getAbsolute?
bucket.getPublicUrl(path)https://<origin>/v1/files/<bucket>/<path>yes
bucket.getPublicUrl(path, { variant })the same, plus ?variant=<name>yes
createSignedUrl(...)data.signedUrl/v1/files/s/<bucket>/<path>?<signature>no
PalbaseFileObject.variants[name] and UploadedObject.variants[name]/v1/files/<bucket>/<path>?variant=<name>no

So: hand a client a getPublicUrl(...) result whenever you can, and prefix the other two yourself when you cannot. A relative URL that leaves the response body — into an <img>, an email, another host — has no base to resolve against. That is not hypothetical: an iOS client handed a returned /v1/files/post-images/… straight to URLSession and got NSURLErrorUnsupportedURL (-1002), with the upload itself having worked perfectly.

PALBASE_PUBLIC_ORIGIN

The origin getPublicUrl prefixes is the address clients reach this stack at, and the stack is told it rather than deriving it: internally your backend talks to storage over a local address, and a URL built from that resolves nowhere else. It comes from the PALBASE_PUBLIC_ORIGIN environment variable, written by whoever publishes the stack — on the cloud that is https://<ref>.palbase.studio, and it is set for you.

When it is unset, getPublicUrl throws by name rather than returning something plausible:

Storage.bucket("avatars").getPublicUrl() needs this stack's public origin, and it was not
configured. Set PALBASE_PUBLIC_ORIGIN to the address clients reach this stack at
(e.g. https://myproject.palbase.studio).

It is a synchronous throw, not an { error } envelope, so an unconfigured stack fails the request rather than serving a broken link.

Public reads

/v1/files is the read prefix, and it is mounted outside the stack's internal-token boundary:

GET /v1/files/<bucket>/<path>              a public bucket
GET /v1/files/<bucket>/<path>?variant=…    one of its declared renditions
GET /v1/files/s/<bucket>/<path>?<sig>      a signed grant, any bucket

A public URL needs no credentials — that is the whole point. GET/HEAD on /v1/files/<bucket>/<path> carries no key, so a browser <img src>, a SwiftUI AsyncImage or a Coil load — none of which send headers — renders it directly. Writes are not exempt: everything under /v1/storage/* (uploading, deleting, listing, minting URLs) requires a credential.

Public reads are still metered and rate-limited against your project. They resolve to your Environment from the hostname rather than from a key, so anonymous traffic counts toward your quota exactly like authenticated traffic.

Warning: Public URLs only serve objects from buckets created with --public. For a private bucket, mint a signed URL instead.

Metadata is NOT stripped from the original. A public URL serves the stored bytes verbatim — including EXIF, which on a phone photo routinely carries GPS coordinates, the capture timestamp and the device model. If you accept user photos into a public bucket, that metadata is world-readable at the URL you hand out.

Renditions are clean: a variant is decoded and re-encoded, and the encoder writes no EXIF at all. Two ways to avoid publishing the original — serve a variant rather than the raw object (declare one at full size if you do not want to resize), or keep originals in a private bucket and publish only renditions.

Signed URLs

For a private object, createSignedUrl mints a time-limited URL a client can fetch directly, with no auth header and without the bytes flowing through your handler:

import { PalError, Storage } from "@palbase/backend";

const { data, error } = await Storage.bucket("posts")
  .createSignedUrl("u42/todo-7/receipt.pdf", { expiresIn: "1h" });

if (error || !data) throw new PalError(502, "sign_failed", error?.message ?? "could not sign URL");
const signed = { url: data.signedUrl };

expiresIn is a duration string"30s", "15m", "1h", "24h" — parsed with Go's duration grammar on the stack. A bare number is refused rather than guessed: 3600 reads as seconds to whoever wrote it and as minutes to whoever reads it next, and the disagreement only shows up when a link outlives the thing it opened. An unparseable or non-positive value comes back 400.

The object must already exist. Signing a path that holds nothing is 404 at the moment you ask, rather than a URL that answers 404 to a customer an hour later.

A signature opens exactly one object for exactly as long as it says. Editing the path in a valid link produces 403 forbidden — "This link is not valid for that object." — rather than somebody else's invoice, and an expired link is refused without telling the holder which of the two things went wrong.

Remember that signedUrl is a path. Prefix it with your public origin before putting it in an email.

Image renditions

Renditions are rendered when the object is written and stored as objects. There is no transform on the request path. Serving one is a lookup: the stored rendition comes back under Cache-Control: public, max-age=31536000, immutable with an ETag of "<checksum>-<variant>", and its Content-Type is the format the variant declared. Asking for a ?variant= name the bucket does not declare is an error rather than a silent fall back to the original — an app that requested a 200px thumbnail and quietly received a 5 MB photograph works, looks right, and is found months later on a bandwidth bill.

The trade is worth naming: a variant added today does not appear on objects uploaded yesterday. Re-upload them, or accept that the rendition exists from here on.

Declaring renditions

A bucket's renditions are part of the bucket, on the stack. There is a real gap here: palbase storage add has no --variant flag, so the only ways to declare one today are the raw management call and the panel. The management PUT forwards its body to the storage module unchanged, and the whole bucket declaration is what it takes:

curl -X PUT https://<ref>.palbase.studio/v1/management/storage/buckets/post-images \
  -H "apikey: pb_project_s…" \
  -H "content-type: application/json" \
  -d '{
        "public": true,
        "fileSizeLimit": 5242880,
        "allowedMimeTypes": ["image/jpeg", "image/png", "image/heic"],
        "variants": {
          "thumb": { "width": 100, "height": 100, "fit": "cover", "format": "webp" },
          "card":  { "width": 640, "format": "webp" }
        }
      }'

The credential is the Environment's service-role key on the apikey header — the one palbase apikey reveal prints as pb_project_s…, and the one that must never reach a client (see the two API keys). fileSizeLimit is a byte count here, not the 5MB spelling the CLI accepts. The call replaces the whole declaration, so send every field you want the bucket to keep.

FieldValuesNotes
width / heightpositive integersThe target box. cover needs both.
fitcover · contain · insideDefaults to contain. cover fills the box and crops from the centre; inside is contain that never enlarges a small original.
formatjpeg · png · webpWhat this stack can encode. avif is refused.
quality1100Defaults to 85. The JPEG and WebP encoders read it; PNG ignores it.

The stack refuses a bad declaration by name, at the moment you send it rather than at the first photo somebody posts:

RefusalWhen
400 bad_variant_nameThe name does not match ^[a-z][a-z0-9_]*$.
400 bad_fitfit is not cover, contain or inside.
400 bad_variant_formatThe format is one this stack cannot render — the message names what it can.

Warning: The variant-name rule at the stack is narrower than the one the SDK's ImageVariant type accepts. thumb and card_2x are fine; card-2x, 2x and Thumb are all refused 400 bad_variant_name. Lowercase, start with a letter, underscores only.

Reading them back

import { Storage } from "@palbase/backend";

declare const path: string;

const bucket = Storage.bucket("posts");
const urls = {
  url: bucket.getPublicUrl(path),
  thumb: bucket.getPublicUrl(path, { variant: "thumb" }),
  card: bucket.getPublicUrl(path, { variant: "card" }),
};

Because those URLs are part of the method's return type, the generated clients get them as real typed fields — an iOS or web app reads thumb rather than assembling a URL and hoping the path is right. An @Upload handler is handed the renditions without asking, as obj.variants, but those values are relative: pass them through getPublicUrl before they leave your process.

HEIC decodes. A stock iPhone camera produces HEIC, and this stack decodes it: a HEIC upload gets its dimensions, its thumbhash and every declared rendition like any other image. What the stack cannot write is AVIF. Since no browser renders HEIC, the practical shape of an iPhone photo pipeline is to accept image/heic and serve a webp variant rather than the original.

Listing and managing files

import { Storage } from "@palbase/backend";

const bucket = Storage.bucket("posts");

const { data: files } = await bucket.list("u42/");

await bucket.move("u42/todo-7/receipt.pdf", "u42/archive/receipt.pdf");
await bucket.copy("u42/avatar.png", "u42/avatar-backup.png");
await bucket.remove(["u42/tmp/draft-1.png", "u42/tmp/draft-2.png"]);

list filters by path prefix — with keys shaped <userId>/<todoId>/<filename>, listing "u42/" returns one user's files — and results are ordered by path.

Warning: list does not page. PalbaseListOptions declares limit, offset and sortBy; offset and sortBy never leave the process, and limit is put on the query string and ignored by the storage module, whose list query has no LIMIT clause. Every object under the prefix comes back in one response. Keep prefixes narrow, and do not build a pager on this method — it will silently re-read the same full list.

remove takes an array and deletes one object per request, stopping at the first refusal and returning that error. A partial delete is therefore possible, and the objects it hands back on success carry the paths you passed rather than freshly read metadata.

Receiving files from clients

@Upload is the only inbound-file rail. A client makes one multipart POST /v1/storage/upload/<route>; storage asks your backend whether this caller may write and where the object goes, writes the bytes, renders the bucket's renditions, and then calls your method as the completion handler with the object's metadata. Your process never sees the bytes. The full decorator reference is on Direct Uploads, and the client halves are Web: Uploads and iOS: Uploads.

There is no request-parsed file object. @Req() injects the raw Web Request, and nothing populates a req.file — an older version of this page taught one, and a handler written against it read undefined on every call.

Reacting to storage events

Storage raises file.uploaded and file.deleted. Handle them with @On methods on a class a module lists in its providers — there is no hooks/ directory and nothing reads one:

// modules/audit/storage-listeners.hook.ts
import { Log, On } from "@palbase/backend";
import type { FileDeletedEvent, FileUploadedEvent, HookMeta } from "@palbase/backend";

export class StorageListeners {
  @On("file.uploaded")
  async onFileUploaded(event: FileUploadedEvent, meta: HookMeta): Promise<void> {
    Log.info(`stored ${event.bucket}/${event.path} (${event.size} bytes)`);
  }

  @On("file.deleted")
  async onFileDeleted(event: FileDeletedEvent, meta: HookMeta): Promise<void> {
    Log.info(`removed ${event.bucket}/${event.path}`);
  }
}

Both are listeners: the write has already answered its caller before your handler runs, so nothing here can undo it, and delivery is best-effort. To refuse an upload, use @Upload — a completion handler that answers 4xx makes storage delete the object it just wrote. See Event Hooks.

Warning: hook registration is not reaching the stack right now — measured 2026-09-11. The storage module learns which events a project handles by reading .palbase/hooks/hooks.manifest.json out of the artifact, and no CLI has written that file since 2026-09-01. palbase push still prints bundled hook(s) → … — that line is read out of the container and honestly says what the code carries — but nothing tells the stack which events to call, so an @On deployed today does not fire. The contract above is what the runtime implements; the delivery half is the gap. Check palbase --version against the release notes before relying on one, and never let a storage listener be the only thing that performs a required step. Event Hooks carries the detail.