Policy
The policy module provides authorization via policy classes and anauthorize() middleware factory. Inspired by Laravel's policy pattern — define what each user can do, then enforce it with a single middleware call.
Quick start
import { authorize, allow, deny } from '@strav/http/policy'
import type { PolicyResult } from '@strav/http/policy'
// Define a policy
const userPolicy = {
canView: (actor: User) => allow(),
canCreate: (actor: User) => actor.role === 'admin' ? allow() : deny(),
canUpdate: (actor: User, user: User) =>
actor.id === user.id || actor.role === 'admin' ? allow() : deny(),
canDelete: (actor: User, user: User) =>
actor.role === 'admin' ? allow() : deny(403, 'Only admins can delete users'),
}
// Use as route middleware
router.get('/users/:id', showUser)
router.put('/users/:id',
authorize(userPolicy, 'canUpdate', async (ctx) => User.find(ctx.params.id)),
updateUser,
)
authorize()
function authorize(
policy: Record PolicyResult | Promise>,
method: string,
loadResource?: (ctx: Context) => Promise,
): Middleware
The middleware:
ctx.get('user') — returns 401 if missing.loadResource is provided, calls it and stores the result in ctx.set('resource', ...).policy[method](user, resource?) — returns error response if the method doesn't exist or the policy denies access.next() on success.PolicyResult
Policy methods must return a PolicyResult object:
interface PolicyResult {
allowed: boolean
status: number
reason: string
}
Helper functions
Use these helper functions to create policy results:
import { allow, deny } from '@strav/http/policy'
// Allow access
function allow(): PolicyResult
// Deny access with optional custom status and reason
function deny(status = 403, reason = 'Action forbidden'): PolicyResult
Examples
// Simple allow/deny
canView: (actor: User) => allow()
canCreate: (actor: User) => actor.role === 'admin' ? allow() : deny()
// Custom error message
canDelete: (actor: User, post: Post) =>
actor.role === 'admin' || actor.id === post.authorId
? allow()
: deny(403, 'You can only delete your own posts')
// Custom status code
canModerate: (actor: User) =>
actor.hasPermission('moderate')
? allow()
: deny(401, 'Insufficient permissions')
Usage with routes
Without a resource
For actions that don't operate on a specific resource (like listing or creating):
router.get('/users', authorize(userPolicy, 'canList'), listUsers)
router.post('/users', authorize(userPolicy, 'canCreate'), createUser)
With a resource
For actions on a specific resource (view, update, delete), provide a loader:
const loadUser = async (ctx: Context) => User.find(ctx.params.id)
router.get('/users/:id', authorize(userPolicy, 'canView', loadUser), showUser)
router.put('/users/:id', authorize(userPolicy, 'canUpdate', loadUser), updateUser)
router.delete('/users/:id', authorize(userPolicy, 'canDelete', loadUser), destroyUser)
The loaded resource is available in the handler via ctx.get('resource'):
async function showUser(ctx: Context) {
const user = ctx.get('resource') as User
return ctx.json(user)
}
Async policies
Policy methods can be async:
const postPolicy = {
async canEdit(actor: User, post: Post) {
if (actor.role === 'admin') return allow()
// Check async condition
const hasEditAccess = await checkEditPermissions(actor, post)
return hasEditAccess ? allow() : deny(403, 'Cannot edit this post')
},
}
Generated policies
Runningbun strav generate:api creates policy skeleton files per schema. Methods vary by archetype:
| Archetype | Policy methods |
|---|---|
| entity | canList, canView, canCreate, canUpdate, canDelete |
| attribute | canList, canView, canCreate, canUpdate, canDelete |
| reference | canList, canView, canCreate, canUpdate, canDelete |
| contribution | canList, canView, canCreate, canUpdate, canDelete, canModerate |
| component | canList, canView, canUpdate |
| event | canList, canView, canAppend |
| configuration | canView, canUpsert, canReset |
return allow() — customize the logic to match your authorization rules:
// app/policies/user_policy.ts — Generated by Strav
import { allow, deny } from '@strav/http/policy'
import type { PolicyResult } from '@strav/http/policy'
export default class UserPolicy {
static canList(actor: any): PolicyResult {
return allow()
}
static canView(actor: any, user: any): PolicyResult {
return allow()
}
static canCreate(actor: any): PolicyResult {
return actor.role === 'admin' ? allow() : deny() // customize here
}
static canUpdate(actor: any, user: any): PolicyResult {
return actor.id === user.id || actor.role === 'admin'
? allow()
: deny() // customize here
}
static canDelete(actor: any, user: any): PolicyResult {
return actor.role === 'admin' ? allow() : deny() // customize here
}
}
Response format
The middleware returns JSON error responses based on the policy result:
// 401 - When user is not authenticated
{ "error": "Unauthorized" }
// 403 - When policy denies access (default message)
{ "error": "Action forbidden" }
// Custom error message and status code
{ "error": "Only admins can delete users" }
The response uses:
- Status code: From
PolicyResult.status(defaults to 403) - Error message: From
PolicyResult.reason(customizable viadeny())