Guards

Boolean access control for routes

Guards are functions that return a boolean to allow or deny access to a route. They receive the accumulated context from middleware and can make authorization decisions based on that context.

What is a Guard?

A guard is a function that takes a context object and returns true to allow access or false to deny it. If a guard returns false, request processing stops immediately:

admin-controller.tsTypeScript
import { createController } from '@justscale/core';
import { Delete } from '@justscale/http';
import { parseAuth } from '@justscale/http';
import { UserService } from './user-service';

// Guard type signature
type Guard<TContext> = (ctx: TContext) => boolean | Promise<boolean>;

// Example: Check if user is admin
const isAdmin = (ctx: { user: User }) => {
  return ctx.user.role === 'admin';
};

// Usage in a controller
const AdminController = createController('/admin', {
  inject: { users: UserService },
  routes: (services) => ({
    deleteUser: Delete('/users/:id')
      .use(parseAuth)
      .guard(isAdmin)  // Only admins can proceed
      .handle(({ params, res }) => {
        services.users.delete(params.id);
        res.json({ success: true });
      }),
  }),
});

Creating Inline Guards

The simplest guards are inline functions that check a condition:

inline-guards.tsTypeScript
import { parseAuth } from '@justscale/http';

import { Get, Delete } from '@justscale/http';

export const AdminController = createController('/admin', {
  inject: { users: UserService },

  routes: (services) => ({
    // Check user role
    deleteUser: Delete('/users/:id')
      .use(parseAuth)
      .guard(({ user }) => user.role === 'admin')
      .handle(({ params, res }) => {
        services.users.delete(params.id);
        res.json({ success: true });
      }),

    // Check user permission
    viewLogs: Get('/logs')
      .use(parseAuth)
      .guard(({ user }) => user.permissions.includes('view_logs'))
      .handle(({ res }) => {
        res.json({ logs: getLogs() });
      }),
  }),
});

Boolean Guards

Guards return a boolean value. true allows the request to continue,false blocks it:

boolean-guards.tsTypeScript
// Simple boolean check
const isAuthenticated = (ctx: { user?: User }) => {
  return ctx.user !== undefined;
};

// Multiple conditions
const canEditPost = (ctx: { user: User; post: Post }) => {
  return ctx.user.id === post.authorId || ctx.user.role === 'admin';
};

// Check subscription status
const hasActiveSubscription = (ctx: { user: User }) => {
  return ctx.user.subscription.status === 'active';
};

import { Put } from '@justscale/http';

// Usage in a controller
const PostsController = createController('/posts', {
  routes: () => ({
    update: Put('/:id')
      .use(parseAuth)
      .use(loadPost)
      .guard(canEditPost)  // Returns true/false
      .handle(({ post, body, res }) => {
        updatePost(post, body);
        res.json({ post });
      }),
  }),
});

Throwing Guards

Instead of returning false, guards can throw errors for custom error responses:

throwing-guards.tsTypeScript
// Throw with custom message
const requireAdmin = (ctx: { user: User }) => {
  if (ctx.user.role !== 'admin') {
    throw new Error('Admin access required');
  }
  return true;
};

// Throw with status code (HTTP)
const requireSubscription = (ctx: { user: User }) => {
  if (ctx.user.subscription.status !== 'active') {
    const error = new Error('Active subscription required');
    (error as any).statusCode = 402; // Payment Required
    throw error;
  }
  return true;
};

import { Delete } from '@justscale/http';

// In a controller
const AdminController = createController('/admin', {
  inject: { users: UserService },
  routes: (services) => ({
    deleteUser: Delete('/users/:id')
      .use(parseAuth)
      .guard(requireAdmin)  // Throws if not admin
      .handle(({ params, res }) => {
        services.users.delete(params.id);
        res.json({ success: true });
      }),
  }),
});

Async Guards

Guards can be async for database queries or external API calls:

async-guards.tsTypeScript
// Check database for permission
const hasPermission = async (ctx: { user: User }) => {
  const permissions = await db.permissions.find({ userId: ctx.user.id });
  return permissions.includes('admin:delete');
};

// Check external service
const checkRateLimit = async (ctx: { user: User }) => {
  const allowed = await rateLimiter.check(ctx.user.id);
  if (!allowed) {
    throw new Error('Rate limit exceeded');
  }
  return true;
};

import { Delete } from '@justscale/http';

// In a controller
const PostsController = createController('/posts', {
  inject: { posts: PostService },
  routes: (services) => ({
    delete: Delete('/:id')
      .use(parseAuth)
      .guard(hasPermission)    // Async guard - automatically awaited
      .guard(checkRateLimit)   // Another async guard
      .handle(({ params, res }) => {
        services.posts.delete(params.id);
        res.json({ success: true });
      }),
  }),
});

Guards with Dependencies

For guards that need services, use createGuard to inject dependencies:

guards-with-di.tsTypeScript
import { createGuard } from '@justscale/core';

// Guard that needs services
const HasPermission = createGuard({
  inject: {
    permissions: PermissionService,
  },

  check: ({ permissions }) => async (ctx: { user: User }) => {
    const userPermissions = await permissions.findByUserId(ctx.user.id);
    return userPermissions.includes('admin:write');
  },
});

