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:
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:
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:
// 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:
// 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:
// 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:
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:
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:
// 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:
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
// 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:
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:
// 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