> ## Documentation Index
> Fetch the complete documentation index at: https://ship.paralect.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Middlewares

> Authorization is middleware. Stack gates onto an endpoint with .use() — no scattered if-checks.

In Ship, authorization is composition. You don't write `if (!user) throw` inside handlers — you stack **gates** onto an endpoint with `.use(...)`. Each gate is an oRPC middleware that guarantees something about `context` (or throws) before the handler runs.

## How `.use()` composes

`@/endpoint` already carries every [global middleware](/docs/api-reference/middlewares). Per-endpoint, `.use(...)` adds gates in order. Order matters — a gate can refine the context the next one reads:

```ts resources/users/endpoints/[userId]/update.ts theme={null}
import db from '@/db';
import endpoint from '@/endpoint';
import isAdmin from '@/middlewares/is-admin';

export default endpoint
  .use(isAdmin)              // 1. require an admin
  .input(updateSchema)       // 2. validate input
  .output(publicSchema)      // 3. shape the response
  .handler(async ({ input, context }) => {
    // context.user is present and an admin here
  });
```

A gate either calls `next({ context })` — optionally narrowing the context type — or throws an `ORPCError`. Because the narrowing flows through `.use()`, your handler's `context` is correctly typed without a single cast.

## The gates

| Gate                    | Import                        | Guarantees                                                              |
| ----------------------- | ----------------------------- | ----------------------------------------------------------------------- |
| `isAuthorized`          | `@/middlewares/is-authorized` | a signed-in `context.user` (else `UNAUTHORIZED`)                        |
| `isAdmin`               | `@/middlewares/is-admin`      | `context.user` exists and is an admin (else `FORBIDDEN`)                |
| `canAccess(key, load)`  | `@/middlewares/can-access`    | loads an entity into `context[key]` or throws `NOT_FOUND`               |
| `canEdit(key, service)` | `@/middlewares/can-edit`      | the current user owns the entity (else `NOT_FOUND` — no existence leak) |

### `isAuthorized`

Requires a session. Stack it when an endpoint needs *any* signed-in user:

```ts resources/users/endpoints/current.get.ts theme={null}
import endpoint from '@/endpoint';
import isAuthorized from '@/middlewares/is-authorized';
import { publicSchema } from '@/resources/users/users.schema';

export default endpoint
  .use(isAuthorized)
  .output(publicSchema)
  .handler(async ({ context }) => context.user);
```

Under the hood it's a thin guard:

```ts middlewares/is-authorized.ts theme={null}
export default base.middleware(async ({ context, next }) => {
  if (!context.user) {
    throw new ORPCError('UNAUTHORIZED', { message: 'Authentication required' });
  }
  return next({ context: { ...context, user: context.user } });
});
```

### `isAdmin`

Requires the user to be an admin. It checks the session first, then `user.isAdmin`, throwing `FORBIDDEN` otherwise — the admin user list and other admin endpoints stack this:

```ts theme={null}
export default endpoint
  .use(isAdmin)
  .input(paginationSchema)
  .output(listResultSchema(publicSchema))
  .handler(async ({ input }) => db.users.findPage({ where: { deletedAt: null }, ...input }));
```

### `canAccess`

The general "load it or 404" gate. You pass a `load` function — the gate runs it, stores the result in `context[key]`, and throws `NOT_FOUND` if it comes back empty. The loader decides what "exists" means (a custom `where`, a relationship check, a relation-loaded query):

```ts theme={null}
endpoint
  .use(isAuthorized)
  .input(z.object({ noteId: z.string() }))
  .use(canAccess('note', ({ input }) =>
    db.notes.findFirst({ where: { id: input.noteId, deletedAt: null } }),
  ))
  .handler(async ({ context }) => context.note); // typed, guaranteed present
```

### `canEdit`

The common ownership case, built on `canAccess`. Pass the context key and a `@ship/db` service; it loads the entity by id, scoped to the current user, and throws `NOT_FOUND` on a mismatch — so a non-owner can't tell the row exists. Defaults: `idKey` = `` `${key}Id` `` (falling back to `input.id`), owner column = `userId`, soft-delete aware:

```ts theme={null}
endpoint
  .use(isAuthorized)
  .input(z.object({ id: z.string() }))
  .use(canEdit('note', db.notes, { message: 'Note not found' }))
  .handler(async ({ input }) => db.notes.deleteOne({ id: input.id }));
```

## Per-resource gates

When an ownership rule recurs across a resource's endpoints, factor it into `<resource>/middlewares/can-edit-*.ts` and reuse it. The Notes plugin ships exactly this:

```ts resources/notes/middlewares/can-edit-note.ts theme={null}
import db from '@/db';
import canEdit from '@/middlewares/can-edit';

// Loads the current user's note (by id) into context.note, or throws NOT_FOUND.
export default canEdit('note', db.notes, { message: 'Note not found' });
```

```ts resources/notes/endpoints/remove.ts theme={null}
import endpoint from '@/endpoint';
import isAuthorized from '@/middlewares/is-authorized';
import canEditNote from '@/resources/notes/middlewares/can-edit-note';
import db from '@/db';
import { z } from 'zod';

export default endpoint
  .use(isAuthorized)
  .route({ method: 'DELETE', path: '/notes/{id}' })
  .input(z.object({ id: z.string() }))
  .use(canEditNote)
  .output(z.void())
  .handler(async ({ input }) => {
    await db.notes.deleteOne({ id: input.id });
  });
```

<Note>
  `canEdit` runs **after** `.input()` — it reads the validated id off `input`. Stack `.use(isAuthorized)` before it so `context.user` is guaranteed.
</Note>

## See also

* [Global middlewares](/docs/api-reference/middlewares) — the always-on layer every endpoint inherits
* [Routing overview](/docs/api-reference/routing/overview) — how endpoints mount and the contract is generated
* [How Ship works](/docs/how-ship-works) — the resource-owns-everything model
