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
filetables on your schema, viaschema().files(). - An
OuterStorageon 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
| Procedure | Input | Notes |
|---|---|---|
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/:id | — | Serves 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
| Option | Type | Default |
|---|---|---|
storage | OuterStorage | the new Outer({ storage }) instance |
maxBytes | number | 10 * 1024 * 1024 — larger uploads get 413 |
accept | string[] ("image/*" allowed) | all types |
permissions | { upload, list, get, delete } | upload/list "authenticated", get/delete "owner" |
path | string containing :id | "/files/:id" |
roles | string[] | ["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:
| Host | Backing store | Adapter |
|---|---|---|
| VPS / Coolify / Docker | Local disk via unstorage fs-lite | fromUnstorage(createStorage(...)) |
Nitro (the ilha template) | A Nitro storage mount | fromUnstorage(useStorage("fs")) |
| Cloudflare Workers | R2 bucket binding | ~6-line literal over env.MY_BUCKET |
| Vercel Functions | Vercel Blob | literal over put / get / del |
| Anything S3-compatible | S3, R2, Backblaze B2, MinIO | fromS3(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
- Deployment — match the storage backend to your host
- Permissions — the vocabulary
.files()reuses