Skip to main content
Search...

server middleware

Protect your API routes with duck-iam server integrations for Express, NestJS, Hono, and Next.js App Router.

Overview

duck-iam ships server integrations for the most popular Node.js frameworks. Each integration follows the same pattern: wrap your engine around incoming requests, extract the user identity, map the HTTP method to an action, infer the resource from the route, and let the engine decide.

Loading diagram...

Every integration is a thin adapter over engine.can(). There is no runtime framework dependency -- duck-iam defines its own minimal type interfaces so you never pull in extra packages.

HTTP method mapping

All server integrations share a default method-to-action map:

const METHOD_ACTION_MAP = {
  GET: 'read',
  HEAD: 'read',
  OPTIONS: 'read',
  POST: 'create',
  PUT: 'update',
  PATCH: 'update',
  DELETE: 'delete',
}
const METHOD_ACTION_MAP = {
  GET: 'read',
  HEAD: 'read',
  OPTIONS: 'read',
  POST: 'create',
  PUT: 'update',
  PATCH: 'update',
  DELETE: 'delete',
}

You can override this per-route or globally through the getAction option.


Express

import { accessMiddleware, guard, adminRouter } from '@gentleduck/iam/server/express'

Global middleware

Apply access control to every route under a path prefix. The middleware extracts the user ID, action, and resource from each request, then calls engine.can().

import { accessMiddleware } from '@gentleduck/iam/server/express'
 
const middleware = accessMiddleware(engine, {
  // Extract user ID from your auth layer (passport, jwt, etc.)
  getUserId: (req) => req.user?.id,
 
  // Map HTTP method to action (defaults to METHOD_ACTION_MAP)
  getAction: (req) => METHOD_ACTION_MAP[req.method],
 
  // Infer resource from the URL path
  getResource: (req) => {
    const parts = req.path.split('/').filter(Boolean)
    return { type: parts[0] ?? 'root', id: parts[1], attributes: {} }
  },
 
  // Optional: extract scope from headers or query
  getScope: (req) => req.headers['x-org-id'] as string | undefined,
 
  // Optional: extract environment context (IP, user agent, timestamp)
  getEnvironment: (req) => ({
    ip: req.ip,
    userAgent: req.headers['user-agent'],
    timestamp: Date.now(),
  }),
 
  // Custom denial response
  onDenied: (req, res) => res.status(403).json({ error: 'Forbidden' }),
 
  // Custom error handler
  onError: (err, req, res) => res.status(500).json({ error: 'Internal server error' }),
})
 
app.use('/api', middleware)
import { accessMiddleware } from '@gentleduck/iam/server/express'
 
const middleware = accessMiddleware(engine, {
  // Extract user ID from your auth layer (passport, jwt, etc.)
  getUserId: (req) => req.user?.id,
 
  // Map HTTP method to action (defaults to METHOD_ACTION_MAP)
  getAction: (req) => METHOD_ACTION_MAP[req.method],
 
  // Infer resource from the URL path
  getResource: (req) => {
    const parts = req.path.split('/').filter(Boolean)
    return { type: parts[0] ?? 'root', id: parts[1], attributes: {} }
  },
 
  // Optional: extract scope from headers or query
  getScope: (req) => req.headers['x-org-id'] as string | undefined,
 
  // Optional: extract environment context (IP, user agent, timestamp)
  getEnvironment: (req) => ({
    ip: req.ip,
    userAgent: req.headers['user-agent'],
    timestamp: Date.now(),
  }),
 
  // Custom denial response
  onDenied: (req, res) => res.status(403).json({ error: 'Forbidden' }),
 
  // Custom error handler
  onError: (err, req, res) => res.status(500).json({ error: 'Internal server error' }),
})
 
app.use('/api', middleware)

Per-route guard

For more granular control, use the guard function on individual routes. This is useful when the action and resource are known at definition time.

import { guard } from '@gentleduck/iam/server/express'
 
// Basic guard -- action and resource are fixed
app.delete('/posts/:id', guard(engine, 'delete', 'post'), (req, res) => {
  // Only reached if engine.can() returns true
  res.json({ deleted: true })
})
 
