Skip to content

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, use Model.can.action as 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 throwstop() returns a clean 403. Throwing gives 500 unless you set statusCode.