chapter 6: server integration
Add authorization middleware to your backend. Protect Express, Hono, NestJS, or Next.js routes with duck-iam guards and generate permission maps for the client.
Goal
BlogDuck has a working authorization engine. Now you need to protect actual HTTP endpoints. This chapter shows how to wire duck-iam into four popular frameworks -- pick the one you use.
How It Works
All server integrations share the same pattern:
- Extract the user ID from the request (JWT, session, header)
- Determine the action (from HTTP method) and resource (from URL path)
- Call
engine.can()with the extracted context - Allow or deny the request
HTTP Method to Action Mapping
duck-iam maps HTTP methods to actions automatically via METHOD_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',
}URL Path to Resource Mapping
The default resource extraction parses the URL path:
/api/posts/123
^ ^
| └── resourceId: '123'
└── resourceType: 'posts'
The first path segment after a base path is the resource type, the second is the resource ID.
You can customize this with the getResource callback.
Environment Extraction
The extractEnvironment() helper reads standard headers:
import { extractEnvironment } from '@gentleduck/iam/server/generic'
const env = extractEnvironment(req)
// {
// ip: '192.168.1.1', // from req.ip or x-forwarded-for or x-real-ip
// userAgent: 'Mozilla/...', // from user-agent header
// timestamp: 1708300000000, // Date.now()
// }import { extractEnvironment } from '@gentleduck/iam/server/generic'
const env = extractEnvironment(req)
// {
// ip: '192.168.1.1', // from req.ip or x-forwarded-for or x-real-ip
// userAgent: 'Mozilla/...', // from user-agent header
// timestamp: 1708300000000, // Date.now()
// }Generic Helpers
These framework-agnostic utilities work with any server:
generatePermissionMap()
Generate a PermissionMap for the client (Chapter 7):
import { generatePermissionMap } from '@gentleduck/iam/server/generic'
const permissions = await generatePermissionMap(engine, userId, [
{ action: 'create', resource: 'post' },
{ action: 'update', resource: 'post', resourceId: 'post-1' },
{ action: 'delete', resource: 'post', resourceId: 'post-1' },
{ action: 'manage', resource: 'dashboard' },
{ action: 'manage', resource: 'user', scope: 'acme' },
])
// { 'create:post': true, 'update:post:post-1': true, ... }import { generatePermissionMap } from '@gentleduck/iam/server/generic'
const permissions = await generatePermissionMap(engine, userId, [
{ action: 'create', resource: 'post' },
{ action: 'update', resource: 'post', resourceId: 'post-1' },
{ action: 'delete', resource: 'post', resourceId: 'post-1' },
{ action: 'manage', resource: 'dashboard' },
{ action: 'manage', resource: 'user', scope: 'acme' },
])
// { 'create:post': true, 'update:post:post-1': true, ... }Pass this map to the client AccessProvider for permission-based UI rendering.
createSubjectCan()
Create a reusable can function bound to a user:
import { createSubjectCan } from '@gentleduck/iam/server/generic'
const can = createSubjectCan(engine, userId)
// Now use it without repeating the userId
if (await can('delete', 'post', 'post-1')) {
// delete the post
}
if (await can('manage', 'dashboard')) {
// show admin panel
}
// With scope
if (await can('manage', 'user', undefined, 'acme')) {
// manage users in acme
}import { createSubjectCan } from '@gentleduck/iam/server/generic'
const can = createSubjectCan(engine, userId)
// Now use it without repeating the userId
if (await can('delete', 'post', 'post-1')) {
// delete the post
}
if (await can('manage', 'dashboard')) {
// show admin panel
}
// With scope
if (await can('manage', 'user', undefined, 'acme')) {
// manage users in acme
}Express
Global middleware
import express from 'express'
import { engine } from './access'
import { accessMiddleware } from '@gentleduck/iam/server/express'
const app = express()
app.use(express.json())
app.use(accessMiddleware(engine, {
getUserId: (req) => req.headers['x-user-id'] as string,
getScope: (req) => req.headers['x-organization'] as string,
}))import express from 'express'
import { engine } from './access'
import { accessMiddleware } from '@gentleduck/iam/server/express'
const app = express()
app.use(express.json())
app.use(accessMiddleware(engine, {
getUserId: (req) => req.headers['x-user-id'] as string,
getScope: (req) => req.headers['x-organization'] as string,
}))The middleware checks every request against the engine. If the user ID is missing, it returns 401. If the permission is denied, it returns 403.
Full options:
interface ExpressOptions {
getUserId?: (req) => string | null // default: req.user?.id
getResource?: (req) => Resource // default: parse from URL
getAction?: (req) => string // default: METHOD_ACTION_MAP
getEnvironment?: (req) => Environment // default: extractEnvironment()
getScope?: (req) => string | undefined // default: none
onDenied?: (req, res) => void // default: res.status(403).json(...)
onError?: (err, req, res, next) => void // default: res.status(500).json(...)
}interface ExpressOptions {
getUserId?: (req) => string | null // default: req.user?.id
getResource?: (req) => Resource // default: parse from URL
getAction?: (req) => string // default: METHOD_ACTION_MAP
getEnvironment?: (req) => Environment // default: extractEnvironment()
getScope?: (req) => string | undefined // default: none
onDenied?: (req, res) => void // default: res.status(403).json(...)
onError?: (err, req, res, next) => void // default: res.status(500).json(...)
}Per-route guards
import { guard } from '@gentleduck/iam/server/express'
// Explicit action and resource
app.delete('/api/posts/:id',
guard(engine, 'delete', 'post', {
getUserId: (req) => req.headers['x-user-id'] as string,
}),
(req, res) => {
res.json({ deleted: req.params.id })
}
)
// With a fixed scope
app.post('/api/admin/users',
guard(engine, 'manage', 'user', {
getUserId: (req) => req.headers['x-user-id'] as string,
scope: 'admin',
}),
(req, res) => {
res.json({ created: true })
}
)import { guard } from '@gentleduck/iam/server/express'
// Explicit action and resource
app.delete('/api/posts/:id',
guard(engine, 'delete', 'post', {
getUserId: (req) => req.headers['x-user-id'] as string,
}),
(req, res) => {
res.json({ deleted: req.params.id })
}
)
// With a fixed scope
app.post('/api/admin/users',
guard(engine, 'manage', 'user', {
getUserId: (req) => req.headers['x-user-id'] as string,
scope: 'admin',
}),
(req, res) => {
res.json({ created: true })
}
)guard() is more explicit -- you specify the action and resource.
The resource ID is auto-extracted from req.params.id.
Admin router
import { Router } from 'express'
import { adminRouter } from '@gentleduck/iam/server/express'
// Mount admin endpoints
app.use('/api/access-admin', adminRouter(engine)(() => Router()))import { Router } from 'express'
import { adminRouter } from '@gentleduck/iam/server/express'
// Mount admin endpoints
app.use('/api/access-admin', adminRouter(engine)(() => Router()))Exposed endpoints:
| Method | Path | Description | Body |
|---|---|---|---|
| GET | /policies | List all policies | |
| GET | /roles | List all roles | |
| PUT | /policies | Save/update a policy | Policy object |
| PUT | /roles | Save/update a role | Role object |
| POST | /subjects/:id/roles | Assign role to subject | { roleId, scope? } |
| DELETE | /subjects/:id/roles/:roleId | Revoke role from subject |
Protect the admin router with your own auth middleware. Never expose it to unauthenticated users.
Hono
Global middleware
import { Hono } from 'hono'
import { engine } from './access'
import { accessMiddleware } from '@gentleduck/iam/server/hono'
const app = new Hono()
app.use('*', accessMiddleware(engine, {
getUserId: (c) => c.get('userId') || c.req.header('x-user-id'),
getScope: (c) => c.req.header('x-organization'),
}))import { Hono } from 'hono'
import { engine } from './access'
import { accessMiddleware } from '@gentleduck/iam/server/hono'
const app = new Hono()
app.use('*', accessMiddleware(engine, {
getUserId: (c) => c.get('userId') || c.req.header('x-user-id'),
getScope: (c) => c.req.header('x-organization'),
}))The default getUserId checks c.get('userId') first (set by your auth middleware),
then falls back to the x-user-id header.
For Cloudflare Workers, the IP is extracted from the cf-connecting-ip header
(then x-forwarded-for as fallback).
Full options:
interface HonoOptions {
getUserId?: (c) => string | null
getResource?: (c) => Resource
getAction?: (c) => string
getEnvironment?: (c) => Environment
getScope?: (c) => string | undefined
onDenied?: (c) => Response
onError?: (err, c) => Response
}interface HonoOptions {
getUserId?: (c) => string | null
getResource?: (c) => Resource
getAction?: (c) => string
getEnvironment?: (c) => Environment
getScope?: (c) => string | undefined
onDenied?: (c) => Response
onError?: (err, c) => Response
}Per-route guards
import { guard } from '@gentleduck/iam/server/hono'
app.delete('/api/posts/:id',
guard(engine, 'delete', 'post', {
getUserId: (c) => c.get('userId'),
}),
async (c) => {
return c.json({ deleted: c.req.param('id') })
}
)import { guard } from '@gentleduck/iam/server/hono'
app.delete('/api/posts/:id',
guard(engine, 'delete', 'post', {
getUserId: (c) => c.get('userId'),
}),
async (c) => {
return c.json({ deleted: c.req.param('id') })
}
)NestJS
Create the access module
import { Module } from '@nestjs/common'
import { createEngineProvider, ACCESS_ENGINE_TOKEN } from '@gentleduck/iam/server/nest'
import { engine } from './access'
@Module({
providers: [
createEngineProvider(() => engine),
],
exports: [ACCESS_ENGINE_TOKEN],
})
export class AccessModule {}import { Module } from '@nestjs/common'
import { createEngineProvider, ACCESS_ENGINE_TOKEN } from '@gentleduck/iam/server/nest'
import { engine } from './access'
@Module({
providers: [
createEngineProvider(() => engine),
],
exports: [ACCESS_ENGINE_TOKEN],
})
export class AccessModule {}createEngineProvider() returns a NestJS provider that makes the engine available
via dependency injection using the ACCESS_ENGINE_TOKEN injection token.
Create the access guard
import { CanActivate, ExecutionContext, Injectable, Inject } from '@nestjs/common'
import { nestAccessGuard, ACCESS_ENGINE_TOKEN } from '@gentleduck/iam/server/nest'
import type { Engine } from '@gentleduck/iam'
@Injectable()
export class AccessGuard implements CanActivate {
private check: ReturnType<typeof nestAccessGuard>
constructor(@Inject(ACCESS_ENGINE_TOKEN) engine: Engine) {
this.check = nestAccessGuard(engine, {
getUserId: (req) => req.user?.id || req.user?.sub,
getScope: (req) => req.headers['x-organization'],
getResourceId: (req) => req.params?.id,
})
}
async canActivate(context: ExecutionContext): Promise<boolean> {
return this.check(context)
}
}import { CanActivate, ExecutionContext, Injectable, Inject } from '@nestjs/common'
import { nestAccessGuard, ACCESS_ENGINE_TOKEN } from '@gentleduck/iam/server/nest'
import type { Engine } from '@gentleduck/iam'
@Injectable()
export class AccessGuard implements CanActivate {
private check: ReturnType<typeof nestAccessGuard>
constructor(@Inject(ACCESS_ENGINE_TOKEN) engine: Engine) {
this.check = nestAccessGuard(engine, {
getUserId: (req) => req.user?.id || req.user?.sub,
getScope: (req) => req.headers['x-organization'],
getResourceId: (req) => req.params?.id,
})
}
async canActivate(context: ExecutionContext): Promise<boolean> {
return this.check(context)
}
}Full options:
interface NestGuardOptions {
getUserId?: (req) => string | null // default: req.user?.id or req.user?.sub
getEnvironment?: (req) => Environment // default: extractEnvironment()
getResourceId?: (req) => string | undefined // default: req.params?.id
getScope?: (req) => string | undefined // default: none
onError?: (err, req) => boolean // default: return false (deny)
}interface NestGuardOptions {
getUserId?: (req) => string | null // default: req.user?.id or req.user?.sub
getEnvironment?: (req) => Environment // default: extractEnvironment()
getResourceId?: (req) => string | undefined // default: req.params?.id
getScope?: (req) => string | undefined // default: none
onError?: (err, req) => boolean // default: return false (deny)
}Use the @Authorize decorator
import { Controller, Delete, Get, Post, Param, UseGuards } from '@nestjs/common'
import { Authorize } from '@gentleduck/iam/server/nest'
import { AccessGuard } from '../access/access.guard'
@Controller('posts')
@UseGuards(AccessGuard)
export class PostsController {
// Explicit action and resource
@Delete(':id')
@Authorize({ action: 'delete', resource: 'post' })
async deletePost(@Param('id') id: string) {
return { deleted: id }
}
// With scope
@Post('admin')
@Authorize({ action: 'manage', resource: 'post', scope: 'admin' })
async adminAction() {
return { success: true }
}
// Infer action from HTTP method, resource from route path
@Get()
@Authorize() // infer: true by default
async listPosts() {
return []
}
}import { Controller, Delete, Get, Post, Param, UseGuards } from '@nestjs/common'
import { Authorize } from '@gentleduck/iam/server/nest'
import { AccessGuard } from '../access/access.guard'
@Controller('posts')
@UseGuards(AccessGuard)
export class PostsController {
// Explicit action and resource
@Delete(':id')
@Authorize({ action: 'delete', resource: 'post' })
async deletePost(@Param('id') id: string) {
return { deleted: id }
}
// With scope
@Post('admin')
@Authorize({ action: 'manage', resource: 'post', scope: 'admin' })
async adminAction() {
return { success: true }
}
// Infer action from HTTP method, resource from route path
@Get()
@Authorize() // infer: true by default
async listPosts() {
return []
}
}When @Authorize() uses infer: true (the default):
- Action is inferred from the HTTP method via
METHOD_ACTION_MAP - Resource is inferred from the route path (last non-parameter segment)
If no @Authorize decorator is present on a handler, the guard allows the request
through (no authorization check is performed).
AuthorizeMeta interface:
interface AuthorizeMeta {
action?: string // explicit action
resource?: string // explicit resource type
scope?: string // explicit scope
infer?: boolean // infer from HTTP method + route (default: true)
}interface AuthorizeMeta {
action?: string // explicit action
resource?: string // explicit resource type
scope?: string // explicit scope
infer?: boolean // infer from HTTP method + route (default: true)
}Type-safe decorator
import { createTypedAuthorize } from '@gentleduck/iam/server/nest'
type AppAction = 'read' | 'create' | 'update' | 'delete' | 'manage'
type AppResource = 'post' | 'comment' | 'user' | 'dashboard'
type AppScope = 'acme' | 'globex'
export const Authorize = createTypedAuthorize<AppAction, AppResource, AppScope>()
// Now typos are compile errors:
// @Authorize({ action: 'delet', resource: 'post' }) // TypeScript errorimport { createTypedAuthorize } from '@gentleduck/iam/server/nest'
type AppAction = 'read' | 'create' | 'update' | 'delete' | 'manage'
type AppResource = 'post' | 'comment' | 'user' | 'dashboard'
type AppScope = 'acme' | 'globex'
export const Authorize = createTypedAuthorize<AppAction, AppResource, AppScope>()
// Now typos are compile errors:
// @Authorize({ action: 'delet', resource: 'post' }) // TypeScript errorNext.js (App Router)
Protect API route handlers
import { withAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access'
export const DELETE = withAccess(engine, 'delete', 'post',
async (req, { params }) => {
const { id } = await params
return Response.json({ deleted: id })
},
{
getUserId: (req) => req.headers.get('x-user-id'),
scope: 'acme',
}
)import { withAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access'
export const DELETE = withAccess(engine, 'delete', 'post',
async (req, { params }) => {
const { id } = await params
return Response.json({ deleted: id })
},
{
getUserId: (req) => req.headers.get('x-user-id'),
scope: 'acme',
}
)WithAccessOptions:
interface WithAccessOptions {
getUserId?: (req: Request) => string | null | Promise<string | null>
getEnvironment?: (req: Request) => Environment
scope?: string
onError?: (err: Error, req: Request) => Response
}interface WithAccessOptions {
getUserId?: (req: Request) => string | null | Promise<string | null>
getEnvironment?: (req: Request) => Environment
scope?: string
onError?: (err: Error, req: Request) => Response
}Note: getUserId can be async -- useful for reading from cookies or JWT.
Check permissions in Server Components
import { checkAccess, getPermissions } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access'
export default async function PostPage({ params }) {
const { id } = await params
const userId = 'alice' // from your auth
// Single check
const canDelete = await checkAccess(engine, userId, 'delete', 'post', id)
// Batch check for the client
const permissions = await getPermissions(engine, userId, [
{ action: 'update', resource: 'post', resourceId: id },
{ action: 'delete', resource: 'post', resourceId: id },
{ action: 'create', resource: 'comment' },
])
return (
<div>
<h1>Post {id}</h1>
{canDelete && <button>Delete</button>}
<ClientToolbar permissions={permissions} />
</div>
)
}import { checkAccess, getPermissions } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access'
export default async function PostPage({ params }) {
const { id } = await params
const userId = 'alice' // from your auth
// Single check
const canDelete = await checkAccess(engine, userId, 'delete', 'post', id)
// Batch check for the client
const permissions = await getPermissions(engine, userId, [
{ action: 'update', resource: 'post', resourceId: id },
{ action: 'delete', resource: 'post', resourceId: id },
{ action: 'create', resource: 'comment' },
])
return (
<div>
<h1>Post {id}</h1>
{canDelete && <button>Delete</button>}
<ClientToolbar permissions={permissions} />
</div>
)
}checkAccess() returns a boolean. getPermissions() returns a PermissionMap
for client-side hydration.
Next.js middleware for route-level protection
import { createNextMiddleware } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access'
const checkAccess = createNextMiddleware(engine, {
rules: [
{ pattern: '/api/admin', resource: 'dashboard', action: 'manage' },
{ pattern: /^\/api\/posts\/\w+$/, resource: 'post' },
{ pattern: '/api/users', resource: 'user', scope: 'admin' },
],
getUserId: (req) => req.headers.get('x-user-id'),
})
export async function middleware(req: Request) {
const result = await checkAccess(req)
if (result) return result // 401 or 403
// No match or allowed -- continue
}
export const config = {
matcher: ['/api/:path*'],
}import { createNextMiddleware } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access'
const checkAccess = createNextMiddleware(engine, {
rules: [
{ pattern: '/api/admin', resource: 'dashboard', action: 'manage' },
{ pattern: /^\/api\/posts\/\w+$/, resource: 'post' },
{ pattern: '/api/users', resource: 'user', scope: 'admin' },
],
getUserId: (req) => req.headers.get('x-user-id'),
})
export async function middleware(req: Request) {
const result = await checkAccess(req)
if (result) return result // 401 or 403
// No match or allowed -- continue
}
export const config = {
matcher: ['/api/:path*'],
}Rule matching:
- String patterns use
path.startsWith(pattern)--/api/adminmatches/api/admin/users - Regex patterns use
pattern.test(path)-- for precise matching - First matching rule is used (order matters)
- If no rule matches, the request passes through (no authorization check)
- If no action is specified in the rule, it is inferred from the HTTP method
Rule interface:
interface Rule {
pattern: string | RegExp // URL path pattern
resource: string // resource type
action?: string // optional: explicit action (otherwise inferred)
scope?: string // optional: scope for this route
}interface Rule {
pattern: string | RegExp // URL path pattern
resource: string // resource type
action?: string // optional: explicit action (otherwise inferred)
scope?: string // optional: scope for this route
}Custom Error Responses
All integrations support custom error handlers:
// Express
accessMiddleware(engine, {
onDenied: (req, res) => {
res.status(403).json({
error: 'forbidden',
message: `You cannot ${req.method.toLowerCase()} this resource`,
})
},
onError: (err, req, res, next) => {
console.error('Auth error:', err)
res.status(500).json({ error: 'internal' })
},
})
// Hono
accessMiddleware(engine, {
onDenied: (c) => c.json({ error: 'forbidden' }, 403),
onError: (err, c) => c.json({ error: 'internal' }, 500),
})
// NestJS (via guard options)
nestAccessGuard(engine, {
onError: (err, req) => {
console.error('Auth error:', err)
return false // deny on error
},
})
// Next.js
withAccess(engine, 'delete', 'post', handler, {
onError: (err, req) => Response.json({ error: 'internal' }, { status: 500 }),
})// Express
accessMiddleware(engine, {
onDenied: (req, res) => {
res.status(403).json({
error: 'forbidden',
message: `You cannot ${req.method.toLowerCase()} this resource`,
})
},
onError: (err, req, res, next) => {
console.error('Auth error:', err)
res.status(500).json({ error: 'internal' })
},
})
// Hono
accessMiddleware(engine, {
onDenied: (c) => c.json({ error: 'forbidden' }, 403),
onError: (err, c) => c.json({ error: 'internal' }, 500),
})
// NestJS (via guard options)
nestAccessGuard(engine, {
onError: (err, req) => {
console.error('Auth error:', err)
return false // deny on error
},
})
// Next.js
withAccess(engine, 'delete', 'post', handler, {
onError: (err, req) => Response.json({ error: 'internal' }, { status: 500 }),
})Default error responses:
401 Unauthorized-- no user ID found403 Forbidden-- permission denied500 Internal Server Error-- unexpected error
Permissions Endpoint Pattern
A common pattern is to create a dedicated endpoint that returns permissions for the client:
app.get('/api/permissions', async (req, res) => {
const userId = req.headers['x-user-id'] as string
if (!userId) return res.status(401).json({ error: 'unauthorized' })
const scope = req.headers['x-organization'] as string
const permissions = await generatePermissionMap(engine, userId, [
{ action: 'create', resource: 'post', scope },
{ action: 'update', resource: 'post', scope },
{ action: 'delete', resource: 'post', scope },
{ action: 'manage', resource: 'dashboard', scope },
{ action: 'manage', resource: 'user', scope },
])
res.json(permissions)
})app.get('/api/permissions', async (req, res) => {
const userId = req.headers['x-user-id'] as string
if (!userId) return res.status(401).json({ error: 'unauthorized' })
const scope = req.headers['x-organization'] as string
const permissions = await generatePermissionMap(engine, userId, [
{ action: 'create', resource: 'post', scope },
{ action: 'update', resource: 'post', scope },
{ action: 'delete', resource: 'post', scope },
{ action: 'manage', resource: 'dashboard', scope },
{ action: 'manage', resource: 'user', scope },
])
res.json(permissions)
})The client calls this endpoint and passes the result to AccessProvider (Chapter 7).
Chapter 6 FAQ
Which framework integration should I use?
Use the integration that matches your backend framework. Express and Hono provide
middleware functions. NestJS provides decorators and guards. Next.js provides route
handler wrappers and Server Component helpers. If your framework is not listed, use
the generic helpers (generatePermissionMap, createSubjectCan, extractEnvironment)
and call engine.can() directly in your middleware.
Should I use global middleware or per-route guards?
Per-route guards are more explicit and easier to reason about -- you see exactly what action and resource each route requires. Global middleware is convenient for APIs where the URL structure maps cleanly to resources. Most apps use a combination: global middleware for the common case and per-route guards where you need explicit control.
How do I extract the user ID from a JWT?
duck-iam does not handle authentication -- it only handles authorization. Use your
existing auth middleware (passport, next-auth, clerk, etc.) to verify the JWT and
attach the user to the request. Then pass getUserId: (req) => req.user.id to the
duck-iam middleware. Separation of auth and authz keeps both systems simpler.
How do I extract the scope from the URL?
Use the getScope callback. For a URL like /api/orgs/acme/posts, extract the
org slug: getScope: (req) => req.params.orgId (Express) or
getScope: (c) => c.req.param('orgId') (Hono). You can also read it from a header
(X-Organization) or from JWT claims.
Should I protect the admin router?
Yes. The admin router exposes mutation endpoints for policies, roles, and assignments.
Add your own authentication and authorization middleware before mounting it. For example,
use a guard(engine, 'manage', 'dashboard') before the admin router to restrict it to
admin users only.
Does duck-iam depend on Express/Hono/NestJS?
No. Each server integration uses lightweight type interfaces instead of importing the framework. This means duck-iam does not add the framework to your bundle or require specific versions. The integration code uses generic request/response shapes that are compatible with the framework's types.
How does NestJS action/resource inference work?
When @Authorize() uses infer: true (the default), the guard reads the HTTP method
to determine the action (via METHOD_ACTION_MAP) and parses the controller route path
for the resource type. It takes the last non-parameter segment (ignoring :id style
params). For @Controller('posts') with @Delete(':id'), the inferred action is
delete and resource is posts.