// Scoped guard -- restrict to a specific scope
app.post('/admin/users', guard(engine, 'manage', 'user', { scope: 'admin' }), (req, res) => {
  res.json({ created: true })
})
 
// With custom environment
app.patch('/posts/:id', guard(engine, 'update', 'post', {
  getEnvironment: (req) => ({ ip: req.ip, timestamp: Date.now() }),
}), handler)
 
// Custom user ID extraction
app.get('/reports', guard(engine, 'read', 'report', {
  getUserId: (req) => req.headers['x-api-key'] as string,
}), handler)
import { guard } from '@gentleduck/iam/server/express'
 
// Basic guard -- action and resource are fixed
app.delete('/posts/:id', guard(engine, 'delete', 'post'), (req, res) => {
  // Only reached if engine.can() returns true
  res.json({ deleted: true })
})
 
// Scoped guard -- restrict to a specific scope
app.post('/admin/users', guard(engine, 'manage', 'user', { scope: 'admin' }), (req, res) => {
  res.json({ created: true })
})
 
// With custom environment
app.patch('/posts/:id', guard(engine, 'update', 'post', {
  getEnvironment: (req) => ({ ip: req.ip, timestamp: Date.now() }),
}), handler)
 
// Custom user ID extraction
app.get('/reports', guard(engine, 'read', 'report', {
  getUserId: (req) => req.headers['x-api-key'] as string,
}), handler)

The guard function catches errors and passes them to Express's next(err), so they reach your error-handling middleware. The accessMiddleware catches errors and calls the onError option.

Admin router

Mount a pre-built admin API for managing policies, roles, and assignments at runtime.

import express from 'express'
import { adminRouter } from '@gentleduck/iam/server/express'
 
const createRouter = adminRouter(engine)
app.use('/api/access-admin', createRouter(() => express.Router()))
import express from 'express'
import { adminRouter } from '@gentleduck/iam/server/express'
 
const createRouter = adminRouter(engine)
app.use('/api/access-admin', createRouter(() => express.Router()))

This exposes:

MethodPathDescription
GET/policiesList all policies
GET/rolesList all roles
PUT/policiesCreate or update a policy
PUT/rolesCreate or update a role
POST/subjects/:id/rolesAssign a role to a subject
DELETE/subjects/:id/roles/:roleIdRevoke a role from a subject

Options reference

OptionTypeDefaultDescription
getUserId(req) -> string or nullreq.user?.idExtract the subject ID from the request
getAction(req) -> stringHTTP method mapMap the request to an action
getResource(req) -> ResourceInfer from URL pathMap the request to a resource
getEnvironment(req) -> EnvironmentIP + user agent + timestampExtract environment context
getScope(req) -> string or undefinedundefinedExtract scope (e.g. org ID, team ID)
onDenied(req, res) -> void403 JSONCustom denial response
onError(err, req, res, next) -> void500 JSONCustom error handler

NestJS

import { nestAccessGuard, Authorize, createTypedAuthorize, createEngineProvider } from '@gentleduck/iam/server/nest'

Guard factory

Create a NestJS-compatible guard function. It reads metadata from the @Authorize decorator on each handler to determine the required action and resource.

import { nestAccessGuard } from '@gentleduck/iam/server/nest'
 
const canAccess = nestAccessGuard(engine, {
  getUserId: (req) => req.user?.id ?? req.user?.sub,
  getScope: (req) => req.headers['x-org-id'] as string | undefined,
  getResourceId: (req) => req.params?.id,
  onError: (err, req) => false,
})
 
// Register as a global guard in your module:
// APP_GUARD -> { provide: APP_GUARD, useValue: { canActivate: canAccess } }
import { nestAccessGuard } from '@gentleduck/iam/server/nest'
 
const canAccess = nestAccessGuard(engine, {
  getUserId: (req) => req.user?.id ?? req.user?.sub,
  getScope: (req) => req.headers['x-org-id'] as string | undefined,
  getResourceId: (req) => req.params?.id,
  onError: (err, req) => false,
})
 
// Register as a global guard in your module:
// APP_GUARD -> { provide: APP_GUARD, useValue: { canActivate: canAccess } }

Handlers without an @Authorize decorator are allowed by default -- the guard only activates when metadata is present.

The Authorize decorator

