# 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()`](/guide/schema#file-tables).
- An `OuterStorage` on the constructor. `.files()` throws without one.

## Set up uploads

```ts
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:

```ts
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:

```ts
.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:

```ts
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](https://unstorage.unjs.io) 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](https://vercel.com/docs/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](/guide/deployment) — match the storage backend to your host
- [Permissions](/guide/permissions) — the vocabulary `.files()` reuses
