# Routes (https://v2.s3.dimah.dev/docs/server/routes)

A **route** is a named storage policy on your server. Each route owns its
constraints, object keys, and enabled operations (`upload`, `download`,
`delete`, and `upload.multipart`).

The browser sends a route name with each request. The server enforces file
constraints, validates permissions, and generates object keys.

```ts title="lib/s3.ts"
import { dimahS3, route } from "@dimah-s3/server";
import { awsS3 } from "./aws-s3";

export const s3 = dimahS3({
  client: awsS3,
  bucket: process.env.S3_BUCKET!,
  routes: {
    avatar: route({
      upload: {
        fileTypes: ["image/*"],
        maxFileSize: 2 * 1024 * 1024,
      },
      download: true,
      delete: true,
    }),
    document: route({
      upload: {
        fileTypes: ["application/pdf", "image/*"],
        maxFileSize: 10 * 1024 * 1024,
      },
      download: true,
      delete: true,
    }),
    video: route({
      upload: {
        fileTypes: ["video/*"],
        maxFileSize: 500 * 1024 * 1024,
        multipart: true,
      },
    }),
  },
});
```

`awsS3` is the `S3Client` from [Server Setup](https://v2.s3.dimah.dev/docs/server/setup#configure-s3-and-routes).

---

## Combining operations [#combining-operations]

Enable operations on the **same route** when they manage the same objects. For example, if users upload, view, and remove their avatars:

```ts
avatar: route({
  upload: {
    fileTypes: ["image/*"],
    maxFileSize: 2 * 1024 * 1024,
  },
  download: true, // [!code ++]
  delete: true, // [!code ++]
}),
```

With the default `keyPrefix` (the route name), download and delete must target
keys within that route's namespace. That is route isolation, not per-user
ownership — see
[Security](https://v2.s3.dimah.dev/docs/server/security#route-namespace-vs-ownership).

---

## Key generation and object identity [#key-generation-and-object-identity]

By default, the server generates object keys formatted as:

```
{keyPrefix}/{uuid}/{sanitizedName}
```

`keyPrefix` defaults to the **route name** (`avatar` → `avatar/…`, `document`
→ `document/…`). The same prefix is used twice:

1. **Upload and multipart init** — the server _generates_ the key. The client
   sends a route name, not an object key. The key travels inside the
   [upload ticket](https://v2.s3.dimah.dev/docs/server/upload-tickets), so confirm and the later
   multipart calls never send it.
2. **Download and delete** — the client _sends_ a key. The server accepts it
   only if it sits under that route's prefix.

So `DELETE ?route=avatar&key=avatar/{uuid}/photo.jpg` is valid, and
`DELETE ?route=avatar&key=document/{uuid}/cv.pdf` returns `KEY_INVALID`.
Two routes cannot share or nest prefixes (`files` and `files/images`);
`dimahS3()` throws at startup.

<Callout type="warn" title="Disabling namespace isolation">
  `keyPrefix: false` allows download and delete to target any safe key in the
  bucket. Use it only when strict route or operation guards enforce the complete
  access policy.
</Callout>

To customize key generation or organize files by user or tenant, return
`keyFolder` or `key` from `upload.object`:

```ts
avatar: route({
  upload: {
    fileTypes: ["image/*"],
    maxFileSize: 2 * 1024 * 1024,
    object: async ({ request }) => {
      const session = await getSession(request);
      return {
        keyFolder: `users/${session.userId}`, // [!code ++]
        // Results in: avatar/users/{userId}/{uuid}/{name}
      };
    },
  },
}),
```

`object` runs after the instance and route guards, so it can also read
[`locals`](https://v2.s3.dimah.dev/docs/server/hooks/global-guard#locals) instead of loading the
session again.

Keys are limited to 1024 UTF-8 bytes. A generated key turns `${` in the file
name into `_{`. On a `method: "POST"` route, a key from `object` that
contains `${filename}` returns `KEY_INVALID`, so the presigned form always
pins the exact key.

Route lifetimes and sizes (`upload.expiresIn`, `upload.confirmWindow`,
`upload.maxFileSize`, `upload.multipart.partSize`,
`upload.multipart.ticketExpiresIn`, and `download.maxExpiresIn`) must be
positive integers. Any other value throws at `dimahS3()`.

---

## Type reference [#type-reference]

```ts
import type {
  DimahS3RouteConfig,
  UploadConfig,
  DownloadConfig,
  DeleteConfig,
  MultipartConfig,
  UploadObjectInfo,
} from "@dimah-s3/server";
```

### DimahS3RouteConfig [#dimahs3routeconfig]

<AutoTypeTable
  path="packages/server/src/types/config.ts"
  name="DimahS3RouteConfig"
/>

---

### UploadConfig [#uploadconfig]

<AutoTypeTable path="packages/server/src/types/config.ts" name="UploadConfig" />

---

### UploadObjectInfo [#uploadobjectinfo]

What `upload.object` returns.

<AutoTypeTable
  path="packages/server/src/types/hook-contexts.ts"
  name="UploadObjectInfo"
/>

---

### DownloadConfig [#downloadconfig]

<AutoTypeTable
  path="packages/server/src/types/config.ts"
  name="DownloadConfig"
/>

---

### DeleteConfig [#deleteconfig]

<AutoTypeTable path="packages/server/src/types/config.ts" name="DeleteConfig" />

## Frequently asked questions [#frequently-asked-questions]

<Accordions>

<Accordion title="How do I overwrite the same avatar key instead of generating a UUID?">

Return a deterministic `key` from `object`. S3 replaces the object at that
key when the new upload lands:

```ts
avatar: route({
  upload: {
    fileTypes: ["image/*"],
    maxFileSize: 2 * 1024 * 1024,
    object: async ({ request }) => {
      const session = await getSession(request);
      return {
        key: `avatars/${session.userId}/avatar.png`, // [!code ++]
      };
    },
  },
}),
```

With [`db()`](https://v2.s3.dimah.dev/docs/db), an upload onto a key that already holds an active
object fails with `CONFLICT`. Allow it per route with
`plugins: { db: { replace: "overwrite" } }`.

</Accordion>

<Accordion title="How do I clean up the user's previous avatar after a new upload?">

Return `previousKey` from `upload.object`. After the new upload is confirmed
and every hook succeeds, dimah-s3 deletes the previous object from S3:

```ts
avatar: route({
  upload: {
    fileTypes: ["image/*"],
    maxFileSize: 2 * 1024 * 1024,
    object: async ({ request }) => {
      const user = await getCurrentUser(request);
      return {
        keyFolder: `users/${user.id}`,
        previousKey: user.currentAvatarKey, // [!code ++]
      };
    },
  },
}),
```

`previousKey` must sit under the same route prefix.

</Accordion>

<Accordion title="How do I set storage class, Cache-Control, or object tags?">

Return them from `upload.object`. They are signed into PUT, POST, and
multipart init (and applied by `s3.put`) so the browser cannot change
them. Tag values are visible to the client — do not put secrets there.

```ts
avatar: route({
  upload: {
    fileTypes: ["image/*"],
    object: () => ({
      storageClass: "STANDARD_IA", // [!code ++]
      cacheControl: "max-age=31536000, immutable", // [!code ++]
      tagging: { purpose: "avatar" }, // [!code ++]
    }),
  },
}),
```

Tags must be printable ASCII, at most 10 per object. User `metadata` may hold
any text: non-ASCII values are RFC 2047-encoded on write and decoded on read.
Metadata keys and values share a 2 KB budget. Going over either limit is a
config error that returns `500` and is logged.

</Accordion>

<Accordion title="How do I enforce SHA-256 integrity checksums?">

Set `checksum: true`. The React client hashes the file in the browser, and
the server signs the SHA-256 into the upload, so S3 rejects any other bytes.
Multipart uploads hash and sign every part:

```ts
avatar: route({
  upload: {
    fileTypes: ["image/*"],
    maxFileSize: 2 * 1024 * 1024,
    checksum: true, // [!code ++]
  },
}),
```

A presign or part request without a checksum returns `CHECKSUM_REQUIRED`.
`GET /routes` reports the setting as `upload.checksum`, so custom clients
know to send one.

</Accordion>

<Accordion title="How do I check that the bytes match the declared type?">

Set `verifyContent: true`. After confirm, the server reads the first bytes of
the object and compares their signature with the declared type. A mismatch
deletes the object and returns `FILE_CONTENT_MISMATCH`. An upload without a
type is checked against the type its name gives, so `x.png` is checked as
`image/png`:

```ts
avatar: route({
  upload: {
    fileTypes: ["image/png", "image/jpeg", "image/webp"],
    verifyContent: true, // [!code ++]
  },
}),
```

`verifyContent` turns `checksum` on, because the signed checksum pins the
bytes that were checked. Setting `checksum: false` with it throws at
`dimahS3()`. Types without a known signature, such as `text/plain`, pass
without a read.

</Accordion>

<Accordion title="How do I stream downloads through my server without direct bucket URLs?">

Set `mode: "proxy"` on `download`. The returned URL points to the
`/api/s3/file` endpoint, which streams the object through your server. See
[File Proxy](https://v2.s3.dimah.dev/docs/server/file-proxy):

```ts
avatar: route({
  download: {
    mode: "proxy", // [!code ++]
  },
}),
```

</Accordion>

</Accordions>