Mark controller methods with their required permissions.

import { Authorize } from '@gentleduck/iam/server/nest'
 
@Controller('posts')
export class PostsController {
  @Delete(':id')
  @Authorize({ action: 'delete', resource: 'post' })
  async deletePost(@Param('id') id: string) {
    return this.postsService.delete(id)
  }
 
  @Post()
  @Authorize({ action: 'create', resource: 'post' })
  async createPost(@Body() dto: CreatePostDto) {
    return this.postsService.create(dto)
  }
 
  // Infer action from HTTP method, resource from route path
  @Get()
  @Authorize({ infer: true })
  async listPosts() {
    return this.postsService.list()
  }
}
import { Authorize } from '@gentleduck/iam/server/nest'
 
@Controller('posts')
export class PostsController {
  @Delete(':id')
  @Authorize({ action: 'delete', resource: 'post' })
  async deletePost(@Param('id') id: string) {
    return this.postsService.delete(id)
  }
 
  @Post()
  @Authorize({ action: 'create', resource: 'post' })
  async createPost(@Body() dto: CreatePostDto) {
    return this.postsService.create(dto)
  }
 
  // Infer action from HTTP method, resource from route path
  @Get()
  @Authorize({ infer: true })
  async listPosts() {
    return this.postsService.list()
  }
}

Scoped authorization

Attach a scope to restrict access within a specific context (e.g. a particular organization).

@Authorize({ action: 'manage', resource: 'billing', scope: 'admin' })
async updateBilling() { ... }
@Authorize({ action: 'manage', resource: 'billing', scope: 'admin' })
async updateBilling() { ... }

If no scope is set on the decorator, the guard falls back to the getScope option from the guard factory.

Type-safe decorator

Use createTypedAuthorize to constrain the decorator to your application's exact action, resource, and scope types. Typos become compile-time errors.

import { createTypedAuthorize } from '@gentleduck/iam/server/nest'
 
type Action = 'create' | 'read' | 'update' | 'delete' | 'manage'
type Resource = 'post' | 'user' | 'billing' | 'report'
type Scope = 'admin' | 'member'
 
const Auth = createTypedAuthorize<Action, Resource, Scope>()
 
// This compiles:
@Auth({ action: 'delete', resource: 'post' })
 
// This is a compile error -- "craete" is not in Action:
@Auth({ action: 'craete', resource: 'post' })
import { createTypedAuthorize } from '@gentleduck/iam/server/nest'
 
type Action = 'create' | 'read' | 'update' | 'delete' | 'manage'
type Resource = 'post' | 'user' | 'billing' | 'report'
type Scope = 'admin' | 'member'
 
const Auth = createTypedAuthorize<Action, Resource, Scope>()
 
// This compiles:
@Auth({ action: 'delete', resource: 'post' })
 
// This is a compile error -- "craete" is not in Action:
@Auth({ action: 'craete', resource: 'post' })

Engine provider

Register the engine as a NestJS provider for dependency injection.

import { createEngineProvider, ACCESS_ENGINE_TOKEN } from '@gentleduck/iam/server/nest'
 
@Module({
  providers: [
    createEngineProvider(() => new Engine({ adapter })),
    {
      provide: APP_GUARD,
      useFactory: (engine) => ({ canActivate: nestAccessGuard(engine) }),
      inject: [ACCESS_ENGINE_TOKEN],
    },
  ],
})
export class AppModule {}
import { createEngineProvider, ACCESS_ENGINE_TOKEN } from '@gentleduck/iam/server/nest'
 
@Module({
  providers: [
    createEngineProvider(() => new Engine({ adapter })),
    {
      provide: APP_GUARD,
      useFactory: (engine) => ({ canActivate: nestAccessGuard(engine) }),
      inject: [ACCESS_ENGINE_TOKEN],
    },
  ],
})
export class AppModule {}

Options reference

OptionTypeDefaultDescription
getUserId(req) -> string or nullreq.user?.id ?? req.user?.subExtract the subject ID
getEnvironment(req) -> EnvironmentIP + user agent + timestampExtract environment context
getResourceId(req) -> string or undefinedreq.params?.idExtract the resource instance ID
getScope(req) -> string or undefinedundefinedFallback scope when decorator has none
onError(err, req) -> boolean() => falseReturn true to allow on error, false to deny

