Skip to content

File uploads

Uploads take two calls. schema().files() registers the tables, and .files() on the builder chain mounts the procedures and the download route.

The bytes never live in Postgres. They go to an object store you pass as storage, and the database keeps only the pointer and its ownership.

Before you begin

You need two things in place:

  • The file tables on your schema, via schema().files().
  • An OuterStorage on the constructor. .files() throws without one.

Set up uploads

import { Outer } from "@outerjs/server";
import { schema } from "@outerjs/server/schema";
import { fromUnstorage } from "@outerjs/server/storage";
import { pglite } from "@outerjs/server/pglite";
import { createStorage } from "unstorage";
import fsLite from "unstorage/drivers/fs-lite";

const v1_0 = schema("1.0.0")
  .auth()
  .table("post", (t) => ({ id: t.text().primaryKey(), title: t.text() }))
  .files({ attachTo: ["post"] })
  .build();

const outer = new Outer({
  db: pglite(),
  storage: fromUnstorage(createStorage({ driver: fsLite({ base: ".outer/files" }) })),
})
  .schema(v1_0)
  .auth({ secret: process.env.AUTH_SECRET! })
  .files({ maxBytes: 10 * 1024 * 1024 })
  .build();

.files() can appear anywhere in the chain before .build().

What gets registered

ProcedureInputNotes
file.upload{ file, name?, attach? }Returns a FileRecord including url
file.list{ attachedTo?, take?, skip? }Non-admins only ever see their own files
file.get{ id }null when missing, like .resource().get
file.delete{ id }Removes the row, then the bytes
file.attach{ id, table, entityId, role?, position? }Links an existing file to a row
file.detach{ id, table, entityId }Unlinks it
GET /files/:idServes the bytes; path configurable via path

Upload from the client

Uploads travel the ordinary typed SDK. There is no separate endpoint and no manual FormData — oRPC's codec detects the File field and switches the request to multipart/form-data on its own:

const { id, url } = await client.file.upload({ file: input.files[0] });

// or attach it to a row in the same call
await client.file.upload({ file, attach: { table: "post", entityId: post.id } });

Files are private by default

The default permissions are "authenticated" for upload and list, and "owner" for get and delete.

"owner" means only the user in file.userId can read or delete a file. The download route answers 404, not 403, to everyone else, so file IDs cannot be probed for existence. Admins bypass ownership, so moderation tooling needs no second code path.

For avatars and other world-readable assets, opt in explicitly:

.files({ permissions: { get: "public" } })   // the route switches to Cache-Control: public

The download route is hardened against stored XSS

An uploaded file's content-type is caller-controlled, so the download route never lets a browser treat one as executable content on your API's origin. Every response sends X-Content-Type-Options: nosniff and a Content-Security-Policy: default-src 'none'; sandbox, and markup types (text/html, image/svg+xml, XML) are forced to download (Content-Disposition: attachment) instead of rendering inline. Set accept to an allowlist of the types you actually expect to narrow the surface further.

Attaching does not check the target row

file.attach (and attach on upload) verifies the caller owns the file, but not that they may write to the target entityId — Outer has no ownership model for your own tables. If attachments are visible to others, guard the link yourself: wrap the attach in a .procedure() that checks the caller owns the target row, or keep attachable tables to ones where any authenticated user may attach.

Options

OptionTypeDefault
storageOuterStoragethe new Outer({ storage }) instance
maxBytesnumber10 * 1024 * 1024 — larger uploads get 413
acceptstring[] ("image/*" allowed)all types
permissions{ upload, list, get, delete }upload/list "authenticated", get/delete "owner"
pathstring containing :id"/files/:id"
rolesstring[]["admin"]

Attach files to rows

attachTo on the schema builder creates one pivot table per name. post gets post_file, with fileId, entityId, a nullable role so one table can carry several kinds of attachment ("avatar", "cover", and so on), and an integer position for ordered galleries.

Both foreign keys are enforced in the database, and a manyToMany relation is declared in both directions, so context.db.query traverses post → file and file → post.

attachTo only accepts tables already declared on the builder — unknown names are a type error. Attaching at runtime to a table you did not list returns a 400 that names the fix.

Storage adapters

OuterStorage is three methods, so Outer's core never depends on unstorage, S3, or a filesystem:

type OuterStorage = {
  get(key: string): Promise<Uint8Array | null>;
  set(key: string, bytes: Uint8Array): Promise<void>;
  delete(key: string): Promise<void>;
};

Two adapters ship with the package: fromUnstorage(storage) for any unstorage instance, including Nitro's useStorage(), and fromS3(client, commands, bucket) for @aws-sdk/client-s3. memoryStorage() is a Map, for tests. Anything else is a small object literal, as the templates show:

HostBacking storeAdapter
VPS / Coolify / DockerLocal disk via unstorage fs-litefromUnstorage(createStorage(...))
Nitro (the ilha template)A Nitro storage mountfromUnstorage(useStorage("fs"))
Cloudflare WorkersR2 bucket binding~6-line literal over env.MY_BUCKET
Vercel FunctionsVercel Blobliteral over put / get / del
Anything S3-compatibleS3, R2, Backblaze B2, MinIOfromS3(client, commands, bucket)

Under Nitro, reach for useStorage() rather than constructing your own createStorage(). A second instance ignores devStorage overrides and any runtime storage.mount(). Using the mount also makes production a config change: put the s3 driver under storage, keep fs-lite under devStorage, and no application code moves.

Write ordering

On upload the database row commits before the bytes are written. On delete the row is removed before the bytes. A failure therefore leaves at worst a retryable orphaned blob, never a row pointing at bytes that are not there.

Uploads are buffered in memory, so maxBytes is a real ceiling rather than a suggestion. For large media, issue presigned URLs — or use your platform's client-upload flow — and send bytes straight to the bucket instead of through the Outer process.

Next steps