Guards
Access control with guards and model permissions
Guards gate access to routes. They run after middleware and can deny the request. JustScale supports inline guard functions, DI-injected guards, and declarative model permissions via Ticket.can.resolve.
Model Permissions
The most common guard pattern uses model-level permissions declared with permit(). The model defines who can do what:
src/domain/ticket.tsTypeScript
import { defineModel, field } from '@justscale/core/models';
import { permit } from '@justscale/permission';
import { Customer } from './customer';
import { Agent } from './agent';
export class Ticket extends defineModel({
fields: {
subject: field.string(),
status: field.enum('TicketStatus', ['open', 'in_progress', 'resolved', 'closed'] as const),
customer: field.ref(Customer),
assignedAgent: field.ref(Agent).optional(),
},
permissions: ({ customer }) => ({
// Customers see their own tickets, agents see all
view: [permit(Customer).when(customer), permit(Agent).always()],
// Only agents can assign and resolve
assign: permit(Agent).always(),
resolve: permit(Agent).always(),
// Only the owning customer can close
close: permit(Customer).when(customer),
}),
}) {}Then use Ticket.can.resolve as a guard on routes:
src/controllers/guarded-routes.tsTypeScript
import { createController } from '@justscale/core';
import { Post } from '@justscale/http';
import { auth } from '@justscale/auth';
import { Ticket } from '../domain/models';
import { HelpdeskService } from '../services/helpdesk';
export const TicketController = createController('/tickets', {
inject: { helpdesk: HelpdeskService },
routes: ({ helpdesk }) => ({
// Only agents can resolve tickets
resolve: Post('/:ticket/resolve')
.types({ Ticket })
.use(auth)
.guard(Ticket.can.resolve) // 403 if not an agent
.handle(async ({ params, user, res }) => {
const ticket = await params.ticket;
await helpdesk.resolve(ticket, user.name);
res.status(204).end();
}),
// Only the owning customer can close
close: Post('/:ticket/close')
.types({ Ticket })
.use(auth)
.guard(Ticket.can.close) // 403 if not the ticket's customer
.handle(async ({ params, res }) => {
const ticket = await params.ticket;
await helpdesk.close(ticket);
res.status(204).end();
}),
}),
});ℹ️
Info
Ticket.can.close with permit(Customer).when(customer) checks that the authenticated user is the customer who created the ticket. The framework resolves the principal, loads the ticket, and compares the reference — all automatically.Inline Guards
For simple checks, use an inline function. It receives the accumulated context:
inline-guard.tsTypeScript
import { Post } from '@justscale/http';
import { auth } from '@justscale/auth';
// Only users with @support.com emails
Post('/tickets/:ticket/assign')
.use(auth)
.guard(({ user, stop }) => {
if (!user.email.endsWith('@support.com')) {
return stop(); // Returns 403 Forbidden
}
})
.handle(async ({ params, res, user }) => {
// Only reached if guard passed
res.status(204).end();
});Combining Guards
Chain multiple .guard() calls — all must pass. They execute in order:
src/controllers/combined-guards.tsTypeScript
import { Post } from '@justscale/http';
import { auth } from '@justscale/auth';
import { Ticket } from '../domain/models';
Post('/:ticket/resolve')
.types({ Ticket })
.use(auth)
.guard(Ticket.can.resolve) // 1. Must be an agent
.guard(async ({ params }) => { // 2. Ticket must be open
const ticket = await params.ticket;
if (ticket?.status === 'closed') throw new Error('Ticket already closed');
})
.handle(async ({ params, res, user }) => {
// Both guards passed
res.status(204).end();
});OR Guards (Array)
Pass an array of guards for OR semantics — any matching guard allows access:
src/controllers/or-guard.tsTypeScript
import { Get } from '@justscale/http';
import { auth } from '@justscale/auth';
import { Ticket } from '../domain/models';
// Customer who owns the ticket OR any agent
Get('/:ticket')
.types({ Ticket })
.use(auth)
.guard(Ticket.can.view) // Array permission — either rule matches
.handle(async ({ params, res }) => {
const ticket = await params.ticket;
res.json(ticket);
});Best Practices
- Use model permissions — declare
permit()rules on the model, useModel.can.actionas guards. One source of truth. - Auth before guards — always chain
.use(auth)before.guard(). Guards need to know who's asking. - Guards don't add context — they check and deny. Use
.use()for adding data to context. - Prefer stop() over throw —
stop()returns a clean 403. Throwing gives 500 unless you setstatusCode.