import { Delete } from '@justscale/http';

// Use it in a controller
const SettingsController = createController('/admin', {
  routes: () => ({
    deleteSettings: Delete('/settings')
      .use(parseAuth)
      .guard(HasPermission)  // Auto-resolves PermissionService
      .handle(({ res }) => {
        res.json({ success: true });
      }),
  }),
});

Combining Multiple Guards

Chain multiple guards with multiple .guard() calls. All guards must pass:

multiple-guards.tsTypeScript
const isAdmin = (ctx: { user: User }) => ctx.user.role === 'admin';
const isVerified = (ctx: { user: User }) => ctx.user.emailVerified;
const notSuspended = (ctx: { user: User }) => !ctx.user.suspended;

import { Delete } from '@justscale/http';

// In a controller
const CriticalController = createController('/critical', {
  routes: () => ({
    delete: Delete('/:id')
      .use(parseAuth)
      .guard(isAdmin)         // Must be admin
      .guard(isVerified)      // Must be verified
      .guard(notSuspended)    // Must not be suspended
      .handle(({ res }) => {
        // Only reached if all three guards pass
        res.json({ success: true });
      }),
  }),
});

Combining with Logic

For OR logic or complex conditions, combine checks within a single guard:

combining-logic.tsTypeScript
// OR: Admin OR owner
const canDelete = (ctx: { user: User; post: Post }) => {
  return ctx.user.role === 'admin' || ctx.user.id === post.authorId;
};

// Complex logic
const canAccess = (ctx: { user: User; resource: Resource }) => {
  const isOwner = ctx.user.id === ctx.resource.ownerId;
  const isCollaborator = ctx.resource.collaborators.includes(ctx.user.id);
  const isPublic = ctx.resource.visibility === 'public';

  return isOwner || isCollaborator || isPublic;
};

import { Delete } from '@justscale/http';

// In a controller
const PostsController = createController('/posts', {
  routes: () => ({
    delete: Delete('/:id')
      .use(parseAuth)
      .use(loadPost)
      .guard(canDelete)  // Single guard with OR logic
      .handle(({ post, res }) => {
        deletePost(post);
        res.json({ success: true });
      }),
  }),
});

Accessing Context

Guards receive all accumulated context from previous middleware:

accessing-context.tsTypeScript
import { Post } from '@justscale/http';

const PostsController = createController('/posts', {
  routes: () => ({
    create: Post('/')
      .use(parseAuth)           // Adds { user }
      .apply(body(schema))   // Adds { body }
      .use(loadCategory)        // Adds { category }
      .guard(({ user, body, category }) => {
        // All three available in guard
        if (category.restricted && user.role !== 'admin') {
          return false;
        }
        if (body.title.length > 100 && !user.premium) {
          return false;
        }
        return true;
      })
      .handle(({ body, res }) => {
        const post = createPost(body);
        res.json({ post });
      }),
  }),
});

Guards vs Middleware

When should you use a guard instead of middleware?

  • Use guards for authorization - When you need to allow/deny access based on conditions
  • Use middleware for transformation - When you need to add data to context
  • Use guards for validation - When you want to short-circuit on invalid state
  • Use middleware for parsing - When you need to parse and enrich the request
guards-vs-middleware.tsTypeScript
// Good: Guard for authorization
.guard(({ user }) => user.role === 'admin')

// Bad: Guard that adds to context (use middleware instead)
.guard((ctx) => {
  ctx.timestamp = Date.now(); // Don't do this!
  return true;
})

// Good: Middleware for adding data
.use((ctx) => ({ timestamp: Date.now() }))

// Good: Guard throws on invalid state
.guard(({ user }) => {
  if (!user.emailVerified) {
    throw new Error('Email verification required');
  }
  return true;
})

Reusable Guards

Create libraries of reusable guards for common authorization patterns:

Files
guards/auth.tsTypeScript
export const isAdmin = (ctx: { user: User }) =>
  ctx.user.role === 'admin';

export const isVerified = (ctx: { user: User }) =>
  ctx.user.emailVerified;

export const isPremium = (ctx: { user: User }) =>
  ctx.user.subscription?.tier === 'premium';

export const owns = (resourceKey: string) =>
  (ctx: any) => ctx.user.id === ctx[resourceKey].ownerId;

Error Responses

When a guard returns false or throws, the HTTP transport returns an appropriate error response:

error-responses.tsTypeScript
// Returns false -> 403 Forbidden
.guard(({ user }) => user.role === 'admin')

// Throws error -> 500 with error message
.guard(({ user }) => {
  if (!user.emailVerified) {
    throw new Error('Email verification required');
  }
  return true;
})

// Throws with custom status code
.guard(({ user }) => {
  if (!user.subscription.active) {
    const error = new Error('Subscription required');
    (error as any).statusCode = 402;
    throw error;
  }
  return true;
})

Best Practices

  • Keep guards pure - Guards should only check conditions, not modify state
  • Return boolean or throw - Don't return null, undefined, or other values
  • Type your context - Use TypeScript to document expected context properties
  • Fail with clear messages - When throwing, provide helpful error messages
  • Compose for flexibility - Build small, reusable guard functions
  • Order matters - Place guards after middleware that provides needed context