Skip to main content
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. Per-endpoint, .use(...) adds gates in order. Order matters — a gate can refine the context the next one reads:
resources/users/endpoints/[userId]/update.ts
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

GateImportGuarantees
isAuthorized@/middlewares/is-authorizeda signed-in context.user (else UNAUTHORIZED)
isAdmin@/middlewares/is-admincontext.user exists and is an admin (else FORBIDDEN)
canAccess(key, load)@/middlewares/can-accessloads an entity into context[key] or throws NOT_FOUND
canEdit(key, service)@/middlewares/can-editthe 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:
resources/users/endpoints/current.get.ts
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:
middlewares/is-authorized.ts
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:
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):
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:
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:
resources/notes/middlewares/can-edit-note.ts
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' });
resources/notes/endpoints/remove.ts
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 });
  });
canEdit runs after .input() — it reads the validated id off input. Stack .use(isAuthorized) before it so context.user is guaranteed.

See also