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
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:
resources/users/endpoints/current.get.ts
middlewares/is-authorized.ts
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:
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):
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:
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
resources/notes/endpoints/remove.ts
canEdit runs after .input() — it reads the validated id off input. Stack .use(isAuthorized) before it so context.user is guaranteed.See also
- Global middlewares — the always-on layer every endpoint inherits
- Routing overview — how endpoints mount and the contract is generated
- How Ship works — the resource-owns-everything model