Hono

import { accessMiddleware, guard } from '@gentleduck/iam/server/hono'

Global middleware

Apply access control to all routes under a path pattern.

import { accessMiddleware } from '@gentleduck/iam/server/hono'
 
app.use('/api/*', accessMiddleware(engine, {
  getUserId: (c) => c.get('userId') as string ?? c.req.header('x-user-id'),
  getScope: (c) => c.req.header('x-org-id'),
  onDenied: (c) => c.json({ error: 'Forbidden' }, 403),
  onError: (err, c) => c.json({ error: 'Internal server error' }, 500),
}))
import { accessMiddleware } from '@gentleduck/iam/server/hono'
 
app.use('/api/*', accessMiddleware(engine, {
  getUserId: (c) => c.get('userId') as string ?? c.req.header('x-user-id'),
  getScope: (c) => c.req.header('x-org-id'),
  onDenied: (c) => c.json({ error: 'Forbidden' }, 403),
  onError: (err, c) => c.json({ error: 'Internal server error' }, 500),
}))

The middleware reads the user ID from context or headers by default. If no user ID is found, it returns a 401 response immediately.

Per-route guard

Guard individual routes with fixed action and resource types.

import { guard } from '@gentleduck/iam/server/hono'
 
app.delete(
  '/posts/:id',
  guard(engine, 'delete', 'post'),
  async (c) => {
    // Only reached if the user can delete posts
    return c.json({ deleted: true })
  }
)
 
app.post(
  '/admin/users',
  guard(engine, 'manage', 'user', { scope: 'admin' }),
  async (c) => {
    return c.json({ created: true })
  }
)
import { guard } from '@gentleduck/iam/server/hono'
 
app.delete(
  '/posts/:id',
  guard(engine, 'delete', 'post'),
  async (c) => {
    // Only reached if the user can delete posts
    return c.json({ deleted: true })
  }
)
 
app.post(
  '/admin/users',
  guard(engine, 'manage', 'user', { scope: 'admin' }),
  async (c) => {
    return c.json({ created: true })
  }
)

The guard automatically reads c.req.param('id') as the resource ID when available.

Environment extraction

Hono's default environment extractor reads:

  • cf-connecting-ip or x-forwarded-for for the IP address
  • user-agent for the user agent string
  • Date.now() for the timestamp

Override with the getEnvironment option if you need custom fields.

Options reference

OptionTypeDefaultDescription
getUserId(c) -> string or nullContext userId or x-user-id headerExtract the subject ID
getAction(c) -> stringHTTP method mapMap the request to an action
getResource(c) -> ResourceInfer from URL pathMap the request to a resource
getEnvironment(c) -> EnvironmentCF/forwarded IP + user agent + timestampExtract environment context
getScope(c) -> string or undefinedundefinedExtract scope from the request
onDenied(c) -> Response403 JSONCustom denial response
onError(err, c) -> Response500 JSONCustom error handler

Next.js App Router

import { withAccess, checkAccess, getPermissions, createNextMiddleware } from '@gentleduck/iam/server/next'

The Next.js integration covers four use cases: route handler protection, server component checks, permission map generation for client hydration, and edge middleware.

Route handler wrapper

Protect App Router route handlers (the functions exported as GET, POST, DELETE, etc.).

import { withAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { auth } from '@/lib/auth'
 
async function handler(req: Request, ctx: { params: Promise<{ id: string }> }) {
  const { id } = await ctx.params
  // Only reached if the user can delete this post
  await deletePost(id)
  return Response.json({ deleted: true })
}
 
export const DELETE = withAccess(engine, 'delete', 'post', handler, {
  getUserId: async (req) => {
    const session = await auth()
    return session?.user?.id ?? null
  },
})
import { withAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { auth } from '@/lib/auth'
 
async function handler(req: Request, ctx: { params: Promise<{ id: string }> }) {
  const { id } = await ctx.params
  // Only reached if the user can delete this post
  await deletePost(id)
  return Response.json({ deleted: true })
}
 
export const DELETE = withAccess(engine, 'delete', 'post', handler, {
  getUserId: async (req) => {
    const session = await auth()
    return session?.user?.id ?? null
  },
})

The wrapper automatically reads params.id as the resource instance ID.

Server component checks

Check a single permission inside a React Server Component or server action.

import { checkAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
 
export default async function PostPage({ params }: { params: { id: string } }) {
  const { id } = params
  const canDelete = await checkAccess(engine, userId, 'delete', 'post', id)
  const canEdit = await checkAccess(engine, userId, 'update', 'post', id)
 
  return (
    <article>
      <h1>Post {id}</h1>
      {canEdit && <EditButton />}
      {canDelete && <DeleteButton />}
    </article>
  )
}
import { checkAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
 
export default async function PostPage({ params }: { params: { id: string } }) {
  const { id } = params
  const canDelete = await checkAccess(engine, userId, 'delete', 'post', id)
  const canEdit = await checkAccess(engine, userId, 'update', 'post', id)
 
  return (
    <article>
      <h1>Post {id}</h1>
      {canEdit && <EditButton />}
      {canDelete && <DeleteButton />}
    </article>
  )
}

Permission map generation

Generate a PermissionMap on the server and pass it to the client. This is the recommended pattern for client-side access control -- evaluate permissions once on the server and hydrate the client with the results.

import { getPermissions } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { AccessProvider } from '@/lib/access-client'
 
export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const session = await auth()
  const userId = session?.user?.id
 
  const permissions = userId
    ? await getPermissions(engine, userId, [
        { action: 'create', resource: 'post' },
        { action: 'delete', resource: 'post' },
        { action: 'manage', resource: 'team' },
        { action: 'read', resource: 'analytics' },
      ])
    : {}
 
  return (
    <html>
      <body>
        <AccessProvider permissions={permissions}>
          {children}
        </AccessProvider>
      </body>
    </html>
  )
}
import { getPermissions } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { AccessProvider } from '@/lib/access-client'
 
export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const session = await auth()
  const userId = session?.user?.id
 
  const permissions = userId
    ? await getPermissions(engine, userId, [
        { action: 'create', resource: 'post' },
        { action: 'delete', resource: 'post' },
        { action: 'manage', resource: 'team' },
        { action: 'read', resource: 'analytics' },
      ])
    : {}
 
  return (
    <html>
      <body>
        <AccessProvider permissions={permissions}>
          {children}
        </AccessProvider>
      </body>
    </html>
  )
}

Edge middleware

Protect routes at the edge before they reach your application code. Define rules that map URL patterns to required permissions.

// middleware.ts
import { createNextMiddleware } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { NextResponse } from 'next/server'
 
const checkAccess = createNextMiddleware(engine, {
  getUserId: async (req) => {
    // Extract user ID from cookie/token at the edge
    const token = req.headers.get('authorization')?.replace('Bearer ', '')
    return token ? decodeUserId(token) : null
  },
  rules: [
    { pattern: '/api/admin', resource: 'admin', action: 'manage' },
    { pattern: '/api/posts', resource: 'post' },  // action inferred from HTTP method
    { pattern: /^\/api\/billing/, resource: 'billing', scope: 'admin' },
  ],
})
 
export async function middleware(req: Request) {
  const denied = await checkAccess(req)
  if (denied) return denied  // 401 or 403 Response
  return NextResponse.next()
}
 
export const config = {
  matcher: ['/api/:path*'],
}
// middleware.ts
import { createNextMiddleware } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { NextResponse } from 'next/server'
 
const checkAccess = createNextMiddleware(engine, {
  getUserId: async (req) => {
    // Extract user ID from cookie/token at the edge
    const token = req.headers.get('authorization')?.replace('Bearer ', '')
    return token ? decodeUserId(token) : null
  },
  rules: [
    { pattern: '/api/admin', resource: 'admin', action: 'manage' },
    { pattern: '/api/posts', resource: 'post' },  // action inferred from HTTP method
    { pattern: /^\/api\/billing/, resource: 'billing', scope: 'admin' },
  ],
})
 
export async function middleware(req: Request) {
  const denied = await checkAccess(req)
  if (denied) return denied  // 401 or 403 Response
  return NextResponse.next()
}
 
export const config = {
  matcher: ['/api/:path*'],
}

When no rule matches a request path, the middleware returns null and the request passes through.

Options reference

withAccess options:

OptionTypeDefaultDescription
getUserId(req) -> string or null or Promisex-user-id headerExtract the subject ID
getEnvironment(req) -> EnvironmentIP + user agent + timestampExtract environment context
scopestringundefinedFixed scope for this handler
onError(err, req) -> Response500 JSONCustom error handler

createNextMiddleware options:

OptionTypeDefaultDescription
rulesArray--Route rules (see below). Required.
getUserId(req) -> string or null or Promise--Extract the subject ID. Required.
onError(err, req) -> Response500 JSONCustom error handler

Rule format (each entry in rules):

FieldTypeRequiredDescription
patternstring or RegExpyesURL path prefix (string) or regex pattern
resourcestringyesResource type for matched routes
actionstringnoFixed action (defaults to HTTP method map)
scopestringnoRequired scope for matched routes

Common patterns

Combining global and per-route protection

Use global middleware for broad protection, then layer per-route guards for specific endpoints that need different rules.

// Express example
app.use('/api', accessMiddleware(engine, { getUserId: (req) => req.user?.id }))
 
// Override for a specific route that needs a different scope
app.delete('/api/admin/users/:id',
  guard(engine, 'manage', 'user', { scope: 'admin' }),
  deleteUserHandler
)
// Express example
app.use('/api', accessMiddleware(engine, { getUserId: (req) => req.user?.id }))
 
// Override for a specific route that needs a different scope
app.delete('/api/admin/users/:id',
  guard(engine, 'manage', 'user', { scope: 'admin' }),
  deleteUserHandler
)

Custom action mapping

Override the default HTTP method mapping when your API uses non-standard patterns.

const middleware = accessMiddleware(engine, {
  getUserId: (req) => req.user?.id,
  getAction: (req) => {
    // POST /api/posts/:id/publish -> "publish" action
    if (req.method === 'POST' && req.path.endsWith('/publish')) {
      return 'publish'
    }
    return METHOD_ACTION_MAP[req.method] ?? 'read'
  },
})
const middleware = accessMiddleware(engine, {
  getUserId: (req) => req.user?.id,
  getAction: (req) => {
    // POST /api/posts/:id/publish -> "publish" action
    if (req.method === 'POST' && req.path.endsWith('/publish')) {
      return 'publish'
    }
    return METHOD_ACTION_MAP[req.method] ?? 'read'
  },
})

Error handling

All server integrations wrap engine.can() in try/catch. If the engine throws (adapter failure, misconfigured policy, etc.), the default behavior is:

  • Express middleware: calls onError(err, req, res, next) -- defaults to 500 JSON.
  • Express guard: passes the error to next(err) for Express error middleware.
  • Hono: returns onError(err, c) -- defaults to 500 JSON.
  • NestJS: calls onError(err, req) -- defaults to returning false (deny).
  • Next.js: returns onError(err, req) -- defaults to 500 JSON.

The engine itself never throws from authorize() -- it catches internal errors, calls the onError hook, and returns a deny decision. The server integration try/catch is an extra safety net for edge cases like adapter connection failures during subject resolution.

Server-driven client permissions

The recommended end-to-end pattern:

  1. Generate a PermissionMap on the server using getPermissions or engine.permissions()
  2. Pass the map to your client via props, response body, or server-rendered HTML
  3. Hydrate an AccessProvider (React), plugin (Vue), or AccessClient (vanilla) on the client
  4. All client-side checks are instant lookups -- no additional network requests
// Server: generate the permission map
const checks = [
  { action: 'create', resource: 'post' },
  { action: 'delete', resource: 'post' },
  { action: 'manage', resource: 'team' },
] as const
 
const permissions = await engine.permissions(userId, checks)
 
// Client: hydrate and use
// See the client libraries documentation for framework-specific setup
// Server: generate the permission map
const checks = [
  { action: 'create', resource: 'post' },
  { action: 'delete', resource: 'post' },
  { action: 'manage', resource: 'team' },
] as const
 
const permissions = await engine.permissions(userId, checks)
 
// Client: hydrate and use
// See the client libraries documentation for framework-specific setup